sprag-cli 3.66.0 → 3.68.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.
- package/README.ko.md +29 -62
- package/README.md +26 -61
- package/bin/cli.js +130 -62
- package/docs/CODEX.ko.md +32 -0
- package/docs/CODEX.md +34 -0
- package/package.json +11 -4
- package/presets/ratchet-rules.json +7 -0
- package/src/agents.js +33 -11
- package/src/{codex-brief.js → codex/brief.js} +7 -7
- package/src/{codex-budget.js → codex/budget.js} +2 -2
- package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
- package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
- package/src/{codex-delegation.js → codex/delegation.js} +11 -11
- package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
- package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
- package/src/{codex-doctor.js → codex/doctor.js} +4 -4
- package/src/{codex-harness.js → codex/harness.js} +5 -5
- package/src/{codex-hooks.js → codex/hooks.js} +20 -20
- package/src/{codex-installer.js → codex/installer.js} +2 -2
- package/src/{codex-ledger.js → codex/ledger.js} +6 -6
- package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
- package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
- package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
- package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
- package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
- package/src/{codex-panel.js → codex/panel.js} +43 -34
- package/src/{codex-parser.js → codex/parser.js} +5 -9
- package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
- package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
- package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
- package/src/commands/codex-delegate.js +6 -6
- package/src/commands/codex.js +44 -44
- package/src/commands/cowork.js +107 -0
- package/src/commands/delegate.js +43 -0
- package/src/commands/install.js +21 -7
- package/src/commands/korean.js +1 -1
- package/src/commands/litellm-budget.js +56 -0
- package/src/commands/route-scan.js +6 -0
- package/src/commands/saved.js +60 -5
- package/src/commands/uninstall.js +7 -3
- package/src/config.js +4 -0
- package/src/cowork-sync.js +361 -0
- package/src/delegation-agents.js +133 -0
- package/src/formatters/statusline.js +713 -504
- package/src/glyphs.js +2 -0
- package/src/harness.js +1 -1
- package/src/implementation-delegation.js +5 -2
- package/src/installer.js +77 -1
- package/src/model-alias.js +45 -60
- package/src/model-rules.js +12 -5
- package/src/model-share.js +136 -0
- package/src/parser.js +4 -13
- package/src/route-inject.js +8 -3
- package/src/session-records.js +2 -8
- package/src/statusline-base.js +9 -3
- package/src/stdin-payload.js +1 -1
- package/src/transcript-lines.js +40 -0
- /package/src/{codex-cache.js → codex/cache.js} +0 -0
- /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
package/src/commands/saved.js
CHANGED
|
@@ -26,18 +26,19 @@ const SCOPE_SETS = {
|
|
|
26
26
|
async function currentTotals(scope, dir) {
|
|
27
27
|
if (scope === 'routing') {
|
|
28
28
|
const { delegationSavedTotals } = await import('../savings-ledger.js');
|
|
29
|
-
|
|
29
|
+
const t = delegationSavedTotals(Date.now(), dir);
|
|
30
|
+
return { usd: t.total, spent: t.spent, spentMissing: t.spentMissing };
|
|
30
31
|
}
|
|
31
32
|
if (scope === 'doc2md') {
|
|
32
33
|
const { doc2mdSavedTotals } = await import('../doc2md-ledger.cjs');
|
|
33
34
|
return { usd: doc2mdSavedTotals(dir).total };
|
|
34
35
|
}
|
|
35
36
|
if (scope === 'codex-routing') {
|
|
36
|
-
const { codexRoutingSavedTotals } = await import('../codex
|
|
37
|
+
const { codexRoutingSavedTotals } = await import('../codex/ledger.js');
|
|
37
38
|
const totals = codexRoutingSavedTotals({ dir });
|
|
38
39
|
return { usd: totals.priced ? totals.total : null, unpriced: totals.runs - totals.priced };
|
|
39
40
|
}
|
|
40
|
-
const { codexDocumentTotals } = await import('../codex
|
|
41
|
+
const { codexDocumentTotals } = await import('../codex/doc2md-ledger.js');
|
|
41
42
|
const totals = codexDocumentTotals({ dir });
|
|
42
43
|
return { docs: totals.docs, usd: totals.savings.priced ? totals.savings.total : null, unpriced: totals.savings.unpriced };
|
|
43
44
|
}
|
|
@@ -67,6 +68,54 @@ async function refreshRuleSavings(ko, rescan) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
const FAMILY_NAMES = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable', mythos: 'Mythos' };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The model-share table for the routing counter's period. Best-effort like the
|
|
75
|
+
* statusline: a failure to read transcripts must not break the totals above it.
|
|
76
|
+
*/
|
|
77
|
+
async function printModelShare({ ko, dir, money }) {
|
|
78
|
+
const { loadModelShare, roundShares } = await import('../model-share.js');
|
|
79
|
+
let share;
|
|
80
|
+
try {
|
|
81
|
+
share = await loadModelShare({ dir, env: process.env });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
debug('saved:model-share', e);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let title = ko ? '모델별 사용 비중 (같은 기간, 비용 기준)' : 'Model share (same period, by cost)';
|
|
87
|
+
if (share.sinceKind === 'month') title += ko ? ' · 초기화 기록이 없어 이번 달 기준' : ' · no reset on record, this month';
|
|
88
|
+
else if (share.sinceKind === 'capped') title += ko ? ' · 최근 90일' : ' · last 90 days';
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(title);
|
|
91
|
+
if (!(share.totalUsd > 0)) {
|
|
92
|
+
console.log(ko ? '이 기간에 집계할 사용 기록이 없습니다.' : 'No usage to count in this period.');
|
|
93
|
+
} else {
|
|
94
|
+
const names = share.families.map((f) => FAMILY_NAMES[f.family] || f.family);
|
|
95
|
+
const nameW = Math.max(...names.map((n) => n.length));
|
|
96
|
+
const pcts = share.families.map((f) => f.pct.toFixed(1));
|
|
97
|
+
const pctW = Math.max(...pcts.map((p) => p.length));
|
|
98
|
+
share.families.forEach((f, i) => {
|
|
99
|
+
const filled = roundShares([f.usd, share.totalUsd - f.usd], 20)[0];
|
|
100
|
+
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
|
|
101
|
+
console.log(` ${names[i].padEnd(nameW)} ${bar} ${pcts[i].padStart(pctW)}% ${money(f.usd)}`);
|
|
102
|
+
});
|
|
103
|
+
if (share.subagent.usd > 0) {
|
|
104
|
+
const subs = share.subagent.families;
|
|
105
|
+
const subPct = Math.round(share.subagent.pct);
|
|
106
|
+
// A share that rounds to 0 still spent money; "0%" would read as unused.
|
|
107
|
+
const pctText = (p) => (p > 0 && Math.round(p) === 0 ? '<1' : String(Math.round(p)));
|
|
108
|
+
const detail = subs
|
|
109
|
+
.map((f) => `${f.family} ${pctText(f.pct)}%`)
|
|
110
|
+
.join(', ');
|
|
111
|
+
console.log(` ${ko ? '서브에이전트 몫' : 'Subagents'}: ${subPct}% (${detail})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (share.unpriced > 0) {
|
|
115
|
+
console.log(ko ? `단가가 없어 제외한 부분 ${share.unpriced}건` : `${share.unpriced} part(s) left out: no price`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
70
119
|
export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan }) {
|
|
71
120
|
const { userLanguage } = await import('../config.js');
|
|
72
121
|
const { userDataDir } = await import('../paths.js');
|
|
@@ -79,8 +128,13 @@ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan
|
|
|
79
128
|
const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`);
|
|
80
129
|
const fmtTotal = (t) => {
|
|
81
130
|
const value = Number.isFinite(t.usd) ? money(t.usd) : 'n/a';
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
if (t.docs !== undefined) return `${ko ? `${t.docs}건` : `${t.docs} doc(s)`}, ${value} (${ko ? 'XML 대비 추정' : 'XML estimate'}; ${t.unpriced} unpriced)`;
|
|
132
|
+
// What the delegated runs cost on the models they ran on. The statusline
|
|
133
|
+
// no longer draws it beside the saving, so this is where it is read.
|
|
134
|
+
const spent = Number(t.spent) > 0
|
|
135
|
+
? ` · ${ko ? '위임 실행 비용' : 'spent on delegated runs'} ${money(t.spent)}${t.spentMissing > 0 ? ` +${t.spentMissing} unpriced` : ''}`
|
|
136
|
+
: '';
|
|
137
|
+
return `${value}${t.unpriced ? ` +${t.unpriced} unpriced` : ''}${spent}`;
|
|
84
138
|
};
|
|
85
139
|
const fmtTime = (ms) => new Date(ms).toLocaleString(ko ? 'ko-KR' : 'en-US');
|
|
86
140
|
const label = (scope) => ({
|
|
@@ -101,6 +155,7 @@ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan
|
|
|
101
155
|
: (ko ? `${fmtTime(marks[s])} 이후` : `since ${fmtTime(marks[s])}`);
|
|
102
156
|
console.log(`${label(s)}: ${fmtTotal(t)} (${since})`);
|
|
103
157
|
}
|
|
158
|
+
if (agent !== 'codex') await printModelShare({ ko, dir, money });
|
|
104
159
|
return;
|
|
105
160
|
}
|
|
106
161
|
|
|
@@ -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 {
|
|
@@ -39,8 +43,8 @@ export async function run({ hasFlag, args = [] }) {
|
|
|
39
43
|
// undo that as well; otherwise Codex keeps calling a command that may no
|
|
40
44
|
// longer exist. Only our own entries go, exactly as `uninstall --agent codex`.
|
|
41
45
|
try {
|
|
42
|
-
const { codexHookStatus, configureCodexHooks } = await import('../codex
|
|
43
|
-
const { codexHarnessStatus, uninitCodexHarness } = await import('../codex
|
|
46
|
+
const { codexHookStatus, configureCodexHooks } = await import('../codex/installer.js');
|
|
47
|
+
const { codexHarnessStatus, uninitCodexHarness } = await import('../codex/harness.js');
|
|
44
48
|
const hooked = codexHookStatus().some((h) => h.registered);
|
|
45
49
|
let harness = false;
|
|
46
50
|
try { harness = codexHarnessStatus({ scope: 'global' }).configured > 0; } catch { /* no AGENTS.md */ }
|
|
@@ -52,7 +56,7 @@ export async function run({ hasFlag, args = [] }) {
|
|
|
52
56
|
: ` Codex: hooks ${hook.action} (${hook.file}), harness ${h.removed ? 'removed' : 'absent'}`);
|
|
53
57
|
if (process.platform === 'darwin') {
|
|
54
58
|
try {
|
|
55
|
-
const { installPanelShell } = await import('../codex
|
|
59
|
+
const { installPanelShell } = await import('../codex/panel-shell.js');
|
|
56
60
|
installPanelShell({ remove: true });
|
|
57
61
|
} catch (e) { /* a malformed or foreign block stays for the user to repair */ }
|
|
58
62
|
}
|
package/src/config.js
CHANGED
|
@@ -56,6 +56,9 @@ const KEYWORDS = {
|
|
|
56
56
|
'no-timer': { key: 'timer', value: false },
|
|
57
57
|
color: { key: 'color', value: true },
|
|
58
58
|
'no-color': { key: 'color', value: false },
|
|
59
|
+
// Model-share bar and letters beside the routing chip; on unless switched off.
|
|
60
|
+
share: { key: 'modelShare', value: true },
|
|
61
|
+
'no-share': { key: 'modelShare', value: false },
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
// Output language for `last`/`history`/advice. Stored at top level
|
|
@@ -195,6 +198,7 @@ export function statuslineDefaults() {
|
|
|
195
198
|
verbose: s.verbose !== false,
|
|
196
199
|
timer: s.timer !== false,
|
|
197
200
|
color: s.color !== false,
|
|
201
|
+
modelShare: s.modelShare !== false,
|
|
198
202
|
windowHours,
|
|
199
203
|
windowLabel: formatWindow(windowHours),
|
|
200
204
|
// 'auto' | '5m' | '1h'. Auto lets the measured split decide and falls back
|
|
@@ -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
|
+
}
|