sprag-cli 3.69.0 → 3.71.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/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlap guard for the statusline command.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code relaunches the statusline command every refreshInterval (5s)
|
|
5
|
+
* without checking whether the previous run has finished. Measured with a
|
|
6
|
+
* 20-second command: 15 processes were alive at once after 75 seconds. On a
|
|
7
|
+
* busy machine one render can exceed 5s, so processes pile up without bound
|
|
8
|
+
* (each is 170-320MB) and memory climbs until the machine stalls.
|
|
9
|
+
*
|
|
10
|
+
* Two defences live here: a per-session lock so a second run exits at once
|
|
11
|
+
* (printing the last good line), and a hard time budget after which a slow run
|
|
12
|
+
* prints the last good line and exits. Nothing here may ever throw or break
|
|
13
|
+
* the statusline: every filesystem failure degrades to "no guard".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { userDataDir } from './paths.js';
|
|
19
|
+
import { extractSessionId } from './stdin-payload.js';
|
|
20
|
+
import { writeStateFile } from './state-file.js';
|
|
21
|
+
import { envVar } from './env.js';
|
|
22
|
+
|
|
23
|
+
const LOCK_DIR = 'statusline-locks';
|
|
24
|
+
const LAST_FILE = 'statusline-last.json';
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Default liveness probe: signal 0 checks existence without sending anything. */
|
|
28
|
+
export function defaultIsAlive(pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return !(e && e.code === 'ESRCH');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sessionKeyFrom(stdinJson) {
|
|
38
|
+
const id = extractSessionId(stdinJson);
|
|
39
|
+
if (!id) return 'default';
|
|
40
|
+
const key = id.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
|
|
41
|
+
return key || 'default';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readHolder(path) {
|
|
45
|
+
try {
|
|
46
|
+
const h = JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
if (h && typeof h === 'object' && Number.isFinite(h.pid) && Number.isFinite(h.startedAt)) return h;
|
|
48
|
+
} catch { /* unreadable or broken: treated as stale by the caller */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tryCreate(path, pid, now) {
|
|
53
|
+
const fd = openSync(path, 'wx');
|
|
54
|
+
try {
|
|
55
|
+
writeSync(fd, JSON.stringify({ pid, startedAt: now }));
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function acquireStatuslineLock({
|
|
62
|
+
dir = userDataDir(), key = 'default', now = Date.now(), pid = process.pid,
|
|
63
|
+
isAlive = defaultIsAlive, staleMs = 30_000, registerExit = true,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const noop = { acquired: true, release() {} };
|
|
66
|
+
try {
|
|
67
|
+
const lockDir = join(dir, LOCK_DIR);
|
|
68
|
+
mkdirSync(lockDir, { recursive: true });
|
|
69
|
+
const path = join(lockDir, `${key}.lock`);
|
|
70
|
+
const release = () => {
|
|
71
|
+
try {
|
|
72
|
+
const h = readHolder(path);
|
|
73
|
+
if (h && h.pid === pid) unlinkSync(path);
|
|
74
|
+
} catch { /* already gone */ }
|
|
75
|
+
};
|
|
76
|
+
const success = () => {
|
|
77
|
+
if (registerExit) process.once('exit', release);
|
|
78
|
+
return { acquired: true, path, release };
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
tryCreate(path, pid, now);
|
|
82
|
+
return success();
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (!e || e.code !== 'EEXIST') return noop;
|
|
85
|
+
}
|
|
86
|
+
const holder = readHolder(path);
|
|
87
|
+
const stale = !holder || !isAlive(holder.pid) || now - holder.startedAt > staleMs;
|
|
88
|
+
if (!stale) return { acquired: false, holder };
|
|
89
|
+
try { unlinkSync(path); } catch { /* someone else removed it */ }
|
|
90
|
+
try {
|
|
91
|
+
tryCreate(path, pid, now);
|
|
92
|
+
return success();
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (!e || e.code !== 'EEXIST') return noop;
|
|
95
|
+
return { acquired: false, holder: readHolder(path) };
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return noop;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readLastMap(dir) {
|
|
103
|
+
try {
|
|
104
|
+
const m = JSON.parse(readFileSync(join(dir, LAST_FILE), 'utf8'));
|
|
105
|
+
return m && typeof m === 'object' && !Array.isArray(m) ? m : {};
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readLastRender({ dir = userDataDir(), key = 'default' } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
const entry = readLastMap(dir)[key];
|
|
114
|
+
return entry && typeof entry.text === 'string' ? entry.text : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function writeLastRender({
|
|
121
|
+
dir = userDataDir(), key = 'default', text, now = Date.now(), maxAgeMs = DAY_MS,
|
|
122
|
+
} = {}) {
|
|
123
|
+
try {
|
|
124
|
+
const map = readLastMap(dir);
|
|
125
|
+
for (const [k, v] of Object.entries(map)) {
|
|
126
|
+
if (!v || typeof v.at !== 'number' || now - v.at > maxAgeMs) delete map[k];
|
|
127
|
+
}
|
|
128
|
+
map[key] = { text, at: now };
|
|
129
|
+
writeStateFile(join(dir, LAST_FILE), JSON.stringify(map));
|
|
130
|
+
} catch { /* the cache is a convenience */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function statuslineBudgetMs(env = process.env) {
|
|
134
|
+
const raw = envVar('STATUSLINE_BUDGET_MS', env);
|
|
135
|
+
if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) return Number(raw.trim());
|
|
136
|
+
return 4000;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function armStatuslineDeadline({ budgetMs, onExpire }) {
|
|
140
|
+
if (!budgetMs) return null;
|
|
141
|
+
const timer = setTimeout(onExpire, budgetMs);
|
|
142
|
+
timer.unref();
|
|
143
|
+
return timer;
|
|
144
|
+
}
|
package/src/stdin-payload.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* else in the CLI takes the extracted values.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { readSync } from 'node:fs';
|
|
8
|
+
import { isatty } from 'node:tty';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Read the JSON blob Claude Code feeds the statusline command on stdin.
|
|
@@ -30,12 +31,40 @@ import { readFileSync } from 'node:fs';
|
|
|
30
31
|
// command, so the raw text is read on first use and cached for the process.
|
|
31
32
|
let rawCache = null;
|
|
32
33
|
|
|
34
|
+
// The TTY test goes through isatty(0), never process.stdin: touching that
|
|
35
|
+
// getter builds a stream that switches a pipe to non-blocking mode, and
|
|
36
|
+
// readFileSync(0) then throws EAGAIN whenever the writer has not written yet.
|
|
37
|
+
// Under load that dropped the whole payload (session id, rate limits, model)
|
|
38
|
+
// on a random refresh. EAGAIN is still retried below for the same reason, in
|
|
39
|
+
// case some other module touches process.stdin first.
|
|
40
|
+
const STDIN_WAIT_MS = 2000;
|
|
41
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
42
|
+
|
|
43
|
+
function readFd0() {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
const buf = Buffer.alloc(65536);
|
|
46
|
+
const deadline = Date.now() + STDIN_WAIT_MS;
|
|
47
|
+
for (;;) {
|
|
48
|
+
let n;
|
|
49
|
+
try {
|
|
50
|
+
n = readSync(0, buf, 0, buf.length, null);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e && e.code === 'EAGAIN' && Date.now() < deadline) { sleep(5); continue; }
|
|
53
|
+
if (e && e.code === 'EOF') break; // Windows reports a closed pipe this way
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
if (n === 0) break;
|
|
57
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
58
|
+
}
|
|
59
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
/** The raw stdin text, read once. '' when stdin is a TTY or empty. */
|
|
34
63
|
export function readStdinRaw() {
|
|
35
64
|
if (rawCache !== null) return rawCache;
|
|
36
|
-
if (
|
|
65
|
+
if (isatty(0)) return (rawCache = '');
|
|
37
66
|
try {
|
|
38
|
-
rawCache =
|
|
67
|
+
rawCache = readFd0() || '';
|
|
39
68
|
} catch {
|
|
40
69
|
rawCache = '';
|
|
41
70
|
}
|
|
@@ -43,7 +72,7 @@ export function readStdinRaw() {
|
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
export function readStdinJson() {
|
|
46
|
-
if (
|
|
75
|
+
if (isatty(0)) return null;
|
|
47
76
|
try {
|
|
48
77
|
const raw = readStdinRaw();
|
|
49
78
|
if (!raw || !raw.trim()) return null;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team-prompts — the Enterprise prompt uploader.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside syncTeam only when the team's policy has prompt capture on (the
|
|
5
|
+
* server sends prompt_capture = true, and only for Enterprise organizations).
|
|
6
|
+
* What leaves the machine: the prompts the user typed in their own main
|
|
7
|
+
* sessions (Claude Code transcripts and Codex sessions), each as
|
|
8
|
+
* { at, project (a directory basename), text }, with obvious secrets replaced
|
|
9
|
+
* by "[redacted]". Never sent: assistant replies, tool output, file contents,
|
|
10
|
+
* subagent transcripts, slash-command wrappers, system reminders, local paths.
|
|
11
|
+
*
|
|
12
|
+
* Consent boundary: the first time a machine sees capture on, every existing
|
|
13
|
+
* transcript's offset is set to its current size, so history written before the
|
|
14
|
+
* policy reached this machine is never uploaded. Files created later start at 0.
|
|
15
|
+
* Turning capture off deletes the offsets file, so turning it on again starts
|
|
16
|
+
* fresh at the end of every file.
|
|
17
|
+
*
|
|
18
|
+
* Incremental by byte offset (prompts-offsets.json: { [path]: { offset, size, ino } }).
|
|
19
|
+
* The stored offset advances only after the server accepted the chunk or said
|
|
20
|
+
* it already had it. The path never leaves the machine: file_key is the first
|
|
21
|
+
* 16 hex chars of sha256(path).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { openSync, readSync, closeSync, fstatSync, readdirSync, statSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { claudeUserDir, userDataDir } from './paths.js';
|
|
28
|
+
import { codexUserDir } from './agent.js';
|
|
29
|
+
import { discoverCodexSessionFiles } from './codex/parser.js';
|
|
30
|
+
import { writeStateFile } from './state-file.js';
|
|
31
|
+
import { debug } from './debug.js';
|
|
32
|
+
|
|
33
|
+
export const MAX_RUN_PROMPTS = 2000;
|
|
34
|
+
export const MAX_RUN_BYTES = 8 * 1024 * 1024;
|
|
35
|
+
export const MAX_CHUNK_PROMPTS = 500;
|
|
36
|
+
const MAX_CHUNK_CHARS = 1800000; // server cap is 2,000,000
|
|
37
|
+
const MAX_TEXT = 20000;
|
|
38
|
+
const READ_WINDOW = 16 * 1024 * 1024;
|
|
39
|
+
const READ_WINDOW_MAX = 256 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
export const promptOffsetsPath = (dir = userDataDir()) => join(dir, 'prompts-offsets.json');
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Redaction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const REDACTED = '[redacted]';
|
|
48
|
+
const SECRET_PATTERNS = [
|
|
49
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
50
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*$/g, // key block cut off by the end of the prompt
|
|
51
|
+
/\bsk-ant-[A-Za-z0-9_-]{16,}/g,
|
|
52
|
+
/\bsk-[A-Za-z0-9_-]{20,}/g,
|
|
53
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
54
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
55
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
56
|
+
/\bxox[abpr]-[A-Za-z0-9-]{10,}/g,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Replace obvious secrets with "[redacted]". Conservative: it may over-redact, never under-redact these shapes. */
|
|
60
|
+
export function redactSecrets(text) {
|
|
61
|
+
let s = String(text ?? '');
|
|
62
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED);
|
|
63
|
+
s = s.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, `$1${REDACTED}`);
|
|
64
|
+
s = s.replace(
|
|
65
|
+
/\b([A-Za-z0-9_]*(?:KEY|TOKEN|PASSWORD|PASSWD|SECRET)[A-Za-z0-9_]*)(\s*=\s*)("[^"\n]*"|'[^'\n]*'|[^\s"']+)/gi,
|
|
66
|
+
(m, name, eq, val) => (val === REDACTED ? m : `${name}${eq}${REDACTED}`),
|
|
67
|
+
);
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Prompt extraction
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const basenameOf = (p) => {
|
|
76
|
+
const s = String(p ?? '').replace(/[\\/]+$/, '');
|
|
77
|
+
return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1).slice(0, 80);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const WRAPPER_RE = /<(?:command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat)>/;
|
|
81
|
+
const REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
82
|
+
|
|
83
|
+
/** Strip reminders, drop wrappers and injected context; null when nothing user-typed is left. */
|
|
84
|
+
function cleanPrompt(raw) {
|
|
85
|
+
if (typeof raw !== 'string') return null;
|
|
86
|
+
if (WRAPPER_RE.test(raw)) return null;
|
|
87
|
+
const text = raw.replace(REMINDER_RE, '').trim();
|
|
88
|
+
if (!text) return null;
|
|
89
|
+
if (text.startsWith('[Request interrupted by user')) return null;
|
|
90
|
+
return redactSecrets(text).slice(0, MAX_TEXT);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const textParts = (content) => (Array.isArray(content) ? content : [])
|
|
94
|
+
.filter((c) => c && c.type !== 'tool_result' && typeof c.text === 'string').map((c) => c.text).join('\n');
|
|
95
|
+
|
|
96
|
+
/** One Claude Code transcript line -> { at, project, text } or null. */
|
|
97
|
+
export function claudePromptFromLine(line) {
|
|
98
|
+
let e;
|
|
99
|
+
try { e = JSON.parse(line); } catch { return null; }
|
|
100
|
+
if (!e || e.type !== 'user' || e.isMeta || e.isSidechain || e.isCompactSummary || e.isVisibleInTranscriptOnly) return null;
|
|
101
|
+
const c = e.message?.content;
|
|
102
|
+
const text = cleanPrompt(typeof c === 'string' ? c : textParts(c));
|
|
103
|
+
const at = Date.parse(e.timestamp);
|
|
104
|
+
if (!text || !Number.isFinite(at)) return null;
|
|
105
|
+
return { at: new Date(at).toISOString(), project: basenameOf(e.cwd), text };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CODEX_INJECTED = /^\s*(?:<environment_context>|<user_instructions>|<INSTRUCTIONS>|<turn_aborted>|<permissions instructions>|# AGENTS\.md instructions|<user_action>|<skill>)/;
|
|
109
|
+
|
|
110
|
+
/** One Codex rollout line -> { at, text } or null (project comes from the file's session_meta). */
|
|
111
|
+
export function codexPromptFromLine(line) {
|
|
112
|
+
let e;
|
|
113
|
+
try { e = JSON.parse(line); } catch { return null; }
|
|
114
|
+
const p = e?.payload;
|
|
115
|
+
if (!p || e.type !== 'event_msg') return null;
|
|
116
|
+
let raw = null;
|
|
117
|
+
if (p.type === 'item_completed' && p.item?.type === 'UserMessage') raw = textParts(p.item.content);
|
|
118
|
+
else if (p.type === 'user_message' && typeof p.message === 'string') raw = p.message;
|
|
119
|
+
if (raw == null || CODEX_INJECTED.test(raw)) return null;
|
|
120
|
+
const text = cleanPrompt(raw);
|
|
121
|
+
const at = Date.parse(e.timestamp);
|
|
122
|
+
if (!text || !Number.isFinite(at)) return null;
|
|
123
|
+
return { at: new Date(at).toISOString(), text };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Files
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function readHead(path, bytes = 65536) {
|
|
131
|
+
let fd;
|
|
132
|
+
try {
|
|
133
|
+
fd = openSync(path, 'r');
|
|
134
|
+
const buf = Buffer.alloc(bytes);
|
|
135
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
136
|
+
return buf.subarray(0, n).toString('utf8');
|
|
137
|
+
} catch { return ''; } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* gone */ } }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** { cwdBase, subagent } from a Codex file's session_meta (first line). */
|
|
141
|
+
function codexMeta(path) {
|
|
142
|
+
const first = readHead(path).split('\n', 1)[0];
|
|
143
|
+
try {
|
|
144
|
+
const p = JSON.parse(first).payload || {};
|
|
145
|
+
const src = typeof p.source === 'string' ? p.source : JSON.stringify(p.source || '');
|
|
146
|
+
return { project: basenameOf(p.cwd), subagent: p.thread_source === 'subagent' || /subagent/.test(src) };
|
|
147
|
+
} catch { return { project: '', subagent: false }; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Main-session transcripts only: ~/.claude/projects/<dir>/*.jsonl and <codex>/sessions/**\/*.jsonl. */
|
|
151
|
+
export async function discoverPromptFiles({ claudeDir = claudeUserDir(), codexDir = codexUserDir() } = {}) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const root = join(claudeDir, 'projects');
|
|
154
|
+
let dirs = [];
|
|
155
|
+
try { dirs = readdirSync(root, { withFileTypes: true }); } catch { /* no Claude Code data */ }
|
|
156
|
+
for (const d of dirs) {
|
|
157
|
+
if (!d.isDirectory()) continue;
|
|
158
|
+
let names = [];
|
|
159
|
+
try { names = readdirSync(join(root, d.name)); } catch { continue; }
|
|
160
|
+
for (const n of names) if (n.endsWith('.jsonl')) out.push({ path: join(root, d.name, n), source: 'claude' });
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const sessionsRoot = join(codexDir, 'sessions');
|
|
164
|
+
for (const f of await discoverCodexSessionFiles({ home: codexDir, days: 36500 })) {
|
|
165
|
+
if (f.path.startsWith(sessionsRoot)) out.push({ path: f.path, source: 'codex' });
|
|
166
|
+
}
|
|
167
|
+
} catch (e) { debug('team-prompts', e); }
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fileKey = (path) => createHash('sha256').update(path).digest('hex').slice(0, 16);
|
|
172
|
+
|
|
173
|
+
export function loadOffsets(dir = userDataDir()) {
|
|
174
|
+
try {
|
|
175
|
+
const o = JSON.parse(readFileSync(promptOffsetsPath(dir), 'utf8'));
|
|
176
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
|
|
177
|
+
} catch { return null; }
|
|
178
|
+
}
|
|
179
|
+
const saveOffsets = (o, dir) => writeStateFile(promptOffsetsPath(dir), JSON.stringify(o) + '\n');
|
|
180
|
+
export function dropOffsets(dir = userDataDir()) {
|
|
181
|
+
try { rmSync(promptOffsetsPath(dir), { force: true }); } catch (e) { debug('team-prompts', e); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function statOf(path) {
|
|
185
|
+
try { const s = statSync(path); return { size: s.size, ino: s.ino }; } catch { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** First sight of capture = on: pin every existing file at its current size. */
|
|
189
|
+
function initOffsets(files, dir) {
|
|
190
|
+
const o = {};
|
|
191
|
+
for (const f of files) {
|
|
192
|
+
const s = statOf(f.path);
|
|
193
|
+
if (s) o[f.path] = { offset: s.size, size: s.size, ino: s.ino };
|
|
194
|
+
}
|
|
195
|
+
saveOffsets(o, dir);
|
|
196
|
+
return o;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read one file from its stored offset to the last complete newline and cut the
|
|
201
|
+
* prompts into chunks. Returns { chunks, end, size, ino } where end is the byte
|
|
202
|
+
* offset just after the last complete line read. chunk = { start, end, prompts }.
|
|
203
|
+
*/
|
|
204
|
+
function scanFile(file, entry, { maxPrompts, maxBytes }) {
|
|
205
|
+
const s = statOf(file.path);
|
|
206
|
+
if (!s) return null;
|
|
207
|
+
let offset = entry ? entry.offset : 0;
|
|
208
|
+
if (entry && (s.size < entry.offset || (entry.ino && s.ino && entry.ino !== s.ino))) offset = 0; // shrank or rotated
|
|
209
|
+
if (s.size <= offset) return { chunks: [], end: offset, size: s.size, ino: s.ino };
|
|
210
|
+
let fd;
|
|
211
|
+
try {
|
|
212
|
+
fd = openSync(file.path, 'r');
|
|
213
|
+
let win = Math.min(s.size - offset, READ_WINDOW);
|
|
214
|
+
let buf; let last = -1;
|
|
215
|
+
for (;;) {
|
|
216
|
+
buf = Buffer.alloc(win);
|
|
217
|
+
const n = readSync(fd, buf, 0, win, offset);
|
|
218
|
+
buf = buf.subarray(0, n);
|
|
219
|
+
last = buf.lastIndexOf(0x0a);
|
|
220
|
+
if (last >= 0 || offset + win >= s.size || win >= READ_WINDOW_MAX) break;
|
|
221
|
+
win = Math.min(win * 2, s.size - offset); // one very long line: widen the window
|
|
222
|
+
}
|
|
223
|
+
if (last < 0) return { chunks: [], end: offset, size: s.size, ino: s.ino }; // partial last line: wait
|
|
224
|
+
const meta = file.source === 'codex' ? codexMeta(file.path) : null;
|
|
225
|
+
if (meta?.subagent) return { chunks: [], end: offset + last + 1, size: s.size, ino: s.ino };
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let cur = { start: offset, end: offset, prompts: [], chars: 0 };
|
|
228
|
+
let bytes = 0; let total = 0; let pos = 0; let stoppedAt = null;
|
|
229
|
+
let prevText = null;
|
|
230
|
+
while (pos <= last) {
|
|
231
|
+
const nl = buf.indexOf(0x0a, pos);
|
|
232
|
+
const line = buf.toString('utf8', pos, nl);
|
|
233
|
+
const lineStart = offset + pos;
|
|
234
|
+
const lineEnd = offset + nl + 1;
|
|
235
|
+
let pr = null;
|
|
236
|
+
if (line.length > 2) pr = file.source === 'codex' ? codexPromptFromLine(line) : claudePromptFromLine(line);
|
|
237
|
+
if (pr) {
|
|
238
|
+
if (file.source === 'codex') {
|
|
239
|
+
pr.project = meta.project;
|
|
240
|
+
// current rollouts carry both UserMessage and a legacy user_message for one turn
|
|
241
|
+
if (prevText === pr.text) { pos = nl + 1; cur.end = lineEnd; continue; }
|
|
242
|
+
prevText = pr.text;
|
|
243
|
+
}
|
|
244
|
+
const b = Buffer.byteLength(pr.text);
|
|
245
|
+
if (total >= maxPrompts || bytes + b > maxBytes) { stoppedAt = lineStart; break; }
|
|
246
|
+
if (cur.prompts.length >= MAX_CHUNK_PROMPTS || cur.chars + pr.text.length > MAX_CHUNK_CHARS) {
|
|
247
|
+
cur.end = lineStart;
|
|
248
|
+
chunks.push(cur);
|
|
249
|
+
cur = { start: lineStart, end: lineStart, prompts: [], chars: 0 };
|
|
250
|
+
}
|
|
251
|
+
cur.prompts.push(pr);
|
|
252
|
+
cur.chars += pr.text.length;
|
|
253
|
+
bytes += b; total += 1;
|
|
254
|
+
}
|
|
255
|
+
cur.end = lineEnd;
|
|
256
|
+
pos = nl + 1;
|
|
257
|
+
}
|
|
258
|
+
if (stoppedAt !== null) cur.end = stoppedAt;
|
|
259
|
+
if (cur.prompts.length) chunks.push(cur);
|
|
260
|
+
const end = stoppedAt !== null ? stoppedAt : offset + last + 1;
|
|
261
|
+
return { chunks, end, size: s.size, ino: s.ino, stopped: stoppedAt !== null };
|
|
262
|
+
} catch (e) {
|
|
263
|
+
debug('team-prompts', e);
|
|
264
|
+
return null;
|
|
265
|
+
} finally {
|
|
266
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* gone */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Upload
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Upload what is new. `put(params)` calls the prompt_chunk_put RPC and returns its JSON.
|
|
276
|
+
* Returns { uploaded, chunks, duplicates, capped, off?, error? }.
|
|
277
|
+
*/
|
|
278
|
+
export async function uploadPrompts({
|
|
279
|
+
put, dir = userDataDir(), claudeDir, codexDir,
|
|
280
|
+
maxPrompts = MAX_RUN_PROMPTS, maxBytes = MAX_RUN_BYTES,
|
|
281
|
+
} = {}) {
|
|
282
|
+
const files = await discoverPromptFiles({ claudeDir, codexDir });
|
|
283
|
+
let offsets = loadOffsets(dir);
|
|
284
|
+
if (!offsets) {
|
|
285
|
+
initOffsets(files, dir); // consent boundary: no backfill
|
|
286
|
+
return { uploaded: 0, chunks: 0, duplicates: 0, capped: false, initialized: true };
|
|
287
|
+
}
|
|
288
|
+
const res = { uploaded: 0, chunks: 0, duplicates: 0, capped: false };
|
|
289
|
+
let promptsLeft = maxPrompts; let bytesLeft = maxBytes;
|
|
290
|
+
const withMtime = files.map((f) => ({ ...f, mtime: (() => { try { return statSync(f.path).mtimeMs; } catch { return 0; } })() }))
|
|
291
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
292
|
+
for (const f of withMtime) {
|
|
293
|
+
if (promptsLeft <= 0 || bytesLeft <= 0) { res.capped = true; break; }
|
|
294
|
+
const entry = offsets[f.path];
|
|
295
|
+
const scan = scanFile(f, entry, { maxPrompts: promptsLeft, maxBytes: bytesLeft });
|
|
296
|
+
if (!scan) continue;
|
|
297
|
+
const restart = entry && (scan.size < entry.offset || (entry.ino && scan.ino && entry.ino !== scan.ino));
|
|
298
|
+
if (restart) { offsets[f.path] = { offset: 0, size: scan.size, ino: scan.ino }; }
|
|
299
|
+
const key = fileKey(f.path);
|
|
300
|
+
for (const c of scan.chunks) {
|
|
301
|
+
let r;
|
|
302
|
+
try {
|
|
303
|
+
r = await put({
|
|
304
|
+
source: f.source, file_key: key, start_offset: c.start, end_offset: c.end,
|
|
305
|
+
prompts: c.prompts.map((p) => ({ at: p.at, project: p.project, text: p.text })),
|
|
306
|
+
});
|
|
307
|
+
} catch (e) {
|
|
308
|
+
if (/prompt capture is off/.test(String(e.message))) { res.off = true; return res; }
|
|
309
|
+
debug('team-prompts', e);
|
|
310
|
+
res.error = e.message;
|
|
311
|
+
saveOffsets(offsets, dir);
|
|
312
|
+
return res;
|
|
313
|
+
}
|
|
314
|
+
const accepted = Number(r?.accepted) || 0;
|
|
315
|
+
if (r?.duplicate) res.duplicates += 1;
|
|
316
|
+
res.uploaded += accepted;
|
|
317
|
+
res.chunks += 1;
|
|
318
|
+
promptsLeft -= c.prompts.length;
|
|
319
|
+
bytesLeft -= Buffer.byteLength(c.prompts.map((p) => p.text).join(''));
|
|
320
|
+
offsets[f.path] = { offset: c.end, size: scan.size, ino: scan.ino };
|
|
321
|
+
saveOffsets(offsets, dir);
|
|
322
|
+
}
|
|
323
|
+
// lines after the last prompt carry nothing to send: move past them without a server call
|
|
324
|
+
if (scan.end > (offsets[f.path]?.offset ?? 0)) {
|
|
325
|
+
offsets[f.path] = { offset: scan.end, size: scan.size, ino: scan.ino };
|
|
326
|
+
saveOffsets(offsets, dir);
|
|
327
|
+
}
|
|
328
|
+
if (scan.stopped || promptsLeft <= 0 || bytesLeft <= 0) res.capped = true;
|
|
329
|
+
}
|
|
330
|
+
return res;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** For `sprag team status --show-payload`: how many prompts wait and the shape of one (never all the text). */
|
|
334
|
+
export async function pendingPrompts({ dir = userDataDir(), claudeDir, codexDir } = {}) {
|
|
335
|
+
const offsets = loadOffsets(dir);
|
|
336
|
+
if (!offsets) return { count: 0, example: null, initialized: false };
|
|
337
|
+
const files = await discoverPromptFiles({ claudeDir, codexDir });
|
|
338
|
+
let count = 0; let example = null;
|
|
339
|
+
for (const f of files) {
|
|
340
|
+
const scan = scanFile(f, offsets[f.path], { maxPrompts: Infinity, maxBytes: Infinity });
|
|
341
|
+
if (!scan) continue;
|
|
342
|
+
for (const c of scan.chunks) {
|
|
343
|
+
count += c.prompts.length;
|
|
344
|
+
if (!example && c.prompts.length) {
|
|
345
|
+
const p = c.prompts[0];
|
|
346
|
+
example = { at: p.at, project: p.project, text: p.text.slice(0, 80) };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { count, example, initialized: true };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const promptOffsetsExist = (dir = userDataDir()) => existsSync(promptOffsetsPath(dir));
|