mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 17:46:14 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd6600afc | ||
|
|
9e9eac81e3 | ||
|
|
59f0e3a378 | ||
|
|
656c4cc7fa | ||
|
|
f28c10aa4d | ||
|
|
97ccafce3b | ||
|
|
1864bc4161 | ||
|
|
87ef814190 | ||
|
|
e945ddf80e | ||
|
|
fa1b4ef7ec |
@@ -50,6 +50,14 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
if: runner.os == 'Linux'
|
||||
run: bun run test:effect-simplification-rules
|
||||
|
||||
- name: Check Effect simplifications
|
||||
if: runner.os == 'Linux'
|
||||
run: bun run lint:effect-simplifications
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-17
@@ -1,19 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"smoke_modal": "ctrl+alt+m",
|
||||
"smoke_screen": "ctrl+alt+o",
|
||||
"smoke_screen_home": "escape,ctrl+shift+h",
|
||||
"smoke_screen_modal": "ctrl+alt+m",
|
||||
"smoke_dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
"$schema": "https://opencode.ai/tui.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
|
||||
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
|
||||
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
|
||||
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
|
||||
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
|
||||
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
|
||||
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
|
||||
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -18,13 +18,16 @@
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
@@ -33,8 +36,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
@@ -127,7 +129,6 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
@@ -174,6 +175,6 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
if (response.status >= 200 && response.status < 300) return undefined
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
||||
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
|
||||
})
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ interface ParserState {
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -328,7 +329,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text })
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -540,14 +541,16 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
let lifecycle = state.lifecycle
|
||||
if (state.reasoningSignature !== undefined)
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
@@ -579,10 +582,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let nextToolCallId = nextState.nextToolCallId
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
||||
reasoningSignature = part.thoughtSignature
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
|
||||
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
lifecycle = Lifecycle.reasoningDelta(
|
||||
@@ -590,7 +597,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -600,7 +607,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
"reasoning-0",
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
part.text,
|
||||
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textSignature = undefined
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -637,6 +651,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
nextToolCallId,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -21,9 +21,15 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const textDelta = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
|
||||
return started
|
||||
}
|
||||
|
||||
|
||||
@@ -905,6 +905,61 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves thoughtSignature on visible text parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
const delta = response.events.find((event) => event.type === "text-delta")
|
||||
expect(delta).toMatchObject({
|
||||
id: "text-0",
|
||||
text: "All done.",
|
||||
providerMetadata: { google: { thoughtSignature: "text_sig" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes a trailing empty signed text part at block close", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
const delta = response.events.find((event) => event.type === "text-delta")
|
||||
const end = response.events.find((event) => event.type === "text-end")
|
||||
|
||||
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
|
||||
expect(end).toMatchObject({
|
||||
id: "text-0",
|
||||
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.5",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.5",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.5",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.5",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.5",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const layer = Layer.effect(
|
||||
const file = path.join(global.config, "cli.json")
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
@@ -87,7 +87,7 @@ export const layer = Layer.effect(
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
|
||||
@@ -258,7 +258,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
|
||||
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const text = yield* fs.readFileString(target).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
|
||||
@@ -119,7 +119,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () {
|
||||
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
|
||||
return yield* fs.readFileString(configFile).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.catch(() => Effect.succeed({} as Info)),
|
||||
Effect.orElseSucceed(() => ({}) as Info),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export const layer = Layer.effect(
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs.readFileString(path.join(global.config, name)).pipe(
|
||||
Effect.map(decodePolicy),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
@@ -63,7 +63,7 @@ export const layer = Layer.effect(
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -113,12 +112,13 @@
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -20,16 +20,6 @@ const result = await Bun.build({
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-shell-scan",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
|
||||
path: path.resolve("../shell-scan/src/index.ts"),
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
splitting: true,
|
||||
loader: {
|
||||
".txt": "text",
|
||||
|
||||
@@ -347,7 +347,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.filterEffect((event) =>
|
||||
wellknown.entries().pipe(
|
||||
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
Effect.orElseSucceed(() => false),
|
||||
),
|
||||
),
|
||||
Stream.runForEach(() =>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const Plugin = define({
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => (content ? decode(file, content) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
|
||||
})
|
||||
@@ -176,7 +176,7 @@ function discover(fs: FSUtil.Interface, directory: string) {
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((files) => files.flat()),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +86,11 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
return yield* Effect.forEach(files.toSorted(), (filepath) =>
|
||||
fs.readFileStringSafe(filepath).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((commands) =>
|
||||
|
||||
@@ -148,7 +148,7 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
Effect.orElseSucceed(() => operation),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,14 +35,12 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved
|
||||
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
|
||||
: undefined
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)) : undefined
|
||||
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.orElseSucceed(() => store))
|
||||
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
|
||||
}
|
||||
}).pipe(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { gitExecutable } from "./util/git-executable.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -314,7 +315,7 @@ const layer = Layer.effect(
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(repository, args), {
|
||||
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
|
||||
cwd: repository.worktree,
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
@@ -449,10 +450,14 @@ const layer = Layer.effect(
|
||||
if (!input.paths.length) return new Set<RelativePath>()
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
|
||||
cwd: input.repository.worktree,
|
||||
extendEnv: true,
|
||||
}),
|
||||
ChildProcess.make(
|
||||
gitExecutable,
|
||||
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
|
||||
{
|
||||
cwd: input.repository.worktree,
|
||||
extendEnv: true,
|
||||
},
|
||||
),
|
||||
{ stdin: input.paths.join("\0") + "\0" },
|
||||
)
|
||||
.pipe(
|
||||
@@ -625,7 +630,7 @@ const layer = Layer.effect(
|
||||
cwd = repository.worktree,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
|
||||
@@ -722,7 +727,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
ChildProcess.make(gitExecutable, args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
|
||||
@@ -622,7 +622,7 @@ export const layer = (options?: Options) =>
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as PluginHost from "./host.js"
|
||||
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
@@ -481,6 +480,6 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
}
|
||||
}
|
||||
|
||||
function credential(value: CredentialOAuth) {
|
||||
function credential(value: Credential.OAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ const oauth = (app: App.Info) =>
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
Effect.map((apiEndpoint) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
@@ -159,7 +159,7 @@ export const GithubCopilotPlugin = define({
|
||||
const load = Effect.fn("GithubCopilotPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("github-copilot")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth") {
|
||||
loaded.baseURL = undefined
|
||||
|
||||
@@ -148,7 +148,7 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
|
||||
)
|
||||
shows.set(model.model, { digest: model.digest, info })
|
||||
return { ...model, show: info }
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
}).pipe(Effect.orElseSucceed(() => undefined)),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
const filtered = models.filter(
|
||||
|
||||
@@ -175,7 +175,7 @@ export const OpenAIPlugin = define({
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
chatgpt =
|
||||
credential?.type === "oauth" &&
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
@@ -92,7 +91,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
connected = connection !== undefined
|
||||
providers = credential
|
||||
@@ -199,7 +198,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
}),
|
||||
})
|
||||
|
||||
function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
|
||||
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const metadata = value.metadata
|
||||
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
|
||||
@@ -144,7 +144,7 @@ function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, u
|
||||
if (response.ok) return yield* decode(response, Token)
|
||||
const error = yield* Effect.promise(() => response.text()).pipe(
|
||||
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (error?.error === "authorization_pending") {
|
||||
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Versioned } from "../plugin.js"
|
||||
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk` host) contributes,
|
||||
* so `PluginSupervisor` can add them on every Location boot through the ordinary
|
||||
* generation path that `PluginSupervisor` uses for plugins discovered from
|
||||
* config. Registration publishes an unlocated update so every booted Location
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface Resolved {
|
||||
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ const layer = Layer.effect(
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
Effect.map((value) => (value ? ID.make(value) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ const layer = Layer.effect(
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!result || result.exitCode !== 0) return undefined
|
||||
const node = result.stdout
|
||||
.toString("utf8")
|
||||
@@ -216,7 +216,7 @@ const layer = Layer.effect(
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!dotHg) return undefined
|
||||
const worktree = AbsolutePath.make(path.dirname(dotHg))
|
||||
@@ -241,7 +241,7 @@ const layer = Layer.effect(
|
||||
? repo.worktree
|
||||
: yield* git.worktree.list(repo).pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
Effect.orElseSucceed(() => repo.worktree),
|
||||
)
|
||||
return yield* persist({
|
||||
previous,
|
||||
|
||||
@@ -768,7 +768,7 @@ const layer = Layer.effect(
|
||||
const expanded =
|
||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
@@ -787,7 +787,7 @@ const layer = Layer.effect(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* result.get(input.sessionID)
|
||||
const source = yield* fs.stat(latest.location.directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!source || source.type !== "Directory") {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
|
||||
@@ -120,7 +120,7 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return undefined
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
|
||||
return message?.type === "user" ? message : undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -319,7 +319,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
}).pipe(
|
||||
Effect.andThen(metric("connect_failure")),
|
||||
Effect.andThen(metric("fallback")),
|
||||
Effect.asVoid,
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -181,6 +181,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
// Terminal events for active projections stay on the parent, so forks copy only settled history.
|
||||
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -152,7 +152,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
{
|
||||
type: "text",
|
||||
text: item.text,
|
||||
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
// Text can carry provider-bound state (e.g. Gemini thought signatures),
|
||||
// which is only replayable against the model that produced it.
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
if (item.type === "reasoning")
|
||||
@@ -167,6 +169,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
// Call-side metadata is model-scoped proof of generation (Gemini thought
|
||||
// signatures, OpenAI encrypted reasoning): only the producing model may
|
||||
// replay it.
|
||||
const reuseToolProviderMetadata =
|
||||
reuseProviderMetadata ||
|
||||
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
|
||||
@@ -175,8 +180,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
// Hosted result payloads are provider-format state, not model state:
|
||||
// replay must survive a model switch within the same provider.
|
||||
// Hosted tools (e.g. google_search) run inside the provider, so their
|
||||
// result payload (`providerResultState`) is provider-format data rather
|
||||
// than model-scoped proof: it stays replayable across models of the same
|
||||
// provider. After a model switch, echo only that payload — never fall
|
||||
// back to `providerState`, whose call-side values are bound to the old
|
||||
// model.
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseToolProviderMetadata
|
||||
|
||||
@@ -77,7 +77,7 @@ export const cleanup = Effect.fn("Shell.cleanup")(function* () {
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const projects = yield* fs.readDirectoryEntries(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => entry.type === "directory")),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const files = yield* Effect.forEach(
|
||||
projects,
|
||||
@@ -90,7 +90,7 @@ export const cleanup = Effect.fn("Shell.cleanup")(function* () {
|
||||
: [],
|
||||
),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
Effect.orElseSucceed(() => []),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/To
|
||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const entries = yield* fs.readDirectory(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
yield* FileRetention.cleanup(
|
||||
fs,
|
||||
|
||||
@@ -142,7 +142,7 @@ export const Plugin = {
|
||||
.map((entry) => join(dirname(input), entry))
|
||||
.slice(0, 3),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
Effect.orElseSucceed(() => [] as string[]),
|
||||
)
|
||||
const message =
|
||||
suggestions.length === 0
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import path from "path"
|
||||
import { which } from "./which.js"
|
||||
|
||||
const resolved = process.platform === "win32" ? which("git") : undefined
|
||||
|
||||
export const gitExecutable = resolved ? path.resolve(resolved) : "git"
|
||||
@@ -49,7 +49,7 @@ const layer = Layer.effect(
|
||||
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
|
||||
|
||||
if (vcs && impl) {
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.orElseSucceed(() => vcs.store))
|
||||
const isBranchMetadata =
|
||||
vcs.type === "git"
|
||||
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs.js"
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
|
||||
import type { Patch } from "./patch.js"
|
||||
import { gitExecutable } from "../util/git-executable.js"
|
||||
|
||||
/**
|
||||
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
|
||||
@@ -128,7 +129,7 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("git", [...cfg, ...args], {
|
||||
ChildProcess.make(gitExecutable, [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
@@ -141,7 +142,7 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
}
|
||||
},
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })),
|
||||
Effect.orElseSucceed(() => ({ exitCode: 1, text: () => "", truncated: false })),
|
||||
)
|
||||
|
||||
const text = Effect.fnUntraced(function* (args: string[], opts: { cwd: string }) {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("ConfigWebSearchPlugin.Plugin", () => {
|
||||
yield* waitUntil(
|
||||
websearch.default().pipe(
|
||||
Effect.map((provider) => provider?.id === WebSearch.ID.make("test")),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
Effect.orElseSucceed(() => false),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(Config.testLayer([configured(false)]))),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const source = Effect.succeed(1)
|
||||
const exactUndefined: Effect.Effect<undefined> = source.pipe(Effect.as(undefined))
|
||||
// @ts-expect-error Effect.asVoid widens the success type to void.
|
||||
const voidSuccess: Effect.Effect<undefined> = source.pipe(Effect.asVoid)
|
||||
|
||||
test("Effect.as preserves the exact undefined success type", () => {
|
||||
expect(Effect.runSync(exactUndefined)).toBeUndefined()
|
||||
expect(Effect.runSync(voidSuccess)).toBeUndefined()
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -14,6 +15,41 @@ import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
test("honors wildcard directory rules from .gitignore", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
|
||||
try {
|
||||
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
|
||||
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
|
||||
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
|
||||
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
|
||||
expect(git.exitCode).toBe(0)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(ref, {
|
||||
vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const entries = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
return yield* search.find({ query: "target" })
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
|
||||
@@ -401,6 +401,56 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not copy a running assistant into a fork", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "Run both tools", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, parent.id, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
|
||||
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
id: "call_running",
|
||||
text: '{"command":"sleep 10"}',
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: parent.id,
|
||||
assistantMessageID,
|
||||
id: "call_running",
|
||||
input: { command: "sleep 10" },
|
||||
executed: true,
|
||||
})
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
|
||||
expect(yield* session.context(parent.id)).toMatchObject([
|
||||
{ type: "user", text: "Run both tools" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [{ type: "tool", id: "call_running", state: { status: "running" } }],
|
||||
},
|
||||
])
|
||||
expect(yield* session.context(forked.id)).toMatchObject([{ type: "user", text: "Run both tools" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects forking an empty session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -470,6 +520,11 @@ describe("Session.create", () => {
|
||||
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
|
||||
expect(yield* session.context(beforeFirst.id)).toEqual([])
|
||||
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
|
||||
expect(yield* session.context(complete.id)).toMatchObject([
|
||||
{ type: "user", text: "First" },
|
||||
{ type: "user", text: "Second" },
|
||||
{ type: "assistant", finish: "stop" },
|
||||
])
|
||||
expect(complete).toMatchObject({
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
|
||||
@@ -1020,7 +1020,7 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves assistant text provider state across same-provider model changes and failures", () => {
|
||||
test("drops assistant text provider state across model changes and failures", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
@@ -1042,6 +1042,36 @@ Recent work
|
||||
Model.Ref.make({ id: Model.ID.make("new"), providerID: Provider.ID.make("provider") }),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves assistant text provider state for the same model", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-phase"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model: { id: Model.ID.make("same"), providerID: Provider.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
state: { phase: "commentary" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
Model.Ref.make({ id: Model.ID.make("same"), providerID: Provider.ID.make("provider") }),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -28,7 +28,7 @@ const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName
|
||||
|
||||
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!result) return null
|
||||
|
||||
@@ -110,7 +110,7 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
|
||||
for (const file of paths) {
|
||||
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
|
||||
for (const dir of dirs) {
|
||||
const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []))
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
@@ -130,8 +130,5 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
|
||||
})
|
||||
|
||||
function exists(fs: FileSystem.FileSystem, path: string) {
|
||||
return fs.access(path).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
return fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
}
|
||||
|
||||
@@ -85,10 +85,7 @@ function make(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
)
|
||||
}),
|
||||
revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) {
|
||||
const exists = yield* fs.stat(target).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))
|
||||
if (!exists) return false
|
||||
shell.showItemInFolder(target)
|
||||
return true
|
||||
|
||||
@@ -163,7 +163,7 @@ export const tail = Effect.fn("DesktopLogging.tail")(function* () {
|
||||
const contents = yield* fs.readFileString(path)
|
||||
const lines = contents.split("\n")
|
||||
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
|
||||
}).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
}).pipe(Effect.orElseSucceed(() => ""))
|
||||
})
|
||||
|
||||
function initRunDirectory(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary
|
||||
Effect.fnUntraced(function* (entry) {
|
||||
const target = path.join(root, entry)
|
||||
if (target === current) return
|
||||
const stat = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const stat = yield* fs.stat(target).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stat?.type !== "Directory") return
|
||||
yield* fs
|
||||
.remove(target, { recursive: true, force: true })
|
||||
|
||||
@@ -19,7 +19,7 @@ export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
|
||||
const candidates = (yield* Effect.forEach(
|
||||
entries,
|
||||
Effect.fnUntraced(function* (entry) {
|
||||
@@ -27,7 +27,7 @@ export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function
|
||||
if (!kind) return
|
||||
|
||||
const file = path.join(userDataPath, entry)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stats?.type !== "File") return
|
||||
|
||||
return {
|
||||
@@ -74,7 +74,7 @@ export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty"
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const file = path.join(userDataPath, name)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stats?.type !== "File") return false
|
||||
if (!(yield* isEmptyStore(file, stats.size))) return false
|
||||
|
||||
@@ -91,7 +91,7 @@ const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string,
|
||||
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const raw = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (raw === undefined) return false
|
||||
if (raw.trim() === "") return true
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -98,12 +98,12 @@ export const fileSystem = (
|
||||
const pathFor = (name: string) => cassettePath(directory, name)
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
Effect.orElseSucceed(() => [] as string[]),
|
||||
)
|
||||
})
|
||||
return nested.flat()
|
||||
@@ -144,11 +144,7 @@ export const fileSystem = (
|
||||
recorded.set(name, { interactions, findings: interactionFindings })
|
||||
}),
|
||||
),
|
||||
exists: (name) =>
|
||||
fs.access(pathFor(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
exists: (name) => fs.exists(pathFor(name)).pipe(Effect.orElseSucceed(() => false)),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./v1": "./src/v1/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"files": [
|
||||
@@ -25,7 +24,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -33,7 +31,6 @@
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.6",
|
||||
"@opentui/keymap": ">=0.5.6",
|
||||
"@opentui/solid": ">=0.5.6",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
@@ -44,9 +41,6 @@
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/keymap": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -57,7 +51,6 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import type {
|
||||
Event,
|
||||
createOpencodeClient,
|
||||
Project,
|
||||
Model,
|
||||
Provider,
|
||||
Permission,
|
||||
UserMessage,
|
||||
Message,
|
||||
Part,
|
||||
Config as SDKConfig,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
|
||||
|
||||
import type { BunShell } from "./shell.js"
|
||||
import { type ToolDefinition } from "./tool.js"
|
||||
|
||||
export * from "./tool.js"
|
||||
|
||||
export type ProviderContext = {
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
info: Provider
|
||||
options: Record<string, any>
|
||||
}
|
||||
|
||||
export type WorkspaceInfo = {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
branch: string | null
|
||||
directory: string | null
|
||||
extra: unknown | null
|
||||
projectID: string
|
||||
}
|
||||
|
||||
export type WorkspaceTarget =
|
||||
| {
|
||||
type: "local"
|
||||
directory: string
|
||||
}
|
||||
| {
|
||||
type: "remote"
|
||||
url: string | URL
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdapter = {
|
||||
name: string
|
||||
description: string
|
||||
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||
remove(config: WorkspaceInfo): Promise<void>
|
||||
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
|
||||
}
|
||||
|
||||
export type PluginInput = {
|
||||
client: ReturnType<typeof createOpencodeClient>
|
||||
project: Project
|
||||
directory: string
|
||||
worktree: string
|
||||
experimental_workspace: {
|
||||
register(type: string, adapter: WorkspaceAdapter): void
|
||||
}
|
||||
serverUrl: URL
|
||||
$: BunShell
|
||||
}
|
||||
|
||||
export type PluginOptions = Record<string, unknown>
|
||||
|
||||
export type Config = Omit<SDKConfig, "plugin"> & {
|
||||
plugin?: Array<string | [string, PluginOptions]>
|
||||
}
|
||||
|
||||
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
|
||||
|
||||
export type PluginModule = {
|
||||
id?: string
|
||||
server: Plugin
|
||||
tui?: never
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
key: string
|
||||
op: "eq" | "neq"
|
||||
value: string
|
||||
}
|
||||
|
||||
export type AuthHook = {
|
||||
provider: string
|
||||
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
|
||||
methods: (
|
||||
| {
|
||||
type: "oauth"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
/** @deprecated Use `when` instead */
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
when?: Rule
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
/** @deprecated Use `when` instead */
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
when?: Rule
|
||||
}
|
||||
>
|
||||
authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
|
||||
}
|
||||
| {
|
||||
type: "api"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
/** @deprecated Use `when` instead */
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
when?: Rule
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
/** @deprecated Use `when` instead */
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
when?: Rule
|
||||
}
|
||||
>
|
||||
authorize?(inputs?: Record<string, string>): Promise<
|
||||
| {
|
||||
type: "success"
|
||||
key: string
|
||||
provider?: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)[]
|
||||
}
|
||||
|
||||
export type AuthOAuthResult = { url: string; instructions: string } & (
|
||||
| {
|
||||
method: "auto"
|
||||
callback(): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
accountId?: string
|
||||
enterpriseUrl?: string
|
||||
}
|
||||
| { key: string; metadata?: Record<string, string> }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
method: "code"
|
||||
callback(code: string): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
accountId?: string
|
||||
enterpriseUrl?: string
|
||||
}
|
||||
| { key: string; metadata?: Record<string, string> }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)
|
||||
|
||||
export type ProviderHookContext = {
|
||||
auth?: Auth
|
||||
}
|
||||
|
||||
export type ProviderHook = {
|
||||
id: string
|
||||
models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise<Record<string, ModelV2>>
|
||||
}
|
||||
|
||||
/** @deprecated Use AuthOAuthResult instead. */
|
||||
export type AuthOuathResult = AuthOAuthResult
|
||||
|
||||
export interface Hooks {
|
||||
dispose?: () => Promise<void>
|
||||
event?: (input: { event: Event }) => Promise<void>
|
||||
config?: (input: Config) => Promise<void>
|
||||
tool?: {
|
||||
[key: string]: ToolDefinition
|
||||
}
|
||||
auth?: AuthHook
|
||||
provider?: ProviderHook
|
||||
/**
|
||||
* Called when a new message is received
|
||||
*/
|
||||
"chat.message"?: (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
variant?: string
|
||||
},
|
||||
output: { message: UserMessage; parts: Part[] },
|
||||
) => Promise<void>
|
||||
/**
|
||||
* Modify parameters sent to LLM
|
||||
*/
|
||||
"chat.params"?: (
|
||||
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
|
||||
output: {
|
||||
temperature: number
|
||||
topP: number
|
||||
topK: number
|
||||
maxOutputTokens: number | undefined
|
||||
options: Record<string, any>
|
||||
},
|
||||
) => Promise<void>
|
||||
"chat.headers"?: (
|
||||
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
|
||||
output: { headers: Record<string, string> },
|
||||
) => Promise<void>
|
||||
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
|
||||
"command.execute.before"?: (
|
||||
input: { command: string; sessionID: string; arguments: string },
|
||||
output: { parts: Part[] },
|
||||
) => Promise<void>
|
||||
"tool.execute.before"?: (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: any },
|
||||
) => Promise<void>
|
||||
"shell.env"?: (
|
||||
input: { cwd: string; sessionID?: string; callID?: string },
|
||||
output: { env: Record<string, string> },
|
||||
) => Promise<void>
|
||||
"tool.execute.after"?: (
|
||||
input: { tool: string; sessionID: string; callID: string; args: any },
|
||||
output: {
|
||||
title: string
|
||||
output: string
|
||||
metadata: any
|
||||
},
|
||||
) => Promise<void>
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: {},
|
||||
output: {
|
||||
messages: {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}[]
|
||||
},
|
||||
) => Promise<void>
|
||||
"experimental.chat.system.transform"?: (
|
||||
input: { sessionID?: string; model: Model },
|
||||
output: {
|
||||
system: string[]
|
||||
},
|
||||
) => Promise<void>
|
||||
"experimental.provider.small_model"?: (input: { provider: ProviderV2 }, output: { model?: ModelV2 }) => Promise<void>
|
||||
/**
|
||||
* Called before session compaction starts. Allows plugins to customize
|
||||
* the compaction prompt.
|
||||
*
|
||||
* - `context`: Additional context strings appended to the default prompt
|
||||
* - `prompt`: If set, replaces the default compaction prompt entirely
|
||||
*/
|
||||
"experimental.session.compacting"?: (
|
||||
input: { sessionID: string },
|
||||
output: { context: string[]; prompt?: string },
|
||||
) => Promise<void>
|
||||
/**
|
||||
* Called after compaction succeeds and before a synthetic user
|
||||
* auto-continue message is added.
|
||||
*
|
||||
* - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic
|
||||
* user "continue" turn.
|
||||
*/
|
||||
"experimental.compaction.autocontinue"?: (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent: string
|
||||
model: Model
|
||||
provider: ProviderContext
|
||||
message: UserMessage
|
||||
overflow: boolean
|
||||
},
|
||||
output: { enabled: boolean },
|
||||
) => Promise<void>
|
||||
"experimental.text.complete"?: (
|
||||
input: { sessionID: string; messageID: string; partID: string },
|
||||
output: { text: string },
|
||||
) => Promise<void>
|
||||
/**
|
||||
* Modify tool definitions (description and parameters) sent to LLM
|
||||
*/
|
||||
"tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise<void>
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
export type ShellFunction = (input: Uint8Array) => Uint8Array
|
||||
|
||||
export type ShellExpression =
|
||||
| { toString(): string }
|
||||
| Array<ShellExpression>
|
||||
| string
|
||||
| { raw: string }
|
||||
| ReadableStream
|
||||
|
||||
export interface BunShell {
|
||||
(strings: TemplateStringsArray, ...expressions: ShellExpression[]): BunShellPromise
|
||||
|
||||
/**
|
||||
* Perform bash-like brace expansion on the given pattern.
|
||||
* @param pattern - Brace pattern to expand
|
||||
*/
|
||||
braces(pattern: string): string[]
|
||||
|
||||
/**
|
||||
* Escape strings for input into shell commands.
|
||||
*/
|
||||
escape(input: string): string
|
||||
|
||||
/**
|
||||
* Change the default environment variables for shells created by this instance.
|
||||
*/
|
||||
env(newEnv?: Record<string, string | undefined>): BunShell
|
||||
|
||||
/**
|
||||
* Default working directory to use for shells created by this instance.
|
||||
*/
|
||||
cwd(newCwd?: string): BunShell
|
||||
|
||||
/**
|
||||
* Configure the shell to not throw an exception on non-zero exit codes.
|
||||
*/
|
||||
nothrow(): BunShell
|
||||
|
||||
/**
|
||||
* Configure whether or not the shell should throw an exception on non-zero exit codes.
|
||||
*/
|
||||
throws(shouldThrow: boolean): BunShell
|
||||
}
|
||||
|
||||
export interface BunShellPromise extends Promise<BunShellOutput> {
|
||||
readonly stdin: WritableStream
|
||||
|
||||
/**
|
||||
* Change the current working directory of the shell.
|
||||
*/
|
||||
cwd(newCwd: string): this
|
||||
|
||||
/**
|
||||
* Set environment variables for the shell.
|
||||
*/
|
||||
env(newEnv: Record<string, string> | undefined): this
|
||||
|
||||
/**
|
||||
* By default, the shell will write to the current process's stdout and stderr, as well as buffering that output.
|
||||
* This configures the shell to only buffer the output.
|
||||
*/
|
||||
quiet(): this
|
||||
|
||||
/**
|
||||
* Read from stdout as a string, line by line
|
||||
* Automatically calls quiet() to disable echoing to stdout.
|
||||
*/
|
||||
lines(): AsyncIterable<string>
|
||||
|
||||
/**
|
||||
* Read from stdout as a string.
|
||||
* Automatically calls quiet() to disable echoing to stdout.
|
||||
*/
|
||||
text(encoding?: BufferEncoding): Promise<string>
|
||||
|
||||
/**
|
||||
* Read from stdout as a JSON object
|
||||
* Automatically calls quiet()
|
||||
*/
|
||||
json(): Promise<any>
|
||||
|
||||
/**
|
||||
* Read from stdout as an ArrayBuffer
|
||||
* Automatically calls quiet()
|
||||
*/
|
||||
arrayBuffer(): Promise<ArrayBuffer>
|
||||
|
||||
/**
|
||||
* Read from stdout as a Blob
|
||||
* Automatically calls quiet()
|
||||
*/
|
||||
blob(): Promise<Blob>
|
||||
|
||||
/**
|
||||
* Configure the shell to not throw an exception on non-zero exit codes.
|
||||
*/
|
||||
nothrow(): this
|
||||
|
||||
/**
|
||||
* Configure whether or not the shell should throw an exception on non-zero exit codes.
|
||||
*/
|
||||
throws(shouldThrow: boolean): this
|
||||
}
|
||||
|
||||
export interface BunShellOutput {
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly exitCode: number
|
||||
|
||||
/**
|
||||
* Read from stdout as a string
|
||||
*/
|
||||
text(encoding?: BufferEncoding): string
|
||||
|
||||
/**
|
||||
* Read from stdout as a JSON object
|
||||
*/
|
||||
json(): any
|
||||
|
||||
/**
|
||||
* Read from stdout as an ArrayBuffer
|
||||
*/
|
||||
arrayBuffer(): ArrayBuffer
|
||||
|
||||
/**
|
||||
* Read from stdout as an Uint8Array
|
||||
*/
|
||||
bytes(): Uint8Array
|
||||
|
||||
/**
|
||||
* Read from stdout as a Blob
|
||||
*/
|
||||
blob(): Blob
|
||||
}
|
||||
|
||||
export type BunShellError = Error & BunShellOutput
|
||||
@@ -1,54 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export type ToolContext = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
/**
|
||||
* Current project directory for this session.
|
||||
* Prefer this over process.cwd() when resolving relative paths.
|
||||
*/
|
||||
directory: string
|
||||
/**
|
||||
* Project worktree root for this session.
|
||||
* Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)).
|
||||
*/
|
||||
worktree: string
|
||||
abort: AbortSignal
|
||||
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
|
||||
ask(input: AskInput): Promise<void>
|
||||
}
|
||||
|
||||
type AskInput = {
|
||||
permission: string
|
||||
patterns: string[]
|
||||
always: string[]
|
||||
metadata: { [key: string]: any }
|
||||
}
|
||||
|
||||
export type ToolAttachment = {
|
||||
type: "file"
|
||||
mime: string
|
||||
url: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
export type ToolResult =
|
||||
| string
|
||||
| {
|
||||
title?: string
|
||||
output: string
|
||||
metadata?: { [key: string]: any }
|
||||
attachments?: ToolAttachment[]
|
||||
}
|
||||
|
||||
export function tool<Args extends z.ZodRawShape>(input: {
|
||||
description: string
|
||||
args: Args
|
||||
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>
|
||||
}) {
|
||||
return input
|
||||
}
|
||||
tool.schema = z
|
||||
|
||||
export type ToolDefinition = ReturnType<typeof tool>
|
||||
@@ -1,655 +0,0 @@
|
||||
import type {
|
||||
OpencodeClient,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Provider,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Config as SdkConfig,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
import type { Types } from "effect"
|
||||
import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
|
||||
import type { Binding, Keymap } from "@opentui/keymap"
|
||||
import {
|
||||
createBindingLookup as createKeymapBindingLookup,
|
||||
type BindingConfig,
|
||||
type CreateBindingLookupOptions,
|
||||
type KeySequenceFormatPart,
|
||||
type SequenceBindingLike,
|
||||
} from "@opentui/keymap/extras"
|
||||
import type { JSX, SolidPlugin } from "@opentui/solid"
|
||||
import type { Config as PluginConfig, PluginOptions } from "./index.js"
|
||||
|
||||
export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
|
||||
export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
|
||||
export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
|
||||
export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
|
||||
export type {
|
||||
BindingConfig,
|
||||
BindingLookup,
|
||||
BindingValue,
|
||||
CreateBindingLookupOptions,
|
||||
FormatCommandBindingsOptions,
|
||||
FormatKeySequenceOptions,
|
||||
KeySequenceFormatPart,
|
||||
SequenceBindingLike,
|
||||
} from "@opentui/keymap/extras"
|
||||
|
||||
export function createBindingLookup(
|
||||
config: BindingConfig<Renderable, KeyEvent> | undefined,
|
||||
options?: CreateBindingLookupOptions<Renderable, KeyEvent>,
|
||||
) {
|
||||
return createKeymapBindingLookup<Renderable, KeyEvent>(config ?? {}, options)
|
||||
}
|
||||
|
||||
export type TuiRouteCurrent =
|
||||
| {
|
||||
name: "home"
|
||||
}
|
||||
| {
|
||||
name: "session"
|
||||
params: {
|
||||
sessionID: string
|
||||
prompt?: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TuiRouteDefinition = {
|
||||
name: string
|
||||
render: (input: { params?: Record<string, unknown> }) => JSX.Element
|
||||
}
|
||||
|
||||
export type TuiKeys = {
|
||||
formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
|
||||
formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
|
||||
}
|
||||
|
||||
export type TuiKeymap = Keymap<Renderable, KeyEvent>
|
||||
|
||||
export type TuiModeApi = {
|
||||
current: () => string
|
||||
push: (mode: string) => () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead.
|
||||
*/
|
||||
export type TuiCommand = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
onSelect?: (dialog?: TuiDialogStack) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
|
||||
* `api.keymap.dispatchCommand("command.palette.show")` instead.
|
||||
*/
|
||||
export type TuiCommandApi = {
|
||||
/** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */
|
||||
register: (cb: () => TuiCommand[]) => () => void
|
||||
/** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */
|
||||
trigger: (value: string) => void
|
||||
/** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */
|
||||
show: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogProps = {
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
onClose: () => void
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export type TuiDialogStack = {
|
||||
replace: (render: () => JSX.Element, onClose?: () => void) => void
|
||||
clear: () => void
|
||||
setSize: (size: "medium" | "large" | "xlarge") => void
|
||||
readonly size: "medium" | "large" | "xlarge"
|
||||
readonly depth: number
|
||||
readonly open: boolean
|
||||
}
|
||||
|
||||
export type TuiDialogAlertProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogConfirmProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
busy?: boolean
|
||||
busyText?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogSelectOption<Value = unknown> = {
|
||||
title: string
|
||||
value: Value
|
||||
description?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogSelectProps<Value = unknown> = {
|
||||
title: string
|
||||
placeholder?: string
|
||||
options: TuiDialogSelectOption<Value>[]
|
||||
flat?: boolean
|
||||
onMove?: (option: TuiDialogSelectOption<Value>) => void
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: TuiDialogSelectOption<Value>) => void
|
||||
skipFilter?: boolean
|
||||
current?: Value
|
||||
}
|
||||
|
||||
export type TuiPromptInfo = Types.DeepMutable<PromptInput.Prompt> & {
|
||||
pasted: {
|
||||
text: string
|
||||
source: {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
}[]
|
||||
mode?: "normal" | "shell"
|
||||
}
|
||||
|
||||
export type TuiPromptRef = {
|
||||
focused: boolean
|
||||
current: TuiPromptInfo
|
||||
set(prompt: TuiPromptInfo): void
|
||||
reset(): void
|
||||
blur(): void
|
||||
focus(): void
|
||||
submit(): void
|
||||
}
|
||||
|
||||
export type TuiPromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: TuiPromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
shell?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiToast = {
|
||||
variant?: "info" | "success" | "warning" | "error"
|
||||
title?: string
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type TuiAttentionWhen = "always" | "focused" | "blurred"
|
||||
|
||||
export const TuiAttentionSoundNames = ["default", "question", "permission", "error", "done", "subagent_done"] as const
|
||||
export type TuiAttentionSoundName = (typeof TuiAttentionSoundNames)[number]
|
||||
|
||||
export type TuiAttentionSound =
|
||||
| boolean
|
||||
| {
|
||||
name?: TuiAttentionSoundName
|
||||
volume?: number
|
||||
when?: TuiAttentionWhen
|
||||
}
|
||||
|
||||
export type TuiAttentionNotification =
|
||||
| boolean
|
||||
| {
|
||||
when?: TuiAttentionWhen
|
||||
}
|
||||
|
||||
export type TuiAttentionSoundPack = {
|
||||
id: string
|
||||
name?: string
|
||||
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
||||
}
|
||||
|
||||
export type TuiAttentionSoundPackInfo = {
|
||||
id: string
|
||||
name?: string
|
||||
active: boolean
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
export type TuiAttentionSoundboardActivateOptions = {
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
export type TuiAttentionSoundboard = {
|
||||
registerPack(pack: TuiAttentionSoundPack): () => void
|
||||
activate(id: string, options?: TuiAttentionSoundboardActivateOptions): boolean
|
||||
current(): string
|
||||
list(): ReadonlyArray<TuiAttentionSoundPackInfo>
|
||||
}
|
||||
|
||||
export type TuiAttentionNotifyInput = {
|
||||
title?: string
|
||||
message: string
|
||||
notification?: TuiAttentionNotification
|
||||
sound?: TuiAttentionSound
|
||||
}
|
||||
|
||||
export type TuiAttentionNotifySkipReason =
|
||||
| "attention_disabled"
|
||||
| "empty_message"
|
||||
| "blurred"
|
||||
| "focused"
|
||||
| "focus_unknown"
|
||||
| "renderer_destroyed"
|
||||
|
||||
export type TuiAttentionNotifyResult = {
|
||||
ok: boolean
|
||||
notification: boolean
|
||||
sound: boolean
|
||||
skipped?: TuiAttentionNotifySkipReason
|
||||
}
|
||||
|
||||
export type TuiAttention = {
|
||||
notify(input: TuiAttentionNotifyInput): Promise<TuiAttentionNotifyResult>
|
||||
soundboard: TuiAttentionSoundboard
|
||||
}
|
||||
|
||||
export type TuiThemeCurrent = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
}
|
||||
|
||||
export type TuiTheme = {
|
||||
readonly current: TuiThemeCurrent
|
||||
readonly selected: string
|
||||
has: (name: string) => boolean
|
||||
set: (name: string) => boolean
|
||||
install: (jsonPath: string) => Promise<void>
|
||||
mode: () => "dark" | "light"
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
export type TuiKV = {
|
||||
get: <Value = unknown>(key: string, fallback?: Value) => Value
|
||||
set: (key: string, value: unknown) => void
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
export type TuiState = {
|
||||
readonly ready: boolean
|
||||
readonly config: SdkConfig
|
||||
readonly provider: ReadonlyArray<Provider>
|
||||
readonly path: {
|
||||
state: string
|
||||
config: string
|
||||
worktree: string
|
||||
directory: string
|
||||
}
|
||||
readonly vcs: { branch?: string; default_branch?: string } | undefined
|
||||
session: {
|
||||
count: () => number
|
||||
get: (sessionID: string) => Session | undefined
|
||||
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
||||
messages: (sessionID: string) => ReadonlyArray<Message>
|
||||
status: (sessionID: string) => SessionStatus | undefined
|
||||
permission: (sessionID: string) => ReadonlyArray<PermissionRequest>
|
||||
question: (sessionID: string) => ReadonlyArray<QuestionRequest>
|
||||
}
|
||||
part: (messageID: string) => ReadonlyArray<Part>
|
||||
lsp: () => ReadonlyArray<TuiSidebarLspItem>
|
||||
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
|
||||
}
|
||||
|
||||
type TuiBindingLookupView = {
|
||||
readonly bindings: ReadonlyArray<Binding<Renderable, KeyEvent>>
|
||||
get: (command: string) => ReadonlyArray<Binding<Renderable, KeyEvent>>
|
||||
has: (command: string) => boolean
|
||||
gather: (name: string, commands: readonly string[]) => ReadonlyArray<Binding<Renderable, KeyEvent>>
|
||||
pick: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
|
||||
omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
|
||||
}
|
||||
|
||||
type TuiAttentionConfigView = {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
sound: boolean
|
||||
volume: number
|
||||
sound_pack: string
|
||||
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
||||
}
|
||||
|
||||
type TuiConfigView = {
|
||||
$schema?: string
|
||||
theme?: string | { name?: string; mode?: "system" | "dark" | "light" }
|
||||
plugin?: PluginConfig["plugin"]
|
||||
plugins?: ReadonlyArray<string | { package: string; options?: Record<string, any> }>
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
leader?: { timeout: number }
|
||||
leader_timeout?: number
|
||||
scroll?: { speed?: number; acceleration?: boolean }
|
||||
scroll_speed?: number
|
||||
scroll_acceleration?: { enabled: boolean }
|
||||
attention: TuiAttentionConfigView
|
||||
diffs?: {
|
||||
wrap?: "word" | "none"
|
||||
tree?: boolean
|
||||
single?: boolean
|
||||
view?: "auto" | "split" | "unified"
|
||||
}
|
||||
diff_style?: "auto" | "stacked"
|
||||
terminal?: { title?: boolean }
|
||||
prompt?: { editor?: boolean; paste?: "compact" | "full" } | { max_height?: number; max_width?: number | "auto" }
|
||||
session?: {
|
||||
sidebar?: "auto" | "hide"
|
||||
scrollbar?: boolean
|
||||
thinking?: "show" | "hide"
|
||||
markdown?: "source" | "rendered"
|
||||
grouping?: "auto" | "none"
|
||||
}
|
||||
hints?: { onboarding?: boolean }
|
||||
animations?: boolean
|
||||
mouse: boolean
|
||||
keybinds: TuiBindingLookupView
|
||||
}
|
||||
|
||||
export type TuiApp = {
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
type Frozen<Value> = Value extends (...args: never[]) => unknown
|
||||
? Value
|
||||
: Value extends ReadonlyArray<infer Item>
|
||||
? ReadonlyArray<Frozen<Item>>
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: Frozen<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TuiSidebarMcpItem = {
|
||||
name: string
|
||||
status: McpStatus["status"]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
|
||||
|
||||
export type TuiSidebarFileItem = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export type TuiHostSlotMap = {
|
||||
app: {}
|
||||
app_bottom: {}
|
||||
home_logo: {}
|
||||
home_prompt: {
|
||||
ref?: (ref: TuiPromptRef | undefined) => void
|
||||
}
|
||||
home_prompt_right: {}
|
||||
session_prompt: {
|
||||
session_id: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
on_submit?: () => void
|
||||
ref?: (ref: TuiPromptRef | undefined) => void
|
||||
}
|
||||
session_prompt_right: {
|
||||
session_id: string
|
||||
}
|
||||
home_bottom: {}
|
||||
home_footer: {}
|
||||
sidebar_title: {
|
||||
session_id: string
|
||||
title: string
|
||||
share_url?: string
|
||||
}
|
||||
sidebar_content: {
|
||||
session_id: string
|
||||
}
|
||||
sidebar_footer: {
|
||||
session_id: string
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiSlotMap<Slots extends Record<string, object> = {}> = TuiHostSlotMap & Slots
|
||||
|
||||
type TuiSlotShape<Name extends string, Slots extends Record<string, object>> = Name extends keyof TuiHostSlotMap
|
||||
? TuiHostSlotMap[Name]
|
||||
: Name extends keyof Slots
|
||||
? Slots[Name]
|
||||
: Record<string, unknown>
|
||||
|
||||
export type TuiSlotProps<Name extends string = string, Slots extends Record<string, object> = {}> = {
|
||||
name: Name
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotShape<Name, Slots>
|
||||
|
||||
export type TuiSlotContext = {
|
||||
theme: TuiTheme
|
||||
}
|
||||
|
||||
type SlotCore<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
|
||||
|
||||
export type TuiSlotPlugin<Slots extends Record<string, object> = {}> = Omit<SlotCore<Slots>, "id"> & {
|
||||
id?: never
|
||||
}
|
||||
|
||||
export type TuiSlots = {
|
||||
register: {
|
||||
(plugin: TuiSlotPlugin): string
|
||||
<Slots extends Record<string, object>>(plugin: TuiSlotPlugin<Slots>): string
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiEventBus = {
|
||||
on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
) => () => void
|
||||
}
|
||||
|
||||
export type TuiDispose = () => void | Promise<void>
|
||||
|
||||
export type TuiLifecycle = {
|
||||
readonly signal: AbortSignal
|
||||
onDispose: (fn: TuiDispose) => () => void
|
||||
}
|
||||
|
||||
export type TuiPluginState = "first" | "updated" | "same"
|
||||
|
||||
export type TuiPluginEntry = {
|
||||
id: string
|
||||
source: "file" | "npm" | "internal"
|
||||
spec: string
|
||||
target: string
|
||||
requested?: string
|
||||
version?: string
|
||||
modified?: number
|
||||
first_time: number
|
||||
last_time: number
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type TuiPluginMeta = TuiPluginEntry & {
|
||||
state: TuiPluginState
|
||||
}
|
||||
|
||||
export type TuiPluginStatus = {
|
||||
id: string
|
||||
source: TuiPluginEntry["source"]
|
||||
spec: string
|
||||
target: string
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type TuiPluginInstallOptions = {
|
||||
global?: boolean
|
||||
}
|
||||
|
||||
export type TuiPluginInstallResult =
|
||||
| {
|
||||
ok: true
|
||||
dir: string
|
||||
tui: boolean
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
message: string
|
||||
missing?: boolean
|
||||
}
|
||||
|
||||
export type TuiWorkspace = {
|
||||
current: () => string | undefined
|
||||
set: (workspaceID?: string) => void
|
||||
}
|
||||
|
||||
export type TuiPluginApi = {
|
||||
app: TuiApp
|
||||
attention: TuiAttention
|
||||
/**
|
||||
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
|
||||
* `api.keymap.dispatchCommand("command.palette.show")` instead.
|
||||
*/
|
||||
command?: TuiCommandApi
|
||||
keys: TuiKeys
|
||||
keymap: TuiKeymap
|
||||
mode: TuiModeApi
|
||||
route: {
|
||||
register: (routes: TuiRouteDefinition[]) => () => void
|
||||
navigate: (name: string, params?: Record<string, unknown>) => void
|
||||
readonly current: TuiRouteCurrent
|
||||
}
|
||||
ui: {
|
||||
Dialog: (props: TuiDialogProps) => JSX.Element
|
||||
DialogAlert: (props: TuiDialogAlertProps) => JSX.Element
|
||||
DialogConfirm: (props: TuiDialogConfirmProps) => JSX.Element
|
||||
DialogPrompt: (props: TuiDialogPromptProps) => JSX.Element
|
||||
DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value>) => JSX.Element
|
||||
Slot: <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
|
||||
Prompt: (props: TuiPromptProps) => JSX.Element
|
||||
toast: (input: TuiToast) => void
|
||||
dialog: TuiDialogStack
|
||||
}
|
||||
readonly tuiConfig: Frozen<TuiConfigView>
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
kv: TuiKV
|
||||
state: TuiState
|
||||
theme: TuiTheme
|
||||
client: OpencodeClient
|
||||
event: TuiEventBus
|
||||
renderer: CliRenderer
|
||||
slots: TuiSlots
|
||||
plugins: {
|
||||
list: () => ReadonlyArray<TuiPluginStatus>
|
||||
activate: (id: string) => Promise<boolean>
|
||||
deactivate: (id: string) => Promise<boolean>
|
||||
add: (spec: string) => Promise<boolean>
|
||||
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
|
||||
}
|
||||
lifecycle: TuiLifecycle
|
||||
}
|
||||
|
||||
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
|
||||
|
||||
export type TuiPluginModule = {
|
||||
id?: string
|
||||
tui: TuiPlugin
|
||||
server?: never
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
- Do not preserve `V2` as the permanent name for the replacement architecture. Remove `V2` from current namespaces, brands, and identifiers as the contracts are normalized.
|
||||
- Retained V1 contracts live under `src/v1/`. New/current code must not depend on that subtree.
|
||||
- V1 coexistence is temporary. Keep compatibility entrypoints only where migration requires them, and delete the V1 subtree when the legacy runtime is retired.
|
||||
- `@opencode-ai/protocol` and `@opencode-ai/sdk-next` are current `/api/...` surfaces.
|
||||
- `@opencode-ai/protocol` and `@opencode-ai/sdk` are current `/api/...` surfaces.
|
||||
|
||||
## Events
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# @opencode-ai/sdk-next
|
||||
# @opencode-ai/sdk
|
||||
|
||||
Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
|
||||
Effect-native scoped OpenCode host for in-process applications.
|
||||
|
||||
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk-next"
|
||||
import { OpenCode } from "@opencode-ai/sdk"
|
||||
|
||||
const opencode = yield * OpenCode.create()
|
||||
const session = yield * opencode.sessions.get({ sessionID })
|
||||
@@ -1,21 +1,32 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk-next",
|
||||
"private": true,
|
||||
"version": "1.18.4",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/sdk"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./workerd": "./src/workerd.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
|
||||
items.map((item) => `src/${item}`),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
|
||||
await Promise.all(
|
||||
files.map(async (file) =>
|
||||
Bun.write(
|
||||
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
|
||||
await transpiler.transform(await Bun.file(file).text()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
value.replace("./src/", "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
|
||||
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [key, { import: output(value), types: output(value, true) }]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -1,27 +1,69 @@
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Database } from "@opencode-ai/core/database/database"
|
||||
import type { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import type { ServerFetch } from "@opencode-ai/server/fetch"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import type { ServerOptions } from "@opencode-ai/server/options"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Config, Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import * as Logging from "./logging"
|
||||
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
|
||||
import type { LogOptions } from "./logging"
|
||||
|
||||
export type CreateOptions = ServerOptions & {
|
||||
export interface CreateOptions {
|
||||
readonly app?: {
|
||||
readonly name?: string
|
||||
readonly version?: string
|
||||
readonly channel?: string
|
||||
}
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly password?: string
|
||||
readonly simulation?: boolean
|
||||
readonly database?: Database.Options
|
||||
readonly events?: { readonly persist?: boolean }
|
||||
readonly models?: ModelsDev.Options
|
||||
readonly config?: {
|
||||
readonly directory?: string
|
||||
readonly project?: boolean
|
||||
readonly file?: string
|
||||
readonly content?: string
|
||||
}
|
||||
readonly windows?: { readonly gitbash?: string }
|
||||
readonly fs?: {
|
||||
readonly filewatcher?: boolean
|
||||
readonly fff?: boolean
|
||||
}
|
||||
readonly log?: LogOptions
|
||||
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
|
||||
}
|
||||
|
||||
/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */
|
||||
export type EmbedOptions = ServerFetch.BootOptions
|
||||
export interface EmbedOptions {
|
||||
readonly overrides?: LayerNode.Replacements
|
||||
}
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) {
|
||||
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly sessions: OpenCodeClient["session"]
|
||||
readonly events: OpenCodeClient["event"]
|
||||
readonly workspace: {
|
||||
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
|
||||
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
|
||||
}
|
||||
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
|
||||
}
|
||||
|
||||
export const create: (
|
||||
options?: CreateOptions,
|
||||
embed?: EmbedOptions,
|
||||
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
|
||||
options: CreateOptions = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const { log, workspaceProviders, ...server } = options
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
@@ -75,8 +117,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt
|
||||
}
|
||||
})
|
||||
|
||||
export type Interface = Effect.Success<ReturnType<typeof create>>
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
|
||||
|
||||
export const layer = (options: CreateOptions = {}) => Layer.effect(Service, create(options))
|
||||
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
|
||||
Layer.effect(Service, create(options))
|
||||
@@ -1,10 +1,17 @@
|
||||
export * as OpenCodeWorkerd from "./workerd"
|
||||
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { ServerWorkerd } from "@opencode-ai/server/workerd"
|
||||
import { Layer } from "effect"
|
||||
import { Config, Effect, Layer, Scope } from "effect"
|
||||
import * as OpenCode from "./opencode"
|
||||
|
||||
export type CreateOptions = ServerWorkerd.Options & Pick<OpenCode.CreateOptions, "log" | "workspaceProviders">
|
||||
export interface CreateOptions extends Pick<OpenCode.CreateOptions, "log" | "workspaceProviders"> {
|
||||
readonly storage: DurableObjectStorage
|
||||
readonly app?: OpenCode.CreateOptions["app"]
|
||||
readonly password?: string
|
||||
readonly config?: { readonly content?: string }
|
||||
readonly models?: OpenCode.CreateOptions["models"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the embedded opencode SDK on the workerd runtime profile: the full
|
||||
@@ -20,10 +27,17 @@ export type CreateOptions = ServerWorkerd.Options & Pick<OpenCode.CreateOptions,
|
||||
* session operations plus the live `events.subscribe()` stream — served over
|
||||
* an in-process fetch transport, so no request leaves the isolate.
|
||||
*/
|
||||
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) =>
|
||||
export const create: (
|
||||
options: CreateOptions,
|
||||
) => Effect.Effect<OpenCode.Interface, Config.ConfigError | Error, Scope.Scope> = ({
|
||||
log,
|
||||
workspaceProviders,
|
||||
...options
|
||||
}) =>
|
||||
OpenCode.create(
|
||||
{ ...ServerWorkerd.serverOptions(options), log, workspaceProviders },
|
||||
{ overrides: ServerWorkerd.replacements(options) },
|
||||
)
|
||||
|
||||
export const layer = (options: CreateOptions) => Layer.effect(OpenCode.Service, create(options))
|
||||
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
|
||||
Layer.effect(OpenCode.Service, create(options))
|
||||
+1
-1
@@ -20,7 +20,7 @@ async function bundleInputs() {
|
||||
const entrypoint = join(temporary, "index.ts")
|
||||
const metafile = join(temporary, "meta.json")
|
||||
try {
|
||||
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
|
||||
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk"')
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"incremental": false,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": null
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -2,13 +2,24 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo -b",
|
||||
"probe:workerd": "bun run script/workerd-probe.ts"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
|
||||
items.map((item) => `src/${item}`),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
|
||||
await Promise.all(
|
||||
files.map(async (file) =>
|
||||
Bun.write(
|
||||
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
|
||||
await transpiler.transform(await Bun.file(file).text()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"tsBuildInfoFile": null
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -2,12 +2,24 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/shell-scan"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -2,9 +2,19 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/simulation"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./backend": "./src/backend/index.ts",
|
||||
"./backend/*": "./src/backend/*.ts",
|
||||
@@ -14,6 +24,7 @@
|
||||
"./recording": "./src/recording.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
|
||||
items.map((item) => `src/${item}`),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
|
||||
await Promise.all(
|
||||
files.map(async (file) =>
|
||||
Bun.write(
|
||||
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
|
||||
await transpiler.transform(await Bun.file(file).text()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Config, Effect, Layer } from "effect"
|
||||
import { Config, Effect, FileSystem, Layer } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { DriveManifest } from "../manifest"
|
||||
import { SimulationNetwork } from "./network"
|
||||
@@ -21,7 +21,9 @@ import { SimulatedProvider } from "./simulated-provider"
|
||||
*
|
||||
*/
|
||||
|
||||
export const simulationReplacements = Effect.fn("Simulation.replacements")(function* (app: {
|
||||
export const simulationReplacements: (app: {
|
||||
readonly version: string
|
||||
}) => Effect.Effect<LayerNode.Replacements, Error, FileSystem.FileSystem> = Effect.fn("Simulation.replacements")(function* (app: {
|
||||
readonly version: string
|
||||
}) {
|
||||
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"tsBuildInfoFile": null
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
SLACK_APP_TOKEN=xapp-your-app-token
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -1,27 +0,0 @@
|
||||
# @opencode-ai/slack
|
||||
|
||||
Slack bot integration for opencode that creates threaded conversations.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Slack app at https://api.slack.com/apps
|
||||
2. Enable Socket Mode
|
||||
3. Add the following OAuth scopes:
|
||||
- `chat:write`
|
||||
- `app_mentions:read`
|
||||
- `channels:history`
|
||||
- `groups:history`
|
||||
4. Install the app to your workspace
|
||||
5. Set environment variables in `.env`:
|
||||
- `SLACK_BOT_TOKEN` - Bot User OAuth Token
|
||||
- `SLACK_SIGNING_SECRET` - Signing Secret from Basic Information
|
||||
- `SLACK_APP_TOKEN` - App-Level Token from Basic Information
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Edit .env with your Slack app credentials
|
||||
bun dev
|
||||
```
|
||||
|
||||
The bot will respond to messages in channels where it's added, creating separate opencode sessions for each thread.
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@slack/bolt": "^3.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { App } from "@slack/bolt"
|
||||
import { createOpencode, type ToolPart } from "@opencode-ai/sdk"
|
||||
|
||||
const app = new App({
|
||||
token: process.env.SLACK_BOT_TOKEN,
|
||||
signingSecret: process.env.SLACK_SIGNING_SECRET,
|
||||
socketMode: true,
|
||||
appToken: process.env.SLACK_APP_TOKEN,
|
||||
})
|
||||
|
||||
console.log("🔧 Bot configuration:")
|
||||
console.log("- Bot token present:", !!process.env.SLACK_BOT_TOKEN)
|
||||
console.log("- Signing secret present:", !!process.env.SLACK_SIGNING_SECRET)
|
||||
console.log("- App token present:", !!process.env.SLACK_APP_TOKEN)
|
||||
|
||||
console.log("🚀 Starting opencode server...")
|
||||
const opencode = await createOpencode({
|
||||
port: 0,
|
||||
})
|
||||
console.log("✅ Opencode server ready")
|
||||
|
||||
const sessions = new Map<string, { sessionId: string; channel: string; thread: string }>()
|
||||
void (async () => {
|
||||
const events = await opencode.client.event.subscribe()
|
||||
for await (const event of events.stream) {
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool") {
|
||||
// Find the session for this tool update
|
||||
for (const session of sessions.values()) {
|
||||
if (session.sessionId === part.sessionID) {
|
||||
void handleToolUpdate(part, session.channel, session.thread)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
async function handleToolUpdate(part: ToolPart, channel: string, thread: string) {
|
||||
if (part.state.status !== "completed") return
|
||||
const toolMessage = `*${part.tool}* - ${part.state.title}`
|
||||
await app.client.chat
|
||||
.postMessage({
|
||||
channel,
|
||||
thread_ts: thread,
|
||||
text: toolMessage,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
app.use(async ({ next, context }) => {
|
||||
console.log("📡 Raw Slack event:", JSON.stringify(context, null, 2))
|
||||
await next()
|
||||
})
|
||||
|
||||
app.message(async ({ message, say }) => {
|
||||
console.log("📨 Received message event:", JSON.stringify(message, null, 2))
|
||||
|
||||
if (message.subtype || !("text" in message) || !message.text) {
|
||||
console.log("⏭️ Skipping message - no text or has subtype")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("✅ Processing message:", message.text)
|
||||
|
||||
const channel = message.channel
|
||||
const thread = ("thread_ts" in message && typeof message.thread_ts === "string" && message.thread_ts) || message.ts
|
||||
const sessionKey = `${channel}-${thread}`
|
||||
|
||||
let session = sessions.get(sessionKey)
|
||||
|
||||
if (!session) {
|
||||
console.log("🆕 Creating new opencode session...")
|
||||
const createResult = await opencode.client.session.create({
|
||||
body: { title: `Slack thread ${thread}` },
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
console.error("❌ Failed to create session:", createResult.error)
|
||||
await say({
|
||||
text: "Sorry, I had trouble creating a session. Please try again.",
|
||||
thread_ts: thread,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log("✅ Created opencode session:", createResult.data.id)
|
||||
|
||||
session = { sessionId: createResult.data.id, channel, thread }
|
||||
sessions.set(sessionKey, session)
|
||||
|
||||
const shareResult = await opencode.client.session.share({ path: { id: createResult.data.id } })
|
||||
if (!shareResult.error && shareResult.data) {
|
||||
const sessionUrl = shareResult.data.share?.url
|
||||
console.log("🔗 Session shared:", sessionUrl)
|
||||
await app.client.chat.postMessage({ channel, thread_ts: thread, text: sessionUrl })
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📝 Sending to opencode:", message.text)
|
||||
const result = await opencode.client.session.prompt({
|
||||
path: { id: session.sessionId },
|
||||
body: { parts: [{ type: "text", text: message.text }] },
|
||||
})
|
||||
|
||||
console.log("📤 Opencode response:", JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.error) {
|
||||
console.error("❌ Failed to send message:", result.error)
|
||||
await say({
|
||||
text: "Sorry, I had trouble processing your message. Please try again.",
|
||||
thread_ts: thread,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build response text
|
||||
const responseText =
|
||||
result.data.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n") || "I received your message but didn't have a response."
|
||||
|
||||
console.log("💬 Sending response:", responseText)
|
||||
|
||||
// Send main response (tool updates will come via live events)
|
||||
await say({ text: responseText, thread_ts: thread })
|
||||
})
|
||||
|
||||
app.command("/test", async ({ command, ack, say }) => {
|
||||
await ack()
|
||||
console.log("🧪 Test command received:", JSON.stringify(command, null, 2))
|
||||
await say("🤖 Bot is working! I can hear you loud and clear.")
|
||||
})
|
||||
|
||||
await app.start()
|
||||
console.log("⚡️ Slack bot is running!")
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
+26
-100
@@ -1,15 +1,13 @@
|
||||
/// <reference path="./audio.d.ts" />
|
||||
import type {
|
||||
TuiAttention,
|
||||
TuiAttentionNotifyInput,
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/v1/tui"
|
||||
import { AttentionSoundName, type Config } from "./config"
|
||||
Attention,
|
||||
AttentionNotifyOptions,
|
||||
AttentionNotifyResult,
|
||||
AttentionNotifySkipReason,
|
||||
AttentionWhen,
|
||||
AttentionSoundName,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import { Config } from "./config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import * as TuiAudio from "./audio"
|
||||
@@ -30,33 +28,23 @@ type AttentionRenderer = {
|
||||
triggerNotification(message: string, title?: string): boolean
|
||||
}
|
||||
|
||||
type RegisteredSoundPack = TuiAttentionSoundPack & {
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
type TuiAttentionHost = TuiAttention & {
|
||||
type AttentionHost = Attention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "OpenCode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
id: DEFAULT_PACK_ID,
|
||||
name: "OpenCode Default",
|
||||
builtin: true,
|
||||
sounds: {
|
||||
default: defaultSoundPath,
|
||||
question: questionSoundPath,
|
||||
permission: permissionSoundPath,
|
||||
error: errorSoundPath,
|
||||
done: defaultSoundPath,
|
||||
subagent_done: subagentDoneSoundPath,
|
||||
},
|
||||
const BUILTIN_SOUNDS: Record<AttentionSoundName, string> = {
|
||||
default: defaultSoundPath,
|
||||
question: questionSoundPath,
|
||||
permission: permissionSoundPath,
|
||||
error: errorSoundPath,
|
||||
done: defaultSoundPath,
|
||||
subagent_done: subagentDoneSoundPath,
|
||||
}
|
||||
|
||||
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
|
||||
function skipped(reason: AttentionNotifySkipReason): AttentionNotifyResult {
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
@@ -79,7 +67,7 @@ function clampVolume(volume: number) {
|
||||
return Math.min(1, Math.max(0, volume))
|
||||
}
|
||||
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<Config.Resolved, "attention">) {
|
||||
function soundVolume(input: AttentionNotifyOptions, config: Pick<Config.Resolved, "attention">) {
|
||||
if (!config.attention.sound) return
|
||||
if (input.sound === false) return
|
||||
if (input.sound === undefined) return clampVolume(config.attention.volume)
|
||||
@@ -87,23 +75,7 @@ function soundVolume(input: TuiAttentionNotifyInput, config: Pick<Config.Resolve
|
||||
return clampVolume(input.sound.volume ?? config.attention.volume)
|
||||
}
|
||||
|
||||
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
|
||||
const id = pack.id.trim()
|
||||
if (!id) return
|
||||
return {
|
||||
id,
|
||||
name: pack.name?.trim() || undefined,
|
||||
builtin: false,
|
||||
sounds: Object.fromEntries(
|
||||
Object.entries(pack.sounds).filter(
|
||||
(item): item is [TuiAttentionSoundName, string] =>
|
||||
Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
function focusSkip(when: AttentionWhen, focus: FocusState) {
|
||||
if (when === "always") return
|
||||
if (focus === "unknown") return "focus_unknown"
|
||||
if (when === "blurred" && focus === "focused") return "focused"
|
||||
@@ -113,13 +85,10 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
update?: Config.Interface["update"]
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
}): AttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
let disposed = false
|
||||
let activePackID: string | undefined
|
||||
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
|
||||
const audio = input.audio ?? TuiAudio
|
||||
|
||||
const onFocus = () => {
|
||||
@@ -132,21 +101,13 @@ export function createTuiAttention(input: {
|
||||
input.renderer.on("focus", onFocus)
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
return activePackID ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
return packs.get(configuredPackID()) ?? BUILTIN_PACK
|
||||
}
|
||||
|
||||
function soundCandidates(name: TuiAttentionSoundName) {
|
||||
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
|
||||
function soundCandidates(name: AttentionSoundName) {
|
||||
return [input.config.attention.sounds[name], BUILTIN_SOUNDS[name]].filter(
|
||||
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
|
||||
)
|
||||
}
|
||||
|
||||
async function playSound(name: TuiAttentionSoundName, volume: number) {
|
||||
async function playSound(name: AttentionSoundName, volume: number) {
|
||||
try {
|
||||
for (const file of soundCandidates(name)) {
|
||||
const current = await audio.loadSoundFile(file).catch((error) => {
|
||||
@@ -194,7 +155,9 @@ export function createTuiAttention(input: {
|
||||
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
|
||||
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
|
||||
const soundName =
|
||||
requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
|
||||
requestedSound?.name && Schema.is(Config.AttentionSoundName)(requestedSound.name)
|
||||
? requestedSound.name
|
||||
: "default"
|
||||
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
|
||||
|
||||
if (!notification && !sound) {
|
||||
@@ -216,43 +179,6 @@ export function createTuiAttention(input: {
|
||||
}
|
||||
}
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack) {
|
||||
const next = normalizePack(pack)
|
||||
if (!next) return () => {}
|
||||
packs.set(next.id, next)
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
if (packs.get(next.id) === next) packs.delete(next.id)
|
||||
}
|
||||
},
|
||||
activate(id, options) {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist)
|
||||
void input
|
||||
.update?.((draft) => {
|
||||
draft.attention = { ...draft.attention, sound_pack: pack.id }
|
||||
})
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
return currentPack().id
|
||||
},
|
||||
list(): TuiAttentionSoundPackInfo[] {
|
||||
const current = currentPack().id
|
||||
return Array.from(packs.values()).map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
active: pack.id === current,
|
||||
builtin: pack.builtin,
|
||||
}))
|
||||
},
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { locationKey, useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
@@ -262,6 +262,7 @@ export function Prompt(props: PromptProps) {
|
||||
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -285,13 +286,18 @@ export function Prompt(props: PromptProps) {
|
||||
expanded,
|
||||
)
|
||||
if (!sessionID) {
|
||||
setPendingDirectory(directory)
|
||||
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
|
||||
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
|
||||
return undefined
|
||||
})
|
||||
if (!location) return
|
||||
if (!location) {
|
||||
setPendingDirectory(undefined)
|
||||
return
|
||||
}
|
||||
if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory)
|
||||
currentLocation.set(location)
|
||||
setPendingDirectory(undefined)
|
||||
return
|
||||
}
|
||||
const error = await client.api.session.move({ sessionID, directory: input }).then(
|
||||
@@ -308,7 +314,6 @@ export function Prompt(props: PromptProps) {
|
||||
],
|
||||
}))
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const connected = useConnected()
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
@@ -1023,7 +1028,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
const item = history.move(props.sessionID, -1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -1062,7 +1067,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(1, input.plainText)
|
||||
const item = history.move(props.sessionID, 1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -1181,7 +1186,6 @@ export function Prompt(props: PromptProps) {
|
||||
// snapshot unless the user has started typing something new.
|
||||
const currentMode = store.mode
|
||||
const entry = { ...store.prompt, mode: currentMode }
|
||||
history.append(entry)
|
||||
resetComposer()
|
||||
props.onSubmit?.()
|
||||
const restoreEntry = () => {
|
||||
@@ -1256,6 +1260,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const target = sessionID
|
||||
history.append(target, entry)
|
||||
const dispatch = (send: () => Promise<unknown>) => {
|
||||
const setup = newSession
|
||||
if (setup) void setup.gate.then(send).catch(setup.recover)
|
||||
@@ -1557,7 +1562,7 @@ export function Prompt(props: PromptProps) {
|
||||
(store.prompt.files?.length ?? 0) > 0 ||
|
||||
(store.prompt.agents?.length ?? 0) > 0
|
||||
) {
|
||||
history.append({
|
||||
history.append(props.sessionID, {
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
@@ -1565,30 +1570,53 @@ export function Prompt(props: PromptProps) {
|
||||
resetComposer()
|
||||
}
|
||||
|
||||
// Keep the last resolved prompt display visible while destination catalogs load;
|
||||
// availability and submission still use the live location-scoped catalog.
|
||||
const promptDisplay = createMemo<{
|
||||
agentLabel: string | undefined
|
||||
agentColor: RGBA | undefined
|
||||
modelLabel: string
|
||||
providerLabel: string
|
||||
variant: string | undefined
|
||||
}>(
|
||||
(previous) => {
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const sessionLocation = props.sessionID ? data.session.get(props.sessionID)?.location : location
|
||||
if (!sessionLocation || locationKey(sessionLocation) !== locationKey(location)) return previous
|
||||
|
||||
const loading = data.location.agent.list(location) === undefined || !local.model.catalogReady
|
||||
const error = currentLocation.error
|
||||
const failed = error && locationKey(error.location) === locationKey(location)
|
||||
if (loading && !failed) return previous
|
||||
|
||||
const agent = local.agent.current()
|
||||
const model = local.model.parsed()
|
||||
return {
|
||||
agentLabel: agent ? Locale.titlecase(agent.id) : undefined,
|
||||
agentColor: agent ? local.agent.color(agent.id) : undefined,
|
||||
modelLabel: model.model,
|
||||
providerLabel: model.provider,
|
||||
variant: local.model.variant.current(),
|
||||
}
|
||||
},
|
||||
{
|
||||
agentLabel: undefined,
|
||||
agentColor: undefined,
|
||||
modelLabel: local.model.parsed().model,
|
||||
providerLabel: local.model.parsed().provider,
|
||||
variant: undefined,
|
||||
},
|
||||
)
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return theme.border.default
|
||||
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return theme.border.default
|
||||
return local.agent.color(agent.id)
|
||||
return promptDisplay().agentColor ?? theme.border.default
|
||||
})
|
||||
const agentLabel = createMemo(() => {
|
||||
if (store.mode === "shell") return "Shell"
|
||||
const agent = local.agent.current()
|
||||
return agent ? Locale.titlecase(agent.id) : undefined
|
||||
})
|
||||
|
||||
const showVariant = createMemo(() => {
|
||||
const variants = local.model.variant.list()
|
||||
if (variants.length === 0) return false
|
||||
const current = local.model.variant.current()
|
||||
return !!current
|
||||
})
|
||||
|
||||
const agentMetaAlpha = createFadeIn(() => store.mode === "shell" || !!local.agent.current(), animationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
|
||||
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
|
||||
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled)
|
||||
const variantMetaAlpha = createFadeIn(
|
||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
() => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant,
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
@@ -1617,7 +1645,8 @@ export function Prompt(props: PromptProps) {
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
const pending = pendingDirectory()
|
||||
const location = pending ? { directory: pending } : footerLocation()
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
@@ -1635,8 +1664,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border.default
|
||||
const color = promptDisplay().agentColor ?? theme.border.default
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
@@ -1851,14 +1879,14 @@ export function Prompt(props: PromptProps) {
|
||||
truncate
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
{promptDisplay().modelLabel}
|
||||
</text>
|
||||
<Show when={dimensions().width >= 50}>
|
||||
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
|
||||
{currentProviderLabel()}
|
||||
{promptDisplay().providerLabel}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={showVariant() && dimensions().width >= 70}>
|
||||
<Show when={promptDisplay().variant && dimensions().width >= 70}>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
@@ -1867,7 +1895,7 @@ export function Prompt(props: PromptProps) {
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{local.model.variant.current()}
|
||||
{promptDisplay().variant}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user