sprag-cli 3.65.0 → 3.67.0

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.
@@ -87,6 +87,8 @@ export async function run({ hasFlag }) {
87
87
  print('PostToolUse hook (delegation rescan)', r.delegationHook);
88
88
  print('mod (delegation meter, Claude Code 2.1.287+)', r.delegationMeterMod);
89
89
  if (r.delegationMeterMod.reason) console.log(` ${r.delegationMeterMod.reason}`);
90
+ print('mod (desktop statusline, Claude Code 2.1.287+)', r.statuslineMod);
91
+ if (r.statuslineMod.reason) console.log(` ${r.statuslineMod.reason}`);
90
92
  print('UserPromptSubmit hook (brief)', r.briefHook);
91
93
  {
92
94
  let s = r.statusline;
@@ -366,6 +368,17 @@ export async function run({ hasFlag }) {
366
368
  // they have not read. All the install does is say what is waiting.
367
369
  // Presets now live in ratchet-preset.md. Moving the ones older versions
368
370
  // appended to ratchet.md comes first, so they are not offered again below.
371
+ try {
372
+ const d = (await import('../delegation-agents.js')).syncDelegationAgents();
373
+ if (d.written.length) {
374
+ console.log(lang === 'ko'
375
+ ? ` agents: 위임 전용 에이전트 ${d.written.length}개를 ~/.claude/agents 에 설치했습니다 (MCP 도구 없음).`
376
+ : ` agents: installed ${d.written.length} delegation-only agent(s) in ~/.claude/agents (no MCP tools).`);
377
+ }
378
+ } catch (e) {
379
+ debug('install:delegation-agents', e);
380
+ }
381
+
369
382
  try {
370
383
  const { preparePresetRatchet } = await import('../preset-ratchet.js');
371
384
  const p = preparePresetRatchet();
@@ -296,11 +296,15 @@ export async function run({ args, hasFlag, numArg }) {
296
296
  // would keep reading the old wording indefinitely. The call writes only
297
297
  // when the rendering differs, so the common case is a read and a string
298
298
  // compare.
299
+ // Agent files come first so the ratchet-model.md rendered next names them.
300
+ try { (await import('../delegation-agents.js')).syncDelegationAgents(); } catch (e) { debug('route-scan:delegation-agents', e); }
299
301
  try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
300
302
  // Same reason for the preset ratchet: an upgrade that rewords or drops a
301
303
  // bundled rule reaches ratchet-preset.md here, without a reinstall. It
302
304
  // also moves presets older versions appended to ratchet.md.
303
305
  try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
306
+ // Cowork cannot follow the ~/.claude imports, so it gets a pasted copy.
307
+ try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
304
308
  // Registered rules whose delegated-category error rate crossed the
305
309
  // health threshold since promotion — the user approved these, so a
306
310
  // status change must be briefed, not just written into the md file.
@@ -466,6 +470,8 @@ export async function run({ args, hasFlag, numArg }) {
466
470
  let cache = rs.readRouteScan();
467
471
  if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
468
472
  cache = await rs.runRouteScan({ days });
473
+ // model-rules were rewritten by the scan; carry them to the Cowork copies.
474
+ try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
469
475
  }
470
476
  if (hasFlag('--quiet')) return;
471
477
  if (hasFlag('--json')) {
@@ -26,6 +26,10 @@ export async function run({ hasFlag, args = [] }) {
26
26
  return;
27
27
  }
28
28
 
29
+ // The Cowork copies and the settings.json exclude entry are ours too.
30
+ try { (await import('../cowork-sync.js')).removeCoworkRatchets(); } catch { /* uninstall goes on */ }
31
+ try { (await import('../delegation-agents.js')).removeDelegationAgents(); } catch { /* uninstall goes on */ }
32
+
29
33
  if (r.removed.length === 0) {
30
34
  console.log(lang === 'ko' ? '설치된 항목이 없습니다.' : 'Nothing was installed.');
31
35
  } else {
@@ -0,0 +1,361 @@
1
+ /**
2
+ * cowork-sync: copy the global ratchet files into the folders connected to the
3
+ * Claude desktop app's Cowork tab.
4
+ *
5
+ * Cowork does not read ~/.claude (no hooks, no `@` imports), but it does read
6
+ * `<folder>/.claude/rules/*.md`. So the three global ratchet files are pasted
7
+ * into one tool-owned rules file per connected folder. Claude Code reads the
8
+ * same rules directory, so ~/.claude/settings.json gets a `claudeMdExcludes`
9
+ * entry that keeps Claude Code from loading the copy a second time (Cowork does
10
+ * not read that settings file).
11
+ *
12
+ * Where Cowork keeps its session metadata (macOS verified):
13
+ * ~/Library/Application Support/Claude-3p/local-agent-mode-sessions/<org>/<user>/local_<id>.json
14
+ * The 1p app is assumed to use ".../Claude/local-agent-mode-sessions" (not
15
+ * verified). Windows is assumed to use %APPDATA%\Claude and %APPDATA%\Claude-3p
16
+ * (not verified). Linux is out of scope.
17
+ *
18
+ * Nothing here throws to the caller: a folder that cannot be written is
19
+ * reported in `skipped`, and the SessionStart hook ignores the whole call on
20
+ * failure.
21
+ */
22
+
23
+ import {
24
+ existsSync, readFileSync, readdirSync, statSync, realpathSync, mkdirSync, writeFileSync, rmSync,
25
+ appendFileSync,
26
+ } from 'node:fs';
27
+ import { join, resolve, sep, isAbsolute } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { execFileSync } from 'node:child_process';
30
+ import { userDataDir, claudeUserDir } from './paths.js';
31
+ import { loadConfig, saveConfig, userLanguage } from './config.js';
32
+ import { writeStateFile } from './state-file.js';
33
+ import { readSettings, writeSettings } from './installer.js';
34
+ import { modelRatchetPathFor } from './model-rules.js';
35
+ import { presetRatchetPath } from './preset-ratchet.js';
36
+ import { globalRatchetMdPath } from './harness.js';
37
+ import { debug } from './debug.js';
38
+
39
+ export const COWORK_MARKER = '<!-- sprag:cowork-ratchet -->';
40
+ export const COWORK_EXCLUDE_PATTERN = '**/.claude/rules/sprag-cowork-ratchet.md';
41
+ const COPY_REL = join('.claude', 'rules', 'sprag-cowork-ratchet.md');
42
+ const MAX_PARSE_PER_CALL = 200;
43
+
44
+ export const coworkStatePath = () => join(userDataDir(), 'cowork-sync.json');
45
+
46
+ // ---------- config ----------
47
+
48
+ export function coworkConfig() {
49
+ const c = loadConfig().cowork;
50
+ return {
51
+ sync: !(c && c.sync === false),
52
+ exclude: Array.isArray(c?.exclude) ? c.exclude.filter((x) => typeof x === 'string') : [],
53
+ };
54
+ }
55
+
56
+ export function saveCoworkConfig(patch) {
57
+ const cfg = loadConfig();
58
+ saveConfig({ ...cfg, cowork: { ...coworkConfig(), ...patch } });
59
+ }
60
+
61
+ // ---------- state ----------
62
+
63
+ export function loadCoworkState() {
64
+ try {
65
+ const s = JSON.parse(readFileSync(coworkStatePath(), 'utf8'));
66
+ if (s && typeof s === 'object' && !Array.isArray(s)) {
67
+ return {
68
+ version: 1,
69
+ scanned: s.scanned && typeof s.scanned === 'object' ? s.scanned : {},
70
+ folders: s.folders && typeof s.folders === 'object' ? s.folders : {},
71
+ written: s.written && typeof s.written === 'object' ? s.written : {},
72
+ ...(typeof s.lastSync === 'string' ? { lastSync: s.lastSync } : {}),
73
+ };
74
+ }
75
+ } catch { /* first run or unreadable: start empty */ }
76
+ return { version: 1, scanned: {}, folders: {}, written: {} };
77
+ }
78
+
79
+ export function saveCoworkState(state) {
80
+ try { writeStateFile(coworkStatePath(), JSON.stringify(state, null, 2) + '\n'); } catch (e) { debug('cowork-sync:save-state', e); }
81
+ }
82
+
83
+ // ---------- discovery ----------
84
+
85
+ export function coworkSessionRoots() {
86
+ const bases = [];
87
+ if (process.platform === 'darwin') {
88
+ const support = join(homedir(), 'Library', 'Application Support');
89
+ bases.push(join(support, 'Claude'), join(support, 'Claude-3p'));
90
+ } else if (process.platform === 'win32' && process.env.APPDATA) {
91
+ bases.push(join(process.env.APPDATA, 'Claude'), join(process.env.APPDATA, 'Claude-3p'));
92
+ }
93
+ return bases.map((b) => join(b, 'local-agent-mode-sessions')).filter((p) => existsSync(p));
94
+ }
95
+
96
+ function subdirs(dir) {
97
+ try {
98
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
99
+ } catch { return []; }
100
+ }
101
+
102
+ /** local_*.json exactly three levels under a root: <org>/<user>/local_*.json */
103
+ function sessionMetaFiles(root) {
104
+ const out = [];
105
+ for (const org of subdirs(root)) {
106
+ for (const user of subdirs(org)) {
107
+ let names;
108
+ try { names = readdirSync(user); } catch { continue; }
109
+ for (const n of names) {
110
+ if (n.startsWith('local_') && n.endsWith('.json')) out.push(join(user, n));
111
+ }
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Parse only the metadata files whose mtime changed since the last call (at
119
+ * most 200 per call, newest first; the rest wait for the next call) and add
120
+ * the folders they name to `state.folders`. Folders are never forgotten.
121
+ */
122
+ export function discoverCoworkFolders({ state = loadCoworkState() } = {}) {
123
+ const now = new Date().toISOString();
124
+ const seen = {};
125
+ const changed = [];
126
+ for (const root of coworkSessionRoots()) {
127
+ for (const file of sessionMetaFiles(root)) {
128
+ let mtime;
129
+ try { mtime = statSync(file).mtimeMs; } catch { continue; }
130
+ seen[file] = mtime;
131
+ if (state.scanned[file] !== mtime) changed.push({ file, mtime });
132
+ }
133
+ }
134
+ changed.sort((a, b) => b.mtime - a.mtime);
135
+ const nextScanned = {};
136
+ // Unchanged files keep their mark; changed ones are marked only once handled,
137
+ // so the ones beyond the cap are picked up by the next call.
138
+ for (const [file, mtime] of Object.entries(seen)) {
139
+ if (state.scanned[file] === mtime) nextScanned[file] = mtime;
140
+ }
141
+ for (const { file, mtime } of changed.slice(0, MAX_PARSE_PER_CALL)) {
142
+ nextScanned[file] = mtime;
143
+ let meta;
144
+ try { meta = JSON.parse(readFileSync(file, 'utf8')); } catch { continue; }
145
+ const list = Array.isArray(meta?.userSelectedFolders) ? meta.userSelectedFolders : [];
146
+ for (const f of list) {
147
+ if (typeof f !== 'string' || !f) continue;
148
+ const rec = state.folders[f];
149
+ state.folders[f] = { firstSeen: rec?.firstSeen || now, lastSeen: now };
150
+ }
151
+ }
152
+ state.scanned = nextScanned;
153
+ return Object.keys(state.folders);
154
+ }
155
+
156
+ // ---------- eligibility ----------
157
+
158
+ function real(p) {
159
+ try { return realpathSync(p); } catch { return resolve(p); }
160
+ }
161
+
162
+ const isInside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
163
+
164
+ /** @returns {{ ok: boolean, reason?: string, dir?: string }} `dir` is the real path when ok */
165
+ export function eligibleFolder(dir) {
166
+ if (typeof dir !== 'string' || !isAbsolute(dir)) return { ok: false, reason: 'not-absolute' };
167
+ let st;
168
+ try { st = statSync(dir); } catch { return { ok: false, reason: 'missing' }; }
169
+ if (!st.isDirectory()) return { ok: false, reason: 'not-a-directory' };
170
+ const rd = real(dir);
171
+ const home = real(homedir());
172
+ if (rd === home || isInside(home, rd)) return { ok: false, reason: 'home-or-ancestor' };
173
+ if (isInside(rd, real(claudeUserDir()))) return { ok: false, reason: 'inside-claude-dir' };
174
+ if (process.platform === 'darwin' && isInside(rd, real(join(homedir(), 'Library')))) return { ok: false, reason: 'inside-library' };
175
+ if (isInside(rd, real(userDataDir()))) return { ok: false, reason: 'inside-sprag-data' };
176
+ for (const ex of coworkConfig().exclude) {
177
+ if (isInside(rd, real(ex))) return { ok: false, reason: 'excluded' };
178
+ }
179
+ return { ok: true, dir: rd };
180
+ }
181
+
182
+ export const coworkCopyPath = (dir) => join(dir, COPY_REL);
183
+
184
+ // ---------- content ----------
185
+
186
+ /** @returns {string|null} null when none of the three source files exists */
187
+ export function renderCoworkRatchet(lang = userLanguage()) {
188
+ const ko = lang === 'ko';
189
+ const parts = [];
190
+ for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), globalRatchetMdPath()]) {
191
+ let body;
192
+ try { body = readFileSync(file, 'utf8'); } catch { continue; }
193
+ const name = file.split(/[\\/]/).pop();
194
+ parts.push(`## ${ko ? '원본' : 'Source'}: ~/.claude/${name}\n\n${body.trim()}\n`);
195
+ }
196
+ if (parts.length === 0) return null;
197
+ const intro = ko
198
+ ? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫 세 파일의 본문을 여기에 옮겨 둡니다. sprag가 원본이 바뀔 때마다 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. Claude Code(Code 탭)는 settings.json 의 claudeMdExcludes 로 이 파일을 읽지 않습니다.'
199
+ : 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the three source ratchet files are copied here. sprag rewrites this whole file whenever a source changes, so do not edit it by hand. Claude Code (the Code tab) skips this file through claudeMdExcludes in settings.json.';
200
+ return `${COWORK_MARKER}\n${intro}\n\n${parts.join('\n')}`;
201
+ }
202
+
203
+ function firstLine(file) {
204
+ const raw = readFileSync(file, 'utf8');
205
+ const i = raw.indexOf('\n');
206
+ return (i === -1 ? raw : raw.slice(0, i)).replace(/\r$/, '').trim();
207
+ }
208
+
209
+ const isOwned = (file) => { try { return firstLine(file) === COWORK_MARKER; } catch { return false; } };
210
+
211
+ // ---------- git exclude ----------
212
+
213
+ const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], {
214
+ timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8',
215
+ }).trim();
216
+
217
+ function addGitExclude(dir) {
218
+ try {
219
+ if (git(dir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
220
+ const rel = COPY_REL.split(sep).join('/');
221
+ try { git(dir, ['check-ignore', '-q', rel]); return; } catch { /* exit 1: not ignored yet */ }
222
+ const excludeFile = resolve(dir, git(dir, ['rev-parse', '--git-path', 'info/exclude']));
223
+ const prefix = git(dir, ['rev-parse', '--show-prefix']);
224
+ const line = `/${prefix}${rel}`;
225
+ let existing = '';
226
+ try { existing = readFileSync(excludeFile, 'utf8'); } catch { /* new file */ }
227
+ if (existing.split(/\r?\n/).includes(line)) return;
228
+ mkdirSync(resolve(excludeFile, '..'), { recursive: true });
229
+ appendFileSync(excludeFile, `${existing && !existing.endsWith('\n') ? '\n' : ''}${line}\n`);
230
+ } catch (e) {
231
+ debug('cowork-sync:git-exclude', e);
232
+ }
233
+ }
234
+
235
+ // ---------- settings.json (Code tab de-dup) ----------
236
+
237
+ const settingsFile = () => join(claudeUserDir(), 'settings.json');
238
+
239
+ function ensureClaudeMdExclude() {
240
+ const file = settingsFile();
241
+ const read = readSettings(file);
242
+ if (read.state !== 'ok' && read.state !== 'absent') return;
243
+ const cur = read.settings.claudeMdExcludes;
244
+ if (cur !== undefined && !Array.isArray(cur)) return; // not ours to reshape
245
+ if (cur && cur.includes(COWORK_EXCLUDE_PATTERN)) return;
246
+ read.settings.claudeMdExcludes = [...(cur || []), COWORK_EXCLUDE_PATTERN];
247
+ const problem = writeSettings(file, read.settings);
248
+ if (problem) debug('cowork-sync:settings-write', problem);
249
+ }
250
+
251
+ function dropClaudeMdExclude() {
252
+ const file = settingsFile();
253
+ const read = readSettings(file);
254
+ if (read.state !== 'ok') return;
255
+ const cur = read.settings.claudeMdExcludes;
256
+ if (!Array.isArray(cur) || !cur.includes(COWORK_EXCLUDE_PATTERN)) return;
257
+ const next = cur.filter((x) => x !== COWORK_EXCLUDE_PATTERN);
258
+ if (next.length) read.settings.claudeMdExcludes = next;
259
+ else delete read.settings.claudeMdExcludes;
260
+ const problem = writeSettings(file, read.settings);
261
+ if (problem) debug('cowork-sync:settings-write', problem);
262
+ }
263
+
264
+ // ---------- sync / remove ----------
265
+
266
+ export function syncCoworkRatchets({ discover = true, agent = 'claude' } = {}) {
267
+ const result = { written: [], unchanged: [], skipped: [], removed: [] };
268
+ if (agent === 'codex') return result;
269
+ try {
270
+ if (!coworkConfig().sync) return removeCoworkRatchets();
271
+ const state = loadCoworkState();
272
+ if (discover) {
273
+ try { discoverCoworkFolders({ state }); } catch (e) { debug('cowork-sync:discover', e); }
274
+ }
275
+ const content = renderCoworkRatchet();
276
+ for (const folder of Object.keys(state.folders)) {
277
+ try {
278
+ const el = eligibleFolder(folder);
279
+ if (!el.ok) { result.skipped.push({ dir: folder, reason: el.reason }); continue; }
280
+ if (content === null) { result.skipped.push({ dir: folder, reason: 'no-source' }); continue; }
281
+ const target = coworkCopyPath(el.dir);
282
+ if (existsSync(target)) {
283
+ if (!isOwned(target)) { result.skipped.push({ dir: folder, reason: 'not-owned' }); continue; }
284
+ if (readFileSync(target, 'utf8') === content) {
285
+ result.unchanged.push(folder);
286
+ state.written[target] = state.written[target] || { at: new Date().toISOString() };
287
+ continue;
288
+ }
289
+ }
290
+ mkdirSync(join(el.dir, '.claude', 'rules'), { recursive: true });
291
+ writeFileSync(target, content);
292
+ state.written[target] = { at: new Date().toISOString() };
293
+ result.written.push(folder);
294
+ addGitExclude(el.dir);
295
+ } catch (e) {
296
+ result.skipped.push({ dir: folder, reason: `error: ${e?.message || e}` });
297
+ }
298
+ }
299
+ if (result.written.length || result.unchanged.length) {
300
+ try { ensureClaudeMdExclude(); } catch (e) { debug('cowork-sync:settings', e); }
301
+ }
302
+ state.lastSync = new Date().toISOString();
303
+ saveCoworkState(state);
304
+ } catch (e) {
305
+ debug('cowork-sync:sync', e);
306
+ }
307
+ return result;
308
+ }
309
+
310
+ /** Remove the copy in one folder (only when it carries our marker). */
311
+ export function removeCoworkRatchetIn(dir) {
312
+ const candidates = new Set([coworkCopyPath(dir), coworkCopyPath(real(dir))]);
313
+ let removed = false;
314
+ const state = loadCoworkState();
315
+ for (const target of candidates) {
316
+ try {
317
+ if (existsSync(target) && isOwned(target)) { rmSync(target, { force: true }); removed = true; }
318
+ } catch (e) { debug('cowork-sync:remove-one', e); }
319
+ delete state.written[target];
320
+ }
321
+ saveCoworkState(state);
322
+ return removed;
323
+ }
324
+
325
+ export function removeCoworkRatchets() {
326
+ const result = { written: [], unchanged: [], skipped: [], removed: [] };
327
+ try {
328
+ const state = loadCoworkState();
329
+ const targets = new Set(Object.keys(state.written));
330
+ for (const folder of Object.keys(state.folders)) targets.add(coworkCopyPath(folder));
331
+ for (const target of targets) {
332
+ try {
333
+ if (existsSync(target) && isOwned(target)) {
334
+ rmSync(target, { force: true });
335
+ result.removed.push(resolve(target, '..', '..', '..'));
336
+ }
337
+ } catch (e) {
338
+ result.skipped.push({ dir: target, reason: `error: ${e?.message || e}` });
339
+ }
340
+ }
341
+ state.written = {};
342
+ saveCoworkState(state);
343
+ try { dropClaudeMdExclude(); } catch (e) { debug('cowork-sync:settings', e); }
344
+ } catch (e) {
345
+ debug('cowork-sync:remove', e);
346
+ }
347
+ return result;
348
+ }
349
+
350
+ /** Per-folder view for `sprag cowork`. */
351
+ export function coworkStatus() {
352
+ const state = loadCoworkState();
353
+ const cfg = coworkConfig();
354
+ const folders = Object.keys(state.folders).map((dir) => {
355
+ const el = eligibleFolder(dir);
356
+ const copy = coworkCopyPath(el.ok ? el.dir : dir);
357
+ const there = existsSync(copy);
358
+ return { dir, eligible: el.ok, reason: el.reason, hasCopy: there && isOwned(copy), foreign: there && !isOwned(copy) };
359
+ });
360
+ return { enabled: cfg.sync, exclude: cfg.exclude, lastSync: state.lastSync || null, folders };
361
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Delegation-only subagents: Claude Code agents with a `tools` allowlist.
3
+ *
4
+ * Behind a gateway (ANTHROPIC_BASE_URL) Claude Code turns MCP tool search off,
5
+ * so every MCP tool definition rides along on each subagent request. A
6
+ * `general-purpose` haiku measured 46,618 tokens on its first request; a haiku
7
+ * agent limited to Read/Grep/Glob/Bash measured 20,141. A `tools` allowlist
8
+ * excludes MCP tools by definition, which is the whole saving.
9
+ *
10
+ * The files live in ~/.claude/agents and carry an ownership marker inside the
11
+ * frontmatter (a YAML comment, so it never reaches the prompt). Only files with
12
+ * that marker are ever overwritten or deleted; a same-named user file is left.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { claudeUserDir } from './paths.js';
18
+ import { loadConfig } from './config.js';
19
+
20
+ export const OWNER_MARK = 'sprag:delegation-agent';
21
+ const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
22
+
23
+ // Models are aliases on purpose: behind a gateway they resolve through
24
+ // ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping.
25
+ export const DEFINITIONS = [
26
+ {
27
+ name: 'haiku-explore',
28
+ model: 'haiku',
29
+ tools: 'Read, Grep, Glob, Bash',
30
+ role: 'Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.',
31
+ description: 'Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools.',
32
+ },
33
+ {
34
+ name: 'haiku-runner',
35
+ model: 'haiku',
36
+ tools: 'Bash, Read, Grep, Glob',
37
+ role: 'Your role: run the build, test, or git commands you are given and report the result.',
38
+ description: 'Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools.',
39
+ },
40
+ {
41
+ name: 'haiku-translate',
42
+ model: 'haiku',
43
+ tools: 'Read, Grep, Glob, Edit, Write',
44
+ role: 'Your role: batch translation and mechanical text conversion of the files or text you are given.',
45
+ description: 'Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools.',
46
+ },
47
+ {
48
+ name: 'sonnet-worker',
49
+ model: 'sonnet',
50
+ tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
51
+ role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
52
+ description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
53
+ },
54
+ ];
55
+
56
+ function render(def) {
57
+ return [
58
+ '---',
59
+ `name: ${def.name}`,
60
+ `description: ${def.description}`,
61
+ `tools: ${def.tools}`,
62
+ `model: ${def.model}`,
63
+ MARK_LINE,
64
+ '---',
65
+ def.role,
66
+ 'Do only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.',
67
+ "Report concisely, in the language of the caller's prompt.",
68
+ 'If you need an MCP tool or any tool not on your list, stop at once and report one line: "needs general-purpose: <tools needed>". The parent will delegate again.',
69
+ 'Do not guess at anything you could not verify; say that you could not verify it.',
70
+ '',
71
+ ].join('\n');
72
+ }
73
+
74
+ const agentsDir = () => join(claudeUserDir(), 'agents');
75
+ const fileOf = (name) => join(agentsDir(), `${name}.md`);
76
+
77
+ /** True when the marker sits inside the leading frontmatter block. */
78
+ function isOwned(text) {
79
+ if (!text.startsWith('---')) return false;
80
+ const end = text.indexOf('\n---', 3);
81
+ return (end === -1 ? text : text.slice(0, end)).includes(OWNER_MARK);
82
+ }
83
+
84
+ function readOwned(path) {
85
+ try { return { text: readFileSync(path, 'utf8') }; } catch { return null; }
86
+ }
87
+
88
+ export function removeDelegationAgents() {
89
+ const removed = [];
90
+ for (const def of DEFINITIONS) {
91
+ const p = fileOf(def.name);
92
+ try {
93
+ const f = readOwned(p);
94
+ if (f && isOwned(f.text)) { unlinkSync(p); removed.push(def.name); }
95
+ } catch { /* one file failing does not stop the rest */ }
96
+ }
97
+ return { written: [], unchanged: [], skipped: [], removed };
98
+ }
99
+
100
+ export function syncDelegationAgents({ agent = 'claude' } = {}) {
101
+ const res = { written: [], unchanged: [], skipped: [], removed: [] };
102
+ if (agent === 'codex') return res;
103
+ try {
104
+ if (loadConfig().delegationAgents === false) return removeDelegationAgents();
105
+ } catch { /* an unreadable config means the default, on */ }
106
+ for (const def of DEFINITIONS) {
107
+ try {
108
+ const p = fileOf(def.name);
109
+ const want = render(def);
110
+ const cur = existsSync(p) ? readOwned(p) : null;
111
+ if (existsSync(p) && !cur) { res.skipped.push({ name: def.name, reason: 'unreadable' }); continue; }
112
+ if (cur && !isOwned(cur.text)) { res.skipped.push({ name: def.name, reason: 'not-owned' }); continue; }
113
+ if (cur && cur.text === want) { res.unchanged.push(def.name); continue; }
114
+ mkdirSync(agentsDir(), { recursive: true });
115
+ writeFileSync(p, want);
116
+ res.written.push(def.name);
117
+ } catch (e) {
118
+ res.skipped.push({ name: def.name, reason: `error: ${e.message}` });
119
+ }
120
+ }
121
+ return res;
122
+ }
123
+
124
+ export function delegationAgentStatus() {
125
+ let enabled = true;
126
+ try { enabled = loadConfig().delegationAgents !== false; } catch { /* default on */ }
127
+ const agents = DEFINITIONS.map((def) => {
128
+ const f = existsSync(fileOf(def.name)) ? readOwned(fileOf(def.name)) : null;
129
+ const state = !f ? 'absent' : isOwned(f.text) ? 'installed-by-sprag' : 'user-file';
130
+ return { name: def.name, state };
131
+ });
132
+ return { enabled, agents };
133
+ }