@dashclaw/cli 0.2.0 → 0.3.2

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.
@@ -0,0 +1,405 @@
1
+ // cli/lib/codex/install.js
2
+ //
3
+ // `dashclaw install codex` — provisions DashClaw governance into Codex CLI.
4
+ //
5
+ // What it does, idempotently:
6
+ // 1. Copies the Python governance hooks (pretool, posttool, stop) and the
7
+ // vendored `dashclaw_agent_intel` module into ~/.codex/hooks/dashclaw/.
8
+ // 2. Merges a managed block into ~/.codex/config.toml that registers:
9
+ // - the DashClaw MCP server (stdio)
10
+ // - PreToolUse / PostToolUse / Stop hooks pointing at the copied scripts
11
+ // - approval_policy = "on-request" so Codex surfaces require_approval
12
+ // decisions from DashClaw guard
13
+ // 3. Drops a managed block into <project>/AGENTS.md (or creates the file)
14
+ // that teaches the Codex agent the DashClaw governance protocol.
15
+ //
16
+ // Idempotency is implemented with sentinel markers:
17
+ //
18
+ // # >>> dashclaw start — managed block, do not edit by hand
19
+ // ...
20
+ // # <<< dashclaw end
21
+ //
22
+ // Re-running replaces only the block between the markers. Anything else in
23
+ // the file is left untouched.
24
+ //
25
+ // Safety: every file we mutate gets a `.dashclaw-bak` sibling on first write
26
+ // so the user always has an escape hatch.
27
+
28
+ import {
29
+ existsSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ mkdirSync,
33
+ copyFileSync,
34
+ readdirSync,
35
+ statSync,
36
+ } from 'node:fs';
37
+ import { dirname, join, resolve } from 'node:path';
38
+ import { homedir } from 'node:os';
39
+
40
+ // -----------------------------------------------------------------------------
41
+ // Constants
42
+ // -----------------------------------------------------------------------------
43
+
44
+ export const CODEX_HOME_DIRNAME = '.codex';
45
+ export const DASHCLAW_HOOKS_SUBDIR = 'hooks/dashclaw';
46
+
47
+ export const MANAGED_START = '# >>> dashclaw start — managed block, do not edit by hand';
48
+ export const MANAGED_END = '# <<< dashclaw end';
49
+
50
+ export const AGENTS_MANAGED_START =
51
+ '<!-- >>> dashclaw start — managed block, do not edit by hand -->';
52
+ export const AGENTS_MANAGED_END = '<!-- <<< dashclaw end -->';
53
+
54
+ // Hook filenames we ship from `hooks/`. The agent_intel module is a directory
55
+ // and is handled separately.
56
+ const HOOK_FILES = [
57
+ 'dashclaw_pretool.py',
58
+ 'dashclaw_posttool.py',
59
+ 'dashclaw_stop.py',
60
+ ];
61
+ const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
62
+
63
+ // -----------------------------------------------------------------------------
64
+ // Path resolution
65
+ // -----------------------------------------------------------------------------
66
+
67
+ export function codexHome(env = process.env) {
68
+ if (env.CODEX_HOME) return resolve(env.CODEX_HOME);
69
+ return resolve(homedir(), CODEX_HOME_DIRNAME);
70
+ }
71
+
72
+ export function codexConfigPath(env = process.env) {
73
+ return join(codexHome(env), 'config.toml');
74
+ }
75
+
76
+ export function codexHooksDir(env = process.env) {
77
+ return join(codexHome(env), DASHCLAW_HOOKS_SUBDIR);
78
+ }
79
+
80
+ // -----------------------------------------------------------------------------
81
+ // Hook copy
82
+ // -----------------------------------------------------------------------------
83
+
84
+ function copyDirRecursive(src, dst) {
85
+ mkdirSync(dst, { recursive: true });
86
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
87
+ const sp = join(src, entry.name);
88
+ const dp = join(dst, entry.name);
89
+ if (entry.isDirectory()) {
90
+ if (entry.name === '__pycache__') continue;
91
+ copyDirRecursive(sp, dp);
92
+ } else if (entry.isFile()) {
93
+ copyFileSync(sp, dp);
94
+ }
95
+ }
96
+ }
97
+
98
+ export function copyHooks({ hooksSrc, hooksDst }) {
99
+ if (!existsSync(hooksSrc)) {
100
+ throw new Error(`Hook source directory not found: ${hooksSrc}`);
101
+ }
102
+ mkdirSync(hooksDst, { recursive: true });
103
+
104
+ for (const name of HOOK_FILES) {
105
+ const sp = join(hooksSrc, name);
106
+ if (!existsSync(sp)) {
107
+ throw new Error(`Required hook script missing: ${sp}`);
108
+ }
109
+ copyFileSync(sp, join(hooksDst, name));
110
+ }
111
+
112
+ const intelSrc = join(hooksSrc, HOOK_INTEL_DIR);
113
+ if (existsSync(intelSrc) && statSync(intelSrc).isDirectory()) {
114
+ copyDirRecursive(intelSrc, join(hooksDst, HOOK_INTEL_DIR));
115
+ } else {
116
+ throw new Error(`Required intel module missing: ${intelSrc}`);
117
+ }
118
+
119
+ return { hooksDst, files: HOOK_FILES, intelDir: HOOK_INTEL_DIR };
120
+ }
121
+
122
+ // -----------------------------------------------------------------------------
123
+ // TOML managed-block merge
124
+ // -----------------------------------------------------------------------------
125
+
126
+ // We do NOT parse TOML. Instead we maintain a single contiguous block
127
+ // delimited by MANAGED_START / MANAGED_END comment markers and rewrite that
128
+ // block on every install. This avoids depending on a third-party TOML
129
+ // library and keeps user-authored content outside the block 100% intact.
130
+
131
+ export function replaceManagedBlock(
132
+ source,
133
+ newBlock,
134
+ { startMarker = MANAGED_START, endMarker = MANAGED_END } = {},
135
+ ) {
136
+ const start = source.indexOf(startMarker);
137
+ const end = source.indexOf(endMarker);
138
+
139
+ if (start !== -1 && end !== -1 && end > start) {
140
+ const before = source.slice(0, start);
141
+ const after = source.slice(end + endMarker.length);
142
+ return ensureTrailingNewline(
143
+ stripTrailingBlankLines(before) +
144
+ (before ? '\n\n' : '') +
145
+ newBlock +
146
+ (after.startsWith('\n') ? '' : '\n') +
147
+ after,
148
+ );
149
+ }
150
+
151
+ // No existing block — append.
152
+ const sep = source.length === 0 || source.endsWith('\n') ? '\n' : '\n\n';
153
+ return ensureTrailingNewline(
154
+ source + (source.length === 0 ? '' : sep) + newBlock,
155
+ );
156
+ }
157
+
158
+ function stripTrailingBlankLines(s) {
159
+ return s.replace(/\n+$/, '');
160
+ }
161
+
162
+ function ensureTrailingNewline(s) {
163
+ return s.endsWith('\n') ? s : s + '\n';
164
+ }
165
+
166
+ // -----------------------------------------------------------------------------
167
+ // Block builders
168
+ // -----------------------------------------------------------------------------
169
+
170
+ // Quote a path for inclusion in a TOML basic string. TOML basic strings
171
+ // require `\` and `"` to be escaped.
172
+ function tomlString(value) {
173
+ return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
174
+ }
175
+
176
+ export function buildConfigTomlBlock({
177
+ mcpServerPath,
178
+ hooksDir,
179
+ approvalPolicy = 'on-request',
180
+ includeNotify = false,
181
+ dashclawCliPath = null,
182
+ }) {
183
+ const py = pythonCommand();
184
+ const pre = join(hooksDir, 'dashclaw_pretool.py');
185
+ const post = join(hooksDir, 'dashclaw_posttool.py');
186
+ const stop = join(hooksDir, 'dashclaw_stop.py');
187
+
188
+ const lines = [
189
+ MANAGED_START,
190
+ '#',
191
+ '# Re-run `dashclaw install codex` to refresh this block. Edits made',
192
+ '# between these markers will be overwritten on next install.',
193
+ '',
194
+ `approval_policy = ${tomlString(approvalPolicy)}`,
195
+ ];
196
+
197
+ if (includeNotify) {
198
+ if (!dashclawCliPath) {
199
+ throw new Error('dashclawCliPath is required when includeNotify=true');
200
+ }
201
+ // Codex's notify config takes a list of strings: argv prefix. Codex
202
+ // appends the JSON payload as the final argument when it fires.
203
+ lines.push(
204
+ `notify = ["node", ${tomlString(dashclawCliPath)}, "codex", "notify"]`,
205
+ );
206
+ }
207
+
208
+ lines.push(
209
+ '',
210
+ '[mcp_servers.dashclaw]',
211
+ `command = ${tomlString(py)}`,
212
+ `args = [${tomlString(mcpServerPath)}, "--agent-id", "codex"]`,
213
+ '',
214
+ '[[hooks.PreToolUse]]',
215
+ 'matcher = "Bash|Edit|Write|MultiEdit"',
216
+ '[[hooks.PreToolUse.hooks]]',
217
+ 'type = "command"',
218
+ `command = ${tomlString(`${py} ${pre}`)}`,
219
+ 'timeoutSec = 3600',
220
+ '',
221
+ '[[hooks.PostToolUse]]',
222
+ 'matcher = "Bash|Edit|Write|MultiEdit"',
223
+ '[[hooks.PostToolUse.hooks]]',
224
+ 'type = "command"',
225
+ `command = ${tomlString(`${py} ${post}`)}`,
226
+ '',
227
+ '[[hooks.Stop]]',
228
+ '[[hooks.Stop.hooks]]',
229
+ 'type = "command"',
230
+ `command = ${tomlString(`${py} ${stop}`)}`,
231
+ MANAGED_END,
232
+ );
233
+
234
+ return lines.join('\n');
235
+ }
236
+
237
+ function pythonCommand() {
238
+ // Use `python` on Windows, `python3` elsewhere. The hook scripts work with
239
+ // either; we just pick the canonical name for the OS so the spawn succeeds
240
+ // without PATH gymnastics. Users can override by editing config.toml after
241
+ // install (the managed block warns about overwrites — but the python
242
+ // command is the only path-dependent piece).
243
+ return process.platform === 'win32' ? 'python' : 'python3';
244
+ }
245
+
246
+ // -----------------------------------------------------------------------------
247
+ // AGENTS.md template
248
+ // -----------------------------------------------------------------------------
249
+
250
+ export function buildAgentsMdBlock({ baseUrl } = {}) {
251
+ const lines = [
252
+ AGENTS_MANAGED_START,
253
+ '',
254
+ '## DashClaw Governance Protocol',
255
+ '',
256
+ 'You are governed by DashClaw. Before any non-trivial action, follow this',
257
+ 'protocol so a human reviewer (and the audit log) can trust your work.',
258
+ '',
259
+ '### Session start',
260
+ '',
261
+ '1. Call `dashclaw_session_start` via the `dashclaw` MCP server with your',
262
+ ' agent id (`codex`) and a one-sentence workspace description. This',
263
+ ' groups all your actions for tracking in Mission Control.',
264
+ '2. Read the `dashclaw://policies` and `dashclaw://capabilities` MCP',
265
+ ' resources to learn what rules govern you and what capabilities are',
266
+ ' registered. Treat unknown action types as high-risk by default.',
267
+ '',
268
+ '### Before each risky action',
269
+ '',
270
+ "Call `dashclaw_guard` with the action you intend to take. You will get",
271
+ 'back one of four decisions:',
272
+ '',
273
+ '- `allow` — proceed; call `dashclaw_record` afterward with the outcome.',
274
+ '- `warn` — proceed with caution; include the warning context in your',
275
+ ' `dashclaw_record` call.',
276
+ '- `block` — stop. Report the block reason to the user and do not attempt',
277
+ ' the action through another path.',
278
+ "- `require_approval` — call `dashclaw_wait_for_approval` and wait. Don't",
279
+ ' poll faster than the tool already does.',
280
+ '',
281
+ 'Risky actions include: shell commands that write or delete, file edits',
282
+ 'outside the project root, network requests, package installs, deploys,',
283
+ 'and any external API call you have not used in this session before.',
284
+ '',
285
+ "The PreToolUse hook installed by `dashclaw install codex` will guard",
286
+ 'Bash, Edit, Write, and MultiEdit automatically. The guidance above is',
287
+ 'still required for tool calls that fall outside that matcher (MCP tool',
288
+ "invocations, agent-internal capabilities) so DashClaw's audit trail",
289
+ 'covers them too.',
290
+ '',
291
+ '### After each action',
292
+ '',
293
+ 'Call `dashclaw_record` with the action id (from `dashclaw_guard` or from',
294
+ 'a PostToolUse-emitted breadcrumb) and the outcome (`success`,',
295
+ '`failure`, or `partial`). This is what makes the decision replayable.',
296
+ '',
297
+ baseUrl ? `### This instance\n\nDashClaw: ${baseUrl}` : '',
298
+ '',
299
+ AGENTS_MANAGED_END,
300
+ ];
301
+ return lines.filter((l) => l !== null).join('\n');
302
+ }
303
+
304
+ // -----------------------------------------------------------------------------
305
+ // File mutation helpers
306
+ // -----------------------------------------------------------------------------
307
+
308
+ function backupOnce(path) {
309
+ if (!existsSync(path)) return null;
310
+ const bak = path + '.dashclaw-bak';
311
+ if (existsSync(bak)) return bak;
312
+ copyFileSync(path, bak);
313
+ return bak;
314
+ }
315
+
316
+ export function mergeConfigToml({
317
+ configPath,
318
+ mcpServerPath,
319
+ hooksDir,
320
+ approvalPolicy,
321
+ includeNotify = false,
322
+ dashclawCliPath = null,
323
+ }) {
324
+ const before = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
325
+ const block = buildConfigTomlBlock({
326
+ mcpServerPath,
327
+ hooksDir,
328
+ approvalPolicy,
329
+ includeNotify,
330
+ dashclawCliPath,
331
+ });
332
+ const after = replaceManagedBlock(before, block);
333
+ mkdirSync(dirname(configPath), { recursive: true });
334
+ const backup = backupOnce(configPath);
335
+ writeFileSync(configPath, after);
336
+ return { changed: before !== after, backup };
337
+ }
338
+
339
+ export function mergeAgentsMd({ agentsMdPath, baseUrl }) {
340
+ const before = existsSync(agentsMdPath) ? readFileSync(agentsMdPath, 'utf8') : '';
341
+ const block = buildAgentsMdBlock({ baseUrl });
342
+ const after = replaceManagedBlock(before, block, {
343
+ startMarker: AGENTS_MANAGED_START,
344
+ endMarker: AGENTS_MANAGED_END,
345
+ });
346
+ mkdirSync(dirname(agentsMdPath), { recursive: true });
347
+ const backup = backupOnce(agentsMdPath);
348
+ writeFileSync(agentsMdPath, after);
349
+ return { changed: before !== after, backup };
350
+ }
351
+
352
+ // -----------------------------------------------------------------------------
353
+ // Top-level install
354
+ // -----------------------------------------------------------------------------
355
+
356
+ export async function installCodex({
357
+ repoRoot,
358
+ projectDir = process.cwd(),
359
+ baseUrl,
360
+ approvalPolicy = 'on-request',
361
+ includeNotify = false,
362
+ env = process.env,
363
+ logger = console,
364
+ }) {
365
+ if (!repoRoot) {
366
+ throw new Error('repoRoot is required (path to the DashClaw checkout)');
367
+ }
368
+
369
+ const hooksSrc = join(repoRoot, 'hooks');
370
+ const mcpServerPath = join(repoRoot, 'mcp-server', 'bin', 'dashclaw-mcp.js');
371
+ const dashclawCliPath = join(repoRoot, 'cli', 'bin', 'dashclaw.js');
372
+
373
+ if (!existsSync(mcpServerPath)) {
374
+ throw new Error(`MCP server entrypoint missing: ${mcpServerPath}`);
375
+ }
376
+ if (includeNotify && !existsSync(dashclawCliPath)) {
377
+ throw new Error(`dashclaw CLI not found at ${dashclawCliPath} — can't wire notify`);
378
+ }
379
+
380
+ const hooksDst = codexHooksDir(env);
381
+ const configPath = codexConfigPath(env);
382
+ const agentsMdPath = join(projectDir, 'AGENTS.md');
383
+
384
+ logger.info(`Installing DashClaw hooks → ${hooksDst}`);
385
+ const hookResult = copyHooks({ hooksSrc, hooksDst });
386
+
387
+ logger.info(`Merging Codex config → ${configPath}`);
388
+ const configResult = mergeConfigToml({
389
+ configPath,
390
+ mcpServerPath,
391
+ hooksDir: hooksDst,
392
+ approvalPolicy,
393
+ includeNotify,
394
+ dashclawCliPath: includeNotify ? dashclawCliPath : null,
395
+ });
396
+
397
+ logger.info(`Merging governance protocol → ${agentsMdPath}`);
398
+ const agentsResult = mergeAgentsMd({ agentsMdPath, baseUrl });
399
+
400
+ return {
401
+ hooks: hookResult,
402
+ config: { path: configPath, ...configResult },
403
+ agentsMd: { path: agentsMdPath, ...agentsResult },
404
+ };
405
+ }
@@ -0,0 +1,203 @@
1
+ // cli/lib/codex/notify.js
2
+ //
3
+ // `dashclaw codex notify` — Codex legacy `notify` config target.
4
+ //
5
+ // Codex CLI supports a `notify` config that runs an external command after
6
+ // each agent turn completes. Codex appends a JSON payload as the FINAL argv
7
+ // argument:
8
+ //
9
+ // notify = ["node", "/path/to/dashclaw.js", "codex", "notify"]
10
+ //
11
+ // then on each turn-complete, Codex spawns:
12
+ //
13
+ // node /path/to/dashclaw.js codex notify '{"type":"agent-turn-complete", ...}'
14
+ //
15
+ // This module:
16
+ // 1. Extracts the JSON payload from the last argv arg.
17
+ // 2. Validates it's an `agent-turn-complete` event.
18
+ // 3. POSTs a record to /api/actions/by-tool-use-id (the same endpoint the
19
+ // PostToolUse Python hook uses for its terminal patch) with an action
20
+ // shape that represents the turn as a single observable unit.
21
+ //
22
+ // Failure mode: Codex spawns notify fire-and-forget with stdio nulled. We
23
+ // MUST exit 0 on any failure so we don't surface error output to the user.
24
+ // All errors are logged to stderr and swallowed by the spawn.
25
+
26
+ import { request } from 'node:http';
27
+ import { request as requestHttps } from 'node:https';
28
+
29
+ const TURN_COMPLETE_TYPE = 'agent-turn-complete';
30
+
31
+ /**
32
+ * Parse the Codex notify payload from argv. Codex appends the JSON as the
33
+ * final argv slot. We accept either:
34
+ * - argv last arg is a JSON string → parse it
35
+ * - argv last arg is empty + stdin has JSON → read stdin
36
+ *
37
+ * Returns the parsed object, or null if no valid payload was found.
38
+ */
39
+ export function parseNotifyPayload(argv) {
40
+ if (!Array.isArray(argv) || argv.length === 0) return null;
41
+ const last = argv[argv.length - 1];
42
+ if (typeof last !== 'string' || last.length === 0) return null;
43
+ if (last[0] !== '{') return null;
44
+ try {
45
+ return JSON.parse(last);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Map a Codex turn-complete payload to a DashClaw action_record shape.
53
+ * We declare the turn as a single coarse action so it shows up in the
54
+ * decision ledger. Token/cost data is not in the notify payload — it will
55
+ * be back-filled by the Phase 3 JSONL ingest path.
56
+ */
57
+ export function buildActionFromNotify(payload, { agentId = 'codex' } = {}) {
58
+ const lastMessage = payload.last_assistant_message || '';
59
+ const summary = lastMessage.length > 200
60
+ ? lastMessage.slice(0, 197) + '...'
61
+ : lastMessage;
62
+
63
+ return {
64
+ agent_id: agentId,
65
+ action_type: 'agent_turn',
66
+ declared_goal: `Codex turn ${payload.turn_id || '?'} (${payload.thread_id || 'thread'})`,
67
+ outcome: 'success',
68
+ metadata: {
69
+ source: 'codex-notify',
70
+ thread_id: payload.thread_id,
71
+ turn_id: payload.turn_id,
72
+ cwd: payload.cwd,
73
+ client: payload.client || 'codex',
74
+ input_message_count: Array.isArray(payload.input_messages)
75
+ ? payload.input_messages.length
76
+ : 0,
77
+ last_assistant_summary: summary,
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Send an action record to DashClaw. Returns:
84
+ * - { status: 'sent', actionId } on 2xx
85
+ * - { status: 'skipped', reason } on local-validation skip
86
+ * - { status: 'error', reason } on network/4xx/5xx
87
+ *
88
+ * Never throws. All errors are returned in the result object so the caller
89
+ * can decide whether to swallow them.
90
+ */
91
+ export async function postNotifyAction({ baseUrl, apiKey, action, timeoutMs = 5000 }) {
92
+ if (!baseUrl || !apiKey) {
93
+ return { status: 'skipped', reason: 'missing_config' };
94
+ }
95
+
96
+ let url;
97
+ try {
98
+ url = new URL('/api/actions', baseUrl);
99
+ } catch (err) {
100
+ return { status: 'error', reason: 'invalid_base_url', detail: err.message };
101
+ }
102
+
103
+ const body = JSON.stringify(action);
104
+ const headers = {
105
+ 'content-type': 'application/json',
106
+ 'content-length': Buffer.byteLength(body).toString(),
107
+ 'x-api-key': apiKey,
108
+ };
109
+
110
+ return new Promise((resolve) => {
111
+ const lib = url.protocol === 'https:' ? requestHttps : request;
112
+ const req = lib(
113
+ {
114
+ method: 'POST',
115
+ host: url.hostname,
116
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
117
+ path: url.pathname + url.search,
118
+ headers,
119
+ },
120
+ (res) => {
121
+ let chunks = '';
122
+ res.setEncoding('utf8');
123
+ res.on('data', (c) => { chunks += c; });
124
+ res.on('end', () => {
125
+ if (res.statusCode >= 200 && res.statusCode < 300) {
126
+ let parsed = null;
127
+ try { parsed = JSON.parse(chunks); } catch { /* keep null */ }
128
+ resolve({
129
+ status: 'sent',
130
+ actionId: parsed?.action_id || parsed?.id || null,
131
+ });
132
+ } else {
133
+ resolve({
134
+ status: 'error',
135
+ reason: `http_${res.statusCode}`,
136
+ detail: chunks.slice(0, 200),
137
+ });
138
+ }
139
+ });
140
+ },
141
+ );
142
+
143
+ req.on('error', (err) => {
144
+ resolve({ status: 'error', reason: 'network', detail: err.message });
145
+ });
146
+
147
+ req.setTimeout(timeoutMs, () => {
148
+ req.destroy();
149
+ resolve({ status: 'error', reason: 'timeout' });
150
+ });
151
+
152
+ req.write(body);
153
+ req.end();
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Run the notify command. This is the CLI entrypoint.
159
+ *
160
+ * IMPORTANT: must always exit 0 (the caller calls process.exit(0)).
161
+ * Codex spawns notify with stdio nulled and treats any failure as silent.
162
+ * We log to stderr (which Codex discards by design) for diagnostics.
163
+ *
164
+ * Options:
165
+ * argv — argv array (defaults to process.argv.slice(2))
166
+ * baseUrl — DashClaw instance URL
167
+ * apiKey — DashClaw API key
168
+ * agentId — agent identity (default: codex)
169
+ * logger — { warn, info } for diagnostic output
170
+ * skipPost — if true, do everything except the HTTP call (testing)
171
+ */
172
+ export async function runCodexNotify({
173
+ argv = process.argv.slice(2),
174
+ baseUrl,
175
+ apiKey,
176
+ agentId = 'codex',
177
+ logger = console,
178
+ skipPost = false,
179
+ } = {}) {
180
+ const payload = parseNotifyPayload(argv);
181
+ if (!payload) {
182
+ logger.warn('codex-notify: no JSON payload in argv, exiting 0');
183
+ return { status: 'skipped', reason: 'no_payload' };
184
+ }
185
+ if (payload.type !== TURN_COMPLETE_TYPE) {
186
+ logger.warn(`codex-notify: unknown payload type "${payload.type}", exiting 0`);
187
+ return { status: 'skipped', reason: 'unknown_type', type: payload.type };
188
+ }
189
+
190
+ const action = buildActionFromNotify(payload, { agentId });
191
+
192
+ if (skipPost) {
193
+ return { status: 'dry_run', action };
194
+ }
195
+
196
+ const result = await postNotifyAction({ baseUrl, apiKey, action });
197
+ if (result.status === 'sent') {
198
+ logger.info?.(`codex-notify: recorded action ${result.actionId || '(unknown id)'}`);
199
+ } else if (result.status === 'error') {
200
+ logger.warn(`codex-notify: ${result.reason}${result.detail ? ' — ' + result.detail : ''}`);
201
+ }
202
+ return result;
203
+ }