flowviant 0.103.0 → 0.104.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/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE CODE'S SESSION PRESENCE, read off its own on-disk state (split out of
|
|
3
|
+
* localSessions.mjs 2026-09-26, SOLID F062). The liveness registry
|
|
4
|
+
* (~/.claude/sessions/<pid>.json) says what is open right now, and the
|
|
5
|
+
* transcript store (~/.claude/projects/<munged-cwd>/<id>.jsonl) says what was.
|
|
6
|
+
* A Claude transcript-format change lands here; the Antigravity reader is
|
|
7
|
+
* agySessions.mjs and the shared fence and report cap are the coordinator's
|
|
8
|
+
* (localSessions.mjs).
|
|
9
|
+
*
|
|
10
|
+
* NOTHING in this file throws: a torn registry file or a vanished cwd is a
|
|
11
|
+
* session to skip, not an error to raise.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
statSync,
|
|
20
|
+
openSync,
|
|
21
|
+
readSync,
|
|
22
|
+
closeSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
// ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
|
|
29
|
+
// newest per directory, few. (The window itself is the coordinator's.)
|
|
30
|
+
const ENDED_CAP = 5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The conversation's own title, off the transcript's `ai-title` records
|
|
34
|
+
* (the LAST one wins — titles get rewritten as a session evolves), and ONLY
|
|
35
|
+
* those records: this wire is presence METADATA, and the first user message
|
|
36
|
+
* — the fallback this used to relay — is transcript CONTENT wearing a
|
|
37
|
+
* title's clothes. An untitled row is honest presence; a quoted message is
|
|
38
|
+
* a leak. Those records sit anywhere in the file (measured: line 81 to line
|
|
39
|
+
* 4457), so this reads the WHOLE transcript — behind an mtime cache, because
|
|
40
|
+
* the scan runs every minute and a title only changes when the file does:
|
|
41
|
+
* steady state is a stat, not a read.
|
|
42
|
+
*/
|
|
43
|
+
const titleCache = new Map(); // file → { mtimeMs, title }
|
|
44
|
+
function transcriptTitle(file, mtimeMs) {
|
|
45
|
+
const hit = titleCache.get(file);
|
|
46
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.title;
|
|
47
|
+
let title = null;
|
|
48
|
+
try {
|
|
49
|
+
const stat = statSync(file);
|
|
50
|
+
// A transcript past this is not worth a read per minute of drift.
|
|
51
|
+
if (stat.size <= 64 * 1024 * 1024) {
|
|
52
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
53
|
+
if (!line.includes('"type":"ai-title"')) continue;
|
|
54
|
+
try {
|
|
55
|
+
const t = JSON.parse(line)?.aiTitle;
|
|
56
|
+
if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
|
|
57
|
+
} catch {
|
|
58
|
+
/* torn line */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
title = null;
|
|
65
|
+
}
|
|
66
|
+
if (titleCache.size > 400) titleCache.clear(); // a bound, not an LRU — refills in one scan
|
|
67
|
+
titleCache.set(file, { mtimeMs, title });
|
|
68
|
+
return title;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* THE TITLE CLAUDE GAVE ITS OWN CONVERSATION, for one session we already know
|
|
73
|
+
* the id of.
|
|
74
|
+
*
|
|
75
|
+
* `scanLocalSessions` finds transcripts the hard way — walk every project
|
|
76
|
+
* directory, read each file's own `cwd` — because it is answering "what is in
|
|
77
|
+
* this repo" and knows no ids. This asks the same store a narrower question:
|
|
78
|
+
* the tab pinned the id the CLI reported at `system.init`, and the directory is
|
|
79
|
+
* the place the turn was spawned in, so the file is one munge away (Claude Code
|
|
80
|
+
* names the directory after the cwd with `/` and `.` replaced by `-`).
|
|
81
|
+
*
|
|
82
|
+
* The realpath fallback is not defensive padding: a place under a symlinked
|
|
83
|
+
* home munges to a DIFFERENT directory name depending on which form the CLI was
|
|
84
|
+
* handed, and the daemon does not control which that was.
|
|
85
|
+
*
|
|
86
|
+
* Returns null for anything it cannot read. A missing title is honest silence —
|
|
87
|
+
* the tab keeps whatever name it has.
|
|
88
|
+
*/
|
|
89
|
+
export function titleForSession(cwd, sessionId) {
|
|
90
|
+
if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const projects = join(homedir(), '.claude', 'projects');
|
|
94
|
+
const dirs = [String(cwd)];
|
|
95
|
+
try {
|
|
96
|
+
const real = realpathSync(String(cwd));
|
|
97
|
+
if (real !== String(cwd)) dirs.push(real);
|
|
98
|
+
} catch {
|
|
99
|
+
/* the place is gone — nothing to read */
|
|
100
|
+
}
|
|
101
|
+
for (const dir of dirs) {
|
|
102
|
+
const file = join(projects, claudeProjectDir(dir), `${sessionId}.jsonl`);
|
|
103
|
+
try {
|
|
104
|
+
return transcriptTitle(file, statSync(file).mtimeMs);
|
|
105
|
+
} catch {
|
|
106
|
+
/* not this munge — try the other */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
|
|
114
|
+
* copied exactly (2.1.281): every character that is not an ASCII letter or
|
|
115
|
+
* digit becomes `-`, and a name past 200 characters is cut there and suffixed
|
|
116
|
+
* with a base-36 Java-style string hash of the ORIGINAL path.
|
|
117
|
+
*
|
|
118
|
+
* This file used `replace(/[/.]/g, '-')`, which agrees with Claude Code only
|
|
119
|
+
* while a path holds nothing but letters, digits, `/` and `.` — so a repo at
|
|
120
|
+
* `~/code/my_app` or `~/My Projects/x` never offered an ended terminal session
|
|
121
|
+
* for adoption and never got a tab auto-title, silently (audit 2026-09-24).
|
|
122
|
+
*/
|
|
123
|
+
const CLAUDE_DIR_MAX = 200;
|
|
124
|
+
export function claudeProjectDir(path) {
|
|
125
|
+
const p = String(path);
|
|
126
|
+
const s = p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
127
|
+
if (s.length <= CLAUDE_DIR_MAX) return s;
|
|
128
|
+
let h = 0;
|
|
129
|
+
for (let i = 0; i < p.length; i++) h = ((h << 5) - h + p.charCodeAt(i)) | 0;
|
|
130
|
+
return `${s.slice(0, CLAUDE_DIR_MAX)}-${Math.abs(h).toString(36)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Is this pid the SAME process the registry entry recorded?
|
|
135
|
+
*
|
|
136
|
+
* Registry entries go stale — Claude exits, the pid is recycled by something
|
|
137
|
+
* else, the file stays. `/proc/<pid>` existing only proves A process; the
|
|
138
|
+
* starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
|
|
139
|
+
* field (parenthesised, may itself contain spaces and parens) makes naive
|
|
140
|
+
* whitespace-splitting wrong, so fields are counted from after the LAST ')':
|
|
141
|
+
* the first post-comm field is field 3, which puts starttime at index 19.
|
|
142
|
+
*/
|
|
143
|
+
function pidAlive(pid, procStart) {
|
|
144
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
145
|
+
let stat;
|
|
146
|
+
try {
|
|
147
|
+
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
// No /proc ENTRY means the process is gone — but only where /proc exists
|
|
150
|
+
// at all. On macOS there is no /proc, so this read fails for EVERY pid,
|
|
151
|
+
// every session scans as dead, and live sessions become adoptable — the
|
|
152
|
+
// exact two-drivers-on-one-conversation outcome liveness exists to
|
|
153
|
+
// prevent. Probe by name instead: `ps -o comm=` on the pid, alive only if
|
|
154
|
+
// the surviving process still LOOKS like a coding-CLI process. A bare
|
|
155
|
+
// signal-0 probe was tried first and reads a RECYCLED pid as alive
|
|
156
|
+
// forever — a crashed session whose pid a launchd service inherited would
|
|
157
|
+
// report live for weeks, refuse adoption, and suppress its own ended row.
|
|
158
|
+
// The name check keeps the conservative direction (a transient race still
|
|
159
|
+
// errs live) without the permanent false-live.
|
|
160
|
+
if (!existsSync('/proc')) {
|
|
161
|
+
try {
|
|
162
|
+
const comm = execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
165
|
+
timeout: 5_000,
|
|
166
|
+
})
|
|
167
|
+
.trim()
|
|
168
|
+
.toLowerCase();
|
|
169
|
+
return /\b(claude|node|bun|codex|agy)\b|\/(claude|node|bun|codex|agy)$/.test(comm);
|
|
170
|
+
} catch {
|
|
171
|
+
return false; // ps errored or the pid is gone — the process is dead
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false; // /proc is real and has no entry — the process is gone
|
|
175
|
+
}
|
|
176
|
+
if (procStart == null) return true; // nothing recorded to compare against
|
|
177
|
+
const close = stat.lastIndexOf(')');
|
|
178
|
+
if (close === -1) return false;
|
|
179
|
+
const fields = stat.slice(close + 1).trim().split(/\s+/);
|
|
180
|
+
return fields[19] === String(procStart);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Is a terminal Claude session with this id open on the machine RIGHT NOW?
|
|
185
|
+
*
|
|
186
|
+
* The adoption path asks this at the moment of adopting: forking a session
|
|
187
|
+
* while its terminal is still typing into it would put two Claudes on one
|
|
188
|
+
* conversation, which is the exact incoherence the Workbench's own locks
|
|
189
|
+
* exist to prevent.
|
|
190
|
+
*/
|
|
191
|
+
export function isTerminalSessionLive(sessionId) {
|
|
192
|
+
try {
|
|
193
|
+
const dir = join(homedir(), '.claude', 'sessions');
|
|
194
|
+
for (const name of readdirSync(dir)) {
|
|
195
|
+
if (!name.endsWith('.json')) continue;
|
|
196
|
+
let rec;
|
|
197
|
+
try {
|
|
198
|
+
rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
199
|
+
} catch {
|
|
200
|
+
continue; // torn write / not JSON — not evidence of anything
|
|
201
|
+
}
|
|
202
|
+
if (rec?.sessionId !== sessionId) continue;
|
|
203
|
+
if (pidAlive(rec.pid, rec.procStart)) return true;
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
/* registry unreadable — no proof of life is "not live" */
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* First transcript record that carries a cwd, from the file's head only.
|
|
213
|
+
*
|
|
214
|
+
* A transcript can be megabytes; the cwd/gitBranch identity rides on every
|
|
215
|
+
* record, so ~16KB from the front is enough to verify WHOSE session this is
|
|
216
|
+
* without paying to read the conversation. A file whose first cwd-bearing
|
|
217
|
+
* line does not parse (truncated at the window edge) is skipped, not retried
|
|
218
|
+
* deeper — this is presence, not forensics.
|
|
219
|
+
*/
|
|
220
|
+
function firstCwdRecord(file) {
|
|
221
|
+
let fd;
|
|
222
|
+
try {
|
|
223
|
+
fd = openSync(file, 'r');
|
|
224
|
+
const buf = Buffer.alloc(16384);
|
|
225
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
226
|
+
for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
|
|
227
|
+
if (!line.includes('"cwd":"')) continue;
|
|
228
|
+
try {
|
|
229
|
+
const rec = JSON.parse(line);
|
|
230
|
+
if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
|
|
231
|
+
} catch {
|
|
232
|
+
/* an incomplete line at the window edge — try the next candidate */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
} finally {
|
|
239
|
+
if (fd !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
closeSync(fd);
|
|
242
|
+
} catch {
|
|
243
|
+
/* best-effort */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every Claude terminal session belonging to this repo: LIVE ones from the
|
|
251
|
+
* liveness registry, ENDED ones from the transcript store. Returns
|
|
252
|
+
* [{ id, cwd, live, lastActiveAt, branch?, title? }], live first, then newest
|
|
253
|
+
* ended, capped at `cap`, deterministically ordered (so a stringified report
|
|
254
|
+
* only changes when the facts do).
|
|
255
|
+
*
|
|
256
|
+
* The coordinator (localSessions.mjs) hands over the fence: `realRoot`, the
|
|
257
|
+
* `ours(path)` containment test with the daemon's worktrees carved out, the
|
|
258
|
+
* `mine` id set (our own tabs), and the ended-session `cutoff`.
|
|
259
|
+
*/
|
|
260
|
+
export function scanClaudeSessions({ realRoot, ours, mine, cutoff, cap }) {
|
|
261
|
+
const live = [];
|
|
262
|
+
const ended = [];
|
|
263
|
+
try {
|
|
264
|
+
// ── LIVE: the registry, validated pid by pid ─────────────────────────
|
|
265
|
+
const nowIso = new Date().toISOString();
|
|
266
|
+
const liveIds = new Set();
|
|
267
|
+
let regNames = [];
|
|
268
|
+
try {
|
|
269
|
+
regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
|
|
270
|
+
} catch {
|
|
271
|
+
/* no registry — no live sessions */
|
|
272
|
+
}
|
|
273
|
+
for (const name of regNames) {
|
|
274
|
+
if (!name.endsWith('.json')) continue; // .key files ride alongside
|
|
275
|
+
let rec;
|
|
276
|
+
try {
|
|
277
|
+
rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
|
|
282
|
+
if (liveIds.has(rec.sessionId)) continue;
|
|
283
|
+
if (!pidAlive(rec.pid, rec.procStart)) continue;
|
|
284
|
+
let cwd;
|
|
285
|
+
try {
|
|
286
|
+
cwd = realpathSync(rec.cwd);
|
|
287
|
+
} catch {
|
|
288
|
+
continue; // the directory is gone — nothing to point a tab at
|
|
289
|
+
}
|
|
290
|
+
if (!ours(cwd)) continue;
|
|
291
|
+
liveIds.add(rec.sessionId);
|
|
292
|
+
// A live session's title, off its own transcript (the registry `name`
|
|
293
|
+
// is a machine-y fallback like "flowviant-35").
|
|
294
|
+
let liveTitle = null;
|
|
295
|
+
try {
|
|
296
|
+
const liveFile = join(
|
|
297
|
+
homedir(),
|
|
298
|
+
'.claude',
|
|
299
|
+
'projects',
|
|
300
|
+
claudeProjectDir(cwd),
|
|
301
|
+
`${rec.sessionId}.jsonl`
|
|
302
|
+
);
|
|
303
|
+
liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
|
|
304
|
+
} catch {
|
|
305
|
+
/* no transcript yet */
|
|
306
|
+
}
|
|
307
|
+
if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
|
|
308
|
+
if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
|
|
309
|
+
live.push({
|
|
310
|
+
id: rec.sessionId,
|
|
311
|
+
cwd,
|
|
312
|
+
live: true,
|
|
313
|
+
lastActiveAt: nowIso,
|
|
314
|
+
...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
|
|
315
|
+
...(liveTitle ? { title: liveTitle } : {}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
319
|
+
|
|
320
|
+
// ── ENDED: the transcript store, verified file by file ───────────────
|
|
321
|
+
//
|
|
322
|
+
// The munged directory name is a PREFIX match on purpose: a session run in
|
|
323
|
+
// a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
|
|
324
|
+
// But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
|
|
325
|
+
// which is why every candidate is verified against the cwd its own records
|
|
326
|
+
// embed rather than trusted on its directory name.
|
|
327
|
+
const munged = claudeProjectDir(realRoot);
|
|
328
|
+
// A root past Claude's 200-character cut carries a hash of ITSELF, which a
|
|
329
|
+
// subdirectory's name does not share — so the prefix a subdirectory can
|
|
330
|
+
// match is the cut, and the cwd verification below does the rest.
|
|
331
|
+
const mungedStem = munged.length > CLAUDE_DIR_MAX ? munged.slice(0, CLAUDE_DIR_MAX) : `${munged}-`;
|
|
332
|
+
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
333
|
+
let projDirs = [];
|
|
334
|
+
try {
|
|
335
|
+
projDirs = readdirSync(projectsDir);
|
|
336
|
+
} catch {
|
|
337
|
+
/* no transcript store — live sessions still report */
|
|
338
|
+
}
|
|
339
|
+
const candidates = [];
|
|
340
|
+
for (const dirName of projDirs) {
|
|
341
|
+
if (dirName !== munged && !dirName.startsWith(mungedStem)) continue;
|
|
342
|
+
let entries = [];
|
|
343
|
+
try {
|
|
344
|
+
entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
|
|
345
|
+
} catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
for (const ent of entries) {
|
|
349
|
+
if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
|
|
350
|
+
const id = ent.name.slice(0, -'.jsonl'.length);
|
|
351
|
+
if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
|
|
352
|
+
const file = join(projectsDir, dirName, ent.name);
|
|
353
|
+
let mtimeMs;
|
|
354
|
+
try {
|
|
355
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
356
|
+
} catch {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (mtimeMs < cutoff) continue; // an aged session is history, not presence
|
|
360
|
+
candidates.push({ id, file, mtimeMs });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Newest first, then verify only as many as the cap still has room for —
|
|
364
|
+
// the verification read is the expensive step, so it is not spent on
|
|
365
|
+
// sessions the report would drop anyway.
|
|
366
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
|
|
367
|
+
// ONE row per DIRECTORY, newest first, few: twenty sessions in the repo
|
|
368
|
+
// root are one offer — the newest is the one `--resume`'s picker would
|
|
369
|
+
// reach for and the only one worth importing 95% of the time. The rest
|
|
370
|
+
// are scrollback, and the product's own law says scrollback doesn't
|
|
371
|
+
// matter.
|
|
372
|
+
const room = Math.min(ENDED_CAP, Math.max(0, cap - Math.min(live.length, cap)));
|
|
373
|
+
const endedIds = new Set();
|
|
374
|
+
const seenCwds = new Set(live.map((s) => s.cwd));
|
|
375
|
+
for (const cand of candidates) {
|
|
376
|
+
if (ended.length >= room) break;
|
|
377
|
+
if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
|
|
378
|
+
endedIds.add(cand.id);
|
|
379
|
+
const rec = firstCwdRecord(cand.file);
|
|
380
|
+
if (!rec) continue;
|
|
381
|
+
let cwd;
|
|
382
|
+
try {
|
|
383
|
+
cwd = realpathSync(rec.cwd);
|
|
384
|
+
} catch {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (!ours(cwd)) continue;
|
|
388
|
+
/**
|
|
389
|
+
* OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
|
|
390
|
+
* claims the directory — otherwise the daemon's transcript (always the
|
|
391
|
+
* freshest thing in the checkout) took the one slot that directory gets
|
|
392
|
+
* and a genuine terminal session there could never be offered at all.
|
|
393
|
+
*/
|
|
394
|
+
if (mine.has(cand.id)) continue;
|
|
395
|
+
if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
|
|
396
|
+
seenCwds.add(cwd);
|
|
397
|
+
const title = transcriptTitle(cand.file, cand.mtimeMs);
|
|
398
|
+
ended.push({
|
|
399
|
+
id: cand.id,
|
|
400
|
+
cwd,
|
|
401
|
+
live: false,
|
|
402
|
+
lastActiveAt: new Date(cand.mtimeMs).toISOString(),
|
|
403
|
+
...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
|
|
404
|
+
...(title ? { title } : {}),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
/* presence must never throw into the poll loop — report what was gathered */
|
|
409
|
+
}
|
|
410
|
+
return [...live.slice(0, cap), ...ended];
|
|
411
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE CODE'S EVENT STREAM, READ — one line of `--output-format stream-json`
|
|
3
|
+
* into feed activities, the answer text, the init facts and the turn's tokens.
|
|
4
|
+
*
|
|
5
|
+
* Split out of claude.mjs (SOLID 2026-09-26) because it changes when the CLI's
|
|
6
|
+
* event schema changes, which is a different reason from a permission list or
|
|
7
|
+
* a process's lifetime. Codex's and agy's readers, and the Claude tool
|
|
8
|
+
* humanizers this loop calls, live in runtimeEvents.mjs; this is the stream
|
|
9
|
+
* loop Claude's `parse: null` means.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { humanizeClaudeTool, THINK_MARKER } from './runtimeEvents.mjs';
|
|
13
|
+
|
|
14
|
+
// Turn one Claude tool_use into a compact activity {kind, label}, or null for
|
|
15
|
+
// tools not worth surfacing. `kind:'read'` is what the file counter counts; a
|
|
16
|
+
// Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
|
|
17
|
+
// stream exactly which files Claude is touching (daemon console + app cover).
|
|
18
|
+
//
|
|
19
|
+
// The body moved to runtimeEvents.mjs, beside Codex's equivalent, because they are
|
|
20
|
+
// the same job for two vendors and keeping them apart is how the two activity
|
|
21
|
+
// vocabularies drift. Re-exported under its original name: a dozen call sites
|
|
22
|
+
// know it, and none of them care where it lives.
|
|
23
|
+
export const humanizeToolUse = humanizeClaudeTool;
|
|
24
|
+
|
|
25
|
+
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
26
|
+
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
|
|
30
|
+
*
|
|
31
|
+
* The `result` event carries a `usage` object — `input_tokens`,
|
|
32
|
+
* `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
|
|
33
|
+
* and this reads exactly those four and nothing else. A RELAY: nothing here
|
|
34
|
+
* derives, estimates or prices anything.
|
|
35
|
+
*
|
|
36
|
+
* DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
|
|
37
|
+
* notional list price that a subscription operator did not pay, so relaying it
|
|
38
|
+
* as "cost" would be the product asserting a figure nobody was charged — the
|
|
39
|
+
* refusal the board's fix pass already made once against the mock's "1.4
|
|
40
|
+
* spent". Tokens are measured; dollars are not.
|
|
41
|
+
*
|
|
42
|
+
* COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
|
|
43
|
+
* negative reads as 0, because these are summed into a counter that only ever
|
|
44
|
+
* goes up and one bad field must not poison the other three.
|
|
45
|
+
*
|
|
46
|
+
* Returns null when there is no usage object at all — the three-state rule
|
|
47
|
+
* every readout here keeps: a turn that reported nothing charges nothing, and
|
|
48
|
+
* that is not the same as a turn that reported zeros.
|
|
49
|
+
*/
|
|
50
|
+
export function usageFromResult(ev) {
|
|
51
|
+
const u = ev?.usage;
|
|
52
|
+
if (!u || typeof u !== 'object') return null;
|
|
53
|
+
const n = (v) => {
|
|
54
|
+
const x = Number(v);
|
|
55
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
input: n(u.input_tokens),
|
|
59
|
+
output: n(u.output_tokens),
|
|
60
|
+
cacheCreate: n(u.cache_creation_input_tokens),
|
|
61
|
+
cacheRead: n(u.cache_read_input_tokens),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
|
|
66
|
+
// Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
|
|
67
|
+
// daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
|
|
68
|
+
// bursts before/between tools; emitting only tools left long silent gaps).
|
|
69
|
+
// Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
|
|
70
|
+
// sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
|
|
71
|
+
//
|
|
72
|
+
// `answerFromResult` narrows that last part for callers whose `out` IS the
|
|
73
|
+
// answer rather than a haystack to match sentinels in (a Workbench tab's turn):
|
|
74
|
+
// every intermediate text block still NARRATES, but only the final `result`
|
|
75
|
+
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
76
|
+
// they stream and once in the result, and the tab posts the duplicate.
|
|
77
|
+
//
|
|
78
|
+
// EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
|
|
79
|
+
// `onUsage` threading through this function, `runTurn`'s options and `onLine`
|
|
80
|
+
// was four edits and NOT ONE of them was reachable from either suite — delete
|
|
81
|
+
// any one and both stay green, while the only symptom in production is a
|
|
82
|
+
// container that reports no spend, which is indistinguishable from an older
|
|
83
|
+
// daemon by design. A callback that is silent when it breaks has to be called
|
|
84
|
+
// directly by something.
|
|
85
|
+
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
|
|
86
|
+
let ev;
|
|
87
|
+
try {
|
|
88
|
+
ev = JSON.parse(line);
|
|
89
|
+
} catch {
|
|
90
|
+
appendText(line + '\n');
|
|
91
|
+
emit(line + '\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const push = (a) => {
|
|
95
|
+
if (!a || !a.label) return;
|
|
96
|
+
emit(a.label + '\n');
|
|
97
|
+
onActivity?.(a);
|
|
98
|
+
};
|
|
99
|
+
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
|
|
100
|
+
for (const b of ev.message.content) {
|
|
101
|
+
if (b.type === 'thinking' || b.type === 'redacted_thinking') {
|
|
102
|
+
/**
|
|
103
|
+
* NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
|
|
104
|
+
*
|
|
105
|
+
* This used to say the text is "usually redacted", which was a guess
|
|
106
|
+
* doing the work of a fact. The fact: across three real transcripts, 93
|
|
107
|
+
* thinking blocks, EVERY ONE of them carried an empty `thinking` — and
|
|
108
|
+
* a live probe with MAX_THINKING_TOKENS set and
|
|
109
|
+
* `--include-partial-messages` on returned an empty `thinking_delta`
|
|
110
|
+
* and a complete block of length zero (signature only). So the CLI
|
|
111
|
+
* emits the FACT that it reasoned and not a word of the reasoning, and
|
|
112
|
+
* "show the full thinking" cannot be conjured from this stream.
|
|
113
|
+
*
|
|
114
|
+
* THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
|
|
115
|
+
* whole point: when text is present it rides `full` UNCLIPPED, so the
|
|
116
|
+
* day a CLI release starts emitting it, the trace carries the thought
|
|
117
|
+
* whole with no daemon change and no version floor — the report's own
|
|
118
|
+
* presence is the capability. Until then every block takes the marker
|
|
119
|
+
* arm, and `trace.mjs` collapses a run of identical markers so the
|
|
120
|
+
* absence reads as one quiet step rather than forty.
|
|
121
|
+
*/
|
|
122
|
+
push(
|
|
123
|
+
b.thinking
|
|
124
|
+
? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
|
|
125
|
+
: { kind: 'think', label: THINK_MARKER }
|
|
126
|
+
);
|
|
127
|
+
} else if (b.type === 'text' && b.text?.trim()) {
|
|
128
|
+
if (!answerFromResult) appendText(b.text + '\n');
|
|
129
|
+
// `label` for the console and the pulse — one collapsed 160-char line,
|
|
130
|
+
// byte-identical to what it has always printed. `full` for the trace:
|
|
131
|
+
// a `say` is the agent NARRATING, several sentences at a time, and the
|
|
132
|
+
// clip at 160 was landing mid-sentence on the one thing a person opens
|
|
133
|
+
// the page to read.
|
|
134
|
+
push({ kind: 'say', label: oneLine(b.text), full: b.text });
|
|
135
|
+
} else if (b.type === 'tool_use') {
|
|
136
|
+
push(humanizeToolUse(b.name, b.input || {}, cwd));
|
|
137
|
+
// The STRUCTURED form of the same event, for the transcript's tool
|
|
138
|
+
// cards — raw name + input, so the collector can keep what the
|
|
139
|
+
// one-line humanizer drops (an Edit's counts, the plan's items).
|
|
140
|
+
onToolEvent?.(b.name, b.input || {});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else if (ev.type === 'system' && ev.subtype === 'init') {
|
|
144
|
+
// WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
|
|
145
|
+
// CLI's OWN answer — it has already resolved personal skills, this repo's
|
|
146
|
+
// skills, plugins and whatever the project settings enable or disable — so
|
|
147
|
+
// reading it costs nothing and cannot drift the way a `~/.claude/skills`
|
|
148
|
+
// scan of our own would. `skills` (rather than `slash_commands`) is the
|
|
149
|
+
// deliberate narrowing: the 50-odd commands beside it are the CLI's own
|
|
150
|
+
// interactive furniture (/clear, /model, /compact), and offering those in a
|
|
151
|
+
// relayed tab would be an offer wired to nothing.
|
|
152
|
+
//
|
|
153
|
+
// Only ever REPORTED, never enforced. Flowviant does not decide what your
|
|
154
|
+
// Claude can do; it relays what your Claude said it has.
|
|
155
|
+
// `sessionId` rides along for one reason: a `-p` turn WRITES a transcript,
|
|
156
|
+
// and `claudeSessions.mjs` offers the newest ended session per directory as
|
|
157
|
+
// ADOPTABLE — so any headless turn we run for our own purposes would leave
|
|
158
|
+
// a phantom untitled session in the `+` menu. The caller that needs to
|
|
159
|
+
// clean up after itself cannot do so without this id.
|
|
160
|
+
//
|
|
161
|
+
// `mcpServers` (0.97.0) is the same kind of fact from the same event: the
|
|
162
|
+
// MCP servers and claude.ai connectors the CLI mounted, each with its own
|
|
163
|
+
// status (`needs-auth` above all — a sign-in that has to happen at this
|
|
164
|
+
// box). Relayed raw; `recordMcpServers` (runtimeCapabilities.mjs) normalises it and
|
|
165
|
+
// drops this daemon's own `flowviant` server.
|
|
166
|
+
if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
|
|
167
|
+
onInit?.({
|
|
168
|
+
skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
|
|
169
|
+
sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
|
|
170
|
+
mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} else if (ev.type === 'result') {
|
|
174
|
+
/**
|
|
175
|
+
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
176
|
+
*
|
|
177
|
+
* FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
|
|
178
|
+
* hit a limit, ran out of permission or aborted still sent the requests it
|
|
179
|
+
* sent, and a spend readout that quietly skipped every unhappy turn would
|
|
180
|
+
* under-report exactly the runs somebody is looking at the number to
|
|
181
|
+
* understand. `usageFromResult` returns null when the event carries no
|
|
182
|
+
* usage at all, and a caller that passes no `onUsage` sees no change.
|
|
183
|
+
*/
|
|
184
|
+
const usage = usageFromResult(ev);
|
|
185
|
+
if (usage) onUsage?.(usage);
|
|
186
|
+
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
187
|
+
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
188
|
+
else if (ev.is_error || ev.subtype) {
|
|
189
|
+
// A result that carries no text is a FAILED turn (a limit, a refused
|
|
190
|
+
// permission, an aborted run). Under `answerFromResult` this is the only
|
|
191
|
+
// stdout that would have said so, and a caller whose `out` is the answer
|
|
192
|
+
// must not report "no output" for a turn that explained itself.
|
|
193
|
+
// `errors[]` FIRST, because it is where the real sentence is. Claude
|
|
194
|
+
// Code reports a dead `--resume` id as
|
|
195
|
+
// `{subtype:'error_during_execution', errors:['No conversation found
|
|
196
|
+
// with session ID: …']}` — reading only `error`/`subtype` dropped that
|
|
197
|
+
// and appended the literal string `error_during_execution`, which told
|
|
198
|
+
// the driver nothing and hid the one phrase the caller needs to
|
|
199
|
+
// recognise a lost conversation.
|
|
200
|
+
const listed = Array.isArray(ev.errors)
|
|
201
|
+
? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
|
|
202
|
+
: '';
|
|
203
|
+
const msg = listed || ev.error?.message || ev.error || ev.subtype;
|
|
204
|
+
appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI-limit trigger — whether a CLI's output says the account hit a
|
|
3
|
+
* usage limit, relayed in the CLI's own words. Split out of
|
|
4
|
+
* workAgentReview.mjs (SOLID F039) because two lanes read it (the agent turn,
|
|
5
|
+
* which parks on it, and the pre-review, which only skips) and neither owns
|
|
6
|
+
* it: it is a fact about what the CLIs print.
|
|
7
|
+
*/
|
|
8
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DID THE ACCOUNT HIT A LIMIT?
|
|
12
|
+
*
|
|
13
|
+
* Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
|
|
14
|
+
* and the matched line is relayed VERBATIM. It is a trigger, not a
|
|
15
|
+
* classifier: nothing here decides what an error "means" or writes a sentence
|
|
16
|
+
* of its own, because the product's own rule is that it relays and never
|
|
17
|
+
* infers. The honest limit is that a phrasing nobody listed reads as an
|
|
18
|
+
* ordinary failed turn — which lands the agent in Stuck with the CLI's words
|
|
19
|
+
* attached, and is a perfectly survivable second-best.
|
|
20
|
+
*/
|
|
21
|
+
const LIMIT_PHRASES = [
|
|
22
|
+
/usage limit reached/i,
|
|
23
|
+
/rate limit/i,
|
|
24
|
+
/you've reached your .* limit/i,
|
|
25
|
+
/quota exceeded/i,
|
|
26
|
+
/insufficient_quota/i,
|
|
27
|
+
];
|
|
28
|
+
export const limitLine = (text) => {
|
|
29
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|