@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.83

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 (50) hide show
  1. package/CHANGELOG.md +1044 -0
  2. package/dist/node/index.cjs +76 -70
  3. package/dist/node/index.d.cts +100 -8
  4. package/dist/node/index.d.cts.map +1 -1
  5. package/dist/node/index.d.ts +100 -8
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -70
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +14 -9
  10. package/src/command-module-utils.test.ts +37 -0
  11. package/src/command-module-utils.ts +2 -0
  12. package/src/default/__tests__/default-command-modules.test.ts +50 -11
  13. package/src/default/__tests__/model-exposure.test.ts +16 -3
  14. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  15. package/src/default/default-command-modules.ts +7 -8
  16. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  17. package/src/devices/devices-command-module.ts +108 -24
  18. package/src/devices/devices-command-port.ts +62 -1
  19. package/src/devices/index.ts +3 -0
  20. package/src/doctor/doctor-node-deps.ts +6 -7
  21. package/src/editor/editor-command-module.ts +5 -0
  22. package/src/events/__tests__/events-command.test.ts +73 -0
  23. package/src/events/events-command-module.ts +49 -0
  24. package/src/events/events-command.ts +69 -0
  25. package/src/events/index.ts +2 -0
  26. package/src/git/__tests__/git-command-module.test.ts +10 -2
  27. package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
  28. package/src/handoff/handoff-command-module.ts +1 -1
  29. package/src/handoff/handoff-command.ts +2 -2
  30. package/src/help/__tests__/help-command-module.test.ts +3 -0
  31. package/src/help/__tests__/help-command.test.ts +3 -0
  32. package/src/index.ts +8 -0
  33. package/src/keybindings/keybindings-command-module.ts +17 -3
  34. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
  35. package/src/mcp-activation/mcp-activation-command.ts +8 -3
  36. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  37. package/src/peers/__tests__/peers-command.test.ts +78 -0
  38. package/src/peers/peers-command-module.ts +1 -1
  39. package/src/peers/peers-command.ts +33 -4
  40. package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
  41. package/src/rewind/rewind-command.ts +23 -2
  42. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  43. package/src/schedule/loop-command.ts +3 -1
  44. package/src/shell/shell-command-module.ts +5 -0
  45. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  46. package/src/terminal-client/index.ts +5 -0
  47. package/src/terminal-client/terminal-client-commands.ts +109 -0
  48. package/src/theme/__tests__/theme-command.test.ts +1 -1
  49. package/src/theme/theme-command-module.ts +12 -2
  50. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
@@ -55,12 +55,14 @@ function createCommandHostContext() {
55
55
  displayName: 'Help',
56
56
  description: 'Show available commands',
57
57
  modelInvocable: true,
58
+ runner: 'runtime',
58
59
  },
59
60
  {
60
61
  name: 'provider',
61
62
  displayName: 'Provider Setup',
62
63
  description: 'Manage provider profiles',
63
64
  modelInvocable: true,
65
+ runner: 'runtime',
64
66
  },
65
67
  // SEC-008: `plugin` installs and enables code, so it is NOT model-invocable — the fixture says
66
68
  // what the real command says rather than the value that happened to compile.
@@ -69,6 +71,7 @@ function createCommandHostContext() {
69
71
  displayName: 'Plugins',
70
72
  description: 'Manage plugins',
71
73
  modelInvocable: false,
74
+ runner: 'runtime',
72
75
  },
73
76
  ],
74
77
  listEditCheckpoints: () => [],
@@ -86,6 +86,7 @@ describe('formatCommandHelpMessage — example field', () => {
86
86
  description: 'Compress context window',
87
87
  example: '/compact Summarize the current context',
88
88
  modelInvocable: true,
89
+ runner: 'runtime',
89
90
  },
90
91
  ]);
91
92
 
@@ -102,6 +103,7 @@ describe('formatCommandHelpMessage — example field', () => {
102
103
  description: 'Manage provider profiles',
103
104
  example: '/provider switch production',
104
105
  modelInvocable: true,
106
+ runner: 'runtime',
105
107
  },
106
108
  ]);
