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

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 (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +75 -72
  4. package/dist/node/index.d.cts +37 -45
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +37 -45
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +78 -75
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  15. package/src/default/__tests__/default-command-modules.test.ts +5 -4
  16. package/src/default/__tests__/model-exposure.test.ts +2 -0
  17. package/src/default/default-command-modules.ts +8 -0
  18. package/src/effort/effort-command.test.ts +65 -2
  19. package/src/effort/effort-command.ts +19 -5
  20. package/src/git/__tests__/fake-git-port.ts +9 -2
  21. package/src/git/git-command-module.ts +3 -4
  22. package/src/git/git-commit.ts +6 -3
  23. package/src/git/index.ts +4 -1
  24. package/src/goal/goal-command.ts +3 -1
  25. package/src/index.ts +6 -0
  26. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +131 -0
  27. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  28. package/src/mcp-activation/mcp-activation-command.ts +54 -1
  29. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  30. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  31. package/src/memory/memory-command-module.ts +4 -2
  32. package/src/memory/memory-command.ts +42 -2
  33. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  34. package/src/mode/mode-command.ts +1 -1
  35. package/src/model/__tests__/model-command-module.test.ts +80 -0
  36. package/src/model/__tests__/model-command.test.ts +240 -0
  37. package/src/model/index.ts +6 -0
  38. package/src/model/model-command-module.ts +56 -0
  39. package/src/model/model-command.ts +223 -0
  40. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  41. package/src/plugin/plugin-command.ts +47 -4
  42. package/src/provider/__tests__/org-policy.test.ts +4 -0
  43. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  44. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  45. package/src/provider/provider-command-execution.ts +54 -8
  46. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  47. package/src/provider/provider-command-profile-operations.ts +14 -0
  48. package/src/provider/provider-command-profile.ts +7 -4
  49. package/src/provider/provider-command-setup.ts +14 -1
  50. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  51. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  52. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  53. package/src/schedule/loop-command.ts +95 -3
  54. package/src/schedule/schedule-command-module.ts +8 -4
  55. package/src/settings/settings-command-module.ts +2 -2
  56. package/src/git/__tests__/git-diff.test.ts +0 -125
  57. package/src/git/__tests__/git-process.test.ts +0 -221
  58. package/src/git/__tests__/git-status.test.ts +0 -117
  59. package/src/git/git-diff.ts +0 -129
  60. package/src/git/git-process.ts +0 -169
  61. package/src/git/git-status.ts +0 -134
@@ -8,7 +8,10 @@ import {
8
8
  import { formatProviderChoiceLabel } from './provider-command-profile-operations.js';
9
9
 
10
10
  import type { IUserInteraction } from '@robota-sdk/agent-core';
11
- import type { IProviderCommandModuleOptions } from '@robota-sdk/agent-framework';
11
+ import type {
12
+ IProviderCommandModuleOptions,
13
+ IProviderProfileSettings,
14
+ } from '@robota-sdk/agent-framework';
12
15
  import type { ICommandResult } from '@robota-sdk/agent-interface-command';
13
16
 
14
17
  const MAX_DUPLICATE_PROFILE_SUFFIX = 1000;
@@ -90,6 +93,47 @@ function completeProviderDuplicate(
90
93
  };
91
94
  }
92
95
 
96
+ /**
97
+ * #3282 §4b: the direct, non-interactive delete itself — no replacement ask (the interactive
98
+ * per-profile menu's Delete, `buildProviderDelete` below, still offers one); refuses instead when the
99
+ * profile is the one in use. Never asks to CONFIRM either — its caller in `provider-command-execution`
100
+ * does that first, when a human can answer, before reaching this function; this function itself is
101
+ * called only once a delete is already decided (confirmed by a human, or `--confirmed` from the
102
+ * Settings screen's own ConfirmDialog).
103
+ */
104
+ export function buildProviderProfileDelete(
105
+ providers: Record<string, IProviderProfileSettings> | undefined,
106
+ currentProvider: string | undefined,
107
+ profileName: string | undefined,
108
+ options: IProviderCommandModuleOptions,
109
+ ): ICommandResult {
110
+ if (!profileName) {
111
+ return { message: 'Usage: /provider delete <profile>', success: false };
112
+ }
113
+ if (!providers?.[profileName]) {
114
+ return { message: `Provider profile "${profileName}" was not found.`, success: false };
115
+ }
116
+ if (Object.keys(providers).length <= 1) {
117
+ return { message: 'Cannot delete the only provider profile.', success: false };
118
+ }
119
+ if (currentProvider === profileName) {
120
+ return {
121
+ message: 'Cannot delete the profile in use. Switch to another profile first.',
122
+ success: false,
123
+ };
124
+ }
125
+ if (options.settings.readTargetSettings().providers?.[profileName] === undefined) {
126
+ return {
127
+ message: `Provider profile "${profileName}" is not stored in the active write target; edit its source settings file or override it before deleting.`,
128
+ success: false,
129
+ };
130
+ }
131
+ options.settings.writeTargetSettings(
132
+ deleteProviderProfile(options.settings.readTargetSettings(), profileName),
133
+ );
134
+ return { message: `Provider profile deleted: ${profileName}.`, success: true };
135
+ }
136
+
93
137
  export async function buildProviderDelete(
94
138
  ui: IUserInteraction,
95
139
  profileName: string,
@@ -109,8 +153,10 @@ export async function buildProviderDelete(
109
153
  success: false,
110
154
  };
111
155
  }
156
+ // #3282 §2: the GUI renders this confirm on its shared ConfirmDialog (id 'provider-delete') with
157
+ // this exact title — kept in the quoted-name form the TUI's inline picker shows the same way.
112
158
  const response = await ui.ask(
113
- confirmAction('provider-delete', `Delete provider profile ${profileName}?`),
159
+ confirmAction('provider-delete', `Delete profile "${profileName}"?`),
114
160
  );
115
161
  if (!isConfirmed(response)) {
116
162
  return { message: 'Provider delete cancelled.', success: true };
@@ -4,6 +4,7 @@ import {
4
4
  isApiKeyPlaintext,
5
5
  setCurrentProvider,
6
6
  upsertProviderProfile,
7
+ validateProviderProfile,
7
8
  } from '@robota-sdk/agent-framework';
8
9
 
9
10
  import { runProviderSetupAsk } from './provider-command-setup.js';
@@ -51,6 +52,19 @@ export function buildProviderSwitch(
51
52
  return { message: `Already using provider "${profileName}".`, success: true };
52
53
  }
53
54
  const profile = providers[profileName];
55
+ // #3282: the hot-swap this returns as a `hostActions` entry builds this exact profile against
56
+ // these exact definitions (`resolveUserSettingsProviderSwitch` in agent-framework). Validating it
57
+ // HERE, before anything is written, means a switch that would fail changes nothing on disk — the
58
+ // previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
59
+ // (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
60
+ try {
61
+ validateProviderProfile(profileName, profile, { providerDefinitions: options.providerDefinitions });
62
+ } catch (error) {
63
+ return {
64
+ message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
65
+ success: false,
66
+ };
67
+ }
54
68
  const target = options.settings.readTargetSettings();
55
69
  const merged = options.settings.readMergedSettings();
56
70
  const next =
@@ -23,7 +23,6 @@ const ACTION_EDIT = 'edit';
23
23
  const ACTION_TEST = 'test';
24
24
  const ACTION_DUPLICATE = 'duplicate';
25
25
  const ACTION_DELETE = 'delete';
26
- const ACTION_CANCEL = 'cancel';
27
26
 
28
27
  /**
29
28
  * Ask the user to pick a provider profile, then drive its action menu (CMD-004 inline ask). Replaces
@@ -58,6 +57,13 @@ async function askProviderProfileAction(
58
57
  if (!settings.providers?.[profileName]) {
59
58
  return { message: `Provider profile "${profileName}" was not found.`, success: false };
60
59
  }
60
+ // #3282 §2: no separate 'cancel' option here — the picker's own persistent Cancel affordance
61
+ // (every ask renderer offers one, e.g. `PermissionPrompt`'s ghost Cancel button in agent-ui-web)
62
+ // is the only Cancel a person sees. A second one, indistinguishable from Switch/Edit/Test/Duplicate
63
+ // in a flat list, was the audited bug (#3282 issue text): "Delete looks the same as Switch... plus
64
+ // a second Cancel." Order matters too — Switch first as the primary action, then Edit/Test/
65
+ // Duplicate, with Delete last: a client (the GUI's provider-action menu) that wants Delete
66
+ // separated and styled destructive can rely on it always being the final entry.
61
67
  const response = await ui.ask(
62
68
  selectAction('provider-profile-action', `Provider profile: ${profileName}`, [
63
69
  { value: ACTION_SWITCH, label: 'Switch' },
@@ -65,7 +71,6 @@ async function askProviderProfileAction(
65
71
  { value: ACTION_TEST, label: 'Test' },
66
72
  { value: ACTION_DUPLICATE, label: 'Duplicate' },
67
73
  { value: ACTION_DELETE, label: 'Delete' },
68
- { value: ACTION_CANCEL, label: 'Cancel' },
69
74
  ]),
70
75
  );
71
76
  if (response.type !== 'answer' || response.values[0] === undefined) {
@@ -97,8 +102,6 @@ async function executeProviderProfileAction(
97
102
  return buildProviderDuplicate(ui, profileName, options);
98
103
  case ACTION_DELETE:
99
104
  return buildProviderDelete(ui, profileName, options);
100
- case ACTION_CANCEL:
101
- return { message: 'Provider profile action cancelled.', success: true };
102
105
  default:
103
106
  return { message: `Unknown provider profile action "${action}".`, success: false };
104
107
  }
@@ -87,11 +87,12 @@ export function runProviderAddSetup(
87
87
  ui: IUserInteraction,
88
88
  flow: IProviderSetupFlowState,
89
89
  options: IProviderCommandModuleOptions,
90
+ isSetupRequired: boolean,
90
91
  ): Promise<ICommandResult> {
91
92
  return runProviderSetupAsk(
92
93
  ui,
93
94
  flow,
94
- (input) => completeProviderSetup(input, options),
95
+ (input) => completeProviderSetup(input, options, isSetupRequired),
95
96
  'Provider setup cancelled.',
96
97
  );
97
98
  }
@@ -99,12 +100,24 @@ export function runProviderAddSetup(
99
100
  function completeProviderSetup(
100
101
  input: IProviderSetupInput,
101
102
  options: IProviderCommandModuleOptions,
103
+ isSetupRequired: boolean,
102
104
  ): ICommandResult {
103
105
  const target = options.settings.readTargetSettings();
104
106
  const patch = buildProviderSetupPatch(input, {
105
107
  providerDefinitions: options.providerDefinitions,
106
108
  });
107
109
  options.settings.writeTargetSettings(mergeProviderPatch(target, patch));
110
+ // #3282 §3: the session's own live setup-mode state (never settings — an ordinary env-default
111
+ // session also has no persisted `currentProvider`, and restarts like any other session). Only a
112
+ // session that itself started with a placeholder provider hot-swaps the real one in; every other
113
+ // `/provider add`, even a session's first EVER persisted profile, restarts as before.
114
+ if (isSetupRequired) {
115
+ return {
116
+ message: `Provider ${input.profile} configured.`,
117
+ success: true,
118
+ hostActions: [{ type: 'provider-hot-swap', profileName: input.profile }],
119
+ };
120
+ }
108
121
  return {
109
122
  message: `Provider ${input.profile} configured. Restarting...`,
110
123
  success: true,
@@ -278,7 +278,8 @@ describe('executeRewindCommand', () => {
278
278
  const result = await session.executeCommand('rewind', 'list');
279
279
 
280
280
  expect(result?.success).toBe(false);
281
- expect(result?.message).toContain('cannot prove a write stays inside the project');
281
+ // #3289 §3: plain words naming the feature ("Rewind"), not the internal write-containment reason.
282
+ expect(result?.message).toBe("Rewind isn't available on this computer yet.");
282
283
  expect(result?.message).not.toContain('trust');
283
284
  });
284
285
  });
@@ -10,6 +10,15 @@ afterEach(async () => {
10
10
  harness = undefined;
11
11
  });
12
12
 
13
+ /**
14
+ * #3288 §1: a bare/interval-only loop now asks before it falls back to the default prompt, even in
15
+ * this harness's "typed the command" (`source: 'user'`) path — subscribing an `askHandler` gives
16
+ * `getUserInteraction()` a live port (REMOTE-007 D4a), so these functional tests stay interactive
17
+ * rather than hitting the new headless refusal. Always answers "Start".
18
+ */
19
+ const confirmDefaultStart = async (): Promise<{ type: 'answer'; values: readonly string[] }> =>
20
+ ({ type: 'answer', values: ['start'] });
21
+
13
22
  describe('/loop command in a real interactive session', () => {
14
23
  it('re-reads an omitted self-paced prompt before each iteration without changing explicit prompts', async () => {
15
24
  let prompt = 'project first';
@@ -18,6 +27,7 @@ describe('/loop command in a real interactive session', () => {
18
27
  turns: [{ text: 'first' }, { text: 'second' }],
19
28
  commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
20
29
  resolveDefaultLoopPrompt: resolveDefaultPrompt,
30
+ askHandler: confirmDefaultStart,
21
31
  backgroundTasks: true,
22
32
  persistence: true,
23
33
  });
@@ -51,6 +61,7 @@ describe('/loop command in a real interactive session', () => {
51
61
  turns: [{ text: 'first' }, { text: 'second' }],
52
62
  commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
53
63
  resolveDefaultLoopPrompt: resolveDefaultPrompt,
64
+ askHandler: confirmDefaultStart,
54
65
  backgroundTasks: true,
55
66
  persistence: true,
56
67
  });
@@ -109,6 +120,7 @@ describe('/loop command in a real interactive session', () => {
109
120
  turns: [{ text: 'done' }],
110
121
  commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
111
122
  resolveDefaultLoopPrompt: resolveDefaultPrompt,
123
+ askHandler: confirmDefaultStart,
112
124
  backgroundTasks: true,
113
125
  persistence: true,
114
126
  });
@@ -297,6 +309,7 @@ describe('/loop command in a real interactive session', () => {
297
309
  harness = scriptedSession({
298
310
  turns: [{ text: 'unused' }],
299
311
  commandModules: [createScheduleCommandModule({ defaultPrompt: maintenancePrompt })],
312
+ askHandler: confirmDefaultStart,
300
313
  backgroundTasks: true,
301
314
  persistence: true,
302
315
  });
@@ -2,15 +2,27 @@ import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { executeLoopCommand } from '../loop-command.js';
4
4
 
5
+ import type { ILoopInvocationContext } from '../loop-command.js';
5
6
  import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
6
7
 
8
+ /**
9
+ * #3288 §1: every default-prompt form (bare or interval-only) is gated on confirmation — an
10
+ * interactive invocation with an ask port that answers Start, for a test whose OWN point is
11
+ * something else (default-prompt resolution, cadence, etc.), not the confirmation gate itself.
12
+ */
13
+ function interactiveStart(): ILoopInvocationContext {
14
+ return { ui: { ask: vi.fn().mockResolvedValue({ type: 'answer', values: ['start'] }) }, source: 'user' };
15
+ }
16
+
7
17
  describe('fixed in-session loop', () => {
8
18
  it('uses the host maintenance prompt for interval-only forms', async () => {
9
19
  const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_default' });
10
20
  const host = createTestAgentJobHost({ spawnScheduledWake });
11
21
  const options = { defaultPrompt: 'Tend only the current task and its PR.' };
12
22
 
13
- expect((await executeLoopCommand(host, vi.fn(), '5m', options)).success).toBe(true);
23
+ expect(
24
+ (await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
25
+ ).toBe(true);
14
26
  expect(spawnScheduledWake).toHaveBeenLastCalledWith(
15
27
  expect.objectContaining({
16
28
  cronExpression: expect.any(String),
@@ -27,7 +39,10 @@ describe('fixed in-session loop', () => {
27
39
  const host = createTestAgentJobHost({ spawnScheduledWake, createSelfPacedLoop });
28
40
  const options = { defaultPrompt: 'Tend the current task.' };
29
41
 
30
- expect((await executeLoopCommand(host, vi.fn(), '', options)).success).toBe(true);
42
+ expect(
43
+ (await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
44
+ ).toBe(true);
45
+ // An explicit prompt is never gated — no invocation (headless) needed to succeed.
31
46
  expect((await executeLoopCommand(host, vi.fn(), 'check the build')).success).toBe(true);
32
47
  expect(createSelfPacedLoop.mock.calls.map(([prompt]) => prompt)).toEqual([
33
48
  options.defaultPrompt,
@@ -45,9 +60,13 @@ describe('fixed in-session loop', () => {
45
60
  const resolveDefaultPrompt = vi.fn().mockReturnValue('current file prompt');
46
61
  const options = { defaultPrompt: 'built-in prompt', resolveDefaultPrompt };
47
62
 
48
- expect((await executeLoopCommand(host, vi.fn(), '', options)).success).toBe(true);
63
+ expect(
64
+ (await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
65
+ ).toBe(true);
49
66
  expect(createSelfPacedLoop).toHaveBeenCalledWith('current file prompt', { useDefaultPrompt: true });
50
- expect((await executeLoopCommand(host, vi.fn(), '5m', options)).success).toBe(true);
67
+ expect(
68
+ (await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
69
+ ).toBe(true);
51
70
  expect(spawnScheduledWake).toHaveBeenCalledWith(expect.objectContaining({
52
71
  agentInstruction: 'current file prompt', sessionLoopDefaultPrompt: true,
53
72
  }));
@@ -358,3 +377,218 @@ describe('fixed in-session loop', () => {
358
377
  },
359
378
  );
360
379
  });
380
+
381
+ /**
382
+ * #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or
383
+ * an interval-only `/loop <N><s|m|h|d>` (self-paced or fixed cadence) — keeps working on its own for
384
+ * up to 7 days: a standing commitment the operator never typed, gated the same way regardless of
385
+ * which form reached it. An explicit prompt is the operator's own instruction and is never gated.
386
+ * A model-invoked call is always refused outright; an interactive one (an ask port attached) asks;
387
+ * a headless one (no ask port — print mode, a scheduled run) is refused too, the same reason as the
388
+ * model: nobody is there to say yes to a loop that can run for a week.
389
+ */
390
+ describe('/loop asks before starting on its own (#3288 §1)', () => {
391
+ function askResolving(value: 'start' | 'cancel'): ReturnType<typeof vi.fn> {
392
+ return vi.fn().mockResolvedValue({ type: 'answer', values: [value] });
393
+ }
394
+
395
+ it('asks before creating a bare self-paced loop, and creates it on Start', async () => {
396
+ const createSelfPacedLoop = vi.fn().mockResolvedValue({
397
+ loopId: 'loop_default',
398
+ expiresAt: '2030-01-01T00:00:00.000Z',
399
+ });
400
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
401
+ const ask = askResolving('start');
402
+ const options = { defaultPrompt: 'Tend the current task.\nCheck CI when it finishes.' };
403
+
404
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
405
+
406
+ expect(ask).toHaveBeenCalledTimes(1);
407
+ const request = ask.mock.calls[0]![0] as { title: string; options: Array<{ value: string }> };
408
+ expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
409
+ expect(request.title).toContain('Tend the current task.');
410
+ expect(request.title).not.toContain('Check CI when it finishes.');
411
+ expect(request.options.map((option) => option.value)).toEqual(['start', 'cancel']);
412
+ expect(createSelfPacedLoop).toHaveBeenCalledWith('Tend the current task.\nCheck CI when it finishes.', {
413
+ useDefaultPrompt: true,
414
+ });
415
+ expect(result.success).toBe(true);
416
+ });
417
+
418
+ it('does not create the loop when the user cancels', async () => {
419
+ const createSelfPacedLoop = vi.fn();
420
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
421
+ const ask = askResolving('cancel');
422
+ const options = { defaultPrompt: 'Tend the current task.' };
423
+
424
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
425
+
426
+ expect(createSelfPacedLoop).not.toHaveBeenCalled();
427
+ expect(result).toMatchObject({ success: true, message: 'Loop not started.' });
428
+ });
429
+
430
+ it('treats a dismissed (cancelled) ask the same as Cancel', async () => {
431
+ const createSelfPacedLoop = vi.fn();
432
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
433
+ const ask = vi.fn().mockResolvedValue({ type: 'cancelled' });
434
+ const options = { defaultPrompt: 'Tend the current task.' };
435
+
436
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
437
+
438
+ expect(createSelfPacedLoop).not.toHaveBeenCalled();
439
+ expect(result.success).toBe(true);
440
+ expect(result.message).toBe('Loop not started.');
441
+ });
442
+
443
+ it('lists other active loops in the confirmation', async () => {
444
+ const createSelfPacedLoop = vi.fn().mockResolvedValue({
445
+ loopId: 'loop_new',
446
+ expiresAt: '2030-01-01T00:00:00.000Z',
447
+ });
448
+ const host = createTestAgentJobHost({
449
+ createSelfPacedLoop,
450
+ listSelfPacedLoops: () => [
451
+ {
452
+ loopId: 'loop_existing',
453
+ instruction: 'watch CI',
454
+ phase: 'waiting',
455
+ createdAt: '2026-09-24T00:00:00.000Z',
456
+ expiresAt: '2026-10-01T00:00:00.000Z',
457
+ revision: 1,
458
+ generation: 1,
459
+ fallbackUsed: false,
460
+ },
461
+ ],
462
+ });
463
+ const ask = askResolving('start');
464
+
465
+ await executeLoopCommand(host, vi.fn(), '', { defaultPrompt: 'Tend.' }, { ui: { ask }, source: 'user' });
466
+
467
+ const request = ask.mock.calls[0]![0] as { description?: string };
468
+ expect(request.description).toContain('loop_existing');
469
+ });
470
+
471
+ it('refuses a model-invoked bare loop outright, without asking', async () => {
472
+ const createSelfPacedLoop = vi.fn();
473
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
474
+ const ask = vi.fn();
475
+ const options = { defaultPrompt: 'Tend the current task.' };
476
+
477
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'model' });
478
+
479
+ expect(ask).not.toHaveBeenCalled();
480
+ expect(createSelfPacedLoop).not.toHaveBeenCalled();
481
+ expect(result.success).toBe(false);
482
+ expect(result.message).toContain('/loop');
483
+ });
484
+
485
+ it('refuses a model-invoked bare loop even with no interactive renderer attached', async () => {
486
+ const createSelfPacedLoop = vi.fn();
487
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
488
+ const options = { defaultPrompt: 'Tend the current task.' };
489
+
490
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'model' });
491
+
492
+ expect(createSelfPacedLoop).not.toHaveBeenCalled();
493
+ expect(result.success).toBe(false);
494
+ });
495
+
496
+ it('refuses a bare loop headless (no ask port attached) — never proceeds unconfirmed', async () => {
497
+ const createSelfPacedLoop = vi.fn();
498
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
499
+ const options = { defaultPrompt: 'Tend the current task.' };
500
+
501
+ const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'user' });
502
+
503
+ expect(createSelfPacedLoop).not.toHaveBeenCalled();
504
+ expect(result.success).toBe(false);
505
+ expect(result.message).toContain('needs someone to confirm it');
506
+ expect(result.message).toContain('/loop <prompt>');
507
+ });
508
+
509
+ it('refuses an interval-only default-prompt loop headless too, the same as bare', async () => {
510
+ const spawnScheduledWake = vi.fn();
511
+ const host = createTestAgentJobHost({ spawnScheduledWake });
512
+ const options = { defaultPrompt: 'Tend the current task.' };
513
+
514
+ const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: undefined, source: 'user' });
515
+
516
+ expect(spawnScheduledWake).not.toHaveBeenCalled();
517
+ expect(result.success).toBe(false);
518
+ expect(result.message).toContain('needs someone to confirm it');
519
+ });
520
+
521
+ it('refuses a model-invoked interval-only default-prompt loop outright, without asking', async () => {
522
+ const spawnScheduledWake = vi.fn();
523
+ const host = createTestAgentJobHost({ spawnScheduledWake });
524
+ const ask = vi.fn();
525
+ const options = { defaultPrompt: 'Tend the current task.' };
526
+
527
+ const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'model' });
528
+
529
+ expect(ask).not.toHaveBeenCalled();
530
+ expect(spawnScheduledWake).not.toHaveBeenCalled();
531
+ expect(result.success).toBe(false);
532
+ });
533
+
534
+ it('asks for an interactive interval-only default-prompt loop, and creates it on Start', async () => {
535
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_interval' });
536
+ const host = createTestAgentJobHost({ spawnScheduledWake });
537
+ const ask = askResolving('start');
538
+ const options = { defaultPrompt: 'Tend the current task.' };
539
+
540
+ const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'user' });
541
+
542
+ expect(ask).toHaveBeenCalledTimes(1);
543
+ const request = ask.mock.calls[0]![0] as { title: string };
544
+ expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
545
+ expect(spawnScheduledWake).toHaveBeenCalledWith(
546
+ expect.objectContaining({ agentInstruction: options.defaultPrompt }),
547
+ );
548
+ expect(result.success).toBe(true);
549
+ });
550
+
551
+ it('never asks for an interval WITH an explicit prompt, even headless', async () => {
552
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_explicit_interval' });
553
+ const host = createTestAgentJobHost({ spawnScheduledWake });
554
+
555
+ // No invocation at all (defaults to headless, source 'user') — an explicit prompt is unaffected.
556
+ const result = await executeLoopCommand(host, vi.fn(), '1h check the build', {
557
+ defaultPrompt: 'Tend the current task.',
558
+ });
559
+
560
+ expect(spawnScheduledWake).toHaveBeenCalledWith(
561
+ expect.objectContaining({ agentInstruction: 'check the build' }),
562
+ );
563
+ expect(result.success).toBe(true);
564
+ });
565
+
566
+ it('never asks for an explicit prompt, even with a person attached', async () => {
567
+ const createSelfPacedLoop = vi.fn().mockResolvedValue({
568
+ loopId: 'loop_explicit',
569
+ expiresAt: '2030-01-01T00:00:00.000Z',
570
+ });
571
+ const host = createTestAgentJobHost({ createSelfPacedLoop });
572
+ const ask = vi.fn();
573
+
574
+ const result = await executeLoopCommand(host, vi.fn(), 'check the build', {}, { ui: { ask }, source: 'user' });
575
+
576
+ expect(ask).not.toHaveBeenCalled();
577
+ expect(createSelfPacedLoop).toHaveBeenCalledWith('check the build');
578
+ expect(result.success).toBe(true);
579
+ });
580
+
581
+ it('never asks for a fixed-cadence loop with an explicit prompt', async () => {
582
+ const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_fixed' });
583
+ const host = createTestAgentJobHost({ spawnScheduledWake });
584
+ const ask = vi.fn();
585
+
586
+ const result = await executeLoopCommand(host, vi.fn(), '5m check the build', {}, {
587
+ ui: { ask },
588
+ source: 'user',
589
+ });
590
+
591
+ expect(ask).not.toHaveBeenCalled();
592
+ expect(result.success).toBe(true);
593
+ });
594
+ });
@@ -2,10 +2,14 @@
2
2
 
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
+ import { selectAction } from '@robota-sdk/agent-core';
6
+ import { isLoopStopVerb } from '@robota-sdk/agent-framework';
7
+
5
8
  import { jitterFixedLoop } from './loop-jitter.js';
6
9
 
10
+ import type { IUserInteraction } from '@robota-sdk/agent-core';
7
11
  import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
8
- import type { ICommandResult } from '@robota-sdk/agent-interface-command';
12
+ import type { ICommandResult, TCommandInvocationSource } from '@robota-sdk/agent-interface-command';
9
13
  import type { IBackgroundTaskState } from '@robota-sdk/agent-interface-execution';
10
14
 
11
15
  const LOOP_LABEL = 'Loop: ';
@@ -14,6 +18,7 @@ const HOURS_PER_DAY = 24;
14
18
  const MAX_LABEL_LENGTH = 48;
15
19
  const MAX_ACTIVE_LOOPS = 3;
16
20
  const LOOP_LIFETIME_MS = 7 * 24 * 60 * 60_000;
21
+ const LOOP_LIFETIME_DAYS = LOOP_LIFETIME_MS / (HOURS_PER_DAY * 60 * SECONDS_PER_MINUTE * 1_000);
17
22
  const MAX_DEFAULT_PROMPT_LENGTH = 4_096;
18
23
  const pendingCreates = new WeakMap<object, number>();
19
24
  const TRAILING_UNITS = new Set([
@@ -31,6 +36,20 @@ export interface ILoopCommandOptions {
31
36
  disabled?: boolean;
32
37
  }
33
38
 
39
+ /**
40
+ * #3288 §1: who invoked `/loop`, and how to ask them. `ui` is the injected "ask the user" port
41
+ * (absent for a headless/automation host — see `ICommandHostUserInteraction`); `source` is the
42
+ * command's own invocation source. Defaults to `{ ui: undefined, source: 'user' }` when the caller
43
+ * omits it, matching every pre-existing (headless) call site: proceed unconfirmed, the same as
44
+ * before this gate existed.
45
+ */
46
+ export interface ILoopInvocationContext {
47
+ readonly ui: IUserInteraction | undefined;
48
+ readonly source: TCommandInvocationSource;
49
+ }
50
+
51
+ const DEFAULT_INVOCATION: ILoopInvocationContext = { ui: undefined, source: 'user' };
52
+
34
53
  const UNIT_MS: Record<string, number> = {
35
54
  s: 1_000,
36
55
  m: 60_000,
@@ -164,20 +183,80 @@ function loopIdOf(task: IBackgroundTaskState): string {
164
183
  return typeof id === 'string' && id.length > 0 ? id : task.id;
165
184
  }
166
185
 
186
+ function describeActiveLoops(
187
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
188
+ ): string {
189
+ const loops = activeLoops(host);
190
+ const selfPaced = activeSelfPacedLoops(host);
191
+ if (loops.length + selfPaced.length === 0) return 'No other active loops.';
192
+ return [
193
+ ...loops.map((task) => `- ${loopIdOf(task)} [${task.status}] ${task.label}`),
194
+ ...selfPaced.map((loop) => `- ${loop.loopId} [${loop.phase}] Loop: ${loop.instruction}`),
195
+ ].join('\n');
196
+ }
197
+
198
+ /** The plain refusal for a default-prompt loop with no one to ask — never a silent, unconfirmed start. */
199
+ const HEADLESS_REFUSAL =
200
+ 'A loop with the default maintenance prompt needs someone to confirm it. Start it from an ' +
201
+ 'interactive session, or give the loop its own prompt: /loop <prompt>';
202
+
203
+ /**
204
+ * #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or an
205
+ * interval-only `/loop <N><s|m|h|d>` — keeps working on its own for up to 7 days: a standing
206
+ * commitment the operator never typed, gated the same way regardless of which form reached it.
207
+ * Returns the refusal/decline result to return in place of creating the loop, or `undefined` to
208
+ * proceed. A `source: 'model'` call is refused outright and never asks — the model must give its own
209
+ * prompt, or name `/loop` to the user (AGENTS.md: trust and permission-widening actions stay
210
+ * user-only, and a refusal names the command to suggest). A headless/automation host (no ask port —
211
+ * print mode, a scheduled run) is refused too, the same reason: a loop that can run for a week must
212
+ * not start without a person saying yes, and there is no one here to ask.
213
+ */
214
+ async function confirmDefaultLoopStart(
215
+ host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
216
+ invocation: ILoopInvocationContext,
217
+ instruction: string,
218
+ ): Promise<ICommandResult | undefined> {
219
+ if (invocation.source === 'model') {
220
+ return {
221
+ success: false,
222
+ message:
223
+ 'Starting a loop with its built-in prompt needs a person to confirm it — ' +
224
+ 'ask the user to run /loop, or give your own prompt with /loop <prompt>.',
225
+ };
226
+ }
227
+ const { ui } = invocation;
228
+ if (!ui) return { success: false, message: HEADLESS_REFUSAL };
229
+ const firstLine = instruction.split('\n')[0]!.trim() || instruction;
230
+ const response = await ui.ask(
231
+ selectAction(
232
+ 'loop-start-default',
233
+ `Start a loop that keeps working on its own for up to ${LOOP_LIFETIME_DAYS} days? It will: ${firstLine}`,
234
+ [
235
+ { value: 'start', label: 'Start' },
236
+ { value: 'cancel', label: 'Cancel' },
237
+ ],
238
+ { description: describeActiveLoops(host) },
239
+ ),
240
+ );
241
+ if (response.type === 'answer' && response.values.includes('start')) return undefined;
242
+ return { success: true, message: 'Loop not started.' };
243
+ }
244
+
167
245
  export async function executeLoopCommand(
168
246
  host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops' | 'stopSelfPacedLoop'>,
169
247
  cancelBackgroundTask: (taskId: string, reason: string) => Promise<void>,
170
248
  args: string,
171
249
  options: ILoopCommandOptions = {},
250
+ invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
172
251
  ): Promise<ICommandResult> {
173
252
  const trimmed = args.trim();
174
253
  if (trimmed === 'list') return listLoops(host, options);
175
- if (/^stop(?:\s|$)/.test(trimmed)) return stopLoop(host, cancelBackgroundTask, trimmed);
254
+ if (isLoopStopVerb(trimmed)) return stopLoop(host, cancelBackgroundTask, trimmed);
176
255
 
177
256
  if (options.disabled) {
178
257
  return { success: false, message: 'Session loops are disabled by the host.' };
179
258
  }
180
- return createLoop(host, trimmed, options);
259
+ return createLoop(host, trimmed, options, invocation);
181
260
  }
182
261
 
183
262
  function listLoops(
@@ -237,6 +316,7 @@ async function createLoop(
237
316
  host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops'>,
238
317
  args: string,
239
318
  options: ILoopCommandOptions,
319
+ invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
240
320
  ): Promise<ICommandResult> {
241
321
  const useDefaultPrompt = args === '' || /^\d+(s|m|h|d)$/i.test(args);
242
322
  let defaultPrompt = options.defaultPrompt;
@@ -259,6 +339,10 @@ async function createLoop(
259
339
  if (!host.createSelfPacedLoop) {
260
340
  return { success: false, message: 'Self-paced loops require a persistent interactive session.' };
261
341
  }
342
+ if (useDefaultPrompt) {
343
+ const declined = await confirmDefaultLoopStart(host, invocation, instruction);
344
+ if (declined) return declined;
345
+ }
262
346
  const pending = pendingCreates.get(host) ?? 0;
263
347
  if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
264
348
  return { success: false, message: `At most ${MAX_ACTIVE_LOOPS} active loops are allowed. Stop one before creating another.` };
@@ -279,6 +363,14 @@ async function createLoop(
279
363
  else pendingCreates.set(host, remaining);
280
364
  }
281
365
  }
366
+ // #3288 §1: an interval-only form (`/loop 5m`) that fell back to the host's default prompt is
367
+ // gated exactly like the bare form — it is the SAME standing commitment (up to 7 days, unattended),
368
+ // only on a fixed cadence instead of self-paced. An explicit prompt (`/loop 5m check the build`)
369
+ // never reaches here with `useDefaultPrompt` true, so it is never gated.
370
+ if (useDefaultPrompt) {
371
+ const declined = await confirmDefaultLoopStart(host, invocation, parsed.instruction);
372
+ if (declined) return declined;
373
+ }
282
374
  const pending = pendingCreates.get(host) ?? 0;
283
375
  if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
284
376
  return {