@justjoshn/pi-pstack 0.15.36-pi.12 → 0.15.36-pi.14

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/src/child-env.ts CHANGED
@@ -7,6 +7,7 @@ export const READONLY_ENV = 'PI_PSTACK_READONLY';
7
7
  export const ROOT_STORE_ENV = 'PI_PSTACK_ROOT_STORE';
8
8
  export const WORKSPACE_ENV = 'PI_PSTACK_WORKSPACE_STATE';
9
9
  export const ROOT_WORKSPACE_ENV = 'PI_PSTACK_ROOT_WORKSPACE';
10
+ export const BUILD_PLAN_ENV = 'PI_PSTACK_BUILD_PLAN';
10
11
 
11
12
  export interface ChildIdentity {
12
13
  depth: number;
@@ -18,6 +19,7 @@ export interface ChildIdentity {
18
19
  workspaceState: string | undefined;
19
20
  /** The main chat's working directory, which authorizes every record stored in its shared workspace. */
20
21
  rootWorkspace: string | undefined;
22
+ buildPlan: string | undefined;
21
23
  }
22
24
 
23
25
  function read(): ChildIdentity {
@@ -31,6 +33,7 @@ function read(): ChildIdentity {
31
33
  rootStore: process.env[ROOT_STORE_ENV] || undefined,
32
34
  workspaceState: process.env[WORKSPACE_ENV] || undefined,
33
35
  rootWorkspace: process.env[ROOT_WORKSPACE_ENV] || undefined,
36
+ buildPlan: process.env[BUILD_PLAN_ENV] || undefined,
34
37
  };
35
38
  }
36
39
 
@@ -42,6 +45,7 @@ delete process.env[READONLY_ENV];
42
45
  delete process.env[ROOT_STORE_ENV];
43
46
  delete process.env[WORKSPACE_ENV];
44
47
  delete process.env[ROOT_WORKSPACE_ENV];
48
+ delete process.env[BUILD_PLAN_ENV];
45
49
 
46
50
  /** Variables for a child of this process at `depth` (an agent keeps the depth it was created at). */
47
51
  export function childVariables(agentType: string, depth: number, readonly: boolean, rootStore: string, workspaceState: string, rootWorkspace: string): Record<string, string> {
package/src/index.ts CHANGED
@@ -5,16 +5,17 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
5
  import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
6
6
  import { registerAskQuestion } from './ask.ts';
7
7
  import { aliasAutocomplete } from './autocomplete.ts';
8
+ import { registerBackgroundShell } from './background-shell.ts';
8
9
  import { childIdentity } from './child-env.ts';
9
10
  import { registerGenerateImage } from './generate-image.ts';
10
11
  import { registerLoop } from './loop.ts';
11
12
  import { registerMode } from './mode.ts';
12
13
  import { canAsk, isOneShot } from './modes.ts';
13
- import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir } from './paths.ts';
14
+ import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir, terminalsDir } from './paths.ts';
14
15
  import { registerPlanMode } from './plan.ts';
15
16
  import { registerReadonlyShell } from './readonly-shell.ts';
16
17
  import { migrateLegacyRule, modelsSection, readRule } from './rule.ts';
17
- import { alwaysAppliedRules, hostSection, subagentsSection } from './sections.ts';
18
+ import { alwaysAppliedRules, hostSection, subagentsSection, todayText } from './sections.ts';
18
19
  import { registerSubscriptions } from './subscriptions/index.ts';
19
20
  import { type AgentDefinition, loadAgentDefinitions } from './task/agents.ts';
20
21
  import { registerAgentStatus } from './task/status.ts';
@@ -39,6 +40,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
39
40
  const activeTools = pi.getActiveTools();
40
41
  return {
41
42
  pstack_host: hostSection({
43
+ today: todayText(new Date()),
42
44
  packageRoot: PACKAGE_ROOT,
43
45
  skillsDir: SKILLS_DIR,
44
46
  skillNames: packageSkillNames(pi.getCommands()),
@@ -51,6 +53,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
51
53
  rootStoreDir: rootStoreDir(ctx.cwd, ctx.sessionManager.getSessionId(), childIdentity.rootStore),
52
54
  projectsDir: projectsDir(ctx.cwd),
53
55
  artifactsDir: artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()),
56
+ terminalsDir: terminalsDir(ctx.cwd),
54
57
  loopAvailable: !isOneShot(ctx.mode),
55
58
  questionsAvailable: canAsk(ctx),
56
59
  activeTools,
@@ -103,7 +106,11 @@ export default function piPstack(pi: ExtensionAPI): void {
103
106
  });
104
107
 
105
108
  const mode = registerMode(pi);
106
- const plan = registerPlanMode(pi, { buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud' }) });
109
+ const plan = registerPlanMode(pi, {
110
+ ...(childIdentity.buildPlan === undefined ? {} : { buildPlan: childIdentity.buildPlan }),
111
+ setTodos: (items, ctx) => todos.replace(items, ctx),
112
+ buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud', buildPlan: planFile }),
113
+ });
107
114
  registerVisualize(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()) });
108
115
  registerGenerateImage(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()), mayOffer: () => !childIdentity.readonly && !plan.enabled() });