107
109
 
@@ -117,6 +119,7 @@ describe('formatCommandHelpMessage — example field', () => {
117
119
  displayName: 'Help',
118
120
  description: 'Show available commands',
119
121
  modelInvocable: true,
122
+ runner: 'runtime',
120
123
  },
121
124
  ]);
122
125
 
package/src/index.ts CHANGED
@@ -79,8 +79,11 @@ export {
79
79
  type IDeviceListEntry,
80
80
  type IDevicesCommandPort,
81
81
  type IDevicesInitResult,
82
+ type IDevicesAddResult,
83
+ type IDevicesJoinResult,
82
84
  type IDevicesRecoverResult,
83
85
  type IDevicesRevokeResult,
86
+ type IDevicesMeshStatus,
84
87
  type IDevicesView,
85
88
  type TDevicesOutcome,
86
89
  type TDevicesRefusal,
@@ -146,4 +149,9 @@ export * from './session/index.js';
146
149
  export * from './settings/index.js';
147
150
  export * from './skills/index.js';
148
151
  export * from './statusline/index.js';
152
+ export {
153
+ createTerminalClientCommands,
154
+ type ITerminalClientCommand,
155
+ type ITerminalClientCommandOptions,
156
+ } from './terminal-client/index.js';
149
157
  export * from './user-local/index.js';
@@ -21,13 +21,23 @@ export function createKeybindingsCommandEntry(): ICommand {
21
21
  source: 'keybindings',
22
22
  // User-only: UI preference.
23
23
  modelInvocable: false,
24
+ // Key bindings belong to the terminal the user sits at, so that terminal runs it, even when
25
+ // attached.
26
+ runner: 'client',
27
+ surfaces: ['terminal'],
24
28
  };
25
29
  }
26
30
 
