@robota-sdk/agent-command 3.0.0-beta.82 → 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.
- package/CHANGELOG.md +1071 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +81 -72
- package/dist/node/index.d.cts +136 -52
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +136 -52
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +79 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -10
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +54 -14
- package/src/default/__tests__/model-exposure.test.ts +18 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +15 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/__tests__/git-command-module.test.ts +10 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/handoff/handoff-command.ts +2 -2
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +14 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +62 -4
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +78 -0
- package/src/peers/peers-command-module.ts +1 -1
- package/src/peers/peers-command.ts +33 -4
- package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
- package/src/plugin/plugin-command.ts +47 -4
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +98 -4
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -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,
|
|
@@ -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,35 @@ 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
|
+
// #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.");
|
|
283
|
+
expect(result?.message).not.toContain('trust');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
255
286
|
});
|
|
@@ -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:
|
|
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
|
|
246
|
+
return list(context);
|
|
226
247
|
}
|
|
227
248
|
|
|
228
249
|
if (subcommand === 'inspect') {
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
});
|
|
@@ -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
|
});
|
|
@@ -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,
|
|
@@ -115,7 +134,9 @@ function parseCreate(
|
|
|
115
134
|
const requestedMs = parseDuration(intervalOnly[1]!, intervalOnly[2]!.toLowerCase());
|
|
116
135
|
return requestedMs === undefined ? undefined : { instruction: boundedDefault, requestedMs };
|
|
117
136
|
}
|
|
118
|
-
|
|
137
|
+
// One required space, then the rest verbatim (trimmed below): `\s+` before `[\s\S]+` split the
|
|
138
|
+
// same spaces two ways, which CodeQL reads as polynomial backtracking.
|
|
139
|
+
const leading = /^(\d+)(s|m|h|d)\s([\s\S]+)$/i.exec(args);
|
|
119
140
|
if (leading) {
|
|
120
141
|
const instruction = leading[3]!.trim();
|
|
121
142
|
const requestedMs = parseDuration(leading[1]!, leading[2]!.toLowerCase());
|
|
@@ -162,20 +183,80 @@ function loopIdOf(task: IBackgroundTaskState): string {
|
|
|
162
183
|
return typeof id === 'string' && id.length > 0 ? id : task.id;
|
|
163
184
|
}
|
|
164
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
|
+
|
|
165
245
|
export async function executeLoopCommand(
|
|
166
246
|
host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops' | 'stopSelfPacedLoop'>,
|
|
167
247
|
cancelBackgroundTask: (taskId: string, reason: string) => Promise<void>,
|
|
168
248
|
args: string,
|
|
169
249
|
options: ILoopCommandOptions = {},
|
|
250
|
+
invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
|
|
170
251
|
): Promise<ICommandResult> {
|
|
171
252
|
const trimmed = args.trim();
|
|
172
253
|
if (trimmed === 'list') return listLoops(host, options);
|
|
173
|
-
if (
|
|
254
|
+
if (isLoopStopVerb(trimmed)) return stopLoop(host, cancelBackgroundTask, trimmed);
|
|
174
255
|
|
|
175
256
|
if (options.disabled) {
|
|
176
257
|
return { success: false, message: 'Session loops are disabled by the host.' };
|
|
177
258
|
}
|
|
178
|
-
return createLoop(host, trimmed, options);
|
|
259
|
+
return createLoop(host, trimmed, options, invocation);
|
|
179
260
|
}
|
|
180
261
|
|
|
181
262
|
function listLoops(
|
|
@@ -235,6 +316,7 @@ async function createLoop(
|
|
|
235
316
|
host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops'>,
|
|
236
317
|
args: string,
|
|
237
318
|
options: ILoopCommandOptions,
|
|
319
|
+
invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
|
|
238
320
|
): Promise<ICommandResult> {
|
|
239
321
|
const useDefaultPrompt = args === '' || /^\d+(s|m|h|d)$/i.test(args);
|
|
240
322
|
let defaultPrompt = options.defaultPrompt;
|
|
@@ -257,6 +339,10 @@ async function createLoop(
|
|
|
257
339
|
if (!host.createSelfPacedLoop) {
|
|
258
340
|
return { success: false, message: 'Self-paced loops require a persistent interactive session.' };
|
|
259
341
|
}
|
|
342
|
+
if (useDefaultPrompt) {
|
|
343
|
+
const declined = await confirmDefaultLoopStart(host, invocation, instruction);
|
|
344
|
+
if (declined) return declined;
|
|
345
|
+
}
|
|
260
346
|
const pending = pendingCreates.get(host) ?? 0;
|
|
261
347
|
if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
|
|
262
348
|
return { success: false, message: `At most ${MAX_ACTIVE_LOOPS} active loops are allowed. Stop one before creating another.` };
|
|
@@ -277,6 +363,14 @@ async function createLoop(
|
|
|
277
363
|
else pendingCreates.set(host, remaining);
|
|
278
364
|
}
|
|
279
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
|
+
}
|
|
280
374
|
const pending = pendingCreates.get(host) ?? 0;
|
|
281
375
|
if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
|
|
282
376
|
return {
|