@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.
- package/CHANGELOG.md +28 -0
- package/README.md +14 -14
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/skills/reproduce-and-fix-issues/SKILL.md +3 -3
- package/automations/benny/skills/setup-benny/SKILL.md +3 -3
- package/automations/benny/skills/triage-issue-reports/SKILL.md +2 -2
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +55 -11
- package/package.json +4 -3
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/child-env.ts +4 -0
- package/src/generate-image.ts +98 -0
- package/src/index.ts +19 -11
- package/src/loop.ts +113 -60
- package/src/mode.ts +84 -46
- package/src/plan.ts +338 -46
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +6 -8
- package/src/sections.ts +6 -1
- package/src/subscriptions/durable-store.ts +2 -75
- package/src/subscriptions/index.ts +2 -17
- package/src/subscriptions/supervisor.ts +1 -1
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +4 -2
- package/src/task/format.ts +17 -3
- package/src/task/records.ts +37 -32
- package/src/task/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +62 -22
- package/src/todo.ts +50 -7
- package/src/path-skills.ts +0 -104
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
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 =
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
return
|
|
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
|
|
104
|
+
const run = () => {
|
|
111
105
|
if (loop === undefined) return;
|
|
112
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
206
|
+
function handleLoopCommand(registry: LoopRegistry, args: string, ctx: ExtensionCommandContext): void {
|
|
170
207
|
const command = parseLoopArgs(args);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
256
|
+
function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
|
|
211
257
|
pi.registerTool({
|
|
212
258
|
name: 'Loop',
|
|
213
259
|
label: 'Loop',
|
|
214
|
-
description:
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
231
|
-
|
|
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
|
|
234
|
-
handler: async (args, ctx) => handleLoopCommand(
|
|
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,
|
|
237
|
-
return { section:
|
|
289
|
+
registerLoopTool(pi, registry);
|
|
290
|
+
return { section: registry.section };
|
|
238
291
|
}
|