@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90
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 +460 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +304 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1409 -382
- 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/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 +132 -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 +587 -299
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +118 -7
- package/dist/services/lifecycle-invocations.js +1055 -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 +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 +789 -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-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 +195 -88
- package/dist/services/vnext-code.js +285 -252
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +160 -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 +716 -168
- 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
|
-
|
|
487
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
488
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
365
489
|
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);
|
|
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" || !commandOption(parsed.invocation, "invocation-id"))
|
|
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
|
|
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
|
|
576
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
577
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
461
578
|
});
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
observed:
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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,49 @@ 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
622
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
522
623
|
})).digest("hex");
|
|
624
|
+
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
625
|
const payload = {
|
|
524
626
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
525
627
|
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
628
|
};
|
|
527
629
|
const inserted = recordHookEvent(context, {
|
|
528
|
-
projectId: project.id, protocolId:
|
|
630
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
529
631
|
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
530
632
|
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
531
633
|
});
|
|
532
634
|
recordFlowSessionObservation(context, {
|
|
533
|
-
projectId: project.id, sessionId,
|
|
635
|
+
projectId: project.id, sessionId,
|
|
534
636
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
535
637
|
});
|
|
536
638
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
639
|
...(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;
|
|
640
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
541
641
|
}
|
|
542
642
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
543
643
|
export function handleOpenCodeEvent(context, input) {
|
|
544
644
|
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
645
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
548
646
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
647
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
648
|
+
}
|
|
649
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
650
|
+
export function handleDroidEvent(context, input) {
|
|
651
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
652
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
653
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
654
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
655
|
+
}
|
|
656
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
657
|
+
const agy = harness === "antigravity-cli";
|
|
658
|
+
const droid = harness === "droid-cli";
|
|
659
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
660
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
549
661
|
const phase = stringValue(event.phase);
|
|
550
662
|
if (phase !== "before" && phase !== "after")
|
|
551
|
-
throw new AppError(
|
|
663
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
552
664
|
const session = objectRecord(event.session);
|
|
553
665
|
const profile = objectRecord(event.profile);
|
|
554
666
|
const rawInput = objectRecord(event.input);
|
|
@@ -556,16 +668,18 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
556
668
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
557
669
|
const daemonId = stringValue(event.daemon_id);
|
|
558
670
|
const toolCallId = stringValue(event.tool_call_id);
|
|
559
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
671
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
560
672
|
const toolName = stringValue(event.tool);
|
|
561
673
|
const directory = stringValue(session.directory);
|
|
562
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
563
|
-
throw new AppError(
|
|
674
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
675
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
564
676
|
}
|
|
565
677
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
566
678
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
567
|
-
throw new AppError(
|
|
679
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
568
680
|
}
|
|
681
|
+
if (droid && nativeParentId === providerSessionId)
|
|
682
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
569
683
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
684
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
571
685
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -576,45 +690,40 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
576
690
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
577
691
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
578
692
|
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
|
|
693
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
580
694
|
});
|
|
581
695
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
582
696
|
}
|
|
583
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
697
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
584
698
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
585
699
|
}
|
|
586
700
|
const lifecycle = lifecycleFacts(command);
|
|
587
|
-
if (!lifecycle)
|
|
701
|
+
if (!lifecycle && !continuationCommand(command))
|
|
588
702
|
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);
|
|
703
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
704
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
597
705
|
if (bootstrapStageStart)
|
|
598
706
|
registerProject(context, { root: expectedRoot });
|
|
599
707
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
708
|
+
if (droid) {
|
|
709
|
+
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]);
|
|
710
|
+
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)) {
|
|
711
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
600
714
|
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;
|
|
715
|
+
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
716
|
const payload = {
|
|
608
717
|
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)
|
|
718
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
719
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
611
720
|
};
|
|
612
721
|
const inserted = recordHookEvent(context, {
|
|
613
|
-
projectId: project.id, protocolId:
|
|
722
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
614
723
|
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
|
|
724
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
|
|
616
725
|
});
|
|
617
|
-
recordFlowSessionObservation(context, { projectId: project.id, sessionId,
|
|
726
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
618
727
|
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
728
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
620
729
|
}
|
|
@@ -663,15 +772,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
663
772
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
664
773
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
665
774
|
*/
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
777
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
778
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
779
|
+
*/
|
|
780
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
671
781
|
const analysis = parseLifecycleCommand(command);
|
|
672
|
-
if (analysis.kind !== "standalone"
|
|
782
|
+
if (analysis.kind !== "standalone")
|
|
673
783
|
return command;
|
|
674
|
-
|
|
784
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
785
|
+
return command;
|
|
786
|
+
}
|
|
787
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
788
|
+
if (analysis.invocation.rewriteSafe) {
|
|
789
|
+
const index = shellSuffixOffset(command);
|
|
790
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
791
|
+
}
|
|
792
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
793
|
+
const index = firstHeredoc(command);
|
|
794
|
+
if (index >= 0)
|
|
795
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
796
|
+
}
|
|
797
|
+
return command;
|
|
798
|
+
}
|
|
799
|
+
function firstHeredoc(command) {
|
|
800
|
+
let quoteState = null;
|
|
801
|
+
let escaped = false;
|
|
802
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
803
|
+
const char = command[index];
|
|
804
|
+
if (escaped) {
|
|
805
|
+
escaped = false;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (char === "\\" && quoteState !== "'") {
|
|
809
|
+
escaped = true;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (quoteState) {
|
|
813
|
+
if (char === quoteState)
|
|
814
|
+
quoteState = null;
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (char === "'" || char === '"') {
|
|
818
|
+
quoteState = char;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
822
|
+
return index;
|
|
823
|
+
}
|
|
824
|
+
return -1;
|
|
675
825
|
}
|
|
676
826
|
/**
|
|
677
827
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -692,25 +842,51 @@ function lifecycleFacts(command) {
|
|
|
692
842
|
return null;
|
|
693
843
|
const invocation = analysis.invocation;
|
|
694
844
|
const stageStart = invocation.operation === "stage_start";
|
|
845
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
846
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
695
847
|
const stageResume = invocation.operation === "stage_resume";
|
|
696
848
|
const workStart = invocation.operation === "work_start";
|
|
849
|
+
const workFinish = invocation.operation === "work_finish";
|
|
850
|
+
const workFail = invocation.operation === "work_fail";
|
|
697
851
|
return {
|
|
698
852
|
analysis,
|
|
699
853
|
invocation,
|
|
700
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
701
854
|
stageStart,
|
|
855
|
+
stageFinish,
|
|
856
|
+
stagePause,
|
|
702
857
|
stageResume,
|
|
703
|
-
workStart,
|
|
858
|
+
workStart, workFinish, workFail,
|
|
704
859
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
705
860
|
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,
|
|
861
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
862
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
863
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
709
864
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
710
865
|
};
|
|
711
866
|
}
|
|
712
|
-
|
|
713
|
-
|
|
867
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
868
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
869
|
+
// not a second authority. Everything else remains standalone-only.
|
|
870
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
871
|
+
const operation = facts.invocation.operation;
|
|
872
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
873
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
874
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
875
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
876
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
877
|
+
}
|
|
878
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
879
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
880
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
881
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
882
|
+
return null;
|
|
883
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
884
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
885
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
886
|
+
if (invocation.operation === "recovery_accept") {
|
|
887
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
888
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
889
|
+
}
|
|
714
890
|
if (bootstrapStageStart)
|
|
715
891
|
return bootstrapMatchKey({
|
|
716
892
|
projectRoot,
|
|
@@ -718,14 +894,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
718
894
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
719
895
|
...(contextSha256 ? { contextSha256 } : {})
|
|
720
896
|
});
|
|
721
|
-
if (stageStart &&
|
|
722
|
-
return stageStartMatchKey(
|
|
723
|
-
if (
|
|
724
|
-
return
|
|
897
|
+
if (stageStart && effectiveRunId && stageName)
|
|
898
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
899
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
900
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
901
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
902
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
903
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
904
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
725
905
|
if (workStart && workId)
|
|
726
|
-
return workStartMatchKey(workId,
|
|
906
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
907
|
+
if (workFinish && workId)
|
|
908
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
909
|
+
if (workFail && workId)
|
|
910
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
727
911
|
return null;
|
|
728
912
|
}
|
|
913
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
914
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
915
|
+
export function observeCommandHook(context, command) {
|
|
916
|
+
const facts = lifecycleFacts(command);
|
|
917
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
918
|
+
return;
|
|
919
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
920
|
+
if (!project)
|
|
921
|
+
return; // Bootstrap has not registered a project yet.
|
|
922
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
923
|
+
if (!matchKey)
|
|
924
|
+
return;
|
|
925
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
926
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
927
|
+
return;
|
|
928
|
+
if (!key) {
|
|
929
|
+
try {
|
|
930
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
return;
|
|
934
|
+
} // Dispatch still owns its mandatory admission error.
|
|
935
|
+
}
|
|
936
|
+
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]);
|
|
937
|
+
if (event)
|
|
938
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
939
|
+
}
|
|
940
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
941
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
942
|
+
}
|
|
943
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
944
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
945
|
+
}
|
|
946
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
947
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
948
|
+
}
|
|
949
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
950
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
951
|
+
}
|
|
952
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
953
|
+
const runReferences = entityReferenceVariants(runId);
|
|
954
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
955
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
956
|
+
}
|
|
957
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
958
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
959
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
960
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
961
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
962
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
963
|
+
}).eventKey;
|
|
964
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
965
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
966
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
967
|
+
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 });
|
|
968
|
+
}
|
|
969
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
970
|
+
if (claimed.changes !== 1)
|
|
971
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
972
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
973
|
+
}
|
|
974
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
975
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
976
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
977
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
978
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
979
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
980
|
+
}).eventKey;
|
|
981
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
982
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
983
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
984
|
+
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 });
|
|
985
|
+
}
|
|
986
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
987
|
+
if (claimed.changes !== 1)
|
|
988
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
989
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
990
|
+
}
|
|
729
991
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
730
992
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
731
993
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -741,6 +1003,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
741
1003
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
742
1004
|
if (!event?.session_id)
|
|
743
1005
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
1006
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
744
1007
|
return {
|
|
745
1008
|
hookEventId: event.id,
|
|
746
1009
|
harness: event.harness,
|
|
@@ -777,21 +1040,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
777
1040
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
778
1041
|
export function findRecentMatchingHookEvent(context, input) {
|
|
779
1042
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1043
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1044
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1045
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1046
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1047
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1048
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1049
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1050
|
+
AND (? IS NULL OR session_id = ?)
|
|
1051
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1052
|
+
let events = find();
|
|
1053
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1054
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
786
1055
|
const deadline = Date.now() + 250;
|
|
787
|
-
while (
|
|
1056
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
788
1057
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
789
|
-
|
|
1058
|
+
events = find();
|
|
1059
|
+
}
|
|
1060
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1061
|
+
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
1062
|
}
|
|
791
|
-
if (
|
|
792
|
-
throw new AppError(
|
|
1063
|
+
if (events.length > 1) {
|
|
1064
|
+
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
1065
|
}
|
|
794
|
-
return { id:
|
|
1066
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
795
1067
|
}
|
|
796
1068
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
797
1069
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -814,9 +1086,9 @@ export function bootstrapMatchKey(input) {
|
|
|
814
1086
|
})).digest("hex");
|
|
815
1087
|
}
|
|
816
1088
|
export function claimWorkStartHookEvent(context, input) {
|
|
817
|
-
const expected =
|
|
1089
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
818
1090
|
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.
|
|
1091
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
820
1092
|
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
1093
|
}
|
|
822
1094
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -824,15 +1096,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
824
1096
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
825
1097
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
826
1098
|
}
|
|
827
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
828
1099
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
829
1100
|
export function assertStageStartHookEvent(context, input) {
|
|
830
|
-
const expected =
|
|
1101
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
831
1102
|
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,
|
|
1103
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1104
|
+
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
1105
|
}
|
|
835
|
-
|
|
1106
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1107
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1108
|
+
return identity;
|
|
836
1109
|
}
|
|
837
1110
|
export function claimStageStartHookEvent(context, input) {
|
|
838
1111
|
assertStageStartHookEvent(context, input);
|
|
@@ -862,24 +1135,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
862
1135
|
}
|
|
863
1136
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
864
1137
|
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") {
|
|
1138
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1139
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1140
|
+
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]);
|
|
1141
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
871
1142
|
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
1143
|
}
|
|
1144
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1145
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
873
1146
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
874
1147
|
if (claimed.changes !== 1)
|
|
875
1148
|
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
|
|
1149
|
+
return identity;
|
|
877
1150
|
}
|
|
878
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1151
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
879
1152
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
880
1153
|
operation: "work_start",
|
|
881
1154
|
work_id: workId,
|
|
882
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1155
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1156
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
883
1157
|
})).digest("hex");
|
|
884
1158
|
}
|
|
885
1159
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -919,6 +1193,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
919
1193
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
920
1194
|
}
|
|
921
1195
|
function resolveProfileName(profile) {
|
|
1196
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1197
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
922
1198
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
923
1199
|
}
|
|
924
1200
|
function sanitizeProfile(value) {
|
|
@@ -946,11 +1222,12 @@ function readTomlIfExists(file) {
|
|
|
946
1222
|
if (!fs.existsSync(file)) {
|
|
947
1223
|
return {};
|
|
948
1224
|
}
|
|
1225
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
949
1226
|
try {
|
|
950
|
-
return parse(
|
|
1227
|
+
return parse(text);
|
|
951
1228
|
}
|
|
952
1229
|
catch (error) {
|
|
953
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1230
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
954
1231
|
}
|
|
955
1232
|
}
|
|
956
1233
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -969,7 +1246,7 @@ function objectTable(value) {
|
|
|
969
1246
|
function createSharedLinks(sourceHome, targetHome) {
|
|
970
1247
|
const linked = [];
|
|
971
1248
|
const skipped = [];
|
|
972
|
-
for (const entry of
|
|
1249
|
+
for (const entry of codexHomeSharedEntries) {
|
|
973
1250
|
const source = path.join(sourceHome, entry);
|
|
974
1251
|
const target = path.join(targetHome, entry);
|
|
975
1252
|
if (!fs.existsSync(source)) {
|
|
@@ -992,7 +1269,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
992
1269
|
return { linked, skipped };
|
|
993
1270
|
}
|
|
994
1271
|
function removeSharedSymlinks(targetHome) {
|
|
995
|
-
for (const entry of
|
|
1272
|
+
for (const entry of codexHomeSharedEntries) {
|
|
996
1273
|
const target = path.join(targetHome, entry);
|
|
997
1274
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
998
1275
|
fs.rmSync(target);
|
|
@@ -1005,7 +1282,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1005
1282
|
}
|
|
1006
1283
|
}
|
|
1007
1284
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1008
|
-
return
|
|
1285
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1009
1286
|
const source = path.join(sourceHome, entry);
|
|
1010
1287
|
const target = path.join(targetHome, entry);
|
|
1011
1288
|
return {
|
|
@@ -1026,10 +1303,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1026
1303
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1027
1304
|
}
|
|
1028
1305
|
function commandHook(command, statusMessage) {
|
|
1029
|
-
|
|
1306
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1307
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1308
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1309
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1030
1310
|
}
|
|
1031
1311
|
function hookCommand(event) {
|
|
1032
|
-
const
|
|
1312
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1313
|
+
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
1314
|
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
1315
|
}
|
|
1035
1316
|
function resolveHookTarget(target) {
|
|
@@ -1205,11 +1486,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1205
1486
|
function installationScope(target, profile, hooksPath) {
|
|
1206
1487
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1207
1488
|
}
|
|
1208
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1489
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1209
1490
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1210
1491
|
const now = context.now();
|
|
1211
1492
|
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
1493
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1214
1494
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1215
1495
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1263,12 +1543,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1263
1543
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1264
1544
|
}
|
|
1265
1545
|
function recordHookEvent(context, input) {
|
|
1266
|
-
|
|
1267
|
-
if (existing)
|
|
1546
|
+
if (assertHookEventReplay(context, input))
|
|
1268
1547
|
return false;
|
|
1269
1548
|
context.db.run(`INSERT INTO hook_events
|
|
1270
|
-
|
|
1271
|
-
|
|
1549
|
+
(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)
|
|
1550
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1272
1551
|
input.projectId,
|
|
1273
1552
|
input.protocolId,
|
|
1274
1553
|
input.harness,
|
|
@@ -1295,6 +1574,23 @@ function recordHookEvent(context, input) {
|
|
|
1295
1574
|
]);
|
|
1296
1575
|
return true;
|
|
1297
1576
|
}
|
|
1577
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1578
|
+
function assertHookEventReplay(context, input) {
|
|
1579
|
+
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
|
|
1580
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1581
|
+
if (existing) {
|
|
1582
|
+
const observed = {
|
|
1583
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1584
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1585
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1586
|
+
};
|
|
1587
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1588
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1589
|
+
}
|
|
1590
|
+
return true;
|
|
1591
|
+
}
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1298
1594
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1299
1595
|
if (explicitRoot)
|
|
1300
1596
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1321,14 +1617,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1321
1617
|
command
|
|
1322
1618
|
})).digest("hex");
|
|
1323
1619
|
}
|
|
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
1620
|
function toolNameFromPayload(payload) {
|
|
1333
1621
|
const toolInput = objectRecord(payload.tool_input);
|
|
1334
1622
|
return stringValue(toolInput.name);
|
|
@@ -1371,10 +1659,10 @@ function writeJsonFile(file, value) {
|
|
|
1371
1659
|
ensureDir(path.dirname(file));
|
|
1372
1660
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1373
1661
|
}
|
|
1374
|
-
function backupFile(file, now) {
|
|
1662
|
+
function backupFile(file, now, text) {
|
|
1375
1663
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1376
1664
|
const backup = `${file}.${suffix}.bak`;
|
|
1377
|
-
fs.
|
|
1665
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1378
1666
|
return backup;
|
|
1379
1667
|
}
|
|
1380
1668
|
function hashObject(value) {
|