@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100
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 +561 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +91 -15
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1506 -334
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -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 +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -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/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +75 -21
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +676 -79
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +134 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +125 -80
- package/dist/services/eval-snapshots.js +838 -74
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +609 -310
- package/dist/services/lanes.js +62 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +425 -0
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +78 -27
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +27 -18
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +136 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +803 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +437 -81
- package/dist/services/runtime-budget.js +439 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +21 -19
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +61 -80
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +112 -108
- package/dist/services/stage-pause.js +117 -73
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +108 -132
- package/dist/services/vnext-code-review.js +245 -98
- package/dist/services/vnext-code.js +355 -257
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +166 -26
- package/dist/services/vnext-merge.js +413 -147
- package/dist/services/vnext-plan-review.js +182 -109
- package/dist/services/vnext-plan.js +199 -82
- package/dist/services/vnext-protocolize.js +133 -60
- package/dist/services/vnext-specify.js +94 -71
- package/dist/services/work-registry.js +813 -160
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +569 -16
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
- package/tools/repair-paused-run-status.mjs +59 -0
package/dist/services/hooks.js
CHANGED
|
@@ -2,17 +2,22 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { parse, stringify } from "smol-toml";
|
|
5
|
+
import { parse, stringify, TomlError } from "smol-toml";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { entityReferenceVariants } from "../shared/entity-references.js";
|
|
7
8
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
|
-
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
10
|
import { appendAudit } from "./audit.js";
|
|
10
|
-
import {
|
|
11
|
+
import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
|
|
12
|
+
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
13
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
14
|
+
import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
15
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
|
|
11
16
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
12
|
-
import { activeFlowSessionsForProject,
|
|
17
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
13
18
|
const defaultProfileName = "default";
|
|
14
19
|
const maxSanitizedSummaryLength = 1600;
|
|
15
|
-
const
|
|
20
|
+
export const codexHomeSharedEntries = [
|
|
16
21
|
"auth.json",
|
|
17
22
|
"auth.bk",
|
|
18
23
|
"sessions",
|
|
@@ -49,22 +54,28 @@ export function planCodexHome(context, input) {
|
|
|
49
54
|
config_path: paths.configPath,
|
|
50
55
|
hooks_path: paths.hooksPath,
|
|
51
56
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
52
|
-
shared_entries:
|
|
57
|
+
shared_entries: codexHomeSharedEntries,
|
|
53
58
|
exists: fs.existsSync(paths.targetHome),
|
|
54
59
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
|
-
export function
|
|
62
|
+
export function prepareCodexHome(context, input) {
|
|
58
63
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
59
64
|
const profile = resolveProfileName(input.profile);
|
|
60
65
|
const paths = resolveHomePaths(context, project, profile, input);
|
|
66
|
+
if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
|
|
67
|
+
throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
|
|
68
|
+
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
69
|
+
return { project, profile, paths, config };
|
|
70
|
+
}
|
|
71
|
+
export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
|
|
72
|
+
const { project, profile, paths, config } = prepared;
|
|
61
73
|
const now = context.now();
|
|
62
74
|
ensureDir(paths.targetHome);
|
|
63
75
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
64
76
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
65
77
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
66
78
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
67
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
68
79
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
69
80
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
70
81
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -135,7 +146,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
135
146
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
136
147
|
};
|
|
137
148
|
}
|
|
138
|
-
export function
|
|
149
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
139
150
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
140
151
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
141
152
|
}
|
|
@@ -143,9 +154,30 @@ export function removeCodexHome(context, input) {
|
|
|
143
154
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
144
155
|
const profile = resolveProfileName(input.profile);
|
|
145
156
|
const record = homeProfile(context, project.id, profile);
|
|
157
|
+
if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
|
|
158
|
+
let marker;
|
|
159
|
+
try {
|
|
160
|
+
const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
162
|
+
throw new Error("Invalid marker");
|
|
163
|
+
marker = value;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
|
|
167
|
+
}
|
|
168
|
+
if (marker.managed_by !== "dd-flow-cli" || marker.project_id !== project.id || marker.profile !== profile || marker.target_home !== record.target_home || marker.source_home !== record.source_home || canonicalPath(record.target_home, false) === canonicalPath(record.source_home, false)) {
|
|
169
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { project, profile, record, mode };
|
|
173
|
+
}
|
|
174
|
+
export function removeCodexHome(context, input) {
|
|
175
|
+
const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
|
|
146
176
|
if (!record) {
|
|
147
177
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
148
178
|
}
|
|
179
|
+
if (record.status === "removed")
|
|
180
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
149
181
|
const removedEntries = [];
|
|
150
182
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
151
183
|
const target = path.join(record.target_home, relative);
|
|
@@ -206,7 +238,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
206
238
|
recorded
|
|
207
239
|
};
|
|
208
240
|
}
|
|
209
|
-
export function
|
|
241
|
+
export function prepareCodexHooks(context, input) {
|
|
210
242
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
211
243
|
const target = resolveHookTarget(input.target);
|
|
212
244
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -216,9 +248,16 @@ export function installCodexHooks(context, input) {
|
|
|
216
248
|
hooks_path: location.hooksPath
|
|
217
249
|
});
|
|
218
250
|
}
|
|
219
|
-
const
|
|
220
|
-
const
|
|
251
|
+
const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
|
|
252
|
+
const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
|
|
253
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing) || (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== "object" || Array.isArray(existing.hooks) || Object.values(existing.hooks).some(entries => !Array.isArray(entries)))))
|
|
254
|
+
return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
|
|
255
|
+
return { project, target, location, existing, text };
|
|
256
|
+
}
|
|
257
|
+
export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
258
|
+
const { project, target, location, existing, text } = prepared;
|
|
221
259
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
260
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
222
261
|
ensureDir(path.dirname(location.hooksPath));
|
|
223
262
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
224
263
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -237,20 +276,11 @@ export function installCodexHooks(context, input) {
|
|
|
237
276
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
238
277
|
};
|
|
239
278
|
}
|
|
240
|
-
export function removeCodexHooks(context, input) {
|
|
241
|
-
const project
|
|
242
|
-
const target = resolveHookTarget(input.target);
|
|
243
|
-
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
244
|
-
if (target === "default" && !input.yes) {
|
|
245
|
-
throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
|
|
246
|
-
target,
|
|
247
|
-
hooks_path: location.hooksPath
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
const existing = readJsonIfExists(location.hooksPath) ?? {};
|
|
279
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
280
|
+
const { project, target, location, existing, text } = prepared;
|
|
251
281
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
252
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
253
282
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
283
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
254
284
|
ensureDir(path.dirname(location.hooksPath));
|
|
255
285
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
256
286
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -267,207 +297,292 @@ export function handleCodexHook(context, input) {
|
|
|
267
297
|
if (eventName !== "PreToolUse") {
|
|
268
298
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
269
299
|
}
|
|
270
|
-
const
|
|
300
|
+
const rootProviderSessionId = stringValue(payload.session_id) ?? null;
|
|
301
|
+
// Codex deliberately retains its legacy root session_id inside a
|
|
302
|
+
// thread-spawned subagent. `agent_id` is the current child Thread id and
|
|
303
|
+
// therefore the only valid identity for a fresh Work.
|
|
304
|
+
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
305
|
+
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
271
306
|
const turnId = stringValue(payload.turn_id);
|
|
272
307
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
273
308
|
const command = lifecycleCommandFromPayload(payload);
|
|
274
309
|
if (!command)
|
|
275
310
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
276
311
|
const lifecycle = lifecycleFacts(command);
|
|
277
|
-
if (!lifecycle)
|
|
312
|
+
if (!lifecycle && !continuationCommand(command))
|
|
278
313
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
314
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
279
315
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
280
316
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
permissionDecision: "deny",
|
|
291
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
292
|
-
}
|
|
293
|
-
};
|
|
317
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
318
|
+
const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
|
|
319
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
320
|
+
const proof = context.codexHookProof;
|
|
321
|
+
if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
|
|
322
|
+
|| proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
|
|
323
|
+
|| proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
|
|
324
|
+
throw new AppError("native_hook_unproven", "Managed Codex admission requires its active native hook delivery, not a manual handler call", 1, { ...(invocationId ? { invocation_id: invocationId } : {}), effect: "no_effect" });
|
|
325
|
+
}
|
|
294
326
|
}
|
|
295
|
-
const
|
|
296
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
327
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
297
328
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
298
329
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
299
|
-
|
|
300
|
-
|
|
330
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
331
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
332
|
+
registerProject(context, { root: bootstrapRoot });
|
|
301
333
|
}
|
|
302
|
-
const project = projectForHook(context,
|
|
303
|
-
if (!project)
|
|
334
|
+
const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
|
|
335
|
+
if (!project) {
|
|
336
|
+
if (invocationId)
|
|
337
|
+
throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
|
|
304
338
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
339
|
+
}
|
|
340
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
|
|
341
|
+
// Codex retains the root session_id for a thread-spawned child, while the
|
|
342
|
+
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
343
|
+
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
311
344
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
328
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
329
|
-
});
|
|
330
|
-
if (effectiveSessionId) {
|
|
331
|
-
recordFlowSessionObservation(context, {
|
|
345
|
+
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
346
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
347
|
+
const eventCwd = stringValue(payload.cwd) ?? project.root;
|
|
348
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: eventCwd });
|
|
349
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
350
|
+
// Resolve legacy transcript discovery before acquiring the receipt write lock.
|
|
351
|
+
const transcriptPath = stringValue(payload.transcript_path)
|
|
352
|
+
?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
|
|
353
|
+
const persist = (scope) => {
|
|
354
|
+
if (scope)
|
|
355
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
|
|
356
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
|
|
357
|
+
const effectiveSessionId = storageId;
|
|
358
|
+
const protocolId = binding?.protocol_id ?? null;
|
|
359
|
+
const inserted = recordHookEvent(context, {
|
|
332
360
|
projectId: project.id,
|
|
333
|
-
sessionId: effectiveSessionId,
|
|
334
|
-
runId: observedSession?.run_id ?? null,
|
|
335
361
|
protocolId,
|
|
336
|
-
|
|
362
|
+
harness: "codex-desktop",
|
|
363
|
+
providerSessionId: providerSessionId ?? null,
|
|
364
|
+
parentSessionId,
|
|
365
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
366
|
+
// state or an agent-supplied shell command/payload.
|
|
367
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
368
|
+
sessionId: effectiveSessionId,
|
|
369
|
+
agentId: rawChildProviderSessionId ?? null,
|
|
370
|
+
turnId: turnId ?? null,
|
|
371
|
+
eventName,
|
|
337
372
|
toolName: toolName ?? null,
|
|
338
|
-
|
|
373
|
+
status: "observed",
|
|
374
|
+
payload,
|
|
375
|
+
eventKey,
|
|
376
|
+
matchKey,
|
|
377
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
378
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
339
379
|
});
|
|
340
|
-
|
|
341
|
-
|
|
380
|
+
if (effectiveSessionId) {
|
|
381
|
+
recordFlowSessionObservation(context, {
|
|
382
|
+
projectId: project.id,
|
|
383
|
+
sessionId: effectiveSessionId,
|
|
384
|
+
protocolId,
|
|
385
|
+
cwd: eventCwd,
|
|
386
|
+
toolName: toolName ?? null,
|
|
387
|
+
eventKey
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return { inserted, protocolId };
|
|
391
|
+
};
|
|
392
|
+
const common = {
|
|
342
393
|
ok: true,
|
|
343
|
-
observed: inserted,
|
|
344
|
-
duplicate: !inserted,
|
|
345
394
|
event_key: eventKey,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
395
|
+
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
396
|
+
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
397
|
+
protocol_id: null
|
|
398
|
+
};
|
|
399
|
+
if (!lifecycle) {
|
|
400
|
+
const persisted = context.db.writeTransaction(() => persist());
|
|
401
|
+
return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
|
|
402
|
+
}
|
|
403
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
404
|
+
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
405
|
+
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
406
|
+
if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
|
|
407
|
+
throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
|
|
408
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
409
|
+
...(invocationId ? { id: invocationId } : {}),
|
|
410
|
+
command,
|
|
411
|
+
harness: "codex-desktop",
|
|
412
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
|
|
413
|
+
recordReceipt: scope => { persist(scope); return eventKey; }
|
|
414
|
+
});
|
|
415
|
+
const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
416
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
417
|
+
if (admittedInvocationId)
|
|
418
|
+
return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
419
|
+
return {
|
|
420
|
+
...common,
|
|
421
|
+
protocol_id: stored?.protocol_id ?? null,
|
|
422
|
+
observed: !receipt.duplicate,
|
|
423
|
+
duplicate: receipt.duplicate,
|
|
424
|
+
hookSpecificOutput: {
|
|
425
|
+
hookEventName: "PreToolUse",
|
|
426
|
+
permissionDecision: "allow",
|
|
427
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const persisted = persist();
|
|
432
|
+
return {
|
|
433
|
+
...common,
|
|
434
|
+
protocol_id: persisted.protocolId,
|
|
435
|
+
observed: persisted.inserted,
|
|
436
|
+
duplicate: !persisted.inserted,
|
|
437
|
+
...(providerSessionId
|
|
349
438
|
? {
|
|
350
439
|
hookSpecificOutput: {
|
|
351
440
|
hookEventName: "PreToolUse",
|
|
352
441
|
permissionDecision: "allow",
|
|
353
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
442
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
354
443
|
}
|
|
355
444
|
}
|
|
356
445
|
: {})
|
|
357
446
|
};
|
|
358
447
|
}
|
|
359
|
-
/**
|
|
448
|
+
/** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
|
|
449
|
+
function codexNativeParentId(payload, childId) {
|
|
450
|
+
const transcript = stringValue(payload.transcript_path);
|
|
451
|
+
if (!transcript || !path.isAbsolute(transcript))
|
|
452
|
+
return null;
|
|
453
|
+
try {
|
|
454
|
+
const descriptor = fs.openSync(transcript, "r");
|
|
455
|
+
const chunks = [];
|
|
456
|
+
try {
|
|
457
|
+
for (;;) {
|
|
458
|
+
const buffer = Buffer.alloc(4096);
|
|
459
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
460
|
+
if (!length)
|
|
461
|
+
break;
|
|
462
|
+
const newline = buffer.subarray(0, length).indexOf(10);
|
|
463
|
+
chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
|
|
464
|
+
if (newline >= 0)
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
fs.closeSync(descriptor);
|
|
470
|
+
}
|
|
471
|
+
const firstLine = Buffer.concat(chunks).toString("utf8");
|
|
472
|
+
if (!firstLine)
|
|
473
|
+
return null;
|
|
474
|
+
const record = JSON.parse(firstLine);
|
|
475
|
+
return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
|
|
476
|
+
? record.payload.parent_thread_id
|
|
477
|
+
: null;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
484
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
360
485
|
export function handleZcodeEvent(context, input) {
|
|
361
486
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const params = objectRecord(notification.params);
|
|
365
|
-
const update = objectRecord(params.update);
|
|
366
|
-
if (update.sessionUpdate !== "tool_call")
|
|
487
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
488
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
367
489
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
368
|
-
const
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const rawInput = objectRecord(update.rawInput);
|
|
490
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
491
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
492
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
493
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
373
494
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
374
|
-
|
|
495
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
496
|
+
if (parsed.kind === "none")
|
|
497
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
498
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
499
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
500
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
501
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
502
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
503
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
507
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
508
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
509
|
+
*/
|
|
510
|
+
export function handleZcodeHook(context, input) {
|
|
511
|
+
return recordZcodeLifecycle(context, input);
|
|
512
|
+
}
|
|
513
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
514
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
515
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
516
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
517
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
518
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
519
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
520
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
521
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
375
522
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
376
|
-
}
|
|
377
523
|
const lifecycle = lifecycleFacts(command);
|
|
378
|
-
if (!lifecycle)
|
|
524
|
+
if (!lifecycle && !continuationCommand(command))
|
|
379
525
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
standalone_command: lifecycle.invocation.command
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
526
|
+
if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
|
|
527
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
386
528
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
529
|
+
const meta = objectRecord(hook._meta);
|
|
530
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
531
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
532
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
533
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
534
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
535
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
536
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
392
537
|
}
|
|
393
|
-
|
|
394
|
-
|
|
538
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
539
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
540
|
+
}
|
|
541
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
542
|
+
if (!toolUseId)
|
|
543
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
544
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
545
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
546
|
+
if (lifecycle?.bootstrapStageStart)
|
|
395
547
|
registerProject(context, { root: expectedRoot });
|
|
396
548
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
397
|
-
const
|
|
398
|
-
const
|
|
549
|
+
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
550
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
399
551
|
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
harness: "zcode-acp", providerSessionId, command
|
|
418
|
-
})).digest("hex");
|
|
419
|
-
const payload = {
|
|
420
|
-
session_id: providerSessionId,
|
|
421
|
-
agent_id: agentId,
|
|
422
|
-
cwd: expectedRoot,
|
|
423
|
-
tool_name: toolName ?? "Bash",
|
|
424
|
-
tool_input: rawInput,
|
|
425
|
-
command,
|
|
426
|
-
provider: stringValue(observedProfile.provider),
|
|
427
|
-
model: stringValue(observedProfile.model),
|
|
428
|
-
reasoning: stringValue(observedProfile.reasoning),
|
|
429
|
-
mode: stringValue(observedProfile.mode),
|
|
430
|
-
agent_type: stringValue(runtime.source)
|
|
552
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
553
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
554
|
+
const persist = (scope) => {
|
|
555
|
+
if (scope)
|
|
556
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
557
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
558
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
559
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
560
|
+
const inserted = recordHookEvent(context, {
|
|
561
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
562
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
563
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
564
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
565
|
+
});
|
|
566
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
567
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
|
|
568
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
431
569
|
};
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
570
|
+
if (!lifecycle)
|
|
571
|
+
return context.db.writeTransaction(() => persist());
|
|
572
|
+
let persistedResult;
|
|
573
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
574
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
435
575
|
harness: "zcode-acp",
|
|
436
|
-
providerSessionId,
|
|
437
|
-
|
|
438
|
-
daemonId: daemonId ?? null,
|
|
439
|
-
sessionId,
|
|
440
|
-
agentId: agentId ?? null,
|
|
441
|
-
turnId: null,
|
|
442
|
-
eventName: "PreToolUse",
|
|
443
|
-
toolName: toolName ?? "Bash",
|
|
444
|
-
status: "observed",
|
|
445
|
-
payload,
|
|
446
|
-
eventKey,
|
|
447
|
-
matchKey,
|
|
448
|
-
transcriptPath: null,
|
|
449
|
-
cwd: expectedRoot
|
|
576
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
577
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
450
578
|
});
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
eventKey
|
|
459
|
-
});
|
|
460
|
-
return {
|
|
461
|
-
ok: true,
|
|
462
|
-
observed: inserted,
|
|
463
|
-
duplicate: !inserted,
|
|
464
|
-
event_key: eventKey,
|
|
465
|
-
harness: "zcode-acp",
|
|
466
|
-
session_id: sessionId,
|
|
467
|
-
provider_session_id: providerSessionId,
|
|
468
|
-
parent_session_id: parentSessionId,
|
|
469
|
-
daemon_id: daemonId ?? null
|
|
470
|
-
};
|
|
579
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
580
|
+
if (admittedInvocationId)
|
|
581
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
582
|
+
// Without an issued managed attempt this is still ordinary hook evidence.
|
|
583
|
+
// Preserve persist()'s replay result instead of overwriting it with the
|
|
584
|
+
// admission layer's non-authoritative duplicate flag.
|
|
585
|
+
return persistedResult ?? persist();
|
|
471
586
|
}
|
|
472
587
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
473
588
|
export function handleGrokEvent(context, input) {
|
|
@@ -483,21 +598,11 @@ export function handleGrokEvent(context, input) {
|
|
|
483
598
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
484
599
|
}
|
|
485
600
|
const lifecycle = lifecycleFacts(command);
|
|
486
|
-
if (!lifecycle)
|
|
601
|
+
if (!lifecycle && !continuationCommand(command))
|
|
487
602
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
488
|
-
|
|
489
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
490
|
-
standalone_command: lifecycle.invocation.command
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
603
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
494
604
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
495
|
-
|
|
496
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
497
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
605
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
501
606
|
if (bootstrapStageStart)
|
|
502
607
|
registerProject(context, { root: expectedRoot });
|
|
503
608
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
@@ -509,45 +614,54 @@ export function handleGrokEvent(context, input) {
|
|
|
509
614
|
if (!rootProviderSessionId || !providerSessionId)
|
|
510
615
|
throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
|
|
511
616
|
const isChild = providerSessionId !== rootProviderSessionId;
|
|
512
|
-
const sessionId =
|
|
513
|
-
const parentSessionId = isChild ?
|
|
617
|
+
const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
|
|
618
|
+
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
514
619
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
515
620
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
516
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
517
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
518
|
-
}, sessionId) : undefined;
|
|
519
621
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
520
|
-
harness: "grok-acp", providerSessionId,
|
|
622
|
+
harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
|
|
623
|
+
promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
521
624
|
})).digest("hex");
|
|
625
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
522
626
|
const payload = {
|
|
523
627
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
524
628
|
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
|
|
525
629
|
};
|
|
526
630
|
const inserted = recordHookEvent(context, {
|
|
527
|
-
projectId: project.id, protocolId:
|
|
631
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
528
632
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
529
633
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
530
634
|
});
|
|
531
635
|
recordFlowSessionObservation(context, {
|
|
532
|
-
projectId: project.id, sessionId,
|
|
636
|
+
projectId: project.id, sessionId,
|
|
533
637
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
534
638
|
});
|
|
535
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey,
|
|
536
|
-
|
|
537
|
-
return
|
|
538
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
539
|
-
: result;
|
|
639
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
640
|
+
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
641
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
540
642
|
}
|
|
541
643
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
542
644
|
export function handleOpenCodeEvent(context, input) {
|
|
543
645
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
544
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
545
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
546
646
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
547
647
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
648
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
649
|
+
}
|
|
650
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
651
|
+
export function handleDroidEvent(context, input) {
|
|
652
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
653
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
654
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
655
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
656
|
+
}
|
|
657
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
658
|
+
const agy = harness === "antigravity-cli";
|
|
659
|
+
const droid = harness === "droid-cli";
|
|
660
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
661
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
548
662
|
const phase = stringValue(event.phase);
|
|
549
663
|
if (phase !== "before" && phase !== "after")
|
|
550
|
-
throw new AppError(
|
|
664
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
551
665
|
const session = objectRecord(event.session);
|
|
552
666
|
const profile = objectRecord(event.profile);
|
|
553
667
|
const rawInput = objectRecord(event.input);
|
|
@@ -555,18 +669,20 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
555
669
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
556
670
|
const daemonId = stringValue(event.daemon_id);
|
|
557
671
|
const toolCallId = stringValue(event.tool_call_id);
|
|
558
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
672
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
559
673
|
const toolName = stringValue(event.tool);
|
|
560
674
|
const directory = stringValue(session.directory);
|
|
561
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
562
|
-
throw new AppError(
|
|
675
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
676
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
563
677
|
}
|
|
564
678
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
565
679
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
566
|
-
throw new AppError(
|
|
680
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
567
681
|
}
|
|
568
|
-
|
|
569
|
-
|
|
682
|
+
if (droid && nativeParentId === providerSessionId)
|
|
683
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
684
|
+
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
685
|
+
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
570
686
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
571
687
|
const baseKey = `${eventId}:${phase}`;
|
|
572
688
|
if (phase === "after") {
|
|
@@ -575,46 +691,41 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
575
691
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
576
692
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
577
693
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
578
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
694
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
579
695
|
});
|
|
580
|
-
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
696
|
+
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
581
697
|
}
|
|
582
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
698
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
583
699
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
584
700
|
}
|
|
585
701
|
const lifecycle = lifecycleFacts(command);
|
|
586
|
-
if (!lifecycle)
|
|
702
|
+
if (!lifecycle && !continuationCommand(command))
|
|
587
703
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
592
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
593
|
-
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
594
|
-
}
|
|
595
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
704
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
705
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
596
706
|
if (bootstrapStageStart)
|
|
597
707
|
registerProject(context, { root: expectedRoot });
|
|
598
708
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
709
|
+
if (droid) {
|
|
710
|
+
const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
|
|
711
|
+
if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
|
|
712
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
599
715
|
const agentId = stringValue(session.agent_id);
|
|
600
|
-
|
|
601
|
-
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
602
|
-
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
603
|
-
reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
|
|
604
|
-
metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
|
|
605
|
-
}, sessionId) : undefined;
|
|
716
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
|
|
606
717
|
const payload = {
|
|
607
718
|
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
608
|
-
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
|
|
609
|
-
mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
|
|
719
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
720
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
610
721
|
};
|
|
611
722
|
const inserted = recordHookEvent(context, {
|
|
612
|
-
projectId: project.id, protocolId:
|
|
723
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
613
724
|
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
614
|
-
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
725
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
615
726
|
});
|
|
616
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
617
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
727
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
728
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
|
|
618
729
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
619
730
|
}
|
|
620
731
|
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
@@ -630,6 +741,13 @@ export function handleAgyEvent(context, input) {
|
|
|
630
741
|
const toolName = stringValue(event.tool);
|
|
631
742
|
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
632
743
|
throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
|
|
744
|
+
const rawInput = objectRecord(event.input);
|
|
745
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
746
|
+
const invalidStep = !Number.isSafeInteger(event.step_index) || Number(event.step_index) < 0;
|
|
747
|
+
const invalidExecution = event.execution_num !== undefined && (!Number.isSafeInteger(event.execution_num) || Number(event.execution_num) < 0);
|
|
748
|
+
if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command)) && (invalidStep || invalidExecution)) {
|
|
749
|
+
throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require a native step identity and a valid execution identity when provided", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
750
|
+
}
|
|
633
751
|
const translated = {
|
|
634
752
|
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
635
753
|
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
@@ -662,15 +780,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
662
780
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
663
781
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
664
782
|
*/
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
783
|
+
/**
|
|
784
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
785
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
786
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
787
|
+
*/
|
|
788
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
670
789
|
const analysis = parseLifecycleCommand(command);
|
|
671
|
-
if (analysis.kind !== "standalone"
|
|
790
|
+
if (analysis.kind !== "standalone")
|
|
672
791
|
return command;
|
|
673
|
-
|
|
792
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
793
|
+
return command;
|
|
794
|
+
}
|
|
795
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
796
|
+
if (analysis.invocation.rewriteSafe) {
|
|
797
|
+
const index = shellSuffixOffset(command);
|
|
798
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
799
|
+
}
|
|
800
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
801
|
+
const index = firstHeredoc(command);
|
|
802
|
+
if (index >= 0)
|
|
803
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
804
|
+
}
|
|
805
|
+
return command;
|
|
806
|
+
}
|
|
807
|
+
function firstHeredoc(command) {
|
|
808
|
+
let quoteState = null;
|
|
809
|
+
let escaped = false;
|
|
810
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
811
|
+
const char = command[index];
|
|
812
|
+
if (escaped) {
|
|
813
|
+
escaped = false;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (char === "\\" && quoteState !== "'") {
|
|
817
|
+
escaped = true;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (quoteState) {
|
|
821
|
+
if (char === quoteState)
|
|
822
|
+
quoteState = null;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (char === "'" || char === '"') {
|
|
826
|
+
quoteState = char;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
830
|
+
return index;
|
|
831
|
+
}
|
|
832
|
+
return -1;
|
|
674
833
|
}
|
|
675
834
|
/**
|
|
676
835
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -691,25 +850,51 @@ function lifecycleFacts(command) {
|
|
|
691
850
|
return null;
|
|
692
851
|
const invocation = analysis.invocation;
|
|
693
852
|
const stageStart = invocation.operation === "stage_start";
|
|
853
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
854
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
694
855
|
const stageResume = invocation.operation === "stage_resume";
|
|
695
856
|
const workStart = invocation.operation === "work_start";
|
|
857
|
+
const workFinish = invocation.operation === "work_finish";
|
|
858
|
+
const workFail = invocation.operation === "work_fail";
|
|
696
859
|
return {
|
|
697
860
|
analysis,
|
|
698
861
|
invocation,
|
|
699
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
700
862
|
stageStart,
|
|
863
|
+
stageFinish,
|
|
864
|
+
stagePause,
|
|
701
865
|
stageResume,
|
|
702
|
-
workStart,
|
|
866
|
+
workStart, workFinish, workFail,
|
|
703
867
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
704
868
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
705
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
706
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
707
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
869
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
870
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
871
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
708
872
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
709
873
|
};
|
|
710
874
|
}
|
|
711
|
-
|
|
712
|
-
|
|
875
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
876
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
877
|
+
// not a second authority. Everything else remains standalone-only.
|
|
878
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
879
|
+
const operation = facts.invocation.operation;
|
|
880
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
881
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
882
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
883
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
884
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
885
|
+
}
|
|
886
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
887
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
888
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
889
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
890
|
+
return null;
|
|
891
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
892
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
893
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
894
|
+
if (invocation.operation === "recovery_accept") {
|
|
895
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
896
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
897
|
+
}
|
|
713
898
|
if (bootstrapStageStart)
|
|
714
899
|
return bootstrapMatchKey({
|
|
715
900
|
projectRoot,
|
|
@@ -717,14 +902,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
717
902
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
718
903
|
...(contextSha256 ? { contextSha256 } : {})
|
|
719
904
|
});
|
|
720
|
-
if (stageStart &&
|
|
721
|
-
return stageStartMatchKey(
|
|
722
|
-
if (
|
|
723
|
-
return
|
|
905
|
+
if (stageStart && effectiveRunId && stageName)
|
|
906
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
907
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
908
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
909
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
910
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
911
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
912
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
724
913
|
if (workStart && workId)
|
|
725
|
-
return workStartMatchKey(workId,
|
|
914
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
915
|
+
if (workFinish && workId)
|
|
916
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
917
|
+
if (workFail && workId)
|
|
918
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
726
919
|
return null;
|
|
727
920
|
}
|
|
921
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
922
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
923
|
+
export function observeCommandHook(context, command) {
|
|
924
|
+
const facts = lifecycleFacts(command);
|
|
925
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
926
|
+
return;
|
|
927
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
928
|
+
if (!project)
|
|
929
|
+
return; // Bootstrap has not registered a project yet.
|
|
930
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
931
|
+
if (!matchKey)
|
|
932
|
+
return;
|
|
933
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
934
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
935
|
+
return;
|
|
936
|
+
if (!key) {
|
|
937
|
+
try {
|
|
938
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
return;
|
|
942
|
+
} // Dispatch still owns its mandatory admission error.
|
|
943
|
+
}
|
|
944
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ? AND match_key = ? AND status = 'observed'", [project.id, key, matchKey]);
|
|
945
|
+
if (event)
|
|
946
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
947
|
+
}
|
|
948
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
949
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
950
|
+
}
|
|
951
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
952
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
953
|
+
}
|
|
954
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
955
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
956
|
+
}
|
|
957
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
958
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
959
|
+
}
|
|
960
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
961
|
+
const runReferences = entityReferenceVariants(runId);
|
|
962
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
963
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
964
|
+
}
|
|
965
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
966
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
967
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
968
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
969
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
970
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
971
|
+
}).eventKey;
|
|
972
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
973
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
974
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
975
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
976
|
+
}
|
|
977
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
978
|
+
if (claimed.changes !== 1)
|
|
979
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
980
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
981
|
+
}
|
|
982
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
983
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
984
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
985
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
986
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
987
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
988
|
+
}).eventKey;
|
|
989
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
990
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
991
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
992
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
|
|
993
|
+
}
|
|
994
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
995
|
+
if (claimed.changes !== 1)
|
|
996
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
997
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
998
|
+
}
|
|
728
999
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
729
1000
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
730
1001
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -736,10 +1007,11 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
|
736
1007
|
/** Reads immutable identity facts already captured by PreToolUse. */
|
|
737
1008
|
export function hookSessionIdentity(context, projectId, eventKey) {
|
|
738
1009
|
const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
|
|
739
|
-
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
|
|
1010
|
+
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = COALESCE(he.provider_session_id, he.session_id)
|
|
740
1011
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
741
1012
|
if (!event?.session_id)
|
|
742
1013
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
1014
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
743
1015
|
return {
|
|
744
1016
|
hookEventId: event.id,
|
|
745
1017
|
harness: event.harness,
|
|
@@ -747,7 +1019,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
747
1019
|
parentSessionId: event.parent_session_id,
|
|
748
1020
|
daemonId: event.daemon_id,
|
|
749
1021
|
agentId: event.agent_id,
|
|
750
|
-
|
|
1022
|
+
// agent_id is an auxiliary provider fact, never a replacement Session ID.
|
|
1023
|
+
sessionId: event.session_id,
|
|
1024
|
+
nativeSessionId: event.provider_session_id ?? event.session_id,
|
|
751
1025
|
turnId: event.turn_id,
|
|
752
1026
|
transcriptPath: event.transcript_path,
|
|
753
1027
|
provider: event.provider,
|
|
@@ -774,21 +1048,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
774
1048
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
775
1049
|
export function findRecentMatchingHookEvent(context, input) {
|
|
776
1050
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1051
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1052
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1053
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1054
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1055
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1056
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1057
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1058
|
+
AND (? IS NULL OR session_id = ?)
|
|
1059
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1060
|
+
let events = find();
|
|
1061
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1062
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
783
1063
|
const deadline = Date.now() + 250;
|
|
784
|
-
while (
|
|
1064
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
785
1065
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
786
|
-
|
|
1066
|
+
events = find();
|
|
1067
|
+
}
|
|
1068
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1069
|
+
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
|
|
787
1070
|
}
|
|
788
|
-
if (
|
|
789
|
-
throw new AppError(
|
|
1071
|
+
if (events.length > 1) {
|
|
1072
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
|
|
790
1073
|
}
|
|
791
|
-
return { id:
|
|
1074
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
792
1075
|
}
|
|
793
1076
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
794
1077
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -811,9 +1094,9 @@ export function bootstrapMatchKey(input) {
|
|
|
811
1094
|
})).digest("hex");
|
|
812
1095
|
}
|
|
813
1096
|
export function claimWorkStartHookEvent(context, input) {
|
|
814
|
-
const expected =
|
|
1097
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
815
1098
|
const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
816
|
-
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.
|
|
1099
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
817
1100
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
818
1101
|
}
|
|
819
1102
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -821,15 +1104,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
821
1104
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
822
1105
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
823
1106
|
}
|
|
824
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
825
1107
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
826
1108
|
export function assertStageStartHookEvent(context, input) {
|
|
827
|
-
const expected =
|
|
1109
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
828
1110
|
const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
829
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
830
|
-
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey,
|
|
1111
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1112
|
+
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
831
1113
|
}
|
|
832
|
-
|
|
1114
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1115
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1116
|
+
return identity;
|
|
833
1117
|
}
|
|
834
1118
|
export function claimStageStartHookEvent(context, input) {
|
|
835
1119
|
assertStageStartHookEvent(context, input);
|
|
@@ -859,24 +1143,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
859
1143
|
}
|
|
860
1144
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
861
1145
|
export function claimStageResumeHookEvent(context, input) {
|
|
862
|
-
const
|
|
863
|
-
const
|
|
864
|
-
const event = input.eventKey
|
|
865
|
-
|
|
866
|
-
: context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
|
|
867
|
-
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
1146
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1147
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1148
|
+
const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1149
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
868
1150
|
throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
|
|
869
1151
|
}
|
|
1152
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1153
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
870
1154
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
871
1155
|
if (claimed.changes !== 1)
|
|
872
1156
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
873
|
-
return
|
|
1157
|
+
return identity;
|
|
874
1158
|
}
|
|
875
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1159
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
876
1160
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
877
1161
|
operation: "work_start",
|
|
878
1162
|
work_id: workId,
|
|
879
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1163
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1164
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
880
1165
|
})).digest("hex");
|
|
881
1166
|
}
|
|
882
1167
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -916,6 +1201,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
916
1201
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
917
1202
|
}
|
|
918
1203
|
function resolveProfileName(profile) {
|
|
1204
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1205
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
919
1206
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
920
1207
|
}
|
|
921
1208
|
function sanitizeProfile(value) {
|
|
@@ -943,11 +1230,12 @@ function readTomlIfExists(file) {
|
|
|
943
1230
|
if (!fs.existsSync(file)) {
|
|
944
1231
|
return {};
|
|
945
1232
|
}
|
|
1233
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
946
1234
|
try {
|
|
947
|
-
return parse(
|
|
1235
|
+
return parse(text);
|
|
948
1236
|
}
|
|
949
1237
|
catch (error) {
|
|
950
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1238
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
951
1239
|
}
|
|
952
1240
|
}
|
|
953
1241
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -966,7 +1254,7 @@ function objectTable(value) {
|
|
|
966
1254
|
function createSharedLinks(sourceHome, targetHome) {
|
|
967
1255
|
const linked = [];
|
|
968
1256
|
const skipped = [];
|
|
969
|
-
for (const entry of
|
|
1257
|
+
for (const entry of codexHomeSharedEntries) {
|
|
970
1258
|
const source = path.join(sourceHome, entry);
|
|
971
1259
|
const target = path.join(targetHome, entry);
|
|
972
1260
|
if (!fs.existsSync(source)) {
|
|
@@ -989,7 +1277,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
989
1277
|
return { linked, skipped };
|
|
990
1278
|
}
|
|
991
1279
|
function removeSharedSymlinks(targetHome) {
|
|
992
|
-
for (const entry of
|
|
1280
|
+
for (const entry of codexHomeSharedEntries) {
|
|
993
1281
|
const target = path.join(targetHome, entry);
|
|
994
1282
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
995
1283
|
fs.rmSync(target);
|
|
@@ -1002,7 +1290,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1002
1290
|
}
|
|
1003
1291
|
}
|
|
1004
1292
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1005
|
-
return
|
|
1293
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1006
1294
|
const source = path.join(sourceHome, entry);
|
|
1007
1295
|
const target = path.join(targetHome, entry);
|
|
1008
1296
|
return {
|
|
@@ -1023,10 +1311,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1023
1311
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1024
1312
|
}
|
|
1025
1313
|
function commandHook(command, statusMessage) {
|
|
1026
|
-
|
|
1314
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1315
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1316
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1317
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1027
1318
|
}
|
|
1028
1319
|
function hookCommand(event) {
|
|
1029
|
-
const
|
|
1320
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1321
|
+
const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin" dd-flow ${args}; fi`;
|
|
1030
1322
|
return `/bin/bash -c 'log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
|
|
1031
1323
|
}
|
|
1032
1324
|
function resolveHookTarget(target) {
|
|
@@ -1202,11 +1494,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1202
1494
|
function installationScope(target, profile, hooksPath) {
|
|
1203
1495
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1204
1496
|
}
|
|
1205
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1497
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1206
1498
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1207
1499
|
const now = context.now();
|
|
1208
1500
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1209
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1210
1501
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1211
1502
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1212
1503
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1260,12 +1551,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1260
1551
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1261
1552
|
}
|
|
1262
1553
|
function recordHookEvent(context, input) {
|
|
1263
|
-
|
|
1264
|
-
if (existing)
|
|
1554
|
+
if (assertHookEventReplay(context, input))
|
|
1265
1555
|
return false;
|
|
1266
1556
|
context.db.run(`INSERT INTO hook_events
|
|
1267
|
-
|
|
1268
|
-
|
|
1557
|
+
(project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
1558
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1269
1559
|
input.projectId,
|
|
1270
1560
|
input.protocolId,
|
|
1271
1561
|
input.harness,
|
|
@@ -1292,6 +1582,23 @@ function recordHookEvent(context, input) {
|
|
|
1292
1582
|
]);
|
|
1293
1583
|
return true;
|
|
1294
1584
|
}
|
|
1585
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1586
|
+
function assertHookEventReplay(context, input) {
|
|
1587
|
+
const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
|
|
1588
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1589
|
+
if (existing) {
|
|
1590
|
+
const observed = {
|
|
1591
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1592
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1593
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1594
|
+
};
|
|
1595
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1596
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1597
|
+
}
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1295
1602
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1296
1603
|
if (explicitRoot)
|
|
1297
1604
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1318,14 +1625,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1318
1625
|
command
|
|
1319
1626
|
})).digest("hex");
|
|
1320
1627
|
}
|
|
1321
|
-
function commandFromPayload(payload) {
|
|
1322
|
-
const direct = stringValue(payload.command);
|
|
1323
|
-
if (direct) {
|
|
1324
|
-
return direct;
|
|
1325
|
-
}
|
|
1326
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1327
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1328
|
-
}
|
|
1329
1628
|
function toolNameFromPayload(payload) {
|
|
1330
1629
|
const toolInput = objectRecord(payload.tool_input);
|
|
1331
1630
|
return stringValue(toolInput.name);
|
|
@@ -1368,10 +1667,10 @@ function writeJsonFile(file, value) {
|
|
|
1368
1667
|
ensureDir(path.dirname(file));
|
|
1369
1668
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1370
1669
|
}
|
|
1371
|
-
function backupFile(file, now) {
|
|
1670
|
+
function backupFile(file, now, text) {
|
|
1372
1671
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1373
1672
|
const backup = `${file}.${suffix}.bak`;
|
|
1374
|
-
fs.
|
|
1673
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1375
1674
|
return backup;
|
|
1376
1675
|
}
|
|
1377
1676
|
function hashObject(value) {
|