@north-light/crouter 0.3.234 → 0.3.236

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 (70) hide show
  1. package/dist/api/dto/nodes.d.ts +15 -3
  2. package/dist/clients/attach/viewer.js +398 -398
  3. package/dist/commands/__tests__/node-new.test.d.ts +1 -0
  4. package/dist/commands/__tests__/node-new.test.js +58 -0
  5. package/dist/commands/attention.js +1 -1
  6. package/dist/commands/cron.js +5 -5
  7. package/dist/commands/human.js +1 -1
  8. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
  9. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
  10. package/dist/commands/memory/delete.js +1 -1
  11. package/dist/commands/memory/edit.js +6 -3
  12. package/dist/commands/memory/list.d.ts +4 -0
  13. package/dist/commands/memory/list.js +20 -14
  14. package/dist/commands/memory/move.js +1 -1
  15. package/dist/commands/memory/shared.d.ts +1 -0
  16. package/dist/commands/memory/shared.js +13 -1
  17. package/dist/commands/memory/write.js +4 -1
  18. package/dist/commands/node/create.js +3 -0
  19. package/dist/commands/node/inspect.js +11 -4
  20. package/dist/commands/node/lifecycle.js +2 -1
  21. package/dist/commands/node/message.js +8 -4
  22. package/dist/commands/node/subscription.js +5 -1
  23. package/dist/commands/node/wait.js +5 -1
  24. package/dist/commands/pkg/plugin-manage.js +1 -1
  25. package/dist/commands/pkg.js +1 -0
  26. package/dist/commands/profile/rename.js +1 -1
  27. package/dist/commands/sys/config.js +1 -0
  28. package/dist/commands/sys.js +1 -0
  29. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  30. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  31. package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
  32. package/dist/core/__tests__/cli-help-contract.test.js +150 -0
  33. package/dist/core/__tests__/helpers/harness.js +1 -0
  34. package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
  35. package/dist/core/__tests__/seam/dormancy-release.test.js +70 -1
  36. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
  37. package/dist/core/canvas/canvas.js +11 -1
  38. package/dist/core/canvas/meta-file.js +18 -27
  39. package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
  40. package/dist/core/canvas/remote-canvas-source.js +9 -1
  41. package/dist/core/canvas/types.d.ts +2 -2
  42. package/dist/core/command-manifests/registry.d.ts +2 -0
  43. package/dist/core/command-plugins/compose.js +4 -2
  44. package/dist/core/command-plugins/discovery.d.ts +1 -0
  45. package/dist/core/command-plugins/discovery.js +1 -1
  46. package/dist/core/command.js +2 -2
  47. package/dist/core/fault-classifier.js +8 -0
  48. package/dist/core/pagination.d.ts +2 -0
  49. package/dist/core/pagination.js +2 -1
  50. package/dist/core/runtime/broker/fault-retry.js +6 -0
  51. package/dist/core/runtime/broker/rebind.js +50 -0
  52. package/dist/core/runtime/launch-target.js +1 -1
  53. package/dist/core/runtime/launch.d.ts +2 -2
  54. package/dist/core/runtime/launch.js +17 -4
  55. package/dist/core/runtime/spawn.d.ts +5 -13
  56. package/dist/core/runtime/spawn.js +4 -10
  57. package/dist/core/runtime/stop-guard.js +28 -16
  58. package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
  59. package/dist/core/substrate/frontmatter-validation.js +1 -1
  60. package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
  61. package/dist/daemon/api/handlers/messages.js +12 -12
  62. package/dist/daemon/api/handlers/nodes.js +13 -4
  63. package/dist/daemon/api/handlers/worktree.js +6 -22
  64. package/dist/daemon/api/map.d.ts +4 -2
  65. package/dist/daemon/api/map.js +22 -2
  66. package/dist/daemon/reconcilers/broker-supervision.js +1 -1
  67. package/dist/pi-extensions/canvas-bash-valve.js +4 -3
  68. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  69. package/package.json +1 -1
  70. package/runtime.lock.json +2 -2
@@ -14,7 +14,7 @@ after(() => {
14
14
  rmSync(home, { recursive: true, force: true });
15
15
  });
