@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110
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 +520 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +77 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +48 -0
- package/dist/cli/run-cli.js +1465 -384
- package/dist/cli.js +8 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +26 -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/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -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 +149 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -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 +327 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +108 -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 +71 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/code-work-result.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +132 -39
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +589 -106
- 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 +173 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +994 -97
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +166 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +702 -327
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +113 -18
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1379 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +302 -34
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +66 -28
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +25 -16
- package/dist/services/protocols.js +160 -86
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +142 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +351 -0
- package/dist/services/run-control.js +888 -0
- package/dist/services/run-controller-adapter.js +231 -0
- package/dist/services/run-controller-capture.js +167 -0
- package/dist/services/run-controller-process.js +180 -0
- package/dist/services/run-controller-recovery.js +221 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +798 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +142 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-runtime.js +80 -0
- package/dist/services/run-recovery.js +345 -0
- package/dist/services/runs.js +536 -181
- package/dist/services/runtime-budget.js +413 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +460 -0
- package/dist/services/runtime-scope-resume.js +577 -0
- package/dist/services/runtime-scope-stop.js +159 -0
- package/dist/services/runtime-scope-worker.js +242 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +50 -19
- package/dist/services/stage-lifecycle.js +100 -103
- package/dist/services/stage-pause.js +88 -75
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +35 -98
- package/dist/services/vnext-code-review.js +197 -84
- package/dist/services/vnext-code.js +278 -251
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +172 -17
- package/dist/services/vnext-merge.js +345 -111
- package/dist/services/vnext-plan-review.js +171 -111
- package/dist/services/vnext-plan.js +180 -87
- package/dist/services/vnext-protocolize.js +320 -74
- package/dist/services/vnext-specify.js +115 -77
- package/dist/services/work-registry.js +1001 -187
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +88 -36
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +692 -35
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +24 -13
- package/tools/repair-paused-run-status.mjs +84 -0
package/dist/services/hooks.js
CHANGED
|
@@ -2,18 +2,23 @@ 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";
|
|
10
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
9
11
|
import { appendAudit } from "./audit.js";
|
|
12
|
+
import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
|
|
10
13
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
|
-
import {
|
|
14
|
+
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
15
|
+
import { observeLifecycleInvocation, observedLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
16
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
|
|
12
17
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
|
-
import { activeFlowSessionsForProject,
|
|
18
|
+
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
14
19
|
const defaultProfileName = "default";
|
|
15
20
|
const maxSanitizedSummaryLength = 1600;
|
|
16
|
-
const
|
|
21
|
+
export const codexHomeSharedEntries = [
|
|
17
22
|
"auth.json",
|
|
18
23
|
"auth.bk",
|
|
19
24
|
"sessions",
|
|
@@ -50,22 +55,28 @@ export function planCodexHome(context, input) {
|
|
|
50
55
|
config_path: paths.configPath,
|
|
51
56
|
hooks_path: paths.hooksPath,
|
|
52
57
|
owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
|
|
53
|
-
shared_entries:
|
|
58
|
+
shared_entries: codexHomeSharedEntries,
|
|
54
59
|
exists: fs.existsSync(paths.targetHome),
|
|
55
60
|
codex_env: { CODEX_HOME: paths.targetHome }
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
|
-
export function
|
|
63
|
+
export function prepareCodexHome(context, input) {
|
|
59
64
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
60
65
|
const profile = resolveProfileName(input.profile);
|
|
61
66
|
const paths = resolveHomePaths(context, project, profile, input);
|
|
67
|
+
if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
|
|
68
|
+
throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
|
|
69
|
+
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
70
|
+
return { project, profile, paths, config };
|
|
71
|
+
}
|
|
72
|
+
export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
|
|
73
|
+
const { project, profile, paths, config } = prepared;
|
|
62
74
|
const now = context.now();
|
|
63
75
|
ensureDir(paths.targetHome);
|
|
64
76
|
ensureDir(path.join(paths.targetHome, "log"));
|
|
65
77
|
ensureDir(path.join(paths.targetHome, ".tmp"));
|
|
66
78
|
ensureDir(path.join(paths.targetHome, "tmp"));
|
|
67
79
|
const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
|
|
68
|
-
const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
|
|
69
80
|
fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
|
|
70
81
|
writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
|
|
71
82
|
writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
|
|
@@ -136,7 +147,7 @@ export function printCodexHomeEnv(context, input) {
|
|
|
136
147
|
command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
|
|
137
148
|
};
|
|
138
149
|
}
|
|
139
|
-
export function
|
|
150
|
+
export function prepareCodexHomeRemoval(context, input) {
|
|
140
151
|
if (!["keep-shared", "remove-owned"].includes(input.mode)) {
|
|
141
152
|
throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
|
|
142
153
|
}
|
|
@@ -144,9 +155,30 @@ export function removeCodexHome(context, input) {
|
|
|
144
155
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
145
156
|
const profile = resolveProfileName(input.profile);
|
|
146
157
|
const record = homeProfile(context, project.id, profile);
|
|
158
|
+
if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
|
|
159
|
+
let marker;
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
163
|
+
throw new Error("Invalid marker");
|
|
164
|
+
marker = value;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
|
|
168
|
+
}
|
|
169
|
+
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)) {
|
|
170
|
+
throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { project, profile, record, mode };
|
|
174
|
+
}
|
|
175
|
+
export function removeCodexHome(context, input) {
|
|
176
|
+
const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
|
|
147
177
|
if (!record) {
|
|
148
178
|
return { ok: true, removed: false, profile, status: "not_initialized" };
|
|
149
179
|
}
|
|
180
|
+
if (record.status === "removed")
|
|
181
|
+
return { ok: true, removed: false, profile, status: "already_removed" };
|
|
150
182
|
const removedEntries = [];
|
|
151
183
|
for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
|
|
152
184
|
const target = path.join(record.target_home, relative);
|
|
@@ -207,7 +239,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
207
239
|
recorded
|
|
208
240
|
};
|
|
209
241
|
}
|
|
210
|
-
export function
|
|
242
|
+
export function prepareCodexHooks(context, input) {
|
|
211
243
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
212
244
|
const target = resolveHookTarget(input.target);
|
|
213
245
|
const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
|
|
@@ -217,9 +249,16 @@ export function installCodexHooks(context, input) {
|
|
|
217
249
|
hooks_path: location.hooksPath
|
|
218
250
|
});
|
|
219
251
|
}
|
|
220
|
-
const
|
|
221
|
-
const
|
|
252
|
+
const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
|
|
253
|
+
const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
|
|
254
|
+
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)))))
|
|
255
|
+
return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
|
|
256
|
+
return { project, target, location, existing, text };
|
|
257
|
+
}
|
|
258
|
+
export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
259
|
+
const { project, target, location, existing, text } = prepared;
|
|
222
260
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
261
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
223
262
|
ensureDir(path.dirname(location.hooksPath));
|
|
224
263
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
225
264
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
@@ -238,20 +277,11 @@ export function installCodexHooks(context, input) {
|
|
|
238
277
|
expected_hash: hashObject(expectedHooksConfig(project.root))
|
|
239
278
|
};
|
|
240
279
|
}
|
|
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) ?? {};
|
|
280
|
+
export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
|
|
281
|
+
const { project, target, location, existing, text } = prepared;
|
|
252
282
|
const hadManaged = hasManagedHooks(existing, project.root);
|
|
253
|
-
const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
|
|
254
283
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
284
|
+
const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
|
|
255
285
|
ensureDir(path.dirname(location.hooksPath));
|
|
256
286
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
257
287
|
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
@@ -274,98 +304,143 @@ export function handleCodexHook(context, input) {
|
|
|
274
304
|
// therefore the only valid identity for a fresh Work.
|
|
275
305
|
const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
|
|
276
306
|
const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
|
|
277
|
-
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
278
307
|
const turnId = stringValue(payload.turn_id);
|
|
279
308
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
280
309
|
const command = lifecycleCommandFromPayload(payload);
|
|
281
310
|
if (!command)
|
|
282
311
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
283
312
|
const lifecycle = lifecycleFacts(command);
|
|
284
|
-
if (!lifecycle)
|
|
313
|
+
if (!lifecycle && !continuationCommand(command))
|
|
285
314
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
315
|
+
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
286
316
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
287
317
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
permissionDecision: "deny",
|
|
298
|
-
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
|
|
299
|
-
}
|
|
300
|
-
};
|
|
318
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
319
|
+
const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
|
|
320
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
321
|
+
const proof = context.codexHookProof;
|
|
322
|
+
if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
|
|
323
|
+
|| proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
|
|
324
|
+
|| proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
|
|
325
|
+
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" });
|
|
326
|
+
}
|
|
301
327
|
}
|
|
302
|
-
const
|
|
303
|
-
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
328
|
+
const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
|
|
304
329
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
305
330
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
306
|
-
|
|
307
|
-
|
|
331
|
+
const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
|
|
332
|
+
if (bootstrapStageStart && bootstrapRoot) {
|
|
333
|
+
registerProject(context, { root: bootstrapRoot });
|
|
308
334
|
}
|
|
309
|
-
const project = projectForHook(context,
|
|
310
|
-
if (!project)
|
|
335
|
+
const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
|
|
336
|
+
if (!project) {
|
|
337
|
+
if (invocationId)
|
|
338
|
+
throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
|
|
311
339
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
312
|
-
|
|
340
|
+
}
|
|
341
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
|
|
342
|
+
// Codex retains the root session_id for a thread-spawned child, while the
|
|
343
|
+
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
344
|
+
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
345
|
+
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
313
346
|
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
314
347
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
protocolId,
|
|
329
|
-
harness: "codex-desktop",
|
|
330
|
-
providerSessionId: providerSessionId ?? null,
|
|
331
|
-
parentSessionId,
|
|
332
|
-
sessionId: effectiveSessionId,
|
|
333
|
-
agentId: rawChildProviderSessionId ?? null,
|
|
334
|
-
turnId: turnId ?? null,
|
|
335
|
-
eventName,
|
|
336
|
-
toolName: toolName ?? null,
|
|
337
|
-
status: "observed",
|
|
338
|
-
payload,
|
|
339
|
-
eventKey,
|
|
340
|
-
matchKey,
|
|
341
|
-
transcriptPath: binding?.transcript_path ?? null,
|
|
342
|
-
cwd: stringValue(payload.cwd) ?? null
|
|
343
|
-
});
|
|
344
|
-
if (effectiveSessionId) {
|
|
345
|
-
recordFlowSessionObservation(context, {
|
|
348
|
+
const eventCwd = stringValue(payload.cwd) ?? project.root;
|
|
349
|
+
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 });
|
|
350
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
351
|
+
// Resolve legacy transcript discovery before acquiring the receipt write lock.
|
|
352
|
+
const transcriptPath = stringValue(payload.transcript_path)
|
|
353
|
+
?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
|
|
354
|
+
const persist = (scope) => {
|
|
355
|
+
if (scope)
|
|
356
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
|
|
357
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
|
|
358
|
+
const effectiveSessionId = storageId;
|
|
359
|
+
const protocolId = binding?.protocol_id ?? null;
|
|
360
|
+
const inserted = recordHookEvent(context, {
|
|
346
361
|
projectId: project.id,
|
|
347
|
-
sessionId: effectiveSessionId,
|
|
348
|
-
runId: observedSession?.run_id ?? null,
|
|
349
362
|
protocolId,
|
|
350
|
-
|
|
363
|
+
harness: "codex-desktop",
|
|
364
|
+
providerSessionId: providerSessionId ?? null,
|
|
365
|
+
parentSessionId,
|
|
366
|
+
// Pinned by the owning app-server process, never derived from current RUN
|
|
367
|
+
// state or an agent-supplied shell command/payload.
|
|
368
|
+
daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
|
|
369
|
+
sessionId: effectiveSessionId,
|
|
370
|
+
agentId: rawChildProviderSessionId ?? null,
|
|
371
|
+
turnId: turnId ?? null,
|
|
372
|
+
eventName,
|
|
351
373
|
toolName: toolName ?? null,
|
|
352
|
-
|
|
374
|
+
status: "observed",
|
|
375
|
+
payload,
|
|
376
|
+
eventKey,
|
|
377
|
+
matchKey,
|
|
378
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
379
|
+
cwd: stringValue(payload.cwd) ?? project.root
|
|
353
380
|
});
|
|
354
|
-
|
|
355
|
-
|
|
381
|
+
if (effectiveSessionId) {
|
|
382
|
+
recordFlowSessionObservation(context, {
|
|
383
|
+
projectId: project.id,
|
|
384
|
+
sessionId: effectiveSessionId,
|
|
385
|
+
protocolId,
|
|
386
|
+
cwd: eventCwd,
|
|
387
|
+
toolName: toolName ?? null,
|
|
388
|
+
eventKey
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return { inserted, protocolId };
|
|
392
|
+
};
|
|
393
|
+
const common = {
|
|
356
394
|
ok: true,
|
|
357
|
-
observed: inserted,
|
|
358
|
-
duplicate: !inserted,
|
|
359
395
|
event_key: eventKey,
|
|
360
396
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
361
397
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
362
|
-
protocol_id:
|
|
363
|
-
|
|
398
|
+
protocol_id: null
|
|
399
|
+
};
|
|
400
|
+
if (!lifecycle) {
|
|
401
|
+
const persisted = context.db.writeTransaction(() => persist());
|
|
402
|
+
return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
|
|
403
|
+
}
|
|
404
|
+
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
405
|
+
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
406
|
+
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
407
|
+
if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
|
|
408
|
+
throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
|
|
409
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
410
|
+
...(invocationId ? { id: invocationId } : {}),
|
|
411
|
+
command,
|
|
412
|
+
harness: "codex-desktop",
|
|
413
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
|
|
414
|
+
recordReceipt: scope => { persist(scope); return eventKey; }
|
|
415
|
+
});
|
|
416
|
+
const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
|
|
417
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
418
|
+
if (admittedInvocationId)
|
|
419
|
+
return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
420
|
+
return {
|
|
421
|
+
...common,
|
|
422
|
+
protocol_id: stored?.protocol_id ?? null,
|
|
423
|
+
observed: !receipt.duplicate,
|
|
424
|
+
duplicate: receipt.duplicate,
|
|
425
|
+
hookSpecificOutput: {
|
|
426
|
+
hookEventName: "PreToolUse",
|
|
427
|
+
permissionDecision: "allow",
|
|
428
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const persisted = persist();
|
|
433
|
+
return {
|
|
434
|
+
...common,
|
|
435
|
+
protocol_id: persisted.protocolId,
|
|
436
|
+
observed: persisted.inserted,
|
|
437
|
+
duplicate: !persisted.inserted,
|
|
438
|
+
...(providerSessionId
|
|
364
439
|
? {
|
|
365
440
|
hookSpecificOutput: {
|
|
366
441
|
hookEventName: "PreToolUse",
|
|
367
442
|
permissionDecision: "allow",
|
|
368
|
-
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
443
|
+
updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
|
|
369
444
|
}
|
|
370
445
|
}
|
|
371
446
|
: {})
|
|
@@ -377,7 +452,24 @@ function codexNativeParentId(payload, childId) {
|
|
|
377
452
|
if (!transcript || !path.isAbsolute(transcript))
|
|
378
453
|
return null;
|
|
379
454
|
try {
|
|
380
|
-
const
|
|
455
|
+
const descriptor = fs.openSync(transcript, "r");
|
|
456
|
+
const chunks = [];
|
|
457
|
+
try {
|
|
458
|
+
for (;;) {
|
|
459
|
+
const buffer = Buffer.alloc(4096);
|
|
460
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
461
|
+
if (!length)
|
|
462
|
+
break;
|
|
463
|
+
const newline = buffer.subarray(0, length).indexOf(10);
|
|
464
|
+
chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
|
|
465
|
+
if (newline >= 0)
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
fs.closeSync(descriptor);
|
|
471
|
+
}
|
|
472
|
+
const firstLine = Buffer.concat(chunks).toString("utf8");
|
|
381
473
|
if (!firstLine)
|
|
382
474
|
return null;
|
|
383
475
|
const record = JSON.parse(firstLine);
|
|
@@ -389,117 +481,110 @@ function codexNativeParentId(payload, childId) {
|
|
|
389
481
|
return null;
|
|
390
482
|
}
|
|
391
483
|
}
|
|
392
|
-
/**
|
|
484
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
485
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
393
486
|
export function handleZcodeEvent(context, input) {
|
|
394
487
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
395
|
-
|
|
488
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
489
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
396
490
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
400
|
-
return { ok: true, observed: false, reason: "
|
|
401
|
-
const updateMeta = objectRecord(update._meta);
|
|
402
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
403
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
404
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
405
|
-
const rawInput = objectRecord(update.rawInput);
|
|
491
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
492
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
493
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
494
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
406
495
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
407
|
-
|
|
496
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
497
|
+
if (parsed.kind === "none")
|
|
498
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
499
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
500
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
501
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
502
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
503
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
504
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
|
|
508
|
+
* commands instead rendezvous with their ACP event while the CLI awaits
|
|
509
|
+
* admission; the native hook must not create a competing receipt for them.
|
|
510
|
+
*/
|
|
511
|
+
export function handleZcodeHook(context, input) {
|
|
512
|
+
return recordZcodeLifecycle(context, input);
|
|
513
|
+
}
|
|
514
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
515
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
516
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
517
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
518
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
519
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
520
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
521
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
522
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
408
523
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
409
|
-
}
|
|
410
524
|
const lifecycle = lifecycleFacts(command);
|
|
411
|
-
if (!lifecycle)
|
|
525
|
+
if (!lifecycle && !continuationCommand(command))
|
|
412
526
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
413
|
-
if (lifecycle.
|
|
414
|
-
|
|
415
|
-
standalone_command: lifecycle.invocation.command
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
527
|
+
if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
|
|
528
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
419
529
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
530
|
+
const meta = objectRecord(hook._meta);
|
|
531
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
532
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
533
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
534
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
535
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
536
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
537
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
425
538
|
}
|
|
426
|
-
|
|
427
|
-
|
|
539
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
540
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
541
|
+
}
|
|
542
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
543
|
+
if (!toolUseId)
|
|
544
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
545
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
546
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
547
|
+
if (lifecycle?.bootstrapStageStart)
|
|
428
548
|
registerProject(context, { root: expectedRoot });
|
|
429
549
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
430
|
-
const envelopeMeta = objectRecord(notification._meta);
|
|
431
|
-
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
432
|
-
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
433
|
-
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
434
|
-
const daemonId = stringValue(ddZcode.daemonId);
|
|
435
|
-
if (!rootProviderSessionId)
|
|
436
|
-
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
437
|
-
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
438
|
-
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
439
550
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
440
|
-
const parentSessionId =
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
model: stringValue(observedProfile.model),
|
|
461
|
-
reasoning: stringValue(observedProfile.reasoning),
|
|
462
|
-
mode: stringValue(observedProfile.mode),
|
|
463
|
-
agent_type: stringValue(runtime.source)
|
|
551
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
552
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
553
|
+
const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
|
|
554
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
555
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
556
|
+
const persist = (scope) => {
|
|
557
|
+
if (scope)
|
|
558
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
559
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: observedCwd });
|
|
560
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
561
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
562
|
+
const inserted = recordHookEvent(context, {
|
|
563
|
+
projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
564
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
565
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
566
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd
|
|
567
|
+
});
|
|
568
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
|
|
569
|
+
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 };
|
|
570
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
464
571
|
};
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
572
|
+
if (!lifecycle)
|
|
573
|
+
return context.db.writeTransaction(() => persist());
|
|
574
|
+
let persistedResult;
|
|
575
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
576
|
+
...(invocationId ? { id: invocationId } : {}), command,
|
|
468
577
|
harness: "zcode-acp",
|
|
469
|
-
providerSessionId,
|
|
470
|
-
|
|
471
|
-
daemonId: daemonId ?? null,
|
|
472
|
-
sessionId,
|
|
473
|
-
agentId: agentId ?? null,
|
|
474
|
-
turnId: null,
|
|
475
|
-
eventName: "PreToolUse",
|
|
476
|
-
toolName: toolName ?? "Bash",
|
|
477
|
-
status: "observed",
|
|
478
|
-
payload,
|
|
479
|
-
eventKey,
|
|
480
|
-
matchKey,
|
|
481
|
-
transcriptPath: null,
|
|
482
|
-
cwd: expectedRoot
|
|
483
|
-
});
|
|
484
|
-
recordFlowSessionObservation(context, {
|
|
485
|
-
projectId: project.id,
|
|
486
|
-
sessionId,
|
|
487
|
-
runId: observedSession?.run_id ?? null,
|
|
488
|
-
protocolId: observedSession?.protocol_id ?? null,
|
|
489
|
-
cwd: expectedRoot,
|
|
490
|
-
toolName: toolName ?? "Bash",
|
|
491
|
-
eventKey
|
|
578
|
+
identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
579
|
+
recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
|
|
492
580
|
});
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
observed:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
501
|
-
daemon_id: daemonId ?? null
|
|
502
|
-
};
|
|
581
|
+
const admittedInvocationId = receipt.invocationId ?? invocationId;
|
|
582
|
+
if (admittedInvocationId)
|
|
583
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
|
|
584
|
+
// Without an issued managed attempt this is still ordinary hook evidence.
|
|
585
|
+
// Preserve persist()'s replay result instead of overwriting it with the
|
|
586
|
+
// admission layer's non-authoritative duplicate flag.
|
|
587
|
+
return persistedResult ?? persist();
|
|
503
588
|
}
|
|
504
589
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
505
590
|
export function handleGrokEvent(context, input) {
|
|
@@ -515,27 +600,18 @@ export function handleGrokEvent(context, input) {
|
|
|
515
600
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
516
601
|
}
|
|
517
602
|
const lifecycle = lifecycleFacts(command);
|
|
518
|
-
if (!lifecycle)
|
|
603
|
+
if (!lifecycle && !continuationCommand(command))
|
|
519
604
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
520
|
-
|
|
521
|
-
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
522
|
-
standalone_command: lifecycle.invocation.command
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
605
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
526
606
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
527
|
-
|
|
528
|
-
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
529
|
-
expected: expectedRoot, actual: commandProjectRoot
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
607
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
533
608
|
if (bootstrapStageStart)
|
|
534
609
|
registerProject(context, { root: expectedRoot });
|
|
535
610
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
536
611
|
const meta = objectRecord(hook._meta);
|
|
537
612
|
const ddGrok = objectRecord(meta.ddGrok);
|
|
538
613
|
const observedProfile = objectRecord(ddGrok.observedProfile);
|
|
614
|
+
const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
|
|
539
615
|
const rootProviderSessionId = stringValue(ddGrok.rootProviderSessionId);
|
|
540
616
|
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
541
617
|
if (!rootProviderSessionId || !providerSessionId)
|
|
@@ -545,41 +621,121 @@ export function handleGrokEvent(context, input) {
|
|
|
545
621
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
546
622
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
547
623
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
548
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
549
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
550
|
-
}, sessionId) : undefined;
|
|
551
624
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
552
|
-
harness: "grok-acp", providerSessionId,
|
|
625
|
+
harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
|
|
626
|
+
promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
553
627
|
})).digest("hex");
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
628
|
+
const persist = (scope) => {
|
|
629
|
+
if (scope)
|
|
630
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
631
|
+
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: observedCwd });
|
|
632
|
+
const payload = {
|
|
633
|
+
...hook, session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
634
|
+
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)
|
|
635
|
+
};
|
|
636
|
+
const inserted = recordHookEvent(context, {
|
|
637
|
+
projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
638
|
+
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
639
|
+
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: observedCwd
|
|
640
|
+
});
|
|
641
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
|
|
642
|
+
return inserted;
|
|
557
643
|
};
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
644
|
+
const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
645
|
+
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null });
|
|
646
|
+
if (!lifecycle)
|
|
647
|
+
return { ...result(context.db.writeTransaction(() => persist())), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
648
|
+
let inserted = false;
|
|
649
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
650
|
+
command, harness: "grok-acp", projectId: project.id,
|
|
651
|
+
identity: { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey },
|
|
652
|
+
recordReceipt: scope => { inserted = persist(scope); return eventKey; }
|
|
566
653
|
});
|
|
567
|
-
|
|
568
|
-
...(
|
|
569
|
-
return (
|
|
570
|
-
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
571
|
-
: result;
|
|
654
|
+
if (receipt.invocationId)
|
|
655
|
+
return { ...result(inserted), invocation_id: receipt.invocationId, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
656
|
+
return { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
|
|
572
657
|
}
|
|
573
658
|
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
574
659
|
export function handleOpenCodeEvent(context, input) {
|
|
575
660
|
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
576
|
-
const agy = event.source_harness === "antigravity-cli";
|
|
577
|
-
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
578
661
|
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
579
662
|
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
663
|
+
return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
|
|
664
|
+
}
|
|
665
|
+
/** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
|
|
666
|
+
export function handleDroidEvent(context, input) {
|
|
667
|
+
const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
|
|
668
|
+
if (event.schema_id !== "dd-flow/droid-tool-event@1")
|
|
669
|
+
throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
|
|
670
|
+
return handleControlledToolEvent(context, input, event, "droid-cli");
|
|
671
|
+
}
|
|
672
|
+
/** Stable project identity is not the native Session's execution workspace. */
|
|
673
|
+
function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
|
|
674
|
+
if (facts?.bootstrapStageStart)
|
|
675
|
+
return projectRoot;
|
|
676
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
677
|
+
if (!project)
|
|
678
|
+
return projectRoot;
|
|
679
|
+
let work;
|
|
680
|
+
if (facts?.workId) {
|
|
681
|
+
try {
|
|
682
|
+
work = resolveWorkReference(context, facts.workId, { projectId: project.id });
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
|
|
690
|
+
if (!reference)
|
|
691
|
+
return projectRoot;
|
|
692
|
+
const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
|
|
693
|
+
if (!run)
|
|
694
|
+
return projectRoot; // Hook ingress does not validate a mistyped CLI target.
|
|
695
|
+
if (work) {
|
|
696
|
+
// PROTOCOLIZE provisions the feature checkout after its coordinator
|
|
697
|
+
// Session has launched in the stable project root. Its transition Work
|
|
698
|
+
// must keep that physical route until the Stage is complete.
|
|
699
|
+
if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
|
|
700
|
+
&& context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
|
|
701
|
+
return projectRoot;
|
|
702
|
+
const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
|
|
703
|
+
if (merge)
|
|
704
|
+
return resolveProjectRoot(merge.target_workspace);
|
|
705
|
+
if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'work_launches'")) {
|
|
706
|
+
const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching', 'start_requested')", [work.work_id]);
|
|
707
|
+
if (launch?.provider_session_id === providerSessionId && run.run_root) {
|
|
708
|
+
const copy = JSON.parse(launch.profile_json).review_copy?.root;
|
|
709
|
+
if (copy) {
|
|
710
|
+
const target = path.resolve(run.run_root, copy);
|
|
711
|
+
const relative = path.relative(run.run_root, target);
|
|
712
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`))
|
|
713
|
+
throw new AppError("read_only_workspace_unbound", "Review copy escapes its owning RUN", 1);
|
|
714
|
+
return resolveProjectRoot(target);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (facts?.stageName === "specify" || facts?.stageName === "protocolize")
|
|
720
|
+
return projectRoot;
|
|
721
|
+
if (facts?.stageName === "merge") {
|
|
722
|
+
const pending = context.db.all("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND run_id = ? AND status IN ('dispatching','active','action_required') ORDER BY updated_at DESC", [project.id, run.id]);
|
|
723
|
+
const targets = [...new Set(pending.map(item => resolveProjectRoot(item.target_workspace)))];
|
|
724
|
+
if (targets.length > 1)
|
|
725
|
+
throw new AppError("merge_workspace_ambiguous", "Current MERGE requests disagree on the native execution workspace", 1, { run_id: run.id, target_workspaces: targets });
|
|
726
|
+
if (targets.length === 1)
|
|
727
|
+
return targets[0];
|
|
728
|
+
}
|
|
729
|
+
return resolveProjectRoot(run.workspace_root);
|
|
730
|
+
}
|
|
731
|
+
function handleControlledToolEvent(context, input, event, harness) {
|
|
732
|
+
const agy = harness === "antigravity-cli";
|
|
733
|
+
const droid = harness === "droid-cli";
|
|
734
|
+
const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
|
|
735
|
+
const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
|
|
580
736
|
const phase = stringValue(event.phase);
|
|
581
737
|
if (phase !== "before" && phase !== "after")
|
|
582
|
-
throw new AppError(
|
|
738
|
+
throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
|
|
583
739
|
const session = objectRecord(event.session);
|
|
584
740
|
const profile = objectRecord(event.profile);
|
|
585
741
|
const rawInput = objectRecord(event.input);
|
|
@@ -587,16 +743,16 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
587
743
|
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
588
744
|
const daemonId = stringValue(event.daemon_id);
|
|
589
745
|
const toolCallId = stringValue(event.tool_call_id);
|
|
590
|
-
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
746
|
+
const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
|
|
591
747
|
const toolName = stringValue(event.tool);
|
|
592
748
|
const directory = stringValue(session.directory);
|
|
593
|
-
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
594
|
-
throw new AppError(
|
|
749
|
+
if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
750
|
+
throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
|
|
595
751
|
}
|
|
596
752
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
753
|
+
const observedCwd = resolveProjectRoot(directory);
|
|
754
|
+
if (droid && nativeParentId === providerSessionId)
|
|
755
|
+
throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
|
|
600
756
|
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
601
757
|
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
602
758
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
@@ -606,67 +762,107 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
606
762
|
const inserted = recordHookEvent(context, {
|
|
607
763
|
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
608
764
|
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
609
|
-
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd:
|
|
610
|
-
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd:
|
|
765
|
+
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: observedCwd, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
766
|
+
eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
|
|
611
767
|
});
|
|
612
768
|
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
613
769
|
}
|
|
614
|
-
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
770
|
+
if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
|
|
615
771
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
616
772
|
}
|
|
617
773
|
const lifecycle = lifecycleFacts(command);
|
|
618
|
-
|
|
774
|
+
const auxiliary = continuationCommand(command);
|
|
775
|
+
if (!lifecycle && !auxiliary)
|
|
619
776
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
777
|
+
const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
|
|
778
|
+
? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
|
|
779
|
+
const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
|
|
780
|
+
if (observedCwd !== expectedCwd) {
|
|
781
|
+
throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
|
|
626
782
|
}
|
|
627
|
-
const
|
|
783
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
784
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
628
785
|
if (bootstrapStageStart)
|
|
629
786
|
registerProject(context, { root: expectedRoot });
|
|
630
787
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
788
|
+
if (droid) {
|
|
789
|
+
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]);
|
|
790
|
+
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)) {
|
|
791
|
+
throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
631
794
|
const agentId = stringValue(session.agent_id);
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
795
|
+
const persist = (scope) => {
|
|
796
|
+
if (scope)
|
|
797
|
+
matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
|
|
798
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: observedCwd });
|
|
799
|
+
const payload = {
|
|
800
|
+
session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
|
|
801
|
+
...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
|
|
802
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
|
|
803
|
+
mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
|
|
804
|
+
};
|
|
805
|
+
const inserted = recordHookEvent(context, {
|
|
806
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
807
|
+
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
808
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
|
|
809
|
+
});
|
|
810
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName, eventKey: baseKey });
|
|
811
|
+
return inserted;
|
|
642
812
|
};
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
813
|
+
const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
|
|
814
|
+
if (agy && lifecycle) {
|
|
815
|
+
let inserted = false;
|
|
816
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
817
|
+
command, harness, projectId: project.id, identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
|
|
818
|
+
recordReceipt: scope => { inserted = persist(scope); return baseKey; }
|
|
819
|
+
});
|
|
820
|
+
return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
|
|
821
|
+
}
|
|
822
|
+
const inserted = context.db.writeTransaction(() => persist());
|
|
823
|
+
return agy ? result(inserted) : { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
651
824
|
}
|
|
652
825
|
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
653
826
|
export function handleAgyEvent(context, input) {
|
|
654
827
|
const event = parseJsonObject(input.stdin || "{}", "Antigravity tool event");
|
|
655
828
|
if (event.schema_id !== "dd-flow/agy-tool-event@1")
|
|
656
829
|
throw new AppError("unsupported_agy_event_schema", "Unsupported Antigravity event schema", 1);
|
|
657
|
-
|
|
658
|
-
|
|
830
|
+
// workspacePaths lists available workspaces; its first element is not a
|
|
831
|
+
// native selected-cwd observation. The daemon's spawn cwd is a separate,
|
|
832
|
+
// launch-bound route, and the list only corroborates its membership.
|
|
833
|
+
const workspacePaths = Array.isArray(event.workspace_paths)
|
|
834
|
+
? event.workspace_paths.filter((value) => typeof value === "string" && path.isAbsolute(value)) : [];
|
|
835
|
+
const launchCwd = stringValue(event.launch_cwd);
|
|
836
|
+
const qualifiedLaunchCwd = launchCwd && path.isAbsolute(launchCwd) ? launchCwd : undefined;
|
|
837
|
+
const directory = qualifiedLaunchCwd ?? (workspacePaths.length === 1 ? workspacePaths[0] : undefined);
|
|
838
|
+
if (!directory && workspacePaths.length > 1)
|
|
839
|
+
throw new AppError("agy_workspace_route_unproven", "Antigravity did not identify the active route among multiple workspaces", 1, { workspace_paths: workspacePaths });
|
|
840
|
+
if (directory && qualifiedLaunchCwd && workspacePaths.length
|
|
841
|
+
&& !workspacePaths.some(value => resolveProjectRoot(value) === resolveProjectRoot(directory))) {
|
|
842
|
+
throw new AppError("agy_workspace_route_unproven", "Antigravity launch cwd is absent from the native workspace list", 1, { launch_cwd: directory, workspace_paths: workspacePaths });
|
|
843
|
+
}
|
|
659
844
|
const providerSessionId = stringValue(event.conversation_id);
|
|
660
845
|
const daemonId = stringValue(event.daemon_id);
|
|
661
846
|
const eventId = stringValue(event.event_id);
|
|
662
847
|
const toolName = stringValue(event.tool);
|
|
663
848
|
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
664
849
|
throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
|
|
850
|
+
const rawInput = objectRecord(event.input);
|
|
851
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
852
|
+
if (command && (lifecycleFacts(command) || continuationCommand(command)) && !qualifiedLaunchCwd) {
|
|
853
|
+
throw new AppError("agy_workspace_route_unproven", "Managed Antigravity call has no launch-bound execution cwd", 1, { phase: "prepare", effect: "no_effect", recoverable: false, workspace_paths: workspacePaths });
|
|
854
|
+
}
|
|
855
|
+
const invalidStep = !Number.isSafeInteger(event.step_index) || Number(event.step_index) < 0;
|
|
856
|
+
const invalidExecution = event.execution_num !== undefined && (!Number.isSafeInteger(event.execution_num) || Number(event.execution_num) < 0);
|
|
857
|
+
if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command)) && (invalidStep || invalidExecution)) {
|
|
858
|
+
throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require a native step identity and a valid execution identity when provided", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
859
|
+
}
|
|
665
860
|
const translated = {
|
|
666
861
|
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
667
862
|
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
668
863
|
message_id: event.step_index === undefined ? null : String(event.step_index),
|
|
669
864
|
session: { provider_session_id: providerSessionId, parent_provider_session_id: event.parent_conversation_id, directory },
|
|
865
|
+
cwd_provenance: qualifiedLaunchCwd ? "launch_bound" : "workspace_paths_singleton",
|
|
670
866
|
profile: { provider: objectRecord(event.profile).provider, model: objectRecord(event.profile).model, variant: objectRecord(event.profile).reasoning, agent: objectRecord(event.profile).mode },
|
|
671
867
|
outcome: { status: stringValue(event.error) ? "error" : "completed" }
|
|
672
868
|
};
|
|
@@ -694,15 +890,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
|
694
890
|
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
695
891
|
* then apply the same lifecycle parser to its actual shell payload.
|
|
696
892
|
*/
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
893
|
+
/**
|
|
894
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
895
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
896
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
897
|
+
*/
|
|
898
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
702
899
|
const analysis = parseLifecycleCommand(command);
|
|
703
|
-
if (analysis.kind !== "standalone"
|
|
900
|
+
if (analysis.kind !== "standalone")
|
|
901
|
+
return command;
|
|
902
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
704
903
|
return command;
|
|
705
|
-
|
|
904
|
+
}
|
|
905
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
906
|
+
if (analysis.invocation.rewriteSafe) {
|
|
907
|
+
const index = shellSuffixOffset(command);
|
|
908
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
909
|
+
}
|
|
910
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
911
|
+
const index = firstHeredoc(command);
|
|
912
|
+
if (index >= 0)
|
|
913
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
914
|
+
}
|
|
915
|
+
return command;
|
|
916
|
+
}
|
|
917
|
+
function firstHeredoc(command) {
|
|
918
|
+
let quoteState = null;
|
|
919
|
+
let escaped = false;
|
|
920
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
921
|
+
const char = command[index];
|
|
922
|
+
if (escaped) {
|
|
923
|
+
escaped = false;
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (char === "\\" && quoteState !== "'") {
|
|
927
|
+
escaped = true;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (quoteState) {
|
|
931
|
+
if (char === quoteState)
|
|
932
|
+
quoteState = null;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (char === "'" || char === '"') {
|
|
936
|
+
quoteState = char;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
940
|
+
return index;
|
|
941
|
+
}
|
|
942
|
+
return -1;
|
|
706
943
|
}
|
|
707
944
|
/**
|
|
708
945
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -723,25 +960,51 @@ function lifecycleFacts(command) {
|
|
|
723
960
|
return null;
|
|
724
961
|
const invocation = analysis.invocation;
|
|
725
962
|
const stageStart = invocation.operation === "stage_start";
|
|
963
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
964
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
726
965
|
const stageResume = invocation.operation === "stage_resume";
|
|
727
966
|
const workStart = invocation.operation === "work_start";
|
|
967
|
+
const workFinish = invocation.operation === "work_finish";
|
|
968
|
+
const workFail = invocation.operation === "work_fail";
|
|
728
969
|
return {
|
|
729
970
|
analysis,
|
|
730
971
|
invocation,
|
|
731
|
-
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
732
972
|
stageStart,
|
|
973
|
+
stageFinish,
|
|
974
|
+
stagePause,
|
|
733
975
|
stageResume,
|
|
734
|
-
workStart,
|
|
976
|
+
workStart, workFinish, workFail,
|
|
735
977
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
736
978
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
737
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
738
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
739
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
979
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
980
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
981
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
740
982
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
741
983
|
};
|
|
742
984
|
}
|
|
743
|
-
|
|
744
|
-
|
|
985
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
986
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
987
|
+
// not a second authority. Everything else remains standalone-only.
|
|
988
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
989
|
+
const operation = facts.invocation.operation;
|
|
990
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
991
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
992
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
993
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
994
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
995
|
+
}
|
|
996
|
+
function lifecycleMatchKey(facts, projectRoot, scope) {
|
|
997
|
+
// Observe the actual argument without validating it. A mistyped project
|
|
998
|
+
// must not leave a receipt matching a later, corrected non-rewriting call.
|
|
999
|
+
if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
|
|
1000
|
+
return null;
|
|
1001
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
1002
|
+
const effectiveRoot = scope?.projectRoot ?? projectRoot;
|
|
1003
|
+
const effectiveRunId = scope?.runId ?? runId;
|
|
1004
|
+
if (invocation.operation === "recovery_accept") {
|
|
1005
|
+
const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
|
|
1006
|
+
return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
|
|
1007
|
+
}
|
|
745
1008
|
if (bootstrapStageStart)
|
|
746
1009
|
return bootstrapMatchKey({
|
|
747
1010
|
projectRoot,
|
|
@@ -749,14 +1012,100 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
749
1012
|
intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
|
|
750
1013
|
...(contextSha256 ? { contextSha256 } : {})
|
|
751
1014
|
});
|
|
752
|
-
if (stageStart &&
|
|
753
|
-
return stageStartMatchKey(
|
|
754
|
-
if (
|
|
755
|
-
return
|
|
1015
|
+
if (stageStart && effectiveRunId && stageName)
|
|
1016
|
+
return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
|
|
1017
|
+
if (stageFinish && effectiveRunId && stageName)
|
|
1018
|
+
return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
|
|
1019
|
+
if (stagePause && effectiveRunId && stageName && workId)
|
|
1020
|
+
return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
|
|
1021
|
+
if (stageResume && effectiveRunId && stageName && workId)
|
|
1022
|
+
return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
|
|
756
1023
|
if (workStart && workId)
|
|
757
|
-
return workStartMatchKey(workId,
|
|
1024
|
+
return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
|
|
1025
|
+
if (workFinish && workId)
|
|
1026
|
+
return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
|
|
1027
|
+
if (workFail && workId)
|
|
1028
|
+
return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
|
|
758
1029
|
return null;
|
|
759
1030
|
}
|
|
1031
|
+
/** Observe exact immutable command attribution before reading model-supplied
|
|
1032
|
+
* result files. This does not claim or authorize the lifecycle mutation. */
|
|
1033
|
+
export function observeCommandHook(context, command) {
|
|
1034
|
+
const facts = lifecycleFacts(command);
|
|
1035
|
+
if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
|
|
1036
|
+
return;
|
|
1037
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
|
|
1038
|
+
if (!project)
|
|
1039
|
+
return; // Bootstrap has not registered a project yet.
|
|
1040
|
+
const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
|
|
1041
|
+
if (!matchKey)
|
|
1042
|
+
return;
|
|
1043
|
+
let key = commandOption(facts.invocation, "hook-event-id");
|
|
1044
|
+
if (!key && commandOption(facts.invocation, "invocation-id"))
|
|
1045
|
+
return;
|
|
1046
|
+
if (!key) {
|
|
1047
|
+
try {
|
|
1048
|
+
key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
return;
|
|
1052
|
+
} // Dispatch still owns its mandatory admission error.
|
|
1053
|
+
}
|
|
1054
|
+
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]);
|
|
1055
|
+
if (event)
|
|
1056
|
+
context.observeLifecycleHook?.(project.id, event.id);
|
|
1057
|
+
}
|
|
1058
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
1059
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
1060
|
+
}
|
|
1061
|
+
export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
|
|
1062
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
1063
|
+
}
|
|
1064
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
1065
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
1066
|
+
}
|
|
1067
|
+
function workLifecycleMatchKeys(operation, workId, projectRoot) {
|
|
1068
|
+
return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
|
|
1069
|
+
}
|
|
1070
|
+
function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
|
|
1071
|
+
const runReferences = entityReferenceVariants(runId);
|
|
1072
|
+
const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
|
|
1073
|
+
return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
|
|
1074
|
+
}
|
|
1075
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
1076
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
1077
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
1078
|
+
const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
|
|
1079
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
1080
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
1081
|
+
}).eventKey;
|
|
1082
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1083
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
1084
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
1085
|
+
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 });
|
|
1086
|
+
}
|
|
1087
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1088
|
+
if (claimed.changes !== 1)
|
|
1089
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
1090
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
1091
|
+
}
|
|
1092
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
1093
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
1094
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
1095
|
+
const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
1096
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
1097
|
+
projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
1098
|
+
}).eventKey;
|
|
1099
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
1100
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
|
|
1101
|
+
const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
|
|
1102
|
+
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 });
|
|
1103
|
+
}
|
|
1104
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1105
|
+
if (claimed.changes !== 1)
|
|
1106
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
1107
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
1108
|
+
}
|
|
760
1109
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
761
1110
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
762
1111
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -772,6 +1121,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
772
1121
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
773
1122
|
if (!event?.session_id)
|
|
774
1123
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
1124
|
+
context.observeLifecycleHook?.(projectId, event.id);
|
|
775
1125
|
return {
|
|
776
1126
|
hookEventId: event.id,
|
|
777
1127
|
harness: event.harness,
|
|
@@ -808,21 +1158,30 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
808
1158
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
809
1159
|
export function findRecentMatchingHookEvent(context, input) {
|
|
810
1160
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1161
|
+
const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
|
|
1162
|
+
const placeholders = matchKeys.map(() => "?").join(", ");
|
|
1163
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
1164
|
+
WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
|
|
1165
|
+
AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
|
|
1166
|
+
AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
|
|
1167
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
1168
|
+
AND (? IS NULL OR session_id = ?)
|
|
1169
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
1170
|
+
let events = find();
|
|
1171
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
1172
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
817
1173
|
const deadline = Date.now() + 250;
|
|
818
|
-
while (
|
|
1174
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
819
1175
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
820
|
-
|
|
1176
|
+
events = find();
|
|
821
1177
|
}
|
|
822
|
-
if (!
|
|
823
|
-
throw new AppError(input.errorCode, `${input.operation} requires
|
|
1178
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
1179
|
+
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" });
|
|
824
1180
|
}
|
|
825
|
-
|
|
1181
|
+
if (events.length > 1) {
|
|
1182
|
+
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) });
|
|
1183
|
+
}
|
|
1184
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
826
1185
|
}
|
|
827
1186
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
828
1187
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -845,9 +1204,9 @@ export function bootstrapMatchKey(input) {
|
|
|
845
1204
|
})).digest("hex");
|
|
846
1205
|
}
|
|
847
1206
|
export function claimWorkStartHookEvent(context, input) {
|
|
848
|
-
const expected =
|
|
1207
|
+
const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
|
|
849
1208
|
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]);
|
|
850
|
-
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.
|
|
1209
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
851
1210
|
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
852
1211
|
}
|
|
853
1212
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
@@ -855,15 +1214,16 @@ export function claimWorkStartHookEvent(context, input) {
|
|
|
855
1214
|
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
856
1215
|
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
857
1216
|
}
|
|
858
|
-
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
859
1217
|
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
860
1218
|
export function assertStageStartHookEvent(context, input) {
|
|
861
|
-
const expected =
|
|
1219
|
+
const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
|
|
862
1220
|
const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
863
|
-
if (!event?.session_id || event.status !== "observed" || event.match_key
|
|
864
|
-
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,
|
|
1221
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
|
|
1222
|
+
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 });
|
|
865
1223
|
}
|
|
866
|
-
|
|
1224
|
+
const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
1225
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
1226
|
+
return identity;
|
|
867
1227
|
}
|
|
868
1228
|
export function claimStageStartHookEvent(context, input) {
|
|
869
1229
|
assertStageStartHookEvent(context, input);
|
|
@@ -893,24 +1253,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
893
1253
|
}
|
|
894
1254
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
895
1255
|
export function claimStageResumeHookEvent(context, input) {
|
|
896
|
-
const
|
|
897
|
-
const
|
|
898
|
-
const event = input.eventKey
|
|
899
|
-
|
|
900
|
-
: 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]);
|
|
901
|
-
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
1256
|
+
const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
|
|
1257
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1258
|
+
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]);
|
|
1259
|
+
if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
|
|
902
1260
|
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 });
|
|
903
1261
|
}
|
|
1262
|
+
const identity = hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1263
|
+
assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
|
|
904
1264
|
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
905
1265
|
if (claimed.changes !== 1)
|
|
906
1266
|
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
907
|
-
return
|
|
1267
|
+
return identity;
|
|
908
1268
|
}
|
|
909
|
-
export function workStartMatchKey(workId, projectRoot) {
|
|
1269
|
+
export function workStartMatchKey(workId, projectRoot, recoveryId) {
|
|
910
1270
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
911
1271
|
operation: "work_start",
|
|
912
1272
|
work_id: workId,
|
|
913
|
-
project_root: resolveProjectRoot(projectRoot)
|
|
1273
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
1274
|
+
...(recoveryId ? { recovery_id: recoveryId } : {})
|
|
914
1275
|
})).digest("hex");
|
|
915
1276
|
}
|
|
916
1277
|
export function hookStatusForProject(context, projectId) {
|
|
@@ -950,6 +1311,8 @@ export function codexHookEventsForProject(context, projectId) {
|
|
|
950
1311
|
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
951
1312
|
}
|
|
952
1313
|
function resolveProfileName(profile) {
|
|
1314
|
+
if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
|
|
1315
|
+
throw new AppError("validation", "--profile must be nonempty text", 2);
|
|
953
1316
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
954
1317
|
}
|
|
955
1318
|
function sanitizeProfile(value) {
|
|
@@ -977,11 +1340,12 @@ function readTomlIfExists(file) {
|
|
|
977
1340
|
if (!fs.existsSync(file)) {
|
|
978
1341
|
return {};
|
|
979
1342
|
}
|
|
1343
|
+
const { text } = prepareTextFile(file, "Codex config");
|
|
980
1344
|
try {
|
|
981
|
-
return parse(
|
|
1345
|
+
return parse(text);
|
|
982
1346
|
}
|
|
983
1347
|
catch (error) {
|
|
984
|
-
throw new AppError("validation", `Invalid TOML in ${file}
|
|
1348
|
+
throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
|
|
985
1349
|
}
|
|
986
1350
|
}
|
|
987
1351
|
function withManagedConfig(config, sourceHome, targetHome) {
|
|
@@ -1000,7 +1364,7 @@ function objectTable(value) {
|
|
|
1000
1364
|
function createSharedLinks(sourceHome, targetHome) {
|
|
1001
1365
|
const linked = [];
|
|
1002
1366
|
const skipped = [];
|
|
1003
|
-
for (const entry of
|
|
1367
|
+
for (const entry of codexHomeSharedEntries) {
|
|
1004
1368
|
const source = path.join(sourceHome, entry);
|
|
1005
1369
|
const target = path.join(targetHome, entry);
|
|
1006
1370
|
if (!fs.existsSync(source)) {
|
|
@@ -1023,7 +1387,7 @@ function createSharedLinks(sourceHome, targetHome) {
|
|
|
1023
1387
|
return { linked, skipped };
|
|
1024
1388
|
}
|
|
1025
1389
|
function removeSharedSymlinks(targetHome) {
|
|
1026
|
-
for (const entry of
|
|
1390
|
+
for (const entry of codexHomeSharedEntries) {
|
|
1027
1391
|
const target = path.join(targetHome, entry);
|
|
1028
1392
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
|
|
1029
1393
|
fs.rmSync(target);
|
|
@@ -1036,7 +1400,7 @@ function removeDirIfEmpty(dir) {
|
|
|
1036
1400
|
}
|
|
1037
1401
|
}
|
|
1038
1402
|
function sharedEntryStatus(sourceHome, targetHome) {
|
|
1039
|
-
return
|
|
1403
|
+
return codexHomeSharedEntries.map((entry) => {
|
|
1040
1404
|
const source = path.join(sourceHome, entry);
|
|
1041
1405
|
const target = path.join(targetHome, entry);
|
|
1042
1406
|
return {
|
|
@@ -1057,10 +1421,14 @@ function hookEntry(matcher, command, statusMessage) {
|
|
|
1057
1421
|
return { matcher, hooks: [commandHook(command, statusMessage)] };
|
|
1058
1422
|
}
|
|
1059
1423
|
function commandHook(command, statusMessage) {
|
|
1060
|
-
|
|
1424
|
+
// Hook admission is synchronous (the CLI must see a committed receipt), but
|
|
1425
|
+
// the old 5s budget was shorter than a cold CLI/store open under fan-out.
|
|
1426
|
+
// Startup owns migrations; this budget only covers the bounded hot path.
|
|
1427
|
+
return { type: "command", command, timeout: 30, statusMessage };
|
|
1061
1428
|
}
|
|
1062
1429
|
function hookCommand(event) {
|
|
1063
|
-
const
|
|
1430
|
+
const args = `codex hook handle --event ${event} --json`;
|
|
1431
|
+
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`;
|
|
1064
1432
|
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"'`;
|
|
1065
1433
|
}
|
|
1066
1434
|
function resolveHookTarget(target) {
|
|
@@ -1236,11 +1604,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
1236
1604
|
function installationScope(target, profile, hooksPath) {
|
|
1237
1605
|
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
1238
1606
|
}
|
|
1239
|
-
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
1607
|
+
function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
|
|
1240
1608
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
1241
1609
|
const now = context.now();
|
|
1242
1610
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
1243
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1244
1611
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
1245
1612
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
1246
1613
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -1294,12 +1661,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1294
1661
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1295
1662
|
}
|
|
1296
1663
|
function recordHookEvent(context, input) {
|
|
1297
|
-
|
|
1298
|
-
if (existing)
|
|
1664
|
+
if (assertHookEventReplay(context, input))
|
|
1299
1665
|
return false;
|
|
1300
1666
|
context.db.run(`INSERT INTO hook_events
|
|
1301
|
-
|
|
1302
|
-
|
|
1667
|
+
(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)
|
|
1668
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1303
1669
|
input.projectId,
|
|
1304
1670
|
input.protocolId,
|
|
1305
1671
|
input.harness,
|
|
@@ -1326,6 +1692,23 @@ function recordHookEvent(context, input) {
|
|
|
1326
1692
|
]);
|
|
1327
1693
|
return true;
|
|
1328
1694
|
}
|
|
1695
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1696
|
+
function assertHookEventReplay(context, input) {
|
|
1697
|
+
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
|
|
1698
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1699
|
+
if (existing) {
|
|
1700
|
+
const observed = {
|
|
1701
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1702
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1703
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1704
|
+
};
|
|
1705
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1706
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1707
|
+
}
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1329
1712
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1330
1713
|
if (explicitRoot)
|
|
1331
1714
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|
|
@@ -1352,14 +1735,6 @@ function hookEventKey(payload, eventName, toolName, command) {
|
|
|
1352
1735
|
command
|
|
1353
1736
|
})).digest("hex");
|
|
1354
1737
|
}
|
|
1355
|
-
function commandFromPayload(payload) {
|
|
1356
|
-
const direct = stringValue(payload.command);
|
|
1357
|
-
if (direct) {
|
|
1358
|
-
return direct;
|
|
1359
|
-
}
|
|
1360
|
-
const toolInput = objectRecord(payload.tool_input);
|
|
1361
|
-
return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
|
|
1362
|
-
}
|
|
1363
1738
|
function toolNameFromPayload(payload) {
|
|
1364
1739
|
const toolInput = objectRecord(payload.tool_input);
|
|
1365
1740
|
return stringValue(toolInput.name);
|
|
@@ -1402,10 +1777,10 @@ function writeJsonFile(file, value) {
|
|
|
1402
1777
|
ensureDir(path.dirname(file));
|
|
1403
1778
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1404
1779
|
}
|
|
1405
|
-
function backupFile(file, now) {
|
|
1780
|
+
function backupFile(file, now, text) {
|
|
1406
1781
|
const suffix = now.replace(/[:.]/g, "-");
|
|
1407
1782
|
const backup = `${file}.${suffix}.bak`;
|
|
1408
|
-
fs.
|
|
1783
|
+
fs.writeFileSync(backup, text, { mode: 0o600 });
|
|
1409
1784
|
return backup;
|
|
1410
1785
|
}
|
|
1411
1786
|
function hashObject(value) {
|