sprag-cli 3.70.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.
Files changed (45) hide show
  1. package/README.ko.md +7 -1
  2. package/README.md +7 -1
  3. package/bin/cli.js +47 -5
  4. package/docs/CODEX.ko.md +2 -2
  5. package/docs/CODEX.md +4 -4
  6. package/docs/TEAM.ko.md +74 -19
  7. package/docs/TEAM.md +75 -19
  8. package/package.json +2 -1
  9. package/presets/agents.json +118 -0
  10. package/presets/doc2md/convert.py +1 -1
  11. package/presets/ratchet-rules.json +12 -2
  12. package/src/cli-entry.js +40 -0
  13. package/src/codex/hooks.js +11 -10
  14. package/src/codex/panel-setup.js +2 -1
  15. package/src/codex/panel.js +2 -1
  16. package/src/commands/brief.js +2 -0
  17. package/src/commands/codex.js +3 -2
  18. package/src/commands/harness.js +2 -3
  19. package/src/commands/install.js +19 -18
  20. package/src/commands/presets.js +158 -0
  21. package/src/commands/profile-map.js +2 -2
  22. package/src/commands/route-scan.js +9 -7
  23. package/src/commands/team.js +82 -26
  24. package/src/commands/update-check.js +2 -2
  25. package/src/debug.js +3 -2
  26. package/src/delegation-agents.js +48 -60
  27. package/src/doc2md.cjs +4 -3
  28. package/src/env.cjs +7 -0
  29. package/src/env.js +19 -0
  30. package/src/first-run-note.js +3 -2
  31. package/src/harness.js +4 -14
  32. package/src/hook.cjs +4 -3
  33. package/src/preset-sign.js +106 -0
  34. package/src/preset-sync.js +133 -0
  35. package/src/presets-source.js +209 -0
  36. package/src/prompt.js +2 -1
  37. package/src/route-scan.js +14 -1
  38. package/src/seed-rules.js +14 -16
  39. package/src/star-note.js +3 -2
  40. package/src/statusline-guard.js +144 -0
  41. package/src/stdin-payload.js +33 -4
  42. package/src/team-prompts.js +353 -0
  43. package/src/team-ratchet.js +116 -16
  44. package/src/team-sync.js +336 -37
  45. package/src/update-check.js +3 -2
package/src/seed-rules.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * This module closes that gap with presets bundled in the package:
13
13
  * presets/model-rules.json → tier-delegation rules (the model ratchet)
14
14
  * presets/ratchet-rules.json → field-tested mistake rules (the global ratchet)
15
+ * or, when one has been fetched and verifies, the centrally published bundle
16
+ * with the same shape (presets-source.js).
15
17
  *
16
18
  * Nothing is written without the user agreeing to that specific rule. The
17
19
  * SessionStart hook lists what is pending and tells the model to ask one rule at
@@ -25,9 +27,8 @@
25
27
 
26
28
  import { readFileSync } from 'node:fs';
27
29
  import { CLI_NAME } from './cli-name.js';
28
- import { join, dirname, resolve } from 'node:path';
29
- import { fileURLToPath } from 'node:url';
30
- import { createHash } from 'node:crypto';
30
+ import { join, resolve } from 'node:path';
31
+ import { modelPresets as sourceModelPresets } from './presets-source.js';
31
32
  import { sharedModelRulesForProject, loadSharedModelRules, sharedProjectRoots, realProjectRoot } from './shared-model-rules.js';
32
33
  import { userDataDir } from './paths.js';
33
34
  import { userLanguage } from './config.js';
@@ -45,8 +46,6 @@ import {
45
46
  presetRuleEntries,
46
47
  } from './harness.js';
47
48
 
48
- const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
49
-
50
49
  // Codex answers are kept apart: saying no in one agent must not consume the
51
50
  // other agent's offer, and the two agents write to different ratchet files.
52
51
  export function seedStatePath({ agent = 'claude' } = {}) {
@@ -66,14 +65,12 @@ export function saveSeedState(state, opts = {}) {
66
65
  writeStateFile(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
67
66
  }
68
67
 
69
- /** Bundled tier-delegation presets (empty array when the file is unreadable). */
68
+ /**
69
+ * Tier-delegation presets: the published bundle when it verifies, else the
70
+ * bundled presets/model-rules.json (empty array when that is unreadable).
71
+ */
70
72
  export function modelPresets() {
71
- try {
72
- const data = JSON.parse(readFileSync(join(packageRoot, 'presets', 'model-rules.json'), 'utf8'));
73
- return Array.isArray(data.presets) ? data.presets : [];
74
- } catch {
75
- return [];
76
- }
73
+ return sourceModelPresets();
77
74
  }
78
75
 
79
76
  /**
@@ -87,10 +84,11 @@ export function ratchetPresets(lang = userLanguage(), { agent = 'claude' } = {})
87
84
  // tiers would only mislead a Codex session.
88
85
  .filter((r) => !r.agents || r.agents.includes(agent))
89
86
  .map((r) => ({
90
- // Hashed from the Korean text in both languages: the id has to survive a
91
- // reworded translation, or a user who declined a rule would be asked again
92
- // the next time its English copy changes.
93
- id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
87
+ // The id is carried by the preset itself: first derived from the Korean
88
+ // text, then kept when a published bundle rewords the rule, so a user who
89
+ // accepted or declined it keeps that answer. presets-source.js fills it in
90
+ // for an entry that has none, from the Korean text as before.
91
+ id: r.id,
94
92
  text: lang === 'ko' ? r.ko : r.en,
95
93
  ko: r.ko,
96
94
  }));
package/src/star-note.js CHANGED
@@ -8,10 +8,11 @@
8
8
  *
9
9
  * Once, because a chip that keeps asking is an advertisement. For 24 hours,
10
10
  * which is long enough to be seen across a day of sessions and short enough
11
- * not to linger. CTS_NO_NOTE=1 suppresses it, the same opt-out as every
11
+ * not to linger. SPRAG_NO_NOTE=1 suppresses it, the same opt-out as every
12
12
  * other one-time note.
13
13
  */
14
14
 
15
+ import { envVar } from './env.js';
15
16
  import { createRequire } from 'node:module';
16
17
  import { delegationSavedTotals } from './savings-ledger.js';
17
18
  import { noteShownAt, markNoteShown } from './first-run-note.js';
@@ -43,7 +44,7 @@ export function toolSavedUsd(now = Date.now(), dir = userDataDir()) {
43
44
  /** `{ usd, shownAt }` while the chip should show, else null. Never throws. */
44
45
  export function readStarChip({ now = Date.now(), dir = userDataDir(), savedUsd } = {}) {
45
46
  try {
46
- if (process.env.CTS_NO_NOTE === '1') return null;
47
+ if (envVar('NO_NOTE') === '1') return null;
47
48
  const shownAt = noteShownAt('star');
48
49
  if (shownAt === null) {
49
50
  const usd = savedUsd ?? toolSavedUsd(now, dir);
@@ -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
+ }
@@ -4,7 +4,8 @@
4
4
  * else in the CLI takes the extracted values.
5
5
  */
6
6
 
7
- import { readFileSync } from 'node:fs';
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 (process.stdin.isTTY) return (rawCache = '');
65
+ if (isatty(0)) return (rawCache = '');
37
66
  try {
38
- rawCache = readFileSync(0, 'utf8') || '';
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 (process.stdin.isTTY) return null;
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));