16
16
  test('only a watchdog-stamped aborted settlement records an automatic connection fault', () => {
17
- const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted') => {
17
+ const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted', errorText) => {
18
18
  const session = {
19
19
  sessionManager: { getLeafId: () => 'leaf-entry' },
20
20
  model: undefined,
@@ -27,7 +27,7 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
27
27
  messages: [{
28
28
  role: 'assistant',
29
29
  stopReason,
30
- errorMessage: stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted',
30
+ errorMessage: errorText ?? (stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted'),
31
31
  }],
32
32
  },
33
33
  stagedWatchdogAbort,
@@ -53,14 +53,19 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
53
53
  settleAbort('human-node', false, false);
54
54
  assert.equal(readFault('human-node'), null, 'an otherwise-identical human abort must not arm recovery');
55
55
  // Pi 0.84 currently serializes a host abort from `node yield` as an `error`
56
- // settlement. Only the refresh-abort provenance suppresses that provider-looking error.
56
+ // settlement.
57
57
  const refresh = settleAbort('refresh-node', false, true, 'error');
58
58
  assert.equal(readFault('refresh-node'), null, 'a refresh-aborted error settlement must not enter provider fault recovery');
59
59
  assert.equal(refresh.generation.stagedRefreshAbort, false, 'settlement consumes the one-shot refresh provenance');
60
- settleAbort('provider-node', false, false, 'error');
60
+ // #683 — an abort that lands between requests settles as an error carrying
61
+ // AbortError text. It stopped because something asked it to; a fatal fault
62
+ // here parks the node and fans a false permanent-failure notice.
63
+ settleAbort('aborted-node', false, false, 'error');
64
+ assert.equal(readFault('aborted-node'), null, 'an abort-shaped error settlement must not record a provider fault');
65
+ settleAbort('provider-node', false, false, 'error', 'connection reset by peer');
61
66
  const providerFault = readFault('provider-node');
62
- assert.equal(providerFault?.kind, 'other', 'the same provider error without refresh provenance still classifies');
63
- assert.deepEqual(providerFault?.retry, { disposition: 'fatal' }, 'the same provider error without refresh provenance still records its fault');
67
+ assert.equal(providerFault?.kind, 'connection', 'a real provider error still classifies');
68
+ assert.equal(providerFault?.retry.disposition, 'auto', 'a real provider error still records its fault');
64
69
  });
65
70
  test('a failed retry settlement carries the episode forward instead of restarting it', () => {
66
71
  // The episode marker a daemon retry records before re-prompting: original
@@ -164,8 +164,18 @@ function runtimeFromRow(nodeId) {
164
164
  * row (identity + runtime from the incoming meta). Returns the hydrated view. */
165
165
  export function createNode(meta) {
166
166
  ensureHome();
167
+ const scaffolded = !existsSync(nodeDir(meta.node_id));
167
168
  ensureNodeDirs(meta.node_id);
168
- writeMeta(meta);
169
+ try {
170
+ writeMeta(meta);
171
+ }
172
+ catch (error) {
173
+ // No row exists yet, so a dir left here is invisible to every surface and to
174
+ // ghost-row reaping. Only unwind a dir this call created.
175
+ if (scaffolded)
176
+ rmSync(nodeDir(meta.node_id), { recursive: true, force: true });
177
+ throw error;
178
+ }
169
179
  seedRow(meta);
170
180
  return getNode(meta.node_id);
171
181
  }
@@ -1,7 +1,8 @@
1
1
  // Durable node-meta file primitives shared by normal canvas writes and forward migrations.
2
2
  import { randomUUID } from 'node:crypto';
3
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
3
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
5
6
  function parseObject(path, strict) {
6
7
  if (!existsSync(path))
7
8
  return null;
@@ -36,40 +37,30 @@ export function readMetaObject(path) {
36
37
  * re-entrant — the operation must use {@link writeMetaObjectLocked}, never the
37
38
  * lock-taking {@link writeJsonFileDurably}. */
38
39
  export function withMetaLock(path, operation) {
39
- const lock = `${path}.lock`;
40
- const wait = new Int32Array(new SharedArrayBuffer(4));
41
- const deadline = Date.now() + 5_000;
42
- for (;;) {
43
- try {
44
- mkdirSync(lock);
45
- break;
46
- }
47
- catch (error) {
48
- if (error.code !== 'EEXIST' || Date.now() >= deadline)
49
- throw error;
50
- Atomics.wait(wait, 0, 0, 10);
51
- }
52
- }
53
- try {
54
- return operation();
55
- }
56
- finally {
57
- rmSync(lock, { recursive: true, force: true });
58
- }
40
+ return withExclusiveDirectoryLock(`${path}.lock`, operation, {
41
+ timeoutMs: 5_000,
42
+ timeoutError: () => new Error(`timed out waiting to write node metadata: ${path}`),
43
+ });
59
44
  }
60
45
  /** Collision-free durable write (fsync file + parent dir), assuming the caller
61
46
  * already holds {@link withMetaLock} for `path`. */
62
47
  export function writeMetaObjectLocked(path, value) {
63
48
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
64
- const fd = openSync(tmp, 'w');
65
49
  try {
66
- writeSync(fd, JSON.stringify(value, null, 2));
67
- fsyncSync(fd);
50
+ const fd = openSync(tmp, 'w');
51
+ try {
52
+ writeSync(fd, JSON.stringify(value, null, 2));
53
+ fsyncSync(fd);
54
+ }
55
+ finally {
56
+ closeSync(fd);
57
+ }
58
+ renameSync(tmp, path);
68
59
  }
69
- finally {
70
- closeSync(fd);
60
+ catch (error) {
61
+ rmSync(tmp, { force: true });
62
+ throw error;
71
63
  }
72
- renameSync(tmp, path);
73
64
  const dirFd = openSync(dirname(path), 'r');
74
65
  try {
75
66
  fsyncSync(dirFd);
@@ -31,6 +31,14 @@ export interface RemoteShowResponse {
31
31
  session_file?: string | null;
32
32
  };
33
33
  follow_up: string;
34
+ fault?: {
35
+ link: string;
36
+ kind: string;
37
+ retry: {
38
+ disposition: string;
39
+ };
40
+ since: string;
41
+ } | null;
34
42
  }
35
43
  export declare class RemoteCanvasSource implements CanvasSource {
36
44
  private readonly previewEndpoint;
@@ -47,6 +47,14 @@ function isValidShowResponse(x) {
47
47
  return false;
48
48
  if (typeof x['follow_up'] !== 'string')
49
49
  return false;
50
+ const fault = x['fault'];
51
+ if (fault !== undefined && fault !== null) {
52
+ if (!isPlainObject(fault) || typeof fault['link'] !== 'string' || typeof fault['kind'] !== 'string' || typeof fault['since'] !== 'string')
53
+ return false;
54
+ const retry = fault['retry'];
55
+ if (!isPlainObject(retry) || typeof retry['disposition'] !== 'string')
56
+ return false;
57
+ }
50
58
  return true;
51
59
  }
52
60
  /** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
@@ -121,7 +129,7 @@ export class RemoteCanvasSource {
121
129
  // node/inspect.ts's remote `show` passthrough render — see only well-formed
122
130
  // neighbor entries, never a malformed one they'd dereference unguarded.
123
131
  const res = isValidShowResponse(raw)
124
- ? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
132
+ ? { ...raw, fault: raw.fault ?? null, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
125
133
  : null;
126
134
  this.nodeCache.set(id, { value: res, at: Date.now() });
127
135
  return res;
@@ -11,8 +11,8 @@
11
11
  * only `dead` is a fault. */
12
12
  export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
13
13
  /** Does stopping finalize the node? terminal = worker (finalizes on push --final);
14
- * resident = interactable while attached or holding a live obligation. An
15
- * unattended obligation-free resident completes after the daemon grace. */
14
+ * resident = an interactable conversation that parks and remains wakeable
15
+ * without being forced to submit a final. */
16
16
  export type Lifecycle = 'terminal' | 'resident';
17
17
  /** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
18
18
  export type Mode = 'base' | 'orchestrator';
@@ -24,6 +24,8 @@ export type LeafAdapter = (leaf: DeclLeafBase, commandPath: readonly string[]) =
24
24
  export interface CommandContribution {
25
25
  contributor: CommandContributorRef;
26
26
  node: DeclBranch<DeclLeafBase>;
27
+ /** Root name from the manifest, before collision qualification. */
28
+ manifestName: string;
27
29
  adaptLeaf: LeafAdapter;
28
30
  }
29
31
  /** Per-contributor collision issue. */
@@ -6,15 +6,17 @@ export function adaptPluginContributions(contributions) {
6
6
  contributions: contributions.map((contribution) => ({
7
7
  contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
8
8
  node: contribution.node,
9
+ manifestName: contribution.manifestName,
9
10
  adaptLeaf: (leaf, commandPath) => {
10
11
  const transport = contribution.transport;
12
+ const manifestCommandPath = [contribution.manifestName, ...commandPath.slice(1)];
11
13
  if (transport.kind === 'exec') {
12
14
  const executable = transport.executable;
13
15
  if (executable === undefined)
14
16
  throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
15
17
  return {
16
18
  outputKind: 'object',
17
- run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: [...commandPath], output: leaf.output }, input),
19
+ run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
18
20
  };
19
21
  }
20
22
  const httpLeaf = leaf;
@@ -24,7 +26,7 @@ export function adaptPluginContributions(contributions) {
24
26
  registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
25
27
  ...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
26
28
  ...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
27
- params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: [...commandPath],
29
+ params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
28
30
  }, input, context),
29
31
  };
30
32
  },
@@ -14,6 +14,7 @@ export interface ValidatedContribution {
14
14
  transport: PluginTransport;
15
15
  node: DeclBranchGeneric<DeclLeafGeneric>;
16
16
  manifest: ValidatedCommandManifest;
17
+ manifestName: string;
17
18
  }
18
19
  export interface PluginCommandValidation {
19
20
  plugin: InstalledPlugin;
@@ -130,7 +130,7 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
130
130
  : transport;
131
131
  if (resolvedTransport === undefined)
132
132
  return { plugin, manifestPath, transport, contributions: [], issues };
133
- return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest })), issues };
133
+ return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
134
  }
135
135
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
136
136
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
@@ -138,9 +138,9 @@ export function defineRoot(opts) {
138
138
  /** Validate and return child names for an unknown-path error. */
139
139
  function childNames(node) {
140
140
  if (node.kind === 'root')
141
- return node.subtrees.map((s) => s.name);
141
+ return node.subtrees.filter((s) => s.tier !== 'hidden').map((s) => s.name);
142
142
  if (node.kind === 'branch')
143
- return node.children.map((c) => c.name);
143
+ return node.children.filter((c) => c.tier !== 'hidden').map((c) => c.name);
144
144
  return [];
145
145
  }
146
146
  /** Walk argv tokens to the deepest matched node.
@@ -42,6 +42,10 @@ function classifyProviderText(message) {
42
42
  return 'context-overflow';
43
43
  if (/websocket.*(?:closed.*\b(?:1002|1003|1007|1008|1009|1010)\b|protocol error|policy violation|unsupported data|invalid (?:frame|payload)|message too big|mandatory extension)/i.test(m))
44
44
  return 'protocol';
45
+ // proper-lockfile contention on a credential file: another process holds the
46
+ // mutex right now, so the same request wins once it releases.
47
+ if (/lock file is already being held/i.test(m))
48
+ return 'overloaded';
45
49
  if (/overloaded|\b5\d\d\b|internal server error|api.?error|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
46
50
  return 'overloaded';
47
51
  if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|\bterminated\b|other side closed|premature close|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i.test(m))
@@ -55,6 +59,10 @@ function classifyErrno(raw) {
55
59
  if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(code)) {
56
60
  return 'connection';
57
61
  }
62
+ // A held filesystem mutex, not a broken link: back off on time rather than
63
+ // behind the online probe a connection fault waits on.
64
+ if (/^ELOCKED$/i.test(code))
65
+ return 'overloaded';
58
66
  return null;
59
67
  }
60
68
  function classifyHttp(raw) {
@@ -16,6 +16,8 @@ export interface PaginateOpts<T> {
16
16
  /** Returns the stable sort key for an item. Must match the sort order of the
17
17
  * input array — ascending by this key. */
18
18
  keyOf: (item: T) => string;
19
+ /** Compares sort keys when their order is not raw code-unit order. */
20
+ compareKeys?: (left: string, right: string) => number;
19
21
  /** 'count' → compute and return `total`; 'omit' → return `null`. */
20
22
  total: 'count' | 'omit';
21
23
  }
@@ -67,9 +67,10 @@ export function paginate(allItemsSortedAscByKey, params, opts) {
67
67
  let startIndex = 0;
68
68
  if (params.cursor !== undefined) {
69
69
  const cursorKey = decodeCursor(params.cursor); // throws CrtrError on bad cursor
70
+ const compareKeys = opts.compareKeys ?? ((left, right) => left < right ? -1 : left > right ? 1 : 0);
70
71
  // Find the first item whose key is strictly greater than cursorKey.
71
72
  // Linear scan; callers with large datasets should pre-filter upstream.
72
- startIndex = allItemsSortedAscByKey.findIndex((item) => opts.keyOf(item) > cursorKey);
73
+ startIndex = allItemsSortedAscByKey.findIndex((item) => compareKeys(opts.keyOf(item), cursorKey) > 0);
73
74
  if (startIndex === -1) {
74
75
  // All items are at or before the cursor key — list is exhausted.
75
76
  startIndex = allItemsSortedAscByKey.length;
@@ -9,6 +9,7 @@ import { emitEvent } from '../../events/emit.js';
9
9
  import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatCard, formatModelFallbackRecovery } from '../../../shared/generated-context.js';
10
10
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery.js';
11
11
  import { parseModelSpec } from '../model-selection.js';
12
+ import { endedByAbort } from '../session-cycles.js';
12
13
  import { resolveProviderCandidates } from '../../subscription-state.js';
13
14
  const MAX_TIMEOUT_MS = 2_147_483_647;
14
15
  /** Owns provider fallback and daemon-driven fault retry state for one broker session. */
@@ -90,6 +91,11 @@ export class FaultRetry {
90
91
  if (refreshAbort)
91
92
  return;
92
93
  const raw = typeof last.errorMessage === 'string' ? last.errorMessage : 'engine error (no errorMessage recorded)';
94
+ // An abort landing between requests reaches pi's catch as a bare AbortError
95
+ // and settles as an error rather than `aborted`. Something asked that turn
96
+ // to stop, so there is no provider fault to record.
97
+ if (endedByAbort({ stopReason: 'error', errorMessage: raw }))
98
+ return;
93
99
  const uncompactedOverflow = isContextOverflow(last, settledSession.model?.contextWindow);
94
100
  const classified = classify('pi→provider', last);
95
101
  const coolingDeadline = extractCoolingDeadline(last);
@@ -8,6 +8,55 @@ import { abortSessionFromExtension } from './extension-abort.js';
8
8
  const MAX_ADMITTED_REPLACEMENTS = 2;
9
9
  const REPLACEMENT_DEADLINE_MS = 30_000;
10
10
  const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
11
+ const TRAILING_THINKING_PLACEHOLDER = '(interrupted)';
12
+ const TRAILING_THINKING_GUARD = Symbol.for('crtr.trailing-thinking-guard');
13
+ /** The last block an Anthropic request will actually carry: the serializer
14
+ * drops whitespace-only text and signature-less empty thinking. */
15
+ function lastSerializedBlock(content) {
16
+ for (let i = content.length - 1; i >= 0; i--) {
17
+ const block = content[i];
18
+ if (block === undefined)
19
+ continue;
20
+ if (block.type === 'text') {
21
+ if (block.text.trim().length === 0)
22
+ continue;
23
+ return block;
24
+ }
25
+ if (block.type === 'thinking') {
26
+ const signature = block.thinkingSignature;
27
+ const hasSignature = typeof signature === 'string' && signature.trim().length > 0;
28
+ if (block.redacted !== true && block.thinking.trim().length === 0 && !hasSignature)
29
+ continue;
30
+ return block;
31
+ }
32
+ return block;
33
+ }
34
+ return undefined;
35
+ }
36
+ function guardTrailingThinking(messages) {
37
+ return messages.map((message) => {
38
+ if (message.role !== 'assistant')
39
+ return message;
40
+ if (lastSerializedBlock(message.content)?.type !== 'thinking')
41
+ return message;
42
+ return { ...message, content: [...message.content, { type: 'text', text: TRAILING_THINKING_PLACEHOLDER }] };
43
+ });
44
+ }
45
+ /** Crouter aborts live streams (watchdog, extension abort, viewer esc), which
46
+ * persists assistant turns that end on a thinking block. Anthropic rejects a
47
+ * request whose final assistant block is `thinking`, and pi-ai's serializer
48
+ * has no guard for it, so every later turn re-fails on embedded history. */
49
+ function installTrailingThinkingGuard(agent) {
50
+ const inner = agent.convertToLlm;
51
+ if (inner[TRAILING_THINKING_GUARD] === true)
52
+ return;
53
+ const wrapped = (messages) => {
54
+ const converted = inner(messages);
55
+ return converted instanceof Promise ? converted.then(guardTrailingThinking) : guardTrailingThinking(converted);
56
+ };
57
+ wrapped[TRAILING_THINKING_GUARD] = true;
58
+ agent.convertToLlm = wrapped;
59
+ }
11
60
  /** Owns session subscription replacement, its serialization lane, and replacement admission policy. */
12
61
  export class RebindDriver {
13
62
  deps;
@@ -86,6 +135,7 @@ export class RebindDriver {
86
135
  });
87
136
  agent.steeringMode = 'all';
88
137
  agent.followUpMode = 'all';
138
+ installTrailingThinkingGuard(agent);
89
139
  }
90
140
  if (this.deps.cfg.editorName !== undefined && this.deps.cfg.editorName !== '') {
91
141
  try {
@@ -32,7 +32,7 @@ export async function resolveLaunchTarget(req, options = {}) {
32
32
  await assertLaunchKind(kind, cwd, profileId);
33
33
  const mode = (req.mode ?? 'base');
34
34
  const launch = (await buildLaunchSpecAsync(kind, mode, {
35
- lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
35
+ lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
36
36
  hasManager: req.root !== true,
37
37
  ...(req.model !== undefined ? { model: req.model } : {}),
38
38
  cwd,
@@ -15,8 +15,8 @@ export type { PortableModelSelection } from './model-selection.js';
15
15
  * stays unpinned. */
16
16
  export declare function isProviderPinnedModelToken(model: string): boolean;
17
17
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
18
- * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
19
- * i.e. a form `normalizeModel` resolves through the ladders rather than
18
+ * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
19
+ * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
20
20
  * passing through as a concrete id. Portable tokens are safe to PERSIST as
21
21
  * defaults (`persistDefaultKindModel`): they re-resolve against whatever
22
22
  * ladder the running build ships, so they never freeze a stale concrete
@@ -85,10 +85,14 @@ function findLadderCell(normalized, ladders, providers) {
85
85
  * strong/medium/light, ALWAYS anthropic (ignores the env/config default).
86
86
  * 4. Anything else → pass through unchanged. */
87
87
  function modelTokenStrength(model) {
88
- const rest = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
88
+ const slash = model.indexOf('/');
89
+ const provider = slash === -1 ? undefined : model.slice(0, slash);
90
+ const rest = slash === -1 ? model : model.slice(slash + 1);
89
91
  const strength = STRENGTH_ALIASES[rest];
90
92
  if (strength !== undefined)
91
93
  return strength;
94
+ if (provider !== undefined && provider !== 'anthropic')
95
+ return null;
92
96
  if (rest === 'opus')
93
97
  return 'strong';
94
98
  if (rest === 'sonnet')
@@ -133,8 +137,8 @@ export function isProviderPinnedModelToken(model) {
133
137
  return model.includes('/') && !isPortableModelToken(model);
134
138
  }
135
139
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
136
- * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
137
- * i.e. a form `normalizeModel` resolves through the ladders rather than
140
+ * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
141
+ * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
138
142
  * passing through as a concrete id. Portable tokens are safe to PERSIST as
139
143
  * defaults (`persistDefaultKindModel`): they re-resolve against whatever
140
144
  * ladder the running build ships, so they never freeze a stale concrete
@@ -144,7 +148,8 @@ export function isPortableModelToken(model) {
144
148
  const slash = model.indexOf('/');
145
149
  const provider = model.slice(0, slash);
146
150
  const rest = model.slice(slash + 1);
147
- return (provider === 'anthropic' || provider === 'openai') && STRENGTH_ALIASES[rest] !== undefined;
151
+ return (provider === 'anthropic' || provider === 'openai') && (STRENGTH_ALIASES[rest] !== undefined
152
+ || (provider === 'anthropic' && (rest === 'opus' || rest === 'sonnet' || rest === 'haiku')));
148
153
  }
149
154
  return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
150
155
  }
@@ -168,6 +173,14 @@ export function normalizeModel(model, ladders = modelLadders()) {
168
173
  if (strength !== undefined && (provider === 'anthropic' || provider === 'openai')) {
169
174
  return ladderFor(provider, ladders)[strength];
170
175
  }
176
+ if (provider === 'anthropic') {
177
+ if (rest === 'opus')
178
+ return ladders.anthropic.strong;
179
+ if (rest === 'sonnet')
180
+ return ladders.anthropic.medium;
181
+ if (rest === 'haiku')
182
+ return ladders.anthropic.light;
183
+ }
171
184
  // Concrete `provider/id` (optionally `...:thinking`) — pass through.
172
185
  return model;
173
186
  }
@@ -15,19 +15,11 @@ export interface SpawnChildOpts {
15
15
  * subscription back to the spawner, spawned_by=spawner. Resident and brought
16
16
  * forefront by default — see `rootLifecycle` for the unattended variant. */
17
17
  root?: boolean;
18
- /** Only meaningful with `root`. The independent node's lifecycle.
19
- *
20
- * `'resident'` (default) is the ATTENDED root: a standing conversation a
21
- * human picks up, so it owes no final, never reaps on its own, and is
22
- * brought forefront in tmux on spawn.
23
- *
24
- * `'terminal'` is the UNATTENDED root: still parentless (nobody subscribes,
25
- * so nothing receives its push) but it still owes a final, so it reaps when
26
- * its work is done — and it is NEVER brought forefront. This is what
27
- * machinery firing with no human present must birth: the cron executor
28
- * spawns one when its cron's creating node is gone. A resident root there
29
- * would accrete one permanently-live node per fire (the count-based prune
30
- * never deletes live nodes) and steal the human's tmux focus every time. */
18
+ /** Lifecycle for the new node. Roots default to resident and managed
19
+ * children default to terminal. A resident child remains an interactable,
20
+ * wakeable conversation without owing a final; a terminal child remains a
21
+ * bounded worker that must finalize. For roots, terminal keeps the
22
+ * unattended no-viewer behavior. */
31
23
  rootLifecycle?: Lifecycle;
32
24
  /** Direct root creation waits until the broker accepts viewers before it
33
25
  * returns. Cron-launched births use `launch`: the daemon must never wait in
@@ -209,16 +209,10 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
209
209
  const root = opts.root === true;
210
210
  const spawner = resolveSpawner(opts.parent, ctx.nodeId ?? null, root);
211
211
  const mode = opts.mode ?? 'base';
212
- // Lifecycle keys on ROOT-ness only, independent of mode: an independent root
213
- // (or `--root`) is resident by default (a conversation that persists, woken by
214
- // inbox/human); every spawned child is terminal — it owes a final up the spine
215
- // and reaps when done. A child born as an orchestrator is terminal/orchestrator
216
- // (delegates + holds a roadmap, but still reports up), NOT resident.
217
- //
218
- // `rootLifecycle: 'terminal'` is the one deliberate exception: a parentless
219
- // node that STILL owes a final (see the option's docblock). It is unattended
220
- // by construction, so `attendedRoot` below also suppresses viewer placement.
221
- const lifecycle = root ? (opts.rootLifecycle ?? 'resident') : 'terminal';
212
+ // Roots are resident by default; managed children are terminal workers by
213
+ // default. The one lifecycle option deliberately applies to both shapes so a
214
+ // child can be born as a standing, wakeable conversation too.
215
+ const lifecycle = opts.rootLifecycle ?? (root ? 'resident' : 'terminal');
222
216
  const attendedRoot = root && lifecycle === 'resident';
223
217
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
224
218
  // its first message (the kickoff task) inside its own pi process, so spawn
@@ -17,8 +17,10 @@
17
17
  // • finished/asked → it pushed --final (done) or called `crtr ask` this turn.
18
18
  // Also fine.
19
19
  // • structured → a TERMINAL node has a pending structured-output request.
20
- // Valid requests reprompt with the schema; invalid requests
21
- // reprompt with an error and require manual cleanup.
20
+ // A valid request reprompts with the schema unless the node
21
+ // also holds a wake source, which it may sleep on first;
22
+ // an invalid request reprompts with an error and requires
23
+ // manual cleanup.
22
24
  // • otherwise → a TERMINAL node with nothing live to wait for and no
23
25
  // final pushed. Re-prompt it to finish or escalate.
24
26
  import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
@@ -39,6 +41,19 @@ function formatInvalidStructuredOutputReprompt(error) {
39
41
  `After resolving the file, retry the submission. ` +
40
42
  `If this is unexpected, escalate to the user using \`crtr human send\`.`);
41
43
  }
44
+ /** The wake sources that legitimize a terminal node's dormancy: a pending
45
+ * cancel-on-wake deadline, an active subscription to a live publisher, an
46
+ * explicit live controller wait, or a live background bash job whose completion
47
+ * sends an inbox message. Parentage alone is deliberately absent. */
48
+ function wakeCapableWait(nodeId, backgroundJobsRunning) {
49
+ if (hasPendingCancelOnWakeCron(nodeId))
50
+ return 'scheduled';
51
+ if (hasActiveLiveSubscription(nodeId)
52
+ || hasLiveMessageWait(nodeId)
53
+ || backgroundJobsRunning)
54
+ return 'awaiting';
55
+ return null;
56
+ }
42
57
  /** Decide what to do when a node stops. crtrd supplies every current-canvas
43
58
  * fact; the broker contributes only whether its Pi-owned background job registry
44
59
  * is still running work. */
@@ -49,9 +64,14 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
49
64
  return { action: 'allow', reason: 'escalated' };
50
65
  // A pending structured-output request overrides every stored-state dormancy
51
66
  // path: a post-final one-off follow-up still owes `submit` before it may stop.
52
- // Invalid requests surface an explicit error and require manual cleanup.
67
+ // A wake source is the exception — the request outlives the sleep, so
68
+ // reprompting instead would busy-wait the node against its own child, deadline,
69
+ // or job. Invalid requests surface an explicit error and require manual cleanup.
53
70
  const outputState = readOutputRequest(nodeId);
54
71
  if (outputState.state === 'valid') {
72
+ const pendingWait = wakeCapableWait(nodeId, backgroundJobsRunning);
73
+ if (pendingWait !== null)
74
+ return { action: 'allow', reason: pendingWait };
55
75
  return {
56
76
  action: 'reprompt',
57
77
  reason: 'stalled',
@@ -82,19 +102,11 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
82
102
  if (node !== null && node.lifecycle === 'resident') {
83
103
  return { action: 'allow', reason: 'dormant' };
84
104
  }
85
- // A terminal node with a pending cancel-on-wake deadline is legitimately
86
- // waiting on the clock, so it should idle-release rather than busy-wait
87
- // holding its broker.
88
- if (hasPendingCancelOnWakeCron(nodeId))
89
- return { action: 'allow', reason: 'scheduled' };
90
- // A terminal node holding something live to wake it is legitimately awaiting.
91
- // Parentage alone is deliberately absent: only an active subscription, an
92
- // explicit controller wait, or a live background bash job whose completion
93
- // sends an inbox message can suppress the stall guard.
94
- if (hasActiveLiveSubscription(nodeId)
95
- || hasLiveMessageWait(nodeId)
96
- || backgroundJobsRunning)
97
- return { action: 'allow', reason: 'awaiting' };
105
+ // A terminal node holding a wake source is legitimately waiting: it should
106
+ // idle-release rather than busy-wait holding its broker.
107
+ const pendingWait = wakeCapableWait(nodeId, backgroundJobsRunning);
108
+ if (pendingWait !== null)
109
+ return { action: 'allow', reason: pendingWait };
98
110
  // A terminal node with nothing live and no final pushed has stalled.
99
111
  return {
100
112
  action: 'reprompt',
@@ -1,4 +1,5 @@
1
1
  import { type SurfaceEntry } from './schema.js';
2
+ export declare const SUPPRESSIBLE_RULES: readonly string[];
2
3
  /** Strict validation of one raw routing entry. Runtime parsing deliberately
3
4
  * tolerates malformed docs; this authoring/preflight contract does not. */
4
5
  export declare function lintSubstrateSurfaces(value: unknown): string | null;
@@ -1,7 +1,7 @@
1
1
  import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
2
2
  const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
3
3
  const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'gate', 'at'];
4
- const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
4
+ export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
5
5
  /** Strict validation of one raw routing entry. Runtime parsing deliberately
6
6
  * tolerates malformed docs; this authoring/preflight contract does not. */
7
7
  export function lintSubstrateSurfaces(value) {