@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.
Files changed (85) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  20. package/dist/core/__tests__/bash-guard.test.js +190 -0
  21. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  22. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  23. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  24. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  25. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  26. package/dist/core/__tests__/helpers/harness.js +9 -0
  27. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  28. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  29. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  30. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  31. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  32. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  33. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  34. package/dist/core/bash-guard.d.ts +6 -0
  35. package/dist/core/bash-guard.js +393 -0
  36. package/dist/core/bash-jobs.d.ts +20 -8
  37. package/dist/core/bash-jobs.js +40 -21
  38. package/dist/core/canvas/types.d.ts +4 -2
  39. package/dist/core/command.js +1 -1
  40. package/dist/core/fault-classifier.d.ts +1 -1
  41. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  42. package/dist/core/runtime/broker/event-projection.js +2 -15
  43. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  44. package/dist/core/runtime/broker/fault-retry.js +74 -6
  45. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  46. package/dist/core/runtime/broker.js +0 -5
  47. package/dist/core/runtime/fault.js +1 -1
  48. package/dist/core/runtime/host.js +10 -1
  49. package/dist/core/runtime/spawn.js +5 -6
  50. package/dist/core/shell-segments.d.ts +42 -0
  51. package/dist/core/shell-segments.js +169 -0
  52. package/dist/core/substrate/surface-match.d.ts +0 -9
  53. package/dist/core/substrate/surface-match.js +4 -160
  54. package/dist/core/worktree-close.d.ts +4 -0
  55. package/dist/core/worktree-close.js +225 -0
  56. package/dist/core/worktree-containment.d.ts +14 -0
  57. package/dist/core/worktree-containment.js +48 -0
  58. package/dist/core/worktree-mutation-async.d.ts +13 -0
  59. package/dist/core/worktree-mutation-async.js +281 -0
  60. package/dist/core/worktree-sweep.js +17 -79
  61. package/dist/core/worktree.d.ts +1 -0
  62. package/dist/core/worktree.js +1 -1
  63. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  64. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  65. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  66. package/dist/daemon/api/handlers/messages.js +7 -5
  67. package/dist/daemon/api/handlers/reports.js +3 -2
  68. package/dist/daemon/api/handlers/worktree.js +7 -6
  69. package/dist/daemon/fleet.d.ts +1 -1
  70. package/dist/daemon/fleet.js +30 -12
  71. package/dist/daemon/manage.d.ts +8 -5
  72. package/dist/daemon/manage.js +63 -40
  73. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  74. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  75. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  76. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  77. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  78. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  79. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  80. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  81. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
  82. package/dist/pi-extensions/canvas-bash-valve.js +75 -77
  83. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  84. package/package.json +1 -1
  85. 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 nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.failedRetryRoutes.has(candidate.routeId) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
254
- if (nextRoute === undefined)
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 nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
294
- if (!nextRoute)
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', () => finish({ code: null, signal: null }));
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 { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
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 = createManagedWorktree(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
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 the broker log for the underlying cause (${jobDir(meta.node_id)}/broker.log) and verify the pi engine can start in this environment.`);
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 ${jobDir(meta.node_id)}/broker.log for the underlying cause (a missing dependency, model auth, or a crash), then retry.`);
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
- rollbackManagedWorktree(managedWorktree);
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,