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.
- package/README.md +4 -5
- package/bin/lib/claudeInput.mjs +61 -1
- package/bin/lib/claudeStream.mjs +30 -1
- package/bin/lib/codexContract.mjs +52 -0
- package/bin/lib/config.mjs +22 -1
- package/bin/lib/mcpInventory.mjs +2 -1
- package/bin/lib/mcpReasons.mjs +6 -0
- package/bin/lib/orchestrator.mjs +9 -0
- package/bin/lib/processGroup.mjs +41 -0
- package/bin/lib/projectTools.mjs +3 -2
- package/bin/lib/prompts.mjs +47 -18
- package/bin/lib/reportJournal.mjs +61 -0
- package/bin/lib/resumeLost.mjs +21 -1
- package/bin/lib/runTurn.mjs +23 -2
- package/bin/lib/runtimeCapabilities.mjs +7 -2
- package/bin/lib/runtimeClaude.mjs +22 -5
- package/bin/lib/runtimeCodex.mjs +3 -0
- package/bin/lib/runtimeOfferings.mjs +3 -1
- package/bin/lib/startProject.mjs +10 -0
- package/bin/lib/update.mjs +18 -4
- package/bin/lib/work.mjs +3 -3
- package/bin/lib/workAgentTurnExecution.mjs +20 -0
- package/bin/lib/workAgentTurnReports.mjs +16 -0
- package/bin/lib/workAgentTurns.mjs +4 -2
- package/bin/lib/workAttachments.mjs +27 -5
- package/bin/lib/workNarration.mjs +32 -0
- package/bin/lib/workReportQueue.mjs +18 -1
- package/bin/lib/workSessionTurns.mjs +59 -20
- package/bin/lib/worktreeDiff.mjs +29 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 —
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/bin/lib/startProject.mjs
CHANGED
|
@@ -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' +
|
package/bin/lib/update.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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({
|
|
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
|
-
|
|
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))
|
|
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)
|
|
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;
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|