@crouton-kit/crouter 0.3.70 → 0.3.78

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 (147) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -31,6 +31,7 @@ export interface SetupStaticInstructions {
31
31
  packageLines: string[];
32
32
  systemLines: string[];
33
33
  exaLine: string;
34
+ composioLine: string;
34
35
  footer: string;
35
36
  }
36
37
  export interface SetupSubmission {
@@ -39,18 +40,23 @@ export interface SetupSubmission {
39
40
  systemDeps: SetupSystemInstallPlan[];
40
41
  exaKey: string;
41
42
  exaKeyPath: string;
43
+ composioKey: string;
44
+ composioKeyPath: string;
42
45
  }
43
46
  export interface SetupExecutionSummary {
44
47
  installed: string[];
45
48
  skipped: string[];
46
49
  exaKeyWritten: boolean;
47
50
  exaKeyPath: string;
51
+ composioKeyWritten: boolean;
52
+ composioKeyPath: string;
48
53
  }
49
54
  export interface SetupInitialState {
50
55
  manifest: SetupPackageManifestEntry[];
51
56
  installedPackageIds: Set<SetupPackageId>;
52
57
  systemTools: SetupSystemToolState[];
53
58
  exaKeyPath: string;
59
+ composioKeyPath: string;
54
60
  }
55
61
  export declare function buildSetupManifest(resolveBuiltinPackageDir?: (name: string) => string): SetupPackageManifestEntry[];
56
62
  export declare function normalizePackageIdentity(source: string, settingsDir: string, homeDir: string): SetupPackageIdentity;
@@ -61,6 +67,7 @@ export declare function buildSystemInstallPlan(tool: SetupTool, packageManager:
61
67
  export declare function buildSystemToolState(tool: SetupTool, packageManager: PackageManager, commandExists?: (command: string) => boolean): SetupSystemToolState;
62
68
  export declare function resolveSettingsPath(homeDir?: string): string;
63
69
  export declare function resolveExaKeyPath(homeDir?: string): string;
70
+ export declare function resolveComposioKeyPath(homeDir?: string): string;
64
71
  export declare function buildSetupInitialState(opts?: {
65
72
  manifest?: SetupPackageManifestEntry[];
66
73
  settingsPath?: string;
@@ -73,9 +80,11 @@ export declare function buildSetupStaticInstructions(opts: {
73
80
  installedPackageIds: Set<SetupPackageId>;
74
81
  systemPlans: SetupSystemInstallPlan[];
75
82
  exaKeyPath: string;
83
+ composioKeyPath: string;
76
84
  }): SetupStaticInstructions;
77
- export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath'>): string;
85
+ export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
78
86
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
87
+ export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
79
88
  export declare function summarizeSetupExecution(result: {
80
89
  installedPackages: string[];
81
90
  skippedPackages: string[];
@@ -83,6 +92,8 @@ export declare function summarizeSetupExecution(result: {
83
92
  skippedSystemDeps: string[];
84
93
  exaKeyWritten: boolean;
85
94
  exaKeyPath: string;
95
+ composioKeyWritten: boolean;
96
+ composioKeyPath: string;
86
97
  }): SetupExecutionSummary;
87
98
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
88
99
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;
@@ -13,8 +13,8 @@ const PACKAGE_COPY = {
13
13
  },
14
14
  'mode-switch': {
15
15
  name: 'pi-mode-switch',
16
- shortDescription: 'Alt+M cycles spec / plan / normal modes; injects mode-specific guidance on change.',
17
- longDescription: 'Press Alt+M to rotate between three operating modes — spec (requirements gathering), plan (design before code), and normal (execution). Each switch appends mode-tailored guidance to keep the agent in the right gear.',
16
+ shortDescription: 'Alt+Shift+P cycles spec / plan / normal modes; injects mode-specific guidance on change.',
17
+ longDescription: 'Press Alt+Shift+P to rotate between three operating modes — spec (requirements gathering), plan (design before code), and normal (execution). Each switch appends mode-tailored guidance to keep the agent in the right gear.',
18
18
  },
19
19
  'oauth-adapter': {
20
20
  name: 'pi-claude-oauth-adapter',
@@ -231,6 +231,9 @@ export function resolveSettingsPath(homeDir = homedir()) {
231
231
  export function resolveExaKeyPath(homeDir = homedir()) {
232
232
  return join(homeDir, '.crouter', 'exa.key');
233
233
  }
234
+ export function resolveComposioKeyPath(homeDir = homedir()) {
235
+ return join(homeDir, '.crouter', 'composio.key');
236
+ }
234
237
  export function buildSetupInitialState(opts = {}) {
235
238
  const homeDir = opts.homeDir ?? homedir();
236
239
  const settingsPath = opts.settingsPath ?? resolveSettingsPath(homeDir);
@@ -240,7 +243,7 @@ export function buildSetupInitialState(opts = {}) {
240
243
  const commandExists = opts.commandExists ?? detectCommandOnPath;
241
244
  const packageManager = opts.packageManager ?? detectPackageManager(process.platform, commandExists);
242
245
  const systemTools = ['tmux', 'nvim'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
243
- return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir) };
246
+ return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir), composioKeyPath: resolveComposioKeyPath(homeDir) };
244
247
  }
245
248
  export function buildSetupStaticInstructions(opts) {
246
249
  return {
@@ -257,6 +260,7 @@ export function buildSetupStaticInstructions(opts) {
257
260
  return `- ${plan.tool}: ${plan.hint}`;
258
261
  }),
259
262
  exaLine: `- Exa key: write to ${opts.exaKeyPath} or set EXA_API_KEY`,
263
+ composioLine: `- Composio key: write to ${opts.composioKeyPath} or set COMPOSIO_API_KEY`,
260
264
  footer: 'Run `crtr sys setup` in a TTY to open the wizard.',
261
265
  };
262
266
  }
@@ -266,6 +270,7 @@ export function renderSetupStaticInstructions(state) {
266
270
  installedPackageIds: state.installedPackageIds,
267
271
  systemPlans: state.systemTools.map((tool) => tool.plan),
268
272
  exaKeyPath: state.exaKeyPath,
273
+ composioKeyPath: state.composioKeyPath,
269
274
  });
270
275
  return [
271
276
  instructions.headline,
@@ -279,6 +284,7 @@ export function renderSetupStaticInstructions(state) {
279
284
  ...instructions.systemLines,
280
285
  '',
281
286
  instructions.exaLine,
287
+ instructions.composioLine,
282
288
  instructions.footer,
283
289
  ].join('\n') + '\n';
284
290
  }
@@ -299,12 +305,19 @@ export async function writeExaKey(exaKeyPath, exaKey) {
299
305
  await writeFile(exaKeyPath, `${exaKey}\n`, 'utf8');
300
306
  await chmod(exaKeyPath, 0o600).catch(() => undefined);
301
307
  }
308
+ export async function writeComposioKey(composioKeyPath, composioKey) {
309
+ await mkdir(dirname(composioKeyPath), { recursive: true });
310
+ await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
311
+ await chmod(composioKeyPath, 0o600).catch(() => undefined);
312
+ }
302
313
  export function summarizeSetupExecution(result) {
303
314
  return {
304
315
  installed: [...result.installedPackages, ...result.installedSystemDeps],
305
316
  skipped: [...result.skippedPackages, ...result.skippedSystemDeps],
306
317
  exaKeyWritten: result.exaKeyWritten,
307
318
  exaKeyPath: result.exaKeyPath,
319
+ composioKeyWritten: result.composioKeyWritten,
320
+ composioKeyPath: result.composioKeyPath,
308
321
  };
309
322
  }
310
323
  export function formatSetupExecutionSummary(summary) {
@@ -313,6 +326,7 @@ export function formatSetupExecutionSummary(summary) {
313
326
  summary.installed.length > 0 ? `installed: ${summary.installed.join(', ')}` : 'installed: none',
314
327
  summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
315
328
  summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
329
+ summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
316
330
  ].join('\n') + '\n';
317
331
  }
318
332
  export async function executeSetupSubmission(submission) {
@@ -344,6 +358,10 @@ export async function executeSetupSubmission(submission) {
344
358
  if (exaKeyWritten) {
345
359
  await writeExaKey(submission.exaKeyPath, submission.exaKey);
346
360
  }
361
+ const composioKeyWritten = submission.composioKey.trim() !== '';
362
+ if (composioKeyWritten) {
363
+ await writeComposioKey(submission.composioKeyPath, submission.composioKey);
364
+ }
347
365
  return summarizeSetupExecution({
348
366
  installedPackages,
349
367
  skippedPackages,
@@ -351,5 +369,7 @@ export async function executeSetupSubmission(submission) {
351
369
  skippedSystemDeps,
352
370
  exaKeyWritten,
353
371
  exaKeyPath: submission.exaKeyPath,
372
+ composioKeyWritten,
373
+ composioKeyPath: submission.composioKeyPath,
354
374
  });
355
375
  }
@@ -33,7 +33,7 @@ const theme = {
33
33
  return getMarkdownTheme().bold(text);
34
34
  },
35
35
  };
36
- import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, } from './setup-core.js';
36
+ import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, resolveComposioKeyPath, } from './setup-core.js';
37
37
  function wrapText(text, width) {
38
38
  const clean = text.trim();
39
39
  if (clean === '')
@@ -102,6 +102,7 @@ class SetupWizard {
102
102
  installedPackageIds;
103
103
  systemTools;
104
104
  exaInput = new Input();
105
+ composioInput = new Input();
105
106
  selectedPackageIds = new Set();
106
107
  selectedSystemTools = new Set();
107
108
  authSummary;
@@ -134,10 +135,13 @@ class SetupWizard {
134
135
  this.systemIndex = firstMissingSystem >= 0 ? firstMissingSystem : 0;
135
136
  this.exaInput.onSubmit = () => this.finalize();
136
137
  this.exaInput.onEscape = () => this.cancel();
138
+ this.composioInput.onSubmit = () => this.finalize();
139
+ this.composioInput.onEscape = () => this.cancel();
137
140
  }
138
141
  set focused(value) {
139
142
  this._focused = value;
140
143
  this.exaInput.focused = value && this.activeTab === 3 && !this.confirming;
144
+ this.composioInput.focused = value && this.activeTab === 4 && !this.confirming;
141
145
  }
142
146
  get focused() {
143
147
  return this._focused;
@@ -159,10 +163,11 @@ class SetupWizard {
159
163
  setTab(next) {
160
164
  this.activeTab = next;
161
165
  this.exaInput.focused = this.focused && next === 3 && !this.confirming;
166
+ this.composioInput.focused = this.focused && next === 4 && !this.confirming;
162
167
  this.invalidate();
163
168
  }
164
169
  moveTab(delta) {
165
- const next = ((this.activeTab + delta + 4) % 4);
170
+ const next = ((this.activeTab + delta + 5) % 5);
166
171
  this.setTab(next);
167
172
  }
168
173
  cancel() {
@@ -246,6 +251,8 @@ class SetupWizard {
246
251
  systemDeps: this.systemTools.filter((tool) => !tool.installed && this.selectedSystemTools.has(tool.tool)).map((tool) => tool.plan),
247
252
  exaKey: this.exaInput.getValue().trim(),
248
253
  exaKeyPath: resolveExaKeyPath(),
254
+ composioKey: this.composioInput.getValue().trim(),
255
+ composioKeyPath: resolveComposioKeyPath(),
249
256
  };
250
257
  }
251
258
  finalize() {
@@ -328,10 +335,14 @@ class SetupWizard {
328
335
  }
329
336
  return;
330
337
  }
331
- this.exaInput.handleInput(data);
338
+ if (this.activeTab === 3) {
339
+ this.exaInput.handleInput(data);
340
+ return;
341
+ }
342
+ this.composioInput.handleInput(data);
332
343
  }
333
344
  renderHeader(width) {
334
- const tabs = ['Login', 'Plugins', 'System deps', 'Exa key'];
345
+ const tabs = ['Login', 'Plugins', 'System deps', 'Exa key', 'Composio key'];
335
346
  const tabLine = tabs
336
347
  .map((label, index) => tabPill(`${index + 1}. ${label}`, index === this.activeTab))
337
348
  .join(theme.fg('dim', ' · '));
@@ -350,7 +361,9 @@ class SetupWizard {
350
361
  ? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
351
362
  : this.activeTab === 2
352
363
  ? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
353
- : 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel';
364
+ : this.activeTab === 3
365
+ ? 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel'
366
+ : 'type COMPOSIO_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel';
354
367
  return [truncateToWidth(theme.fg('dim', line), width)];
355
368
  }
356
369
  renderLogin(width) {
@@ -430,10 +443,22 @@ class SetupWizard {
430
443
  ], width));
431
444
  return out;
432
445
  }
446
+ renderComposio(width) {
447
+ const out = [];
448
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Composio key'))}${theme.fg('dim', ' optional · powers /v1/connectors third-party service access')}`, width));
449
+ out.push(truncateToWidth(`${theme.fg('mdLink', 'Create a key: ')}${theme.fg('mdLinkUrl', 'https://platform.composio.dev/developers')}`, width));
450
+ out.push(truncateToWidth(theme.fg('dim', `Will write to: ${resolveComposioKeyPath()}`), width));
451
+ out.push(...framed([
452
+ theme.fg('accent', theme.bold('Paste COMPOSIO_API_KEY here')),
453
+ theme.fg('dim', 'Leave blank to skip.'),
454
+ ...this.composioInput.render(Math.max(1, width - 4)),
455
+ ], width));
456
+ return out;
457
+ }
433
458
  renderConfirm(width) {
434
459
  const out = [truncateToWidth(theme.fg('warning', theme.bold('Confirm installs')), width)];
435
460
  const submission = this.buildSubmission();
436
- if (submission.packages.length === 0 && submission.systemDeps.length === 0 && submission.exaKey === '') {
461
+ if (submission.packages.length === 0 && submission.systemDeps.length === 0 && submission.exaKey === '' && submission.composioKey === '') {
437
462
  out.push(truncateToWidth(theme.fg('success', 'Nothing to install; press enter to finish.'), width));
438
463
  return out;
439
464
  }
@@ -455,6 +480,9 @@ class SetupWizard {
455
480
  if (submission.exaKey !== '') {
456
481
  out.push(truncateToWidth(`${theme.fg('accent', 'Exa key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.exaKeyPath}`)}`, width));
457
482
  }
483
+ if (submission.composioKey !== '') {
484
+ out.push(truncateToWidth(`${theme.fg('accent', 'Composio key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.composioKeyPath}`)}`, width));
485
+ }
458
486
  out.push(truncateToWidth(theme.fg('dim', 'y/enter confirm · n/esc go back'), width));
459
487
  return out;
460
488
  }
@@ -472,7 +500,9 @@ class SetupWizard {
472
500
  ? this.renderPlugins(width)
473
501
  : this.activeTab === 2
474
502
  ? this.renderSystems(width)
475
- : this.renderExa(width)),
503
+ : this.activeTab === 3
504
+ ? this.renderExa(width)
505
+ : this.renderComposio(width)),
476
506
  '',
477
507
  ...this.renderFooter(width),
478
508
  ];
@@ -611,18 +641,18 @@ async function runSetup() {
611
641
  }
612
642
  export const sysSetupLeaf = defineLeaf({
613
643
  name: 'setup',
614
- description: 'log in to a model provider, install the bundled pi plugins, system deps, and Exa key setup',
615
- whenToUse: 'you want to bootstrap this machine for crtr in one pass: log in to a model provider (same flow as /login), install the vendored pi plugins, confirm tmux and nvim, and save your Exa API key.',
644
+ description: 'log in to a model provider, install the bundled pi plugins, system deps, and Exa + Composio key setup',
645
+ whenToUse: 'you want to bootstrap this machine for crtr in one pass: log in to a model provider (same flow as /login), install the vendored pi plugins, confirm tmux and nvim, and save your Exa and Composio API keys.',
616
646
  help: {
617
647
  name: 'sys setup',
618
- summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa key',
648
+ summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa + Composio keys',
619
649
  inputNote: 'No input parameters.',
620
650
  output: [],
621
651
  outputKind: 'object',
622
652
  effects: [
623
653
  'Opens a pi-tui wizard whose first tab logs in to a model provider (pi\'s real OAuth/API-key flow, same as in-app /login) so a fresh install has credentials before running any node.',
624
- 'Collects companion package installs, system dependency installs, and an Exa API key across the remaining tabs.',
625
- 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
654
+ 'Collects companion package installs, system dependency installs, an Exa API key, and a Composio API key across the remaining tabs.',
655
+ 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key and COMPOSIO_API_KEY to ~/.crouter/composio.key when provided, and prints a summary.',
626
656
  ],
627
657
  },
628
658
  run: async () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { renderPreferencesForSubject } from '../substrate/render.js';
4
+ function subject(mode) {
5
+ return {
6
+ kind: 'plan/reviewers/security',
7
+ mode,
8
+ lifecycle: 'terminal',
9
+ hasManager: true,
10
+ cwd: process.cwd(),
11
+ scope: 'project',
12
+ orchestration: { depth: 1 },
13
+ profile: null,
14
+ };
15
+ }
16
+ test('base sub-personas receive hands-on guidance instead of universal delegation pressure', () => {
17
+ const base = renderPreferencesForSubject(subject('base')).block;
18
+ assert.match(base, /You are a hands-on worker/);
19
+ assert.match(base, /Each child gets a bounded outcome distinct from your whole assignment/);
20
+ assert.doesNotMatch(base, /Hand any self-contained unit of work to a child/);
21
+ const orchestrator = renderPreferencesForSubject(subject('orchestrator')).block;
22
+ assert.doesNotMatch(orchestrator, /You are a hands-on worker/);
23
+ assert.match(orchestrator, /You are an orchestrator/);
24
+ });
@@ -10,7 +10,8 @@ import { mkdirSync, rmSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
- import { appendInbox } from '../feed/inbox.js';
13
+ import { appendInbox, readCursor } from '../feed/inbox.js';
14
+ import { appendSituationalContext } from '../runtime/situational-context.js';
14
15
  import { createNode, setIntent } from '../canvas/canvas.js';
15
16
  import { closeDb } from '../canvas/db.js';
16
17
  // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
@@ -36,11 +37,25 @@ function makeFakePi() {
36
37
  const handlers = {};
37
38
  return {
38
39
  injected: [],
40
+ sentMessages: [],
39
41
  on(e, h) { handlers[e] = h; },
40
42
  sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
43
+ sendMessage(message) { this.sentMessages.push(message); },
41
44
  fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
42
45
  };
43
46
  }
47
+ // Fire agent_settled — the ONLY durable-commit boundary in the at-least-once
48
+ // model. `pending` maps to pi's `hasPendingMessages()`: false = pi's
49
+ // steer/followUp queue is drained (the normal production case — pi 0.80.6 drains
50
+ // retry/queued continuations within the same prompt cycle before this settle —
51
+ // so commit proceeds); true = the context still reports an unconsumed handed-off
52
+ // send, which exercises the extension's DEFENSIVE coarse hold (commit is HELD
53
+ // until a later drained settle). The true case is an API-behavior scenario for
54
+ // the guard, not a lifecycle the normal production settle emits.
55
+ function settle(pi, opts = {}) {
56
+ const pending = opts.pending === true;
57
+ pi.fire('agent_settled', { type: 'agent_settled' }, { isIdle: () => true, hasPendingMessages: () => pending });
58
+ }
44
59
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
45
60
  let origTick;
46
61
  let origDebounce;
@@ -116,3 +131,219 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
116
131
  assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
117
132
  });
118
133
  });
134
+ // Durable-cursor model (at-least-once, one conservative boundary): the in-memory
135
+ // `cursor` advances at buffer time (so a burst is never re-read into the same
136
+ // watcher's buffer), but the on-disk cursor commits ONLY at `agent_settled` (and
137
+ // `session_shutdown` as the graceful safety net) — never on the fire-and-forget
138
+ // send return, never at `agent_end` (which fires before pi decides on retry/
139
+ // continuation). A send returning normally is a HANDOFF, and the commit clamps
140
+ // below anything still buffered. When pi still holds an unconsumed handed-off
141
+ // send — a failed/aborted turn RETAINS its in-memory steer/followUp queue for the
142
+ // next turn — `ctx.hasPendingMessages()` is true and the commit is HELD until a
143
+ // later settle drains it. A watcher that dies (crash, SIGTERM, daemon kick)
144
+ // before a committing settle leaves the on-disk cursor behind, so a fresh watcher
145
+ // re-reads and re-delivers rather than silently losing the entry. See
146
+ // canvas-inbox-watcher.ts's header/inline comments on `handedOffWatermark`,
147
+ // `commitTarget`, and `commitAtSettle` for the full rationale.
148
+ describe('canvas inbox watcher — durable cursor (at-least-once)', () => {
149
+ test('mid-turn handoff, crash before settle: on-disk cursor NOT advanced, fresh watcher re-delivers', async () => {
150
+ freshNode('node-cursor-crash');
151
+ const pi1 = makeFakePi();
152
+ disposers.push(registerCanvasInboxWatcher(pi1));
153
+ // Streaming (mid-turn) so this delivers via followUp, not the idle path.
154
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
155
+ await wait(TICK_MS + 100);
156
+ appendInbox('node-cursor-crash', { from: 'child-1', tier: 'normal', kind: 'update', label: 'still working' });
157
+ await wait(SETTLE_MS);
158
+ assert.equal(pi1.injected.length, 1, 'delivered mid-turn as a followUp');
159
+ assert.equal(pi1.injected[0].deliverAs, 'followUp');
160
+ // The durability boundary: no settle has fired, so the on-disk cursor must
161
+ // still be untouched — this is the assertion that proves the fix (a
162
+ // commit-at-buffer-time / commit-on-send variant would have committed here).
163
+ assert.equal(readCursor('node-cursor-crash'), undefined, 'on-disk cursor not committed before a settle');
164
+ // Simulate the watcher dying without a graceful session_shutdown — never
165
+ // settle OR fire session_shutdown on pi1. The broker's SIGTERM handler DOES
166
+ // run a JS handler (disposeAndExit: session.dispose() + process.exit), but
167
+ // that path never emits this extension's session_shutdown, so its safety-net
168
+ // commit does not run on a kill. Registering a fresh watcher clears pi1's
169
+ // timer (the module-level liveTimer guard) without running its teardown,
170
+ // modeling crash-then-revive rather than a clean stop.
171
+ const pi2 = makeFakePi();
172
+ disposers.push(registerCanvasInboxWatcher(pi2));
173
+ await wait(SETTLE_MS);
174
+ assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads and re-delivers the same uncommitted entry');
175
+ assert.match(pi2.injected[0].content, /still working/);
176
+ });
177
+ test('mid-turn handoff + agent_settled: cursor committed, fresh watcher does NOT redeliver', async () => {
178
+ freshNode('node-cursor-committed');
179
+ const pi1 = makeFakePi();
180
+ disposers.push(registerCanvasInboxWatcher(pi1));
181
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
182
+ await wait(TICK_MS + 100);
183
+ const entry = appendInbox('node-cursor-committed', { from: 'child-2', tier: 'normal', kind: 'update', label: 'still working' });
184
+ await wait(SETTLE_MS);
185
+ assert.equal(pi1.injected.length, 1);
186
+ assert.equal(readCursor('node-cursor-committed'), undefined, 'not yet committed before the settle');
187
+ // The turn settles with pi's queue drained (the followUp was consumed) —
188
+ // THAT commits the cursor.
189
+ settle(pi1);
190
+ assert.equal(readCursor('node-cursor-committed'), entry.ts, 'cursor committed to disk at agent_settled');
191
+ const pi2 = makeFakePi();
192
+ disposers.push(registerCanvasInboxWatcher(pi2));
193
+ await wait(SETTLE_MS);
194
+ assert.equal(pi2.injected.length, 0, 'a fresh watcher does not redeliver an already-committed entry');
195
+ });
196
+ test('idle-path delivery: NOT committed on the void send, committed at agent_settled', async () => {
197
+ freshNode('node-cursor-idle');
198
+ const pi = makeFakePi();
199
+ disposers.push(registerCanvasInboxWatcher(pi));
200
+ // No agent_start fired → watcher treats the node as idle.
201
+ await wait(TICK_MS + 100);
202
+ const entry = appendInbox('node-cursor-idle', { from: 'child-3', tier: 'normal', kind: 'update', label: 'idle update' });
203
+ await wait(SETTLE_MS);
204
+ assert.equal(pi.injected.length, 1);
205
+ assert.equal(pi.injected[0].deliverAs, undefined, 'idle delivery has no deliverAs');
206
+ // The idle send triggers a turn, but its void return is a handoff, not proof
207
+ // of acceptance — the cursor stays put until the turn settles. (One rule: no
208
+ // immediate idle-path commit.)
209
+ assert.equal(readCursor('node-cursor-idle'), undefined, 'idle send does not commit on the fire-and-forget return');
210
+ settle(pi);
211
+ assert.equal(readCursor('node-cursor-idle'), entry.ts, 'idle-path delivery commits at the turn it started settling');
212
+ });
213
+ test('synchronous send throw then agent_settled: durable cursor NOT advanced past the re-buffered entry, fresh watcher re-delivers', async () => {
214
+ freshNode('node-cursor-failsend');
215
+ const pi1 = makeFakePi();
216
+ // Persistent synchronous send failure: the entry is read + buffered, flush
217
+ // throws, it is re-buffered and never handed off.
218
+ pi1.sendUserMessage = () => { throw new Error('send always fails'); };
219
+ disposers.push(registerCanvasInboxWatcher(pi1));
220
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
221
+ await wait(TICK_MS + 100);
222
+ appendInbox('node-cursor-failsend', { from: 'child-1', tier: 'normal', kind: 'update', label: 'never delivered' });
223
+ await wait(SETTLE_MS);
224
+ assert.equal(pi1.injected.length, 0, 'the failing send delivered nothing');
225
+ // agent_settled must NOT commit past the still-re-buffered (never-handed-off) entry.
226
+ settle(pi1);
227
+ assert.equal(readCursor('node-cursor-failsend'), undefined, 'durable cursor not advanced past a re-buffered, never-delivered entry');
228
+ const pi2 = makeFakePi();
229
+ disposers.push(registerCanvasInboxWatcher(pi2));
230
+ await wait(SETTLE_MS);
231
+ assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads and re-delivers the never-committed entry');
232
+ assert.match(pi2.injected[0].content, /never delivered/);
233
+ });
234
+ test('critical abort then abort-generated settle: pending critical entry NOT committed, fresh watcher re-delivers', async () => {
235
+ freshNode('node-cursor-critical');
236
+ const pi1 = makeFakePi();
237
+ let aborted = 0;
238
+ disposers.push(registerCanvasInboxWatcher(pi1));
239
+ // Mid-turn streaming, with an abort() seam on the ctx (a critical entry
240
+ // preempts via ctx.abort()).
241
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false, abort: () => { aborted += 1; } });
242
+ await wait(TICK_MS + 100);
243
+ appendInbox('node-cursor-critical', { from: 'child-1', tier: 'critical', kind: 'update', label: 'critical work' });
244
+ await wait(SETTLE_MS);
245
+ assert.ok(aborted >= 1, 'the critical entry aborted the live turn');
246
+ assert.equal(pi1.injected.length, 0, 'the critical path re-buffers, it does not deliver mid-stream');
247
+ // The abort-generated agent_end/settle must not commit the pending critical
248
+ // entry (it is re-buffered, never handed off).
249
+ pi1.fire('agent_end', { type: 'agent_end', messages: [] }, { isIdle: () => true });
250
+ settle(pi1);
251
+ assert.equal(readCursor('node-cursor-critical'), undefined, 'abort-generated settle does not commit the pending critical entry before its retry');
252
+ const pi2 = makeFakePi();
253
+ disposers.push(registerCanvasInboxWatcher(pi2));
254
+ await wait(SETTLE_MS);
255
+ assert.equal(pi2.injected.length, 1, 'a fresh watcher re-delivers the critical entry');
256
+ assert.match(pi2.injected[0].content, /critical work/);
257
+ });
258
+ test('batch A settles while batch B still debounces: commit stops at A, fresh watcher re-delivers B', async () => {
259
+ freshNode('node-cursor-batchB');
260
+ // Wide debounce for THIS watcher so B stays buffered-but-unflushed when we
261
+ // settle A; the watcher captures the cadence at registration, so restore the
262
+ // file default immediately after for the fresh (pi2) watcher.
263
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = '200';
264
+ const pi1 = makeFakePi();
265
+ disposers.push(registerCanvasInboxWatcher(pi1));
266
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
267
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
268
+ await wait(TICK_MS + 60);
269
+ const a = appendInbox('node-cursor-batchB', { from: 'child-a', tier: 'normal', kind: 'update', label: 'batch A' });
270
+ await wait(280); // > 200ms debounce → A flushes and is delivered mid-turn (handoff)
271
+ assert.equal(pi1.injected.length, 1, 'batch A delivered mid-turn');
272
+ assert.equal(pi1.injected[0].deliverAs, 'followUp');
273
+ const b = appendInbox('node-cursor-batchB', { from: 'child-b', tier: 'normal', kind: 'update', label: 'batch B' });
274
+ await wait(70); // B read into the buffer but < 200ms debounce → not yet flushed
275
+ // A's turn settles while B is still debouncing in the buffer. The commit must
276
+ // clamp below the still-buffered B and land on A only.
277
+ settle(pi1);
278
+ assert.equal(readCursor('node-cursor-batchB'), a.ts, 'commit lands on handed-off batch A');
279
+ assert.notEqual(readCursor('node-cursor-batchB'), b.ts, 'commit does not cross the still-buffered batch B');
280
+ const pi2 = makeFakePi();
281
+ disposers.push(registerCanvasInboxWatcher(pi2));
282
+ await wait(SETTLE_MS + 40);
283
+ assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads from A and re-delivers B');
284
+ assert.match(pi2.injected[0].content, /batch B/);
285
+ assert.doesNotMatch(pi2.injected[0].content, /batch A/, 'A is already committed, only B is re-delivered');
286
+ });
287
+ // Defensive coarse-hold guard, driven at the API boundary. In the installed pi
288
+ // 0.80.6 `AgentSession` a handed-off followUp is drained within the same
289
+ // prompt cycle before the sole extension-facing settle, so the normal
290
+ // production settle sees `hasPendingMessages() === false`. This test does NOT
291
+ // reproduce a production failure→pending-settle→later-settle lifecycle; it
292
+ // pins the guard's contract directly: WHEN a settle's context reports pending
293
+ // sends (hasPendingMessages() = true), committing would durably consume a
294
+ // message not yet delivered, so the commit is HELD (never at agent_end, never
295
+ // at the pending settle); a crash in that window re-delivers; and once a later
296
+ // settle reports a drained queue (hasPendingMessages() = false) the cursor
297
+ // commits exactly once.
298
+ test('settle reporting pending sends: HELD by the defensive guard, then committed once the queue drains', async () => {
299
+ freshNode('node-cursor-failhold');
300
+ const pi1 = makeFakePi();
301
+ disposers.push(registerCanvasInboxWatcher(pi1));
302
+ pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
303
+ await wait(TICK_MS + 100);
304
+ const entry = appendInbox('node-cursor-failhold', { from: 'child-1', tier: 'normal', kind: 'update', label: 'queued across a failure' });
305
+ await wait(SETTLE_MS);
306
+ assert.equal(pi1.injected.length, 1, 'delivered mid-turn as a followUp');
307
+ assert.equal(pi1.injected[0].deliverAs, 'followUp');
308
+ // A failure/aborted agent_end is never a commit point regardless of queue
309
+ // state, so it must NOT commit.
310
+ pi1.fire('agent_end', { type: 'agent_end', messages: [] }, { isIdle: () => false });
311
+ assert.equal(readCursor('node-cursor-failhold'), undefined, 'a failure agent_end does not commit');
312
+ // Drive the guard directly: a settle whose context reports pending sends
313
+ // (hasPendingMessages = true). The commit is HELD — committing now would
314
+ // durably consume a message not yet delivered.
315
+ settle(pi1, { pending: true });
316
+ assert.equal(readCursor('node-cursor-failhold'), undefined, 'settle reporting pending sends does NOT commit (held)');
317
+ // A crash in this exact held window re-delivers — nothing was committed.
318
+ const pi2 = makeFakePi();
319
+ disposers.push(registerCanvasInboxWatcher(pi2));
320
+ await wait(SETTLE_MS);
321
+ assert.equal(pi2.injected.length, 1, 'crash while held re-delivers');
322
+ assert.match(pi2.injected[0].content, /queued across a failure/);
323
+ // The next settle reports a drained queue (hasPendingMessages = false) →
324
+ // commit lands exactly once.
325
+ settle(pi2);
326
+ assert.equal(readCursor('node-cursor-failhold'), entry.ts, 'the settle after pi drains the queue commits the cursor');
327
+ });
328
+ // Situational custom sends do not enter pi's user steer/followUp queue, so
329
+ // hasPendingMessages() never reflects them: a situational handoff commits at
330
+ // the next drained settle (a situational send that async-rejected is the
331
+ // accepted silent-loss window, not held).
332
+ test('situational custom send: committed at the next drained agent_settled', async () => {
333
+ freshNode('node-cursor-situational');
334
+ appendSituationalContext('node-cursor-situational', 'ambient checkout state');
335
+ const pi1 = makeFakePi();
336
+ disposers.push(registerCanvasInboxWatcher(pi1));
337
+ // Idle so the situational-only entry delivers as a hidden custom message.
338
+ await wait(TICK_MS + 100);
339
+ const entry = appendInbox('node-cursor-situational', {
340
+ from: null, tier: 'normal', kind: 'message', label: '(ambient context updated)',
341
+ data: { situational: true, situationalOnly: true },
342
+ });
343
+ await wait(SETTLE_MS);
344
+ assert.equal(pi1.sentMessages.length, 1, 'the hidden custom message was sent');
345
+ assert.equal(readCursor('node-cursor-situational'), undefined, 'not committed before the settle');
346
+ settle(pi1);
347
+ assert.equal(readCursor('node-cursor-situational'), entry.ts, 'the situational handoff commits at the drained settle');
348
+ });
349
+ });
@@ -28,3 +28,18 @@ test('HTTP 401/403 objects still classify as auth (unchanged path)', () => {
28
28
  assert.equal(classify('pi→provider', { status: 401, statusText: 'Unauthorized' }).kind, 'auth');
29
29
  assert.equal(classify('pi→provider', { status: 403 }).kind, 'auth');
30
30
  });
31
+ // Stale ⚠ triangles — undici mid-stream disconnect text (e.g. a laptop sleep/
32
+ // wake episode) must classify as a transient connection blip (disposition
33
+ // 'auto'), not fall through to 'other' (disposition 'fatal', "needs you").
34
+ test('undici stream-drop texts classify as connection/auto, not other/fatal', () => {
35
+ for (const msg of ['terminated', 'other side closed', 'premature close']) {
36
+ const c = classify('pi→provider', msg);
37
+ assert.equal(c.kind, 'connection', `"${msg}" must classify as connection`);
38
+ assert.equal(c.disposition, 'auto', `"${msg}" must be auto-retried, not fatal`);
39
+ }
40
+ });
41
+ test('"terminated" matches as a whole word only (no false positive on substrings)', () => {
42
+ // \bterminated\b must not match e.g. "unterminated" as a stray substring hit —
43
+ // guard the word-boundary choice explicitly.
44
+ assert.notEqual(classify('pi→provider', 'preterminated').kind, 'connection');
45
+ });
@@ -142,10 +142,15 @@ test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dia
142
142
  assert.equal(r1[base].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
143
143
  assert.ok(r1[base].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[base].ms}ms`);
144
144
  // (b) controller attached but silent → the broker resolves on the SHORT explicit
145
- // per-dialog timeout (800ms), never the 120s default.
145
+ // per-dialog timeout (800ms), never the 120s default. On timeout the broker also
146
+ // broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so the
147
+ // controller's overlay (which has no independent timer) is torn down by id.
146
148
  const c = await ctrl('g6-ctrl');
147
149
  h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
148
- await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
150
+ const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
151
+ const reqId = req.id;
152
+ const dismiss = await c.waitFrame((f) => f.type === 'extension_ui_dismiss' && f.id === reqId, 'G6b broker emits a correlated extension_ui_dismiss on timeout');
153
+ assert.equal(dismiss.id, reqId, 'G6b: the dismiss frame carries the timed-out dialog id, not another');
149
154
  const r2 = await awaitDialogs(base + 2, 'G6b attended dialog resolved on timeout');
150
155
  assert.equal(r2[base + 1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
151
156
  assert.ok(r2[base + 1].ms >= 600 && r2[base + 1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[base + 1].ms}ms`);