109
116
  const ownType = agents.find((agent) => agent.type === childIdentity.agentType);
@@ -118,8 +125,16 @@ export default function piPstack(pi: ExtensionAPI): void {
118
125
  return undefined;
119
126
  },
120
127
  });
121
- const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
122
- const todos = registerTodoWrite(pi);
128
+ // A readonly agent's shell is the sandboxed one, and a background job would run outside that sandbox.
129
+ const shells = childIdentity.readonly
130
+ ? undefined
131
+ : registerBackgroundShell(pi, {
132
+ terminalsDir: (ctx) => terminalsDir(ctx.cwd),
133
+ waitsForJobs: (ctx) => childIdentity.depth === 0 && (ctx.mode === 'print' || ctx.mode === 'json'),
134
+ endsWithTurn: childIdentity.depth > 0,
135
+ });
136
+ const loop = registerLoop(pi, { hasPendingWork: () => task.hasBackgroundWork() || shells?.hasRunningJobs() === true });
137
+ const todos = registerTodoWrite(pi, childIdentity.readonly ? {} : { onWrite: plan.syncTodos });
123
138
  registerAskQuestion(pi);
124
139
  if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
125
140
  if (childIdentity.readonly) registerReadonlyShell(pi);
@@ -0,0 +1,121 @@
1
+ // The Linux read-only sandbox: bubblewrap with every namespace unshared, the whole filesystem bound read-only, and a
2
+ // seccomp filter that refuses Unix sockets. bubblewrap installs the filter itself, so this needs no nested user
3
+ // namespace, which hosts with kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later) refuse.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { accessSync, closeSync, constants, existsSync, mkdtempSync, openSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { delimiter, join } from 'node:path';
8
+
9
+ const LD_ABS_W = 0x20;
10
+ const JEQ_K = 0x15;
11
+ const JGE_K = 0x35;
12
+ const RET_K = 0x06;
13
+ const SECCOMP_RET_ALLOW = 0x7fff_0000;
14
+ const SECCOMP_RET_ERRNO_EPERM = 0x0005_0001;
15
+ const SECCOMP_RET_KILL_PROCESS = 0x8000_0000;
16
+ const X32_SYSCALL_BIT = 0x4000_0000;
17
+ const AF_UNIX = 1;
18
+
19
+ const ARCHES: Record<string, { audit: number; socket: number; ioUringSetup: number }> = {
20
+ x64: { audit: 0xc000_003e, socket: 41, ioUringSetup: 425 },
21
+ arm64: { audit: 0xc000_00b7, socket: 198, ioUringSetup: 425 },
22
+ };
23
+
24
+ const instruction = (code: number, jt: number, jf: number, k: number): Buffer => {
25
+ const bytes = Buffer.alloc(8);
26
+ bytes.writeUInt16LE(code, 0);
27
+ bytes.writeUInt8(jt, 2);
28
+ bytes.writeUInt8(jf, 3);
29
+ bytes.writeUInt32LE(k >>> 0, 4);
30
+ return bytes;
31
+ };
32
+
33
+ /**
34
+ * A classic BPF seccomp program: socket(AF_UNIX, ...) and io_uring_setup fail with EPERM, any other architecture or
35
+ * the x32 ABI kills the process, and everything else is allowed. Jumps are relative to the next instruction.
36
+ */
37
+ export function seccompFilter(arch: string): Buffer {
38
+ const target = ARCHES[arch];
39
+ if (target === undefined) throw new Error(`the read-only shell has no seccomp filter for ${arch}`);
40
+ return Buffer.concat([
41
+ instruction(LD_ABS_W, 0, 0, 4), // 0: architecture
42
+ instruction(JEQ_K, 0, 8, target.audit), // 1: native → 2, otherwise → 10
43
+ instruction(LD_ABS_W, 0, 0, 0), // 2: syscall number
44
+ instruction(JGE_K, 6, 0, X32_SYSCALL_BIT), // 3: x32 → 10
45
+ instruction(JEQ_K, 0, 2, target.socket), // 4: socket → 5, otherwise → 7
46
+ instruction(LD_ABS_W, 0, 0, 16), // 5: low word of the first argument, the domain
47
+ instruction(JEQ_K, 2, 1, AF_UNIX), // 6: AF_UNIX → 9, otherwise → 8
48
+ instruction(JEQ_K, 1, 0, target.ioUringSetup), // 7: io_uring_setup → 9, otherwise → 8
49
+ instruction(RET_K, 0, 0, SECCOMP_RET_ALLOW), // 8
50
+ instruction(RET_K, 0, 0, SECCOMP_RET_ERRNO_EPERM), // 9
51
+ instruction(RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS), // 10
52
+ ]);
53
+ }
54
+
55
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
56
+
57
+ /** bubblewrap's arguments before the command: nothing is writable and nothing outside is reachable. */
58
+ export function bwrapArgs(cwd: string): string[] {
59
+ return ['--new-session', '--die-with-parent', '--unshare-all', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--cap-drop', 'ALL', '--chdir', cwd, '--seccomp', '3'];
60
+ }
61
+
62
+ interface Sandbox {
63
+ bwrap: string;
64
+ filterPath: string;
65
+ shell: string;
66
+ }
67
+
68
+ /** The first executable `name` on PATH, so every command runs the bubblewrap the check ran. */
69
+ function onPath(name: string): string | undefined {
70
+ for (const dir of (process.env['PATH'] ?? '').split(delimiter).filter((entry) => entry !== '')) {
71
+ const candidate = join(dir, name);
72
+ try {
73
+ accessSync(candidate, constants.X_OK);
74
+ return candidate;
75
+ } catch {
76
+ // Not here; keep looking.
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ let verified: { sandbox: Sandbox } | { error: Error } | undefined;
83
+
84
+ /** The checked sandbox, or the error that stopped it; either is kept, so a failed host never retries into a weaker state. */
85
+ function verifiedSandbox(): Sandbox {
86
+ verified ??= (() => {
87
+ try {
88
+ return { sandbox: checkSandbox() };
89
+ } catch (error) {
90
+ return { error: error instanceof Error ? error : new Error(String(error)) };
91
+ }
92
+ })();
93
+ if ('error' in verified) throw verified.error;
94
+ return verified.sandbox;
95
+ }
96
+
97
+ /** Writes the filter into a private directory and checks that bubblewrap can load it here. */
98
+ function checkSandbox(): Sandbox {
99
+ const filter = seccompFilter(process.arch);
100
+ const bwrap = onPath('bwrap');
101
+ if (bwrap === undefined) throw new Error('bubblewrap (bwrap) is not available: no bwrap on PATH');
102
+ const dir = mkdtempSync(join(tmpdir(), 'pi-pstack-ro-'));
103
+ const filterPath = join(dir, 'unix-block.bpf');
104
+ writeFileSync(filterPath, filter, { mode: 0o400 });
105
+ const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
106
+ const fd = openSync(filterPath, 'r');
107
+ try {
108
+ const probe = spawnSync(bwrap, [...bwrapArgs('/'), '--', shell, '-c', 'exit 0'], { stdio: ['ignore', 'ignore', 'pipe', fd], encoding: 'utf8', timeout: 15_000 });
109
+ if (probe.error !== undefined) throw new Error(`bubblewrap (bwrap) could not run: ${probe.error.message}`);
110
+ if (probe.status !== 0) throw new Error(`bubblewrap could not start the read-only sandbox: ${probe.stderr.trim() || `exit ${probe.status}`}`);
111
+ } finally {
112
+ closeSync(fd);
113
+ }
114
+ return { bwrap, filterPath, shell };
115
+ }
116
+
117
+ /** The shell command that runs `command` inside the read-only sandbox, or an error when no sandbox can start. */
118
+ export function linuxReadonlyCommand(command: string, cwd: string): string {
119
+ const { bwrap, filterPath, shell } = verifiedSandbox();
120
+ return `exec ${quote(bwrap)} ${bwrapArgs(cwd).map(quote).join(' ')} -- ${quote(shell)} -c ${quote(command)} 3< ${quote(filterPath)}`;
121
+ }
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
  }
@@ -113,7 +105,7 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
113
105
  if (loop === undefined) return;
114
106
  loop = { ...loop, iteration: loop.iteration + 1, due: false };
115
107
  // Expansion lets a loop run a skill or template each time.
116
- pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true });
108
+ pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true, deliverAs: 'followUp' });
117
109
  };
