@crouton-kit/crouter 0.3.68 → 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.
- package/README.md +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +733 -730
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +13 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +128 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +12 -5
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +25 -3
- package/dist/commands/sys/setup.js +166 -20
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +165 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +79 -21
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.d.ts +3 -13
- package/dist/types.js +12 -36
- package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +11 -8
- package/scripts/postinstall.mjs +1 -1
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineLeaf } from '../../core/command.js';
|
|
2
2
|
import { getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import { TUI, ProcessTerminal, Input, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
4
|
-
import { applyTheme } from '../../clients/attach/config-load.js';
|
|
4
|
+
import { applyTheme, defaultAgentDir } from '../../clients/attach/config-load.js';
|
|
5
|
+
import { buildLoginPicker, summarizeProviderAuth } from '../../clients/attach/auth-pickers.js';
|
|
5
6
|
const ANSI_GREEN = (s) => `\x1b[32m${s}\x1b[39m`;
|
|
6
7
|
const ANSI_YELLOW = (s) => `\x1b[33m${s}\x1b[39m`;
|
|
7
8
|
const theme = {
|
|
@@ -32,7 +33,7 @@ const theme = {
|
|
|
32
33
|
return getMarkdownTheme().bold(text);
|
|
33
34
|
},
|
|
34
35
|
};
|
|
35
|
-
import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, } from './setup-core.js';
|
|
36
|
+
import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, resolveComposioKeyPath, } from './setup-core.js';
|
|
36
37
|
function wrapText(text, width) {
|
|
37
38
|
const clean = text.trim();
|
|
38
39
|
if (clean === '')
|
|
@@ -101,8 +102,13 @@ class SetupWizard {
|
|
|
101
102
|
installedPackageIds;
|
|
102
103
|
systemTools;
|
|
103
104
|
exaInput = new Input();
|
|
105
|
+
composioInput = new Input();
|
|
104
106
|
selectedPackageIds = new Set();
|
|
105
107
|
selectedSystemTools = new Set();
|
|
108
|
+
authSummary;
|
|
109
|
+
reloadAuthSummary;
|
|
110
|
+
notice;
|
|
111
|
+
onOpenLogin;
|
|
106
112
|
activeTab = 0;
|
|
107
113
|
pluginIndex = 0;
|
|
108
114
|
systemIndex = 0;
|
|
@@ -113,10 +119,12 @@ class SetupWizard {
|
|
|
113
119
|
_focused = false;
|
|
114
120
|
onSubmit;
|
|
115
121
|
onCancel;
|
|
116
|
-
constructor(manifest, installedPackageIds, systemTools) {
|
|
122
|
+
constructor(manifest, installedPackageIds, systemTools, authSummary, reloadAuthSummary) {
|
|
117
123
|
this.manifest = manifest;
|
|
118
124
|
this.installedPackageIds = installedPackageIds;
|
|
119
125
|
this.systemTools = systemTools;
|
|
126
|
+
this.authSummary = authSummary;
|
|
127
|
+
this.reloadAuthSummary = reloadAuthSummary;
|
|
120
128
|
for (const entry of this.manifest)
|
|
121
129
|
this.selectedPackageIds.add(entry.id);
|
|
122
130
|
for (const tool of this.systemTools) {
|
|
@@ -127,10 +135,13 @@ class SetupWizard {
|
|
|
127
135
|
this.systemIndex = firstMissingSystem >= 0 ? firstMissingSystem : 0;
|
|
128
136
|
this.exaInput.onSubmit = () => this.finalize();
|
|
129
137
|
this.exaInput.onEscape = () => this.cancel();
|
|
138
|
+
this.composioInput.onSubmit = () => this.finalize();
|
|
139
|
+
this.composioInput.onEscape = () => this.cancel();
|
|
130
140
|
}
|
|
131
141
|
set focused(value) {
|
|
132
142
|
this._focused = value;
|
|
133
|
-
this.exaInput.focused = value && this.activeTab ===
|
|
143
|
+
this.exaInput.focused = value && this.activeTab === 3 && !this.confirming;
|
|
144
|
+
this.composioInput.focused = value && this.activeTab === 4 && !this.confirming;
|
|
134
145
|
}
|
|
135
146
|
get focused() {
|
|
136
147
|
return this._focused;
|
|
@@ -139,13 +150,24 @@ class SetupWizard {
|
|
|
139
150
|
this.cachedWidth = undefined;
|
|
140
151
|
this.cachedLines = undefined;
|
|
141
152
|
}
|
|
153
|
+
/** Show a one-line notice on the login tab (login success/failure feedback). */
|
|
154
|
+
setNotice(message) {
|
|
155
|
+
this.notice = message;
|
|
156
|
+
this.invalidate();
|
|
157
|
+
}
|
|
158
|
+
/** Recompute provider auth status after a login flow closes. */
|
|
159
|
+
refreshAuth() {
|
|
160
|
+
this.authSummary = this.reloadAuthSummary();
|
|
161
|
+
this.invalidate();
|
|
162
|
+
}
|
|
142
163
|
setTab(next) {
|
|
143
164
|
this.activeTab = next;
|
|
144
|
-
this.exaInput.focused = this.focused && next ===
|
|
165
|
+
this.exaInput.focused = this.focused && next === 3 && !this.confirming;
|
|
166
|
+
this.composioInput.focused = this.focused && next === 4 && !this.confirming;
|
|
145
167
|
this.invalidate();
|
|
146
168
|
}
|
|
147
169
|
moveTab(delta) {
|
|
148
|
-
const next = ((this.activeTab + delta +
|
|
170
|
+
const next = ((this.activeTab + delta + 5) % 5);
|
|
149
171
|
this.setTab(next);
|
|
150
172
|
}
|
|
151
173
|
cancel() {
|
|
@@ -229,6 +251,8 @@ class SetupWizard {
|
|
|
229
251
|
systemDeps: this.systemTools.filter((tool) => !tool.installed && this.selectedSystemTools.has(tool.tool)).map((tool) => tool.plan),
|
|
230
252
|
exaKey: this.exaInput.getValue().trim(),
|
|
231
253
|
exaKeyPath: resolveExaKeyPath(),
|
|
254
|
+
composioKey: this.composioInput.getValue().trim(),
|
|
255
|
+
composioKeyPath: resolveComposioKeyPath(),
|
|
232
256
|
};
|
|
233
257
|
}
|
|
234
258
|
finalize() {
|
|
@@ -263,6 +287,13 @@ class SetupWizard {
|
|
|
263
287
|
if (this.handleGlobalTabSwitch(data))
|
|
264
288
|
return;
|
|
265
289
|
if (this.activeTab === 0) {
|
|
290
|
+
if (matchesKey(data, 'enter')) {
|
|
291
|
+
this.onOpenLogin?.();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (this.activeTab === 1) {
|
|
266
297
|
if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
|
|
267
298
|
this.movePlugin(1);
|
|
268
299
|
return;
|
|
@@ -285,7 +316,7 @@ class SetupWizard {
|
|
|
285
316
|
}
|
|
286
317
|
return;
|
|
287
318
|
}
|
|
288
|
-
if (this.activeTab ===
|
|
319
|
+
if (this.activeTab === 2) {
|
|
289
320
|
if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
|
|
290
321
|
this.moveSystem(1);
|
|
291
322
|
return;
|
|
@@ -304,10 +335,14 @@ class SetupWizard {
|
|
|
304
335
|
}
|
|
305
336
|
return;
|
|
306
337
|
}
|
|
307
|
-
this.
|
|
338
|
+
if (this.activeTab === 3) {
|
|
339
|
+
this.exaInput.handleInput(data);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.composioInput.handleInput(data);
|
|
308
343
|
}
|
|
309
344
|
renderHeader(width) {
|
|
310
|
-
const tabs = ['Plugins', 'System deps', 'Exa key'];
|
|
345
|
+
const tabs = ['Login', 'Plugins', 'System deps', 'Exa key', 'Composio key'];
|
|
311
346
|
const tabLine = tabs
|
|
312
347
|
.map((label, index) => tabPill(`${index + 1}. ${label}`, index === this.activeTab))
|
|
313
348
|
.join(theme.fg('dim', ' · '));
|
|
@@ -321,12 +356,41 @@ class SetupWizard {
|
|
|
321
356
|
const line = this.confirming
|
|
322
357
|
? 'y/enter confirm · n/esc go back · q cancel'
|
|
323
358
|
: this.activeTab === 0
|
|
324
|
-
? '
|
|
359
|
+
? 'enter log in · Tab/→ next · q cancel'
|
|
325
360
|
: this.activeTab === 1
|
|
326
|
-
? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
|
|
327
|
-
:
|
|
361
|
+
? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
|
|
362
|
+
: this.activeTab === 2
|
|
363
|
+
? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ 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';
|
|
328
367
|
return [truncateToWidth(theme.fg('dim', line), width)];
|
|
329
368
|
}
|
|
369
|
+
renderLogin(width) {
|
|
370
|
+
const out = [];
|
|
371
|
+
out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Login'))}${theme.fg('dim', ' optional · same providers as /login')}`, width));
|
|
372
|
+
for (const line of wrapText('Log in to a model provider so crtr can run turns. With no provider configured, nodes fail to start when you first use the app.', Math.max(1, width))) {
|
|
373
|
+
out.push(truncateToWidth(theme.fg('muted', line), width));
|
|
374
|
+
}
|
|
375
|
+
out.push('');
|
|
376
|
+
const configured = this.authSummary.filter((p) => p.configured);
|
|
377
|
+
if (configured.length > 0) {
|
|
378
|
+
out.push(truncateToWidth(theme.fg('success', `✓ Configured: ${configured.map((p) => p.name).join(', ')}`), width));
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
out.push(truncateToWidth(theme.fg('warning', 'No provider configured yet.'), width));
|
|
382
|
+
}
|
|
383
|
+
out.push('');
|
|
384
|
+
out.push(...framed([
|
|
385
|
+
theme.fg('accent', theme.bold('Press Enter to open the provider picker')),
|
|
386
|
+
theme.fg('dim', 'Pick a provider, then complete its OAuth or API-key login.'),
|
|
387
|
+
], width));
|
|
388
|
+
if (this.notice !== undefined) {
|
|
389
|
+
out.push('');
|
|
390
|
+
out.push(truncateToWidth(theme.fg('success', this.notice), width));
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
330
394
|
renderPlugins(width) {
|
|
331
395
|
const out = [];
|
|
332
396
|
out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Plugins'))}${theme.fg('dim', ' default ON')}`, width));
|
|
@@ -379,10 +443,22 @@ class SetupWizard {
|
|
|
379
443
|
], width));
|
|
380
444
|
return out;
|
|
381
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
|
+
}
|
|
382
458
|
renderConfirm(width) {
|
|
383
459
|
const out = [truncateToWidth(theme.fg('warning', theme.bold('Confirm installs')), width)];
|
|
384
460
|
const submission = this.buildSubmission();
|
|
385
|
-
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 === '') {
|
|
386
462
|
out.push(truncateToWidth(theme.fg('success', 'Nothing to install; press enter to finish.'), width));
|
|
387
463
|
return out;
|
|
388
464
|
}
|
|
@@ -404,6 +480,9 @@ class SetupWizard {
|
|
|
404
480
|
if (submission.exaKey !== '') {
|
|
405
481
|
out.push(truncateToWidth(`${theme.fg('accent', 'Exa key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.exaKeyPath}`)}`, width));
|
|
406
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
|
+
}
|
|
407
486
|
out.push(truncateToWidth(theme.fg('dim', 'y/enter confirm · n/esc go back'), width));
|
|
408
487
|
return out;
|
|
409
488
|
}
|
|
@@ -413,7 +492,17 @@ class SetupWizard {
|
|
|
413
492
|
const lines = [
|
|
414
493
|
...this.renderHeader(width),
|
|
415
494
|
'',
|
|
416
|
-
...(this.confirming
|
|
495
|
+
...(this.confirming
|
|
496
|
+
? this.renderConfirm(width)
|
|
497
|
+
: this.activeTab === 0
|
|
498
|
+
? this.renderLogin(width)
|
|
499
|
+
: this.activeTab === 1
|
|
500
|
+
? this.renderPlugins(width)
|
|
501
|
+
: this.activeTab === 2
|
|
502
|
+
? this.renderSystems(width)
|
|
503
|
+
: this.activeTab === 3
|
|
504
|
+
? this.renderExa(width)
|
|
505
|
+
: this.renderComposio(width)),
|
|
417
506
|
'',
|
|
418
507
|
...this.renderFooter(width),
|
|
419
508
|
];
|
|
@@ -422,6 +511,52 @@ class SetupWizard {
|
|
|
422
511
|
return this.cachedLines;
|
|
423
512
|
}
|
|
424
513
|
}
|
|
514
|
+
const LOGIN_OVERLAY_OPTIONS = { anchor: 'center', width: '80%', maxHeight: '85%' };
|
|
515
|
+
/** Mount pi's real `/login` provider picker + login dialog as a centered overlay
|
|
516
|
+
* over the setup wizard, driving the SAME OAuth/API-key flow the in-app /login
|
|
517
|
+
* uses (`buildLoginPicker`). Credentials write to `agentDir`'s auth.json; on
|
|
518
|
+
* close, focus returns to the wizard and its auth summary refreshes. */
|
|
519
|
+
function openLoginOverlay(tui, wizard, agentDir) {
|
|
520
|
+
let handle;
|
|
521
|
+
let current;
|
|
522
|
+
let done = false;
|
|
523
|
+
const disposeCurrent = () => {
|
|
524
|
+
const disposable = current?.component;
|
|
525
|
+
try {
|
|
526
|
+
disposable?.dispose?.();
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
/* ignore dispose errors during teardown */
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const mount = (p) => {
|
|
533
|
+
handle = tui.showOverlay(p.component, LOGIN_OVERLAY_OPTIONS);
|
|
534
|
+
tui.setFocus(p.focus);
|
|
535
|
+
};
|
|
536
|
+
const unmount = () => handle?.hide();
|
|
537
|
+
const replace = (component, focus) => {
|
|
538
|
+
if (done)
|
|
539
|
+
return;
|
|
540
|
+
disposeCurrent();
|
|
541
|
+
unmount();
|
|
542
|
+
current = { component, focus };
|
|
543
|
+
mount(current);
|
|
544
|
+
tui.requestRender();
|
|
545
|
+
};
|
|
546
|
+
const close = () => {
|
|
547
|
+
if (done)
|
|
548
|
+
return;
|
|
549
|
+
done = true;
|
|
550
|
+
disposeCurrent();
|
|
551
|
+
unmount();
|
|
552
|
+
wizard.refreshAuth();
|
|
553
|
+
tui.setFocus(wizard);
|
|
554
|
+
tui.requestRender();
|
|
555
|
+
};
|
|
556
|
+
current = buildLoginPicker(tui, agentDir, () => wizard.refreshAuth(), (msg) => wizard.setNotice(msg), { close, replace });
|
|
557
|
+
mount(current);
|
|
558
|
+
tui.requestRender();
|
|
559
|
+
}
|
|
425
560
|
async function runSetup() {
|
|
426
561
|
const state = buildSetupInitialState();
|
|
427
562
|
if (!process.stdout.isTTY) {
|
|
@@ -429,9 +564,19 @@ async function runSetup() {
|
|
|
429
564
|
return;
|
|
430
565
|
}
|
|
431
566
|
applyTheme({ cwd: process.cwd() });
|
|
567
|
+
const agentDir = defaultAgentDir();
|
|
568
|
+
const loadAuthSummary = () => {
|
|
569
|
+
try {
|
|
570
|
+
return summarizeProviderAuth(agentDir);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
};
|
|
432
576
|
const terminal = new ProcessTerminal();
|
|
433
577
|
const tui = new TUI(terminal, true);
|
|
434
|
-
const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools);
|
|
578
|
+
const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, loadAuthSummary(), loadAuthSummary);
|
|
579
|
+
wizard.onOpenLogin = () => openLoginOverlay(tui, wizard, agentDir);
|
|
435
580
|
tui.addChild(wizard);
|
|
436
581
|
tui.setFocus(wizard);
|
|
437
582
|
await new Promise((resolve, reject) => {
|
|
@@ -496,17 +641,18 @@ async function runSetup() {
|
|
|
496
641
|
}
|
|
497
642
|
export const sysSetupLeaf = defineLeaf({
|
|
498
643
|
name: 'setup',
|
|
499
|
-
description: 'install the bundled pi plugins, system deps, and Exa key setup',
|
|
500
|
-
whenToUse: 'you want to bootstrap this machine for crtr in one pass: install the vendored pi plugins, confirm tmux and nvim, and save your Exa API
|
|
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.',
|
|
501
646
|
help: {
|
|
502
647
|
name: 'sys setup',
|
|
503
|
-
summary: 'open the multi-tab setup wizard for bundled pi plugins, system deps, and Exa
|
|
648
|
+
summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa + Composio keys',
|
|
504
649
|
inputNote: 'No input parameters.',
|
|
505
650
|
output: [],
|
|
506
651
|
outputKind: 'object',
|
|
507
652
|
effects: [
|
|
508
|
-
'Opens a pi-tui wizard
|
|
509
|
-
'
|
|
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.',
|
|
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.',
|
|
510
656
|
],
|
|
511
657
|
},
|
|
512
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`);
|