@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -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 +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -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 +910 -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 +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -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/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-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/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +120 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -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 +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -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 +211 -0
- package/dist/services/run-controller-capture.js +134 -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 +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -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 +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +223 -215
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +592 -154
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
package/dist/services/hooks.js
CHANGED
|
@@ -8,12 +8,14 @@ import { parseJsonObject } from "../shared/json.js";
|
|
|
8
8
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
-
import {
|
|
11
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
12
|
+
import { observeLifecycleInvocation, settleLifecycleNoEffectRejection } from "./lifecycle-invocations.js";
|
|
13
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
|
|
12
14
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
15
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
14
16
|
const defaultProfileName = "default";
|
|
15
17
|
const maxSanitizedSummaryLength = 1600;
|
|
16
|
-
const
|
|
18
|
+
export const codexHomeSharedEntries = [
|
|
17
19
|
"auth.json",
|
|
18
20
|
"auth.bk",
|
|
19
21
|
"sessions",
|
|
@@ -50,7 +52,7 @@ export function planCodexHome(context, input) {
|
|
|
50
52
|
config_path: paths.configPath,
|
|
51
53
|
hooks_path: paths.hooksPath,
|
|
52
54
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
53
|
-
shared_entries:
|
|
55
|
+
shared_entries: codexHomeSharedEntries,
|
|
54
56
|
exists: fs.existsSync(paths.targetHome),
|
|
55
57
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
56
58
|
};
|
|
@@ -268,7 +270,13 @@ export function handleCodexHook(context, input) {
|
|
|
268
270
|
if (eventName !== "PreToolUse") {
|
|
269
271
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
270
272
|
}
|
|
271
|
-
const
|
|
273
|
+
const rootProviderSessionId = stringValue(payload.session_id) ?? null;
|
|
274
|
+
// Codex deliberately retains its legacy root session_id inside a
|
|
275
|
+
// thread-spawned subagent. `agent_id` is the current child Thread id and
|
|
276
|
+
// therefore the only valid identity for a fresh Work.
|
|
277
|
+
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
278
|
+
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
279
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
272
280
|
const turnId = stringValue(payload.turn_id);
|
|
273
281
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
274
282
|
const command = lifecycleCommandFromPayload(payload);
|
|
@@ -293,32 +301,57 @@ export function handleCodexHook(context, input) {
|
|
|
293
301
|
}
|
|
294
302
|
};
|
|
295
303
|
}
|
|
296
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
304
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
297
305
|
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
298
306
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
299
307
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
300
308
|
if (bootstrapStageStart && commandProjectRoot) {
|
|
301
309
|
registerProject(context, { root: commandProjectRoot });
|
|
302
310
|
}
|
|
303
|
-
|
|
311
|
+
let project;
|
|
312
|
+
try {
|
|
313
|
+
project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
// A terminal command outside a registered flow gets no trusted receipt;
|
|
317
|
+
// the CLI will fail closed if it is actually executed. This keeps hooks
|
|
318
|
+
// inert for unrelated shell-parser probes and stale project directories.
|
|
319
|
+
if (stageFinish || stagePause || workFinish || workFail)
|
|
320
|
+
return { ok: true, observed: false, reason: "project_not_registered" };
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
304
323
|
if (!project)
|
|
305
324
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
306
|
-
|
|
307
|
-
|
|
325
|
+
// Codex retains the root session_id for a thread-spawned child, while the
|
|
326
|
+
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
327
|
+
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
328
|
+
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
329
|
+
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
330
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
331
|
+
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: stringValue(payload.cwd) ?? null });
|
|
332
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
333
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
|
|
308
334
|
const observedSession = flowPayload
|
|
309
|
-
? bindObservedFlowSession(context, project, {
|
|
335
|
+
? bindObservedFlowSession(context, project, {
|
|
336
|
+
...flowPayload,
|
|
337
|
+
harness: "codex-desktop",
|
|
338
|
+
provider_session_id: providerSessionId ?? flowPayload.provider_session_id ?? null,
|
|
339
|
+
parent_session_id: parentSessionId
|
|
340
|
+
}, storageId ?? undefined)
|
|
310
341
|
: undefined;
|
|
311
342
|
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
312
343
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
313
|
-
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
314
344
|
const inserted = recordHookEvent(context, {
|
|
315
345
|
projectId: project.id,
|
|
316
346
|
protocolId,
|
|
317
347
|
harness: "codex-desktop",
|
|
318
|
-
providerSessionId:
|
|
319
|
-
parentSessionId
|
|
348
|
+
providerSessionId: providerSessionId ?? null,
|
|
349
|
+
parentSessionId,
|
|
350
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
351
|
+
// state or an agent-supplied shell command/payload.
|
|
352
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
320
353
|
sessionId: effectiveSessionId,
|
|
321
|
-
agentId:
|
|
354
|
+
agentId: rawChildProviderSessionId ?? null,
|
|
322
355
|
turnId: turnId ?? null,
|
|
323
356
|
eventName,
|
|
324
357
|
toolName: toolName ?? null,
|
|
@@ -345,9 +378,10 @@ export function handleCodexHook(context, input) {
|
|
|
345
378
|
observed: inserted,
|
|
346
379
|
duplicate: !inserted,
|
|
347
380
|
event_key: eventKey,
|
|
348
|
-
session:
|
|
381
|
+
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
382
|
+
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
349
383
|
protocol_id: protocolId,
|
|
350
|
-
...(
|
|
384
|
+
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
351
385
|
? {
|
|
352
386
|
hookSpecificOutput: {
|
|
353
387
|
hookEventName: "PreToolUse",
|
|
@@ -358,117 +392,138 @@ export function handleCodexHook(context, input) {
|
|
|
358
392
|
: {})
|
|
359
393
|
};
|
|
360
394
|
}
|
|
361
|
-
/**
|
|
395
|
+
/** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
|
|
396
|
+
function codexNativeParentId(payload, childId) {
|
|
397
|
+
const transcript = stringValue(payload.transcript_path);
|
|
398
|
+
if (!transcript || !path.isAbsolute(transcript))
|
|
399
|
+
return null;
|
|
400
|
+
try {
|
|
401
|
+
const firstLine = fs.readFileSync(transcript, "utf8").split("\n", 1)[0];
|
|
402
|
+
if (!firstLine)
|
|
403
|
+
return null;
|
|
404
|
+
const record = JSON.parse(firstLine);
|
|
405
|
+
return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
|
|
406
|
+
? record.payload.parent_thread_id
|
|
407
|
+
: null;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
414
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
362
415
|
export function handleZcodeEvent(context, input) {
|
|
363
416
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
364
|
-
|
|
417
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
418
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
365
419
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
369
|
-
return { ok: true, observed: false, reason: "
|
|
370
|
-
const updateMeta = objectRecord(update._meta);
|
|
371
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
372
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
373
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
374
|
-
const rawInput = objectRecord(update.rawInput);
|
|
420
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
421
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
422
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
423
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
375
424
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
376
|
-
|
|
425
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
426
|
+
if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
|
|
427
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
428
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
429
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
430
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
431
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
432
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
433
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
437
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
438
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
439
|
+
*/
|
|
440
|
+
export function handleZcodeHook(context, input) {
|
|
441
|
+
return recordZcodeLifecycle(context, input);
|
|
442
|
+
}
|
|
443
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
444
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
445
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
446
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
447
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
448
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
449
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
450
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
451
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
377
452
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
378
|
-
}
|
|
379
453
|
const lifecycle = lifecycleFacts(command);
|
|
380
454
|
if (!lifecycle)
|
|
381
455
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
382
|
-
if (lifecycle.
|
|
383
|
-
|
|
384
|
-
standalone_command: lifecycle.invocation.command
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
456
|
+
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
457
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
388
458
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
389
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
390
|
-
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
|
|
391
|
-
expected: expectedRoot,
|
|
392
|
-
actual: commandProjectRoot
|
|
393
|
-
});
|
|
459
|
+
if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
|
|
460
|
+
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
|
|
394
461
|
}
|
|
395
|
-
const
|
|
396
|
-
|
|
462
|
+
const meta = objectRecord(hook._meta);
|
|
463
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
464
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
465
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
466
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
467
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
468
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
469
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
470
|
+
}
|
|
471
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
472
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
473
|
+
}
|
|
474
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
475
|
+
if (!toolUseId)
|
|
476
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
477
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
478
|
+
if (lifecycle.analysis.reason !== "shell_composition")
|
|
479
|
+
throw new AppError("compound_lifecycle_command", "Ambiguous shell cannot authorize a lifecycle replacement", 1, { reason: lifecycle.analysis.reason });
|
|
480
|
+
if (invocationId) {
|
|
481
|
+
const error = { code: "compound_lifecycle_command", message: "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", exitCode: 1,
|
|
482
|
+
details: { standalone_command: lifecycle.invocation.command } };
|
|
483
|
+
settleLifecycleNoEffectRejection(context, invocationId, lifecycle.invocation.command, error, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId });
|
|
484
|
+
return { ok: true, observed: false, invocation_id: invocationId, no_effect: true, error };
|
|
485
|
+
}
|
|
486
|
+
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
|
|
487
|
+
}
|
|
488
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
489
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
490
|
+
if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
|
|
491
|
+
throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
|
|
492
|
+
if (lifecycle.bootstrapStageStart)
|
|
397
493
|
registerProject(context, { root: expectedRoot });
|
|
398
494
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
399
|
-
const envelopeMeta = objectRecord(notification._meta);
|
|
400
|
-
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
401
|
-
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
402
|
-
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
403
|
-
const daemonId = stringValue(ddZcode.daemonId);
|
|
404
|
-
if (!rootProviderSessionId)
|
|
405
|
-
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
406
|
-
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
407
|
-
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
408
495
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
409
|
-
const parentSessionId =
|
|
410
|
-
const
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
agent_type: stringValue(runtime.source)
|
|
496
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
497
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
498
|
+
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
499
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
500
|
+
const persist = () => {
|
|
501
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
502
|
+
const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
|
|
503
|
+
...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
|
|
504
|
+
parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
|
|
505
|
+
model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
|
|
506
|
+
mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
|
|
507
|
+
}, sessionId) : undefined;
|
|
508
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
509
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
510
|
+
const inserted = recordHookEvent(context, {
|
|
511
|
+
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
512
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
513
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
514
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
515
|
+
});
|
|
516
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
517
|
+
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 };
|
|
518
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
433
519
|
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
parentSessionId,
|
|
440
|
-
daemonId: daemonId ?? null,
|
|
441
|
-
sessionId,
|
|
442
|
-
agentId: agentId ?? null,
|
|
443
|
-
turnId: null,
|
|
444
|
-
eventName: "PreToolUse",
|
|
445
|
-
toolName: toolName ?? "Bash",
|
|
446
|
-
status: "observed",
|
|
447
|
-
payload,
|
|
448
|
-
eventKey,
|
|
449
|
-
matchKey,
|
|
450
|
-
transcriptPath: null,
|
|
451
|
-
cwd: expectedRoot
|
|
520
|
+
if (!invocationId)
|
|
521
|
+
return persist();
|
|
522
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
523
|
+
id: invocationId, command, identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
524
|
+
recordReceipt: () => { persist(); return eventKey; }
|
|
452
525
|
});
|
|
453
|
-
|
|
454
|
-
projectId: project.id,
|
|
455
|
-
sessionId,
|
|
456
|
-
runId: observedSession?.run_id ?? null,
|
|
457
|
-
protocolId: observedSession?.protocol_id ?? null,
|
|
458
|
-
cwd: expectedRoot,
|
|
459
|
-
toolName: toolName ?? "Bash",
|
|
460
|
-
eventKey
|
|
461
|
-
});
|
|
462
|
-
return {
|
|
463
|
-
ok: true,
|
|
464
|
-
observed: inserted,
|
|
465
|
-
duplicate: !inserted,
|
|
466
|
-
event_key: eventKey,
|
|
467
|
-
harness: "zcode-acp",
|
|
468
|
-
session: nativeSessionIdentity("zcode-acp", providerSessionId),
|
|
469
|
-
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
470
|
-
daemon_id: daemonId ?? null
|
|
471
|
-
};
|
|
526
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: invocationId };
|
|
472
527
|
}
|
|
473
528
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
474
529
|
export function handleGrokEvent(context, input) {
|
|
@@ -491,7 +546,7 @@ export function handleGrokEvent(context, input) {
|
|
|
491
546
|
standalone_command: lifecycle.invocation.command
|
|
492
547
|
});
|
|
493
548
|
}
|
|
494
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
549
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
495
550
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
496
551
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
497
552
|
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
@@ -514,12 +569,13 @@ export function handleGrokEvent(context, input) {
|
|
|
514
569
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
515
570
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
516
571
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
517
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
518
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
519
|
-
}, sessionId) : undefined;
|
|
520
572
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
521
573
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
522
574
|
})).digest("hex");
|
|
575
|
+
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 });
|
|
576
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
577
|
+
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
578
|
+
}, sessionId) : undefined;
|
|
523
579
|
const payload = {
|
|
524
580
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
525
581
|
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)
|
|
@@ -535,20 +591,32 @@ export function handleGrokEvent(context, input) {
|
|
|
535
591
|
});
|
|
536
592
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
593
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
538
|
-
return (flowPayload || stageStart || stageResume || workStart)
|
|
594
|
+
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
|
|
539
595
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
540
596
|
: result;
|
|
541
597
|
}
|
|
542
598
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
543
599
|
export function handleOpenCodeEvent(context, input) {
|
|
544
600
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
545
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
546
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
547
601
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
548
602
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
603
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
604
|
+
}
|
|
605
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
606
|
+
export function handleDroidEvent(context, input) {
|
|
607
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
608
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
609
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
610
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
611
|
+
}
|
|
612
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
613
|
+
const agy = harness === "antigravity-cli";
|
|
614
|
+
const droid = harness === "droid-cli";
|
|
615
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
616
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
549
617
|
const phase = stringValue(event.phase);
|
|
550
618
|
if (phase !== "before" && phase !== "after")
|
|
551
|
-
throw new AppError(
|
|
619
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
552
620
|
const session = objectRecord(event.session);
|
|
553
621
|
const profile = objectRecord(event.profile);
|
|
554
622
|
const rawInput = objectRecord(event.input);
|
|
@@ -556,16 +624,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
556
624
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
557
625
|
const daemonId = stringValue(event.daemon_id);
|
|
558
626
|
const toolCallId = stringValue(event.tool_call_id);
|
|
559
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
627
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
560
628
|
const toolName = stringValue(event.tool);
|
|
561
629
|
const directory = stringValue(session.directory);
|
|
562
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
563
|
-
throw new AppError(
|
|
630
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
631
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
564
632
|
}
|
|
565
633
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
566
634
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
567
|
-
throw new AppError(
|
|
635
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
568
636
|
}
|
|
637
|
+
if (droid && nativeParentId === providerSessionId)
|
|
638
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
569
639
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
640
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
571
641
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -576,43 +646,50 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
576
646
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
577
647
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
578
648
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
579
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
649
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
580
650
|
});
|
|
581
651
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
582
652
|
}
|
|
583
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
653
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
584
654
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
585
655
|
}
|
|
586
656
|
const lifecycle = lifecycleFacts(command);
|
|
587
657
|
if (!lifecycle)
|
|
588
658
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
589
659
|
if (lifecycle.analysis.kind === "compound") {
|
|
590
|
-
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${
|
|
660
|
+
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
591
661
|
}
|
|
592
662
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
593
663
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
594
|
-
throw new AppError("project_mismatch",
|
|
664
|
+
throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
595
665
|
}
|
|
596
666
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
597
667
|
if (bootstrapStageStart)
|
|
598
668
|
registerProject(context, { root: expectedRoot });
|
|
599
669
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
670
|
+
if (droid) {
|
|
671
|
+
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]);
|
|
672
|
+
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)) {
|
|
673
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
600
676
|
const agentId = stringValue(session.agent_id);
|
|
677
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
|
|
601
678
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
602
679
|
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
603
680
|
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
604
|
-
reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
|
|
605
|
-
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 }
|
|
681
|
+
reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
|
|
682
|
+
metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { 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 }
|
|
606
683
|
}, sessionId) : undefined;
|
|
607
684
|
const payload = {
|
|
608
685
|
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
609
|
-
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
|
|
610
|
-
mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
|
|
686
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
687
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
611
688
|
};
|
|
612
689
|
const inserted = recordHookEvent(context, {
|
|
613
690
|
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
|
|
614
691
|
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
615
|
-
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
692
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
616
693
|
});
|
|
617
694
|
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
618
695
|
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 };
|
|
@@ -667,11 +744,58 @@ function lifecycleCommandFromPayload(payload) {
|
|
|
667
744
|
const command = commandFromPayload(payload);
|
|
668
745
|
return command ? unwrapShellCommand(command) : undefined;
|
|
669
746
|
}
|
|
670
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
749
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
750
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
751
|
+
*/
|
|
752
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
671
753
|
const analysis = parseLifecycleCommand(command);
|
|
672
|
-
if (analysis.kind !== "standalone"
|
|
754
|
+
if (analysis.kind !== "standalone")
|
|
755
|
+
return command;
|
|
756
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
757
|
+
if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
|
|
758
|
+
throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
|
|
673
759
|
return command;
|
|
674
|
-
|
|
760
|
+
}
|
|
761
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
762
|
+
if (analysis.invocation.rewriteSafe) {
|
|
763
|
+
const index = shellSuffixOffset(command);
|
|
764
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
765
|
+
}
|
|
766
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
767
|
+
const index = firstHeredoc(command);
|
|
768
|
+
if (index >= 0)
|
|
769
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
770
|
+
}
|
|
771
|
+
return command;
|
|
772
|
+
}
|
|
773
|
+
function firstHeredoc(command) {
|
|
774
|
+
let quoteState = null;
|
|
775
|
+
let escaped = false;
|
|
776
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
777
|
+
const char = command[index];
|
|
778
|
+
if (escaped) {
|
|
779
|
+
escaped = false;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (char === "\\" && quoteState !== "'") {
|
|
783
|
+
escaped = true;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (quoteState) {
|
|
787
|
+
if (char === quoteState)
|
|
788
|
+
quoteState = null;
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (char === "'" || char === '"') {
|
|
792
|
+
quoteState = char;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
796
|
+
return index;
|
|
797
|
+
}
|
|
798
|
+
return -1;
|
|
675
799
|
}
|
|
676
800
|
/**
|
|
677
801
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -692,25 +816,46 @@ function lifecycleFacts(command) {
|
|
|
692
816
|
return null;
|
|
693
817
|
const invocation = analysis.invocation;
|
|
694
818
|
const stageStart = invocation.operation === "stage_start";
|
|
819
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
820
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
695
821
|
const stageResume = invocation.operation === "stage_resume";
|
|
696
822
|
const workStart = invocation.operation === "work_start";
|
|
823
|
+
const workFinish = invocation.operation === "work_finish";
|
|
824
|
+
const workFail = invocation.operation === "work_fail";
|
|
697
825
|
return {
|
|
698
826
|
analysis,
|
|
699
827
|
invocation,
|
|
700
828
|
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
701
829
|
stageStart,
|
|
830
|
+
stageFinish,
|
|
831
|
+
stagePause,
|
|
702
832
|
stageResume,
|
|
703
|
-
workStart,
|
|
833
|
+
workStart, workFinish, workFail,
|
|
704
834
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
705
835
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
706
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
707
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
708
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
836
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
837
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
838
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
709
839
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
710
840
|
};
|
|
711
841
|
}
|
|
842
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
843
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
844
|
+
// not a second authority. Everything else remains standalone-only.
|
|
845
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
846
|
+
const operation = facts.invocation.operation;
|
|
847
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
848
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
849
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
850
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
851
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
852
|
+
}
|
|
712
853
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
713
|
-
const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
|
|
854
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
855
|
+
if (invocation.operation === "recovery_accept") {
|
|
856
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
857
|
+
return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
|
|
858
|
+
}
|
|
714
859
|
if (bootstrapStageStart)
|
|
715
860
|
return bootstrapMatchKey({
|
|
716
861
|
projectRoot,
|
|
@@ -720,12 +865,86 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
720
865
|
});
|
|
721
866
|
if (stageStart && runId && stageName)
|
|
722
867
|
return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
|
|
868
|
+
if (stageFinish && runId && stageName)
|
|
869
|
+
return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
|
|
870
|
+
if (stagePause && runId && stageName && workId)
|
|
871
|
+
return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
|
|
723
872
|
if (stageResume && runId && stageName && workId)
|
|
724
873
|
return stageResumeMatchKey(runId, stageName, workId, projectRoot);
|
|
725
874
|
if (workStart && workId)
|
|
726
|
-
return workStartMatchKey(workId, projectRoot);
|
|
875
|
+
return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
|
|
876
|
+
if (workFinish && workId)
|
|
877
|
+
return workLifecycleMatchKey("work_finish", workId, projectRoot);
|
|
878
|
+
if (workFail && workId)
|
|
879
|
+
return workLifecycleMatchKey("work_fail", workId, projectRoot);
|
|
727
880
|
return null;
|
|
728
881
|
}
|
|
882
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
883
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
884
|
+
export function observeCommandHook(context, command) {
|
|
885
|
+
const facts = lifecycleFacts(command);
|
|
886
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
887
|
+
return;
|
|
888
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
889
|
+
if (!project)
|
|
890
|
+
return; // Bootstrap has not registered a project yet.
|
|
891
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
892
|
+
if (!matchKey)
|
|
893
|
+
return;
|
|
894
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
895
|
+
if (!key) {
|
|
896
|
+
try {
|
|
897
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return;
|
|
901
|
+
} // Dispatch still owns its mandatory admission error.
|
|
902
|
+
}
|
|
903
|
+
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]);
|
|
904
|
+
if (event)
|
|
905
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
906
|
+
}
|
|
907
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
908
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
909
|
+
}
|
|
910
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
911
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
912
|
+
}
|
|
913
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
914
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
915
|
+
}
|
|
916
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
917
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
918
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
919
|
+
const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
|
|
920
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
921
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
922
|
+
}).eventKey;
|
|
923
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
924
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
925
|
+
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 });
|
|
926
|
+
}
|
|
927
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
928
|
+
if (claimed.changes !== 1)
|
|
929
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
930
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
931
|
+
}
|
|
932
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
933
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
934
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
935
|
+
const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
936
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
937
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
938
|
+
}).eventKey;
|
|
939
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
940
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
941
|
+
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 });
|
|
942
|
+
}
|
|
943
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
944
|
+
if (claimed.changes !== 1)
|
|
945
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
946
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
947
|
+
}
|
|
729
948
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
730
949
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
731
950
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -741,6 +960,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
741
960
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
742
961
|
if (!event?.session_id)
|
|
743
962
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
963
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
744
964
|
return {
|
|
745
965
|
hookEventId: event.id,
|
|
746
966
|
harness: event.harness,
|
|
@@ -777,21 +997,26 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
777
997
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
778
998
|
export function findRecentMatchingHookEvent(context, input) {
|
|
779
999
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
780
|
-
const find = () => context.db.
|
|
1000
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
781
1001
|
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1002
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1003
|
+
AND (? IS NULL OR session_id = ?)
|
|
1004
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1005
|
+
let events = find();
|
|
1006
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1007
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
786
1008
|
const deadline = Date.now() + 250;
|
|
787
|
-
while (
|
|
1009
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
788
1010
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
789
|
-
|
|
1011
|
+
events = find();
|
|
1012
|
+
}
|
|
1013
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1014
|
+
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery and updatedInput; managed ZCode requires its issued --invocation-id.`, 1);
|
|
790
1015
|
}
|
|
791
|
-
if (
|
|
792
|
-
throw new AppError(
|
|
1016
|
+
if (events.length > 1) {
|
|
1017
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
|
|
793
1018
|
}
|
|
794
|
-
return { id:
|
|
1019
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
795
1020
|
}
|
|
796
1021
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
797
1022
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -814,7 +1039,7 @@ export function bootstrapMatchKey(input) {
|
|
|
814
1039
|
})).digest("hex");
|
|
815
1040
|
}
|
|
816
1041
|
export function claimWorkStartHookEvent(context, input) {
|
|
817
|
-
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
|
|
1042
|
+
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
|
|
818
1043
|
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]);
|
|
819
1044
|
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
|
|
820
1045
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
@@ -832,7 +1057,9 @@ export function assertStageStartHookEvent(context, input) {
|
|
|
832
1057
|
if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
|
|
833
1058
|
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_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
|
|
834
1059
|
}
|
|
835
|
-
|
|
1060
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1061
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1062
|
+
return identity;
|
|
836
1063
|
}
|
|
837
1064
|
export function claimStageStartHookEvent(context, input) {
|
|
838
1065
|
assertStageStartHookEvent(context, input);
|
|
@@ -863,23 +1090,24 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
863
1090
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
864
1091
|
export function claimStageResumeHookEvent(context, input) {
|
|
865
1092
|
const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
|
|
866
|
-
const
|
|
867
|
-
const event = input.eventKey
|
|
868
|
-
? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
|
|
869
|
-
: 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]);
|
|
1093
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1094
|
+
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]);
|
|
870
1095
|
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
871
1096
|
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 });
|
|
872
1097
|
}
|
|
1098
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1099
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
873
1100
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
874
1101
|
if (claimed.changes !== 1)
|
|
875
1102
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
876
|
-
return
|
|
1103
|
+
return identity;
|
|
877
1104
|
}
|
|
878
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1105
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
879
1106
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
880
1107
|
operation: "work_start",
|
|
881
1108
|
work_id: workId,
|
|
882
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1109
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1110
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
883
1111
|
})).digest("hex");
|
|
884
1112
|
}
|
|
885
1113
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -969,7 +1197,7 @@ function objectTable(value) {
|
|
|
969
1197
|
function createSharedLinks(sourceHome, targetHome) {
|
|
970
1198
|
const linked = [];
|
|
971
1199
|
const skipped = [];
|
|
972
|
-
for (const entry of
|
|
1200
|
+
for (const entry of codexHomeSharedEntries) {
|
|
973
1201
|
const source = path.join(sourceHome, entry);
|
|
974
1202
|
const target = path.join(targetHome, entry);
|
|
975
1203
|
if (!fs.existsSync(source)) {
|
|
@@ -992,7 +1220,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
992
1220
|
return { linked, skipped };
|
|
993
1221
|
}
|
|
994
1222
|
function removeSharedSymlinks(targetHome) {
|
|
995
|
-
for (const entry of
|
|
1223
|
+
for (const entry of codexHomeSharedEntries) {
|
|
996
1224
|
const target = path.join(targetHome, entry);
|
|
997
1225
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
998
1226
|
fs.rmSync(target);
|
|
@@ -1005,7 +1233,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1005
1233
|
}
|
|
1006
1234
|
}
|
|
1007
1235
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1008
|
-
return
|
|
1236
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1009
1237
|
const source = path.join(sourceHome, entry);
|
|
1010
1238
|
const target = path.join(targetHome, entry);
|
|
1011
1239
|
return {
|
|
@@ -1029,7 +1257,8 @@ function commandHook(command, statusMessage) {
|
|
|
1029
1257
|
return { type: "command", command, timeout: 5, statusMessage };
|
|
1030
1258
|
}
|
|
1031
1259
|
function hookCommand(event) {
|
|
1032
|
-
const
|
|
1260
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1261
|
+
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`;
|
|
1033
1262
|
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"'`;
|
|
1034
1263
|
}
|
|
1035
1264
|
function resolveHookTarget(target) {
|
|
@@ -1263,12 +1492,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1263
1492
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1264
1493
|
}
|
|
1265
1494
|
function recordHookEvent(context, input) {
|
|
1266
|
-
|
|
1267
|
-
if (existing)
|
|
1495
|
+
if (assertHookEventReplay(context, input))
|
|
1268
1496
|
return false;
|
|
1269
1497
|
context.db.run(`INSERT INTO hook_events
|
|
1270
|
-
|
|
1271
|
-
|
|
1498
|
+
(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)
|
|
1499
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1272
1500
|
input.projectId,
|
|
1273
1501
|
input.protocolId,
|
|
1274
1502
|
input.harness,
|
|
@@ -1295,6 +1523,23 @@ function recordHookEvent(context, input) {
|
|
|
1295
1523
|
]);
|
|
1296
1524
|
return true;
|
|
1297
1525
|
}
|
|
1526
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1527
|
+
function assertHookEventReplay(context, input) {
|
|
1528
|
+
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
|
|
1529
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1530
|
+
if (existing) {
|
|
1531
|
+
const observed = {
|
|
1532
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1533
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1534
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1535
|
+
};
|
|
1536
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1537
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1538
|
+
}
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1298
1543
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1299
1544
|
if (explicitRoot)
|
|
1300
1545
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|