@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.13

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.
@@ -0,0 +1,98 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, extname, isAbsolute, join, resolve } from 'node:path';
3
+
4
+ import { detectSupportedImageMimeTypeFromFile, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import { Type } from 'typebox';
6
+
7
+ export const GENERATE_IMAGE = 'GenerateImage';
8
+
9
+ const ASPECT_RATIOS = ['1:1', '4:3', '3:4', '16:9', '9:16'] as const;
10
+ const EXTENSIONS: Record<string, string> = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' };
11
+
12
+ // Cursor's GenerateImage rules: explicit requests only, no data charts, a concrete description, and no Markdown echo.
13
+ const DESCRIPTION = [
14
+ 'Generate an image file from a text description.',
15
+ '',
16
+ 'Use this tool only when the user explicitly asks for an image; do not generate images just to be helpful. Do not use it for data-heavy visuals such as charts, plots, or tables; make those with code or the visualize tool.',
17
+ '',
18
+ 'Describe the image concretely first: the subjects, layout, style, colors, any text, and constraints, in at least 6 words. A description of 3 words or fewer is rejected. Set aspect_ratio when the user asks for one, and pass reference images in reference_image_paths.',
19
+ 'The result shows the generated image and names the saved file, so do not repeat the image as Markdown in your reply.',
20
+ ].join('\n');
21
+
22
+ const Params = Type.Object({
23
+ description: Type.String({ description: 'A detailed description of the image, at least 6 words. Descriptions of 3 words or fewer are rejected.' }),
24
+ filename: Type.Optional(Type.String({ description: "Optional file name for the image, such as 'diagram.png', without a directory. Defaults to a timestamped name." })),
25
+ reference_image_paths: Type.Optional(Type.Array(Type.String(), { description: 'Optional paths of reference images to use as additional inputs.' })),
26
+ aspect_ratio: Type.Optional(Type.Enum(ASPECT_RATIOS, { type: 'string', description: 'Optional aspect ratio: "1:1", "4:3", "3:4", "16:9", or "9:16".' })),
27
+ });
28
+
29
+ type ImageBlock = { type: 'image'; data: string; mimeType: string };
30
+
31
+ async function referenceImage(path: string, cwd: string): Promise<ImageBlock> {
32
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
33
+ const mimeType = await detectSupportedImageMimeTypeFromFile(absolute);
34
+ if (mimeType === null) throw new Error(`reference_image_paths: ${absolute} is not a PNG, JPEG, GIF, or WebP image.`);
35
+ return { type: 'image', data: readFileSync(absolute).toString('base64'), mimeType };
36
+ }
37
+
38
+ /** The saved file name: the requested name without any directory, with the extension the image data has. */
39
+ export function imageFileName(requested: string | undefined, mimeType: string, index: number, now: Date): string {
40
+ const extension = EXTENSIONS[mimeType] ?? '.png';
41
+ const base = requested === undefined || basename(requested).trim() === '' ? `image-${now.toISOString().replace(/[:.]/g, '-')}` : basename(requested, extname(requested));
42
+ return `${base}${index === 0 ? '' : `-${index + 1}`}${extension}`;
43
+ }
44
+
45
+ /** The first image model Pi can call with the configured credentials. */
46
+ async function imageModel(ctx: ExtensionContext) {
47
+ const [model] = await ctx.modelRegistry.getAvailableOfType('image');
48
+ return model;
49
+ }
50
+
51
+ /** Cursor's GenerateImage on Pi's image models, offered when one is available. Images save to the session's artifacts folder. */
52
+ export interface GenerateImageOptions {
53
+ artifactsDir: (ctx: ExtensionContext) => string;
54
+ /** False where the session may not write files: a readonly subagent, or plan mode. */
55
+ mayOffer: () => boolean;
56
+ }
57
+
58
+ export function registerGenerateImage(pi: ExtensionAPI, options: GenerateImageOptions): void {
59
+ pi.registerTool({
60
+ name: GENERATE_IMAGE,
61
+ label: 'Generate image',
62
+ description: DESCRIPTION,
63
+ promptSnippet: 'Generate an image file from a text description',
64
+ parameters: Params,
65
+ defaultActive: false,
66
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
67
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
68
+ if (params.description.trim().split(/\s+/).length <= 3) throw new Error('Describe the image in at least 6 words. Descriptions of 3 words or fewer are rejected.');
69
+ const model = await imageModel(ctx);
70
+ if (model === undefined) throw new Error('No image model is available. Configure one, such as an OpenRouter image model, to generate images.');
71
+ const references = await Promise.all((params.reference_image_paths ?? []).map((path) => referenceImage(path, ctx.cwd)));
72
+ const text = params.aspect_ratio === undefined ? params.description.trim() : `${params.description.trim()}\n\nAspect ratio: ${params.aspect_ratio}.`;
73
+ const result = await ctx.modelRegistry.generateImages(model, { input: [{ type: 'text', text }, ...references] }, signal === undefined ? undefined : { signal });
74
+ if (result.stopReason !== 'stop') throw new Error(`Image generation failed: ${result.errorMessage ?? result.stopReason}`);
75
+ const images = result.output.filter((block): block is ImageBlock => block.type === 'image');
76
+ if (images.length === 0) throw new Error('The image model returned no image.');
77
+ const dir = options.artifactsDir(ctx);
78
+ mkdirSync(dir, { recursive: true });
79
+ const now = new Date();
80
+ const paths = images.map((image, index) => {
81
+ const path = join(dir, imageFileName(params.filename, image.mimeType, index, now));
82
+ writeFileSync(path, Buffer.from(image.data, 'base64'));
83
+ return path;
84
+ });
85
+ return {
86
+ content: [{ type: 'text' as const, text: `Generated ${paths.length === 1 ? 'an image' : `${paths.length} images`} with ${model.provider}/${model.id}, saved to ${paths.join(', ')}.` }, ...images],
87
+ details: { paths, model: `${model.provider}/${model.id}` },
88
+ };
89
+ },
90
+ });
91
+
92
+ // The tool is on only while an image model is available, as Cursor offers GenerateImage only where it can run.
93
+ pi.on('session_start', async (_event, ctx) => {
94
+ if (!options.mayOffer() || (await imageModel(ctx)) === undefined) return;
95
+ const active = pi.getActiveTools();
96
+ if (!active.includes(GENERATE_IMAGE)) pi.setActiveTools([...active, GENERATE_IMAGE]);
97
+ });
98
+ }
package/src/index.ts CHANGED
@@ -1,13 +1,15 @@
1
+ import { basename } from 'node:path';
2
+
1
3
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
3
5
  import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
