@deksden-com/dd-flow-cli 0.9.0-beta.10 → 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 +491 -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 +89 -13
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1437 -384
- 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/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/code-work-result.schema.json +3 -3
- 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 +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- 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 +121 -76
- package/dist/services/eval-snapshots.js +832 -73
- 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 +2 -0
- package/dist/services/hooks.js +596 -300
- package/dist/services/lanes.js +61 -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 +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- 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/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- 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 +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +368 -74
- 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 +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +84 -55
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +185 -79
- package/dist/services/vnext-code.js +282 -248
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +164 -17
- package/dist/services/vnext-merge.js +291 -104
- package/dist/services/vnext-plan-review.js +139 -81
- package/dist/services/vnext-plan.js +138 -69
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +88 -65
- package/dist/services/work-registry.js +705 -162
- 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 +459 -32
- 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/repair-paused-run-status.mjs +59 -0
package/dist/services/hooks.js
CHANGED
|
@@ -2,18 +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";
|
|
11
|
+
import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
|
|
10
12
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
-
import {
|
|
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";
|
|
12
16
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
|
-
import { activeFlowSessionsForProject,
|
|
17
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
14
18
|
const defaultProfileName = "default";
|
|
15
19
|
const maxSanitizedSummaryLength = 1600;
|
|
16
|
-
const
|
|
20
|
+
export const codexHomeSharedEntries = [
|
|
17
21
|
"auth.json",
|
|
18
22
|
"auth.bk",
|
|
19
23
|
"sessions",
|
|
@@ -50,22 +54,28 @@ export function planCodexHome(context, input) {
|
|
|
50
54
|
config_path: paths.configPath,
|
|
51
55
|
hooks_path: paths.hooksPath,
|
|
52
56
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
53
|
-
shared_entries:
|
|
57
|
+
shared_entries: codexHomeSharedEntries,
|
|
54
58
|
exists: fs.existsSync(paths.targetHome),
|
|
55
59
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
|
-
export function
|
|
62
|
+
export function prepareCodexHome(context, input) {
|
|
59
63
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
60
64
|
const profile = resolveProfileName(input.profile);
|
|
61
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;
|
|
62
73
|
const now = context.now();
|
|
63
74
|
ensureDir(paths.targetHome);
|
|
64
75
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
65
76
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
66
77
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
67
78
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
68
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
69
79
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
70
80
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
71
81
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -136,7 +146,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
136
146
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
137
147
|
};
|
|
138
148
|
}
|
|
139
|
-
export function
|
|
149
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
140
150
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
141
151
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
142
152
|
}
|
|
@@ -144,9 +154,30 @@ export function removeCodexHome(context, input) {
|
|
|
144
154
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
145
155
|
const profile = resolveProfileName(input.profile);
|
|
146
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);
|
|
147
176
|
if (!record) {
|
|
148
177
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
149
178
|
}
|
|
179
|
+
if (record.status === "removed")
|
|
180
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
150
181
|
const removedEntries = [];
|
|
151
182
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
152
183
|
const target = path.join(record.target_home, relative);
|
|
@@ -207,7 +238,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
207
238
|
recorded
|
|
208
239
|
};
|
|
209
240
|
}
|
|
210
|
-
export function
|
|
241
|
+
export function prepareCodexHooks(context, input) {
|
|
211
242
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
212
243
|
const target = resolveHookTarget(input.target);
|
|
213
244
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -217,9 +248,16 @@ export function installCodexHooks(context, input) {
|
|
|
217
248
|
hooks_path: location.hooksPath
|
|
218
249
|
});
|
|
219
250
|
}
|
|
220
|
-
const
|
|
221
|
-
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;
|
|
222
259
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
260
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
223
261
|
ensureDir(path.dirname(location.hooksPath));
|
|
224
262
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
225
263
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -238,20 +276,11 @@ export function installCodexHooks(context, input) {
|
|
|
238
276
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
239
277
|
};
|
|
240
278
|
}
|
|
241
|
-
export function removeCodexHooks(context, input) {
|
|
242
|
-
const project
|
|
243
|
-
const target = resolveHookTarget(input.target);
|
|
244
|
-
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
245
|
-
if (target === "default" && !input.yes) {
|
|
246
|
-
throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
|
|
247
|
-
target,
|
|
248
|
-
hooks_path: location.hooksPath
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
const existing = readJsonIfExists(location.hooksPath) ?? {};
|
|
279
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
280
|
+
const { project, target, location, existing, text } = prepared;
|
|
252
281
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
253
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
254
282
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
283
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
255
284
|
ensureDir(path.dirname(location.hooksPath));
|
|
256
285
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
257
286
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -268,207 +297,292 @@ export function handleCodexHook(context, input) {
|
|
|
268
297
|
if (eventName !== "PreToolUse") {
|
|
269
298
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
270
299
|
}
|
|
271
|
-
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;
|
|
272
306
|
const turnId = stringValue(payload.turn_id);
|
|
273
307
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
274
308
|
const command = lifecycleCommandFromPayload(payload);
|
|
275
309
|
if (!command)
|
|
276
310
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
277
311
|
const lifecycle = lifecycleFacts(command);
|
|
278
|
-
if (!lifecycle)
|
|
312
|
+
if (!lifecycle && !continuationCommand(command))
|
|
279
313
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
314
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
280
315
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
281
316
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
permissionDecision: "deny",
|
|
292
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
293
|
-
}
|
|
294
|
-
};
|
|
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
|
+
}
|
|
295
326
|
}
|
|
296
|
-
const
|
|
297
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
327
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
298
328
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
299
329
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
300
|
-
|
|
301
|
-
|
|
330
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
331
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
332
|
+
registerProject(context, { root: bootstrapRoot });
|
|
302
333
|
}
|
|
303
|
-
const project = projectForHook(context,
|
|
304
|
-
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 });
|
|
305
338
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
312
|
-
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.
|
|
313
344
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
330
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
331
|
-
});
|
|
332
|
-
if (effectiveSessionId) {
|
|
333
|
-
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, {
|
|
334
360
|
projectId: project.id,
|
|
335
|
-
sessionId: effectiveSessionId,
|
|
336
|
-
runId: observedSession?.run_id ?? null,
|
|
337
361
|
protocolId,
|
|
338
|
-
|
|
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,
|
|
339
372
|
toolName: toolName ?? null,
|
|
340
|
-
|
|
373
|
+
status: "observed",
|
|
374
|
+
payload,
|
|
375
|
+
eventKey,
|
|
376
|
+
matchKey,
|
|
377
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
378
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
341
379
|
});
|
|
342
|
-
|
|
343
|
-
|
|
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 = {
|
|
344
393
|
ok: true,
|
|
345
|
-
observed: inserted,
|
|
346
|
-
duplicate: !inserted,
|
|
347
394
|
event_key: eventKey,
|
|
348
|
-
session:
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
351
438
|
? {
|
|
352
439
|
hookSpecificOutput: {
|
|
353
440
|
hookEventName: "PreToolUse",
|
|
354
441
|
permissionDecision: "allow",
|
|
355
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
442
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
356
443
|
}
|
|
357
444
|
}
|
|
358
445
|
: {})
|
|
359
446
|
};
|
|
360
447
|
}
|
|
361
|
-
/**
|
|
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. */
|
|
362
485
|
export function handleZcodeEvent(context, input) {
|
|
363
486
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const params = objectRecord(notification.params);
|
|
367
|
-
const update = objectRecord(params.update);
|
|
368
|
-
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")
|
|
369
489
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
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" };
|
|
375
494
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
376
|
-
|
|
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"))
|
|
377
522
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
378
|
-
}
|
|
379
523
|
const lifecycle = lifecycleFacts(command);
|
|
380
|
-
if (!lifecycle)
|
|
524
|
+
if (!lifecycle && !continuationCommand(command))
|
|
381
525
|
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;
|
|
526
|
+
if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
|
|
527
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
388
528
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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);
|
|
394
537
|
}
|
|
395
|
-
|
|
396
|
-
|
|
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)
|
|
397
547
|
registerProject(context, { root: expectedRoot });
|
|
398
548
|
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
549
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
409
|
-
const parentSessionId =
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
command
|
|
428
|
-
provider: stringValue(observedProfile.provider),
|
|
429
|
-
model: stringValue(observedProfile.model),
|
|
430
|
-
reasoning: stringValue(observedProfile.reasoning),
|
|
431
|
-
mode: stringValue(observedProfile.mode),
|
|
432
|
-
agent_type: stringValue(runtime.source)
|
|
550
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
551
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
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 } } };
|
|
433
569
|
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
570
|
+
if (!lifecycle)
|
|
571
|
+
return context.db.writeTransaction(() => persist());
|
|
572
|
+
let persistedResult;
|
|
573
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
574
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
437
575
|
harness: "zcode-acp",
|
|
438
|
-
providerSessionId,
|
|
439
|
-
|
|
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
|
|
576
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
577
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
452
578
|
});
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
};
|
|
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();
|
|
472
586
|
}
|
|
473
587
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
474
588
|
export function handleGrokEvent(context, input) {
|
|
@@ -484,21 +598,11 @@ export function handleGrokEvent(context, input) {
|
|
|
484
598
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
485
599
|
}
|
|
486
600
|
const lifecycle = lifecycleFacts(command);
|
|
487
|
-
if (!lifecycle)
|
|
601
|
+
if (!lifecycle && !continuationCommand(command))
|
|
488
602
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
489
|
-
|
|
490
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
491
|
-
standalone_command: lifecycle.invocation.command
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
603
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
495
604
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
496
|
-
|
|
497
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
498
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
605
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
502
606
|
if (bootstrapStageStart)
|
|
503
607
|
registerProject(context, { root: expectedRoot });
|
|
504
608
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
@@ -514,41 +618,50 @@ export function handleGrokEvent(context, input) {
|
|
|
514
618
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
515
619
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
516
620
|
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
621
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
521
|
-
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
|
|
522
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 });
|
|
523
626
|
const payload = {
|
|
524
627
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
525
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)
|
|
526
629
|
};
|
|
527
630
|
const inserted = recordHookEvent(context, {
|
|
528
|
-
projectId: project.id, protocolId:
|
|
631
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
529
632
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
530
633
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
531
634
|
});
|
|
532
635
|
recordFlowSessionObservation(context, {
|
|
533
|
-
projectId: project.id, sessionId,
|
|
636
|
+
projectId: project.id, sessionId,
|
|
534
637
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
535
638
|
});
|
|
536
639
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
640
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
538
|
-
return
|
|
539
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
540
|
-
: result;
|
|
641
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
541
642
|
}
|
|
542
643
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
543
644
|
export function handleOpenCodeEvent(context, input) {
|
|
544
645
|
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
646
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
548
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";
|
|
549
662
|
const phase = stringValue(event.phase);
|
|
550
663
|
if (phase !== "before" && phase !== "after")
|
|
551
|
-
throw new AppError(
|
|
664
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
552
665
|
const session = objectRecord(event.session);
|
|
553
666
|
const profile = objectRecord(event.profile);
|
|
554
667
|
const rawInput = objectRecord(event.input);
|
|
@@ -556,16 +669,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
556
669
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
557
670
|
const daemonId = stringValue(event.daemon_id);
|
|
558
671
|
const toolCallId = stringValue(event.tool_call_id);
|
|
559
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
672
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
560
673
|
const toolName = stringValue(event.tool);
|
|
561
674
|
const directory = stringValue(session.directory);
|
|
562
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
563
|
-
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);
|
|
564
677
|
}
|
|
565
678
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
566
679
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
567
|
-
throw new AppError(
|
|
680
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
568
681
|
}
|
|
682
|
+
if (droid && nativeParentId === providerSessionId)
|
|
683
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
569
684
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
685
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
571
686
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -576,45 +691,40 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
576
691
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
577
692
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
578
693
|
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
|
|
694
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
580
695
|
});
|
|
581
696
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
582
697
|
}
|
|
583
|
-
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)) {
|
|
584
699
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
585
700
|
}
|
|
586
701
|
const lifecycle = lifecycleFacts(command);
|
|
587
|
-
if (!lifecycle)
|
|
702
|
+
if (!lifecycle && !continuationCommand(command))
|
|
588
703
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
593
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
594
|
-
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
595
|
-
}
|
|
596
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
704
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
705
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
597
706
|
if (bootstrapStageStart)
|
|
598
707
|
registerProject(context, { root: expectedRoot });
|
|
599
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
|
+
}
|
|
600
715
|
const agentId = stringValue(session.agent_id);
|
|
601
|
-
|
|
602
|
-
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
603
|
-
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 }
|
|
606
|
-
}, 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 });
|
|
607
717
|
const payload = {
|
|
608
718
|
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)
|
|
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)
|
|
611
721
|
};
|
|
612
722
|
const inserted = recordHookEvent(context, {
|
|
613
|
-
projectId: project.id, protocolId:
|
|
723
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
614
724
|
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
|
|
725
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
616
726
|
});
|
|
617
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
727
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
618
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 };
|
|
619
729
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
620
730
|
}
|
|
@@ -631,6 +741,13 @@ export function handleAgyEvent(context, input) {
|
|
|
631
741
|
const toolName = stringValue(event.tool);
|
|
632
742
|
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
633
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
|
+
}
|
|
634
751
|
const translated = {
|
|
635
752
|
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
636
753
|
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
@@ -663,15 +780,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
663
780
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
664
781
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
665
782
|
*/
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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) {
|
|
671
789
|
const analysis = parseLifecycleCommand(command);
|
|
672
|
-
if (analysis.kind !== "standalone"
|
|
790
|
+
if (analysis.kind !== "standalone")
|
|
673
791
|
return command;
|
|
674
|
-
|
|
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;
|
|
675
833
|
}
|
|
676
834
|
/**
|
|
677
835
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -692,25 +850,51 @@ function lifecycleFacts(command) {
|
|
|
692
850
|
return null;
|
|
693
851
|
const invocation = analysis.invocation;
|
|
694
852
|
const stageStart = invocation.operation === "stage_start";
|
|
853
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
854
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
695
855
|
const stageResume = invocation.operation === "stage_resume";
|
|
696
856
|
const workStart = invocation.operation === "work_start";
|
|
857
|
+
const workFinish = invocation.operation === "work_finish";
|
|
858
|
+
const workFail = invocation.operation === "work_fail";
|
|
697
859
|
return {
|
|
698
860
|
analysis,
|
|
699
861
|
invocation,
|
|
700
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
701
862
|
stageStart,
|
|
863
|
+
stageFinish,
|
|
864
|
+
stagePause,
|
|
702
865
|
stageResume,
|
|
703
|
-
workStart,
|
|
866
|
+
workStart, workFinish, workFail,
|
|
704
867
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
705
868
|
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,
|
|
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,
|
|
709
872
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
710
873
|
};
|
|
711
874
|
}
|
|
712
|
-
|
|
713
|
-
|
|
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
|
+
}
|
|
714
898
|
if (bootstrapStageStart)
|
|
715
899
|
return bootstrapMatchKey({
|
|
716
900
|
projectRoot,
|
|
@@ -718,14 +902,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
718
902
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
719
903
|
...(contextSha256 ? { contextSha256 } : {})
|
|
720
904
|
});
|
|
721
|
-
if (stageStart &&
|
|
722
|
-
return stageStartMatchKey(
|
|
723
|
-
if (
|
|
724
|
-
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);
|
|
725
913
|
if (workStart && workId)
|
|
726
|
-
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);
|
|
727
919
|
return null;
|
|
728
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
|
+
}
|
|
729
999
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
730
1000
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
731
1001
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -741,6 +1011,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
741
1011
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
742
1012
|
if (!event?.session_id)
|
|
743
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);
|
|
744
1015
|
return {
|
|
745
1016
|
hookEventId: event.id,
|
|
746
1017
|
harness: event.harness,
|
|
@@ -777,21 +1048,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
777
1048
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
778
1049
|
export function findRecentMatchingHookEvent(context, input) {
|
|
779
1050
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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.
|
|
786
1063
|
const deadline = Date.now() + 250;
|
|
787
|
-
while (
|
|
1064
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
788
1065
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
789
|
-
|
|
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" });
|
|
790
1070
|
}
|
|
791
|
-
if (
|
|
792
|
-
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) });
|
|
793
1073
|
}
|
|
794
|
-
return { id:
|
|
1074
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
795
1075
|
}
|
|
796
1076
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
797
1077
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -814,9 +1094,9 @@ export function bootstrapMatchKey(input) {
|
|
|
814
1094
|
})).digest("hex");
|
|
815
1095
|
}
|
|
816
1096
|
export function claimWorkStartHookEvent(context, input) {
|
|
817
|
-
const expected =
|
|
1097
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
818
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]);
|
|
819
|
-
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)) {
|
|
820
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 });
|
|
821
1101
|
}
|
|
822
1102
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -824,15 +1104,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
824
1104
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
825
1105
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
826
1106
|
}
|
|
827
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
828
1107
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
829
1108
|
export function assertStageStartHookEvent(context, input) {
|
|
830
|
-
const expected =
|
|
1109
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
831
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]);
|
|
832
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
833
|
-
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 });
|
|
834
1113
|
}
|
|
835
|
-
|
|
1114
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1115
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1116
|
+
return identity;
|
|
836
1117
|
}
|
|
837
1118
|
export function claimStageStartHookEvent(context, input) {
|
|
838
1119
|
assertStageStartHookEvent(context, input);
|
|
@@ -862,24 +1143,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
862
1143
|
}
|
|
863
1144
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
864
1145
|
export function claimStageResumeHookEvent(context, input) {
|
|
865
|
-
const
|
|
866
|
-
const
|
|
867
|
-
const event = input.eventKey
|
|
868
|
-
|
|
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]);
|
|
870
|
-
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") {
|
|
871
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 });
|
|
872
1151
|
}
|
|
1152
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1153
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
873
1154
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
874
1155
|
if (claimed.changes !== 1)
|
|
875
1156
|
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
|
|
1157
|
+
return identity;
|
|
877
1158
|
}
|
|
878
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1159
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
879
1160
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
880
1161
|
operation: "work_start",
|
|
881
1162
|
work_id: workId,
|
|
882
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1163
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1164
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
883
1165
|
})).digest("hex");
|
|
884
1166
|
}
|
|
885
1167
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -919,6 +1201,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
919
1201
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
920
1202
|
}
|
|
921
1203
|
function resolveProfileName(profile) {
|
|
1204
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1205
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
922
1206
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
923
1207
|
}
|
|
924
1208
|
function sanitizeProfile(value) {
|
|
@@ -946,11 +1230,12 @@ function readTomlIfExists(file) {
|
|
|
946
1230
|
if (!fs.existsSync(file)) {
|
|
947
1231
|
return {};
|
|
948
1232
|
}
|
|
1233
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
949
1234
|
try {
|
|
950
|
-
return parse(
|
|
1235
|
+
return parse(text);
|
|
951
1236
|
}
|
|
952
1237
|
catch (error) {
|
|
953
|
-
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 } : {}) });
|
|
954
1239
|
}
|
|
955
1240
|
}
|
|
956
1241
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -969,7 +1254,7 @@ function objectTable(value) {
|
|
|
969
1254
|
function createSharedLinks(sourceHome, targetHome) {
|
|
970
1255
|
const linked = [];
|
|
971
1256
|
const skipped = [];
|
|
972
|
-
for (const entry of
|
|
1257
|
+
for (const entry of codexHomeSharedEntries) {
|
|
973
1258
|
const source = path.join(sourceHome, entry);
|
|
974
1259
|
const target = path.join(targetHome, entry);
|
|
975
1260
|
if (!fs.existsSync(source)) {
|
|
@@ -992,7 +1277,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
992
1277
|
return { linked, skipped };
|
|
993
1278
|
}
|
|
994
1279
|
function removeSharedSymlinks(targetHome) {
|
|
995
|
-
for (const entry of
|
|
1280
|
+
for (const entry of codexHomeSharedEntries) {
|
|
996
1281
|
const target = path.join(targetHome, entry);
|
|
997
1282
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
998
1283
|
fs.rmSync(target);
|
|
@@ -1005,7 +1290,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1005
1290
|
}
|
|
1006
1291
|
}
|
|
1007
1292
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1008
|
-
return
|
|
1293
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1009
1294
|
const source = path.join(sourceHome, entry);
|
|
1010
1295
|
const target = path.join(targetHome, entry);
|
|
1011
1296
|
return {
|
|
@@ -1026,10 +1311,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1026
1311
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1027
1312
|
}
|
|
1028
1313
|
function commandHook(command, statusMessage) {
|
|
1029
|
-
|
|
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 };
|
|
1030
1318
|
}
|
|
1031
1319
|
function hookCommand(event) {
|
|
1032
|
-
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`;
|
|
1033
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"'`;
|
|
1034
1323
|
}
|
|
1035
1324
|
function resolveHookTarget(target) {
|
|
@@ -1205,11 +1494,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1205
1494
|
function installationScope(target, profile, hooksPath) {
|
|
1206
1495
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1207
1496
|
}
|
|
1208
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1497
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1209
1498
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1210
1499
|
const now = context.now();
|
|
1211
1500
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1212
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1213
1501
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1214
1502
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1215
1503
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1263,12 +1551,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1263
1551
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1264
1552
|
}
|
|
1265
1553
|
function recordHookEvent(context, input) {
|
|
1266
|
-
|
|
1267
|
-
if (existing)
|
|
1554
|
+
if (assertHookEventReplay(context, input))
|
|
1268
1555
|
return false;
|
|
1269
1556
|
context.db.run(`INSERT INTO hook_events
|
|
1270
|
-
|
|
1271
|
-
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1272
1559
|
input.projectId,
|
|
1273
1560
|
input.protocolId,
|
|
1274
1561
|
input.harness,
|
|
@@ -1295,6 +1582,23 @@ function recordHookEvent(context, input) {
|
|
|
1295
1582
|
]);
|
|
1296
1583
|
return true;
|
|
1297
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
|
+
}
|
|
1298
1602
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1299
1603
|
if (explicitRoot)
|
|
1300
1604
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1321,14 +1625,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1321
1625
|
command
|
|
1322
1626
|
})).digest("hex");
|
|
1323
1627
|
}
|
|
1324
|
-
function commandFromPayload(payload) {
|
|
1325
|
-
const direct = stringValue(payload.command);
|
|
1326
|
-
if (direct) {
|
|
1327
|
-
return direct;
|
|
1328
|
-
}
|
|
1329
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1330
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1331
|
-
}
|
|
1332
1628
|
function toolNameFromPayload(payload) {
|
|
1333
1629
|
const toolInput = objectRecord(payload.tool_input);
|
|
1334
1630
|
return stringValue(toolInput.name);
|
|
@@ -1371,10 +1667,10 @@ function writeJsonFile(file, value) {
|
|
|
1371
1667
|
ensureDir(path.dirname(file));
|
|
1372
1668
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1373
1669
|
}
|
|
1374
|
-
function backupFile(file, now) {
|
|
1670
|
+
function backupFile(file, now, text) {
|
|
1375
1671
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1376
1672
|
const backup = `${file}.${suffix}.bak`;
|
|
1377
|
-
fs.
|
|
1673
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1378
1674
|
return backup;
|
|
1379
1675
|
}
|
|
1380
1676
|
function hashObject(value) {
|