oh-my-codex 0.21.13 → 0.21.15
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/Cargo.lock +6 -6
- package/Cargo.toml +1 -1
- package/README.md +17 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js +21 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js.map +1 -1
- package/dist/adapt/herdr/wiring.d.ts.map +1 -1
- package/dist/adapt/herdr/wiring.js +5 -0
- package/dist/adapt/herdr/wiring.js.map +1 -1
- package/dist/auth/__tests__/hotswap.test.js +28 -0
- package/dist/auth/__tests__/hotswap.test.js.map +1 -1
- package/dist/auth/hotswap.d.ts +1 -0
- package/dist/auth/hotswap.d.ts.map +1 -1
- package/dist/auth/hotswap.js +4 -1
- package/dist/auth/hotswap.js.map +1 -1
- package/dist/cli/__tests__/codex-plugin-layout.test.js +196 -0
- package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +2 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +14 -1
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/session-timeline.test.d.ts +2 -0
- package/dist/cli/__tests__/session-timeline.test.d.ts.map +1 -0
- package/dist/cli/__tests__/session-timeline.test.js +155 -0
- package/dist/cli/__tests__/session-timeline.test.js.map +1 -0
- package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +1 -0
- package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +1 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +11 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/session-search.d.ts.map +1 -1
- package/dist/cli/session-search.js +7 -0
- package/dist/cli/session-search.js.map +1 -1
- package/dist/cli/session-timeline.d.ts +9 -0
- package/dist/cli/session-timeline.d.ts.map +1 -0
- package/dist/cli/session-timeline.js +174 -0
- package/dist/cli/session-timeline.js.map +1 -0
- package/dist/config/__tests__/codex-hooks.test.js +3 -1
- package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +1 -0
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/codex-hooks.d.ts +1 -1
- package/dist/config/codex-hooks.d.ts.map +1 -1
- package/dist/config/codex-hooks.js +5 -0
- package/dist/config/codex-hooks.js.map +1 -1
- package/dist/evals/astra-defaults/__tests__/collector.test.js +51 -23
- package/dist/evals/astra-defaults/__tests__/collector.test.js.map +1 -1
- package/dist/evals/astra-defaults/collector.d.ts +2 -0
- package/dist/evals/astra-defaults/collector.d.ts.map +1 -1
- package/dist/evals/astra-defaults/collector.js +19 -8
- package/dist/evals/astra-defaults/collector.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js +143 -0
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
- package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
- package/dist/hooks/extensibility/dispatcher.js +120 -28
- package/dist/hooks/extensibility/dispatcher.js.map +1 -1
- package/dist/hooks/extensibility/runtime.d.ts.map +1 -1
- package/dist/hooks/extensibility/runtime.js +1 -0
- package/dist/hooks/extensibility/runtime.js.map +1 -1
- package/dist/hooks/extensibility/types.d.ts +2 -0
- package/dist/hooks/extensibility/types.d.ts.map +1 -1
- package/dist/hooks/native/types.d.ts +1 -1
- package/dist/hooks/native/types.d.ts.map +1 -1
- package/dist/hooks/native/types.js.map +1 -1
- package/dist/notifications/__tests__/lifecycle-dedupe.test.js +200 -2
- package/dist/notifications/__tests__/lifecycle-dedupe.test.js.map +1 -1
- package/dist/notifications/lifecycle-dedupe.d.ts +10 -0
- package/dist/notifications/lifecycle-dedupe.d.ts.map +1 -1
- package/dist/notifications/lifecycle-dedupe.js +175 -19
- package/dist/notifications/lifecycle-dedupe.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +278 -6
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/native-hook-protocol.test.d.ts +2 -0
- package/dist/scripts/__tests__/native-hook-protocol.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/native-hook-protocol.test.js +329 -0
- package/dist/scripts/__tests__/native-hook-protocol.test.js.map +1 -0
- package/dist/scripts/__tests__/smoke-packed-install.test.js +7 -6
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/codex-execution-surface.d.ts +1 -1
- package/dist/scripts/codex-execution-surface.d.ts.map +1 -1
- package/dist/scripts/codex-execution-surface.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts +1 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +124 -13
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/hook-payload-guard.d.ts +1 -1
- package/dist/scripts/hook-payload-guard.d.ts.map +1 -1
- package/dist/scripts/hook-payload-guard.js +1 -0
- package/dist/scripts/hook-payload-guard.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +2 -2
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +12 -1
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/session-history/__tests__/timeline.test.d.ts +2 -0
- package/dist/session-history/__tests__/timeline.test.d.ts.map +1 -0
- package/dist/session-history/__tests__/timeline.test.js +459 -0
- package/dist/session-history/__tests__/timeline.test.js.map +1 -0
- package/dist/session-history/export.d.ts +2 -3
- package/dist/session-history/export.d.ts.map +1 -1
- package/dist/session-history/export.js +5 -66
- package/dist/session-history/export.js.map +1 -1
- package/dist/session-history/timeline.d.ts +97 -0
- package/dist/session-history/timeline.d.ts.map +1 -0
- package/dist/session-history/timeline.js +325 -0
- package/dist/session-history/timeline.js.map +1 -0
- package/dist/session-history/transcript.d.ts +16 -0
- package/dist/session-history/transcript.d.ts.map +1 -0
- package/dist/session-history/transcript.js +111 -0
- package/dist/session-history/transcript.js.map +1 -0
- package/dist/subagents/tracker.d.ts +6 -0
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +32 -0
- package/dist/subagents/tracker.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +12 -3
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +25 -0
- package/dist/team/__tests__/scaling.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +30 -0
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +9 -1
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/wiki/__tests__/session-hooks.test.js +26 -0
- package/dist/wiki/__tests__/session-hooks.test.js.map +1 -1
- package/dist/wiki/lifecycle.d.ts.map +1 -1
- package/dist/wiki/lifecycle.js +6 -1
- package/dist/wiki/lifecycle.js.map +1 -1
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +85 -18
- package/plugins/oh-my-codex/hooks/hooks.json +10 -0
- package/src/scripts/__tests__/codex-native-hook.test.ts +273 -5
- package/src/scripts/__tests__/native-hook-protocol.test.ts +362 -0
- package/src/scripts/__tests__/smoke-packed-install.test.ts +7 -6
- package/src/scripts/codex-execution-surface.ts +1 -0
- package/src/scripts/codex-native-hook.ts +130 -11
- package/src/scripts/hook-payload-guard.ts +1 -0
- package/src/scripts/smoke-packed-install.ts +12 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
8
|
+
import { MANAGED_HOOK_EVENTS, buildManagedCodexHooksConfig } from "../../config/codex-hooks.js";
|
|
9
|
+
import { writeSessionStart } from "../../hooks/session.js";
|
|
10
|
+
import { createSubagentTrackingState, readSubagentTrackingState, recordNativeSubagentAuthorityObservation, recordSubagentTurn, withCrossProcessFileLockSync } from "../../subagents/tracker.js";
|
|
11
|
+
import { dispatchCodexNativeHook, mapCodexHookEventToOmxEvent } from "../codex-native-hook.js";
|
|
12
|
+
import { CODEX_HOOK_EVENT_NAMES, extractRawCodexHookEventName, MAX_NATIVE_STDIN_JSON_BYTES } from "../hook-payload-guard.js";
|
|
13
|
+
import { MANAGED_CODEX_HOOK_EVENTS } from "../smoke-packed-install.js";
|
|
14
|
+
|
|
15
|
+
describe("native hook protocol compatibility", () => {
|
|
16
|
+
let cwd: string;
|
|
17
|
+
let env: NodeJS.ProcessEnv;
|
|
18
|
+
const sessionId = "omx-protocol-session";
|
|
19
|
+
const parentId = "native-parent";
|
|
20
|
+
const agentId = "native-child";
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
env = { ...process.env };
|
|
24
|
+
for (const key of Object.keys(process.env)) {
|
|
25
|
+
if (key.startsWith("OMX_") || key === "CODEX_THREAD_ID") delete process.env[key];
|
|
26
|
+
}
|
|
27
|
+
cwd = await realpath(await mkdtemp(join(tmpdir(), "omx-native-protocol-")));
|
|
28
|
+
process.env.CODEX_HOME = join(cwd, "codex-home");
|
|
29
|
+
await mkdir(process.env.CODEX_HOME);
|
|
30
|
+
});
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
process.env = env;
|
|
33
|
+
await rm(cwd, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function stop(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
37
|
+
return {
|
|
38
|
+
hook_event_name: "SubagentStop",
|
|
39
|
+
cwd,
|
|
40
|
+
session_id: parentId,
|
|
41
|
+
agent_id: agentId,
|
|
42
|
+
agent_type: "executor",
|
|
43
|
+
turn_id: "child-turn-1",
|
|
44
|
+
transcript_path: null,
|
|
45
|
+
agent_transcript_path: null,
|
|
46
|
+
last_assistant_message: null,
|
|
47
|
+
stop_hook_active: false,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function start(): Promise<void> {
|
|
53
|
+
await writeSessionStart(cwd, sessionId, { nativeSessionId: parentId, pid: process.pid });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runNativeHookCli(input = JSON.stringify(stop())) {
|
|
57
|
+
return spawnSync(process.execPath, [join(process.cwd(), "dist/scripts/codex-native-hook.js")], {
|
|
58
|
+
cwd, env: process.env, input, encoding: "utf8", timeout: 10_000,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function captureLifecycle(notifications: Record<string, unknown>): Promise<string> {
|
|
63
|
+
await writeFile(join(process.env.CODEX_HOME!, ".omx-config.json"), JSON.stringify({
|
|
64
|
+
notifications: { enabled: true, ...notifications },
|
|
65
|
+
}));
|
|
66
|
+
const eventsPath = join(cwd, "child-events.jsonl");
|
|
67
|
+
await mkdir(join(cwd, ".omx/hooks"), { recursive: true });
|
|
68
|
+
await writeFile(join(cwd, ".omx/hooks/capture.mjs"),
|
|
69
|
+
`import { appendFileSync } from 'node:fs';\nexport function onHookEvent(event) { appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify(event) + '\\n'); }\n`);
|
|
70
|
+
return eventsPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const notifications of [
|
|
74
|
+
{ verbosity: "session", includeChildAgents: true },
|
|
75
|
+
{ verbosity: "agent" },
|
|
76
|
+
{ verbosity: "verbose" },
|
|
77
|
+
{ verbosity: "session" },
|
|
78
|
+
{ verbosity: "minimal" },
|
|
79
|
+
]) {
|
|
80
|
+
it(`applies the SubagentStop lifecycle policy for ${JSON.stringify(notifications)}`, async () => {
|
|
81
|
+
await start();
|
|
82
|
+
const eventsPath = await captureLifecycle(notifications);
|
|
83
|
+
await dispatchCodexNativeHook(stop());
|
|
84
|
+
assert.ok((await readSubagentTrackingState(cwd)).sessions[sessionId]!.threads[agentId]!.completed_at);
|
|
85
|
+
if (notifications.includeChildAgents !== true && !["agent", "verbose"].includes(notifications.verbosity)) {
|
|
86
|
+
assert.equal(existsSync(eventsPath), false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await dispatchCodexNativeHook(stop({ stop_hook_active: true, stopHookActive: true }));
|
|
90
|
+
const events = (await readFile(eventsPath, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
|
91
|
+
assert.equal(events.length, 1);
|
|
92
|
+
assert.equal(events[0].event, "subagent-stop");
|
|
93
|
+
assert.equal(events[0].source, "native");
|
|
94
|
+
assert.equal(events[0].session_id, sessionId);
|
|
95
|
+
assert.equal(events[0].thread_id, agentId);
|
|
96
|
+
assert.equal(events[0].turn_id, "child-turn-1");
|
|
97
|
+
assert.equal(events[0].context.native_session_id, agentId);
|
|
98
|
+
assert.equal(events[0].context.payload.session_id, parentId);
|
|
99
|
+
assert.equal(events[0].context.payload.stop_hook_active, undefined);
|
|
100
|
+
assert.equal(events[0].context.payload.stopHookActive, undefined);
|
|
101
|
+
await dispatchCodexNativeHook(stop({ turn_id: "child-turn-2" }));
|
|
102
|
+
assert.equal((await readFile(eventsPath, "utf8")).trim().split("\n").length, 2);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it("deduplicates interleaved child and turn replays independently", async () => {
|
|
107
|
+
await start();
|
|
108
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
109
|
+
await dispatchCodexNativeHook(stop());
|
|
110
|
+
await dispatchCodexNativeHook(stop({ agent_id: "other-child" }));
|
|
111
|
+
await dispatchCodexNativeHook(stop());
|
|
112
|
+
await dispatchCodexNativeHook(stop({ turn_id: "child-turn-2" }));
|
|
113
|
+
await dispatchCodexNativeHook(stop());
|
|
114
|
+
const events = (await readFile(eventsPath, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
|
115
|
+
assert.deepEqual(events.map(event => [event.thread_id, event.turn_id]), [
|
|
116
|
+
[agentId, "child-turn-1"], ["other-child", "child-turn-1"], [agentId, "child-turn-2"],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("deduplicates lifecycle hooks when legacy Stop and SubagentStop report the same child turn", async () => {
|
|
121
|
+
await start();
|
|
122
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
123
|
+
const legacyFirstChild = "legacy-first-child";
|
|
124
|
+
const nativeFirstChild = "native-first-child";
|
|
125
|
+
for (const childThreadId of [legacyFirstChild, nativeFirstChild]) {
|
|
126
|
+
recordNativeSubagentAuthorityObservation(cwd, {
|
|
127
|
+
sessionIds: [sessionId], childThreadId, parentThreadId: parentId,
|
|
128
|
+
rootNativeSessionId: parentId, authorityEvidence: "valid",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await dispatchCodexNativeHook({
|
|
133
|
+
hook_event_name: "Stop", cwd, session_id: legacyFirstChild,
|
|
134
|
+
thread_id: legacyFirstChild, turn_id: "turn-legacy-first", stop_hook_active: true,
|
|
135
|
+
});
|
|
136
|
+
await dispatchCodexNativeHook(stop({ agent_id: legacyFirstChild, turn_id: "turn-legacy-first" }));
|
|
137
|
+
await dispatchCodexNativeHook(stop({ agent_id: nativeFirstChild, turn_id: "turn-native-first" }));
|
|
138
|
+
await dispatchCodexNativeHook({
|
|
139
|
+
hook_event_name: "Stop", cwd, session_id: nativeFirstChild,
|
|
140
|
+
thread_id: nativeFirstChild, turn_id: "turn-native-first", stop_hook_active: true,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const events = (await readFile(eventsPath, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
|
144
|
+
assert.deepEqual(events.map(event => [event.event, event.thread_id, event.turn_id]), [
|
|
145
|
+
["stop", legacyFirstChild, "turn-legacy-first"],
|
|
146
|
+
["subagent-stop", nativeFirstChild, "turn-native-first"],
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not deduplicate separate SubagentStop completions when turn IDs are absent", async () => {
|
|
151
|
+
await start();
|
|
152
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
153
|
+
await dispatchCodexNativeHook(stop({ turn_id: null }));
|
|
154
|
+
await dispatchCodexNativeHook(stop({ turn_id: null }));
|
|
155
|
+
|
|
156
|
+
const events = (await readFile(eventsPath, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
|
157
|
+
assert.equal(events.length, 2);
|
|
158
|
+
assert.deepEqual(events.map(event => event.event), ["subagent-stop", "subagent-stop"]);
|
|
159
|
+
assert.deepEqual(events.map(event => event.turn_id), [undefined, undefined]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not treat missing turn IDs as proof of a duplicate completion", () => {
|
|
163
|
+
const first = recordSubagentTurn(createSubagentTrackingState(), {
|
|
164
|
+
sessionId, threadId: agentId, kind: "subagent", completed: true,
|
|
165
|
+
completionSource: "native_stop_event",
|
|
166
|
+
});
|
|
167
|
+
const second = recordSubagentTurn(first, {
|
|
168
|
+
sessionId, threadId: agentId, kind: "subagent", completed: true,
|
|
169
|
+
completionSource: "native_stop_event",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.equal(
|
|
173
|
+
second.sessions[sessionId]!.threads[agentId]!.turn_count,
|
|
174
|
+
first.sessions[sessionId]!.threads[agentId]!.turn_count + 1,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps malformed tracker authority bytes intact and returns CLI no-op with a diagnostic", async () => {
|
|
179
|
+
await start();
|
|
180
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
181
|
+
const trackerPath = join(cwd, ".omx/state/subagent-tracking.json");
|
|
182
|
+
const malformed = '{"schemaVersion":1,"sessions":{"preserve-authority":';
|
|
183
|
+
await writeFile(trackerPath, malformed);
|
|
184
|
+
const result = runNativeHookCli();
|
|
185
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
186
|
+
assert.deepEqual(JSON.parse(result.stdout), {});
|
|
187
|
+
assert.equal(await readFile(trackerPath, "utf8"), malformed);
|
|
188
|
+
assert.equal(existsSync(eventsPath), false);
|
|
189
|
+
const logDir = join(cwd, ".omx/logs");
|
|
190
|
+
const logFile = (await readdir(logDir)).find(name => name.startsWith("native-hook-"));
|
|
191
|
+
assert.ok(logFile);
|
|
192
|
+
const diagnostic = await readFile(join(logDir, logFile), "utf8");
|
|
193
|
+
assert.match(diagnostic, /native_subagent_completion_tracking_error/);
|
|
194
|
+
assert.match(diagnostic, /Malformed subagent tracker authority state/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns CLI no-op without dispatch or mutation when the tracker lock stays held", async () => {
|
|
198
|
+
await start();
|
|
199
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
200
|
+
recordNativeSubagentAuthorityObservation(cwd, {
|
|
201
|
+
sessionIds: [sessionId], childThreadId: agentId, parentThreadId: parentId,
|
|
202
|
+
rootNativeSessionId: parentId, authorityEvidence: "valid",
|
|
203
|
+
});
|
|
204
|
+
const trackerPath = join(cwd, ".omx/state/subagent-tracking.json");
|
|
205
|
+
const before = await readFile(trackerPath, "utf8");
|
|
206
|
+
const result = withCrossProcessFileLockSync(trackerPath, () => runNativeHookCli());
|
|
207
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
208
|
+
assert.deepEqual(JSON.parse(result.stdout), {});
|
|
209
|
+
assert.equal(await readFile(trackerPath, "utf8"), before);
|
|
210
|
+
assert.equal(existsSync(eventsPath), false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects unbound identities before opted-in SubagentStop lifecycle dispatch", async () => {
|
|
214
|
+
await start();
|
|
215
|
+
const eventsPath = await captureLifecycle({ includeChildAgents: true });
|
|
216
|
+
for (const overrides of [{ session_id: "foreign-parent" }, { agent_id: parentId }, { agent_id: null }]) {
|
|
217
|
+
await dispatchCodexNativeHook(stop(overrides));
|
|
218
|
+
}
|
|
219
|
+
assert.equal(existsSync(eventsPath), false);
|
|
220
|
+
assert.equal(existsSync(join(cwd, ".omx/state/subagent-tracking.json")), false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("keeps setup, bounded input parsing, dispatch, plugin, and packed smoke event rosters aligned", async () => {
|
|
224
|
+
assert.deepEqual(CODEX_HOOK_EVENT_NAMES, MANAGED_HOOK_EVENTS);
|
|
225
|
+
assert.deepEqual(MANAGED_CODEX_HOOK_EVENTS, MANAGED_HOOK_EVENTS);
|
|
226
|
+
const plugin = JSON.parse(await readFile(join(process.cwd(), "plugins/oh-my-codex/hooks/hooks.json"), "utf8"));
|
|
227
|
+
assert.deepEqual(Object.keys(plugin.hooks), [...MANAGED_HOOK_EVENTS]);
|
|
228
|
+
const config = buildManagedCodexHooksConfig("/repo");
|
|
229
|
+
assert.deepEqual(Object.keys(config.hooks), [...MANAGED_HOOK_EVENTS]);
|
|
230
|
+
for (const event of MANAGED_HOOK_EVENTS) {
|
|
231
|
+
assert.equal(extractRawCodexHookEventName(JSON.stringify({ hook_event_name: event })), event);
|
|
232
|
+
assert.ok(mapCodexHookEventToOmxEvent(event));
|
|
233
|
+
assert.match(config.hooks[event][0]!.hooks[0]!.command, /codex-native-hook\.js/);
|
|
234
|
+
}
|
|
235
|
+
assert.equal(mapCodexHookEventToOmxEvent("SubagentStop"), "subagent-stop");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("records the child, accepts nullable optional fields, and never changes the leader pointer or grants authority", async () => {
|
|
239
|
+
await start();
|
|
240
|
+
const pointer = join(cwd, ".omx/state/session.json");
|
|
241
|
+
const before = await readFile(pointer, "utf8");
|
|
242
|
+
const modePath = join(cwd, ".omx/state/autopilot-state.json");
|
|
243
|
+
const activeMode = JSON.stringify({ active: true, current_phase: "implementation", session_id: sessionId });
|
|
244
|
+
await writeFile(modePath, activeMode);
|
|
245
|
+
const result = await dispatchCodexNativeHook(stop({ future_field: { ignored: true } }));
|
|
246
|
+
assert.equal(result.hookEventName, "SubagentStop");
|
|
247
|
+
assert.equal(result.omxEventName, "subagent-stop");
|
|
248
|
+
assert.equal(result.outputJson, null);
|
|
249
|
+
assert.equal(await readFile(pointer, "utf8"), before);
|
|
250
|
+
assert.equal(await readFile(modePath, "utf8"), activeMode);
|
|
251
|
+
const state = await readSubagentTrackingState(cwd);
|
|
252
|
+
const thread = state.sessions[sessionId]!.threads[agentId]!;
|
|
253
|
+
assert.equal(thread.kind, "subagent");
|
|
254
|
+
assert.equal(thread.mode, "executor");
|
|
255
|
+
assert.equal(thread.completion_source, "native_stop_event");
|
|
256
|
+
assert.equal(thread.last_completed_turn_id, "child-turn-1");
|
|
257
|
+
assert.ok(thread.completed_at);
|
|
258
|
+
assert.equal(thread.direct_child_root_id, undefined);
|
|
259
|
+
assert.equal(state.sessions[sessionId]!.threads[parentId], undefined);
|
|
260
|
+
assert.equal(state.sessions[parentId], undefined);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("completes the child in every existing correlated tracker partition", async () => {
|
|
264
|
+
await start();
|
|
265
|
+
recordNativeSubagentAuthorityObservation(cwd, {
|
|
266
|
+
sessionIds: [sessionId, parentId, "previous-canonical-session"],
|
|
267
|
+
childThreadId: agentId,
|
|
268
|
+
parentThreadId: parentId,
|
|
269
|
+
rootNativeSessionId: parentId,
|
|
270
|
+
authorityEvidence: "valid",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await dispatchCodexNativeHook(stop());
|
|
274
|
+
|
|
275
|
+
const state = await readSubagentTrackingState(cwd);
|
|
276
|
+
for (const partitionId of [sessionId, parentId, "previous-canonical-session"]) {
|
|
277
|
+
const thread = state.sessions[partitionId]?.threads[agentId];
|
|
278
|
+
assert.equal(thread?.kind, "subagent");
|
|
279
|
+
assert.equal(thread?.completion_source, "native_stop_event");
|
|
280
|
+
assert.equal(thread?.last_completed_turn_id, "child-turn-1");
|
|
281
|
+
assert.ok(thread?.completed_at);
|
|
282
|
+
assert.equal(thread?.status, "closed");
|
|
283
|
+
assert.equal(thread?.direct_child_root_id, parentId);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("deduplicates replays and legacy Stop for the same child turn, but records a new turn", async () => {
|
|
288
|
+
await start();
|
|
289
|
+
recordNativeSubagentAuthorityObservation(cwd, {
|
|
290
|
+
sessionIds: [sessionId], childThreadId: agentId, parentThreadId: parentId,
|
|
291
|
+
rootNativeSessionId: parentId, authorityEvidence: "valid",
|
|
292
|
+
});
|
|
293
|
+
const legacy = { hook_event_name: "Stop", cwd, session_id: sessionId, thread_id: agentId,
|
|
294
|
+
turn_id: "child-turn-1", stop_hook_active: true };
|
|
295
|
+
await dispatchCodexNativeHook(legacy);
|
|
296
|
+
const first = (await readSubagentTrackingState(cwd)).sessions[sessionId]!.threads[agentId]!;
|
|
297
|
+
await Promise.all([dispatchCodexNativeHook(stop()), dispatchCodexNativeHook(stop())]);
|
|
298
|
+
await dispatchCodexNativeHook(legacy);
|
|
299
|
+
const replay = (await readSubagentTrackingState(cwd)).sessions[sessionId]!.threads[agentId]!;
|
|
300
|
+
assert.deepEqual(replay, first);
|
|
301
|
+
await dispatchCodexNativeHook(stop({ turn_id: "child-turn-2" }));
|
|
302
|
+
const next = (await readSubagentTrackingState(cwd)).sessions[sessionId]!.threads[agentId]!;
|
|
303
|
+
assert.equal(next.turn_count, first.turn_count + 1);
|
|
304
|
+
assert.equal(next.last_completed_turn_id, "child-turn-2");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("ignores absent, malformed, conflicting, foreign, or leader identities without creating tracking state", async () => {
|
|
308
|
+
await start();
|
|
309
|
+
for (const overrides of [
|
|
310
|
+
{ agent_id: null }, { agent_id: "" }, { agent_id: {} }, { agent_id: "../escape" },
|
|
311
|
+
{ agent_id: parentId }, { agent_id: sessionId }, { session_id: null },
|
|
312
|
+
{ session_id: "" }, { session_id: {} }, { session_id: "other-session" },
|
|
313
|
+
{ sessionId: "conflicting-session" },
|
|
314
|
+
]) {
|
|
315
|
+
const result = await dispatchCodexNativeHook(stop(overrides));
|
|
316
|
+
assert.equal(result.outputJson, null);
|
|
317
|
+
assert.equal(existsSync(join(cwd, ".omx/state/subagent-tracking.json")), false, JSON.stringify(overrides));
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does not promote a legacy child Stop without thread_id into its own leader session", async () => {
|
|
322
|
+
await start();
|
|
323
|
+
recordNativeSubagentAuthorityObservation(cwd, {
|
|
324
|
+
sessionIds: [sessionId], childThreadId: agentId, parentThreadId: parentId,
|
|
325
|
+
rootNativeSessionId: parentId, authorityEvidence: "valid",
|
|
326
|
+
});
|
|
327
|
+
const before = await readSubagentTrackingState(cwd);
|
|
328
|
+
await dispatchCodexNativeHook({
|
|
329
|
+
hook_event_name: "Stop", cwd, session_id: agentId,
|
|
330
|
+
turn_id: "child-turn-1", stop_hook_active: true,
|
|
331
|
+
});
|
|
332
|
+
assert.deepEqual(await readSubagentTrackingState(cwd), before);
|
|
333
|
+
await dispatchCodexNativeHook(stop());
|
|
334
|
+
const state = await readSubagentTrackingState(cwd);
|
|
335
|
+
assert.equal(state.sessions[agentId], undefined);
|
|
336
|
+
assert.ok(state.sessions[sessionId]!.threads[agentId]!.completed_at);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("does not bootstrap a root session from a standalone SubagentStop", async () => {
|
|
340
|
+
assert.equal((await dispatchCodexNativeHook(stop())).outputJson, null);
|
|
341
|
+
assert.equal(existsSync(join(cwd, ".omx")), false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("does not apply leader Stop behavior to malformed SubagentStop input", async () => {
|
|
345
|
+
await start();
|
|
346
|
+
const result = runNativeHookCli('{"hook_event_name":"SubagentStop",');
|
|
347
|
+
|
|
348
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
349
|
+
assert.deepEqual(JSON.parse(result.stdout), {});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("does not block an oversized SubagentStop completion", async () => {
|
|
353
|
+
await start();
|
|
354
|
+
const result = runNativeHookCli(JSON.stringify({
|
|
355
|
+
...stop(),
|
|
356
|
+
oversized_field: "x".repeat(MAX_NATIVE_STDIN_JSON_BYTES),
|
|
357
|
+
}));
|
|
358
|
+
|
|
359
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
360
|
+
assert.deepEqual(JSON.parse(result.stdout), {});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -519,7 +519,7 @@ test('packed lifecycle requires enabled command handlers and integer hook metada
|
|
|
519
519
|
});
|
|
520
520
|
|
|
521
521
|
|
|
522
|
-
test('packed lifecycle requires exactly
|
|
522
|
+
test('packed lifecycle requires exactly eight generated OMX trust keys and an isolated approval target', () => {
|
|
523
523
|
const hooks = MANAGED_CODEX_HOOK_EVENTS.map((event, index) => ({
|
|
524
524
|
event,
|
|
525
525
|
command: 'node /tmp/omx/dist/scripts/codex-native-hook.js',
|
|
@@ -536,11 +536,11 @@ test('packed lifecycle requires exactly seven generated OMX trust keys and an is
|
|
|
536
536
|
...trust,
|
|
537
537
|
'/tmp/project/.codex/hooks.json:foreign:0:0': 'sha256:foreign',
|
|
538
538
|
}, hooks),
|
|
539
|
-
/exactly the
|
|
539
|
+
/exactly the 8 current OMX hooks with no stale keys/,
|
|
540
540
|
);
|
|
541
541
|
assert.throws(
|
|
542
542
|
() => assertGeneratedTrustMatchesCodex(Object.fromEntries(Object.entries(trust).slice(0, -1)), hooks),
|
|
543
|
-
/exactly the
|
|
543
|
+
/exactly the 8 current OMX hooks with no stale keys/,
|
|
544
544
|
);
|
|
545
545
|
assert.doesNotThrow(() => assertCodexBatchWriteResult({
|
|
546
546
|
filePath: '/tmp/isolated-codex-home/config.toml',
|
|
@@ -591,7 +591,7 @@ test('packed lifecycle fails closed on malformed project hooks.state entries and
|
|
|
591
591
|
const staleRawTrust = generatedHookTrustState(`${validConfig}\nforeign = { trusted_hash = "sha256:foreign" }`);
|
|
592
592
|
assert.throws(
|
|
593
593
|
() => assertGeneratedTrustMatchesCodex(staleRawTrust, hooks),
|
|
594
|
-
/exactly the
|
|
594
|
+
/exactly the 8 current OMX hooks with no stale keys/,
|
|
595
595
|
);
|
|
596
596
|
|
|
597
597
|
const protoRawTrust = generatedHookTrustState(
|
|
@@ -600,7 +600,7 @@ test('packed lifecycle fails closed on malformed project hooks.state entries and
|
|
|
600
600
|
assert.equal(Object.hasOwn(protoRawTrust, '__proto__'), true);
|
|
601
601
|
assert.throws(
|
|
602
602
|
() => assertGeneratedTrustMatchesCodex(protoRawTrust, hooks),
|
|
603
|
-
/exactly the
|
|
603
|
+
/exactly the 8 current OMX hooks with no stale keys/,
|
|
604
604
|
);
|
|
605
605
|
});
|
|
606
606
|
test('packed lifecycle treats only the production command grammar as managed ownership', () => {
|
|
@@ -1078,6 +1078,7 @@ test('packed install smoke covers every installed native hook event with minimal
|
|
|
1078
1078
|
'UserPromptSubmit',
|
|
1079
1079
|
'PreCompact',
|
|
1080
1080
|
'PostCompact',
|
|
1081
|
+
'SubagentStop',
|
|
1081
1082
|
'Stop',
|
|
1082
1083
|
]);
|
|
1083
1084
|
|
|
@@ -1320,7 +1321,7 @@ test('packed install native hook stdout validation allows empty or JSON output o
|
|
|
1320
1321
|
/native hook UserPromptSubmit emitted invalid JSON stdout/,
|
|
1321
1322
|
);
|
|
1322
1323
|
});
|
|
1323
|
-
test('packed lifecycle and native hook smoke share the
|
|
1324
|
+
test('packed lifecycle and native hook smoke share the managed event names', () => {
|
|
1324
1325
|
assert.deepEqual(MANAGED_CODEX_HOOK_EVENTS, PACKED_INSTALL_NATIVE_HOOK_SMOKE_EVENTS);
|
|
1325
1326
|
});
|
|
1326
1327
|
|