4
6
  import { registerAskQuestion } from './ask.ts';
5
7
  import { aliasAutocomplete } from './autocomplete.ts';
6
8
  import { childIdentity } from './child-env.ts';
9
+ import { registerGenerateImage } from './generate-image.ts';
7
10
  import { registerLoop } from './loop.ts';
8
11
  import { registerMode } from './mode.ts';
9
12
  import { canAsk, isOneShot } from './modes.ts';
10
- import { createPathSkills } from './path-skills.ts';
11
13
  import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir } from './paths.ts';
12
14
  import { registerPlanMode } from './plan.ts';
13
15
  import { registerReadonlyShell } from './readonly-shell.ts';
@@ -23,10 +25,14 @@ import { registerVisualize } from './visualize.ts';
23
25
  const WATCHDOG_MS = 1_000;
24
26
  const ORPHAN_EXIT_MS = 5_000;
25
27
 
26
- /** In a subagent, the agent definition body joins the system prompt as the agent's own instructions. */
28
+ // What Cursor tells every subagent in a system reminder, minus its sentence about Cursor Canvas files.
29
+ const SUBAGENT_REMINDER = 'You are running as a subagent under a parent agent. Do not spawn additional subagents unless requested by the user or by your instructions.';
30
+
31
+ /** In a subagent, Cursor's subagent reminder, and the agent definition body as the agent's own instructions. */
27
32
  function agentSection(agents: readonly AgentDefinition[]): Record<string, string> {
33
+ if (childIdentity.depth === 0) return {};
28
34
  const prompt = agents.find((agent) => agent.type === childIdentity.agentType)?.prompt ?? '';
29
- return prompt === '' ? {} : { pstack_agent: prompt };
35
+ return { pstack_subagent: SUBAGENT_REMINDER, ...(prompt === '' ? {} : { pstack_agent: prompt }) };
30
36
  }