118
110
  /**
119
111
  * A timer fired. A fixed loop keeps its wall-clock cadence, like Cursor's sleep-and-echo shell loop, so a long run
@@ -167,31 +159,77 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
167
159
  loop = { ...rest, nextWakeMs: delayMs };
168
160
  };
169
161
  const section = () => (loop === undefined ? undefined : loopSection(loop));
170
- 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 };
171
199
  }
172
200
 
173
201
  type LoopController = ReturnType<typeof createLoopController>;
202
+ type LoopRegistry = ReturnType<typeof createLoopRegistry>;
174
203
 
175
204
  const ONE_SHOT = '/loop needs an interactive or RPC session; print and JSON runs exit when the agent settles.';
176
205
 
177
- function handleLoopCommand(controller: LoopController, args: string, ctx: ExtensionCommandContext): void {
206
+ function handleLoopCommand(registry: LoopRegistry, args: string, ctx: ExtensionCommandContext): void {
178
207
  const command = parseLoopArgs(args);
179
- if (command.kind === 'status') controller.notify(ctx, controller.status());
180
- else if (command.kind === 'stop') controller.stop(ctx, 'stopped by /loop stop.');
181
- else if (command.kind === 'error') controller.notify(ctx, command.message);
182
- else if (isOneShot(ctx.mode)) process.stderr.write(`${ONE_SHOT}\n`);
183
- 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
+ }
184
217
  }
185
218
 
186
219
  const LoopParams = Type.Object({
187
- 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
+ ),
188
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.' })),
189
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.' })),
190
229
  stop: Type.Optional(Type.Boolean({ description: 'End the loop once its goal is met.' })),
191
230
  });
192
231
 
193
- function loopToolText(controller: LoopController, params: { prompt?: string; interval?: string; wake_in?: string; stop?: boolean }, ctx: ExtensionContext): string {
194
- 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 {
195
233
  if (params.stop === true) {
196
234
  if (!controller.active()) throw new Error('No loop is running.');
197
235
  controller.stop(ctx, 'the agent ended it.');
@@ -215,32 +253,39 @@ function loopToolText(controller: LoopController, params: { prompt?: string; int
215
253
  return `Next loop run in ${formatMs(delay)}, after this turn ends.`;
216
254
  }
217
255
 
218
- function registerLoopTool(pi: ExtensionAPI, controller: LoopController): void {
256
+ function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
219
257
  pi.registerTool({
220
258
  name: 'Loop',
221
259
  label: 'Loop',
222
- 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. Run the prompt once yourself in the turn that arms the loop; the first scheduled run comes after the interval or wake_in. Then confirm the interval, that the prompt already ran once, and when the first run comes. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
223
262
  promptSnippet: 'Start a /loop, schedule its next wake, or stop it',
224
263
  parameters: LoopParams,
225
264
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
226
265
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
227
- 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 } };
228
269
  },
229
270
  });
230
271
  }
231
272
 
232
273
  /** /loop: run a prompt at a fixed interval, or at wakes the agent picks, until stopped. */
