@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.134
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 +687 -0
- package/README.md +101 -1
- package/dist/build-info.json +10 -10
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +306 -0
- package/dist/cli/help.js +88 -12
- package/dist/cli/hook-ingress.js +78 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +80 -0
- package/dist/cli/run-cli.js +1465 -450
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +52 -0
- package/dist/cli/runtime-process.js +153 -0
- package/dist/cli.js +17 -2
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +190 -0
- package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +1128 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
- package/dist/harness-runtime/lib/model-observations.mjs +101 -0
- package/dist/harness-runtime/lib/native-children.d.mts +14 -0
- package/dist/harness-runtime/lib/native-children.mjs +94 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.d.mts +5 -0
- package/dist/harness-runtime/lib/process-json.mjs +201 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
- package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-verification.schema.json +7 -5
- package/dist/schemas/code-work-batch.schema.json +2 -2
- package/dist/schemas/flow-run.schema.json +2 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +118 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/branch-context.js +3 -3
- package/dist/services/canon.js +11 -8
- package/dist/services/check-runner-evidence.js +121 -0
- package/dist/services/cleanup.js +133 -40
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +851 -109
- package/dist/services/codex-capacity.js +367 -0
- package/dist/services/codex-hook-delivery.js +32 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/compatibility-preflight.js +13 -3
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +136 -0
- package/dist/services/dashboard.js +135 -56
- package/dist/services/engines.js +245 -119
- package/dist/services/eval-snapshots.js +1146 -105
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +230 -0
- package/dist/services/harness-adapter.js +329 -36
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +753 -325
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +1 -288
- package/dist/services/lifecycle-contract.js +1 -0
- package/dist/services/lifecycle-invocations.js +1828 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-lease-monitor.js +133 -0
- package/dist/services/managed-processes.js +409 -51
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +163 -35
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +28 -17
- package/dist/services/protocols.js +163 -87
- package/dist/services/recovery-observation-budget.js +121 -0
- package/dist/services/recovery-snapshot-database.js +145 -0
- package/dist/services/repair-intents.js +42 -0
- package/dist/services/review-copy.js +157 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +411 -0
- package/dist/services/run-control.js +985 -0
- package/dist/services/run-controller-adapter.js +399 -0
- package/dist/services/run-controller-capture.js +179 -0
- package/dist/services/run-controller-process.js +187 -0
- package/dist/services/run-controller-recovery.js +289 -0
- package/dist/services/run-controller-state.js +97 -0
- package/dist/services/run-controller.js +1035 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +147 -0
- package/dist/services/run-observations.js +152 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +112 -0
- package/dist/services/run-recovery.js +349 -0
- package/dist/services/runs.js +558 -217
- package/dist/services/runtime-budget.js +430 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +468 -0
- package/dist/services/runtime-scope-resume.js +599 -0
- package/dist/services/runtime-scope-stop.js +180 -0
- package/dist/services/runtime-scope-worker.js +287 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +27 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +185 -19
- package/dist/services/stage-lifecycle.js +98 -107
- package/dist/services/stage-pause.js +89 -75
- package/dist/services/stage-report-renderer.js +158 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/tooling.js +32 -23
- package/dist/services/usage.js +49 -101
- package/dist/services/verification-matrix.js +374 -0
- package/dist/services/vnext-code-review.js +222 -87
- package/dist/services/vnext-code.js +324 -251
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +79 -4
- package/dist/services/vnext-fanout.js +182 -17
- package/dist/services/vnext-merge.js +335 -113
- package/dist/services/vnext-plan-review.js +276 -126
- package/dist/services/vnext-plan.js +227 -102
- package/dist/services/vnext-protocolize.js +335 -82
- package/dist/services/vnext-specify.js +119 -81
- package/dist/services/work-registry.js +1083 -194
- package/dist/services/workspace-bootstrap.js +87 -0
- package/dist/services/workspace-files.js +85 -0
- package/dist/services/worktrees.js +98 -44
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +17 -0
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +785 -37
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +30 -13
- package/tools/repair-paused-run-status.mjs +84 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AcpBridge } from "./dd-zcode.mjs";
|
|
5
|
+
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
6
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
7
|
+
import { nativeObservationError } from "./native-streams.mjs";
|
|
8
|
+
|
|
9
|
+
const GROK_HARNESS_CONTRACT = "dd-grok-harness@1";
|
|
10
|
+
|
|
11
|
+
function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
|
|
12
|
+
function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
|
|
13
|
+
function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
14
|
+
function executable(command, args = []) { const resolved = command ?? process.env.DD_GROK_BIN ?? "grok"; return /\.[cm]?js$/.test(resolved) ? { command: process.execPath, args: [resolved, ...args] } : { command: resolved, args }; }
|
|
15
|
+
|
|
16
|
+
async function commandOutput(command, args) {
|
|
17
|
+
const target = executable(command, args);
|
|
18
|
+
return await commandText(target.command, target.args, { phase: "native-version" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function profile(options) { return { provider: "xai", model: options.model ?? null, reasoning: options.reasoning ?? null, mode: options.mode ?? "bypassPermissions" }; }
|
|
22
|
+
function requiredProfile(options) { const result = profile(options); text(result.model, "--model"); text(result.reasoning, "--reasoning"); return result; }
|
|
23
|
+
function requireJournal(options) { absolute(options.journal, "--journal"); }
|
|
24
|
+
function receipt(options, value) { return options.daemonId ? { daemon_id: options.daemonId, ...value } : value; }
|
|
25
|
+
function unwrap(value) { return value?.result ?? value ?? {}; }
|
|
26
|
+
function extension(method) { return method.startsWith("_") ? method : `_${method}`; }
|
|
27
|
+
function usageScope(options, providerSessionId) { return providerSessionId === options.rootProviderSessionId ? "execution_tree_inclusive" : "physical_session"; }
|
|
28
|
+
function emptyToolCalls() { return { total: 0, failures: 0, by_tool: {} }; }
|
|
29
|
+
function sessionToolCalls(options, bridge, sessionId) { return options.toolUsage ? (options.toolUsage.get(sessionId) ?? emptyToolCalls()) : bridge.toolSummary(sessionId); }
|
|
30
|
+
function recordToolCalls(options, bridge, sessionId, cursor) {
|
|
31
|
+
const result = options.toolUsage
|
|
32
|
+
? sessionToolCalls(options, bridge, sessionId)
|
|
33
|
+
: typeof bridge.toolSummarySince === "function"
|
|
34
|
+
? bridge.toolSummarySince(cursor, sessionId)
|
|
35
|
+
: bridge.toolSummary(sessionId);
|
|
36
|
+
options.toolUsage?.set(sessionId, result);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function bridgeOptions(options) {
|
|
41
|
+
const requested = profile(options);
|
|
42
|
+
const grokHome = options.grokHome ? absolute(options.grokHome, "--grok-home") : null;
|
|
43
|
+
return {
|
|
44
|
+
...options,
|
|
45
|
+
observationHarness: "grok-acp",
|
|
46
|
+
// AcpBridge defaults to zcode-acp; Grok must always name its own executable.
|
|
47
|
+
bin: options.bin ?? process.env.DD_GROK_BIN ?? "grok",
|
|
48
|
+
commandArgs: ["agent", "--no-leader", "--always-approve", "--model", requested.model ?? "grok-4.6", "--reasoning-effort", requested.reasoning ?? "high", "stdio"],
|
|
49
|
+
env: { ...(grokHome ? { HOME: grokHome, GROK_HOME: grokHome } : {}), ...(options.authPath ? { GROK_AUTH_PATH: absolute(options.authPath, "--auth-path") } : {}) },
|
|
50
|
+
clientInfo: { name: "dd-grok", version: "0.1.0" }, permission: "allow"
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function withBridge(options, operation) {
|
|
55
|
+
const bridge = new AcpBridge(bridgeOptions(options));
|
|
56
|
+
let primaryError, result;
|
|
57
|
+
try { const initialized = await bridge.start(); result = await operation(bridge, initialized); return result; }
|
|
58
|
+
catch (error) { primaryError = error; throw error; }
|
|
59
|
+
finally { try { await bridge.close(); } catch (error) { if (!primaryError) throw nativeObservationError(error, result === undefined ? {} : { result }); primaryError.cleanup_error = errorRecord(error); primaryError.details = { ...primaryError.details, cleanup_error: primaryError.cleanup_error }; } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function observedProfile(initialized, requested) {
|
|
63
|
+
const state = initialized?._meta?.modelState ?? {};
|
|
64
|
+
const model = state.currentModelId ?? null;
|
|
65
|
+
const available = (state.availableModels ?? []).find((item) => item?.modelId === model);
|
|
66
|
+
return { provider: null, model, reasoning: available?._meta?.reasoningEffort ?? null, mode: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertProfile(requested, observed) { return checkObservedProfile(requested, observed); }
|
|
70
|
+
|
|
71
|
+
async function sessionInfo(bridge, sessionId) { return unwrap(await bridge.request(extension("x.ai/session/info"), { sessionId })); }
|
|
72
|
+
async function sessionUsage(bridge, sessionId) { try { return unwrap(await bridge.request(extension("x.ai/session/usage"), { sessionId })); } catch (error) { return { unavailable: true, error: error.message }; } }
|
|
73
|
+
async function subagents(bridge, sessionId) { return unwrap(await bridge.request(extension("x.ai/subagent/list_running"), { sessionId })); }
|
|
74
|
+
|
|
75
|
+
async function inspect(bridge, sessionId, options) {
|
|
76
|
+
const [info, topology, usage] = await Promise.all([sessionInfo(bridge, sessionId), subagents(bridge, sessionId), sessionUsage(bridge, sessionId)]);
|
|
77
|
+
const native = info?.data ?? info ?? {}, model = typeof native.model === "string" ? native.model : native.model?.modelId ?? null;
|
|
78
|
+
const current = model ? { model, provider: native.provider ?? null, reasoning: native.reasoningEffort ?? null, mode: native.mode ?? null } : bridge.modelProfiles?.get(sessionId) ?? { model: null, provider: null, reasoning: null, mode: null };
|
|
79
|
+
await observeModel({ journal: options.journal, harness: "grok-acp", sessionId, requested: profile(options), observed: model ? current : {},
|
|
80
|
+
source: { channel: "x.ai/session/info", scope: "configured", ...(!model ? { non_asserting: true } : {}) },
|
|
81
|
+
evidence: model ? "configured" : "unavailable", reason: model ? null : "session_info_omits_model" });
|
|
82
|
+
return { info, subagents: topology, usage, observed_profile: model ? current : { model: null, provider: null, reasoning: null, mode: null } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function forwardUsage(options, providerSessionId, usage, toolCalls) {
|
|
86
|
+
if (options.noFlow || !options.ddFlowBin || !usage || usage.unavailable) return;
|
|
87
|
+
const measured = usage.usage && typeof usage.usage === "object" ? usage.usage : usage;
|
|
88
|
+
const input = JSON.stringify({ provider_session_id: providerSessionId, daemon_id: options.daemonId ?? null, observed_at: new Date().toISOString(), usage: measured, tool_calls: toolCalls, usage_scope: usageScope(options, providerSessionId), completeness: measured.usageIsIncomplete ? "partial" : "complete" });
|
|
89
|
+
const target = executable(options.ddFlowBin, ["grok", "usage", "ingest", "--project-root", absolute(options.projectRoot, "--project-root"), "--json"]);
|
|
90
|
+
await commandText(target.command, target.args, { input: `${input}\n`, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {}, phase: "usage-ingest" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function forwardUsageBestEffort(options, providerSessionId, usage, toolCalls) {
|
|
94
|
+
try { await forwardUsage(options, providerSessionId, usage, toolCalls); return null; }
|
|
95
|
+
catch (error) { return { code: error.code ?? "usage_ingest_failed", message: error.message ?? String(error) }; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function identity(bridge, options) {
|
|
99
|
+
const sessionId = text(options.sessionId, "--session-id");
|
|
100
|
+
if (!options.liveSession) await bridge.request("session/load", { sessionId, cwd: absolute(options.cwd, "--cwd"), mcpServers: [] });
|
|
101
|
+
bridge.registerModelSession?.(sessionId, sessionId, profile(options), options.rootProviderSessionId && options.rootProviderSessionId !== sessionId ? options.rootProviderSessionId : null);
|
|
102
|
+
return sessionId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function cancelTree(bridge, sessionId) {
|
|
106
|
+
const before = await subagents(bridge, sessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } })); const cancellations = [];
|
|
107
|
+
for (const child of before.subagents ?? []) {
|
|
108
|
+
const childId = child.sessionId ?? child.subagentId;
|
|
109
|
+
if (childId) {
|
|
110
|
+
try { cancellations.push(await bridge.request(extension("x.ai/subagent/cancel"), { sessionId, subagentId: childId })); }
|
|
111
|
+
catch (error) { cancellations.push({ session_id: childId, cancelled: false, error: { code: error.code, message: error.message } }); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
bridge.notify("session/cancel", { sessionId });
|
|
115
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
117
|
+
const after = await subagents(bridge, sessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
118
|
+
if (after.observation_error) return { before, after, cancellations, settled: false };
|
|
119
|
+
if (!(after.subagents ?? []).length) {
|
|
120
|
+
const root = await sessionInfo(bridge, sessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
121
|
+
const status = root?.status ?? root?.state ?? root?.data?.status ?? null;
|
|
122
|
+
return { before, after, cancellations, root_status: status, settled: ["completed", "failed", "cancelled", "stopped", "idle"].includes(String(status).toLowerCase()) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const root = await sessionInfo(bridge, sessionId).catch(() => null);
|
|
126
|
+
return { before, after: await subagents(bridge, sessionId).catch(() => ({ subagents: [] })), cancellations, root_status: root?.status ?? root?.state ?? null, settled: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function doctor(options = {}) {
|
|
130
|
+
const raw = await commandOutput(options.bin, ["version", "--json"]); const version = JSON.parse(raw).currentVersion?.split(" ")[0] ?? raw.trim();
|
|
131
|
+
return { compatible: true, observed_runtime: { grok: version, acp: 1, dd_harness_contract: GROK_HARNESS_CONTRACT } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function createSession(options) { requireJournal(options); return await withBridge(options, async (bridge, initialized) => await createSessionWithBridge(bridge, options, initialized)); }
|
|
135
|
+
export async function createSessionWithBridge(bridge, options, initialized) {
|
|
136
|
+
requireJournal(options); const cwd = absolute(options.cwd, "--cwd"); const requested = requiredProfile(options);
|
|
137
|
+
const created = await bridge.request("session/new", { cwd, mcpServers: [], ...(options.authMethodId ? { authMethodId: options.authMethodId } : {}) });
|
|
138
|
+
const providerSessionId = text(created.sessionId, "Grok Build sessionId");
|
|
139
|
+
bridge.registerModelSession?.(providerSessionId, providerSessionId, requested);
|
|
140
|
+
const profileReceipt = assertProfile(requested, observedProfile(initialized, requested));
|
|
141
|
+
await options.onSessionCreated?.({ provider_session_id: providerSessionId, adapter_session_id: providerSessionId, cwd });
|
|
142
|
+
const local = { ...options, initialized, rootProviderSessionId: providerSessionId }; const initial_usage_error = await forwardUsageBestEffort(local, providerSessionId, await sessionUsage(bridge, providerSessionId), sessionToolCalls(local, bridge, providerSessionId));
|
|
143
|
+
await bridge.flush(); const evidence = await inspect(bridge, providerSessionId, local); evidence.tool_calls = sessionToolCalls(local, bridge, providerSessionId); const usage_ingest_error = await forwardUsageBestEffort(local, providerSessionId, evidence.usage, evidence.tool_calls); if (initial_usage_error) evidence.initial_usage_error = initial_usage_error; if (usage_ingest_error) evidence.usage_ingest_error = usage_ingest_error;
|
|
144
|
+
return receipt(options, { harness: "grok-acp", runtime_family: "grok", provider_session_id: providerSessionId, adapter_session_id: providerSessionId, cwd, initialized, profile: profileReceipt, evidence });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function promptSession(options) { requireJournal(options); return await withBridge(options, async (bridge, initialized) => await promptSessionWithBridge(bridge, { ...options, initialized })); }
|
|
148
|
+
export async function promptSessionWithBridge(bridge, options) {
|
|
149
|
+
requireJournal(options); const sessionId = await identity(bridge, options); const prompt = text(options.prompt, "prompt"); const requested = requiredProfile(options); const profileReceipt = assertProfile(requested, observedProfile(options.initialized, requested));
|
|
150
|
+
const local = { ...options, rootProviderSessionId: options.rootProviderSessionId ?? sessionId }; const initial_usage_error = await forwardUsageBestEffort(local, sessionId, await sessionUsage(bridge, sessionId), sessionToolCalls(local, bridge, sessionId));
|
|
151
|
+
options.assertDispatch?.();
|
|
152
|
+
bridge.dispatchOperationId = options.operationId;
|
|
153
|
+
const cursor = bridge.toolCursor(); const textCursor = bridge.assistantTextCursor(sessionId); const turn = await bridge.request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, null, { idleSessionId: sessionId, idleTimeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 600_000 });
|
|
154
|
+
try {
|
|
155
|
+
await bridge.flush();
|
|
156
|
+
const evidence = await inspect(bridge, sessionId, local); evidence.tool_calls = recordToolCalls(local, bridge, sessionId, cursor); const usage_ingest_error = await forwardUsageBestEffort(local, sessionId, evidence.usage, evidence.tool_calls); if (initial_usage_error) evidence.initial_usage_error = initial_usage_error; if (usage_ingest_error) evidence.usage_ingest_error = usage_ingest_error;
|
|
157
|
+
if (!options.allowBackground && (evidence.subagents?.subagents ?? []).length) { await cancelTree(bridge, sessionId); fail("background Grok Build subagents cannot outlive the one-shot dd-grok connection"); }
|
|
158
|
+
return receipt(options, { harness: "grok-acp", provider_session_id: sessionId, adapter_session_id: sessionId, assistant_text: bridge.assistantTextSince(sessionId, textCursor), turn, profile: profileReceipt, evidence });
|
|
159
|
+
} catch (error) { throw Object.assign(new Error("Grok native Turn completed but mandatory observation failed", { cause: error }), { code: "native_outcome_observation_failed", retryable: false,
|
|
160
|
+
details: { provider_session_id: sessionId, native_outcome: { status: "completed", turn }, observation_error: errorRecord(error), settled: false, journal_locator: options.journal } }); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function inspectSession(options) { requireJournal(options); return await withBridge(options, async (bridge, initialized) => await inspectSessionWithBridge(bridge, { ...options, initialized })); }
|
|
164
|
+
export async function inspectSessionWithBridge(bridge, options) { requireJournal(options); const sessionId = await identity(bridge, options); const evidence = await inspect(bridge, sessionId, options); evidence.tool_calls = sessionToolCalls(options, bridge, sessionId); const usage_ingest_error = await forwardUsageBestEffort(options, sessionId, evidence.usage, evidence.tool_calls); if (usage_ingest_error) evidence.usage_ingest_error = usage_ingest_error; return receipt(options, { harness: "grok-acp", provider_session_id: sessionId, adapter_session_id: sessionId, ...evidence }); }
|
|
165
|
+
export async function cancelSession(options) { requireJournal(options); return await withBridge(options, async (bridge) => await cancelSessionWithBridge(bridge, options)); }
|
|
166
|
+
export async function cancelSessionWithBridge(bridge, options) { requireJournal(options); const sessionId = await identity(bridge, options); return receipt(options, { harness: "grok-acp", provider_session_id: sessionId, adapter_session_id: sessionId, ...await cancelTree(bridge, sessionId) }); }
|
|
167
|
+
export async function forkSession(options) { requireJournal(options); return await withBridge(options, async (bridge) => await forkSessionWithBridge(bridge, options)); }
|
|
168
|
+
export async function forkSessionWithBridge(bridge, options) {
|
|
169
|
+
requireJournal(options); const sessionId = await identity(bridge, options); const target = options.target;
|
|
170
|
+
if (!target || typeof target !== "object" || !target.newCwd) fail("explicit --target-json with newCwd is required");
|
|
171
|
+
if ((await subagents(bridge, sessionId)).subagents?.length) fail("cannot fork while Grok Build subagents are running", "tree_not_settled");
|
|
172
|
+
const source = await sessionInfo(bridge, sessionId);
|
|
173
|
+
const result = unwrap(await bridge.request(extension("x.ai/session/fork"), { sourceSessionId: sessionId, sourceCwd: source.cwd ? absolute(source.cwd, "Grok Build source cwd") : absolute(options.cwd, "--cwd"), ...target }));
|
|
174
|
+
const child = text(result.newSessionId, "Grok Build fork newSessionId"); return receipt(options, { harness: "grok-acp", parent_provider_session_id: sessionId, provider_session_id: child, adapter_session_id: child, target, fork: result });
|
|
175
|
+
}
|
|
176
|
+
export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
|
|
177
|
+
import { commandText } from "./process-json.mjs";
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { SETUP_STARTUP_MS, startupObserver, observationDetails } from './setup-progress.mjs';
|
|
2
|
+
import { callDaemonObserved } from './daemon-observation.mjs';
|
|
3
|
+
import { admitNativeRequestBuffer } from "./native-streams.mjs";
|
|
4
|
+
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
5
|
+
import { openCodeToolObservation, journalToolObservations } from "./tool-observations.mjs";
|
|
6
|
+
import { ObservationClock } from "./observation-clock.mjs";
|
|
7
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { appendFile, chmod, copyFile, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import net from "node:net";
|
|
11
|
+
import { durableDaemonDispatch, completeDaemonShutdown, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon, assertDaemonDispatch, assertDaemonRestartable } from "./daemon-operations.mjs";
|
|
12
|
+
import { waitForSettlement } from "./session-settlement.mjs";
|
|
13
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
14
|
+
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { OpenCodeClient, OpenCodeError, canonicalHistory, historyDigest, usageSnapshot } from "./dd-opencode.mjs";
|
|
18
|
+
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
|
+
|
|
20
|
+
const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@1", ARCHIVE_SCHEMA = "dd-opencode/session-archive@1";
|
|
21
|
+
const OPENCODE_BASELINE = Object.freeze({ version: "1.18.25", api_fingerprint: "dfb7d42a555389f0c662fa2b4a8af1d61633c96710cf54bce3ff2404e2e7d896" });
|
|
22
|
+
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
23
|
+
const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); };
|
|
24
|
+
function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"), local = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-opencode-${createHash("sha256").update(dir).digest("hex").slice(0, 20)}.sock`, state: path.join(dir, "daemon.json"), journal: path.join(dir, "events.jsonl"), log: path.join(dir, "daemon.log"), data: path.join(dir, "xdg-data"), config: path.join(dir, "xdg-config"), cache: path.join(dir, "xdg-cache"), archive: path.join(dir, "import.json") }; }
|
|
25
|
+
async function writeJson(file, value) { const temporary = `${file}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, file); }
|
|
26
|
+
async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
27
|
+
async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
28
|
+
function executable(bin, args = []) { return /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args }; }
|
|
29
|
+
function isolatedEnv(config) { return { ...process.env, XDG_DATA_HOME: config.dataHome, XDG_CONFIG_HOME: config.configHome, XDG_CACHE_HOME: config.cacheHome, OPENCODE_CONFIG_DIR: config.adapterDir, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: config.password, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.ddFlowBin ? { DD_OPENCODE_DD_FLOW_BIN: config.ddFlowBin, DD_OPENCODE_NODE_BIN: process.execPath, DD_OPENCODE_PROJECT_ROOT: config.projectRoot } : {}), DD_OPENCODE_DAEMON_ID: config.daemonId, DD_OPENCODE_PROVIDER: config.provider, DD_OPENCODE_MODEL: config.model, DD_OPENCODE_VARIANT: config.variant, DD_OPENCODE_AGENT: config.agent, DD_OPENCODE_SERVER_VERSION: config.version ?? "unknown", DD_OPENCODE_PLUGIN_SHA256: config.pluginSha256 ?? "unknown" }; }
|
|
30
|
+
export async function run(bin, args, config, options = {}) {
|
|
31
|
+
const { stdin, timeoutMs = 30_000, startupMs = options.timeoutMs ?? SETUP_STARTUP_MS } = options;
|
|
32
|
+
return await commandText(bin, args, { cwd: config.cwd, env: isolatedEnv(config), input: stdin ?? null, timeoutMs, startupMs, phase: args.includes('--version') ? 'native-version' : 'native-utility' });
|
|
33
|
+
}
|
|
34
|
+
async function forwardUsage(config, sessionId, snapshot) {
|
|
35
|
+
if (!config.ddFlowBin) return;
|
|
36
|
+
const payload = { provider_session_id: sessionId, daemon_id: config.daemonId, observed_at: new Date().toISOString(), usage_scope: "physical_session", completeness: "complete", usage: { inputTokens: snapshot.input_tokens, outputTokens: snapshot.output_tokens, reasoningTokens: snapshot.reasoning_tokens, cacheReadInputTokens: snapshot.cached_input_tokens, cacheWriteInputTokens: snapshot.cache_write_tokens, totalTokens: snapshot.total_tokens, costUsd: snapshot.cost_usd }, tool_calls: snapshot.tool_calls };
|
|
37
|
+
await run(config.ddFlowBin, ["opencode", "usage", "ingest", "--project-root", config.projectRoot, "--json"], config, { stdin: `${JSON.stringify(payload)}\n` });
|
|
38
|
+
}
|
|
39
|
+
async function freePort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); }); }
|
|
40
|
+
const errorPayload = errorRecord;
|
|
41
|
+
|
|
42
|
+
async function writePlugin(paths, config) {
|
|
43
|
+
const directory = path.join(paths.config, "opencode", "plugins"); await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
44
|
+
const helper = new URL("./native-hook-command.mjs", import.meta.url).href;
|
|
45
|
+
const source = `import { hookBudget, invokeNativeHook } from ${JSON.stringify(helper)};\nconst candidate = command => typeof command === "string" && command.length > 0;\nconst participating = new Set();\nconst invoke = async (payload,budget) => await invokeNativeHook({bin:process.env.DD_OPENCODE_DD_FLOW_BIN,args:["opencode","event","handle","--project-root",process.env.DD_OPENCODE_PROJECT_ROOT,"--json"],payload,home:process.env.DD_FLOW_HOME,timeoutMs:budget.remaining("admission",15000)});\nexport const DdFlowPlugin=async({client,project,directory})=>{ const envelope=async(phase,input,extra,budget)=>{ const response=await budget.run(()=>client.session.get({path:{id:input.sessionID}}),"identity"); if(response?.error||!response?.data) throw Object.assign(new Error("OpenCode Session identity is unavailable"),{code:"opencode_session_identity_unavailable"}); const session=response.data; return {schema_id:"dd-flow/opencode-tool-event@1",event_id:[input.sessionID,input.callID,phase].join(":"),phase,daemon_id:process.env.DD_OPENCODE_DAEMON_ID,server_version:process.env.DD_OPENCODE_SERVER_VERSION,plugin_sha256:process.env.DD_OPENCODE_PLUGIN_SHA256,session:{provider_session_id:input.sessionID,parent_provider_session_id:session.parentID??null,agent_id:process.env.DD_OPENCODE_AGENT,directory:session.directory??directory,project_id:session.projectID??project?.id},message_id:input.messageID??null,tool_call_id:input.callID,tool:input.tool,profile:{provider:process.env.DD_OPENCODE_PROVIDER,model:process.env.DD_OPENCODE_MODEL,variant:process.env.DD_OPENCODE_VARIANT,agent:process.env.DD_OPENCODE_AGENT},...extra}; }; return {"tool.execute.before":async(input,output)=>{ const command=output.args?.command; if(!candidate(command)) return; const budget=hookBudget(); const result=await invoke(await envelope("before",input,{input:output.args},budget),budget); if(result?.observed) participating.add(input.callID); const updated=result?.hookSpecificOutput?.updatedInput; if(updated) Object.assign(output.args,updated); },"tool.execute.after":async(input,output)=>{ if(!participating.delete(input.callID)) return; const budget=hookBudget(); await invoke(await envelope("after",input,{input:input.args,outcome:{status:"completed",title:output.title}},budget),budget); }}; };\n`;
|
|
46
|
+
const checksum = createHash("sha256").update(source).digest("hex"); await writeFile(path.join(directory, "dd-flow.js"), source, { mode: 0o600 }); return checksum;
|
|
47
|
+
}
|
|
48
|
+
async function prepare(paths, config, authSource) {
|
|
49
|
+
for (const dir of [paths.data, paths.config, paths.cache, path.join(paths.config, "opencode")]) { await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); }
|
|
50
|
+
const authTarget = path.join(paths.data, "opencode", "auth.json"); await mkdir(path.dirname(authTarget), { recursive: true, mode: 0o700 }); try { await copyFile(authSource, authTarget); await chmod(authTarget, 0o600); config.authStatus = "copied"; } catch (error) { if (error.code !== "ENOENT") throw error; config.authStatus = "absent"; }
|
|
51
|
+
await writeJson(path.join(paths.config, "opencode", "opencode.json"), { $schema: "https://opencode.ai/config.json", autoupdate: false, permission: "allow" });
|
|
52
|
+
if (!config.noFlow) config.pluginSha256 = await writePlugin(paths, config);
|
|
53
|
+
}
|
|
54
|
+
async function importArchive(paths, config, archiveFile) { if (!archiveFile) return null; const manifest = JSON.parse(await readFile(absolute(archiveFile, "--session-archive"), "utf8")); if (manifest.schema_id !== ARCHIVE_SCHEMA || !manifest.payload) throw new DaemonError("invalid_session_archive", "OpenCode archive is incompatible"); if (manifest.opencode_version !== config.version) throw new DaemonError("session_archive_version_mismatch", "OpenCode archive version differs from the daemon version", false, { archive_version: manifest.opencode_version, current_version: config.version }); const payload = Buffer.from(manifest.payload, "base64").toString("utf8"); await writeFile(paths.archive, payload, { mode: 0o600 }); const stdout = await run(config.bin, ["import", paths.archive], config); const match = stdout.match(/ses_[A-Za-z0-9]+/); return { provider_session_id: match?.[0] ?? manifest.provider_session_id, manifest };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
58
|
+
const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
59
|
+
return await callDaemonObserved({ stateDir, socket: locations(stateDir).socket, request, responseSchema: RESPONSE_SCHEMA, harness: 'opencode', timeoutMs });
|
|
60
|
+
}
|
|
61
|
+
function sameConfig(state, config) { return ["cwd","journal","bin","provider","model","variant","agent","projectRoot","ddFlowBin","ddFlowHome","sessionArchive","resourceHome","noFlow"].every(key => (state.config?.[key] ?? null) === (config[key] ?? null)); }
|
|
62
|
+
export async function startDaemon(options) {
|
|
63
|
+
const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
64
|
+
const previous = await readState(paths.dir), daemonId = randomUUID(), noFlow = options.noFlow === true, cwd = await realpath(absolute(options.cwd, "--cwd")); const config = { cwd, journal: options.journal ? absolute(options.journal, "--journal") : paths.journal, bin: options.bin ?? process.env.DD_OPENCODE_BIN ?? "opencode", provider: String(options.provider ?? ""), model: String(options.model ?? ""), variant: String(options.variant ?? "default"), agent: String(options.agent ?? "build"), projectRoot: noFlow ? cwd : await realpath(absolute(options.projectRoot, "--project-root")), ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : (process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null), env: noFlow ? {} : { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") }, sessionArchive: options.sessionArchive ? absolute(options.sessionArchive, "--session-archive") : null, dataHome: paths.data, configHome: paths.config, cacheHome: paths.cache, adapterDir: path.join(paths.config, "opencode"), password: randomBytes(24).toString("hex"), daemonId, noFlow };
|
|
65
|
+
await prepareRuntimeOwner(config, paths.dir);
|
|
66
|
+
assertDaemonRestartable(previous);
|
|
67
|
+
if (!config.provider || !config.model) throw new DaemonError("profile_required", "--provider and --model are required");
|
|
68
|
+
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
69
|
+
assertDaemonRestartable(await readState(paths.dir));
|
|
70
|
+
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config); if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous daemon died with an active or unproven Session tree");
|
|
71
|
+
await removeSocket(paths); const authSource = options.authPath ? absolute(options.authPath, "--auth-path") : path.join(os.homedir(), ".local", "share", "opencode", "auth.json"); await prepare(paths, config, authSource); config.version = (await run(config.bin, ["--version"], config)).trim(); if (config.version !== OPENCODE_BASELINE.version) throw new DaemonError("unsupported_version", `unsupported OpenCode version ${config.version}; expected ${OPENCODE_BASELINE.version}`); const imported = await importArchive(paths, config, config.sessionArchive);
|
|
72
|
+
const resourceProcess = await registerDaemonProcess(config, { kind: "opencode-daemon", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
73
|
+
const state = { schema_id: STATE_SCHEMA, daemon_id: daemonId, shutdown_state: "starting", active_tree: false, auth_status: config.authStatus, versions: { opencode: config.version }, config, sessions: imported ? [{ provider_session_id: imported.provider_session_id, parent_provider_session_id: null, root_provider_session_id: imported.provider_session_id, cwd: config.cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess }; await writeJson(paths.state, state);
|
|
74
|
+
const log = await open(paths.log, "a", 0o600), entry = absolute(options.entryPath, "dd-opencode entry"); const child = spawn(process.execPath, [entry, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
75
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) { await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new DaemonError("daemon_start_failed", "daemon did not become ready", true), { details: observationDetails(startupClock, 'daemon-start', child.pid) }); }
|
|
76
|
+
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
77
|
+
}
|
|
78
|
+
export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); };
|
|
79
|
+
|
|
80
|
+
export class Runtime {
|
|
81
|
+
constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map((state.sessions ?? []).map(x => [x.provider_session_id, x])); this.active = null; this.persisting = Promise.resolve(); }
|
|
82
|
+
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.catch(() => {}).then(() => writeJson(this.paths.state, snapshot)); return this.persisting; }
|
|
83
|
+
async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
84
|
+
async childTree(id, seen = new Set()) { if (seen.has(id)) return []; seen.add(id); const direct = await this.client.children(id) ?? []; const nested = []; for (const child of direct) nested.push(child, ...await this.childTree(child.id, seen)); return nested; }
|
|
85
|
+
async describe(id) { const [info, messages, children, statuses] = await Promise.all([this.client.inspect(id), this.client.messages(id), this.childTree(id), this.client.statuses()]); if (path.resolve(info.directory) !== path.resolve(this.state.config.cwd)) throw new DaemonError("foreign_session", "OpenCode Session directory is outside the controlled workspace", false, { session_directory: info.directory, workspace: this.state.config.cwd }); const status = statuses?.[id]?.type ?? "idle", parent = info.parentID ?? null, existing = this.sessions.get(id); const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: parent ? (this.sessions.get(parent)?.root_provider_session_id ?? parent) : (existing?.root_provider_session_id ?? id), cwd: info.directory }; this.sessions.set(id, record); for (const child of children) if (!this.sessions.has(child.id)) this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: child.parentID ?? id, root_provider_session_id: record.root_provider_session_id, cwd: child.directory }); const unsettledChildren = children.filter(child => (statuses?.[child.id]?.type ?? "idle") !== "idle").map(child => child.id); const lastAssistant = messages.findLast(message => message.info?.role === "assistant"); const assistantText = (lastAssistant?.parts ?? []).filter(part => part.type === "text").map(part => part.text ?? "").join(""); await this.observeModels(id, messages, parent); this.journaledTools ??= new Map(); journalToolObservations(this.state.config.journal, messages.flatMap(message => (message.parts ?? []).map(part => openCodeToolObservation(part, id, message.info?.id))), this.journaledTools); return { ...record, observed_profile: { provider: lastAssistant?.info?.providerID ?? null, model: lastAssistant?.info?.modelID ?? null, reasoning: lastAssistant?.info?.variant ?? null, mode: info.agent ?? null }, assistant_text: assistantText, info, status, settled: status === "idle" && unsettledChildren.length === 0, unsettled_children: unsettledChildren, tree_observations: children.map(child => ({ provider_session_id: child.id, status: statuses?.[child.id]?.type ?? "idle", settled: (statuses?.[child.id]?.type ?? "idle") === "idle" })), children, usage: usageSnapshot(messages, id), history_sha256: historyDigest(messages), message_count: messages.length }; }
|
|
86
|
+
async observeModels(id, messages, parent = null) {
|
|
87
|
+
this.modelMessages ??= new Set();
|
|
88
|
+
for (const message of messages ?? []) {
|
|
89
|
+
const info = message.info;
|
|
90
|
+
if (info?.role !== "assistant" || !info.id || this.modelMessages.has(info.id)) continue;
|
|
91
|
+
if (info.sessionID && info.sessionID !== id) throw new DaemonError("session_identity_mismatch", "native message belongs to another Session");
|
|
92
|
+
await observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: id, parentSessionId: parent,
|
|
93
|
+
requested: { ...this.state.config, reasoning: this.state.config.variant, mode: this.state.config.agent },
|
|
94
|
+
observed: { provider: info.providerID, model: info.modelID, reasoning: info.variant, mode: info.mode }, source: { method: "session/message", message_id: info.id, event_sha256: createHash("sha256").update(`${id}:${info.id}:${info.modelID}`).digest("hex") }, evidence: "response" });
|
|
95
|
+
this.modelMessages.add(info.id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async requireSettled(id) { const value = await this.describe(id); if (!value.settled) throw new DaemonError("tree_not_settled", "OpenCode Session is not idle", false, { session_id: id, status: value.status }); return value; }
|
|
99
|
+
async reconcileUsage(observed) { const errors = []; try { await forwardUsage(this.state.config, observed.provider_session_id, observed.usage); } catch (error) { errors.push({ session_id: observed.provider_session_id, phase: "usage", ...errorPayload(error) }); } for (const child of observed.children.filter(item => (item.parentID ?? observed.provider_session_id) === observed.provider_session_id)) { try { errors.push(...await this.reconcileUsage(await this.describe(child.id))); } catch (error) { errors.push({ session_id: child.id, phase: "usage_child", ...errorPayload(error) }); } } return errors; }
|
|
100
|
+
async productive(name, task) {
|
|
101
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
102
|
+
if (this.active) throw new DaemonError("operation_busy", `${this.active} is already running`, true);
|
|
103
|
+
this.active = name; let result, completed = false;
|
|
104
|
+
try {
|
|
105
|
+
await this.persist({ active_tree: true, active_operation: name });
|
|
106
|
+
assertDaemonDispatch(this.paths?.dir);
|
|
107
|
+
result = await task(); completed = true;
|
|
108
|
+
await this.persist({ active_tree: !result?.settled, active_operation: null });
|
|
109
|
+
await this.journal(name, result); return result;
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
const error = completed ? new DaemonError("operation_observation_lost", "OpenCode returned but its durable observation could not be saved; do not resend", false, { result, cause: errorPayload(cause) }) : cause;
|
|
112
|
+
const failures = [];
|
|
113
|
+
try { await this.persist({ active_tree: true, active_operation: null }); } catch (secondary) { failures.push(errorPayload(secondary)); }
|
|
114
|
+
try { await this.journal(`${name}.failed`, null); } catch (secondary) { failures.push(errorPayload(secondary)); }
|
|
115
|
+
if (failures.length) error.details = { ...error.details, observation_errors: failures };
|
|
116
|
+
throw error;
|
|
117
|
+
} finally { this.active = null; }
|
|
118
|
+
}
|
|
119
|
+
async cancelTree(id, seen = new Set()) {
|
|
120
|
+
if (seen.has(id)) return [];
|
|
121
|
+
seen.add(id);
|
|
122
|
+
const errors = []; let children = [];
|
|
123
|
+
try { children = await this.client.children(id); } catch (error) { errors.push({ session_id: id, phase: "children", ...errorPayload(error) }); }
|
|
124
|
+
for (const child of children) {
|
|
125
|
+
if (child.parentID !== id || !child.directory || path.resolve(child.directory) !== path.resolve(this.state.config.cwd)) {
|
|
126
|
+
errors.push({ session_id: child.id, phase: "identity", code: "session_identity_mismatch" }); continue;
|
|
127
|
+
}
|
|
128
|
+
errors.push(...await this.cancelTree(child.id, seen));
|
|
129
|
+
}
|
|
130
|
+
try { await this.client.abort(id); } catch (error) { errors.push({ session_id: id, phase: "abort", ...errorPayload(error) }); }
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
async dispatch(operation, params, operationId = null) {
|
|
134
|
+
const assertDispatch = captureDispatchGuard(this);
|
|
135
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.archive"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
136
|
+
if (operation === "daemon.status") { const { password, ...config } = this.state.config; return { daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, auth_status: this.state.auth_status, shutdown_state: this.state.shutdown_state, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config }; }
|
|
137
|
+
if (operation === "daemon.doctor") { const doc = await this.client.request("GET", "/doc", undefined, { directory: null, timeoutMs: 5_000 }); const apiFingerprint = createHash("sha256").update(typeof doc === "string" ? doc : JSON.stringify(doc)).digest("hex"); if (apiFingerprint !== OPENCODE_BASELINE.api_fingerprint) throw new DaemonError("api_fingerprint_mismatch", "OpenCode API differs from the qualified baseline", false, { expected: OPENCODE_BASELINE.api_fingerprint, actual: apiFingerprint }); return { compatible: true, versions: this.state.versions, api_fingerprint: apiFingerprint, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status }; }
|
|
138
|
+
if (operation === "session.create") return this.productive(operation, async () => {
|
|
139
|
+
const info = await this.client.create(params.title);
|
|
140
|
+
const created = { provider_session_id: info.id, adapter_session_id: info.id, parent_provider_session_id: info.parentID ?? null, root_provider_session_id: info.id, cwd: info.directory };
|
|
141
|
+
this.sessions.set(info.id, created);
|
|
142
|
+
try {
|
|
143
|
+
const result = await this.describe(info.id);
|
|
144
|
+
result.usage_ingest_errors = await this.reconcileUsage(result);
|
|
145
|
+
return result;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// The Session exists even if its optional post-create observation is
|
|
148
|
+
// temporarily unavailable. Preserve the identity; a later inspect
|
|
149
|
+
// owns any profile/topology proof required before a prompt.
|
|
150
|
+
return { ...created, settled: false, settlement_observation_error: errorPayload(error) };
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
if (operation === "session.inspect" || operation === "session.resume") { const result = await this.describe(params.sessionId); await this.persist(); return result; }
|
|
154
|
+
if (operation === "session.prompt") { await this.requireSettled(params.sessionId); return this.productive(operation, async () => { assertDispatch(); const reply = await this.client.prompt(params.sessionId, { prompt: params.prompt, provider: this.state.config.provider, model: this.state.config.model, variant: this.state.config.variant, agent: this.state.config.agent,
|
|
155
|
+
assertDispatch, livenessTimeoutMs: params.livenessTimeoutMs ?? this.client.timeoutMs,
|
|
156
|
+
onDispatched: () => this.journal("native_dispatch", { operation_id: operationId, provider_session_id: params.sessionId }),
|
|
157
|
+
onModelMessages: (id, messages, parent) => this.observeModels(id, messages, parent),
|
|
158
|
+
onObservationError: error => observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: params.sessionId, requested: this.state.config, source: "native.message_observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" })
|
|
159
|
+
}); const assistant_text = reply.parts.filter(part => part.type === "text").map(part => part.text ?? "").join("");
|
|
160
|
+
const reply_profile = { provider: reply.info.providerID ?? null, model: reply.info.modelID ?? null, reasoning: reply.info.variant ?? null, mode: this.state.config.agent ?? null };
|
|
161
|
+
checkObservedProfile({ ...this.state.config, reasoning: this.state.config.variant, mode: this.state.config.agent }, reply_profile);
|
|
162
|
+
let result;
|
|
163
|
+
try { result = await this.describe(params.sessionId); }
|
|
164
|
+
catch (error) { return { provider_session_id: params.sessionId, adapter_session_id: params.sessionId, assistant_text, response_message_id: reply.info.id, observed_profile: reply_profile, settled: false, settlement_observation_error: errorPayload(error) }; }
|
|
165
|
+
result.assistant_text = assistant_text;
|
|
166
|
+
result.response_message_id = reply.info.id;
|
|
167
|
+
result.observed_profile = { ...reply_profile, mode: result.info.agent ?? reply_profile.mode };
|
|
168
|
+
checkObservedProfile({ ...this.state.config, reasoning: this.state.config.variant, mode: this.state.config.agent }, result.observed_profile); result.usage_ingest_errors = await this.reconcileUsage(result); return result; }); }
|
|
169
|
+
if (operation === "session.fork") { const source = await this.requireSettled(params.sessionId); return this.productive(operation, async () => {
|
|
170
|
+
const info = await this.client.fork(params.sessionId, params.messageId);
|
|
171
|
+
const created = { provider_session_id: info.id, adapter_session_id: info.id, parent_provider_session_id: info.parentID ?? params.sessionId, root_provider_session_id: this.sessions.get(params.sessionId)?.root_provider_session_id ?? params.sessionId, cwd: info.directory };
|
|
172
|
+
this.sessions.set(info.id, created);
|
|
173
|
+
try {
|
|
174
|
+
const fork = await this.describe(info.id);
|
|
175
|
+
const sourceMessages = await this.client.messages(params.sessionId), forkMessages = await this.client.messages(info.id);
|
|
176
|
+
const prefix = JSON.stringify(canonicalHistory(forkMessages)) === JSON.stringify(canonicalHistory(sourceMessages).slice(0, forkMessages.length));
|
|
177
|
+
if (!prefix) throw new DaemonError("fork_continuity_failed", "fork history is not a source prefix");
|
|
178
|
+
return { ...fork, fork_receipt: { source_provider_session_id: params.sessionId, source_history_sha256: source.history_sha256, fork_history_sha256: fork.history_sha256, history_prefix_proven: true } };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// A native fork is materialized already. Do not lose its identity or
|
|
181
|
+
// retry it, but never let an unproven history prefix become usable.
|
|
182
|
+
return { ...created, settled: false, fork_receipt: { source_provider_session_id: params.sessionId, source_history_sha256: source.history_sha256, history_prefix_proven: false }, fork_continuity_error: errorPayload(error) };
|
|
183
|
+
}
|
|
184
|
+
}); }
|
|
185
|
+
if (operation === "session.cancel") {
|
|
186
|
+
if (!this.sessions.has(params.sessionId)) throw new DaemonError("foreign_session", "cannot cancel a Session without daemon ownership");
|
|
187
|
+
cancelPendingDispatch(this);
|
|
188
|
+
const cancellation_errors = await this.cancelTree(params.sessionId); let result;
|
|
189
|
+
try { result = await this.describe(params.sessionId); }
|
|
190
|
+
catch (error) { result = { provider_session_id: params.sessionId, settled: false, inspection_error: errorPayload(error) }; }
|
|
191
|
+
result = { ...result, cancellation_errors };
|
|
192
|
+
await this.persist({ active_tree: Boolean(this.active) || !result.settled }); await this.journal(operation, result); return result;
|
|
193
|
+
}
|
|
194
|
+
if (operation === "session.archive") { const observed = await this.requireSettled(params.sessionId); const payload = await run(this.state.config.bin, ["export", params.sessionId], this.state.config); const manifest = { schema_id: ARCHIVE_SCHEMA, provider_session_id: params.sessionId, source_cwd: observed.cwd, opencode_version: this.state.versions.opencode, history_sha256: observed.history_sha256, exported_at: new Date().toISOString(), payload: Buffer.from(payload).toString("base64") }; const file = absolute(params.archiveFile, "--archive-file"); await writeJson(file, manifest); return { archive_file: file, ...manifest, payload: undefined, sha256: createHash("sha256").update(await readFile(file)).digest("hex") }; }
|
|
195
|
+
if (operation === "daemon.stop") {
|
|
196
|
+
return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), async () => {
|
|
197
|
+
if (params.cancelTree) cancelPendingDispatch(this);
|
|
198
|
+
await waitForSettlement({
|
|
199
|
+
knownSessions: () => [...this.sessions.keys()],
|
|
200
|
+
observe: async () => {
|
|
201
|
+
const busy = [];
|
|
202
|
+
for (const session of this.sessions.values()) if (!(await this.describe(session.provider_session_id)).settled) busy.push(session.provider_session_id);
|
|
203
|
+
return { sessions: busy.reverse(), active: Boolean(this.active), active_operation: this.active };
|
|
204
|
+
},
|
|
205
|
+
...(params.cancelTree ? { cancel: id => this.cancelTree(id) } : {})
|
|
206
|
+
});
|
|
207
|
+
return { clean: true };
|
|
208
|
+
}, [
|
|
209
|
+
["provider", () => stopProcessGroup(this.child)],
|
|
210
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.providerProcess)],
|
|
211
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function serveDaemon(stateDir) {
|
|
218
|
+
const paths = locations(stateDir), state = await readState(paths.dir); if (state?.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing");
|
|
219
|
+
let child, providerProcess;
|
|
220
|
+
try {
|
|
221
|
+
const port = await freePort(), target = executable(state.config.bin, ["serve", "--hostname", "127.0.0.1", "--port", String(port)]);
|
|
222
|
+
providerProcess = await registerDaemonProcess(state.config, { kind: "opencode-provider", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-provider:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
223
|
+
child = spawn(target.command, target.args, { cwd: state.config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", "inherit", "inherit"] });
|
|
224
|
+
await confirmDaemonProcess(state.config, providerProcess, child);
|
|
225
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
226
|
+
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
227
|
+
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
228
|
+
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const startupProgress = startupObserver(paths.dir, state.daemon_id); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); while (!startupClock.sample(await startupProgress())) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
229
|
+
const server = net.createServer(connection => {
|
|
230
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
231
|
+
connection.on("data", chunk => {
|
|
232
|
+
if (consumed) return;
|
|
233
|
+
buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; } const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
234
|
+
void (async () => {
|
|
235
|
+
let request;
|
|
236
|
+
try {
|
|
237
|
+
request = JSON.parse(buffer.slice(0, at));
|
|
238
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}, request.id), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
239
|
+
if (result._shutdown) retireDaemonEndpoint(server);
|
|
240
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
243
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
244
|
+
}
|
|
245
|
+
})();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
trackDaemonConnections(server); runtime.server = server;
|
|
249
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false, server_port: port }); return await new Promise(() => {});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await cleanupFailedStart(state.config, providerProcess, child, error); throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
255
|
+
import { commandText } from "./process-json.mjs";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { ToolObservations, openCodeToolObservation } from "./tool-observations.mjs";
|
|
3
|
+
import { observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
|
|
4
|
+
import { assertNativeLine } from "./native-streams.mjs";
|
|
5
|
+
import { operationProgressReporter } from "./operation-progress.mjs";
|
|
6
|
+
|
|
7
|
+
export class OpenCodeError extends Error {
|
|
8
|
+
constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class OpenCodeClient {
|
|
12
|
+
constructor({ baseUrl, password, directory, timeoutMs = 1_800_000 }) {
|
|
13
|
+
this.baseUrl = new URL(baseUrl); this.password = password; this.directory = directory; this.timeoutMs = timeoutMs;
|
|
14
|
+
}
|
|
15
|
+
async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress, onDispatched } = {}) {
|
|
16
|
+
const url = new URL(pathname, this.baseUrl); if (directory) url.searchParams.set("directory", directory);
|
|
17
|
+
const controller = new AbortController(); const timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
|
|
18
|
+
try {
|
|
19
|
+
const pending = fetch(url, { method, headers: { authorization: `Basic ${Buffer.from(`opencode:${this.password}`).toString("base64")}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal });
|
|
20
|
+
void pending.catch(() => {}); await onDispatched?.(); const response = await pending;
|
|
21
|
+
let text = "";
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
if (response.body) for await (const chunk of response.body) { text += decoder.decode(chunk, { stream: true }); assertNativeLine(text); }
|
|
24
|
+
text += decoder.decode(); assertNativeLine(text);
|
|
25
|
+
let value = null; try { value = text ? JSON.parse(text) : null; } catch { value = text; }
|
|
26
|
+
if (!response.ok) throw new OpenCodeError(response.status === 404 ? "session_not_found" : "opencode_http_error", `OpenCode ${method} ${pathname} returned ${response.status}`, response.status >= 500, value);
|
|
27
|
+
return value;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
controller.abort();
|
|
30
|
+
if (error instanceof OpenCodeError || error.code === "operation_observation_lost") throw error;
|
|
31
|
+
// Aborting HTTP only loses the observer; it does not cancel the provider.
|
|
32
|
+
throw Object.assign(new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname }), { cause: error });
|
|
33
|
+
} finally { clearTimeout(timer); }
|
|
34
|
+
}
|
|
35
|
+
health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
|
|
36
|
+
create(title) { return this.request("POST", "/session", title ? { title } : {}); }
|
|
37
|
+
inspect(id) { return this.request("GET", `/session/${encodeURIComponent(id)}`); }
|
|
38
|
+
list() { return this.request("GET", "/session"); }
|
|
39
|
+
statuses() { return this.request("GET", "/session/status"); }
|
|
40
|
+
children(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/children`); }
|
|
41
|
+
messages(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/message`); }
|
|
42
|
+
async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch, onDispatched, livenessTimeoutMs = this.timeoutMs }) {
|
|
43
|
+
validateObservationDuration(livenessTimeoutMs);
|
|
44
|
+
const previous = await this.messages(id);
|
|
45
|
+
if (!Array.isArray(previous)) throw new OpenCodeError("operation_observation_lost", "OpenCode history cannot establish the prompt boundary", true);
|
|
46
|
+
const previousIds = new Set(previous.map(message => message.info?.id).filter(Boolean));
|
|
47
|
+
const publish = operationProgressReporter();
|
|
48
|
+
let marker, observation = null, cursor = 0, signature = null, highWater = 0;
|
|
49
|
+
const seenSignatures = new Set();
|
|
50
|
+
const floors = new Map([[id, previousIds]]);
|
|
51
|
+
// Native message content is progress; an HTTP poll or busy status is not.
|
|
52
|
+
// Include descendants so a coordinating parent may legitimately be silent.
|
|
53
|
+
const observe = async (session, seen = new Set(), parent = null) => {
|
|
54
|
+
if (seen.has(session)) return [];
|
|
55
|
+
seen.add(session);
|
|
56
|
+
const options = { timeoutMs: Math.min(5_000, this.timeoutMs) };
|
|
57
|
+
const [messages, children] = await Promise.all([
|
|
58
|
+
this.request("GET", `/session/${encodeURIComponent(session)}/message`, undefined, options),
|
|
59
|
+
this.request("GET", `/session/${encodeURIComponent(session)}/children`, undefined, options)
|
|
60
|
+
]);
|
|
61
|
+
if (!Array.isArray(messages) || !Array.isArray(children)) throw new OpenCodeError("operation_observation_lost", "OpenCode observation shape is unproven", true);
|
|
62
|
+
await onModelMessages?.(session, messages, parent);
|
|
63
|
+
// A newly discovered child does not turn its old transcript into work.
|
|
64
|
+
if (!floors.has(session)) floors.set(session, new Set(messages.map(message => message.info?.id).filter(Boolean)));
|
|
65
|
+
const current = messages.filter(message => message.info?.role === "assistant" && message.info?.sessionID === session && !floors.get(session).has(message.info?.id));
|
|
66
|
+
const content = current.map(message => ({ id: message.info?.id, parts: (message.parts ?? []).filter(part => ["text", "reasoning", "tool", "subtask"].includes(part.type)).map(stablePart) })).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
67
|
+
const checked = children.filter(child => child.parentID === session && typeof child.id === "string").sort((a, b) => a.id.localeCompare(b.id));
|
|
68
|
+
const descendants = (await Promise.all(checked.map(child => observe(child.id, seen, session)))).filter(value => value.length > 0);
|
|
69
|
+
return content.some(message => message.parts.length) || descendants.length ? [session, content, ...descendants] : [];
|
|
70
|
+
};
|
|
71
|
+
const refreshObservation = () => observation ??= (async () => {
|
|
72
|
+
try { const projection = JSON.stringify(await observe(id)), next = createHash("sha256").update(projection).digest("hex");
|
|
73
|
+
// Cumulative text can grow indefinitely without retaining every hash;
|
|
74
|
+
// non-growing snapshot states have bounded current-operation replay proof.
|
|
75
|
+
if (signature !== null && !seenSignatures.has(next) && (projection.length > highWater || seenSignatures.size < 1_024)) {
|
|
76
|
+
marker = { cursor: ++cursor, observed_at: Date.now() };
|
|
77
|
+
await publish("native_progress", { session_id: id, observed_at: new Date(marker.observed_at).toISOString() });
|
|
78
|
+
}
|
|
79
|
+
if (seenSignatures.size < 1_024) seenSignatures.add(next);
|
|
80
|
+
highWater = Math.max(highWater, projection.length);
|
|
81
|
+
signature = next;
|
|
82
|
+
}
|
|
83
|
+
catch (error) { await onObservationError?.(error); /* Missing data is not progress. */ }
|
|
84
|
+
})().finally(() => { observation = null; });
|
|
85
|
+
// Begin observing before the long-running request. Waiting for the first
|
|
86
|
+
// interval can let a short liveness window expire before any native
|
|
87
|
+
// content has had a chance to renew it.
|
|
88
|
+
void refreshObservation();
|
|
89
|
+
const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000, livenessTimeoutMs / 4)));
|
|
90
|
+
try {
|
|
91
|
+
assertDispatch?.();
|
|
92
|
+
const reply = await this.request("POST", `/session/${encodeURIComponent(id)}/message`, { model: { providerID: provider, modelID: model }, variant, agent, parts: [{ type: "text", text: prompt }] }, { timeoutMs: livenessTimeoutMs, progress: () => marker, onDispatched });
|
|
93
|
+
if (!reply?.info?.id || reply.info.sessionID !== id || reply.info.role !== "assistant" || previousIds.has(reply.info.id) || !Array.isArray(reply.parts)) {
|
|
94
|
+
throw new OpenCodeError("operation_observation_lost", "OpenCode did not return an identifiable current assistant response", true, { session_id: id, message_id: reply?.info?.id ?? null });
|
|
95
|
+
}
|
|
96
|
+
if (reply.info.error) {
|
|
97
|
+
const native = reply.info.error;
|
|
98
|
+
const message = native.data?.message ?? native.message ?? native.name ?? "OpenCode provider failed";
|
|
99
|
+
const status = native.data?.statusCode;
|
|
100
|
+
const code = status === 402 || /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(message) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(message) ? "provider_rate_limited" : status === 429 ? "provider_limit_unknown" : "opencode_provider_failed";
|
|
101
|
+
throw new OpenCodeError(code, message, code === "provider_rate_limited", { session_id: id, provider_session_id: id, message_id: reply.info.id, provider_error: native,
|
|
102
|
+
observed_at: new Date().toISOString(), native_source: "opencode.reply.info.error", turn_id: reply.info.id });
|
|
103
|
+
}
|
|
104
|
+
return reply;
|
|
105
|
+
}
|
|
106
|
+
finally { clearInterval(timer); await observation; }
|
|
107
|
+
}
|
|
108
|
+
fork(id, messageID) { return this.request("POST", `/session/${encodeURIComponent(id)}/fork`, messageID ? { messageID } : {}); }
|
|
109
|
+
abort(id) { return this.request("POST", `/session/${encodeURIComponent(id)}/abort`); }
|
|
110
|
+
delete(id) { return this.request("DELETE", `/session/${encodeURIComponent(id)}`); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stablePart(part) {
|
|
114
|
+
if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: part.text, synthetic: part.synthetic === true };
|
|
115
|
+
if (part.type === "tool") return { type: "tool", tool: part.tool, state: { status: part.state?.status, input: part.state?.input, output: part.state?.output, error: part.state?.error } };
|
|
116
|
+
if (part.type === "subtask") return { type: "subtask", prompt: part.prompt, description: part.description, agent: part.agent };
|
|
117
|
+
return { type: part.type };
|
|
118
|
+
}
|
|
119
|
+
export function canonicalHistory(messages) {
|
|
120
|
+
return messages.map(({ info, parts }) => ({ role: info.role, ...(info.role === "assistant" ? { provider: info.providerID, model: info.modelID, mode: info.mode, finish: info.finish ?? null, error: info.error?.name ?? null } : {}), parts: parts.map(stablePart) }));
|
|
121
|
+
}
|
|
122
|
+
export function historyDigest(messages) { return createHash("sha256").update(JSON.stringify(canonicalHistory(messages))).digest("hex"); }
|
|
123
|
+
export function usageSnapshot(messages, sessionId) {
|
|
124
|
+
const assistants = messages.filter((item) => item.info?.role === "assistant"); const tokens = assistants.reduce((sum, item) => { const value = item.info.tokens ?? {}; const cache = value.cache ?? {}; sum.input += value.input ?? 0; sum.output += value.output ?? 0; sum.reasoning += value.reasoning ?? 0; sum.cache_read += cache.read ?? 0; sum.cache_write += cache.write ?? 0; return sum; }, { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 });
|
|
125
|
+
const tools = new ToolObservations();
|
|
126
|
+
for (const item of messages) for (const part of item.parts ?? []) tools.add(openCodeToolObservation(part, item.info?.sessionID ?? sessionId, item.info?.id));
|
|
127
|
+
return { input_tokens: tokens.input, output_tokens: tokens.output, reasoning_tokens: tokens.reasoning, cached_input_tokens: tokens.cache_read, cache_write_tokens: tokens.cache_write, total_tokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache_read + tokens.cache_write, tool_calls: tools.summary(), cost_usd: assistants.reduce((sum, item) => sum + (item.info.cost ?? 0), 0) };
|
|
128
|
+
}
|