@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/CHANGELOG.md +24 -0
- package/README.md +12 -10
- 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/guide/01-setup.md +1 -1
- package/docs/parity-status.md +46 -2
- package/package.json +3 -2
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/src/ask.ts +3 -0
- package/src/background-shell.ts +698 -0
- package/src/child-env.ts +4 -0
- package/src/index.ts +20 -5
- package/src/linux-readonly-sandbox.ts +121 -0
- package/src/loop.ts +92 -47
- package/src/paths.ts +5 -0
- package/src/plan.ts +142 -20
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +28 -17
- package/src/sections.ts +22 -3
- 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/records.ts +37 -32
- package/src/task/tool.ts +10 -2
- package/src/todo.ts +29 -6
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, {
|
|
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
|
-
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
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(
|
|
206
|
+
function handleLoopCommand(registry: LoopRegistry, args: string, ctx: ExtensionCommandContext): void {
|
|
178
207
|
const command = parseLoopArgs(args);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
256
|
+
function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
|
|
219
257
|
pi.registerTool({
|
|
220
258
|
name: 'Loop',
|
|
221
259
|
label: 'Loop',
|
|
222
|
-
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. 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
|
-
|
|
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
|
|
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) =>
|
|
239
|
-
|
|
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
|
|
242
|
-
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),
|
|
243
288
|
});
|
|
244
|
-
registerLoopTool(pi,
|
|
245
|
-
return { 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
|
}
|