27
- async function executeKeybindingsCommand(
28
- file: IKeybindingsFilePort,
31
+ // A host without a terminal (the desktop app's sidecar) still answers, so the command is never
32
+ // "unknown" there — it says where key bindings live instead.
33
+ const KEYBINDINGS_UNAVAILABLE =
34
+ 'Key bindings belong to the robota terminal, and this surface has none. Run /keybindings in the robota terminal.';
35
+
36
+ export async function executeKeybindingsCommand(
37
+ file: IKeybindingsFilePort | undefined,
29
38
  context: ICommandHostTerminalHandoff & ICommandHostWorkspace,
30
39
  ): Promise<ICommandResult> {
40
+ if (!file) return { success: false, message: KEYBINDINGS_UNAVAILABLE };
31
41
  if (!context.canHandoffTerminal()) {
32
42
  return { success: false, message: 'Keybindings editor is unavailable here.' };
33
43
  }
@@ -54,7 +64,9 @@ export class KeybindingsCommandSource implements ICommandSource {
54
64
  }
55
65
  }
56
66
 
57
- export function createKeybindingsCommandModule(file: IKeybindingsFilePort): ICommandModule {
67
+ export function createKeybindingsCommandModule(
68
+ file: IKeybindingsFilePort | undefined,
69
+ ): ICommandModule {
58
70
  const entry = createKeybindingsCommandEntry();
59
71
  const command: ISystemCommand = {
60
72
  name: entry.name,
@@ -63,6 +75,8 @@ export function createKeybindingsCommandModule(file: IKeybindingsFilePort): ICom
63
75
  requiresPermission: false,
64
76
  userInvocable: true,
65
77
  modelInvocable: false,
78
+ runner: entry.runner,
79
+ surfaces: entry.surfaces,
66
80
  lifecycle: 'inline',
67
81
  execute: (context) => executeKeybindingsCommand(file, context),
68
82
  };
@@ -484,6 +484,47 @@ describe('/mcp login', () => {
484
484
  expect(h.added).toEqual([]);
485
485
  });
486
486
 
487
+ it('asks nothing for a sign-in that has already ended', async () => {
488
+ const ended = new AbortController();
489
+ ended.abort();
490
+ let choice: string | undefined;
491
+ const h = harness(
492
+ async (request) => {
493
+ choice = await request.confirmBrowser!(PROMPT, ended.signal);
494
+ await expect(request.readRedirect!(PROMPT, ended.signal)).rejects.toThrow();
495
+ return {
496
+ serverId: request.serverId,
497
+ failure: 'cancelled',
498
+ preRegisteredClient: false,
499
+ tools: [],
500
+ };
501
+ },
502
+ { type: 'answer', values: ['open'], text: 'http://127.0.0.1:1/callback?code=c&state=s' },
503
+ );
504
+ await h.run('login files');
505
+ expect(choice).toBe('cancel');
506
+ expect(h.asked).toEqual([]);
507
+ });
508
+
509
+ it('never opens the browser for a sign-in that ended while the user was choosing', async () => {
510
+ const signIn = new AbortController();
511
+ let choice: string | undefined;
512
+ const h = harness(
513
+ async (request) => {
514
+ choice = await request.confirmBrowser!(PROMPT, signIn.signal);
515
+ return signedIn(request.serverId);
516
+ },
517
+ () => {
518
+ // The sign-in times out while the question is still open; the user then picks `open`.
519
+ signIn.abort();
520
+ return { type: 'answer', values: ['open'] };
521
+ },
522
+ );
523
+ await h.run('login files');
524
+ expect(h.asked).toHaveLength(1);
525
+ expect(choice).toBe('cancel');
526
+ });
527
+
487
528
  it('never asks for a client secret, and names the terminal command instead', async () => {
488
529
  const h = harness(async (request) => signedIn(request.serverId));
489
530
  const result = await h.run('login files --client-secret');
@@ -259,7 +259,10 @@ function browserConfirmer(
259
259
  ): ICommandMCPOAuthLoginRequest['confirmBrowser'] {
260
260
  const ui = context.getUserInteraction();
261
261
  if (ui === undefined) return undefined;
262
- return async (prompt) => {
262
+ return async (prompt, signal) => {
263
+ // The ask port cannot withdraw a question, so a sign-in that has already ended is not asked
264
+ // about, and an answer that arrives after it ended never opens a browser for it.
265
+ if (signal.aborted) return 'cancel';
263
266
  loginPrompts += 1;
264
267
  const answer = await ui.ask({
265
268
  id: `mcp-login-browser-${loginPrompts}`,
@@ -275,7 +278,7 @@ function browserConfirmer(
275
278
  maxSelect: 1,
276
279
  default: { values: ['open'] },
277
280
  });
278
- if (answer.type !== 'answer') return 'cancel';
281
+ if (answer.type !== 'answer' || signal.aborted) return 'cancel';
279
282
  const choice = answer.values[0];
280
283
  return choice === 'open' || choice === 'paste' ? choice : 'cancel';
281
284
  };
@@ -288,7 +291,9 @@ function redirectReader(
288
291
  ): ICommandMCPOAuthLoginRequest['readRedirect'] {
289
292
  const ui = context.getUserInteraction();
290
293
  if (ui === undefined) return undefined;
291
- return async (prompt) => {
294
+ return async (prompt, signal) => {
295
+ // A sign-in that has already ended (cancelled or timed out) does not ask for a paste.
296
+ signal.throwIfAborted();
292
297
  loginPrompts += 1;
293
298
  const answer = await ui.ask({
294
299
  id: `mcp-login-redirect-${loginPrompts}`,
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createPeersCommandModule } from '../peers-command-module.js';
4
+
5
+ describe('/peers command module', () => {
6
+ it('is user-only: never model-invocable, and palette metadata matches the executable', () => {
7
+ const module = createPeersCommandModule();
8
+ const palette = module.commandSources?.[0]?.getCommands()[0];
9
+ const executable = module.systemCommands?.[0];
10
+ expect(executable?.modelInvocable).toBe(false);
11
+ expect(palette?.modelInvocable).toBe(false);
12
+ expect(executable?.userInvocable).toBe(true);
13
+ expect(executable?.description).toBe(palette?.description);
14
+ });
15
+
16
+ it('tells the model it covers linked devices, what it returns, and what to suggest', () => {
17
+ const description =
18
+ createPeersCommandModule().commandSources?.[0]?.getCommands()[0]?.description;
19
+ expect(description).toMatch(/linked over the device mesh/);
20
+ expect(description).toMatch(/returns/i);
21
+ expect(description).toMatch(/user-only/i);
22
+ expect(description).toContain('`/peers`');
23
+ });
24
+ });
@@ -142,6 +142,84 @@ describe('what the operator is told', () => {
142
142
  });
143
143
  });
144
144
 
145
+ describe('linked devices of the device mesh', () => {
146
+ const DEVICE = 'D'.repeat(43);
147
+
148
+ function hostWithDevices(
149
+ peers: readonly TPeerSummary[],
150
+ devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
151
+ ): ICommandHostAdapterAccess {
152
+ return {
153
+ getCommandHostAdapters: () => ({
154
+ localPeers: { list: () => peers, ownSessionId: () => OWN, listDevices: () => devices },
155
+ }),
156
+ } as ICommandHostAdapterAccess;
157
+ }
158
+
159
+ it('lists a linked device beside the sessions on this host, addressable by its id', async () => {
160
+ const result = await executePeersCommand(
161
+ hostWithDevices(
162
+ [
163
+ { sessionId: OWN, liveness: 'alive' },
164
+ { sessionId: 'session-other', liveness: 'alive' },
165
+ ],
166
+ [{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }],
167
+ ),
168
+ );
169
+ expect(result.success).toBe(true);
170
+ expect(result.message).toContain('session-other');
171
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
172
+ expect(result.message).toContain('/peers send <session-or-device-id>');
173
+ });
174
+
175
+ it('lists a linked device even when this is the only session on this host', async () => {
176
+ const result = await executePeersCommand(
177
+ hostWithDevices(
178
+ [{ sessionId: OWN, liveness: 'alive' }],
179
+ [{ deviceId: DEVICE, locality: 'same-host' }],
180
+ ),
181
+ );
182
+ expect(result.message).not.toContain('No other live session');
183
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+on this machine`));
184
+ });
185
+ });
186
+
187
+ describe('when local discovery is off but the device mesh is not', () => {
188
+ const DEVICE = 'D'.repeat(43);
189
+
190
+ function hostWithoutDiscovery(
191
+ devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
192
+ ): ICommandHostAdapterAccess {
193
+ return {
194
+ getCommandHostAdapters: () => ({
195
+ localPeers: {
196
+ list: () => [],
197
+ ownSessionId: () => OWN,
198
+ listDevices: () => devices,
199
+ localDiscoveryOff: 'the rendezvous directory was not admitted',
200
+ },
201
+ }),
202
+ } as ICommandHostAdapterAccess;
203
+ }
204
+
205
+ it('lists the linked devices and says why sessions on this host are not listed', async () => {
206
+ const result = await executePeersCommand(
207
+ hostWithoutDiscovery([{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }]),
208
+ );
209
+ expect(result.success).toBe(true);
210
+ expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
211
+ expect(result.message).toContain('not admitted');
212
+ expect(result.message).not.toContain('Live sessions');
213
+ expect(result.message).toContain('/peers send <device-id>');
214
+ });
215
+
216
+ it('with no device linked, says discovery is off rather than that nobody is there', async () => {
217
+ const result = await executePeersCommand(hostWithoutDiscovery([]));
218
+ expect(result.message).toContain('not admitted');
219
+ expect(result.message).not.toContain('No other live session');
220
+ });
221
+ });
222
+
145
223
  describe('when the host wires no discovery', () => {
146
224
  it('says the feature is unavailable rather than reporting no peers', async () => {
147
225
  // The two are different facts and the difference matters: "nobody is there" invites the operator
@@ -8,7 +8,7 @@ export function createPeersCommandEntry(): ICommand {
8
8
  name: 'peers',
9
9
  displayName: 'Peers',
10
10
  description:
11
- 'List the other live sessions on this host, send one a message, or send one a copy of a file',
11
+ "List the other live Robota sessions on this host and the user's other devices linked over the device mesh, send one a message, or send one a copy of a file. Returns the sessions and devices with the ids to address them by (linked devices even when local discovery is off and sessions on this host cannot be listed), or how a send ended. User-only: the model cannot run it; it answers a peer's message with its reply tool, and when the user wants to reach another session or device, suggest they run `/peers`.",
12
12
  source: 'peers',
13
13
  // The model does not enumerate the operator's other sessions. Discovery is an operator-facing
14
14
  // view of who is at the machine, which is a fact about the person and not about the task.
@@ -33,6 +33,16 @@ function describe(peer: TPeerSummary, ownSessionId: string): string {
33
33
  return ` ${peer.sessionId}${peer.name ? ` ${peer.name}` : ''} status ${status}${self ? '' : workspace(peer)}${liveness}${self}`;
34
34
  }
35
35
 
36
+ type TDeviceSummary = ReturnType<
37
+ NonNullable<NonNullable<ICommandHostAdapters['localPeers']>['listDevices']>
38
+ >[number];
39
+
40
+ /** A linked device: its full id, since that is what `send` takes, and where it runs. */
41
+ function describeDevice(device: TDeviceSummary): string {
42
+ const where = device.locality === 'same-host' ? 'on this machine' : 'on another machine';
43
+ return ` ${device.deviceId}${device.name ? ` ${device.name}` : ''} ${where}`;
44
+ }
45
+
36
46
  /** The relation this session verified. A claim it could not confirm is named as such, not shown. */
37
47
  function workspace(peer: TPeerSummary): string {
38
48
  if (peer.workspaceClaim === 'mismatched') return ' workspace claim mismatched, not believed';
@@ -165,6 +175,21 @@ export async function executePeersCommand(
165
175
  const sendVerb = /^send(?=\s|$)/.exec(trimmed);
166
176
  if (sendVerb !== null) return executeSend(adapter, trimmed.slice(sendVerb[0].length));
167
177
 
178
+ const devices = adapter.listDevices?.() ?? [];
179
+ const discoveryOff = adapter.localDiscoveryOff;
180
+ if (discoveryOff !== undefined) {
181
+ const off = `Sessions on this host are not listed: local peer discovery is off for this session (${discoveryOff}).`;
182
+ return {
183
+ message:
184
+ devices.length === 0
185
+ ? `${off}\nNo device is linked right now.`
186
+ : `Linked devices:\n${devices.map(describeDevice).join('\n')}\n\n${off}` +
187
+ `\n\nSend to one: /peers send <device-id> <message>` +
188
+ `\nSend a file: /peers send-file <device-id> <path>`,
189
+ success: true,
190
+ };
191
+ }
192
+
168
193
  const own = adapter.ownSessionId();
169
194
  // The workspace-judged listing when the host has one; it reads git, so only this view asks for it.
170
195
  const peers = (
@@ -172,7 +197,7 @@ export async function executePeersCommand(
172
197
  ).filter(addressable);
173
198
  const others = peers.filter((peer) => peer.sessionId !== own);
174
199
 
175
- if (others.length === 0) {
200
+ if (others.length === 0 && devices.length === 0) {
176
201
  return {
177
202
  message:
178
203
  'No other live session is announced. Start a second session on this host, as this user, ' +
@@ -181,11 +206,15 @@ export async function executePeersCommand(
181
206
  };
182
207
  }
183
208
 
184
- const lines = peers.map((peer) => describe(peer, own));
209
+ const sections = [`Live sessions:\n${peers.map((peer) => describe(peer, own)).join('\n')}`];
210
+ if (devices.length > 0) {
211
+ sections.push(`Linked devices:\n${devices.map(describeDevice).join('\n')}`);
212
+ }
213
+ const target = devices.length > 0 ? '<session-or-device-id>' : '<session-id>';
185
214
  return {
186
215
  message:
187
- `Live sessions:\n${lines.join('\n')}\n\nSend to one: /peers send <session-id> <message>` +
188
- '\nSend a file: /peers send-file <session-id> <path>',
216
+ `${sections.join('\n\n')}\n\nSend to one: /peers send ${target} <message>` +
217
+ `\nSend a file: /peers send-file ${target} <path>`,
189
218
  success: true,
190
219
  };
191
220
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import type {
3
3
  IEditCheckpointInspection,
4
4
  IEditCheckpointRestoreResult,
@@ -252,4 +252,34 @@ describe('executeRewindCommand', () => {
252
252
  expect(result?.success).toBe(false);
253
253
  expect(result?.message).toBe('Unknown edit checkpoint');
254
254
  });
255
+
256
+ describe('in a session built without a checkpoint store', () => {
257
+ afterEach(() => {
258
+ vi.restoreAllMocks();
259
+ });
260
+
261
+ it('tells a restricted workspace to trust it, for every subcommand', async () => {
262
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
263
+ const session = createInteractiveSession();
264
+
265
+ for (const args of ['', 'list', 'inspect turn-0001', 'restore turn-0001', 'branches']) {
266
+ const result = await session.executeCommand('rewind', args);
267
+ expect(result?.success).toBe(false);
268
+ expect(result?.message).toBe(
269
+ 'Edit checkpoints need a trusted workspace: run robota trust --yes, then restart robota.',
270
+ );
271
+ }
272
+ });
273
+
274
+ it('says why on a host that cannot prove a project write is safe, without naming trust', async () => {
275
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
276
+ const session = createInteractiveSession();
277
+
278
+ const result = await session.executeCommand('rewind', 'list');
279
+
280
+ expect(result?.success).toBe(false);
281
+ expect(result?.message).toContain('cannot prove a write stays inside the project');
282
+ expect(result?.message).not.toContain('trust');
283
+ });
284
+ });
255
285
  });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EditCheckpointsUnavailableError,
2
3
  inspectCommandEditCheckpoint,
3
4
  listCommandEditCheckpoints,
4
5
  restoreCommandEditCheckpoint,
@@ -121,13 +122,33 @@ function formatRollbackResult(result: IEditCheckpointRestoreResult): ICommandRes
121
122
  };
122
123
  }
123
124
 
125
+ /** A restricted workspace is the one case the user fixes with a command; the rest say why. */
126
+ function unavailableMessage(error: EditCheckpointsUnavailableError): string {
127
+ return error.reason === 'restricted-workspace'
128
+ ? 'Edit checkpoints need a trusted workspace: run robota trust --yes, then restart robota.'
129
+ : error.message;
130
+ }
131
+
124
132
  function formatError(error: Error | string): ICommandResult {
125
133
  return {
126
- message: error instanceof Error ? error.message : String(error),
134
+ message:
135
+ error instanceof EditCheckpointsUnavailableError
136
+ ? unavailableMessage(error)
137
+ : error instanceof Error
138
+ ? error.message
139
+ : String(error),
127
140
  success: false,
128
141
  };
129
142
  }
130
143
 
144
+ function list(context: ICommandHostCheckpoints): ICommandResult {
145
+ try {
146
+ return formatList(listCommandEditCheckpoints(context));
147
+ } catch (error) {
148
+ return formatError(error instanceof Error ? error : String(error));
149
+ }
150
+ }
151
+
131
152
  function inspect(
132
153
  context: ICommandHostCheckpoints,
133
154
  checkpointId: string | undefined,
@@ -222,7 +243,7 @@ export async function executeRewindCommand(
222
243
  const subcommand = args[SUBCOMMAND_INDEX] ?? 'list';
223
244
 
224
245
  if (subcommand === 'list') {
225
- return formatList(listCommandEditCheckpoints(context));
246
+ return list(context);
226
247
  }
227
248
 
228
249
  if (subcommand === 'inspect') {
@@ -7,10 +7,12 @@
7
7
  * The pre-fix regexes used `\s+(.+)$`; because `.` also matches a space, the split point between
8
8
  * `\s+` and `(.+)` was ambiguous and a non-matching argument was rejected in O(n^2). Measured
9
9
  * pre-fix on the inputs below: ~17s (`/schedule cron`) and ~15s (`/monitor`); post-fix, <1ms.
10
+ * `/loop`'s leading interval had the same `\s+([\s\S]+)$` shape and is held to the same bound.
10
11
  */
11
12
 
12
13
  import { describe, expect, it, vi } from 'vitest';
13
14
 
15
+ import { executeLoopCommand } from '../loop-command.js';
14
16
  import { executeMonitorCommand } from '../schedule-command.js';
15
17
  import { parseScheduleSpec } from '../schedule-spec-parser.js';
16
18
 
@@ -78,4 +80,35 @@ describe('schedule argument parsing is not polynomial-ReDoS-able', () => {
78
80
  }),
79
81
  );
80
82
  });
83
+
84
+ it('parses a pumped leading-interval `/loop` in linear time', async () => {
85
+ // The shape CodeQL named for `\s+([\s\S]+)$`: an interval, then a long run of whitespace.
86
+ const spawnScheduledWake = vi.fn();
87
+ const host = createTestAgentJobHost({ spawnScheduledWake });
88
+ const hostile = `0s\t${'\t\t'.repeat(PUMP_LENGTH)}x`;
89
+
90
+ const started = performance.now();
91
+ const result = await executeLoopCommand(host, vi.fn(), hostile);
92
+ const took = performance.now() - started;
93
+
94
+ expect(result.success).toBe(false);
95
+ expect(spawnScheduledWake).not.toHaveBeenCalled();
96
+ expect(took).toBeLessThan(BUDGET_MS);
97
+ });
98
+
99
+ it.each([
100
+ ['5m check the build', 'check the build'],
101
+ ['5m \t\n check the build ', 'check the build'],
102
+ ['5M\ncheck', 'check'],
103
+ ])('still reads a leading-interval `/loop` identically: %j', async (args, instruction) => {
104
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_1' });
105
+ const host = createTestAgentJobHost({ spawnScheduledWake });
106
+
107
+ const result = await executeLoopCommand(host, vi.fn(), args);
108
+
109
+ expect(result.success).toBe(true);
110
+ expect(spawnScheduledWake).toHaveBeenCalledWith(
111
+ expect.objectContaining({ agentInstruction: instruction }),
112
+ );
113
+ });
81
114
  });
@@ -115,7 +115,9 @@ function parseCreate(
115
115
  const requestedMs = parseDuration(intervalOnly[1]!, intervalOnly[2]!.toLowerCase());
116
116
  return requestedMs === undefined ? undefined : { instruction: boundedDefault, requestedMs };
117
117
  }
118
- const leading = /^(\d+)(s|m|h|d)\s+([\s\S]+)$/i.exec(args);
118
+ // One required space, then the rest verbatim (trimmed below): `\s+` before `[\s\S]+` split the
119
+ // same spaces two ways, which CodeQL reads as polynomial backtracking.
120
+ const leading = /^(\d+)(s|m|h|d)\s([\s\S]+)$/i.exec(args);
119
121
  if (leading) {
120
122
  const instruction = leading[3]!.trim();
121
123
  const requestedMs = parseDuration(leading[1]!, leading[2]!.toLowerCase());
@@ -14,6 +14,9 @@ export function createShellCommandEntry(): ICommand {
14
14
  source: 'shell',
15
15
  // User-only: the user's own shell passthrough; the model has its permission-gated shell tool.
16
16
  modelInvocable: false,
17
+ // It takes over the terminal the user sits at, so that terminal runs it, even when attached.
18
+ runner: 'client',
19
+ surfaces: ['terminal'],
17
20
  };
18
21
  }
19
22
 
@@ -26,6 +29,8 @@ function createShellSystemCommand(executable?: string): ISystemCommand {
26
29
  requiresPermission: true,
27
30
  userInvocable: true,
28
31
  modelInvocable: false,
32
+ runner: entry.runner,
33
+ surfaces: entry.surfaces,
29
34
  lifecycle: 'inline',
30
35
  execute: (context, args) => executeShellCommand(context, args, executable),
31
36
  };