31
37
 
32
38
  function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly AgentDefinition[]): Record<string, string> {
@@ -97,9 +103,15 @@ export default function piPstack(pi: ExtensionAPI): void {
97
103
  });
98
104
 
99
105
  const mode = registerMode(pi);
100
- const plan = registerPlanMode(pi);
106
+ const plan = registerPlanMode(pi, {
107
+ ...(childIdentity.buildPlan === undefined ? {} : { buildPlan: childIdentity.buildPlan }),
108
+ setTodos: (items, ctx) => todos.replace(items, ctx),
109
+ buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud', buildPlan: planFile }),
110
+ });
101
111
  registerVisualize(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()) });
102
- const task = registerTask(pi, { agents, depth: childIdentity.depth, planMode: plan.enabled });
112
+ registerGenerateImage(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()), mayOffer: () => !childIdentity.readonly && !plan.enabled() });
113
+ const ownType = agents.find((agent) => agent.type === childIdentity.agentType);
114
+ const task = registerTask(pi, { agents, depth: childIdentity.depth, canSpawn: childIdentity.depth < 2 && ownType?.spawns !== false, planMode: plan.enabled });
103
115
  registerAgentStatus(pi);
104
116
  registerSubscriptions(pi, {
105
117
  runFresh: async (prompt, name, meta) => {
@@ -111,26 +123,22 @@ export default function piPstack(pi: ExtensionAPI): void {
111
123
  },
112
124
  });
113
125
  const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
114
- const todos = registerTodoWrite(pi);
115
- const pathSkills = createPathSkills(pi);
126
+ const todos = registerTodoWrite(pi, childIdentity.readonly ? {} : { onWrite: plan.syncTodos });
116
127
  registerAskQuestion(pi);
117
128
  if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
118
129
  if (childIdentity.readonly) registerReadonlyShell(pi);
119
130
 
120
131
  pi.on('before_agent_start', (event, ctx) => {
121
- const modeText = mode.section();
122
132
  const loopText = loop.section();
123
133
  const planText = plan.section();
124
134
  const todoText = todos.section();
125
- const pathSkillText = pathSkills.section();
126
135
  Object.assign(
127
136
  event.systemPromptOptions.sections,
128
137
  hostSections(pi, ctx, agents),
129
- modeText === undefined ? {} : { pstack_mode: modeText },
138
+ mode.sections(),
130
139
  loopText === undefined ? {} : { pstack_loop: loopText },
131
140
  planText === undefined ? {} : { pstack_plan: planText },
132
141
  todoText === undefined ? {} : { pstack_todos: todoText },
133
- pathSkillText === undefined ? {} : { pstack_path_skills: pathSkillText },
134
142
  );
135
143
  });
136
144
 
package/src/loop.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { Type } from 'typebox';
4
+ import { type Static, Type } from 'typebox';
3
5
  import { isOneShot } from './modes.ts';
4
6
 
5
7
  const DAY_MS = 86_400_000;
@@ -30,19 +32,16 @@ function delayOf(text: string): number {
30
32
  return ms;
31
33
  }
32
34
 
33
- export type LoopCommand = { kind: 'status' } | { kind: 'stop' } | { kind: 'start'; prompt: string; intervalMs?: number } | { kind: 'error'; message: string };
35
+ export type LoopCommand = { kind: 'status' } | { kind: 'stop'; loopId?: string } | { kind: 'start'; prompt: string; intervalMs?: number } | { kind: 'error'; message: string };
34
36
 
35
- /** The longest trailing phrase that parses as a duration, such as `5m`, `every 10 minutes`, or `1 hour 30 minutes`. */
36
- function trailingInterval(text: string): { prompt: string; intervalMs: number } | undefined {
37
- const tokens = text.split(/\s+/);
38
- for (let take = Math.min(6, tokens.length); take >= 1; take -= 1) {
39
- const intervalMs = parseDuration(tokens.slice(-take).join(' '));
37
+ function edgeInterval(text: string, edge: 'leading' | 'trailing'): { prompt: string; intervalMs: number } | undefined {
38
+ const tokens = [...text.matchAll(/\S+/g)];
39
+ for (let take = tokens.length; take >= 1; take -= 1) {
40
+ const start = edge === 'leading' ? 0 : (tokens[tokens.length - take]?.index ?? 0);
41
+ const end = edge === 'trailing' ? text.length : (tokens[take - 1]?.index ?? 0) + (tokens[take - 1]?.[0].length ?? 0);
42
+ const intervalMs = parseDuration(text.slice(start, end));
40
43
  if (intervalMs === undefined) continue;
41
- const prompt = tokens
42
- .slice(0, -take)
43
- .join(' ')
44
- .replace(/\s+every$/i, '')
45
- .trim();
44
+ const prompt = (edge === 'leading' ? text.slice(end) : text.slice(0, start).replace(/\s+every\s*$/i, '')).trim();
46
45
  return { prompt, intervalMs };
47
46
  }
48
47
  return undefined;
@@ -51,20 +50,13 @@ function trailingInterval(text: string): { prompt: string; intervalMs: number }
51
50
  export function parseLoopArgs(args: string): LoopCommand {
52
51
  const trimmed = args.trim();
53
52
  if (trimmed === '') return { kind: 'status' };
54
- if (trimmed === 'stop' || trimmed === 'off') return { kind: 'stop' };
55
- const [, first = '', rest = ''] = /^(\S+)\s*([\s\S]*)$/.exec(trimmed) ?? [];
56
- const leadMs = parseDuration(first) ?? (first.toLowerCase() === 'every' ? parseDuration(rest.split(/\s+/)[0] ?? '') : undefined);
57
- if (leadMs !== undefined) {
58
- if (leadMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
59
- const after = first.toLowerCase() === 'every' ? rest.split(/\s+/).slice(1).join(' ') : rest;
60
- return after.trim() === '' ? { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' } : { kind: 'start', prompt: after.trim(), intervalMs: leadMs };
61
- }
62
- // Cursor accepts the interval at either end, including `every 10 minutes`, so both ends are tried before treating it as a prompt.
63
- const trailing = trailingInterval(trimmed);
64
- if (trailing !== undefined) {
65
- if (trailing.intervalMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
66
- if (trailing.prompt === '') return { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' };
67
- return { kind: 'start', prompt: trailing.prompt, intervalMs: trailing.intervalMs };
53
+ const stop = /^(?:stop|off)(?:\s+(\S+))?$/.exec(trimmed);
54
+ if (stop !== null) return { kind: 'stop', ...(stop[1] === undefined ? {} : { loopId: stop[1] }) };
55
+ const interval = edgeInterval(trimmed, 'leading') ?? edgeInterval(trimmed, 'trailing');
56
+ if (interval !== undefined) {
57
+ if (interval.intervalMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
58
+ if (interval.prompt === '') return { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' };
59
+ return { kind: 'start', ...interval };
68
60
  }
69
61
  return { kind: 'start', prompt: trimmed };
70
62
  }
@@ -75,6 +67,8 @@ interface Loop {
75
67
  iteration: number;
76
68
  nextWakeMs?: number;
77
69
  timer?: ReturnType<typeof setTimeout>;
70
+ /** A wake came due while a run was going; it fires once when that run settles. */
71
+ due?: boolean;
78
72
  }
79
73
 
80
74
  const formatMs = (ms: number) => (ms % 86_400_000 === 0 ? `${ms / 86_400_000}d` : ms % 3_600_000 === 0 ? `${ms / 3_600_000}h` : ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 1_000)}s`);
@@ -107,39 +101,45 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
107
101
  clear();
108
102
  notify(ctx, `Loop stopped: ${reason}`);
109
103
  };
110
- const fire = (first = false) => {
104
+ const run = () => {
111
105
  if (loop === undefined) return;
112
- // A scheduled tick that lands mid-run is skipped rather than queued, so one long run cannot leave a pile of stale ticks.
113
- if (!first && lastCtx !== undefined && !lastCtx.isIdle()) {
114
- if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(fire, loop.intervalMs) };
115
- return;
116
- }
117
- const { timer: _timer, nextWakeMs: _wake, ...rest } = loop;
118
- loop = { ...rest, iteration: rest.iteration + 1 };
106
+ loop = { ...loop, iteration: loop.iteration + 1, due: false };
119
107
  // Expansion lets a loop run a skill or template each time.
120
- pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true });
121
- // A fixed loop is wall-clock from each fire, like the reference's sleep loop: a long run does not push the next tick out.
122
- if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(fire, loop.intervalMs) };
108
+ pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true, deliverAs: 'followUp' });
109
+ };
110
+ /**
111
+ * A timer fired. A fixed loop keeps its wall-clock cadence, like Cursor's sleep-and-echo shell loop, so a long run
112
+ * does not push the next tick out. A wake that lands while a run is going is delivered once that run settles, as
113
+ * Cursor's monitored shell output notifies after the turn, and several such wakes fire once, not once each.
114
+ */
115
+ const tick = () => {
116
+ if (loop === undefined) return;
117
+ const { timer: _spent, nextWakeMs: _wake, ...rest } = loop;
118
+ loop = rest.intervalMs === undefined ? rest : { ...rest, timer: setTimeout(tick, rest.intervalMs) };
119
+ if (lastCtx !== undefined && !lastCtx.isIdle()) loop = { ...loop, due: true };
120
+ else run();
123
121
  };
124
122
  const settled = (ctx: ExtensionContext, aborted: boolean) => {
125
123
  lastCtx = ctx;
126
124
  if (loop === undefined) return;
127
125
  if (aborted) return stop(ctx, 'the run was aborted.');
126
+ if (loop.due === true) return run();
128
127
  if (loop.timer !== undefined) return;
129
128
  if (loop.intervalMs !== undefined) {
130
129
  // An armed fixed loop has no timer yet; its first tick waits for this turn to end.
131
- loop = { ...loop, timer: setTimeout(fire, loop.intervalMs) };
130
+ loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
132
131
  return;
133
132
  }
134
133
  const delay = loop.nextWakeMs;
135
- if (delay !== undefined) loop = { ...loop, timer: setTimeout(fire, delay) };
134
+ if (delay !== undefined) loop = { ...loop, timer: setTimeout(tick, delay) };
136
135
  else if (!options.hasPendingWork()) stop(ctx, 'the agent scheduled no next wake.');
137
136
  };
138
137
  const start = (ctx: ExtensionContext, prompt: string, intervalMs: number | undefined) => {
139
138
  if (loop !== undefined) stop(ctx, 'replaced by a new /loop.');
140
139
  lastCtx = ctx;
141
140
  loop = { prompt, iteration: 0, ...(intervalMs === undefined ? {} : { intervalMs }) };
142
- fire(true);
141
+ run();
142
+ if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
143
143
  };
144
144
  /** An agent arms a loop mid-turn, so its first run waits for this turn to settle and then for the first delay. */
145
145
  const arm = (ctx: ExtensionContext, prompt: string, schedule: { intervalMs: number } | { firstWakeMs: number }) => {
@@ -159,31 +159,77 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
159
159
  loop = { ...rest, nextWakeMs: delayMs };
160
160
  };
161
161
  const section = () => (loop === undefined ? undefined : loopSection(loop));
162
- return { notify, stop, settled, start, arm, status, wake, clear, section, active: () => loop !== undefined };
162
+ return { stop, settled, start, arm, status, wake, clear, section, prompt: () => loop?.prompt, active: () => loop !== undefined };
163
+ }
164
+
165
+ function createLoopRegistry(pi: ExtensionAPI, options: LoopOptions) {
166
+ const controllers = new Map<string, ReturnType<typeof createLoopController>>();
167
+ const entries = () => {
168
+ for (const [id, controller] of controllers) if (!controller.active()) controllers.delete(id);
169
+ return [...controllers];
170
+ };
171
+ const choose = (loopId?: string, prompt?: string) => {
172
+ const active = entries();
173
+ const existing = active.find(([id, controller]) => (loopId !== undefined ? id === loopId : prompt !== undefined ? controller.prompt() === prompt : active.length === 1));
174
+ if (existing !== undefined) return { loopId: existing[0], controller: existing[1] };
175
+ if (loopId !== undefined) throw new Error(`No running loop has id "${loopId}". /loop lists running loops.`);
176
+ if (prompt !== undefined) {
177
+ const id = randomUUID();
178
+ const controller = createLoopController(pi, options);
179
+ controllers.set(id, controller);
180
+ return { loopId: id, controller };
181
+ }
182
+ if (active.length === 0) throw new Error('No loop is running.');
183
+ throw new Error(`Several loops are running. Pass loop_id to choose one, or use /loop stop <id>. Running loops:\n${active.map(([id, controller]) => `${id} ${controller.status()}`).join('\n')}`);
184
+ };
185
+ const status = () => {
186
+ const active = entries();
187
+ if (active.length === 0) return 'No loop is running. Usage: /loop [interval] <prompt>, with the interval at either end; /loop stop ends one.';
188
+ return active.map(([id, controller]) => (active.length === 1 ? controller.status() : `${id} ${controller.status()}`)).join('\n');
189
+ };
190
+ const section = () => {
191
+ const active = entries();
192
+ if (active.length === 0) return undefined;
193
+ if (active.length === 1) return active[0]?.[1].section();
194
+ return [...active.map(([id, controller]) => `Loop ${id} runs ${JSON.stringify(controller.prompt())}.\n${controller.section()}`), 'Several loops are running. Pass loop_id with wake_in or stop so only the intended loop changes.'].join(
195
+ '\n\n',
196
+ );
197
+ };
198
+ return { choose, entries, status, section };
163
199
  }
164
200
 
165
201
  type LoopController = ReturnType<typeof createLoopController>;
202
+ type LoopRegistry = ReturnType<typeof createLoopRegistry>;
166
203
 
167
204
  const ONE_SHOT = '/loop needs an interactive or RPC session; print and JSON runs exit when the agent settles.';
168
205
 
169
- function handleLoopCommand(controller: LoopController, args: string, ctx: ExtensionCommandContext): void {
206
+ function handleLoopCommand(registry: LoopRegistry, args: string, ctx: ExtensionCommandContext): void {
170
207
  const command = parseLoopArgs(args);
171
- if (command.kind === 'status') controller.notify(ctx, controller.status());
172
- else if (command.kind === 'stop') controller.stop(ctx, 'stopped by /loop stop.');
173
- else if (command.kind === 'error') controller.notify(ctx, command.message);
174
- else if (isOneShot(ctx.mode)) process.stderr.write(`${ONE_SHOT}\n`);
175
- else controller.start(ctx, command.prompt, command.intervalMs);
208
+ try {
209
+ if (command.kind === 'status') ctx.ui.notify(registry.status(), 'info');
210
+ else if (command.kind === 'stop') registry.choose(command.loopId).controller.stop(ctx, 'stopped by /loop stop.');
211
+ else if (command.kind === 'error') ctx.ui.notify(command.message, 'error');
212
+ else if (isOneShot(ctx.mode)) process.stderr.write(`${ONE_SHOT}\n`);
213
+ else registry.choose(undefined, command.prompt).controller.start(ctx, command.prompt, command.intervalMs);
214
+ } catch (error) {
215
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error');
216
+ }
176
217
  }
177
218
 
178
219
  const LoopParams = Type.Object({
179
- prompt: Type.Optional(Type.String({ description: 'Start (or replace) a loop that sends this prompt each run. It may be a skill or template command such as /poteto-mode <task>.' })),
220
+ loop_id: Type.Optional(Type.String({ description: 'Loop ID from an earlier Loop result. Required to wake, stop, or replace one loop when several are running. Omit with a new prompt to start an independent loop.' })),
221
+ prompt: Type.Optional(
222
+ Type.String({
223
+ description:
224
+ 'Start a loop that sends this prompt each run. A different prompt starts an independent loop; the same prompt or an explicit loop_id replaces its earlier schedule. It may be a skill or template command such as /poteto-mode <task>.',
225
+ }),
226
+ ),
180
227
  interval: Type.Optional(Type.String({ description: 'With prompt: a fixed cadence such as 30m or 1h; the first run starts that long after this turn ends.' })),
181
228
  wake_in: Type.Optional(Type.String({ description: 'With prompt: the first run of an agent-paced loop. Alone: the next run of the running agent-paced loop. A duration such as 10m, at most 24d.' })),
182
229
  stop: Type.Optional(Type.Boolean({ description: 'End the loop once its goal is met.' })),
183
230
  });
184
231
 
185
- function loopToolText(controller: LoopController, params: { prompt?: string; interval?: string; wake_in?: string; stop?: boolean }, ctx: ExtensionContext): string {
186
- if (isOneShot(ctx.mode)) throw new Error(`${ONE_SHOT} Do the work now instead of scheduling it.`);
232
+ function loopToolText(controller: LoopController, params: Static<typeof LoopParams>, ctx: ExtensionContext): string {
187
233
  if (params.stop === true) {
188
234
  if (!controller.active()) throw new Error('No loop is running.');
189
235
  controller.stop(ctx, 'the agent ended it.');
@@ -207,32 +253,39 @@ function loopToolText(controller: LoopController, params: { prompt?: string; int
207
253
  return `Next loop run in ${formatMs(delay)}, after this turn ends.`;
208
254
  }
209
255
 
210
- function registerLoopTool(pi: ExtensionAPI, controller: LoopController): void {
256
+ function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
211
257
  pi.registerTool({
212
258
  name: 'Loop',
213
259
  label: 'Loop',
214
- description: '/loop for you: start a loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule the next wake of an agent-paced loop, or stop the loop once its goal is met. Use it when a playbook says to arm /loop.',
260
+ description:
261
+ '/loop for you: start an independent loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule its next wake, or stop it once its goal is met. Use it when a playbook says to arm /loop. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
215
262
  promptSnippet: 'Start a /loop, schedule its next wake, or stop it',
216
263
  parameters: LoopParams,
217
264
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
218
265
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
219
- return { content: [{ type: 'text', text: loopToolText(controller, params, ctx) }], details: undefined };
266
+ if (isOneShot(ctx.mode)) throw new Error(`${ONE_SHOT} Do the work now instead of scheduling it.`);
267
+ const { loopId, controller } = registry.choose(params.loop_id, params.prompt);
268
+ return { content: [{ type: 'text', text: `${loopToolText(controller, params, ctx)}\nLoop ID: ${loopId}` }], details: { loopId } };
220
269
  },
221
270
  });
222
271
  }
223
272
 
224
273
  /** /loop: run a prompt at a fixed interval, or at wakes the agent picks, until stopped. */
225
274
  export function registerLoop(pi: ExtensionAPI, options: LoopOptions): { section: () => string | undefined } {
226
- const controller = createLoopController(pi, options);
275
+ const registry = createLoopRegistry(pi, options);
227
276
  pi.on('session_start', (_event, ctx) => {
228
277
  if (isOneShot(ctx.mode)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== 'Loop'));
229
278
  });
230
- pi.on('agent_settled', (event, ctx) => controller.settled(ctx, event.aborted));
231
- pi.on('session_shutdown', () => controller.clear());
279
+ pi.on('agent_settled', (event, ctx) => {
280
+ for (const [, controller] of registry.entries()) controller.settled(ctx, event.aborted);
281
+ });
282
+ pi.on('session_shutdown', () => {
283
+ for (const [, controller] of registry.entries()) controller.clear();
284
+ });
232
285
  pi.registerCommand('loop', {
233
- description: 'Run a prompt repeatedly: /loop [interval] <prompt>, interval either end and like 30s, 5m, 1h, every 10 minutes (at most 24d); /loop stop',
234
- handler: async (args, ctx) => handleLoopCommand(controller, args, ctx),
286
+ description: 'Run independent prompts repeatedly: /loop [interval] <prompt>, interval either end and like 30s, 5m, 1h, every 10 minutes (at most 24d); /loop lists them; /loop stop [id] stops one',
287
+ handler: async (args, ctx) => handleLoopCommand(registry, args, ctx),
235
288
  });
236
- registerLoopTool(pi, controller);
237
- return { section: controller.section };
289
+ registerLoopTool(pi, registry);
290
+ return { section: registry.section };
238
291
  }