@north-light/crouter 0.3.301 → 0.3.303
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/dto/reports.d.ts +8 -8
- package/dist/clients/attach/viewer.js +394 -394
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
- package/dist/commands/memory/read.js +12 -14
- package/dist/commands/node/lifecycle.js +7 -6
- package/dist/commands/push.js +4 -4
- 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__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- package/dist/core/memory-resolver.d.ts +3 -1
- package/dist/core/memory-resolver.js +3 -3
- package/dist/core/nested-stores.d.ts +1 -1
- package/dist/core/nested-stores.js +3 -3
- package/dist/core/runtime/bearings-render.js +1 -1
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +66 -6
- package/dist/core/runtime/close.js +8 -6
- package/dist/core/runtime/stop-guard.js +6 -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.js +3 -6
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +2 -2
- package/dist/daemon/cron/sinks.d.ts +1 -1
- package/dist/daemon/cron/sinks.js +2 -2
- package/dist/daemon/reconcilers/live-obligation.js +2 -2
- package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
- package/dist/pi-extensions/canvas-bash-valve.js +2 -34
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/daemon/cron-sink.d.ts +0 -26
- package/dist/daemon/cron-sink.js +0 -43
|
@@ -145,6 +145,8 @@ export interface MemoryResolutionOpts extends MemoryCandidateOpts {
|
|
|
145
145
|
* boot render, on-read resolvedDocs, the slash-command snapshot, and
|
|
146
146
|
* persona resolution, which stay on the flat ancestor+profile stack. */
|
|
147
147
|
includeDescendants?: boolean;
|
|
148
|
+
/** Report when bounded descendant discovery omits an owner. Defaults to the inverse of `quiet`, so callers can preserve quiet malformed-document handling while still making an incomplete corpus visible to their caller. */
|
|
149
|
+
reportDiscovery?: boolean;
|
|
148
150
|
}
|
|
149
151
|
/** A loaded corpus and the exact-identity queries over it. */
|
|
150
152
|
export interface MemoryView {
|
|
@@ -195,7 +197,7 @@ export declare function openProjectMemoryStore(ownerDir: string): MemoryStoreDes
|
|
|
195
197
|
/** Every store a target sees, in precedence order: node, each project store
|
|
196
198
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
197
199
|
* One real store reached twice contributes once, at its first position. */
|
|
198
|
-
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean,
|
|
200
|
+
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean, reportDiscovery?: boolean): MemoryStoreDescriptor[];
|
|
199
201
|
/** The native (non-plugin) MOUNTED store dirs in resolution precedence.
|
|
200
202
|
* Addresses a store directly when the document itself may be absent, which is
|
|
201
203
|
* how a DELETED doc's revision log is still found: history outlives the doc,
|
|
@@ -193,7 +193,7 @@ export function openProjectMemoryStore(ownerDir) {
|
|
|
193
193
|
/** Every store a target sees, in precedence order: node, each project store
|
|
194
194
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
195
195
|
* One real store reached twice contributes once, at its first position. */
|
|
196
|
-
export function memoryStoresInPrecedence(target, scope, includeDescendants = false,
|
|
196
|
+
export function memoryStoresInPrecedence(target, scope, includeDescendants = false, reportDiscovery = true) {
|
|
197
197
|
const out = [];
|
|
198
198
|
const projectStores = (crtrRoot, projectMemory) => {
|
|
199
199
|
out.push(projectStoreDescriptor(dirname(crtrRoot), join(crtrRoot, 'memory'), projectMemory));
|
|
@@ -211,7 +211,7 @@ export function memoryStoresInPrecedence(target, scope, includeDescendants = fal
|
|
|
211
211
|
// nested one. They carry the neutral ceiling — no profile entry names
|
|
212
212
|
// them, and descendant discovery is off for every automatic delivery path.
|
|
213
213
|
if (includeDescendants) {
|
|
214
|
-
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r),
|
|
214
|
+
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r), reportDiscovery)) {
|
|
215
215
|
projectStores(root, NEUTRAL_PROJECT_MEMORY);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
@@ -438,7 +438,7 @@ function makeView(stores, docs, scope) {
|
|
|
438
438
|
* with its migration remedy and contributes nothing. */
|
|
439
439
|
export function loadMemoryTargetView(target, opts = {}) {
|
|
440
440
|
const quiet = opts.quiet ?? false;
|
|
441
|
-
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, quiet);
|
|
441
|
+
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, opts.reportDiscovery ?? !quiet);
|
|
442
442
|
const docs = [];
|
|
443
443
|
for (const store of stores) {
|
|
444
444
|
if (store.mountStatus === 'absent')
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
10
10
|
* duplicated dedupe logic.
|
|
11
11
|
*/
|
|
12
|
-
export declare function descendantStoreRoots(ancestorRoots: string[],
|
|
12
|
+
export declare function descendantStoreRoots(ancestorRoots: string[], reportBudgetExceeded?: boolean): string[];
|
|
13
13
|
/** Complete descendant-store discovery for state migrations. Unlike the
|
|
14
14
|
* interactive addressing lane, this filesystem walk has no time or depth
|
|
15
15
|
* budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
|
|
@@ -39,7 +39,7 @@ const cache = new Map();
|
|
|
39
39
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
40
40
|
* duplicated dedupe logic.
|
|
41
41
|
*/
|
|
42
|
-
export function descendantStoreRoots(ancestorRoots,
|
|
42
|
+
export function descendantStoreRoots(ancestorRoots, reportBudgetExceeded = true) {
|
|
43
43
|
const key = [...ancestorRoots].sort().join('\n');
|
|
44
44
|
const cached = cache.get(key);
|
|
45
45
|
if (cached !== undefined)
|
|
@@ -56,8 +56,8 @@ export function descendantStoreRoots(ancestorRoots, quiet = false) {
|
|
|
56
56
|
owners.push(owner);
|
|
57
57
|
}
|
|
58
58
|
const tripBudget = (owner) => {
|
|
59
|
-
if (
|
|
60
|
-
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores
|
|
59
|
+
if (reportBudgetExceeded) {
|
|
60
|
+
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — this result may omit nested stores. To read a document from a known nested store, pass its owner as \`--dir <owner>\`; that exact-store read bypasses discovery.`);
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const found = [];
|
|
@@ -237,5 +237,5 @@ export function buildIdentityAssertion(nodeId, kind, mode, forkFrom, sourceLabel
|
|
|
237
237
|
export function worktreeNote(worktree) {
|
|
238
238
|
if (worktree?.state !== 'open')
|
|
239
239
|
return '';
|
|
240
|
-
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here.
|
|
240
|
+
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here. Before finishing, land it with \`crtr node worktree close\` or keep the checkout and branch when the worktree is clean and its exact tip exists on any branch on origin. Auto-drop applies only when the worktree is clean (no uncommitted changes) and nothing remains to land (zero commits ahead of the base, or its commits are already contained in the base); otherwise, unlanded commits or uncommitted changes block finalization.`;
|
|
241
241
|
}
|
|
@@ -25,6 +25,7 @@ export declare class FaultRetry {
|
|
|
25
25
|
private readonly notFoundModels;
|
|
26
26
|
private providerFallbackInFlight;
|
|
27
27
|
private notFoundFallbackInFlight;
|
|
28
|
+
private stagedCapacityFailure;
|
|
28
29
|
constructor(deps: FaultRetryDeps);
|
|
29
30
|
clearTimer(): void;
|
|
30
31
|
reset(): void;
|
|
@@ -54,6 +55,9 @@ export declare class FaultRetry {
|
|
|
54
55
|
schedule(): void;
|
|
55
56
|
private maybeSwitchProviderForRetry;
|
|
56
57
|
private maybeFallbackOnUnusableModel;
|
|
58
|
+
private nextEligibleFallbackRoute;
|
|
59
|
+
private targetCannotFitContext;
|
|
60
|
+
private stageCapacityFailure;
|
|
57
61
|
private recordPendingProviderFault;
|
|
58
62
|
private sessionFile;
|
|
59
63
|
private pendingEpisodeMatches;
|
|
@@ -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;
|
|
@@ -76,12 +84,12 @@ export class FaultRetry {
|
|
|
76
84
|
: { since: episodeFault.since, anchorEntryId: episodeFault.anchorEntryId, attempt: episodeFault.retry.attempt };
|
|
77
85
|
const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
|
|
78
86
|
const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
|
|
79
|
-
if (overflowFailure !== null) {
|
|
87
|
+
if (capacityFailure !== null || overflowFailure !== null) {
|
|
80
88
|
clearFault(this.deps.nodeId, { link: 'pi→provider' });
|
|
81
89
|
clearProviderRetryEpisode(this.deps.nodeId);
|
|
82
90
|
recordFault(this.deps.nodeId, {
|
|
83
91
|
link: 'pi→provider', op: 'context overflow recovery', kind: 'context-overflow', retry: { disposition: 'fatal' },
|
|
84
|
-
message: overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
92
|
+
message: capacityFailure?.errorMessage ?? overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
85
93
|
});
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
@@ -250,9 +258,23 @@ export class FaultRetry {
|
|
|
250
258
|
const routes = expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId());
|
|
251
259
|
const current = parseModelSpec(currentSpec);
|
|
252
260
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
253
|
-
const
|
|
254
|
-
|
|
261
|
+
const fallback = liveRank === undefined
|
|
262
|
+
? { route: undefined, capacityExhausted: false }
|
|
263
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.failedRetryRoutes.has(candidate.routeId));
|
|
264
|
+
if (fallback.route === undefined) {
|
|
265
|
+
if (fallback.capacityExhausted) {
|
|
266
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
267
|
+
// Pi creates its retry AbortController immediately after it emits this
|
|
268
|
+
// event. Defer the abort so it cancels that controller, then lets the
|
|
269
|
+
// normal agent_settled path publish the staged fatal outcome.
|
|
270
|
+
queueMicrotask(() => {
|
|
271
|
+
if (this.deps.installedGeneration() === generation && this.deps.currentSession() === candidateSession)
|
|
272
|
+
candidateSession.abortRetry();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
255
275
|
return;
|
|
276
|
+
}
|
|
277
|
+
const nextRoute = fallback.route;
|
|
256
278
|
const fallbackPromise = (async () => {
|
|
257
279
|
const target = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
258
280
|
if (!target)
|
|
@@ -290,9 +312,15 @@ export class FaultRetry {
|
|
|
290
312
|
const routeRequest = modelRequestFromConfig(this.deps.cfg.model, envModelIntent(), false);
|
|
291
313
|
const routes = routeRequest ? expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId()) : [];
|
|
292
314
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
293
|
-
const
|
|
294
|
-
|
|
315
|
+
const fallback = liveRank === undefined
|
|
316
|
+
? { route: undefined, capacityExhausted: false }
|
|
317
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`));
|
|
318
|
+
if (fallback.route === undefined) {
|
|
319
|
+
if (fallback.capacityExhausted)
|
|
320
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
295
321
|
return;
|
|
322
|
+
}
|
|
323
|
+
const nextRoute = fallback.route;
|
|
296
324
|
const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
|
|
297
325
|
const fallbackPromise = (async () => {
|
|
298
326
|
const model = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
@@ -323,6 +351,38 @@ export class FaultRetry {
|
|
|
323
351
|
});
|
|
324
352
|
this.notFoundFallbackInFlight = fallbackPromise;
|
|
325
353
|
}
|
|
354
|
+
nextEligibleFallbackRoute(routes, registry, contextTokens, include) {
|
|
355
|
+
let available = 0;
|
|
356
|
+
let tooSmall = 0;
|
|
357
|
+
for (const candidate of routes) {
|
|
358
|
+
if (!include(candidate))
|
|
359
|
+
continue;
|
|
360
|
+
if (resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registry), automaticFallback: true }]).candidates.length === 0)
|
|
361
|
+
continue;
|
|
362
|
+
const target = registry.find(candidate.providerId, candidate.modelId);
|
|
363
|
+
if (target === undefined)
|
|
364
|
+
continue;
|
|
365
|
+
available++;
|
|
366
|
+
if (this.targetCannotFitContext(target.contextWindow, contextTokens)) {
|
|
367
|
+
tooSmall++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
return { route: candidate, capacityExhausted: false };
|
|
371
|
+
}
|
|
372
|
+
return { route: undefined, capacityExhausted: available > 0 && available === tooSmall };
|
|
373
|
+
}
|
|
374
|
+
targetCannotFitContext(contextWindow, contextTokens) {
|
|
375
|
+
return Number.isFinite(contextWindow) && Number.isFinite(contextTokens) && contextWindow < contextTokens;
|
|
376
|
+
}
|
|
377
|
+
stageCapacityFailure(generation, session) {
|
|
378
|
+
const tokens = session.getContextUsage()?.tokens;
|
|
379
|
+
this.stagedCapacityFailure = {
|
|
380
|
+
generation,
|
|
381
|
+
errorMessage: Number.isFinite(tokens)
|
|
382
|
+
? `All available fallback models have context windows smaller than the current conversation (${tokens} tokens).`
|
|
383
|
+
: 'All available fallback models have context windows smaller than the current conversation.',
|
|
384
|
+
};
|
|
385
|
+
}
|
|
326
386
|
recordPendingProviderFault(session, input) {
|
|
327
387
|
const sessionFile = this.sessionFile(session);
|
|
328
388
|
// A pathless session cannot be crash-recovered, but its live broker still
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Closing a node tears down the focused node and every descendant it
|
|
4
4
|
// EXCLUSIVELY owns, walking DOWN the subscribes_to spine (subscriptionsOf = a
|
|
5
|
-
// node's reports/children).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// node's reports/children). A node that never produced substantive assistant
|
|
6
|
+
// output is reaped and cannot be revived. Streaming nodes and nodes with an open
|
|
7
|
+
// managed worktree are retained; other closed nodes keep their pi session,
|
|
8
|
+
// canvas edges, and on-disk state for a later revive (`crtr node lifecycle
|
|
9
|
+
// revive` / focus → `pi --session <id>`).
|
|
8
10
|
//
|
|
9
11
|
// Per node, in this order — the order matters twice:
|
|
10
12
|
//
|
|
@@ -171,9 +173,9 @@ export function closeNode(rootId, opts = {}) {
|
|
|
171
173
|
// Surviving managers captured BEFORE any teardown — a reap (below) deletes
|
|
172
174
|
// this node's edges, so the step-4 fan-out must read them up front.
|
|
173
175
|
const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
|
|
174
|
-
// 0)
|
|
175
|
-
//
|
|
176
|
-
//
|
|
176
|
+
// 0) A node with no substantive assistant output is a useless shell — don't
|
|
177
|
+
// park it as a canceled husk; reap it outright (engine + viewer + row +
|
|
178
|
+
// dir). reapIfEmpty handles the teardown; when it fires we
|
|
177
179
|
// skip the cancel transition + resume notice (the node is gone) but still
|
|
178
180
|
// fan the "child gone" wake out to surviving managers below.
|
|
179
181
|
if (!reapIfEmpty(id)) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// manual cleanup.
|
|
24
24
|
// • otherwise → a TERMINAL node with nothing live to wait for and no
|
|
25
25
|
// final pushed. Re-prompt it to finish or escalate.
|
|
26
|
-
import { hasActiveLiveSubscription, hasLiveMessageWait,
|
|
26
|
+
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCronWake, getNode, contextDir } from '../canvas/index.js';
|
|
27
27
|
import { activeBackgroundBashJobs } from '../bash-jobs.js';
|
|
28
28
|
import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
29
29
|
import { readOutputRequest } from './structured-output.js';
|
|
@@ -41,12 +41,12 @@ function formatInvalidStructuredOutputReprompt(error) {
|
|
|
41
41
|
`After resolving the file, retry with \`crtr push result\` (or \`crtr push result --decline "<reason>" --code <token>\` when the schema cannot be honestly satisfied). ` +
|
|
42
42
|
`If this is unexpected, escalate to the user using \`crtr human send\`.`);
|
|
43
43
|
}
|
|
44
|
-
/** The wake sources that legitimize a terminal node's dormancy: a pending
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
/** The wake sources that legitimize a terminal node's dormancy: a pending cron
|
|
45
|
+
* wake, an active subscription to a live publisher, an explicit live controller
|
|
46
|
+
* wait, or a live background bash job whose completion sends an inbox message.
|
|
47
|
+
* Parentage alone is deliberately absent. */
|
|
48
48
|
function wakeCapableWait(nodeId, backgroundJobsRunning) {
|
|
49
|
-
if (
|
|
49
|
+
if (hasPendingCronWake(nodeId))
|
|
50
50
|
return 'scheduled';
|
|
51
51
|
if (hasActiveLiveSubscription(nodeId)
|
|
52
52
|
|| hasLiveMessageWait(nodeId)
|
|
@@ -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,
|