flowviant 0.125.6 → 0.125.7

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.
@@ -168,9 +168,14 @@ export function claudeMcpInventoryOf(list, tools) {
168
168
  for (const e of list) {
169
169
  if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
170
170
  const name = e.name.trim().slice(0, MCP_NAME_MAX).trim();
171
- if (!name || name === 'flowviant' || seen.has(name)) continue;
171
+ const scope = typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null;
172
+ // The daemon's own `flowviant` rides `--mcp-config`, reported `dynamic`.
173
+ // One in the person's own config is theirs (2026-10-17, the owner) and is
174
+ // listed, so a revoked token there reads failed (2026-10-19). An
175
+ // unscoped report cannot say which, and stays hidden.
176
+ if (!name || name === 'flowviant' && (scope === 'dynamic' || scope == null) || seen.has(name)) continue;
172
177
  const status = MCP_STATUSES.has(e.status) ? e.status : 'other';
173
- seen.set(name, { name, status, scope: typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
178
+ seen.set(name, { name, status, scope, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
174
179
  }
175
180
  // The longest prefix wins, so `a_b` never claims `a`'s tools or the reverse.
176
181
  const byPrefix = [...seen.values()].sort((a, b) => b.prefix.length - a.prefix.length);
@@ -19,6 +19,15 @@ import { machineMcpServers } from './claudeMachineMcp.mjs';
19
19
  import { claudePermFor, withMachineMcp } from './claudePosture.mjs';
20
20
  import { withSettings } from './claudeSettingsArg.mjs';
21
21
  import { orchestratorHooks } from './orchestratorHooks.mjs';
22
+ import { versionAtLeast } from './versionOrder.mjs';
23
+
24
+ /** The release the agent lane first measured `--system-prompt-snapshot` on
25
+ * (decisions.md, 2026-09-24). */
26
+ export const SYSTEM_PROMPT_SNAPSHOT_MIN = '2.1.281';
27
+ /** `--version` prints "2.1.303 (Claude Code)"; the first word is the version. */
28
+ export function claudeSnapshotFlagKnown(version) {
29
+ return versionAtLeast(String(version ?? '').trim().split(/\s+/)[0], SYSTEM_PROMPT_SNAPSHOT_MIN);
30
+ }
22
31
 
23
32
  function claudeMcp(token, mcpUrl) {
24
33
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -113,7 +122,7 @@ export const CLAUDE_RUNTIME = {
113
122
  * strongest form of it available anywhere: `--append-system-prompt` sits
114
123
  * above the conversation rather than inside it.
115
124
  */
116
- args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false }) {
125
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, claudeVersion = null }) {
117
126
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
118
127
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
119
128
  // with no vault, or one no rule can name, has no list — refused here in
@@ -191,10 +200,18 @@ export const CLAUDE_RUNTIME = {
191
200
  // states `default` itself, unless its own list names a mode (plan-mode).
192
201
  if (fenced && !perm.includes('--permission-mode')) a.push('--permission-mode', 'default');
193
202
  else if (agentTools) a.push('--setting-sources', 'user');
194
- if (agentTools) {
195
- a.push('--system-prompt-snapshot', 'off');
196
- if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
197
- }
203
+ // EVERY TURN RENDERS ITS OWN SYSTEM PROMPT (2026-10-19). By default the
204
+ // CLI records a conversation's first system prompt and resends that record
205
+ // on every resume "even when a later launch passes different text" —
206
+ // measured offline on 2.1.303: a resumed turn appended BETA and the model
207
+ // was sent the first turn's ALPHA. A tab's appended prompt moves between
208
+ // turns (plan mode on and off, the knowledge and artifact paragraphs), so
209
+ // a tab switched out of plan mode kept being told to change nothing. An
210
+ // agent turn always passed this (its current base instructions). A CLI too
211
+ // old to name the flag refuses it, so an unmeasured version keeps the old
212
+ // argv.
213
+ if (agentTools || claudeSnapshotFlagKnown(claudeVersion)) a.push('--system-prompt-snapshot', 'off');
214
+ if (agentTools?.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
198
215
  // WHICH MCP SERVERS A TURN MOUNTS. An agent's code, write-up and
199
216
  // mockup/3D-model/deck turns (2026-10-11, the owner) mount this machine's
200
217
  // own — its user and local servers and the account's claude.ai connectors —
@@ -173,6 +173,9 @@ const codexWikiFence = (vaultDir) => [
173
173
  ];
174
174
 
175
175
  function codexPrompt({ system, prompt, profile = 'build' }) {
176
+ // A resumed tab whose thread already holds the contract sends none
177
+ // (codexContract.mjs): the message alone.
178
+ if (!system) return prompt;
176
179
  const readers = profile === 'consult' || profile === 'plan'
177
180
  ? '\nUse exec_command with shell="/bin/bash" and login=false for project discovery. Project files, local Git history and the project knowledge directory are readable; host files are outside this turn’s read boundary.'
178
181
  : '';
@@ -96,7 +96,9 @@ function commandFound(command, env) {
96
96
  export function codexServerRows(servers, env = process.env) {
97
97
  const rows = [];
98
98
  for (const s of servers) {
99
- if (!s || typeof s.name !== 'string' || !nameOk(s.name)) continue;
99
+ // `mcp list` reads the person's own config; the daemon's `flowviant`
100
+ // rides `-c` and is never in it, so one of that name is theirs.
101
+ if (!s || typeof s.name !== 'string' || !(nameOk(s.name) || s.name === 'flowviant')) continue;
100
102
  const t = s.transport && typeof s.transport === 'object' ? s.transport : {};
101
103
  const auth = typeof s.auth_status === 'string' ? s.auth_status : '';
102
104
  let status = 'configured';
@@ -208,6 +208,16 @@ export async function resolveStartProject({ dirGiven }) {
208
208
  `or connect this repo to its own project with \`${terminalCommand('login')}\`.`
209
209
  );
210
210
  process.exit(1);
211
+ } else if (interactive && !CREDENTIAL.choices?.length) {
212
+ // NOTHING STORED AND A PERSON AT THE TERMINAL IS A FIRST CONNECTION
213
+ // (2026-10-19). The app's connect command ends in a bare `flowviant`
214
+ // and the README promises "approve the code in Flowviant → connected",
215
+ // yet this branch printed "no credential found" and exited 1 — so the
216
+ // one command every first connection runs failed after a successful
217
+ // install. Log in here, as the picker's connect row does; headless
218
+ // still refuses in the words below, because nobody can approve a code.
219
+ console.log('No project is connected on this machine yet — connecting one now.');
220
+ await reexecAfterLogin();
211
221
  } else {
212
222
  console.error(
213
223
  'error: no credential found. Easiest:\n' +
@@ -39,7 +39,7 @@ import { platform, arch } from 'node:process';
39
39
  import { pipeline } from 'node:stream/promises';
40
40
  import { Readable } from 'node:stream';
41
41
  import { relaunchSelf, runningCompiledBinary, runningViaNpx, selfArgv, terminalCommand } from './launchCommand.mjs';
42
- import { VERSION } from './config.mjs';
42
+ import { DAEMON_INSTANCE, VERSION } from './config.mjs';
43
43
  import { compareVersions, versionBelow } from './versionOrder.mjs';
44
44
  import { note, ok, warn } from './ui.mjs';
45
45
 
@@ -194,6 +194,9 @@ export function reexec(teardown, { viaNpx = false, target = null } = {}, deps =
194
194
  // as a rival and the new daemon SIGTERMed this proxy. instance.mjs adopts
195
195
  // the lock from this pid when it is really an ancestor of the successor.
196
196
  FLOWVIANT_REEXEC_FROM: String(process.pid),
197
+ // WHO THE SUCCESSOR IS, to the server: this process's own instance, so
198
+ // the idle tabs' session leases are already its own (config.mjs).
199
+ FLOWVIANT_DAEMON_INSTANCE: DAEMON_INSTANCE,
197
200
  // WHAT WE RESTARTED IN ORDER TO BECOME. The successor compares its own
198
201
  // VERSION against this: if it came back still short, the update did not
199
202
  // take (a registry serving a stale `latest`, an npx cache that refused to
@@ -223,9 +226,20 @@ export function updateRestartFailed(target) {
223
226
  return nowVsAttempted === -1 && targetVsAttempted !== null && targetVsAttempted <= 0;
224
227
  }
225
228
 
226
- /** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
227
- function installLatest() {
228
- execFileSync('npm', ['install', '-g', 'flowviant@latest'], { ...npmOutsideCheckout(), stdio: 'inherit' });
229
+ /**
230
+ * HOW LONG A GLOBAL INSTALL MAY TAKE (2026-10-19). The install is synchronous
231
+ * — it holds the event loop for as long as npm runs — and it had no bound, so
232
+ * a registry or a lock that hung npm hung the whole daemon: no poll, no wake,
233
+ * no turn, on a machine that read as connected. Five minutes is far past a
234
+ * real install; past it npm is killed and the failure is the usual one,
235
+ * retried in fifteen minutes.
236
+ */
237
+ export const INSTALL_TIMEOUT_MS = 5 * 60_000;
238
+
239
+ /** Install @latest globally. Throws on failure (EACCES without sudo, offline,
240
+ * past `INSTALL_TIMEOUT_MS`…). `exec` and `timeoutMs` are the test seams. */
241
+ export function installLatest({ exec = execFileSync, timeoutMs = INSTALL_TIMEOUT_MS } = {}) {
242
+ exec('npm', ['install', '-g', 'flowviant@latest'], { ...npmOutsideCheckout(), stdio: 'inherit', timeout: timeoutMs, killSignal: 'SIGKILL' });
229
243
  }
230
244
 
231
245
  /**
package/bin/lib/work.mjs CHANGED
@@ -250,7 +250,7 @@ export function createWorkManager({
250
250
  flushWorkReports,
251
251
  sweepMergedSessionBranch,
252
252
  } = createWorkReportQueue({
253
- repoRoot,
253
+ repoRoot, journalDir: baseDir,
254
254
  baseRef,
255
255
  workAttempts,
256
256
  artifacts,
@@ -261,7 +261,7 @@ export function createWorkManager({
261
261
  });
262
262
  const { workTokens, mintWorkToken } = createWorkSessionTokens({ getLeaseTtl });
263
263
  const { makeNarrator, sayTurnDeferred, lastDeferSaid } = createWorkNarration();
264
- const { fetchAttachments, fetchAgentFiles } = createWorkAttachments();
264
+ const { fetchTabFiles, fetchAgentFiles } = createWorkAttachments();
265
265
  const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
266
266
  const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
267
267
  const { processWorkTurns, interruptWorkTurns, workAnswering } = createWorkSessionTurns({
@@ -287,7 +287,7 @@ export function createWorkManager({
287
287
  sessionRuntime,
288
288
  agyRegistryLookup,
289
289
  carryDirtyState,
290
- fetchAttachments,
290
+ fetchTabFiles,
291
291
  makeNarrator,
292
292
  sayTurnDeferred,
293
293
  lastDeferSaid,
@@ -72,6 +72,7 @@ export function createAgentTurnExecution({
72
72
  agentChildren,
73
73
  postAgentTrace,
74
74
  postAgentActivity,
75
+ postAgentContext = () => {},
75
76
  postAgentParked,
76
77
  fetchAgentFiles,
77
78
  makeLiveMessages = createLiveMessages,
@@ -465,6 +466,25 @@ export function createAgentTurnExecution({
465
466
  ...brain,
466
467
  answerFromResult: true,
467
468
  label: c.cyan('[agent]'),
469
+ // The CLI's own context measure while the turn runs (claudeInput.mjs),
470
+ // the latest one at a time; the server clears it at turn end.
471
+ onContext: (() => {
472
+ let next = null;
473
+ let busy = false;
474
+ return (ctx) => {
475
+ next = ctx;
476
+ if (busy) return;
477
+ busy = true;
478
+ void (async () => {
479
+ while (next) {
480
+ const measured = next;
481
+ next = null;
482
+ await postAgentContext(agentId, turnId, measured);
483
+ }
484
+ busy = false;
485
+ })();
486
+ };
487
+ })(),
468
488
  // The CLI's own tail, relayed. Throttled by the same rule the tab's
469
489
  // narrator keeps: overwritten, never appended, and ignored by the
470
490
  // board past ~90 seconds.
@@ -114,6 +114,21 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
114
114
  }
115
115
  };
116
116
 
117
+ /** The CLI's context measure for a running turn (2026-10-19); the server
118
+ * drops one whose turn already settled. Best effort, like the line. */
119
+ const postAgentContext = async (agentId, turnId, context) => {
120
+ try {
121
+ await fetch(AGENT_ACTIVITY_URL, {
122
+ method: 'POST',
123
+ headers: fleetHeaders(),
124
+ signal: AbortSignal.timeout(15_000),
125
+ body: JSON.stringify({ agentId, turnId, context }),
126
+ });
127
+ } catch {
128
+ /* a readout; the next measurement replaces it */
129
+ }
130
+ };
131
+
117
132
  const postAgentParked = async (reason, runtime) => {
118
133
  try {
119
134
  await fetch(AGENT_PARKED_URL, {
@@ -153,6 +168,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
153
168
  postAgentTurn,
154
169
  postAgentTrace,
155
170
  postAgentActivity,
171
+ postAgentContext,
156
172
  postAgentParked,
157
173
  pruneHeldReports,
158
174
  agentReported,
@@ -27,7 +27,7 @@ import { createAgentTurnReports } from './workAgentTurnReports.mjs';
27
27
  import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
28
28
  import { failedTurnSettlement, interruptedTurnSettlement } from './workAgentTurnOutcome.mjs';
29
29
  import { turnCommits } from './turnReceipts.mjs';
30
- import { killGroup } from './processGroup.mjs';
30
+ import { interruptThenKill } from './processGroup.mjs';
31
31
  import { createDeliveryReporter } from './workDelivery.mjs';
32
32
  import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
33
33
 
@@ -64,6 +64,7 @@ export function createWorkAgentTurns({
64
64
  postAgentTurn,
65
65
  postAgentTrace,
66
66
  postAgentActivity,
67
+ postAgentContext,
67
68
  postAgentParked,
68
69
  pruneHeldReports,
69
70
  agentReported,
@@ -135,6 +136,7 @@ export function createWorkAgentTurns({
135
136
  agentChildren,
136
137
  postAgentTrace,
137
138
  postAgentActivity,
139
+ postAgentContext,
138
140
  postAgentParked,
139
141
  fetchAgentFiles,
140
142
  makeLiveMessages,
@@ -434,7 +436,7 @@ export function createWorkAgentTurns({
434
436
  run.interrupted = true;
435
437
  run.abortSetup?.();
436
438
  const child = agentChildren.get(run.place);
437
- if (child) killGroup(child);
439
+ if (child) interruptThenKill(child);
438
440
  continue;
439
441
  }
440
442
  if (agentReported.has(id) || notRunningSaid.has(id)) continue;
@@ -54,6 +54,8 @@ export function createWorkAttachments() {
54
54
  * doing that check is one deploy away from being zero places.
55
55
  */
56
56
  const UPLOAD_DIR = '.flowviant/uploads';
57
+ /** The pause before a file fetch's one retry. */
58
+ const RETRY_FETCH_MS = 1_000;
57
59
  // `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
58
60
  // home): THE EXTENSION SURVIVES A CUT, and it is idempotent over its own
59
61
  // output, so re-applying it to what the server already sanitized is a no-op
@@ -151,10 +153,17 @@ export function createWorkAttachments() {
151
153
  if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
152
154
  if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
153
155
  try {
154
- const res = await fetch(`${FILE_URL[endpoint]}/${a.id}`, {
156
+ // ONE MORE TRY for a blip (2026-10-19): a dropped connection or a 5xx
157
+ // cost the file outright. A 4xx is the server's answer and stands.
158
+ const get = () => fetch(`${FILE_URL[endpoint]}/${a.id}`, {
155
159
  headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
156
160
  signal: AbortSignal.timeout(60_000),
157
161
  });
162
+ let res = await get().catch(() => null);
163
+ if (!res || res.status >= 500) {
164
+ await new Promise((r) => setTimeout(r, RETRY_FETCH_MS));
165
+ res = await get();
166
+ }
158
167
  if (!res.ok) continue;
159
168
  const buf = Buffer.from(await res.arrayBuffer());
160
169
  if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
@@ -187,12 +196,25 @@ export function createWorkAttachments() {
187
196
  return landed;
188
197
  };
189
198
 
190
- /** @returns relative paths written, in the order the human attached them. */
191
- const fetchAttachments = async (wt, attachments) => {
199
+ /**
200
+ * A TAB MESSAGE'S FILES, each `{ path }` landed or `{ missed: name }`
201
+ * (2026-10-19). The Terminal listed only what arrived, on the argument that
202
+ * its person is watching and re-attaches — but the person sees the file's
203
+ * chip on their message whether it arrived or not, and a CLI told nothing
204
+ * answered as if it had read it. Named, a miss is said in the reply, as the
205
+ * agent lane's always were (`fetchAgentFiles`).
206
+ */
207
+ const fetchTabFiles = async (wt, attachments) => {
192
208
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
193
- return (await landFiles(wt, attachments.slice(0, 8), 'attachment', false)).filter(Boolean);
209
+ const list = attachments.slice(0, 8);
210
+ const landed = await landFiles(wt, list, 'attachment', false);
211
+ return list.map((a, i) => (landed[i] ? { path: landed[i] } : { missed: safeUploadName(a?.name) }));
194
212
  };
195
213
 
214
+ /** @returns relative paths written, in the order the human attached them. */
215
+ const fetchAttachments = async (wt, attachments) =>
216
+ (await fetchTabFiles(wt, attachments)).flatMap((f) => (f.path ? [f.path] : []));
217
+
196
218
  /**
197
219
  * AN AGENT TURN'S FILES (0.112.0), read off the wire (agentFiles.mjs: each
198
220
  * origin at the server's cap, message files first) and fetched from
@@ -218,5 +240,5 @@ export function createWorkAttachments() {
218
240
  return out;
219
241
  };
220
242
 
221
- return { fetchAttachments, fetchAgentFiles };
243
+ return { fetchAttachments, fetchTabFiles, fetchAgentFiles };
222
244
  }
@@ -82,7 +82,39 @@ export function createWorkNarration() {
82
82
  }, wait);
83
83
  timer.unref?.(); // never hold the process open for a spinner
84
84
  };
85
+ // THE CONTEXT READOUT rides the same door with its turn (2026-10-19):
86
+ // the latest measurement only, one POST at a time, never a turn's failure.
87
+ // The server writes no narration line for a context post.
88
+ let ctxPending = null;
89
+ let ctxSending = false;
90
+ const sendContext = async () => {
91
+ ctxSending = true;
92
+ while (ctxPending && !stopped) {
93
+ const context = ctxPending;
94
+ ctxPending = null;
95
+ try {
96
+ await fetch(ACTIVITY_URL, {
97
+ method: 'POST',
98
+ headers: {
99
+ Authorization: `Bearer ${FLEET_TOKEN}`,
100
+ 'User-Agent': USER_AGENT,
101
+ 'Content-Type': 'application/json',
102
+ },
103
+ signal: AbortSignal.timeout(10_000),
104
+ body: JSON.stringify({ sessionId, turnId, lines: [], context }),
105
+ });
106
+ } catch {
107
+ /* a readout is decoration, like the line */
108
+ }
109
+ }
110
+ ctxSending = false;
111
+ };
85
112
  return {
113
+ context(measured) {
114
+ if (!turnId || stopped || !measured) return;
115
+ ctxPending = measured;
116
+ if (!ctxSending) void sendContext();
117
+ },
86
118
  line(label) {
87
119
  // Scrub, like every string that leaves this machine: a narration line
88
120
  // is the CLI's own stdout — a command echoing an env var, a read of a
@@ -19,6 +19,7 @@ import { sweepMergedBranch } from './shipSweep.mjs';
19
19
  import { note } from './ui.mjs';
20
20
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
21
21
  import { fleetRequest } from './fleetPost.mjs';
22
+ import { createReportJournal } from './reportJournal.mjs';
22
23
 
23
24
  export function createWorkReportQueue({
24
25
  repoRoot,
@@ -29,6 +30,9 @@ export function createWorkReportQueue({
29
30
  onRepoChanged,
30
31
  reportPlaceWorktrees,
31
32
  burstListeners,
33
+ /** Where the queue survives a restart (`reportJournal.mjs`); absent, the
34
+ * queue lives in memory only, as it did before 2026-10-19. */
35
+ journalDir = null,
32
36
  }) {
33
37
  const WORK_DONE_URL = fleetEndpoint('work-turn-done', FLEET_URL);
34
38
  const SHIP_DONE_URL = fleetEndpoint('ship-done', FLEET_URL);
@@ -43,6 +47,16 @@ export function createWorkReportQueue({
43
47
  */
44
48
  const pendingWorkReports = new Map(); // turnId -> work-turn-done body
45
49
  const pendingShipReports = new Map(); // sessionId -> ship-done body
50
+ // …AND ON DISK, so a restart between the answer and the 200 delivers the
51
+ // answer rather than re-running the turn (reportJournal.mjs). Seeded once;
52
+ // the first poll's flush delivers what a previous process left.
53
+ const journal = journalDir ? createReportJournal(journalDir) : null;
54
+ if (journal) {
55
+ const kept = journal.load();
56
+ for (const [id, body] of kept.work) pendingWorkReports.set(id, body);
57
+ for (const [id, body] of kept.ship) pendingShipReports.set(id, body);
58
+ }
59
+ const persist = () => journal?.save(pendingWorkReports, pendingShipReports);
46
60
  /** POST a settle body. Four outcomes, and the split between the last two is
47
61
  * load-bearing:
48
62
  * - 'ok' — delivered.
@@ -142,10 +156,13 @@ export function createWorkReportQueue({
142
156
  const deliver = async (q, id, body) => {
143
157
  const r = await postSettle(q.url, body, q.terminal);
144
158
  if (r === 'retry' || r === 'reject') {
159
+ const fresh = !q.pending.has(id);
145
160
  q.pending.set(id, body);
161
+ if (fresh) persist();
146
162
  if (r === 'reject') reportBackoff.set(backoffKey(q, id), Date.now() + REJECT_RETRY_MS);
147
163
  } else {
148
- q.pending.delete(id);
164
+ const held = q.pending.delete(id);
165
+ if (held) persist();
149
166
  reportBackoff.delete(backoffKey(q, id));
150
167
  q.delivered(id);
151
168
  }
@@ -19,7 +19,7 @@
19
19
  import { existsSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
20
  import { git } from './git.mjs';
21
21
  import { measureRepositoryBaseline } from './repositoryBaseline.mjs';
22
- import { REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
22
+ import { REPOSITORY_BASELINE_PARAGRAPH, tabFilesNote } from './prompts.mjs';
23
23
  import { processStartTime } from './procRegistry.mjs';
24
24
  import { c, note, ok, warn } from './ui.mjs';
25
25
  import { mcpFor } from './claude.mjs';
@@ -31,10 +31,11 @@ import { isTerminalSessionLive } from './claudeSessions.mjs';
31
31
  import { isAgyConversationLive } from './agySessions.mjs';
32
32
  import { adoptFence } from './localSessions.mjs';
33
33
  import { resumeRetriesFresh } from './resumeLost.mjs';
34
+ import { CODEX_CONTRACT_MARKER, codexContractKnown, recordCodexContract } from './codexContract.mjs';
34
35
  import { turnLockedByLivePid } from './turnLock.mjs';
35
36
  import { createDeliveryReporter } from './workDelivery.mjs';
36
37
  import { createLiveMessages } from './workLiveMessages.mjs';
37
- import { killGroup } from './processGroup.mjs';
38
+ import { interruptThenKill, killGroup } from './processGroup.mjs';
38
39
  import { printable } from './printable.mjs';
39
40
  import { brainFor } from './workBrain.mjs';
40
41
  import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
@@ -76,6 +77,9 @@ export function createWorkSessionTurns({
76
77
  agyRegistryLookup,
77
78
  carryDirtyState,
78
79
  fetchAttachments,
80
+ // Each file landed or named as missed (workAttachments.mjs); a caller that
81
+ // hands only `fetchAttachments` (an older test) has its paths as landed.
82
+ fetchTabFiles = async (wt, attachments) => (await fetchAttachments(wt, attachments)).map((path) => ({ path })),
79
83
  makeNarrator,
80
84
  sayTurnDeferred,
81
85
  lastDeferSaid,
@@ -654,6 +658,17 @@ export function createWorkSessionTurns({
654
658
  // refused for want of an approval, by `server.tool`.
655
659
  let codexSpoke = false;
656
660
  const approvalRefused = [];
661
+ // WHAT THE CLI SAID OF ITS OWN RUN (2026-10-19): a failed result or
662
+ // error event, or an exit that was not a clean one. A usage limit, a
663
+ // signed-out CLI or a crash printed its words and settled as the
664
+ // tab's REPLY; they settle as the failure they are now, in the same
665
+ // words. Reset for the fresh retry, which is its own run.
666
+ let cliFailed = false;
667
+ let badExit = null;
668
+ // A Codex tab's contract and whether this turn sent it (codexContract.mjs).
669
+ const contractPath = rt.id === 'codex' ? sessionMetaPath(dir.wt, CODEX_CONTRACT_MARKER, job.sessionId) : null;
670
+ let turnSystem = '';
671
+ let contractSent = true;
657
672
  const spawned = []; // this turn's children, for the teardown registry
658
673
  // THE TURN'S TOOL LOG — see workToolLog.mjs for its shape rules.
659
674
  const turnLog = createToolLog(dir.wt);
@@ -675,12 +690,7 @@ export function createWorkSessionTurns({
675
690
  // Files first, then the message that references them: the agent
676
691
  // must be able to open what it is being told about. Only the ones
677
692
  // that actually landed are named.
678
- const files = await fetchAttachments(dir.wt, job.attachments);
679
- const filesNote = files.length
680
- ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
681
- .map((f) => `- ${f}`)
682
- .join('\n')}`
683
- : '';
693
+ const filesNote = tabFilesNote(await fetchTabFiles(dir.wt, job.attachments));
684
694
  const message = [job.body, filesNote, adoptNote, carryNote]
685
695
  .filter(Boolean)
686
696
  .join('\n\n');
@@ -689,8 +699,7 @@ export function createWorkSessionTurns({
689
699
  scope: 'session', turnId: job.id, attempt: job.attempt, cwd: dir.wt, sessionMetaPath,
690
700
  interrupted: () => run.interrupted,
691
701
  format: async (followup) => {
692
- const paths = await fetchAttachments(dir.wt, followup.attachments);
693
- const note = paths.length ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${paths.map((f) => `- ${f}`).join('\n')}` : '';
702
+ const note = tabFilesNote(await fetchTabFiles(dir.wt, followup.attachments));
694
703
  const words = shape.capture ? (followup.libraryPrompt ?? '') + followup.body : followup.body + (followup.libraryPrompt ?? '');
695
704
  return sessionTurnKickoff(shape, { sessionId: job.sessionId, sessionName: job.sessionName,
696
705
  askedByName: followup.askedByName, message: [words, note].filter(Boolean).join('\n\n') });
@@ -728,11 +737,18 @@ export function createWorkSessionTurns({
728
737
  // artifacts paragraph, the planning sentence appended). The
729
738
  // artifacts paragraph also waits on the server — ARTIFACTS
730
739
  // (0.94.0): only while it can show one, read here at spawn.
731
- system: sessionTurnSystem(shape, {
732
- knowledgeDir,
733
- artifactsAccepted: getArtifactsAccepted(),
734
- runtime: rt.id,
735
- }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
740
+ // …and a Codex thread that already holds it is sent none
741
+ // (codexContract.mjs); `turnSystem` keeps the text for a
742
+ // fresh retry and the marker.
743
+ system: (() => {
744
+ turnSystem = sessionTurnSystem(shape, {
745
+ knowledgeDir,
746
+ artifactsAccepted: getArtifactsAccepted(),
747
+ runtime: rt.id,
748
+ }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : '');
749
+ contractSent = !codexContractKnown(contractPath, codexResumeId, turnSystem);
750
+ return contractSent ? turnSystem : '';
751
+ })(),
736
752
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
737
753
  // told it may read there (`--add-dir`) rather than left to
738
754
  // refuse a read-only capture turn a path it was just handed.
@@ -774,6 +790,15 @@ export function createWorkSessionTurns({
774
790
  onModel: (m) => {
775
791
  model = m;
776
792
  },
793
+ // The CLI's own context measure, live while the turn runs
794
+ // (claudeInput.mjs); the server clears it at settle.
795
+ onContext: (ctx) => narrator.context(ctx),
796
+ onCliFailure: () => {
797
+ cliFailed = true;
798
+ },
799
+ onExit: (code, signal) => {
800
+ badExit = signal || (code ? `exit ${code}` : null);
801
+ },
777
802
  // What this CLI says it can be asked for by name, and which of
778
803
  // its connectors need a sign-in, are harvested off this init by
779
804
  // runTurn itself (`initHarvestFor`, runtimeCapabilities.mjs) —
@@ -871,9 +896,19 @@ export function createWorkSessionTurns({
871
896
  */
872
897
  if (
873
898
  !adopting &&
874
- resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
899
+ resumeRetriesFresh({
900
+ resume,
901
+ out,
902
+ runtime: rt.id,
903
+ // The CLI's own word that it found the conversation: Claude's
904
+ // init, Codex's `thread.started` naming the pinned thread.
905
+ sawInit: rt.id === 'codex' ? Boolean(codexResumeId && seenThreadId === codexResumeId) : Boolean(seenClaudeSession),
906
+ })
875
907
  ) {
876
- out = await runCli({ ...turnArgs, resume: false });
908
+ cliFailed = false;
909
+ badExit = null;
910
+ contractSent = true;
911
+ out = await runCli({ ...turnArgs, resume: false, system: turnSystem });
877
912
  }
878
913
  } finally {
879
914
  // The CLI has stopped printing, so stop relaying. The LINE itself
@@ -896,7 +931,10 @@ export function createWorkSessionTurns({
896
931
  // before it ever touches disk — it later rides in argv as
897
932
  // `resume <id>` — and best-effort, like the runtime pin: an
898
933
  // unwritable marker just means the tab runs fresh next turn.
899
- if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
934
+ if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
935
+ pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
936
+ recordCodexContract(contractPath, seenThreadId, turnSystem, contractSent);
937
+ }
900
938
  /**
901
939
  * A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
902
940
  * it is not an answer: the turn settles `interrupted`, with what it
@@ -974,7 +1012,7 @@ export function createWorkSessionTurns({
974
1012
  artifactFailure = `Artifact delivery is incomplete: ${error.message}`;
975
1013
  }
976
1014
  }
977
- const spoke = answer.length > 0 && !loginFailure;
1015
+ const spoke = answer.length > 0 && !loginFailure && !cliFailed && !badExit;
978
1016
  await settleTurn(job.id, {
979
1017
  ...attempt,
980
1018
  ok: spoke && !artifactFailure,
@@ -997,6 +1035,7 @@ export function createWorkSessionTurns({
997
1035
  });
998
1036
  if (spoke) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
999
1037
  else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
1038
+ else if (answer && (cliFailed || badExit)) warn(`the CLI reported its turn failed${badExit ? ` (${badExit})` : ''} — settled as failed, in its words`);
1000
1039
  else warn('session turn produced no output — settled as failed');
1001
1040
  } catch (e) {
1002
1041
  if (toolLog) toolLog.state = 'partial';
@@ -1072,7 +1111,7 @@ export function createWorkSessionTurns({
1072
1111
  if (run) {
1073
1112
  if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
1074
1113
  run.interrupted = true;
1075
- for (const ch of run.spawned) killGroup(ch);
1114
+ for (const ch of run.spawned) interruptThenKill(ch);
1076
1115
  continue;
1077
1116
  }
1078
1117
  if (pendingWorkReports.has(id) || settledRecently(id) || notRunningSaid.has(id)) continue;
@@ -191,14 +191,38 @@ function branchCommits(wt, base) {
191
191
  .split('\n')
192
192
  .filter(Boolean)
193
193
  );
194
+ // EVERY COMMIT ON THE BRANCH, NOT ONLY THE CARDED ONES (2026-10-19). A
195
+ // Terminal tab's prompt says a commit for no named card needs no trailer,
196
+ // so its own commits were never reported and the rail said "N commits
197
+ // ahead" with none of them listed or openable. One more exec names the
198
+ // rest — sha, author, date and the folded subject, NUL-separated (git
199
+ // allows a NUL in none of them) and one per line (`%s` folds its
200
+ // newlines), keyed by sha from the rev-list set with the first line
201
+ // winning, the same membership rule the trailered read keeps. No body and
202
+ // no numstat: the rail lists a commit by its subject, and its patch is the
203
+ // commit-diff job's.
204
+ const listed = new Map();
205
+ for (const line of git(['log', '--no-merges', '-n', String(MAX_COMMITS), '--format=%H%x00%an%x00%aI%x00%s', `${base}..HEAD`], wt).split('\n')) {
206
+ const [sha, author, at, subject] = line.split('\0');
207
+ if (real.includes(sha) && !listed.has(sha)) listed.set(sha, { author, at, subject });
208
+ }
194
209
  for (const sha of real) {
195
- if (!named.has(sha)) continue;
196
210
  // A commit we cannot stat (a numstat past the pipe's buffer) still names
197
211
  // its cards — send it with zero counts rather than drop it, as before.
198
- const rec = commitRecord(sha, wt, { numstat: true }) ?? commitRecord(sha, wt);
199
- if (!rec) continue;
200
- const taskIds = taskIdsFromMessage(stripDelims(rec.body));
201
- if (taskIds.length === 0) continue;
212
+ const rec = named.has(sha) ? commitRecord(sha, wt, { numstat: true }) ?? commitRecord(sha, wt) : null;
213
+ const taskIds = rec ? taskIdsFromMessage(stripDelims(rec.body)) : [];
214
+ if (taskIds.length === 0) {
215
+ const row = listed.get(sha);
216
+ if (!row) continue;
217
+ out.push({
218
+ sha: sha.slice(0, 64),
219
+ subject: stripDelims(row.subject).slice(0, 200),
220
+ author: stripDelims(row.author).slice(0, 80),
221
+ at: stripDelims(row.at).slice(0, 40),
222
+ taskIds: [],
223
+ });
224
+ continue;
225
+ }
202
226
  out.push({
203
227
  // Clamped to the server's zod caps, same rule as everything else in
204
228
  // this file: one over-cap string 400s the whole batch.