akm-cli 0.9.0-rc.0 → 0.9.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +339 -2
- package/SECURITY.md +23 -24
- package/dist/assets/help/help-improve.md +10 -10
- package/dist/assets/hints/cli-hints-full.md +44 -10
- package/dist/assets/hints/cli-hints-short.md +6 -2
- package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
- package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
- package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
- package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
- package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
- package/dist/assets/stash-skeleton/README.md +28 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
- package/dist/assets/tasks/core/backup.yml +1 -0
- package/dist/assets/tasks/core/extract.yml +1 -0
- package/dist/assets/tasks/core/improve.yml +1 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -0
- package/dist/assets/tasks/core/sync.yml +1 -0
- package/dist/assets/tasks/core/version-check.yml +1 -0
- package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
- package/dist/assets/templates/html/health.html +5 -1
- package/dist/cli/config-migrate.js +31 -138
- package/dist/cli/config-validate.js +10 -8
- package/dist/cli.js +48 -14
- package/dist/commands/agent/agent-dispatch.js +17 -16
- package/dist/commands/agent/agent-support.js +0 -24
- package/dist/commands/agent/contribute-cli.js +5 -15
- package/dist/commands/backup-cli.js +54 -0
- package/dist/commands/config-cli.js +45 -159
- package/dist/commands/env/env-binding.js +95 -0
- package/dist/commands/env/env-cli.js +8 -65
- package/dist/commands/env/secret.js +8 -5
- package/dist/commands/health/checks.js +130 -83
- package/dist/commands/health/html-report.js +4 -0
- package/dist/commands/health/improve-metrics.js +30 -32
- package/dist/commands/health/llm-usage.js +19 -19
- package/dist/commands/health/md-report.js +4 -0
- package/dist/commands/health/metrics.js +2 -1
- package/dist/commands/health/surfaces.js +5 -4
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/consolidate/chunking.js +2 -2
- package/dist/commands/improve/consolidate.js +28 -25
- package/dist/commands/improve/distill/promote-memory.js +5 -12
- package/dist/commands/improve/distill/quality-gate.js +5 -7
- package/dist/commands/improve/distill.js +16 -5
- package/dist/commands/improve/eligibility.js +22 -12
- package/dist/commands/improve/extract-cli.js +47 -19
- package/dist/commands/improve/extract.js +110 -62
- package/dist/commands/improve/improve-cli.js +38 -16
- package/dist/commands/improve/improve-result-file.js +30 -24
- package/dist/commands/improve/improve-strategies.js +137 -0
- package/dist/commands/improve/improve.js +60 -30
- package/dist/commands/improve/locks.js +66 -45
- package/dist/commands/improve/loop-stages.js +75 -33
- package/dist/commands/improve/memory/memory-belief.js +79 -7
- package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
- package/dist/commands/improve/preparation.js +71 -73
- package/dist/commands/improve/procedural.js +3 -2
- package/dist/commands/improve/recombine.js +2 -1
- package/dist/commands/improve/reflect.js +119 -214
- package/dist/commands/improve/shared.js +11 -5
- package/dist/commands/lint/base-linter.js +152 -42
- package/dist/commands/mv-cli.js +809 -0
- package/dist/commands/proposal/proposal-cli.js +18 -8
- package/dist/commands/proposal/propose.js +64 -69
- package/dist/commands/read/knowledge.js +436 -4
- package/dist/commands/read/remember-cli.js +39 -2
- package/dist/commands/read/search-cli.js +6 -1
- package/dist/commands/registry-cli.js +29 -14
- package/dist/commands/remember.js +2 -0
- package/dist/commands/sources/init.js +13 -14
- package/dist/commands/sources/migration-help.js +7 -4
- package/dist/commands/sources/schema-repair.js +2 -4
- package/dist/commands/sources/source-add.js +62 -73
- package/dist/commands/sources/source-manage.js +50 -46
- package/dist/commands/sources/stash-cli.js +41 -4
- package/dist/commands/tasks/default-tasks.js +12 -12
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +113 -18
- package/dist/commands/wiki-cli.js +9 -10
- package/dist/commands/workflow-cli.js +276 -12
- package/dist/core/asset/asset-spec.js +58 -1
- package/dist/core/asset/frontmatter.js +12 -2
- package/dist/core/common.js +5 -3
- package/dist/core/config/config-io.js +28 -17
- package/dist/core/config/config-schema.js +379 -66
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config-version.js +29 -0
- package/dist/core/config/config-walker.js +98 -27
- package/dist/core/config/config.js +132 -266
- package/dist/core/config/deep-merge.js +41 -0
- package/dist/core/config/engine-semantics.js +32 -0
- package/dist/core/errors.js +2 -2
- package/dist/core/extra-params.js +61 -0
- package/dist/core/file-lock.js +201 -56
- package/dist/core/improve-result.js +178 -0
- package/dist/core/json-schema.js +142 -0
- package/dist/core/maintenance-barrier.js +119 -0
- package/dist/core/migration-backup.js +416 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/redaction.js +358 -0
- package/dist/core/state/migrations.js +17 -2
- package/dist/core/state-db.js +44 -1
- package/dist/indexer/db/db.js +118 -2
- package/dist/indexer/graph/graph-extraction.js +28 -16
- package/dist/indexer/index-writer-lock.js +31 -24
- package/dist/indexer/index-written-assets.js +15 -6
- package/dist/indexer/indexer.js +47 -2
- package/dist/indexer/passes/memory-inference.js +10 -6
- package/dist/indexer/passes/metadata.js +250 -0
- package/dist/indexer/search/db-search.js +111 -44
- package/dist/indexer/search/fts-query.js +41 -0
- package/dist/indexer/search/ranking-contributors.js +48 -0
- package/dist/indexer/search/ranking.js +36 -23
- package/dist/indexer/search/search-fields.js +11 -1
- package/dist/indexer/walk/matchers.js +39 -0
- package/dist/integrations/agent/builder-shared.js +7 -0
- package/dist/integrations/agent/builders.js +5 -50
- package/dist/integrations/agent/config.js +3 -143
- package/dist/integrations/agent/detect.js +17 -2
- package/dist/integrations/agent/engine-resolution.js +202 -0
- package/dist/integrations/agent/index.js +1 -2
- package/dist/integrations/agent/model-aliases.js +16 -2
- package/dist/integrations/agent/profiles.js +36 -62
- package/dist/integrations/agent/runner-dispatch.js +91 -4
- package/dist/integrations/agent/runner.js +76 -207
- package/dist/integrations/agent/spawn.js +141 -20
- package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
- package/dist/integrations/harnesses/aider/index.js +57 -0
- package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
- package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
- package/dist/integrations/harnesses/amazonq/index.js +58 -0
- package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
- package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
- package/dist/integrations/harnesses/claude/index.js +25 -25
- package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
- package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
- package/dist/integrations/harnesses/codex/index.js +62 -0
- package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
- package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
- package/dist/integrations/harnesses/copilot/index.js +59 -0
- package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
- package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
- package/dist/integrations/harnesses/gemini/index.js +59 -0
- package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
- package/dist/integrations/harnesses/index.js +27 -28
- package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
- package/dist/integrations/harnesses/opencode/index.js +15 -22
- package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
- package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
- package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
- package/dist/integrations/harnesses/openhands/index.js +57 -0
- package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
- package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
- package/dist/integrations/harnesses/pi/index.js +57 -0
- package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
- package/dist/integrations/harnesses/types.js +8 -32
- package/dist/integrations/lockfile.js +32 -21
- package/dist/integrations/session-logs/index.js +24 -11
- package/dist/llm/client.js +48 -14
- package/dist/llm/feature-gate.js +15 -47
- package/dist/llm/graph-extract.js +1 -1
- package/dist/llm/index-passes.js +8 -42
- package/dist/llm/memory-infer-impl.js +1 -1
- package/dist/llm/usage-persist.js +4 -0
- package/dist/llm/usage-telemetry.js +35 -5
- package/dist/output/renderers.js +3 -2
- package/dist/output/shapes/helpers.js +2 -1
- package/dist/output/shapes/passthrough.js +6 -0
- package/dist/output/text/helpers.js +215 -2
- package/dist/output/text/workflow.js +3 -1
- package/dist/schemas/akm-config.json +16638 -0
- package/dist/schemas/akm-task.json +87 -0
- package/dist/schemas/akm-workflow.json +372 -0
- package/dist/scripts/migrate-storage.js +10944 -8801
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
- package/dist/setup/detected-engines.js +142 -0
- package/dist/setup/engine-config.js +89 -0
- package/dist/setup/setup.js +236 -132
- package/dist/setup/steps/connection.js +61 -32
- package/dist/setup/steps/platforms.js +4 -4
- package/dist/setup/steps.js +3 -2
- package/dist/storage/database.js +13 -1
- package/dist/storage/engines/sqlite-migrations.js +1 -0
- package/dist/storage/repositories/improve-runs-repository.js +5 -5
- package/dist/storage/repositories/task-history-repository.js +78 -0
- package/dist/storage/repositories/workflow-runs-repository.js +190 -1
- package/dist/tasks/parser.js +138 -52
- package/dist/tasks/runner.js +71 -75
- package/dist/tasks/schema.js +1 -1
- package/dist/tasks/validator.js +11 -6
- package/dist/text-import-hook.mjs +1 -1
- package/dist/wiki/wiki.js +9 -8
- package/dist/workflows/authoring/authoring.js +123 -10
- package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
- package/dist/workflows/cli.js +4 -0
- package/dist/workflows/concurrency-policy.js +15 -0
- package/dist/workflows/db.js +200 -13
- package/dist/workflows/exec/brief.js +478 -0
- package/dist/workflows/exec/frozen-judge.js +47 -0
- package/dist/workflows/exec/native-executor.js +1034 -0
- package/dist/workflows/exec/param-secrets.js +115 -0
- package/dist/workflows/exec/report.js +1355 -0
- package/dist/workflows/exec/run-workflow.js +609 -0
- package/dist/workflows/exec/scheduler.js +71 -0
- package/dist/workflows/exec/step-work.js +1212 -0
- package/dist/workflows/exec/unit-writer.js +23 -0
- package/dist/workflows/exec/watch.js +116 -0
- package/dist/workflows/exec/worktree.js +171 -0
- package/dist/workflows/ir/compile.js +375 -0
- package/dist/workflows/ir/freeze.js +243 -0
- package/dist/workflows/ir/params.js +54 -0
- package/dist/workflows/ir/plan-hash.js +68 -0
- package/dist/workflows/ir/schema.js +545 -0
- package/dist/workflows/parser.js +10 -1
- package/dist/workflows/program/expressions.js +369 -0
- package/dist/workflows/program/parser.js +869 -0
- package/dist/workflows/program/project.js +104 -0
- package/dist/workflows/program/schema.js +54 -0
- package/dist/workflows/renderer.js +82 -5
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/agent-identity.js +59 -14
- package/dist/workflows/runtime/plan-classifier.js +187 -0
- package/dist/workflows/runtime/runs.js +246 -69
- package/dist/workflows/runtime/unit-checkin.js +45 -0
- package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
- package/dist/workflows/validate-summary.js +24 -3
- package/dist/workflows/validator.js +26 -1
- package/docs/data-and-telemetry.md +4 -3
- package/docs/migration/release-notes/0.6.0.md +1 -1
- package/docs/migration/release-notes/0.7.0.md +5 -4
- package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
- package/docs/migration/v0.8-to-v0.9.md +401 -0
- package/package.json +4 -2
- package/schemas/akm-config.json +16638 -0
- package/schemas/akm-task.json +87 -0
- package/schemas/akm-workflow.json +372 -0
- package/dist/commands/improve/improve-profiles.js +0 -168
- package/dist/core/config/config-migration.js +0 -602
- package/dist/core/deep-merge.js +0 -38
- package/dist/llm/call-ai.js +0 -62
- package/dist/setup/legacy-config.js +0 -106
- package/docs/README.md +0 -104
- /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
|
@@ -6,17 +6,109 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Uses the embedded `@opencode-ai/sdk` instead of `Bun.spawn`. Requires no
|
|
8
8
|
* agent CLI binary to be installed. The user provides an OpenAI-compatible
|
|
9
|
-
* endpoint (or inherits from
|
|
9
|
+
* endpoint (or inherits from the selected fallback LLM engine) for the SDK.
|
|
10
10
|
*
|
|
11
11
|
* This is the runtime surface of the {@link OpencodeSdkHarness} (`id =
|
|
12
|
-
* 'opencode-sdk'`). It is the dispatch path for
|
|
12
|
+
* 'opencode-sdk'`). It is the dispatch path for SDK runner specs; it exposes
|
|
13
13
|
* no native session logs of its own (`capabilities.sessionLogs = false`).
|
|
14
|
+
*
|
|
15
|
+
* ## Per-call cwd and env (redesign addendum R2, open seam decision 1)
|
|
16
|
+
*
|
|
17
|
+
* The plan left one decision open: per-call cwd/env forwarding vs a server
|
|
18
|
+
* keyed by `(cwd, envKeysHash)`. Reading the SDK settled it as a SPLIT — the
|
|
19
|
+
* two halves have different API realities (verified against
|
|
20
|
+
* `@opencode-ai/sdk` 1.2.20):
|
|
21
|
+
*
|
|
22
|
+
* - **cwd is PER-CALL.** `session.create` / `session.prompt` /
|
|
23
|
+
* `session.delete` all accept a `query.directory` parameter that scopes
|
|
24
|
+
* the session's working directory, so a single server can host sessions
|
|
25
|
+
* in any number of working directories. {@link RunAgentOptions.cwd} is
|
|
26
|
+
* forwarded as `query: { directory }` on every session call — no
|
|
27
|
+
* per-cwd server processes, no server-key explosion for worktree
|
|
28
|
+
* isolation (which mints a fresh directory per unit attempt).
|
|
29
|
+
*
|
|
30
|
+
* - **env is PER-SERVER (keyed registry).** The SDK exposes NO per-call or
|
|
31
|
+
* per-session env surface; the only way env reaches tool child processes
|
|
32
|
+
* is the `opencode serve` process environment. {@link getOrStartServer}
|
|
33
|
+
* snapshots and materializes the complete child environment for each
|
|
34
|
+
* dispatch, passes it directly to the managed spawn (never mutating
|
|
35
|
+
* `process.env`), and hashes that exact canonical environment for registry
|
|
36
|
+
* identity. Identical child material shares one server; any material
|
|
37
|
+
* difference receives a separate server.
|
|
38
|
+
*
|
|
39
|
+
* This is what removed the workflow engine's `env_unsupported` hard-fail for
|
|
40
|
+
* the sdk runner: injection genuinely reaches the child, because tool
|
|
41
|
+
* subprocesses (bash etc.) inherit the server process environment.
|
|
42
|
+
*
|
|
43
|
+
* Registry hygiene (peer-review fixes):
|
|
44
|
+
*
|
|
45
|
+
* - **Ports.** `createOpencodeServer` binds a FIXED default port (4096),
|
|
46
|
+
* so coexisting registry entries would contend for the same bind. Every
|
|
47
|
+
* entry is started on its own reserved OS-assigned port (see
|
|
48
|
+
* {@link startServer}).
|
|
49
|
+
* - **Shutdown.** {@link closeServer} closes resolved servers
|
|
50
|
+
* SYNCHRONOUSLY — it runs from `process.once('exit')`, where Bun never
|
|
51
|
+
* drains microtasks, so a `.then()`-based close would orphan every
|
|
52
|
+
* `opencode serve` child.
|
|
53
|
+
*
|
|
54
|
+
* Process-lifecycle note (owner finding 4): a cached `opencode serve` child is
|
|
55
|
+
* a live OS handle that keeps Bun's event loop OPEN, so a one-shot CLI never
|
|
56
|
+
* becomes idle and `process.once('exit')` never fires — the exit hook alone
|
|
57
|
+
* cannot free a process the child is keeping alive (a deadlock that hangs the
|
|
58
|
+
* caller after an otherwise-successful run). The registry is therefore drained
|
|
59
|
+
* PROACTIVELY at the end of a dispatching command: the workflow engine calls
|
|
60
|
+
* `disposeDispatchResources()` (→ {@link closeServer}) in composition-root and
|
|
61
|
+
* workflow `finally` blocks.
|
|
62
|
+
* The `process.once('exit')` hook stays as the last-resort backstop for paths
|
|
63
|
+
* that never reach that drain.
|
|
64
|
+
*
|
|
65
|
+
* ## Managed server spawn (owner finding 4, live-harness follow-up)
|
|
66
|
+
*
|
|
67
|
+
* Draining the registry is necessary but NOT sufficient with the SDK's own
|
|
68
|
+
* `createOpencodeServer`: its `close()` merely sends SIGTERM and it never
|
|
69
|
+
* `unref()`s the child or its stdio pipes, so akm's event loop stays pinned
|
|
70
|
+
* until the child ACTUALLY exits — and a real `opencode serve` (a live HTTP
|
|
71
|
+
* server with provider children) can outlive SIGTERM long enough to hang the
|
|
72
|
+
* caller indefinitely. {@link createManagedOpencode} therefore owns the spawn
|
|
73
|
+
* (the SDK package is used only for `createOpencodeClient`):
|
|
74
|
+
*
|
|
75
|
+
* - after the URL handshake, the child and its stdio are `unref()`ed /
|
|
76
|
+
* destroyed, so the handle can never hold akm open;
|
|
77
|
+
* - `close()` sends SIGTERM and arms a bounded grace timer
|
|
78
|
+
* ({@link SERVER_KILL_GRACE_MS}) that escalates to SIGKILL and is cleared
|
|
79
|
+
* on cooperative exit, so stubborn children cannot survive parent exit and
|
|
80
|
+
* stale timers cannot signal a reused PID;
|
|
81
|
+
* - the spawn receives the immutable per-dispatch environment directly, so
|
|
82
|
+
* asynchronous factory setup cannot race a temporary process-wide overlay.
|
|
14
83
|
*/
|
|
15
|
-
import {
|
|
84
|
+
import { spawn } from "node:child_process";
|
|
85
|
+
import { createHash } from "node:crypto";
|
|
16
86
|
import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js";
|
|
17
87
|
import { resolveModel } from "../../agent/model-aliases.js";
|
|
18
|
-
//
|
|
19
|
-
|
|
88
|
+
// Server registry — one server per complete server-material signature. Caller
|
|
89
|
+
// deadlines race the shared promise independently; they never become startup
|
|
90
|
+
// configuration inherited by later callers.
|
|
91
|
+
const _servers = new Map();
|
|
92
|
+
// Resolved servers by registry key, mirrored from `_servers` as each start
|
|
93
|
+
// promise settles. This exists so closeServer() can close started servers
|
|
94
|
+
// SYNCHRONOUSLY: it is wired to `process.once('exit')`, and Bun does not
|
|
95
|
+
// drain microtasks scheduled inside 'exit' handlers, so a `.then()`-based
|
|
96
|
+
// close never runs there and would orphan every `opencode serve` child.
|
|
97
|
+
const _resolvedServers = new Map();
|
|
98
|
+
// Listen ports handed to non-default registry entries (see startServer) —
|
|
99
|
+
// tracked so two coexisting servers in this process can never be assigned
|
|
100
|
+
// the same port.
|
|
101
|
+
const _serverPorts = new Map();
|
|
102
|
+
// Serializes probe-and-reserve so concurrent starts cannot claim one port.
|
|
103
|
+
let _portAllocationTail = Promise.resolve();
|
|
104
|
+
/** The port `createOpencodeServer` binds when none is passed (SDK 1.2.20). */
|
|
105
|
+
const DEFAULT_SDK_PORT = 4096;
|
|
106
|
+
// Test override: when set, every call uses this server (all keys) and no real
|
|
107
|
+
// server is ever started.
|
|
108
|
+
let _testServer = null;
|
|
109
|
+
// Test seam replacing the real `createOpencode` import (see __setServerFactory).
|
|
110
|
+
let _serverFactory = null;
|
|
111
|
+
let _exitHookInstalled = false;
|
|
20
112
|
/**
|
|
21
113
|
* Test-only seam: inject a fake {@link SdkServer} so `runOpencodeSdk` can be
|
|
22
114
|
* exercised without the real `@opencode-ai/sdk` (which would spin up a server).
|
|
@@ -25,20 +117,59 @@ let _server = null;
|
|
|
25
117
|
* leading underscores mark it as internal.
|
|
26
118
|
*/
|
|
27
119
|
export function __setTestServer(server) {
|
|
28
|
-
|
|
120
|
+
_testServer = server;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Test-only seam: replace the `createOpencode` factory so the env-keyed
|
|
124
|
+
* server registry (module doc, *Per-call cwd and env*) can be exercised
|
|
125
|
+
* without the real SDK. The complete child environment is supplied as
|
|
126
|
+
* `options.env`, so async factories observe the same immutable values as the
|
|
127
|
+
* production spawn. Pass `null` to clear.
|
|
128
|
+
*/
|
|
129
|
+
export function __setServerFactory(factory) {
|
|
130
|
+
_serverFactory = factory;
|
|
29
131
|
}
|
|
30
132
|
/**
|
|
31
|
-
* Close
|
|
32
|
-
*
|
|
133
|
+
* Close every started OpenCode SDK server and reset the registry (and any
|
|
134
|
+
* injected test server). Used by tests for clean teardown between runs and
|
|
135
|
+
* wired to `process.once('exit')` — which is why resolved servers MUST be
|
|
136
|
+
* closed synchronously here: Bun never drains microtasks scheduled inside
|
|
137
|
+
* 'exit' handlers, so a promise-based close would silently orphan the
|
|
138
|
+
* `opencode serve` children (leaking processes AND keeping their ports
|
|
139
|
+
* bound for the next invocation).
|
|
33
140
|
*/
|
|
34
|
-
export function closeServer() {
|
|
141
|
+
export async function closeServer() {
|
|
142
|
+
const closes = [];
|
|
143
|
+
for (const [key, entry] of _servers) {
|
|
144
|
+
const resolved = _resolvedServers.get(key);
|
|
145
|
+
if (resolved) {
|
|
146
|
+
// Synchronous close — safe from the 'exit' hook.
|
|
147
|
+
try {
|
|
148
|
+
closes.push(Promise.resolve(resolved.server.close()));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* ignore */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Still starting: cancel the real managed spawn immediately. A custom
|
|
156
|
+
// factory may ignore the signal; the registry settlement handler closes
|
|
157
|
+
// any late result without awaiting it and pinning shutdown indefinitely.
|
|
158
|
+
entry.controller.abort();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
_servers.clear();
|
|
162
|
+
_resolvedServers.clear();
|
|
163
|
+
_serverPorts.clear();
|
|
35
164
|
try {
|
|
36
|
-
|
|
165
|
+
if (_testServer)
|
|
166
|
+
closes.push(Promise.resolve(_testServer.server.close()));
|
|
37
167
|
}
|
|
38
168
|
catch {
|
|
39
169
|
/* ignore */
|
|
40
170
|
}
|
|
41
|
-
|
|
171
|
+
_testServer = null;
|
|
172
|
+
await Promise.allSettled(closes);
|
|
42
173
|
}
|
|
43
174
|
/**
|
|
44
175
|
* Convert an `AgentDispatchRequest.tools` policy into the SDK's tool-allowlist
|
|
@@ -97,12 +228,14 @@ function toolsToSdkAllowlist(tools) {
|
|
|
97
228
|
* and config-root alias tables apply here.
|
|
98
229
|
*/
|
|
99
230
|
export function buildSdkConfig(profile, llmConfig) {
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
231
|
+
const endpoint = llmConfig?.endpoint;
|
|
232
|
+
const apiKey = llmConfig?.apiKey;
|
|
233
|
+
const profileModel = profile.model
|
|
234
|
+
? profile.modelIsExact
|
|
235
|
+
? profile.model
|
|
236
|
+
: resolveModel(profile.model, "opencode-sdk", profile.modelAliases, profile.globalModelAliases)
|
|
105
237
|
: undefined;
|
|
238
|
+
const model = profileModel ?? llmConfig?.model;
|
|
106
239
|
const sdkConfig = {};
|
|
107
240
|
if (model)
|
|
108
241
|
sdkConfig.model = model;
|
|
@@ -112,32 +245,335 @@ export function buildSdkConfig(profile, llmConfig) {
|
|
|
112
245
|
"akm-custom": {
|
|
113
246
|
npm: "@ai-sdk/openai-compatible",
|
|
114
247
|
options: {
|
|
115
|
-
baseURL: endpoint
|
|
248
|
+
baseURL: canonicalProviderBase(endpoint) ?? undefined,
|
|
116
249
|
...(apiKey ? { apiKey } : {}),
|
|
117
250
|
},
|
|
118
251
|
},
|
|
119
252
|
};
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
253
|
+
// The first path segment selects the OpenCode provider. Model IDs may
|
|
254
|
+
// themselves contain slashes, but still belong to this custom endpoint.
|
|
255
|
+
if (model)
|
|
256
|
+
sdkConfig.model = model.startsWith("akm-custom/") ? model : `akm-custom/${model}`;
|
|
124
257
|
}
|
|
125
258
|
return sdkConfig;
|
|
126
259
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
260
|
+
/** Digest the executable and exact environment received by the child. */
|
|
261
|
+
function serverRegistryKey(profile, env) {
|
|
262
|
+
const material = { bin: profile.bin, env };
|
|
263
|
+
return createHash("sha256")
|
|
264
|
+
.update(JSON.stringify(canonicalize(material)))
|
|
265
|
+
.digest("hex");
|
|
266
|
+
}
|
|
267
|
+
function buildServerEnv(profile, config, bindings, envSource) {
|
|
268
|
+
const env = {};
|
|
269
|
+
const inheritedNames = new Set([
|
|
270
|
+
"HOME",
|
|
271
|
+
"PATH",
|
|
272
|
+
"USER",
|
|
273
|
+
"LANG",
|
|
274
|
+
"LC_ALL",
|
|
275
|
+
"TERM",
|
|
276
|
+
"TMPDIR",
|
|
277
|
+
"SYSTEMROOT",
|
|
278
|
+
"COMSPEC",
|
|
279
|
+
"PATHEXT",
|
|
280
|
+
"WINDIR",
|
|
281
|
+
"TEMP",
|
|
282
|
+
"TMP",
|
|
283
|
+
...(profile.envPassthrough ?? []),
|
|
284
|
+
]);
|
|
285
|
+
for (const key of inheritedNames) {
|
|
286
|
+
const value = envSource[key];
|
|
287
|
+
if (value !== undefined)
|
|
288
|
+
env[key] = value;
|
|
289
|
+
}
|
|
290
|
+
for (const [key, value] of Object.entries(bindings ?? {}))
|
|
291
|
+
env[key] = value;
|
|
292
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config);
|
|
293
|
+
return env;
|
|
294
|
+
}
|
|
295
|
+
function canonicalProviderBase(endpoint) {
|
|
296
|
+
if (!endpoint)
|
|
297
|
+
return null;
|
|
298
|
+
try {
|
|
299
|
+
const url = new URL(endpoint);
|
|
300
|
+
url.pathname = url.pathname.replace(/\/chat\/completions$/, "").replace(/\/$/, "");
|
|
301
|
+
return url.toString().replace(/\/$/, "");
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return endpoint.replace(/\/chat\/completions$/, "").replace(/\/$/, "");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function canonicalize(value) {
|
|
308
|
+
if (Array.isArray(value))
|
|
309
|
+
return value.map(canonicalize);
|
|
310
|
+
if (value && typeof value === "object") {
|
|
311
|
+
return Object.fromEntries(Object.entries(value)
|
|
312
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
313
|
+
.map(([key, child]) => [key, canonicalize(child)]));
|
|
314
|
+
}
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Ask the OS for a currently-free localhost port (bind :0, read the assigned
|
|
319
|
+
* port, release it). Skips the SDK's fixed default port and any port already
|
|
320
|
+
* handed to another registry entry in this process, so coexisting servers
|
|
321
|
+
* never contend. The probe-then-use gap is the standard free-port race —
|
|
322
|
+
* acceptable here because the failure mode is a clean `spawn_failed` on the
|
|
323
|
+
* next dispatch, not corruption.
|
|
324
|
+
*/
|
|
325
|
+
async function allocateFreePort(registryKey) {
|
|
326
|
+
let release;
|
|
327
|
+
const previous = _portAllocationTail;
|
|
328
|
+
_portAllocationTail = new Promise((resolve) => {
|
|
329
|
+
release = resolve;
|
|
330
|
+
});
|
|
331
|
+
await previous;
|
|
332
|
+
try {
|
|
333
|
+
const { createServer } = await import("node:net");
|
|
334
|
+
const taken = new Set(_serverPorts.values());
|
|
335
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
336
|
+
const port = await new Promise((resolve, reject) => {
|
|
337
|
+
const probe = createServer();
|
|
338
|
+
probe.unref();
|
|
339
|
+
probe.on("error", reject);
|
|
340
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
341
|
+
const address = probe.address();
|
|
342
|
+
probe.close(() => {
|
|
343
|
+
if (address && typeof address === "object")
|
|
344
|
+
resolve(address.port);
|
|
345
|
+
else
|
|
346
|
+
reject(new Error("could not read the probe socket's port"));
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
if (port !== DEFAULT_SDK_PORT && !taken.has(port)) {
|
|
351
|
+
_serverPorts.set(registryKey, port);
|
|
352
|
+
return port;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
throw new Error("could not allocate a free port for the OpenCode SDK server");
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
release();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Grace between SIGTERM and SIGKILL when closing a managed server child. */
|
|
362
|
+
const SERVER_KILL_GRACE_MS = 2_000;
|
|
363
|
+
// Test seam: override the argv used to spawn the server child ("opencode"
|
|
364
|
+
// plus serve flags by default) so the managed-spawn lifecycle (handshake,
|
|
365
|
+
// unref, SIGTERM→SIGKILL escalation) is testable without the real binary.
|
|
366
|
+
let _serveCommand = null;
|
|
367
|
+
/** Test-only seam: replace the `opencode serve` argv. Pass `null` to clear. */
|
|
368
|
+
export function __setServeCommand(argv) {
|
|
369
|
+
_serveCommand = argv;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Spawn-owning replacement for the SDK's `createOpencode` (module doc,
|
|
373
|
+
* *Managed server spawn*). Mirrors `createOpencodeServer`'s contract — the
|
|
374
|
+
* `spawn` receives the factory's immutable environment directly,
|
|
375
|
+
* `OPENCODE_CONFIG_CONTENT` carries the config, the handshake parses the
|
|
376
|
+
* "opencode server listening on <url>" line — but manages the child so its
|
|
377
|
+
* handle can never pin akm's event loop:
|
|
378
|
+
*
|
|
379
|
+
* - handshake success → stdio destroyed, listeners dropped, `proc.unref()`;
|
|
380
|
+
* - `close()` → SIGTERM now, SIGKILL after an unref'ed grace timer;
|
|
381
|
+
* - handshake failure → the child is killed and unref'ed before rejecting.
|
|
382
|
+
*/
|
|
383
|
+
async function createManagedOpencode(options) {
|
|
384
|
+
const { createOpencodeClient } = (await import("@opencode-ai/sdk").catch(() => {
|
|
131
385
|
throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
|
|
386
|
+
}));
|
|
387
|
+
const port = options.port ?? DEFAULT_SDK_PORT;
|
|
388
|
+
const argv = _serveCommand ?? [options.bin ?? "opencode", "serve", "--hostname=127.0.0.1", `--port=${port}`];
|
|
389
|
+
const proc = spawn(argv[0], argv.slice(1), {
|
|
390
|
+
env: options.env,
|
|
391
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
132
392
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
393
|
+
let closeStarted = false;
|
|
394
|
+
let closeEscalation;
|
|
395
|
+
const childExited = () => proc.exitCode !== null || proc.signalCode !== null;
|
|
396
|
+
const clearCloseEscalation = () => {
|
|
397
|
+
if (closeEscalation !== undefined) {
|
|
398
|
+
clearTimeout(closeEscalation);
|
|
399
|
+
closeEscalation = undefined;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const closeManaged = () => {
|
|
403
|
+
if (closeStarted)
|
|
404
|
+
return;
|
|
405
|
+
closeStarted = true;
|
|
406
|
+
if (childExited())
|
|
407
|
+
return;
|
|
408
|
+
proc.once("exit", clearCloseEscalation);
|
|
409
|
+
try {
|
|
410
|
+
proc.kill("SIGTERM");
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
proc.off("exit", clearCloseEscalation);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (childExited())
|
|
417
|
+
return;
|
|
418
|
+
closeEscalation = setTimeout(() => {
|
|
419
|
+
closeEscalation = undefined;
|
|
420
|
+
if (childExited())
|
|
421
|
+
return;
|
|
422
|
+
try {
|
|
423
|
+
proc.kill("SIGKILL");
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* already dead */
|
|
427
|
+
}
|
|
428
|
+
}, SERVER_KILL_GRACE_MS);
|
|
429
|
+
};
|
|
430
|
+
const url = await new Promise((resolve, reject) => {
|
|
431
|
+
let output = "";
|
|
432
|
+
let settled = false;
|
|
433
|
+
const cleanupStartup = () => {
|
|
434
|
+
proc.stdout?.off("data", onStdoutData);
|
|
435
|
+
proc.stderr?.off("data", onStderrData);
|
|
436
|
+
proc.off("exit", onExit);
|
|
437
|
+
proc.off("error", onError);
|
|
438
|
+
options.startupSignal.removeEventListener("abort", onAbort);
|
|
439
|
+
};
|
|
440
|
+
const fail = (err) => {
|
|
441
|
+
if (settled)
|
|
442
|
+
return;
|
|
443
|
+
settled = true;
|
|
444
|
+
cleanupStartup();
|
|
445
|
+
closeManaged();
|
|
446
|
+
proc.stdout?.destroy();
|
|
447
|
+
proc.stderr?.destroy();
|
|
448
|
+
proc.unref();
|
|
449
|
+
reject(err);
|
|
450
|
+
};
|
|
451
|
+
const succeed = (serverUrl) => {
|
|
452
|
+
if (settled)
|
|
453
|
+
return;
|
|
454
|
+
settled = true;
|
|
455
|
+
cleanupStartup();
|
|
456
|
+
resolve(serverUrl);
|
|
457
|
+
};
|
|
458
|
+
const onStdoutData = (chunk) => {
|
|
459
|
+
output += chunk.toString();
|
|
460
|
+
for (const line of output.split("\n")) {
|
|
461
|
+
if (line.startsWith("opencode server listening")) {
|
|
462
|
+
const match = line.match(/on\s+(https?:\/\/\S+)/);
|
|
463
|
+
if (!match?.[1]) {
|
|
464
|
+
fail(new Error(`Failed to parse the OpenCode server url from: ${line}`));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
succeed(match[1]);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const onStderrData = (chunk) => {
|
|
473
|
+
output += chunk.toString();
|
|
474
|
+
};
|
|
475
|
+
const onExit = (code, signal) => {
|
|
476
|
+
const status = code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
|
|
477
|
+
fail(new Error(`OpenCode server exited with ${status}${output.trim() ? `\nServer output: ${output}` : ""}`));
|
|
478
|
+
};
|
|
479
|
+
const onError = (err) => {
|
|
480
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
481
|
+
};
|
|
482
|
+
const onAbort = () => fail(new Error("OpenCode server startup cancelled because no callers are waiting"));
|
|
483
|
+
proc.stdout?.on("data", onStdoutData);
|
|
484
|
+
proc.stderr?.on("data", onStderrData);
|
|
485
|
+
proc.on("exit", onExit);
|
|
486
|
+
proc.on("error", onError);
|
|
487
|
+
if (options.startupSignal.aborted)
|
|
488
|
+
onAbort();
|
|
489
|
+
else
|
|
490
|
+
options.startupSignal.addEventListener("abort", onAbort, { once: true });
|
|
137
491
|
});
|
|
138
|
-
|
|
492
|
+
// Handshake done: from here on the child must never hold akm open. Its
|
|
493
|
+
// lifetime is managed explicitly (closeServer → closeManaged), not by the
|
|
494
|
+
// event loop. Destroying the pipes also releases their loop handles.
|
|
495
|
+
proc.stdout?.destroy();
|
|
496
|
+
proc.stderr?.destroy();
|
|
497
|
+
proc.unref();
|
|
498
|
+
return { client: createOpencodeClient({ baseUrl: url }), server: { close: closeManaged } };
|
|
499
|
+
}
|
|
500
|
+
async function startServer(profile, sdkConfig, env, registryKey, startupSignal) {
|
|
501
|
+
const factory = _serverFactory ?? createManagedOpencode;
|
|
502
|
+
const options = {
|
|
503
|
+
bin: profile.bin,
|
|
504
|
+
...(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}),
|
|
505
|
+
env,
|
|
506
|
+
startupSignal,
|
|
507
|
+
};
|
|
508
|
+
// Every cached server receives a separately reserved port. This avoids both
|
|
509
|
+
// inter-entry contention and collisions with an unrelated process on 4096.
|
|
510
|
+
options.port = await allocateFreePort(registryKey);
|
|
511
|
+
if (startupSignal.aborted)
|
|
512
|
+
throw new Error("OpenCode server startup cancelled because no callers are waiting");
|
|
513
|
+
const server = await factory(options);
|
|
514
|
+
if (!server)
|
|
139
515
|
throw new Error("Failed to initialise OpenCode SDK server.");
|
|
140
|
-
|
|
516
|
+
if (!_exitHookInstalled) {
|
|
517
|
+
_exitHookInstalled = true;
|
|
518
|
+
process.once("exit", () => {
|
|
519
|
+
void closeServer();
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return server;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Get (or lazily start) the server for this call's complete server material.
|
|
526
|
+
* Concurrent callers of the same key share one
|
|
527
|
+
* start (the registry stores the in-flight promise). A failed start is
|
|
528
|
+
* evicted so the next call can retry instead of caching the error forever.
|
|
529
|
+
*/
|
|
530
|
+
function getOrStartServer(profile, llmConfig, env, envSource = process.env) {
|
|
531
|
+
if (_testServer)
|
|
532
|
+
return { promise: Promise.resolve(_testServer), release() { } };
|
|
533
|
+
const sdkConfig = buildSdkConfig(profile, llmConfig);
|
|
534
|
+
const serverEnv = buildServerEnv(profile, sdkConfig, env, envSource);
|
|
535
|
+
const key = serverRegistryKey(profile, serverEnv);
|
|
536
|
+
let entry = _servers.get(key);
|
|
537
|
+
if (!entry) {
|
|
538
|
+
const controller = new AbortController();
|
|
539
|
+
entry = {
|
|
540
|
+
promise: startServer(profile, sdkConfig, serverEnv, key, controller.signal),
|
|
541
|
+
controller,
|
|
542
|
+
waiters: 0,
|
|
543
|
+
};
|
|
544
|
+
const started = entry;
|
|
545
|
+
_servers.set(key, started);
|
|
546
|
+
started.promise.then((server) => {
|
|
547
|
+
if (_servers.get(key) === started) {
|
|
548
|
+
started.server = server;
|
|
549
|
+
_resolvedServers.set(key, server);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
void server.server.close();
|
|
553
|
+
}
|
|
554
|
+
}, () => {
|
|
555
|
+
if (_servers.get(key) === started) {
|
|
556
|
+
_servers.delete(key);
|
|
557
|
+
_serverPorts.delete(key);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
entry.waiters++;
|
|
562
|
+
let released = false;
|
|
563
|
+
return {
|
|
564
|
+
promise: entry.promise,
|
|
565
|
+
release() {
|
|
566
|
+
if (released)
|
|
567
|
+
return;
|
|
568
|
+
released = true;
|
|
569
|
+
entry.waiters--;
|
|
570
|
+
if (entry.waiters === 0 && !entry.server && _servers.get(key) === entry) {
|
|
571
|
+
_servers.delete(key);
|
|
572
|
+
_serverPorts.delete(key);
|
|
573
|
+
entry.controller.abort();
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
};
|
|
141
577
|
}
|
|
142
578
|
/**
|
|
143
579
|
* Extract best-effort token usage from a prompt response. Only numeric
|
|
@@ -158,8 +594,77 @@ function extractUsage(info) {
|
|
|
158
594
|
}
|
|
159
595
|
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
160
596
|
}
|
|
597
|
+
const SDK_OPERATION_TIMED_OUT = Symbol("opencode-sdk-operation-timeout");
|
|
598
|
+
const SDK_OPERATION_ABORTED = Symbol("opencode-sdk-operation-aborted");
|
|
599
|
+
const SDK_SESSION_DELETE_TIMEOUT_MS = 5_000;
|
|
600
|
+
async function raceSdkOperation(operation, opts) {
|
|
601
|
+
let timer;
|
|
602
|
+
let onAbort;
|
|
603
|
+
let raceFinished = false;
|
|
604
|
+
const racers = [operation];
|
|
605
|
+
void operation.then((value) => {
|
|
606
|
+
if (raceFinished && opts.onLateSettle)
|
|
607
|
+
void Promise.resolve(opts.onLateSettle({ status: "fulfilled", value })).catch(() => { });
|
|
608
|
+
}, (reason) => {
|
|
609
|
+
if (raceFinished && opts.onLateSettle)
|
|
610
|
+
void Promise.resolve(opts.onLateSettle({ status: "rejected", reason })).catch(() => { });
|
|
611
|
+
});
|
|
612
|
+
if (opts.timeoutMs !== null) {
|
|
613
|
+
racers.push(new Promise((resolve) => {
|
|
614
|
+
timer = opts.setTimeoutFn(() => resolve(SDK_OPERATION_TIMED_OUT), opts.timeoutMs ?? 0);
|
|
615
|
+
if (typeof timer !== "number")
|
|
616
|
+
timer.unref?.();
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
if (opts.signal) {
|
|
620
|
+
racers.push(new Promise((resolve) => {
|
|
621
|
+
onAbort = () => resolve(SDK_OPERATION_ABORTED);
|
|
622
|
+
if (opts.signal?.aborted)
|
|
623
|
+
onAbort();
|
|
624
|
+
else
|
|
625
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
return racers.length === 1 ? await operation : await Promise.race(racers);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
raceFinished = true;
|
|
633
|
+
if (timer !== undefined)
|
|
634
|
+
opts.clearTimeoutFn(timer);
|
|
635
|
+
if (opts.signal && onAbort)
|
|
636
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function errorText(err) {
|
|
640
|
+
return err instanceof Error ? err.message : String(err);
|
|
641
|
+
}
|
|
642
|
+
function appendStderr(stderr, message) {
|
|
643
|
+
return stderr ? `${stderr}\n${message}` : message;
|
|
644
|
+
}
|
|
645
|
+
async function deleteSessionBestEffort(client, sessionId, query, setTimeoutFn, clearTimeoutFn) {
|
|
646
|
+
try {
|
|
647
|
+
const deleted = await raceSdkOperation(client.session.delete({ path: { id: sessionId }, ...(query ? { query } : {}) }), {
|
|
648
|
+
timeoutMs: SDK_SESSION_DELETE_TIMEOUT_MS,
|
|
649
|
+
setTimeoutFn,
|
|
650
|
+
clearTimeoutFn,
|
|
651
|
+
});
|
|
652
|
+
if (deleted === SDK_OPERATION_TIMED_OUT) {
|
|
653
|
+
return `OpenCode session cleanup timed out after ${SDK_SESSION_DELETE_TIMEOUT_MS}ms`;
|
|
654
|
+
}
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
return `OpenCode session cleanup failed: ${errorText(err)}`;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
161
661
|
export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
|
|
162
662
|
const start = Date.now();
|
|
663
|
+
const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
|
|
664
|
+
const deadline = timeoutMs === null ? null : start + timeoutMs;
|
|
665
|
+
const remainingTimeoutMs = () => (deadline === null ? null : Math.max(0, deadline - Date.now()));
|
|
666
|
+
const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
|
|
667
|
+
const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
|
|
163
668
|
if (opts.signal?.aborted) {
|
|
164
669
|
return {
|
|
165
670
|
ok: false,
|
|
@@ -172,23 +677,119 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
|
|
|
172
677
|
};
|
|
173
678
|
}
|
|
174
679
|
let client;
|
|
680
|
+
if (_testServer) {
|
|
681
|
+
client = _testServer.client;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
const startupHandle = getOrStartServer(profile, llmConfig, opts.env, opts.envSource);
|
|
685
|
+
try {
|
|
686
|
+
const startup = await raceSdkOperation(startupHandle.promise, {
|
|
687
|
+
timeoutMs: remainingTimeoutMs(),
|
|
688
|
+
setTimeoutFn: setTimeoutImpl,
|
|
689
|
+
clearTimeoutFn: clearTimeoutImpl,
|
|
690
|
+
signal: opts.signal,
|
|
691
|
+
});
|
|
692
|
+
if (startup === SDK_OPERATION_ABORTED) {
|
|
693
|
+
return {
|
|
694
|
+
ok: false,
|
|
695
|
+
stdout: "",
|
|
696
|
+
stderr: "",
|
|
697
|
+
durationMs: Date.now() - start,
|
|
698
|
+
exitCode: null,
|
|
699
|
+
reason: "aborted",
|
|
700
|
+
error: `opencode-sdk agent "${profile.name}" aborted by caller signal during server startup`,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
if (startup === SDK_OPERATION_TIMED_OUT) {
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
stdout: "",
|
|
707
|
+
stderr: "",
|
|
708
|
+
durationMs: Date.now() - start,
|
|
709
|
+
exitCode: null,
|
|
710
|
+
reason: "timeout",
|
|
711
|
+
error: `opencode-sdk agent "${profile.name}" timed out during server startup after ${timeoutMs}ms`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
client = startup.client;
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
stdout: "",
|
|
720
|
+
stderr: String(e),
|
|
721
|
+
durationMs: Date.now() - start,
|
|
722
|
+
exitCode: 1,
|
|
723
|
+
reason: "spawn_failed",
|
|
724
|
+
error: String(e),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
startupHandle.release();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// #564 bug fix (3): enforce a hard timeout like the CLI path (runAgent).
|
|
732
|
+
// Previously runOpencodeSdk() awaited SDK calls with no timeout, so a stalled
|
|
733
|
+
// local-model endpoint or wedged server could block the caller indefinitely.
|
|
734
|
+
// The same absolute deadline covers server startup, session creation, and
|
|
735
|
+
// prompting. null disables every dispatch timer. Session cleanup remains a
|
|
736
|
+
// separately bounded best-effort operation.
|
|
737
|
+
// Per-call working directory (module doc): forwarded as the SDK's
|
|
738
|
+
// `query.directory` on every session call, so worktree-isolated units run
|
|
739
|
+
// in their own checkout without a per-cwd server.
|
|
740
|
+
const query = opts.cwd ? { directory: opts.cwd } : undefined;
|
|
741
|
+
// One session per call — do NOT reuse (history accumulates, token costs grow).
|
|
742
|
+
// Session creation is startup plumbing, so failures map to spawn_failed rather
|
|
743
|
+
// than bubbling out as a generic workflow dispatch exception.
|
|
744
|
+
const abortSignal = opts.signal;
|
|
745
|
+
let sessionId;
|
|
175
746
|
try {
|
|
176
|
-
({
|
|
747
|
+
const created = await raceSdkOperation(client.session.create({ body: { title: "akm" }, ...(query ? { query } : {}) }), {
|
|
748
|
+
timeoutMs: remainingTimeoutMs(),
|
|
749
|
+
setTimeoutFn: setTimeoutImpl,
|
|
750
|
+
clearTimeoutFn: clearTimeoutImpl,
|
|
751
|
+
signal: abortSignal,
|
|
752
|
+
onLateSettle: (late) => {
|
|
753
|
+
if (late.status === "fulfilled" && late.value.data?.id) {
|
|
754
|
+
void deleteSessionBestEffort(client, late.value.data.id, query, setTimeoutImpl, clearTimeoutImpl);
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
if (created === SDK_OPERATION_ABORTED) {
|
|
759
|
+
return {
|
|
760
|
+
ok: false,
|
|
761
|
+
stdout: "",
|
|
762
|
+
stderr: "",
|
|
763
|
+
durationMs: Date.now() - start,
|
|
764
|
+
exitCode: null,
|
|
765
|
+
reason: "aborted",
|
|
766
|
+
error: `opencode-sdk agent "${profile.name}" aborted by caller signal`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (created === SDK_OPERATION_TIMED_OUT) {
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
stdout: "",
|
|
773
|
+
stderr: "",
|
|
774
|
+
durationMs: Date.now() - start,
|
|
775
|
+
exitCode: null,
|
|
776
|
+
reason: "timeout",
|
|
777
|
+
error: `opencode-sdk agent "${profile.name}" timed out creating a session after ${timeoutMs}ms`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
sessionId = created.data?.id;
|
|
177
781
|
}
|
|
178
|
-
catch (
|
|
782
|
+
catch (err) {
|
|
179
783
|
return {
|
|
180
784
|
ok: false,
|
|
181
785
|
stdout: "",
|
|
182
|
-
stderr:
|
|
786
|
+
stderr: errorText(err),
|
|
183
787
|
durationMs: Date.now() - start,
|
|
184
788
|
exitCode: 1,
|
|
185
789
|
reason: "spawn_failed",
|
|
186
|
-
error:
|
|
790
|
+
error: errorText(err),
|
|
187
791
|
};
|
|
188
792
|
}
|
|
189
|
-
// One session per call — do NOT reuse (history accumulates, token costs grow)
|
|
190
|
-
const sessionRes = await client.session.create({ body: { title: "akm" } });
|
|
191
|
-
const sessionId = sessionRes.data?.id;
|
|
192
793
|
if (!sessionId) {
|
|
193
794
|
return {
|
|
194
795
|
ok: false,
|
|
@@ -212,47 +813,19 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
|
|
|
212
813
|
body.system = system;
|
|
213
814
|
if (tools)
|
|
214
815
|
body.tools = tools;
|
|
215
|
-
|
|
216
|
-
// Previously runOpencodeSdk() awaited session.prompt() with no timeout, so a
|
|
217
|
-
// hung SDK call (e.g. a stalled local-model endpoint) blocked the caller
|
|
218
|
-
// indefinitely while the CLI path would have killed the process. We resolve
|
|
219
|
-
// the same budget runAgent uses (opts.timeoutMs override → profile.timeoutMs
|
|
220
|
-
// → DEFAULT_AGENT_TIMEOUT_MS) and race the prompt against it. null disables
|
|
221
|
-
// the timer (parity with runAgent's "no timeout" contract). There is no
|
|
222
|
-
// OS process to SIGTERM/SIGKILL here, so on timeout we best-effort delete the
|
|
223
|
-
// session (the SDK's equivalent of reaping the in-flight work) and return a
|
|
224
|
-
// structured `timeout` failure with the same reason vocabulary as the CLI.
|
|
225
|
-
const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : (profile.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS);
|
|
226
|
-
const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
|
|
227
|
-
const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
|
|
228
|
-
let timer;
|
|
229
|
-
const TIMED_OUT = Symbol("opencode-sdk-timeout");
|
|
230
|
-
const ABORTED = Symbol("opencode-sdk-aborted");
|
|
231
|
-
// Cooperative cancel: there is no OS process to signal, so an abort simply
|
|
232
|
-
// wins the race below; the finally block reaps the in-flight session, same
|
|
233
|
-
// as the timeout path.
|
|
234
|
-
let onAbort;
|
|
235
|
-
const abortSignal = opts.signal;
|
|
816
|
+
let result;
|
|
236
817
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
onAbort();
|
|
249
|
-
else
|
|
250
|
-
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
251
|
-
}));
|
|
252
|
-
}
|
|
253
|
-
const result = racers.length === 1 ? await promptPromise : await Promise.race(racers);
|
|
254
|
-
if (result === ABORTED) {
|
|
255
|
-
return {
|
|
818
|
+
const prompted = await raceSdkOperation(client.session.prompt({ path: { id: sessionId }, body, ...(query ? { query } : {}) }), {
|
|
819
|
+
timeoutMs: remainingTimeoutMs(),
|
|
820
|
+
setTimeoutFn: setTimeoutImpl,
|
|
821
|
+
clearTimeoutFn: clearTimeoutImpl,
|
|
822
|
+
signal: abortSignal,
|
|
823
|
+
onLateSettle: () => {
|
|
824
|
+
void deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl);
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
if (prompted === SDK_OPERATION_ABORTED) {
|
|
828
|
+
result = {
|
|
256
829
|
ok: false,
|
|
257
830
|
stdout: "",
|
|
258
831
|
stderr: "",
|
|
@@ -263,8 +836,8 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
|
|
|
263
836
|
sessionId,
|
|
264
837
|
};
|
|
265
838
|
}
|
|
266
|
-
if (
|
|
267
|
-
|
|
839
|
+
else if (prompted === SDK_OPERATION_TIMED_OUT) {
|
|
840
|
+
result = {
|
|
268
841
|
ok: false,
|
|
269
842
|
stdout: "",
|
|
270
843
|
stderr: "",
|
|
@@ -275,41 +848,41 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
|
|
|
275
848
|
sessionId,
|
|
276
849
|
};
|
|
277
850
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
851
|
+
else {
|
|
852
|
+
const parts = prompted.data?.parts ?? [];
|
|
853
|
+
const textPart = parts.find((p) => p.type === "text");
|
|
854
|
+
const stdout = textPart?.text ?? "";
|
|
855
|
+
// Token accounting from the AssistantMessage (previously discarded) —
|
|
856
|
+
// the seam that makes workflow budget.maxTokens meterable on the
|
|
857
|
+
// default sdk runner.
|
|
858
|
+
const usage = extractUsage(prompted.data?.info);
|
|
859
|
+
result = {
|
|
860
|
+
ok: true,
|
|
861
|
+
stdout,
|
|
862
|
+
stderr: "",
|
|
863
|
+
durationMs: Date.now() - start,
|
|
864
|
+
exitCode: 0,
|
|
865
|
+
sessionId,
|
|
866
|
+
...(usage ? { usage } : {}),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
294
869
|
}
|
|
295
|
-
catch (
|
|
296
|
-
|
|
870
|
+
catch (err) {
|
|
871
|
+
result = {
|
|
297
872
|
ok: false,
|
|
298
873
|
stdout: "",
|
|
299
|
-
stderr:
|
|
874
|
+
stderr: errorText(err),
|
|
300
875
|
durationMs: Date.now() - start,
|
|
301
876
|
exitCode: 1,
|
|
302
877
|
reason: "non_zero_exit",
|
|
303
|
-
error:
|
|
878
|
+
error: errorText(err),
|
|
304
879
|
sessionId,
|
|
305
880
|
};
|
|
306
881
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
await client.session.delete({ path: { id: sessionId } }).catch(() => { });
|
|
314
|
-
}
|
|
882
|
+
// Clean up session to prevent disk accumulation in ~/.local/share/opencode/.
|
|
883
|
+
// Failures are non-fatal to the agent result but must not be invisible.
|
|
884
|
+
const cleanupWarning = await deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl);
|
|
885
|
+
if (cleanupWarning)
|
|
886
|
+
result.stderr = appendStderr(result.stderr, cleanupWarning);
|
|
887
|
+
return result;
|
|
315
888
|
}
|