flowviant 0.117.0 → 0.124.1
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 +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- package/npm-shrinkwrap.json +0 -59
|
@@ -25,8 +25,12 @@ import { c, note } from './ui.mjs';
|
|
|
25
25
|
import { createAgentTurnReports } from './workAgentTurnReports.mjs';
|
|
26
26
|
import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
|
|
27
27
|
import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
|
|
28
|
+
import { killGroup } from './processGroup.mjs';
|
|
29
|
+
import { createDeliveryReporter } from './workDelivery.mjs';
|
|
30
|
+
import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
|
|
28
31
|
|
|
29
32
|
export function createWorkAgentTurns({
|
|
33
|
+
agentRepositories,
|
|
30
34
|
REJECT_RETRY_MS,
|
|
31
35
|
baseRef,
|
|
32
36
|
inPlace,
|
|
@@ -42,13 +46,16 @@ export function createWorkAgentTurns({
|
|
|
42
46
|
artifacts,
|
|
43
47
|
runReviewEntry,
|
|
44
48
|
publishAgentBranch,
|
|
49
|
+
stopAgentPublishing = () => {},
|
|
45
50
|
admit,
|
|
46
51
|
workChildren,
|
|
47
52
|
agentPublished,
|
|
48
53
|
agentRemoteAt,
|
|
49
54
|
/** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
|
|
50
55
|
fetchAgentFiles,
|
|
56
|
+
makeBrief = createAgentBriefRunner({ admit, workChildren }),
|
|
51
57
|
}) {
|
|
58
|
+
const reportDelivery = createDeliveryReporter();
|
|
52
59
|
const {
|
|
53
60
|
postAgentTurn,
|
|
54
61
|
postAgentTrace,
|
|
@@ -65,11 +72,9 @@ export function createWorkAgentTurns({
|
|
|
65
72
|
// A TIME. The server types the next prompt when this one lands; this side
|
|
66
73
|
// does the work and reports what happened.
|
|
67
74
|
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// pointing at whatever it liked. That is also what keeps this feature from
|
|
72
|
-
// adding a token kind to a scope map that has silently shipped empty twice.
|
|
75
|
+
// Outcomes stay in the final JSON; commits are measured from git. The
|
|
76
|
+
// local card-repository tool is a turn-scoped capability, never a machine
|
|
77
|
+
// credential handed to the CLI.
|
|
73
78
|
//
|
|
74
79
|
// UNLEASED, unlike a plan or a kill. The server hands out at most one turn
|
|
75
80
|
// per agent per poll and its settle is conditional on the row still being
|
|
@@ -91,8 +96,24 @@ export function createWorkAgentTurns({
|
|
|
91
96
|
* directory names, and those are places.
|
|
92
97
|
*/
|
|
93
98
|
const agentChildren = new Map(); // placeId -> child process
|
|
99
|
+
/**
|
|
100
|
+
* INTERRUPT NOW (2026-10-13) — each turn this lane has taken: its place and
|
|
101
|
+
* whether a person asked to stop it. A stop kills the CLI's PROCESS GROUP
|
|
102
|
+
* (it is spawned detached, so the commands it started go with it); a turn
|
|
103
|
+
* asked to stop before it spawned never spawns.
|
|
104
|
+
*/
|
|
105
|
+
const agentRuns = new Map(); // turnId -> { place, interrupted }
|
|
106
|
+
/** Turns answered `ran: false` here, so a roster read made before that
|
|
107
|
+
* settle landed is not answered again at once. */
|
|
108
|
+
const notRunningSaid = new Map(); // turnId -> when
|
|
109
|
+
/** Agents whose run this lane just stopped: the next turn is the one sent to
|
|
110
|
+
* interrupt it, and is told so even when the roster that handed it over was
|
|
111
|
+
* read before the stop's settle landed. Taken once, for a little while. */
|
|
112
|
+
const stoppedFor = new Map(); // agentId -> when
|
|
94
113
|
|
|
95
114
|
const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
|
|
115
|
+
agentRepositories,
|
|
116
|
+
makeBrief,
|
|
96
117
|
baseRef,
|
|
97
118
|
baseDir,
|
|
98
119
|
repoRoot,
|
|
@@ -166,18 +187,26 @@ export function createWorkAgentTurns({
|
|
|
166
187
|
let settled = false;
|
|
167
188
|
const settleOnce = (body) => {
|
|
168
189
|
settled = true;
|
|
169
|
-
|
|
190
|
+
const liveMessages = agentRuns.get(turnId)?.live?.receipts();
|
|
191
|
+
return postAgentTurn({ ...body, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
|
|
170
192
|
};
|
|
171
193
|
|
|
172
194
|
// A WRITER on the agent's own place — see the lane header. Only `a-<id>`
|
|
173
195
|
// places: those are agents' by construction, and anything else here would
|
|
174
196
|
// be a tab's directory, where a turn is a reader by the product's own law.
|
|
197
|
+
const run = agentRuns.get(turnId) ?? { place, interrupted: false };
|
|
175
198
|
await inPlace(place, place.startsWith('a-'), async () => {
|
|
199
|
+
run.waitingReason = null;
|
|
176
200
|
try {
|
|
177
|
-
await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
|
|
201
|
+
await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured, run });
|
|
202
|
+
if (run.interrupted && measured.ranCli) stoppedFor.set(agentId, Date.now());
|
|
178
203
|
} catch (error) {
|
|
179
204
|
if (settled) return;
|
|
180
|
-
const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
|
|
205
|
+
const commits = measured.repositoryReceipts ? [...new Set(measured.repositoryReceipts().map((r) => r.sha))].slice(0, 50) : measured.wt ? commitsBetween(measured.wt, measured.before) : [];
|
|
206
|
+
if (run.interrupted) {
|
|
207
|
+
await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: measured.ranCli === true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
181
210
|
await postAgentTurn(
|
|
182
211
|
failedTurnSettlement({
|
|
183
212
|
turnId,
|
|
@@ -190,6 +219,9 @@ export function createWorkAgentTurns({
|
|
|
190
219
|
})
|
|
191
220
|
);
|
|
192
221
|
}
|
|
222
|
+
}, () => {
|
|
223
|
+
run.waitingReason = 'waiting for this turn to finish';
|
|
224
|
+
reportDelivery('agent', job, 'queued', run.waitingReason);
|
|
193
225
|
});
|
|
194
226
|
/**
|
|
195
227
|
* …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
|
|
@@ -235,15 +267,21 @@ export function createWorkAgentTurns({
|
|
|
235
267
|
}
|
|
236
268
|
};
|
|
237
269
|
|
|
238
|
-
const processAgentTurnJobs = (jobs) => {
|
|
270
|
+
const processAgentTurnJobs = (jobs, { reportsOnly = false } = {}) => {
|
|
239
271
|
const list = Array.isArray(jobs) ? jobs : [];
|
|
240
272
|
pruneHeldReports(list);
|
|
241
273
|
/** One deferral line per tick, however many turns were offered. */
|
|
242
274
|
let saidPressure = false;
|
|
243
275
|
for (const job of list.slice(0, 4)) {
|
|
244
276
|
const id = String(job?.id || '');
|
|
245
|
-
if (!id
|
|
277
|
+
if (!id) continue;
|
|
278
|
+
if (agentTurns.has(id)) {
|
|
279
|
+
const reason = agentRuns.get(id)?.waitingReason;
|
|
280
|
+
if (reason) reportDelivery('agent', job, 'queued', reason);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
246
283
|
const held = agentReported.get(id);
|
|
284
|
+
if (reportsOnly && !held) continue;
|
|
247
285
|
// A body the server REFUSED waits out its backoff — still held, so the
|
|
248
286
|
// CLI is never re-run in the meantime.
|
|
249
287
|
if (held && (agentRejectedUntil.get(id) ?? 0) > Date.now()) continue;
|
|
@@ -296,8 +334,15 @@ export function createWorkAgentTurns({
|
|
|
296
334
|
* would all be admitted against the same stale count. The reserved slot
|
|
297
335
|
* is what the next iteration sees. See admission.mjs.
|
|
298
336
|
*/
|
|
337
|
+
const missing = agentRepositories?.missing(job);
|
|
338
|
+
if (missing) {
|
|
339
|
+
if (!saidPressure) { saidPressure = true; note(missing); }
|
|
340
|
+
void postAgentActivity(String(job.agentId), missing);
|
|
341
|
+
continue; // no CLI, no failed settle; login and the next poll remeasure
|
|
342
|
+
}
|
|
299
343
|
const hold = admit('churn');
|
|
300
344
|
if (hold) {
|
|
345
|
+
reportDelivery('agent', job, 'queued', hold.reason);
|
|
301
346
|
if (!saidPressure) {
|
|
302
347
|
saidPressure = true;
|
|
303
348
|
note(`${c.cyan('agent')} ${c.dim(`— holding off: ${hold.reason}`)}`);
|
|
@@ -306,11 +351,16 @@ export function createWorkAgentTurns({
|
|
|
306
351
|
}
|
|
307
352
|
const releaseSlot = admit.reserve();
|
|
308
353
|
agentTurns.add(id);
|
|
309
|
-
|
|
354
|
+
agentRuns.set(id, { place: String(job.placeId), interrupted: false });
|
|
355
|
+
const stopped = stoppedFor.get(String(job.agentId));
|
|
356
|
+
stoppedFor.delete(String(job.agentId));
|
|
357
|
+
const run = stopped != null && Date.now() - stopped < 120_000 ? { ...job, interrupted: true } : job;
|
|
358
|
+
void runAgentTurn(run, releaseSlot).finally(() => {
|
|
310
359
|
// Belt for every path that never reached a spawn — the release is
|
|
311
360
|
// idempotent, so the normal case releases twice.
|
|
312
361
|
releaseSlot();
|
|
313
362
|
agentTurns.delete(id);
|
|
363
|
+
agentRuns.delete(id);
|
|
314
364
|
});
|
|
315
365
|
}
|
|
316
366
|
};
|
|
@@ -337,6 +387,9 @@ export function createWorkAgentTurns({
|
|
|
337
387
|
* a wedged uplink must not hold the stand-down open.
|
|
338
388
|
*/
|
|
339
389
|
const settleAgentTurns = async (sentence) => {
|
|
390
|
+
// Stand-down precedes flushing held replies; a late accepted reply must
|
|
391
|
+
// never push this displaced machine's work after the holder has moved.
|
|
392
|
+
stopAgentPublishing();
|
|
340
393
|
const answer = String(sentence ?? '').slice(0, 1000);
|
|
341
394
|
const posts = [];
|
|
342
395
|
for (const turnId of agentTurns) {
|
|
@@ -347,5 +400,32 @@ export function createWorkAgentTurns({
|
|
|
347
400
|
await Promise.allSettled(posts);
|
|
348
401
|
};
|
|
349
402
|
|
|
350
|
-
|
|
403
|
+
/**
|
|
404
|
+
* THE AGENT RUNS A PERSON ASKED TO STOP (`roster.interruptAgentTurns`, read
|
|
405
|
+
* before the jobs). One this lane is running is stopped — the CLI's group
|
|
406
|
+
* killed — and reports `interrupted` once its CLI is gone. One that finished
|
|
407
|
+
* here keeps its own settle. One this lane is not running is answered `ran:
|
|
408
|
+
* false`, and the server puts it back in the queue untouched.
|
|
409
|
+
*/
|
|
410
|
+
const interruptAgentTurns = (ids) => {
|
|
411
|
+
const at = Date.now();
|
|
412
|
+
for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
|
|
413
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
414
|
+
if (typeof id !== 'string' || !id) continue;
|
|
415
|
+
const run = agentRuns.get(id);
|
|
416
|
+
if (run) {
|
|
417
|
+
if (!run.interrupted) note(`${c.cyan('agent')} ${c.dim('— interrupting the running turn')}`);
|
|
418
|
+
run.interrupted = true;
|
|
419
|
+
run.abortSetup?.();
|
|
420
|
+
const child = agentChildren.get(run.place);
|
|
421
|
+
if (child) killGroup(child);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (agentReported.has(id) || notRunningSaid.has(id)) continue;
|
|
425
|
+
notRunningSaid.set(id, at);
|
|
426
|
+
void postAgentTurn({ turnId: id, outcome: 'nothing', interrupted: true, ran: false }).catch(() => {});
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
return { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported };
|
|
351
431
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN IDLE AGENT'S DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11).
|
|
3
|
+
*
|
|
4
|
+
* A local discard reached the server only when a turn settled, so an agent
|
|
5
|
+
* waiting in Review kept every stored file a daemon had not relayed removing:
|
|
6
|
+
* the marketing imagery card showed nine images while its worktree held the
|
|
7
|
+
* two the person kept, and nothing would change until somebody sent another
|
|
8
|
+
* message. So each agent worktree this machine holds is measured against its
|
|
9
|
+
* stored inventory once per process, under the place's writer lock and never
|
|
10
|
+
* beside a turn — the reporter's own evidence rule (artifactIteration.mjs)
|
|
11
|
+
* decides what is a discard. Only agents: a tab's next turn is the natural
|
|
12
|
+
* moment to reconcile a conversation, and a delivery in Review has none.
|
|
13
|
+
*
|
|
14
|
+
* A failure is said in the log and tried again on a later beat, a bounded
|
|
15
|
+
* number of times; the stored files stay listed, which is the truth.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
/** Attempts per agent per process, and the wait between two of them. */
|
|
20
|
+
const MAX_TRIES = 4;
|
|
21
|
+
const RETRY_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
export function createWorkArtifactReconcile({
|
|
24
|
+
artifacts,
|
|
25
|
+
placeLocks,
|
|
26
|
+
inPlace,
|
|
27
|
+
placeDir,
|
|
28
|
+
beforeArtifacts,
|
|
29
|
+
getArtifactsAccepted,
|
|
30
|
+
log = () => {},
|
|
31
|
+
now = Date.now,
|
|
32
|
+
}) {
|
|
33
|
+
const done = new Set(); // agent places reconciled by this process
|
|
34
|
+
const tries = new Map(); // agent place -> { n, at }
|
|
35
|
+
let sweeping = null;
|
|
36
|
+
|
|
37
|
+
const reconcileOne = async (place) => {
|
|
38
|
+
const wt = placeDir(place);
|
|
39
|
+
if (!wt || !existsSync(wt)) return;
|
|
40
|
+
const agentId = place.slice(2);
|
|
41
|
+
try {
|
|
42
|
+
await inPlace(place, true, () => artifacts.reconcile({ placeDir: wt, before: beforeArtifacts(wt), agentId }));
|
|
43
|
+
done.add(place);
|
|
44
|
+
tries.delete(place);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const n = (tries.get(place)?.n ?? 0) + 1;
|
|
47
|
+
tries.set(place, { n, at: now() });
|
|
48
|
+
log(`agent ${agentId}: artifact cleanup incomplete — ${error?.message ?? error}` +
|
|
49
|
+
(n >= MAX_TRIES ? ' (not retried again until the daemon restarts)' : ''));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The roster's live places; agent ones are `a-<agentId>`. Never awaited by the poll. */
|
|
54
|
+
const reconcileIdleArtifacts = (activeIds) => {
|
|
55
|
+
if (!Array.isArray(activeIds) || !getArtifactsAccepted()) return null;
|
|
56
|
+
if (sweeping) return sweeping;
|
|
57
|
+
const live = new Set(activeIds);
|
|
58
|
+
for (const id of done) if (!live.has(id)) done.delete(id);
|
|
59
|
+
for (const id of tries.keys()) if (!live.has(id)) tries.delete(id);
|
|
60
|
+
const due = activeIds.filter((place) => {
|
|
61
|
+
if (typeof place !== 'string' || !place.startsWith('a-') || place.length <= 2) return false;
|
|
62
|
+
if (done.has(place) || placeLocks.has(place)) return false; // a turn or a merge is there
|
|
63
|
+
const t = tries.get(place);
|
|
64
|
+
return !t || (t.n < MAX_TRIES && now() - t.at >= RETRY_MS);
|
|
65
|
+
});
|
|
66
|
+
if (due.length === 0) return null;
|
|
67
|
+
// One place at a time: each is an inventory read and its removals.
|
|
68
|
+
sweeping = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
for (const place of due) await reconcileOne(place);
|
|
71
|
+
} finally {
|
|
72
|
+
sweeping = null;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
return sweeping;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return { reconcileIdleArtifacts };
|
|
79
|
+
}
|
|
@@ -60,6 +60,7 @@ export async function runCappedTurn({
|
|
|
60
60
|
onSpawn = () => {},
|
|
61
61
|
onInit = () => {},
|
|
62
62
|
removeTranscript = removeTranscriptSoon,
|
|
63
|
+
signal,
|
|
63
64
|
}) {
|
|
64
65
|
// Wiring mistakes, refused before anything spawns: a hook inside turnArgs
|
|
65
66
|
// would be silently replaced below, and a missing cap would fire at once.
|
|
@@ -74,7 +75,14 @@ export async function runCappedTurn({
|
|
|
74
75
|
const capped = new Promise((resolve) => {
|
|
75
76
|
stopWaiting = resolve;
|
|
76
77
|
});
|
|
78
|
+
const cancel = () => {
|
|
79
|
+
wedged = true;
|
|
80
|
+
try { child?.kill('SIGKILL'); } catch { /* already gone */ }
|
|
81
|
+
stopWaiting('');
|
|
82
|
+
};
|
|
83
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
77
84
|
try {
|
|
85
|
+
if (signal?.aborted) { cancel(); return { out: '', wedged, spawned: false, sessionId }; }
|
|
78
86
|
const turn = runTurn({
|
|
79
87
|
...turnArgs,
|
|
80
88
|
onInit: (i) => {
|
|
@@ -96,11 +104,13 @@ export async function runCappedTurn({
|
|
|
96
104
|
}, capMs);
|
|
97
105
|
timer.unref?.();
|
|
98
106
|
onSpawn(ch);
|
|
107
|
+
if (signal?.aborted) cancel();
|
|
99
108
|
},
|
|
100
109
|
});
|
|
101
110
|
const out = await Promise.race([turn, capped]);
|
|
102
111
|
return { out, wedged, spawned: child !== null, sessionId };
|
|
103
112
|
} finally {
|
|
113
|
+
signal?.removeEventListener('abort', cancel);
|
|
104
114
|
if (timer) clearTimeout(timer);
|
|
105
115
|
if (child) workChildren.delete(child);
|
|
106
116
|
releaseSlot();
|
|
@@ -64,7 +64,14 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
|
64
64
|
// used to cut the command at 2000 before it reached this scrub, so a
|
|
65
65
|
// secret straddling that character kept its prefix. They now hand a
|
|
66
66
|
// bounded window, and the server's cap is applied here, last.
|
|
67
|
-
auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString()
|
|
67
|
+
auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString(),
|
|
68
|
+
...(a.observation ? { observation: {
|
|
69
|
+
...(a.observation.id ? { id: scrubCut(a.observation.id, 160) } : {}),
|
|
70
|
+
outcome: a.observation.outcome ?? 'unknown',
|
|
71
|
+
...(Number.isSafeInteger(a.observation.exitCode) ? { exitCode: a.observation.exitCode } : {}),
|
|
72
|
+
...(a.observation.errorCategory ? { errorCategory: a.observation.errorCategory } : {}),
|
|
73
|
+
} } : {}),
|
|
74
|
+
});
|
|
68
75
|
if (auditBatch.length >= 25) flushAudit();
|
|
69
76
|
};
|
|
70
77
|
return { flushAudit, auditCommand };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A measured wait belongs to its message. Reports decorate the queue and
|
|
2
|
+
* never gate a turn; retries come with the next measurement/poll. */
|
|
3
|
+
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
4
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
5
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
6
|
+
|
|
7
|
+
export function createDeliveryReporter(request = fleetRequest, now = Date.now) {
|
|
8
|
+
const said = new Map();
|
|
9
|
+
return (scope, job, state, reason) => {
|
|
10
|
+
const key = `${scope}:${job.id}`;
|
|
11
|
+
const value = `${state}:${reason ?? ''}`;
|
|
12
|
+
const prior = said.get(key);
|
|
13
|
+
if (prior?.value === value && now() - prior.at < 10_000) return;
|
|
14
|
+
said.set(key, { value, at: now() });
|
|
15
|
+
// Bound a day-long daemon's decoration cache.
|
|
16
|
+
if (said.size > 500) said.delete(said.keys().next().value);
|
|
17
|
+
void request(fleetEndpoint('turn-delivery', FLEET_URL), { timeoutMs: 10_000,
|
|
18
|
+
body: { scope, turnId: job.id, instance: DAEMON_INSTANCE, ...(Number.isInteger(job.attempt) ? { attempt: job.attempt } : {}), state, ...(reason ? { reason } : {}) },
|
|
19
|
+
}).then(result => { if (!result.ok && said.get(key)?.value === value) said.delete(key); }).catch(() => { said.delete(key); });
|
|
20
|
+
};
|
|
21
|
+
}
|
package/bin/lib/workDiffs.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Commit diff requests and their bounded reports. */
|
|
2
2
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
3
|
+
import { listStoredProjects } from './credentials.mjs';
|
|
3
4
|
import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
|
|
4
5
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
5
6
|
import { scrub, scrubCut } from './uplinkScrub.mjs';
|
|
@@ -21,7 +22,9 @@ function gitBig(args, cwd) {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function createWorkDiffs({ repoRoot }) {
|
|
25
|
+
export function createWorkDiffs({ repoRoot, repositories }) {
|
|
26
|
+
// The picker may have changed the active token since config was imported.
|
|
27
|
+
const roots = repositories ?? listStoredProjects().find((entry) => entry.fleetToken === FLEET_TOKEN)?.repositories ?? {};
|
|
25
28
|
const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -82,24 +85,28 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
82
85
|
// In-flight guard, not a cache: the server stops offering a sha the
|
|
83
86
|
// moment it settles, so this only stops the SAME poll's job being
|
|
84
87
|
// started twice while its `git show` is still running.
|
|
85
|
-
|
|
86
|
-
servedDiffs.
|
|
88
|
+
const jobKey = `${job.repo ?? ''}:${sha}`;
|
|
89
|
+
if (servedDiffs.has(jobKey)) continue;
|
|
90
|
+
servedDiffs.add(jobKey);
|
|
91
|
+
const cwd = job.repo ? roots[job.repositoryName] : repoRoot;
|
|
92
|
+
const report = (body) => postDiff({ ...body, ...(job.repo ? { repo: job.repo } : {}) });
|
|
87
93
|
void (async () => {
|
|
88
94
|
try {
|
|
95
|
+
if (!cwd) { await report({ sha, error: 'This repository has no checkout on this machine. Run flowviant login in that checkout.' }); return; }
|
|
89
96
|
let commit = '';
|
|
90
97
|
try {
|
|
91
|
-
commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
|
|
98
|
+
commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], cwd);
|
|
92
99
|
} catch (e) {
|
|
93
100
|
// Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
|
|
94
101
|
// ambiguous, or another object type); anything else is git's own
|
|
95
102
|
// failure, relayed in its words as before.
|
|
96
103
|
if (e?.status !== 1) {
|
|
97
|
-
await
|
|
104
|
+
await report({ sha, error: scrubCut(e?.message || e, 500) });
|
|
98
105
|
return;
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
108
|
if (!/^[0-9a-f]{40,64}$/.test(commit)) {
|
|
102
|
-
await
|
|
109
|
+
await report({ sha, error: 'no such commit on this machine' });
|
|
103
110
|
return;
|
|
104
111
|
}
|
|
105
112
|
let patch = '';
|
|
@@ -109,13 +116,13 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
109
116
|
// date already reached the card on the worktree sweep, and
|
|
110
117
|
// repeating them inside the patch would put a second copy above
|
|
111
118
|
// every hunk.
|
|
112
|
-
patch = scrub(gitBig(['show', '--patch', '--format=', commit],
|
|
119
|
+
patch = scrub(gitBig(['show', '--patch', '--format=', commit], cwd));
|
|
113
120
|
} catch (e) {
|
|
114
|
-
await
|
|
121
|
+
await report({ sha, error: scrubCut(e?.message || e, 500) });
|
|
115
122
|
return;
|
|
116
123
|
}
|
|
117
124
|
try {
|
|
118
|
-
const raw = gitBig(['show', '--numstat', '--format=', commit],
|
|
125
|
+
const raw = gitBig(['show', '--numstat', '--format=', commit], cwd);
|
|
119
126
|
for (const line of raw.split('\n')) {
|
|
120
127
|
if (!line.trim()) continue;
|
|
121
128
|
const [a, d, ...rest] = line.split('\t');
|
|
@@ -133,7 +140,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
133
140
|
/* a header without counts still beats no diff */
|
|
134
141
|
}
|
|
135
142
|
const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
|
|
136
|
-
await
|
|
143
|
+
await report({
|
|
137
144
|
sha,
|
|
138
145
|
// Cut on a LINE boundary: half a hunk header renders as garbage,
|
|
139
146
|
// and the viewer says the patch was truncated either way.
|
|
@@ -144,7 +151,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
144
151
|
truncated,
|
|
145
152
|
});
|
|
146
153
|
} finally {
|
|
147
|
-
servedDiffs.delete(
|
|
154
|
+
servedDiffs.delete(jobKey);
|
|
148
155
|
}
|
|
149
156
|
})();
|
|
150
157
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** One live conversation's input relay, shared by tabs, capture chats and
|
|
2
|
+
* card agents. The server owns queue/holder checks; the CLI echo owns receipt.
|
|
3
|
+
* Durable receipts plus native transcript UUIDs cover a restart before POST.
|
|
4
|
+
* No model call, new process, or re-send of an unacknowledged write here. */
|
|
5
|
+
import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
8
|
+
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
9
|
+
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
10
|
+
import { messageIdsForSession } from './claudeSessions.mjs';
|
|
11
|
+
|
|
12
|
+
export function createLiveMessages({ scope, turnId, attempt, cwd, sessionMetaPath, format, interrupted = () => false,
|
|
13
|
+
request = fleetRequest, nativeIds = messageIdsForSession, setTimer = setInterval, clearTimer = clearInterval, finalFlushMs = 1_000 }) {
|
|
14
|
+
const identity = { scope, turnId, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}) };
|
|
15
|
+
const file = sessionMetaPath(cwd, 'flowviant-live-messages', turnId);
|
|
16
|
+
let sessionId = null;
|
|
17
|
+
let timer = null;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
let send = null;
|
|
20
|
+
let flight = null;
|
|
21
|
+
let posting = null;
|
|
22
|
+
const sent = new Set();
|
|
23
|
+
const initialIds = new Set();
|
|
24
|
+
let initialId = turnId;
|
|
25
|
+
const taken = new Set();
|
|
26
|
+
const confirmed = new Set();
|
|
27
|
+
const outstanding = () => [...taken].filter((id) => !confirmed.has(id));
|
|
28
|
+
try {
|
|
29
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
30
|
+
sessionId = saved.sessionId;
|
|
31
|
+
for (const id of saved.ids ?? []) if (typeof id === 'string') taken.add(id);
|
|
32
|
+
} catch { /* no measured receipts yet */ }
|
|
33
|
+
const save = () => {
|
|
34
|
+
if (!file) return;
|
|
35
|
+
// Keep only ids and the CLI's conversation id, never the person's words.
|
|
36
|
+
const temporary = `${file}.tmp-${process.pid}`;
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(temporary, JSON.stringify({ sessionId, ids: outstanding() }), { mode: 0o600, flag: 'wx' });
|
|
39
|
+
renameSync(temporary, file); // a crash leaves the previous complete receipt
|
|
40
|
+
} catch { /* the native transcript remains the recovery evidence */ }
|
|
41
|
+
finally { try { rmSync(temporary, { force: true }); } catch { /* best effort */ } }
|
|
42
|
+
};
|
|
43
|
+
const flush = () => {
|
|
44
|
+
if (posting) return posting;
|
|
45
|
+
if (!outstanding().length) return Promise.resolve(true);
|
|
46
|
+
posting = (async () => {
|
|
47
|
+
for (const id of outstanding()) {
|
|
48
|
+
const result = await request(fleetEndpoint('live-turn-messages-taken', FLEET_URL), { body: { ...identity, ids: [id] }, timeoutMs: 15_000 });
|
|
49
|
+
if (!result.ok || result.json?.data?.taken !== true) return false;
|
|
50
|
+
confirmed.add(id);
|
|
51
|
+
save();
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
})().catch(() => false).finally(() => { posting = null; });
|
|
55
|
+
return posting;
|
|
56
|
+
};
|
|
57
|
+
const onTaken = (id) => {
|
|
58
|
+
if (initialIds.has(id) || taken.has(id)) return;
|
|
59
|
+
taken.add(id);
|
|
60
|
+
save(); // synchronous before the report or final settlement
|
|
61
|
+
void flush();
|
|
62
|
+
};
|
|
63
|
+
const tick = () => {
|
|
64
|
+
if (flight || stopped || interrupted() || !send) return flight ?? Promise.resolve();
|
|
65
|
+
flight = (async () => {
|
|
66
|
+
await flush();
|
|
67
|
+
if (stopped || interrupted()) return;
|
|
68
|
+
const result = await request(fleetEndpoint('live-turn-messages', FLEET_URL), { body: identity, timeoutMs: 15_000 });
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
if (isConsideredRefusal(result.status)) { if (timer) clearTimer(timer); timer = null; }
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!result.json?.data?.active) return;
|
|
74
|
+
const seen = sessionId ? nativeIds(cwd, sessionId) : null;
|
|
75
|
+
for (const message of result.json.data.messages ?? []) {
|
|
76
|
+
if (stopped || interrupted()) break;
|
|
77
|
+
if (taken.has(message.id) || seen?.has(message.id)) { onTaken(message.id); continue; }
|
|
78
|
+
if (sent.has(message.id)) continue;
|
|
79
|
+
const prompt = await format(message);
|
|
80
|
+
if (!stopped && !interrupted() && send(message.id, prompt)) sent.add(message.id);
|
|
81
|
+
}
|
|
82
|
+
})().catch(() => {}).finally(() => { flight = null; });
|
|
83
|
+
return flight;
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
turnId, onTaken, tick,
|
|
87
|
+
connected: () => Boolean(send) && !stopped,
|
|
88
|
+
get initialId() { return initialId; },
|
|
89
|
+
initialPrompt(prompt, resumeId, resume = Boolean(resumeId), continuation = false) {
|
|
90
|
+
initialId = turnId;
|
|
91
|
+
sessionId = resume || resumeId ? resumeId || sessionId : null;
|
|
92
|
+
// A daemon-authored correction continues the same conversation with
|
|
93
|
+
// new instructions. Restart recovery alone replaces the old prompt.
|
|
94
|
+
if (continuation) {
|
|
95
|
+
initialId = randomUUID();
|
|
96
|
+
initialIds.add(initialId);
|
|
97
|
+
return prompt;
|
|
98
|
+
}
|
|
99
|
+
if ((resume || resumeId) && sessionId && nativeIds(cwd, sessionId)?.has(turnId)) {
|
|
100
|
+
onTaken(turnId); // the native transcript proves the original prompt was read
|
|
101
|
+
initialId = randomUUID(); // continuation, never a second copy of the user's UUID
|
|
102
|
+
initialIds.add(initialId);
|
|
103
|
+
return 'Continue the request already in this conversation after the process stopped. Inspect the work in flight before repeating a command, and finish under the same operating contract.';
|
|
104
|
+
}
|
|
105
|
+
return prompt;
|
|
106
|
+
},
|
|
107
|
+
onInit(id) { sessionId = id; save(); },
|
|
108
|
+
connect(writer) {
|
|
109
|
+
sent.clear(); // a new child can retry writes the previous child never echoed
|
|
110
|
+
stopped = false;
|
|
111
|
+
send = writer;
|
|
112
|
+
timer = setTimer(() => void tick(), 1_000);
|
|
113
|
+
timer?.unref?.();
|
|
114
|
+
void tick();
|
|
115
|
+
},
|
|
116
|
+
receipts: () => ({ ...identity, ids: outstanding() }),
|
|
117
|
+
async stop() {
|
|
118
|
+
stopped = true;
|
|
119
|
+
if (timer) clearTimer(timer);
|
|
120
|
+
timer = null;
|
|
121
|
+
// A completed CLI never waits on a queue read or attachment download.
|
|
122
|
+
// Give receipts one bounded chance; the final report carries the rest.
|
|
123
|
+
let cap;
|
|
124
|
+
try { await Promise.race([flush(), new Promise(resolve => { cap = setTimeout(resolve, finalFlushMs); })]); }
|
|
125
|
+
finally { clearTimeout(cap); }
|
|
126
|
+
if (!outstanding().length && file) try { rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -23,6 +23,7 @@ import { fleetEndpoint } from './fleetWire.mjs';
|
|
|
23
23
|
import { isValidPlace } from './workPlaces.mjs';
|
|
24
24
|
|
|
25
25
|
export function createWorkPlaceReports({
|
|
26
|
+
repositoryPlaces,
|
|
26
27
|
repoRoot,
|
|
27
28
|
baseRef,
|
|
28
29
|
placeOf,
|
|
@@ -101,6 +102,10 @@ export function createWorkPlaceReports({
|
|
|
101
102
|
const wt = placeDir(sessionId);
|
|
102
103
|
const d = worktreeDiff(wt, baseRef());
|
|
103
104
|
if (!d) return null;
|
|
105
|
+
const siblings = repositoryPlaces?.entries(place).filter((r) => r.writable) ?? [];
|
|
106
|
+
if (siblings.length) {
|
|
107
|
+
d.commits = siblings.flatMap((r) => (worktreeDiff(r.wt, r.baseRef)?.commits ?? []).map((commit) => ({ ...commit, repo: r.id }))).slice(0, 50);
|
|
108
|
+
}
|
|
104
109
|
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
105
110
|
// holding the socket. It rides the sweep the daemon already makes rather
|
|
106
111
|
// than taking a beat of its own, exactly as the commit trailers do — and
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* the session lane's turn intake both go through it (audit 2026-10-10, F141),
|
|
13
13
|
* so a lane that learns a place cannot store one unvalidated.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { readRepositoryPlace } from './repositoryPlaces.mjs';
|
|
16
|
+
import { existsSync, statSync } from 'node:fs';
|
|
16
17
|
import { join } from 'node:path';
|
|
17
18
|
import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
|
|
18
19
|
|
|
@@ -51,7 +52,11 @@ export function isValidPlace(place) {
|
|
|
51
52
|
* that does not exist rather than becoming the operator's checkout.
|
|
52
53
|
*/
|
|
53
54
|
export function worktreePath(baseDir, place) {
|
|
54
|
-
|
|
55
|
+
if (!isSafePathSegment(place)) return null;
|
|
56
|
+
const home = join(baseDir, 'sessions', place);
|
|
57
|
+
const state = readRepositoryPlace(home);
|
|
58
|
+
const primary = state?.repositories.find((r) => r.id === state.primary && r.writable);
|
|
59
|
+
return primary ? join(home, primary.folder) : home;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -290,5 +295,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
290
295
|
}
|
|
291
296
|
};
|
|
292
297
|
|
|
293
|
-
|
|
298
|
+
/**
|
|
299
|
+
* WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
|
|
300
|
+
* `commondir`, written once at creation and left alone by commits, lock,
|
|
301
|
+
* repair and move. A recreated worktree is a new birth. Null for the
|
|
302
|
+
* checkout (no such file) and for anything unreadable — read by the
|
|
303
|
+
* artifact reporter, which then has no evidence a place held older rows.
|
|
304
|
+
*/
|
|
305
|
+
const worktreeBornAt = (wt) => {
|
|
306
|
+
const path = sessionMetaPath(wt, 'commondir');
|
|
307
|
+
try {
|
|
308
|
+
return path ? statSync(path).mtimeMs : null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
|
|
294
315
|
}
|
package/bin/lib/workRetire.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { git, isSafePathSegment } from './git.mjs';
|
|
|
14
14
|
import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
|
|
15
15
|
|
|
16
16
|
export function createWorkRetire({
|
|
17
|
+
repositoryPlaces,
|
|
17
18
|
repoRoot,
|
|
18
19
|
baseDir,
|
|
19
20
|
baseRef,
|
|
@@ -113,6 +114,10 @@ export function createWorkRetire({
|
|
|
113
114
|
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
114
115
|
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
115
116
|
// the explicit check keeps the intent legible.)
|
|
117
|
+
if (repositoryPlaces?.stateFor(id)) {
|
|
118
|
+
if (repositoryPlaces.remove(id)) { workTokens.delete(id); removed++; }
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
116
121
|
if (git(['status', '--porcelain'], wt) !== '') continue;
|
|
117
122
|
git(['worktree', 'remove', wt], repoRoot); // non-force
|
|
118
123
|
workTokens.delete(id);
|