233
274
  export function registerLoop(pi: ExtensionAPI, options: LoopOptions): { section: () => string | undefined } {
234
- const controller = createLoopController(pi, options);
275
+ const registry = createLoopRegistry(pi, options);
235
276
  pi.on('session_start', (_event, ctx) => {
236
277
  if (isOneShot(ctx.mode)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== 'Loop'));
237
278
  });
238
- pi.on('agent_settled', (event, ctx) => controller.settled(ctx, event.aborted));
239
- 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
+ });
240
285
  pi.registerCommand('loop', {
241
- description: 'Run a prompt repeatedly: /loop [interval] <prompt>, interval either end and like 30s, 5m, 1h, every 10 minutes (at most 24d); /loop stop',
242
- 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),
243
288
  });
244
- registerLoopTool(pi, controller);
245
- return { section: controller.section };
289
+ registerLoopTool(pi, registry);
290
+ return { section: registry.section };
246
291
  }
package/src/paths.ts CHANGED
@@ -75,6 +75,11 @@ export function artifactsDir(cwd: string, sessionId: string): string {
75
75
  return join(workspaceStateDir(cwd), 'artifacts', sessionId);
76
76
  }
77
77
 
78
+ /** Background shell jobs' output files, `<shell id>.txt`, as Cursor keeps them in a workspace's terminals folder. */
79
+ export function terminalsDir(cwd: string): string {
80
+ return join(workspaceStateDir(cwd), 'terminals');
81
+ }
82
+
78
83
  export function worktreesDir(cwd: string): string {
79
84
  return join(workspaceStateDir(cwd), 'worktrees');
80
85
  }