@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.
- package/README.md +179 -0
- package/bin/dashclaw.js +1143 -166
- package/lib/api.js +64 -0
- package/lib/claude/install.js +386 -0
- package/lib/code/apply.js +164 -0
- package/lib/code/codex-parser.vendored.js +360 -0
- package/lib/code/ingest-codex.js +244 -0
- package/lib/code/ingest.js +261 -0
- package/lib/code/memo.js +57 -0
- package/lib/code/vendored.js +219 -0
- package/lib/codex/install.js +405 -0
- package/lib/codex/notify.js +203 -0
- package/lib/config.js +160 -0
- package/lib/cost.js +108 -0
- package/lib/doctor.js +270 -0
- package/lib/env.js +69 -0
- package/lib/posture.js +45 -0
- package/package.json +20 -4
|
@@ -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
|
+
}
|