@north-light/crouter 0.3.302 → 0.3.304
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/dist/api/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +74 -6
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +225 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +17 -79
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +1 -1
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +7 -5
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
- package/dist/pi-extensions/canvas-bash-valve.js +75 -77
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -22,6 +22,11 @@ export class FaultRetry {
|
|
|
22
22
|
notFoundModels = new Set();
|
|
23
23
|
providerFallbackInFlight = null;
|
|
24
24
|
notFoundFallbackInFlight = null;
|
|
25
|
+
// A fallback can learn its target cannot hold this session before the
|
|
26
|
+
// current turn settles. Keep that outcome here rather than on the engine's
|
|
27
|
+
// agent_end staging field: message_end precedes agent_end, which resets that
|
|
28
|
+
// field for the ordinary provider result.
|
|
29
|
+
stagedCapacityFailure = null;
|
|
25
30
|
constructor(deps) {
|
|
26
31
|
this.deps = deps;
|
|
27
32
|
}
|
|
@@ -35,6 +40,7 @@ export class FaultRetry {
|
|
|
35
40
|
this.clearTimer();
|
|
36
41
|
this.providerFallbackInFlight = null;
|
|
37
42
|
this.notFoundFallbackInFlight = null;
|
|
43
|
+
this.stagedCapacityFailure = null;
|
|
38
44
|
}
|
|
39
45
|
onEvent(event) {
|
|
40
46
|
this.maybeSwitchProviderForRetry(event);
|
|
@@ -54,6 +60,8 @@ export class FaultRetry {
|
|
|
54
60
|
const watchdogAbort = generation.stagedWatchdogAbort;
|
|
55
61
|
const refreshAbort = generation.stagedRefreshAbort;
|
|
56
62
|
const overflowFailure = generation.stagedOverflowFailure;
|
|
63
|
+
const capacityFailure = this.stagedCapacityFailure?.generation === generation ? this.stagedCapacityFailure : null;
|
|
64
|
+
this.stagedCapacityFailure = null;
|
|
57
65
|
generation.stagedProviderAgentEnd = null;
|
|
58
66
|
generation.stagedWatchdogAbort = false;
|
|
59
67
|
generation.stagedRefreshAbort = false;
|
|
@@ -63,6 +71,14 @@ export class FaultRetry {
|
|
|
63
71
|
// admitted retry therefore carries from its durable episode, while an
|
|
64
72
|
// initial provider failure still reads from the ordinary marker.
|
|
65
73
|
const prior = readFault(this.deps.nodeId);
|
|
74
|
+
// A persona gate runs before Pi contacts a provider. It records a typed
|
|
75
|
+
// local API transport failure, then Pi represents that thrown error as a
|
|
76
|
+
// generic provider-looking agent_end. Keep its actual boundary instead of
|
|
77
|
+
// turning it into a fatal pi→provider `other` fault.
|
|
78
|
+
if (prior?.link === 'broker↔crtrd' && prior.kind === 'connection') {
|
|
79
|
+
clearProviderRetryEpisode(this.deps.nodeId);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
66
82
|
const durableEpisode = readProviderRetryEpisode(this.deps.nodeId);
|
|
67
83
|
const episodeFault = this.isActiveAutoFault(prior) && prior.link === 'pi→provider'
|
|
68
84
|
? prior
|
|
@@ -76,12 +92,12 @@ export class FaultRetry {
|
|
|
76
92
|
: { since: episodeFault.since, anchorEntryId: episodeFault.anchorEntryId, attempt: episodeFault.retry.attempt };
|
|
77
93
|
const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
|
|
78
94
|
const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
|
|
79
|
-
if (overflowFailure !== null) {
|
|
95
|
+
if (capacityFailure !== null || overflowFailure !== null) {
|
|
80
96
|
clearFault(this.deps.nodeId, { link: 'pi→provider' });
|
|
81
97
|
clearProviderRetryEpisode(this.deps.nodeId);
|
|
82
98
|
recordFault(this.deps.nodeId, {
|
|
83
99
|
link: 'pi→provider', op: 'context overflow recovery', kind: 'context-overflow', retry: { disposition: 'fatal' },
|
|
84
|
-
message: overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
100
|
+
message: capacityFailure?.errorMessage ?? overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
85
101
|
});
|
|
86
102
|
return;
|
|
87
103
|
}
|
|
@@ -250,9 +266,23 @@ export class FaultRetry {
|
|
|
250
266
|
const routes = expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId());
|
|
251
267
|
const current = parseModelSpec(currentSpec);
|
|
252
268
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
253
|
-
const
|
|
254
|
-
|
|
269
|
+
const fallback = liveRank === undefined
|
|
270
|
+
? { route: undefined, capacityExhausted: false }
|
|
271
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.failedRetryRoutes.has(candidate.routeId));
|
|
272
|
+
if (fallback.route === undefined) {
|
|
273
|
+
if (fallback.capacityExhausted) {
|
|
274
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
275
|
+
// Pi creates its retry AbortController immediately after it emits this
|
|
276
|
+
// event. Defer the abort so it cancels that controller, then lets the
|
|
277
|
+
// normal agent_settled path publish the staged fatal outcome.
|
|
278
|
+
queueMicrotask(() => {
|
|
279
|
+
if (this.deps.installedGeneration() === generation && this.deps.currentSession() === candidateSession)
|
|
280
|
+
candidateSession.abortRetry();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
255
283
|
return;
|
|
284
|
+
}
|
|
285
|
+
const nextRoute = fallback.route;
|
|
256
286
|
const fallbackPromise = (async () => {
|
|
257
287
|
const target = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
258
288
|
if (!target)
|
|
@@ -290,9 +320,15 @@ export class FaultRetry {
|
|
|
290
320
|
const routeRequest = modelRequestFromConfig(this.deps.cfg.model, envModelIntent(), false);
|
|
291
321
|
const routes = routeRequest ? expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId()) : [];
|
|
292
322
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
293
|
-
const
|
|
294
|
-
|
|
323
|
+
const fallback = liveRank === undefined
|
|
324
|
+
? { route: undefined, capacityExhausted: false }
|
|
325
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`));
|
|
326
|
+
if (fallback.route === undefined) {
|
|
327
|
+
if (fallback.capacityExhausted)
|
|
328
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
295
329
|
return;
|
|
330
|
+
}
|
|
331
|
+
const nextRoute = fallback.route;
|
|
296
332
|
const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
|
|
297
333
|
const fallbackPromise = (async () => {
|
|
298
334
|
const model = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
@@ -323,6 +359,38 @@ export class FaultRetry {
|
|
|
323
359
|
});
|
|
324
360
|
this.notFoundFallbackInFlight = fallbackPromise;
|
|
325
361
|
}
|
|
362
|
+
nextEligibleFallbackRoute(routes, registry, contextTokens, include) {
|
|
363
|
+
let available = 0;
|
|
364
|
+
let tooSmall = 0;
|
|
365
|
+
for (const candidate of routes) {
|
|
366
|
+
if (!include(candidate))
|
|
367
|
+
continue;
|
|
368
|
+
if (resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registry), automaticFallback: true }]).candidates.length === 0)
|
|
369
|
+
continue;
|
|
370
|
+
const target = registry.find(candidate.providerId, candidate.modelId);
|
|
371
|
+
if (target === undefined)
|
|
372
|
+
continue;
|
|
373
|
+
available++;
|
|
374
|
+
if (this.targetCannotFitContext(target.contextWindow, contextTokens)) {
|
|
375
|
+
tooSmall++;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
return { route: candidate, capacityExhausted: false };
|
|
379
|
+
}
|
|
380
|
+
return { route: undefined, capacityExhausted: available > 0 && available === tooSmall };
|
|
381
|
+
}
|
|
382
|
+
targetCannotFitContext(contextWindow, contextTokens) {
|
|
383
|
+
return Number.isFinite(contextWindow) && Number.isFinite(contextTokens) && contextWindow < contextTokens;
|
|
384
|
+
}
|
|
385
|
+
stageCapacityFailure(generation, session) {
|
|
386
|
+
const tokens = session.getContextUsage()?.tokens;
|
|
387
|
+
this.stagedCapacityFailure = {
|
|
388
|
+
generation,
|
|
389
|
+
errorMessage: Number.isFinite(tokens)
|
|
390
|
+
? `All available fallback models have context windows smaller than the current conversation (${tokens} tokens).`
|
|
391
|
+
: 'All available fallback models have context windows smaller than the current conversation.',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
326
394
|
recordPendingProviderFault(session, input) {
|
|
327
395
|
const sessionFile = this.sessionFile(session);
|
|
328
396
|
// A pathless session cannot be crash-recovered, but its live broker still
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { contextDir } from '../canvas/paths.js';
|
|
2
|
+
import { isDaemonTransportApiError } from '../../api/errors.js';
|
|
2
3
|
import { emitEvent } from '../events/emit.js';
|
|
4
|
+
import { clearFault, recordFault } from './fault.js';
|
|
3
5
|
import { brokerExtensionState, commitBrokerPersonaAck } from './broker/daemon-ops.js';
|
|
4
6
|
import { readRoadmap, roadmapPath } from './roadmap.js';
|
|
5
7
|
import { buildSubPersonaMenu, renderConfigChangeDelta } from '../substrate/render.js';
|
|
@@ -137,9 +139,19 @@ export function installPersonaTransitionGate(nodeId, session) {
|
|
|
137
139
|
for (const context of new Set([active, messages, transformed]))
|
|
138
140
|
context.push(message);
|
|
139
141
|
});
|
|
142
|
+
clearFault(nodeId, { link: 'broker↔crtrd' });
|
|
140
143
|
return transformed;
|
|
141
144
|
}
|
|
142
145
|
catch (error) {
|
|
146
|
+
if (isDaemonTransportApiError(error)) {
|
|
147
|
+
recordFault(nodeId, {
|
|
148
|
+
link: 'broker↔crtrd',
|
|
149
|
+
op: 'persona transition',
|
|
150
|
+
kind: 'connection',
|
|
151
|
+
retry: { disposition: 'manual' },
|
|
152
|
+
message: error.message,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
143
155
|
emitEvent({
|
|
144
156
|
event: 'broker.daemon.operation.failed',
|
|
145
157
|
level: 'error',
|
|
@@ -375,11 +375,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
375
375
|
notifyTurnAccepted: () => notifyTurnAccepted(),
|
|
376
376
|
emitStartupMilestone,
|
|
377
377
|
formatModelSpec,
|
|
378
|
-
refreshIntent: async () => (await brokerExtensionState(nodeId)).node.intent === 'refresh',
|
|
379
|
-
onRefreshIntentError: (error) => {
|
|
380
|
-
emitEvent({ level: 'error', event: 'broker.daemon_state.read_failed', error });
|
|
381
|
-
disposeAndExit('daemon-state-read-failed', 1);
|
|
382
|
-
},
|
|
383
378
|
});
|
|
384
379
|
const buildSnapshot = (client) => {
|
|
385
380
|
const liveSession = rebind.session();
|
|
@@ -31,7 +31,7 @@ function faultPriority(fault) {
|
|
|
31
31
|
return 2;
|
|
32
32
|
return 1;
|
|
33
33
|
}
|
|
34
|
-
const faultLinks = ['pi→provider', 'viewer↔broker', 'relay↔broker', 'viewer↔crtrd', 'daemon→node', 'crtr→pi'];
|
|
34
|
+
const faultLinks = ['pi→provider', 'viewer↔broker', 'relay↔broker', 'viewer↔crtrd', 'broker↔crtrd', 'daemon→node', 'crtr→pi'];
|
|
35
35
|
const faultKinds = ['rate-limit', 'overloaded', 'connection', 'auth', 'protocol', 'context-overflow', 'other', 'wedged', 'model-not-found'];
|
|
36
36
|
function parseFault(raw) {
|
|
37
37
|
try {
|
|
@@ -319,7 +319,16 @@ export const headlessBrokerHost = {
|
|
|
319
319
|
});
|
|
320
320
|
finish({ code, signal });
|
|
321
321
|
});
|
|
322
|
-
spawned.once('error', () =>
|
|
322
|
+
spawned.once('error', (error) => {
|
|
323
|
+
emitEvent({
|
|
324
|
+
level: 'error',
|
|
325
|
+
event: 'broker.launch.failed',
|
|
326
|
+
node_id: nodeId,
|
|
327
|
+
error,
|
|
328
|
+
fields: { uptime_ms: Date.now() - launchedAt },
|
|
329
|
+
});
|
|
330
|
+
finish({ code: null, signal: null });
|
|
331
|
+
});
|
|
323
332
|
});
|
|
324
333
|
spawned.unref();
|
|
325
334
|
const handle = { pid: spawned.pid ?? null, exited };
|
|
@@ -15,7 +15,7 @@ import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId
|
|
|
15
15
|
import { resolveProfileOperand } from '../profiles/manifest.js';
|
|
16
16
|
import { selectProfileForCwd, selectProfileForCwdReadOnly } from '../profiles/select.js';
|
|
17
17
|
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
18
|
-
import {
|
|
18
|
+
import { createManagedWorktreeAsync, rollbackManagedWorktreeAsync } from '../worktree-mutation-async.js';
|
|
19
19
|
import { usage, brokerLaunchFailed } from '../errors.js';
|
|
20
20
|
import { writeGoal } from './kickoff.js';
|
|
21
21
|
import { appendSituationalContext, formatSituationalProse } from './situational-context.js';
|
|
@@ -25,7 +25,6 @@ import { encodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
|
|
|
25
25
|
import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
|
|
26
26
|
import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
|
|
27
27
|
import { emitEvent } from '../events/emit.js';
|
|
28
|
-
import { jobDir } from '../canvas/paths.js';
|
|
29
28
|
import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
|
|
30
29
|
import { waitForBrokerViewSocket } from './placement-tmux.js';
|
|
31
30
|
import { transition } from './lifecycle.js';
|
|
@@ -259,7 +258,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
259
258
|
let spawnCwd = opts.cwd;
|
|
260
259
|
try {
|
|
261
260
|
if (wantsWorktree) {
|
|
262
|
-
managedWorktree =
|
|
261
|
+
managedWorktree = await createManagedWorktreeAsync(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
|
|
263
262
|
spawnCwd = managedWorktree.path;
|
|
264
263
|
}
|
|
265
264
|
// Spine: a managed child reports up to its spawner (has a manager); an
|
|
@@ -409,7 +408,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
409
408
|
if (placed.pid === null) {
|
|
410
409
|
const error = new Error(`broker host returned no pid for ${meta.node_id}`);
|
|
411
410
|
transition(meta.node_id, 'crash', { reason: 'launch_failed', outcome: launchFailureOutcome(error) });
|
|
412
|
-
throw brokerLaunchFailed(`failed to launch the broker engine for ${meta.node_id} (${meta.name}) — the node was not started.`, `Inspect
|
|
411
|
+
throw brokerLaunchFailed(`failed to launch the broker engine for ${meta.node_id} (${meta.name}) — the node was not started.`, `Inspect canonical event diagnostics with \`crtr sys logs --node ${meta.node_id}\`, then verify the pi engine can start in this environment.`);
|
|
413
412
|
}
|
|
414
413
|
recordPid(meta.node_id, placed.pid);
|
|
415
414
|
// A root created through `node new --root` must accept viewers before the
|
|
@@ -425,7 +424,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
425
424
|
? `the root broker ${meta.node_id} could not start — ${why}`
|
|
426
425
|
: `the root broker ${meta.node_id} never bound its view socket — it was not started.`, why !== null
|
|
427
426
|
? 'Resolve the cause named above, then retry.'
|
|
428
|
-
: `The broker engine exited before binding its view socket. Inspect
|
|
427
|
+
: `The broker engine exited before binding its view socket. Inspect canonical event diagnostics with \`crtr sys logs --node ${meta.node_id}\`, then retry.`);
|
|
429
428
|
}
|
|
430
429
|
// A --root opens NO viewer from here. spawnChild runs daemon-side only, and
|
|
431
430
|
// the daemon is paneless by construction (manage.ts strips TMUX/TMUX_PANE
|
|
@@ -484,7 +483,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
484
483
|
// worktree — the node stays alive (crashed, if the launch itself failed)
|
|
485
484
|
// with its worktree intact rather than pinned to a deleted path.
|
|
486
485
|
if (managedWorktree !== undefined && nodeId !== undefined && getNode(nodeId) === null) {
|
|
487
|
-
|
|
486
|
+
await rollbackManagedWorktreeAsync(managedWorktree);
|
|
488
487
|
}
|
|
489
488
|
throw err;
|
|
490
489
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** One shell word. `opaque` marks a token whose text came from inside quotes:
|
|
2
|
+
* it is data, so it must never be treated as an executable or a matched
|
|
3
|
+
* command word. */
|
|
4
|
+
export type CommandToken = {
|
|
5
|
+
text: string;
|
|
6
|
+
opaque: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** One shell segment: the raw text as the author wrote it, its leading
|
|
9
|
+
* `VAR=value` assignments, and the remaining words — so `tokens[0]` is the
|
|
10
|
+
* executable. An assignment-only segment (`export`-less `FOO=bar`) has
|
|
11
|
+
* assignments and no tokens; the distinction matters, because a leading
|
|
12
|
+
* assignment applies ONLY to the command in its own segment.
|
|
13
|
+
*
|
|
14
|
+
* `background` marks a segment ended by a single `&`. It runs in a subshell,
|
|
15
|
+
* so nothing it assigns or `cd`s reaches the commands after it. */
|
|
16
|
+
export type CommandSegment = {
|
|
17
|
+
text: string;
|
|
18
|
+
assignments: string[];
|
|
19
|
+
tokens: CommandToken[];
|
|
20
|
+
background: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** One raw segment and whether a single `&` — not `&&` — ended it. */
|
|
23
|
+
export type RawSegment = {
|
|
24
|
+
text: string;
|
|
25
|
+
background: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
|
|
28
|
+
* `|`, and a single `&`. Subshell parentheses and backticks are left inside
|
|
29
|
+
* their segment. Heredoc bodies are a separate pre-read concern (see
|
|
30
|
+
* `stripHeredocs`). */
|
|
31
|
+
export declare function splitCommandSegments(command: string): RawSegment[];
|
|
32
|
+
/** Tokenize one segment into shell words, marking quoted spans opaque. */
|
|
33
|
+
export declare function tokenizeCommandSegment(segment: string): CommandToken[];
|
|
34
|
+
/** Every non-empty command segment, paired with its raw text. */
|
|
35
|
+
export declare function commandSegments(command: string): CommandSegment[];
|
|
36
|
+
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
37
|
+
* `DELIM` line) from a command before it is read. The opening line is kept —
|
|
38
|
+
* `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
39
|
+
* body lines, which are data fed on stdin and never executed as commands, are
|
|
40
|
+
* dropped. Without this, a doc-writing command whose body merely MENTIONS a
|
|
41
|
+
* matched invocation would have its whole call held or refused. */
|
|
42
|
+
export declare function stripHeredocs(command: string): string;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// shell-segments.ts — best-effort shell text reading, shared by everything in
|
|
2
|
+
// crtr that has to reason about a command string it did not execute: the doc
|
|
3
|
+
// substrate's `command`/`pre-command` surface matching and the bash valve's
|
|
4
|
+
// refusals. Pure text in, structure out; no execution, no expansion.
|
|
5
|
+
//
|
|
6
|
+
// This is deliberately NOT a shell parser. Subshells, backticks, process
|
|
7
|
+
// substitution, and path-prefixed binaries stay unhandled. Every caller must
|
|
8
|
+
// treat a miss as "this text did not obviously do X", never as "this text
|
|
9
|
+
// cannot do X".
|
|
10
|
+
/** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
|
|
11
|
+
* `|`, and a single `&`. Subshell parentheses and backticks are left inside
|
|
12
|
+
* their segment. Heredoc bodies are a separate pre-read concern (see
|
|
13
|
+
* `stripHeredocs`). */
|
|
14
|
+
export function splitCommandSegments(command) {
|
|
15
|
+
const segments = [];
|
|
16
|
+
let current = '';
|
|
17
|
+
let quote = null;
|
|
18
|
+
let escaped = false;
|
|
19
|
+
const finish = (background = false) => {
|
|
20
|
+
segments.push({ text: current, background });
|
|
21
|
+
current = '';
|
|
22
|
+
};
|
|
23
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
24
|
+
const ch = command[i];
|
|
25
|
+
if (escaped) {
|
|
26
|
+
current += ch;
|
|
27
|
+
escaped = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (ch === '\\' && quote !== "'") {
|
|
31
|
+
current += ch;
|
|
32
|
+
escaped = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (quote !== null) {
|
|
36
|
+
current += ch;
|
|
37
|
+
if (ch === quote)
|
|
38
|
+
quote = null;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === "'" || ch === '"') {
|
|
42
|
+
current += ch;
|
|
43
|
+
quote = ch;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '\n' || ch === ';') {
|
|
47
|
+
finish();
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
|
|
51
|
+
finish();
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === '|') {
|
|
56
|
+
finish();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '&') {
|
|
60
|
+
finish(true);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
current += ch;
|
|
64
|
+
}
|
|
65
|
+
finish();
|
|
66
|
+
return segments;
|
|
67
|
+
}
|
|
68
|
+
/** Tokenize one segment into shell words, marking quoted spans opaque. */
|
|
69
|
+
export function tokenizeCommandSegment(segment) {
|
|
70
|
+
const tokens = [];
|
|
71
|
+
let text = '';
|
|
72
|
+
let opaque = false;
|
|
73
|
+
let started = false;
|
|
74
|
+
let quote = null;
|
|
75
|
+
let escaped = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (started)
|
|
78
|
+
tokens.push({ text, opaque });
|
|
79
|
+
text = '';
|
|
80
|
+
opaque = false;
|
|
81
|
+
started = false;
|
|
82
|
+
};
|
|
83
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
84
|
+
const ch = segment[i];
|
|
85
|
+
if (escaped) {
|
|
86
|
+
text += ch;
|
|
87
|
+
started = true;
|
|
88
|
+
escaped = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '\\' && quote !== "'") {
|
|
92
|
+
escaped = true;
|
|
93
|
+
started = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (quote !== null) {
|
|
97
|
+
started = true;
|
|
98
|
+
if (ch === quote) {
|
|
99
|
+
quote = null;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
text += ch;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === "'" || ch === '"') {
|
|
107
|
+
quote = ch;
|
|
108
|
+
opaque = true;
|
|
109
|
+
started = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (/\s/.test(ch)) {
|
|
113
|
+
finish();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
text += ch;
|
|
117
|
+
started = true;
|
|
118
|
+
}
|
|
119
|
+
if (escaped)
|
|
120
|
+
text += '\\';
|
|
121
|
+
finish();
|
|
122
|
+
return tokens;
|
|
123
|
+
}
|
|
124
|
+
/** Every non-empty command segment, paired with its raw text. */
|
|
125
|
+
export function commandSegments(command) {
|
|
126
|
+
return splitCommandSegments(command)
|
|
127
|
+
.map(({ text, background }) => {
|
|
128
|
+
const tokens = tokenizeCommandSegment(text);
|
|
129
|
+
let first = 0;
|
|
130
|
+
while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
|
|
131
|
+
first += 1;
|
|
132
|
+
return { text: text.trim(), assignments: tokens.slice(0, first).map((t) => t.text), tokens: tokens.slice(first), background };
|
|
133
|
+
})
|
|
134
|
+
.filter((segment) => segment.assignments.length > 0 || segment.tokens.length > 0);
|
|
135
|
+
}
|
|
136
|
+
const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
|
|
137
|
+
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
138
|
+
* `DELIM` line) from a command before it is read. The opening line is kept —
|
|
139
|
+
* `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
140
|
+
* body lines, which are data fed on stdin and never executed as commands, are
|
|
141
|
+
* dropped. Without this, a doc-writing command whose body merely MENTIONS a
|
|
142
|
+
* matched invocation would have its whole call held or refused. */
|
|
143
|
+
export function stripHeredocs(command) {
|
|
144
|
+
const lines = command.split('\n');
|
|
145
|
+
const out = [];
|
|
146
|
+
let i = 0;
|
|
147
|
+
while (i < lines.length) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
out.push(line);
|
|
150
|
+
i += 1;
|
|
151
|
+
// Every heredoc opened on this line, left to right; their bodies stack and
|
|
152
|
+
// are consumed in that order.
|
|
153
|
+
const delims = [];
|
|
154
|
+
HEREDOC_OPEN.lastIndex = 0;
|
|
155
|
+
let m;
|
|
156
|
+
while ((m = HEREDOC_OPEN.exec(line)) !== null)
|
|
157
|
+
delims.push({ name: m[3], dash: m[1] === '-' });
|
|
158
|
+
for (const d of delims) {
|
|
159
|
+
while (i < lines.length) {
|
|
160
|
+
// `<<-` strips leading TABS from the body and the closing delimiter.
|
|
161
|
+
const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
|
|
162
|
+
i += 1;
|
|
163
|
+
if (probe === d.name)
|
|
164
|
+
break; // closing delimiter line — drop it, stop
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out.join('\n');
|
|
169
|
+
}
|
|
@@ -20,15 +20,6 @@ export declare function matchesReadEntry(entry: SurfaceEntry, doc: Pick<Substrat
|
|
|
20
20
|
export declare function matchesMemoryReadEntry(entry: SurfaceEntry, routingAnchor: string, subject: NodeConfigSubject | null, name: string): boolean;
|
|
21
21
|
/** Does a `command` entry fit the executed command string? */
|
|
22
22
|
export declare function matchesCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject | null, command: string): boolean;
|
|
23
|
-
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
24
|
-
* `DELIM` line) from a command before it is matched. The opening line is kept
|
|
25
|
-
* — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
26
|
-
* body lines, which are data fed on stdin and never executed as commands, are
|
|
27
|
-
* dropped. Without this a doc-writing command whose body merely MENTIONS a
|
|
28
|
-
* matched invocation would have its whole call held. Post-execution `command`
|
|
29
|
-
* delivery does not need this (a spurious late injection costs a paragraph);
|
|
30
|
-
* a `pre-command` block costs the agent a turn, so it does. */
|
|
31
|
-
export declare function stripHeredocs(command: string): string;
|
|
32
23
|
/** Does a `pre-command` entry fit a command that is ABOUT to run? The same
|
|
33
24
|
* glob machinery `command` uses — one matcher, one semantics — applied to the
|
|
34
25
|
* command with its heredoc bodies stripped. Subshell parentheses, backticks,
|