@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.81
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 +402 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +81 -9
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1344 -376
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.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 +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/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 +260 -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/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/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/config.js +11 -8
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -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 +575 -293
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +87 -7
- package/dist/services/lifecycle-invocations.js +835 -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/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 +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +779 -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 +434 -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 +69 -28
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +189 -85
- package/dist/services/vnext-code.js +257 -243
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +134 -19
- package/dist/services/vnext-merge.js +285 -102
- package/dist/services/vnext-plan-review.js +148 -94
- package/dist/services/vnext-plan.js +141 -72
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +87 -64
- package/dist/services/work-registry.js +701 -164
- 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 +444 -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 +11 -4
- 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 } 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,7 +297,12 @@ 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);
|
|
@@ -277,198 +311,272 @@ export function handleCodexHook(context, input) {
|
|
|
277
311
|
const lifecycle = lifecycleFacts(command);
|
|
278
312
|
if (!lifecycle)
|
|
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;
|
|
318
|
+
const invocationId = commandOption(lifecycle.invocation, "invocation-id");
|
|
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 = lifecycleMatchKey(lifecycle, project.root);
|
|
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(lifecycle, 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 (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
400
|
+
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
401
|
+
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
402
|
+
if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
|
|
403
|
+
throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
|
|
404
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
405
|
+
...(invocationId ? { id: invocationId } : {}),
|
|
406
|
+
command,
|
|
407
|
+
harness: "codex-desktop",
|
|
408
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
|
|
409
|
+
recordReceipt: scope => { persist(scope); return eventKey; }
|
|
410
|
+
});
|
|
411
|
+
const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
412
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
413
|
+
if (admittedInvocationId)
|
|
414
|
+
return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
415
|
+
return {
|
|
416
|
+
...common,
|
|
417
|
+
protocol_id: stored?.protocol_id ?? null,
|
|
418
|
+
observed: !receipt.duplicate,
|
|
419
|
+
duplicate: receipt.duplicate,
|
|
420
|
+
hookSpecificOutput: {
|
|
421
|
+
hookEventName: "PreToolUse",
|
|
422
|
+
permissionDecision: "allow",
|
|
423
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const persisted = persist();
|
|
428
|
+
return {
|
|
429
|
+
...common,
|
|
430
|
+
protocol_id: persisted.protocolId,
|
|
431
|
+
observed: persisted.inserted,
|
|
432
|
+
duplicate: !persisted.inserted,
|
|
433
|
+
...(providerSessionId
|
|
351
434
|
? {
|
|
352
435
|
hookSpecificOutput: {
|
|
353
436
|
hookEventName: "PreToolUse",
|
|
354
437
|
permissionDecision: "allow",
|
|
355
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
438
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
356
439
|
}
|
|
357
440
|
}
|
|
358
441
|
: {})
|
|
359
442
|
};
|
|
360
443
|
}
|
|
361
|
-
/**
|
|
444
|
+
/** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
|
|
445
|
+
function codexNativeParentId(payload, childId) {
|
|
446
|
+
const transcript = stringValue(payload.transcript_path);
|
|
447
|
+
if (!transcript || !path.isAbsolute(transcript))
|
|
448
|
+
return null;
|
|
449
|
+
try {
|
|
450
|
+
const descriptor = fs.openSync(transcript, "r");
|
|
451
|
+
const chunks = [];
|
|
452
|
+
try {
|
|
453
|
+
for (;;) {
|
|
454
|
+
const buffer = Buffer.alloc(4096);
|
|
455
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
456
|
+
if (!length)
|
|
457
|
+
break;
|
|
458
|
+
const newline = buffer.subarray(0, length).indexOf(10);
|
|
459
|
+
chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
|
|
460
|
+
if (newline >= 0)
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
fs.closeSync(descriptor);
|
|
466
|
+
}
|
|
467
|
+
const firstLine = Buffer.concat(chunks).toString("utf8");
|
|
468
|
+
if (!firstLine)
|
|
469
|
+
return null;
|
|
470
|
+
const record = JSON.parse(firstLine);
|
|
471
|
+
return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
|
|
472
|
+
? record.payload.parent_thread_id
|
|
473
|
+
: null;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
480
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
362
481
|
export function handleZcodeEvent(context, input) {
|
|
363
482
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
364
|
-
|
|
483
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
484
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
365
485
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
369
|
-
return { ok: true, observed: false, reason: "
|
|
370
|
-
const updateMeta = objectRecord(update._meta);
|
|
371
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
372
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
373
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
374
|
-
const rawInput = objectRecord(update.rawInput);
|
|
486
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
487
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
488
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
489
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
375
490
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
376
|
-
|
|
491
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
492
|
+
if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
|
|
493
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
494
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
495
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
496
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
497
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
498
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
499
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
503
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
504
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
505
|
+
*/
|
|
506
|
+
export function handleZcodeHook(context, input) {
|
|
507
|
+
return recordZcodeLifecycle(context, input);
|
|
508
|
+
}
|
|
509
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
510
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
511
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
512
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
513
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
514
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
515
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
516
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
517
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
377
518
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
378
|
-
}
|
|
379
519
|
const lifecycle = lifecycleFacts(command);
|
|
380
520
|
if (!lifecycle)
|
|
381
521
|
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;
|
|
522
|
+
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
523
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
388
524
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
525
|
+
const meta = objectRecord(hook._meta);
|
|
526
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
527
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
528
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
529
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
530
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
531
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
532
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
394
533
|
}
|
|
395
|
-
|
|
396
|
-
|
|
534
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
535
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
536
|
+
}
|
|
537
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
538
|
+
if (!toolUseId)
|
|
539
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
540
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
541
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
542
|
+
if (lifecycle.bootstrapStageStart)
|
|
397
543
|
registerProject(context, { root: expectedRoot });
|
|
398
544
|
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
545
|
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)
|
|
546
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
547
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
548
|
+
let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
549
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
550
|
+
const persist = (scope) => {
|
|
551
|
+
if (scope)
|
|
552
|
+
matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
|
|
553
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
554
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
555
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
556
|
+
const inserted = recordHookEvent(context, {
|
|
557
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
558
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
559
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
560
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
561
|
+
});
|
|
562
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
563
|
+
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 };
|
|
564
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
433
565
|
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
566
|
+
let persistedResult;
|
|
567
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
568
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
437
569
|
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
|
|
452
|
-
});
|
|
453
|
-
recordFlowSessionObservation(context, {
|
|
454
|
-
projectId: project.id,
|
|
455
|
-
sessionId,
|
|
456
|
-
runId: observedSession?.run_id ?? null,
|
|
457
|
-
protocolId: observedSession?.protocol_id ?? null,
|
|
458
|
-
cwd: expectedRoot,
|
|
459
|
-
toolName: toolName ?? "Bash",
|
|
460
|
-
eventKey
|
|
570
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
571
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
461
572
|
});
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
observed:
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
470
|
-
daemon_id: daemonId ?? null
|
|
471
|
-
};
|
|
573
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
574
|
+
if (admittedInvocationId)
|
|
575
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
576
|
+
// Without an issued managed attempt this is still ordinary hook evidence.
|
|
577
|
+
// Preserve persist()'s replay result instead of overwriting it with the
|
|
578
|
+
// admission layer's non-authoritative duplicate flag.
|
|
579
|
+
return persistedResult ?? persist();
|
|
472
580
|
}
|
|
473
581
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
474
582
|
export function handleGrokEvent(context, input) {
|
|
@@ -486,18 +594,8 @@ export function handleGrokEvent(context, input) {
|
|
|
486
594
|
const lifecycle = lifecycleFacts(command);
|
|
487
595
|
if (!lifecycle)
|
|
488
596
|
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;
|
|
597
|
+
const { bootstrapStageStart } = lifecycle;
|
|
495
598
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
496
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
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
599
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
502
600
|
if (bootstrapStageStart)
|
|
503
601
|
registerProject(context, { root: expectedRoot });
|
|
@@ -514,41 +612,49 @@ export function handleGrokEvent(context, input) {
|
|
|
514
612
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
515
613
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
516
614
|
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
615
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
521
616
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
522
617
|
})).digest("hex");
|
|
618
|
+
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
619
|
const payload = {
|
|
524
620
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
525
621
|
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
622
|
};
|
|
527
623
|
const inserted = recordHookEvent(context, {
|
|
528
|
-
projectId: project.id, protocolId:
|
|
624
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
529
625
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
530
626
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
531
627
|
});
|
|
532
628
|
recordFlowSessionObservation(context, {
|
|
533
|
-
projectId: project.id, sessionId,
|
|
629
|
+
projectId: project.id, sessionId,
|
|
534
630
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
535
631
|
});
|
|
536
632
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
633
|
...(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;
|
|
634
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
541
635
|
}
|
|
542
636
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
543
637
|
export function handleOpenCodeEvent(context, input) {
|
|
544
638
|
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
639
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
548
640
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
641
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
642
|
+
}
|
|
643
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
644
|
+
export function handleDroidEvent(context, input) {
|
|
645
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
646
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
647
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
648
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
649
|
+
}
|
|
650
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
651
|
+
const agy = harness === "antigravity-cli";
|
|
652
|
+
const droid = harness === "droid-cli";
|
|
653
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
654
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
549
655
|
const phase = stringValue(event.phase);
|
|
550
656
|
if (phase !== "before" && phase !== "after")
|
|
551
|
-
throw new AppError(
|
|
657
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
552
658
|
const session = objectRecord(event.session);
|
|
553
659
|
const profile = objectRecord(event.profile);
|
|
554
660
|
const rawInput = objectRecord(event.input);
|
|
@@ -556,16 +662,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
556
662
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
557
663
|
const daemonId = stringValue(event.daemon_id);
|
|
558
664
|
const toolCallId = stringValue(event.tool_call_id);
|
|
559
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
665
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
560
666
|
const toolName = stringValue(event.tool);
|
|
561
667
|
const directory = stringValue(session.directory);
|
|
562
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
563
|
-
throw new AppError(
|
|
668
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
669
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
564
670
|
}
|
|
565
671
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
566
672
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
567
|
-
throw new AppError(
|
|
673
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
568
674
|
}
|
|
675
|
+
if (droid && nativeParentId === providerSessionId)
|
|
676
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
569
677
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
678
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
571
679
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -576,45 +684,40 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
576
684
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
577
685
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
578
686
|
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
|
|
687
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
580
688
|
});
|
|
581
689
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
582
690
|
}
|
|
583
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
691
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
584
692
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
585
693
|
}
|
|
586
694
|
const lifecycle = lifecycleFacts(command);
|
|
587
695
|
if (!lifecycle)
|
|
588
696
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
589
|
-
|
|
590
|
-
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
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
|
-
}
|
|
697
|
+
const { bootstrapStageStart } = lifecycle;
|
|
596
698
|
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
597
699
|
if (bootstrapStageStart)
|
|
598
700
|
registerProject(context, { root: expectedRoot });
|
|
599
701
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
702
|
+
if (droid) {
|
|
703
|
+
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]);
|
|
704
|
+
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)) {
|
|
705
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
600
708
|
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;
|
|
709
|
+
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
710
|
const payload = {
|
|
608
711
|
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)
|
|
712
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
713
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
611
714
|
};
|
|
612
715
|
const inserted = recordHookEvent(context, {
|
|
613
|
-
projectId: project.id, protocolId:
|
|
716
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
614
717
|
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
|
|
718
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
616
719
|
});
|
|
617
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
720
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
618
721
|
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
722
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
620
723
|
}
|
|
@@ -663,15 +766,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
663
766
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
664
767
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
665
768
|
*/
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
769
|
+
/**
|
|
770
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
771
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
772
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
773
|
+
*/
|
|
774
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
671
775
|
const analysis = parseLifecycleCommand(command);
|
|
672
|
-
if (analysis.kind !== "standalone"
|
|
776
|
+
if (analysis.kind !== "standalone")
|
|
673
777
|
return command;
|
|
674
|
-
|
|
778
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
779
|
+
return command;
|
|
780
|
+
}
|
|
781
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
782
|
+
if (analysis.invocation.rewriteSafe) {
|
|
783
|
+
const index = shellSuffixOffset(command);
|
|
784
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
785
|
+
}
|
|
786
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
787
|
+
const index = firstHeredoc(command);
|
|
788
|
+
if (index >= 0)
|
|
789
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
790
|
+
}
|
|
791
|
+
return command;
|
|
792
|
+
}
|
|
793
|
+
function firstHeredoc(command) {
|
|
794
|
+
let quoteState = null;
|
|
795
|
+
let escaped = false;
|
|
796
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
797
|
+
const char = command[index];
|
|
798
|
+
if (escaped) {
|
|
799
|
+
escaped = false;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (char === "\\" && quoteState !== "'") {
|
|
803
|
+
escaped = true;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (quoteState) {
|
|
807
|
+
if (char === quoteState)
|
|
808
|
+
quoteState = null;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (char === "'" || char === '"') {
|
|
812
|
+
quoteState = char;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
816
|
+
return index;
|
|
817
|
+
}
|
|
818
|
+
return -1;
|
|
675
819
|
}
|
|
676
820
|
/**
|
|
677
821
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -692,25 +836,51 @@ function lifecycleFacts(command) {
|
|
|
692
836
|
return null;
|
|
693
837
|
const invocation = analysis.invocation;
|
|
694
838
|
const stageStart = invocation.operation === "stage_start";
|
|
839
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
840
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
695
841
|
const stageResume = invocation.operation === "stage_resume";
|
|
696
842
|
const workStart = invocation.operation === "work_start";
|
|
843
|
+
const workFinish = invocation.operation === "work_finish";
|
|
844
|
+
const workFail = invocation.operation === "work_fail";
|
|
697
845
|
return {
|
|
698
846
|
analysis,
|
|
699
847
|
invocation,
|
|
700
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
701
848
|
stageStart,
|
|
849
|
+
stageFinish,
|
|
850
|
+
stagePause,
|
|
702
851
|
stageResume,
|
|
703
|
-
workStart,
|
|
852
|
+
workStart, workFinish, workFail,
|
|
704
853
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
705
854
|
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,
|
|
855
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
856
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
857
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
709
858
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
710
859
|
};
|
|
711
860
|
}
|
|
712
|
-
|
|
713
|
-
|
|
861
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
862
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
863
|
+
// not a second authority. Everything else remains standalone-only.
|
|
864
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
865
|
+
const operation = facts.invocation.operation;
|
|
866
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
867
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
868
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
869
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
870
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
871
|
+
}
|
|
872
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
873
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
874
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
875
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
876
|
+
return null;
|
|
877
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
878
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
879
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
880
|
+
if (invocation.operation === "recovery_accept") {
|
|
881
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
882
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
883
|
+
}
|
|
714
884
|
if (bootstrapStageStart)
|
|
715
885
|
return bootstrapMatchKey({
|
|
716
886
|
projectRoot,
|
|
@@ -718,14 +888,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
718
888
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
719
889
|
...(contextSha256 ? { contextSha256 } : {})
|
|
720
890
|
});
|
|
721
|
-
if (stageStart &&
|
|
722
|
-
return stageStartMatchKey(
|
|
723
|
-
if (
|
|
724
|
-
return
|
|
891
|
+
if (stageStart && effectiveRunId && stageName)
|
|
892
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
893
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
894
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
895
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
896
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
897
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
898
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
725
899
|
if (workStart && workId)
|
|
726
|
-
return workStartMatchKey(workId,
|
|
900
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
901
|
+
if (workFinish && workId)
|
|
902
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
903
|
+
if (workFail && workId)
|
|
904
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
727
905
|
return null;
|
|
728
906
|
}
|
|
907
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
908
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
909
|
+
export function observeCommandHook(context, command) {
|
|
910
|
+
const facts = lifecycleFacts(command);
|
|
911
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
912
|
+
return;
|
|
913
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
914
|
+
if (!project)
|
|
915
|
+
return; // Bootstrap has not registered a project yet.
|
|
916
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
917
|
+
if (!matchKey)
|
|
918
|
+
return;
|
|
919
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
920
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
921
|
+
return;
|
|
922
|
+
if (!key) {
|
|
923
|
+
try {
|
|
924
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
return;
|
|
928
|
+
} // Dispatch still owns its mandatory admission error.
|
|
929
|
+
}
|
|
930
|
+
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]);
|
|
931
|
+
if (event)
|
|
932
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
933
|
+
}
|
|
934
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
935
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
936
|
+
}
|
|
937
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
938
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
939
|
+
}
|
|
940
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
941
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
942
|
+
}
|
|
943
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
944
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
945
|
+
}
|
|
946
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
947
|
+
const runReferences = entityReferenceVariants(runId);
|
|
948
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
949
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
950
|
+
}
|
|
951
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
952
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
953
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
954
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
955
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
956
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
957
|
+
}).eventKey;
|
|
958
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
959
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
960
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
961
|
+
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 });
|
|
962
|
+
}
|
|
963
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
964
|
+
if (claimed.changes !== 1)
|
|
965
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
966
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
967
|
+
}
|
|
968
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
969
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
970
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
971
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
972
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
973
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
974
|
+
}).eventKey;
|
|
975
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
976
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
977
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
978
|
+
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 });
|
|
979
|
+
}
|
|
980
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
981
|
+
if (claimed.changes !== 1)
|
|
982
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
983
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
984
|
+
}
|
|
729
985
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
730
986
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
731
987
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -741,6 +997,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
741
997
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
742
998
|
if (!event?.session_id)
|
|
743
999
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
1000
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
744
1001
|
return {
|
|
745
1002
|
hookEventId: event.id,
|
|
746
1003
|
harness: event.harness,
|
|
@@ -777,21 +1034,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
777
1034
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
778
1035
|
export function findRecentMatchingHookEvent(context, input) {
|
|
779
1036
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1037
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1038
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1039
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1040
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1041
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1042
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1043
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1044
|
+
AND (? IS NULL OR session_id = ?)
|
|
1045
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1046
|
+
let events = find();
|
|
1047
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1048
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
786
1049
|
const deadline = Date.now() + 250;
|
|
787
|
-
while (
|
|
1050
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
788
1051
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
789
|
-
|
|
1052
|
+
events = find();
|
|
790
1053
|
}
|
|
791
|
-
if (!
|
|
792
|
-
throw new AppError(input.errorCode, `${input.operation} requires
|
|
1054
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1055
|
+
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" });
|
|
793
1056
|
}
|
|
794
|
-
|
|
1057
|
+
if (events.length > 1) {
|
|
1058
|
+
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) });
|
|
1059
|
+
}
|
|
1060
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
795
1061
|
}
|
|
796
1062
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
797
1063
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -814,9 +1080,9 @@ export function bootstrapMatchKey(input) {
|
|
|
814
1080
|
})).digest("hex");
|
|
815
1081
|
}
|
|
816
1082
|
export function claimWorkStartHookEvent(context, input) {
|
|
817
|
-
const expected =
|
|
1083
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
818
1084
|
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.
|
|
1085
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
820
1086
|
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
1087
|
}
|
|
822
1088
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -824,15 +1090,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
824
1090
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
825
1091
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
826
1092
|
}
|
|
827
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
828
1093
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
829
1094
|
export function assertStageStartHookEvent(context, input) {
|
|
830
|
-
const expected =
|
|
1095
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
831
1096
|
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,
|
|
1097
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1098
|
+
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
1099
|
}
|
|
835
|
-
|
|
1100
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1101
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1102
|
+
return identity;
|
|
836
1103
|
}
|
|
837
1104
|
export function claimStageStartHookEvent(context, input) {
|
|
838
1105
|
assertStageStartHookEvent(context, input);
|
|
@@ -862,24 +1129,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
862
1129
|
}
|
|
863
1130
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
864
1131
|
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") {
|
|
1132
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1133
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1134
|
+
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]);
|
|
1135
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
871
1136
|
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
1137
|
}
|
|
1138
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1139
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
873
1140
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
874
1141
|
if (claimed.changes !== 1)
|
|
875
1142
|
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
|
|
1143
|
+
return identity;
|
|
877
1144
|
}
|
|
878
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1145
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
879
1146
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
880
1147
|
operation: "work_start",
|
|
881
1148
|
work_id: workId,
|
|
882
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1149
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1150
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
883
1151
|
})).digest("hex");
|
|
884
1152
|
}
|
|
885
1153
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -919,6 +1187,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
919
1187
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
920
1188
|
}
|
|
921
1189
|
function resolveProfileName(profile) {
|
|
1190
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1191
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
922
1192
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
923
1193
|
}
|
|
924
1194
|
function sanitizeProfile(value) {
|
|
@@ -946,11 +1216,12 @@ function readTomlIfExists(file) {
|
|
|
946
1216
|
if (!fs.existsSync(file)) {
|
|
947
1217
|
return {};
|
|
948
1218
|
}
|
|
1219
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
949
1220
|
try {
|
|
950
|
-
return parse(
|
|
1221
|
+
return parse(text);
|
|
951
1222
|
}
|
|
952
1223
|
catch (error) {
|
|
953
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1224
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
954
1225
|
}
|
|
955
1226
|
}
|
|
956
1227
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -969,7 +1240,7 @@ function objectTable(value) {
|
|
|
969
1240
|
function createSharedLinks(sourceHome, targetHome) {
|
|
970
1241
|
const linked = [];
|
|
971
1242
|
const skipped = [];
|
|
972
|
-
for (const entry of
|
|
1243
|
+
for (const entry of codexHomeSharedEntries) {
|
|
973
1244
|
const source = path.join(sourceHome, entry);
|
|
974
1245
|
const target = path.join(targetHome, entry);
|
|
975
1246
|
if (!fs.existsSync(source)) {
|
|
@@ -992,7 +1263,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
992
1263
|
return { linked, skipped };
|
|
993
1264
|
}
|
|
994
1265
|
function removeSharedSymlinks(targetHome) {
|
|
995
|
-
for (const entry of
|
|
1266
|
+
for (const entry of codexHomeSharedEntries) {
|
|
996
1267
|
const target = path.join(targetHome, entry);
|
|
997
1268
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
998
1269
|
fs.rmSync(target);
|
|
@@ -1005,7 +1276,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1005
1276
|
}
|
|
1006
1277
|
}
|
|
1007
1278
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1008
|
-
return
|
|
1279
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1009
1280
|
const source = path.join(sourceHome, entry);
|
|
1010
1281
|
const target = path.join(targetHome, entry);
|
|
1011
1282
|
return {
|
|
@@ -1026,10 +1297,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1026
1297
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1027
1298
|
}
|
|
1028
1299
|
function commandHook(command, statusMessage) {
|
|
1029
|
-
|
|
1300
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1301
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1302
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1303
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1030
1304
|
}
|
|
1031
1305
|
function hookCommand(event) {
|
|
1032
|
-
const
|
|
1306
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1307
|
+
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
1308
|
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
1309
|
}
|
|
1035
1310
|
function resolveHookTarget(target) {
|
|
@@ -1205,11 +1480,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1205
1480
|
function installationScope(target, profile, hooksPath) {
|
|
1206
1481
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1207
1482
|
}
|
|
1208
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1483
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1209
1484
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1210
1485
|
const now = context.now();
|
|
1211
1486
|
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
1487
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1214
1488
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1215
1489
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1263,12 +1537,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1263
1537
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1264
1538
|
}
|
|
1265
1539
|
function recordHookEvent(context, input) {
|
|
1266
|
-
|
|
1267
|
-
if (existing)
|
|
1540
|
+
if (assertHookEventReplay(context, input))
|
|
1268
1541
|
return false;
|
|
1269
1542
|
context.db.run(`INSERT INTO hook_events
|
|
1270
|
-
|
|
1271
|
-
|
|
1543
|
+
(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)
|
|
1544
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1272
1545
|
input.projectId,
|
|
1273
1546
|
input.protocolId,
|
|
1274
1547
|
input.harness,
|
|
@@ -1295,6 +1568,23 @@ function recordHookEvent(context, input) {
|
|
|
1295
1568
|
]);
|
|
1296
1569
|
return true;
|
|
1297
1570
|
}
|
|
1571
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1572
|
+
function assertHookEventReplay(context, input) {
|
|
1573
|
+
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
|
|
1574
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1575
|
+
if (existing) {
|
|
1576
|
+
const observed = {
|
|
1577
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1578
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1579
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1580
|
+
};
|
|
1581
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1582
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1583
|
+
}
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1298
1588
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1299
1589
|
if (explicitRoot)
|
|
1300
1590
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1321,14 +1611,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1321
1611
|
command
|
|
1322
1612
|
})).digest("hex");
|
|
1323
1613
|
}
|
|
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
1614
|
function toolNameFromPayload(payload) {
|
|
1333
1615
|
const toolInput = objectRecord(payload.tool_input);
|
|
1334
1616
|
return stringValue(toolInput.name);
|
|
@@ -1371,10 +1653,10 @@ function writeJsonFile(file, value) {
|
|
|
1371
1653
|
ensureDir(path.dirname(file));
|
|
1372
1654
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1373
1655
|
}
|
|
1374
|
-
function backupFile(file, now) {
|
|
1656
|
+
function backupFile(file, now, text) {
|
|
1375
1657
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1376
1658
|
const backup = `${file}.${suffix}.bak`;
|
|
1377
|
-
fs.
|
|
1659
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1378
1660
|
return backup;
|
|
1379
1661
|
}
|
|
1380
1662
|
function hashObject(value) {
|