@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.12
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 +94 -0
- package/README.md +29 -24
- package/agents/ci-watcher.md +28 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +2 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +10 -9
- package/automations/benny/templates/reproduce-automation-prompt.md +1 -1
- package/automations/benny/templates/triage-automation-prompt.md +1 -1
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/01-setup.md +2 -2
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +14 -8
- package/docs/parity-reverse-engineering.md +94 -0
- package/docs/parity-status.md +128 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +4 -4
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup/SKILL.md +2 -2
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/SKILL.md +2 -2
- package/skills/poteto-mode/playbooks/bug-fix.md +1 -1
- package/skills/poteto-mode/playbooks/feature.md +1 -1
- package/skills/poteto-mode/playbooks/hillclimb.md +1 -1
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +2 -2
- package/skills/poteto-mode/playbooks/orchestrate.md +2 -2
- package/skills/poteto-mode/playbooks/perf-issue.md +1 -1
- package/skills/poteto-mode/playbooks/refactoring.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +2 -2
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/setup-pstack/SKILL.md +13 -13
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/teach/SKILL.md +1 -1
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +2 -2
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/ask.ts +24 -10
- package/src/child-env.ts +15 -2
- package/src/generate-image.ts +98 -0
- package/src/index.ts +28 -9
- package/src/loop.ts +74 -17
- package/src/mode.ts +89 -27
- package/src/paths.ts +18 -0
- package/src/pi-command.ts +12 -0
- package/src/plan.ts +285 -30
- package/src/rule.ts +4 -4
- package/src/sections.ts +27 -3
- package/src/subscriptions/durable-store.ts +531 -0
- package/src/subscriptions/durable-types.ts +69 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +310 -282
- package/src/subscriptions/supervisor-main.ts +134 -0
- package/src/subscriptions/supervisor.ts +325 -0
- package/src/subscriptions/webhook.ts +82 -21
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +31 -0
- package/src/task/format.ts +17 -3
- package/src/task/models.ts +7 -0
- package/src/task/progress.ts +112 -0
- package/src/task/records.ts +7 -9
- package/src/task/registry.ts +9 -4
- package/src/task/runner.ts +116 -24
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +158 -57
- package/src/task/worktree.ts +5 -3
- package/src/todo.ts +29 -4
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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';
|
|
@@ -11,7 +14,7 @@ import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS
|
|
|
11
14
|
import { registerPlanMode } from './plan.ts';
|
|
12
15
|
import { registerReadonlyShell } from './readonly-shell.ts';
|
|
13
16
|
import { migrateLegacyRule, modelsSection, readRule } from './rule.ts';
|
|
14
|
-
import { hostSection, subagentsSection } from './sections.ts';
|
|
17
|
+
import { alwaysAppliedRules, hostSection, subagentsSection } from './sections.ts';
|
|
15
18
|
import { registerSubscriptions } from './subscriptions/index.ts';
|
|
16
19
|
import { type AgentDefinition, loadAgentDefinitions } from './task/agents.ts';
|
|
17
20
|
import { registerAgentStatus } from './task/status.ts';
|
|
@@ -22,10 +25,14 @@ import { registerVisualize } from './visualize.ts';
|
|
|
22
25
|
const WATCHDOG_MS = 1_000;
|
|
23
26
|
const ORPHAN_EXIT_MS = 5_000;
|
|
24
27
|
|
|
25
|
-
|
|
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. */
|
|
26
32
|
function agentSection(agents: readonly AgentDefinition[]): Record<string, string> {
|
|
33
|
+
if (childIdentity.depth === 0) return {};
|
|
27
34
|
const prompt = agents.find((agent) => agent.type === childIdentity.agentType)?.prompt ?? '';
|
|
28
|
-
return prompt === '' ? {} : { pstack_agent: prompt };
|
|
35
|
+
return { pstack_subagent: SUBAGENT_REMINDER, ...(prompt === '' ? {} : { pstack_agent: prompt }) };
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly AgentDefinition[]): Record<string, string> {
|
|
@@ -47,6 +54,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
|
|
|
47
54
|
loopAvailable: !isOneShot(ctx.mode),
|
|
48
55
|
questionsAvailable: canAsk(ctx),
|
|
49
56
|
activeTools,
|
|
57
|
+
rules: alwaysAppliedRules(SKILLS_DIR),
|
|
50
58
|
}),
|
|
51
59
|
...(activeTools.includes('Task') ? { pstack_subagents: subagentsSection(agents, availableModels(ctx), parentModel(ctx)) } : {}),
|
|
52
60
|
pstack_models: modelsSection(readRule(rulePath()), rulePath()),
|
|
@@ -95,27 +103,38 @@ export default function piPstack(pi: ExtensionAPI): void {
|
|
|
95
103
|
});
|
|
96
104
|
|
|
97
105
|
const mode = registerMode(pi);
|
|
98
|
-
const plan = registerPlanMode(pi);
|
|
106
|
+
const plan = registerPlanMode(pi, { buildInWorker: (planFile, request) => task.startBackground(`Build ${basename(planFile)}`, request, { environment: 'cloud' }) });
|
|
99
107
|
registerVisualize(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()) });
|
|
100
|
-
|
|
108
|
+
registerGenerateImage(pi, { artifactsDir: (ctx) => artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()), mayOffer: () => !childIdentity.readonly && !plan.enabled() });
|
|
109
|
+
const ownType = agents.find((agent) => agent.type === childIdentity.agentType);
|
|
110
|
+
const task = registerTask(pi, { agents, depth: childIdentity.depth, canSpawn: childIdentity.depth < 2 && ownType?.spawns !== false, planMode: plan.enabled });
|
|
101
111
|
registerAgentStatus(pi);
|
|
102
|
-
registerSubscriptions(pi, {
|
|
112
|
+
registerSubscriptions(pi, {
|
|
113
|
+
runFresh: async (prompt, name, meta) => {
|
|
114
|
+
await task.startBackground(`timer ${name}`, prompt, {
|
|
115
|
+
...(meta.model === undefined ? {} : { model: `${meta.model.provider}/${meta.model.id}${meta.model.thinking === undefined ? '' : `:${meta.model.thinking}`}` }),
|
|
116
|
+
...(meta.trusted === undefined ? {} : { trusted: meta.trusted }),
|
|
117
|
+
});
|
|
118
|
+
return undefined;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
103
121
|
const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
|
|
104
|
-
registerTodoWrite(pi);
|
|
122
|
+
const todos = registerTodoWrite(pi);
|
|
105
123
|
registerAskQuestion(pi);
|
|
106
124
|
if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
|
|
107
125
|
if (childIdentity.readonly) registerReadonlyShell(pi);
|
|
108
126
|
|
|
109
127
|
pi.on('before_agent_start', (event, ctx) => {
|
|
110
|
-
const modeText = mode.section();
|
|
111
128
|
const loopText = loop.section();
|
|
112
129
|
const planText = plan.section();
|
|
130
|
+
const todoText = todos.section();
|
|
113
131
|
Object.assign(
|
|
114
132
|
event.systemPromptOptions.sections,
|
|
115
133
|
hostSections(pi, ctx, agents),
|
|
116
|
-
|
|
134
|
+
mode.sections(),
|
|
117
135
|
loopText === undefined ? {} : { pstack_loop: loopText },
|
|
118
136
|
planText === undefined ? {} : { pstack_plan: planText },
|
|
137
|
+
todoText === undefined ? {} : { pstack_todos: todoText },
|
|
119
138
|
);
|
|
120
139
|
});
|
|
121
140
|
|
package/src/loop.ts
CHANGED
|
@@ -5,14 +5,21 @@ import { isOneShot } from './modes.ts';
|
|
|
5
5
|
const DAY_MS = 86_400_000;
|
|
6
6
|
const UNIT_MS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: DAY_MS };
|
|
7
7
|
const DURATION = /^(?:\d+[smhd])+$/;
|
|
8
|
+
const WORD_UNITS: Record<string, string> = { second: 's', seconds: 's', sec: 's', secs: 's', minute: 'm', minutes: 'm', min: 'm', mins: 'm', hour: 'h', hours: 'h', hr: 'h', hrs: 'h', day: 'd', days: 'd' };
|
|
8
9
|
// setTimeout fires at once for delays past 2^31-1 ms (about 24.8 days), which would turn a long loop into a runaway.
|
|
9
10
|
const MAX_DELAY_MS = 24 * DAY_MS;
|
|
10
11
|
const TOO_LONG = 'Loop intervals can be at most 24d.';
|
|
11
12
|
|
|
12
|
-
/** Milliseconds for a duration such as 30s, 5m, 1h30m; undefined when the text is not a duration. */
|
|
13
|
+
/** Milliseconds for a duration such as 30s, 5m, 1h30m, 10 minutes, or 1 hour 30 minutes; undefined when the text is not a duration. */
|
|
13
14
|
export function parseDuration(text: string): number | undefined {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const compact = text
|
|
16
|
+
.trim()
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/^every\s+/, '')
|
|
19
|
+
.replace(/(\d+)\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?|days?)\b/g, (_match, count: string, word: string) => `${count}${WORD_UNITS[word] ?? ''}`)
|
|
20
|
+
.replace(/\s+/g, '');
|
|
21
|
+
if (!DURATION.test(compact)) return undefined;
|
|
22
|
+
const total = [...compact.matchAll(/(\d+)([smhd])/g)].reduce((sum, [, count, unit]) => sum + Number(count) * (UNIT_MS[unit ?? 's'] ?? 0), 0);
|
|
16
23
|
return total > 0 ? total : undefined;
|
|
17
24
|
}
|
|
18
25
|
|
|
@@ -25,15 +32,41 @@ function delayOf(text: string): number {
|
|
|
25
32
|
|
|
26
33
|
export type LoopCommand = { kind: 'status' } | { kind: 'stop' } | { kind: 'start'; prompt: string; intervalMs?: number } | { kind: 'error'; message: string };
|
|
27
34
|
|
|
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(' '));
|
|
40
|
+
if (intervalMs === undefined) continue;
|
|
41
|
+
const prompt = tokens
|
|
42
|
+
.slice(0, -take)
|
|
43
|
+
.join(' ')
|
|
44
|
+
.replace(/\s+every$/i, '')
|
|
45
|
+
.trim();
|
|
46
|
+
return { prompt, intervalMs };
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
export function parseLoopArgs(args: string): LoopCommand {
|
|
29
52
|
const trimmed = args.trim();
|
|
30
53
|
if (trimmed === '') return { kind: 'status' };
|
|
31
54
|
if (trimmed === 'stop' || trimmed === 'off') return { kind: 'stop' };
|
|
32
55
|
const [, first = '', rest = ''] = /^(\S+)\s*([\s\S]*)$/.exec(trimmed) ?? [];
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
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 };
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'start', prompt: trimmed };
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
interface Loop {
|
|
@@ -42,6 +75,8 @@ interface Loop {
|
|
|
42
75
|
iteration: number;
|
|
43
76
|
nextWakeMs?: number;
|
|
44
77
|
timer?: ReturnType<typeof setTimeout>;
|
|
78
|
+
/** A wake came due while a run was going; it fires once when that run settles. */
|
|
79
|
+
due?: boolean;
|
|
45
80
|
}
|
|
46
81
|
|
|
47
82
|
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`);
|
|
@@ -74,33 +109,55 @@ function createLoopController(pi: ExtensionAPI, options: LoopOptions) {
|
|
|
74
109
|
clear();
|
|
75
110
|
notify(ctx, `Loop stopped: ${reason}`);
|
|
76
111
|
};
|
|
77
|
-
const
|
|
112
|
+
const run = () => {
|
|
78
113
|
if (loop === undefined) return;
|
|
79
|
-
|
|
80
|
-
loop = { ...rest, iteration: rest.iteration + 1 };
|
|
114
|
+
loop = { ...loop, iteration: loop.iteration + 1, due: false };
|
|
81
115
|
// Expansion lets a loop run a skill or template each time.
|
|
82
|
-
pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true
|
|
116
|
+
pi.sendUserMessage(loop.prompt, { expandPromptTemplates: true });
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* A timer fired. A fixed loop keeps its wall-clock cadence, like Cursor's sleep-and-echo shell loop, so a long run
|
|
120
|
+
* does not push the next tick out. A wake that lands while a run is going is delivered once that run settles, as
|
|
121
|
+
* Cursor's monitored shell output notifies after the turn, and several such wakes fire once, not once each.
|
|
122
|
+
*/
|
|
123
|
+
const tick = () => {
|
|
124
|
+
if (loop === undefined) return;
|
|
125
|
+
const { timer: _spent, nextWakeMs: _wake, ...rest } = loop;
|
|
126
|
+
loop = rest.intervalMs === undefined ? rest : { ...rest, timer: setTimeout(tick, rest.intervalMs) };
|
|
127
|
+
if (lastCtx !== undefined && !lastCtx.isIdle()) loop = { ...loop, due: true };
|
|
128
|
+
else run();
|
|
83
129
|
};
|
|
84
130
|
const settled = (ctx: ExtensionContext, aborted: boolean) => {
|
|
85
131
|
lastCtx = ctx;
|
|
86
|
-
if (loop === undefined
|
|
132
|
+
if (loop === undefined) return;
|
|
87
133
|
if (aborted) return stop(ctx, 'the run was aborted.');
|
|
88
|
-
|
|
89
|
-
if (
|
|
134
|
+
if (loop.due === true) return run();
|
|
135
|
+
if (loop.timer !== undefined) return;
|
|
136
|
+
if (loop.intervalMs !== undefined) {
|
|
137
|
+
// An armed fixed loop has no timer yet; its first tick waits for this turn to end.
|
|
138
|
+
loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const delay = loop.nextWakeMs;
|
|
142
|
+
if (delay !== undefined) loop = { ...loop, timer: setTimeout(tick, delay) };
|
|
90
143
|
else if (!options.hasPendingWork()) stop(ctx, 'the agent scheduled no next wake.');
|
|
91
144
|
};
|
|
92
145
|
const start = (ctx: ExtensionContext, prompt: string, intervalMs: number | undefined) => {
|
|
93
146
|
if (loop !== undefined) stop(ctx, 'replaced by a new /loop.');
|
|
94
147
|
lastCtx = ctx;
|
|
95
148
|
loop = { prompt, iteration: 0, ...(intervalMs === undefined ? {} : { intervalMs }) };
|
|
96
|
-
|
|
149
|
+
run();
|
|
150
|
+
if (loop.intervalMs !== undefined) loop = { ...loop, timer: setTimeout(tick, loop.intervalMs) };
|
|
97
151
|
};
|
|
98
152
|
/** An agent arms a loop mid-turn, so its first run waits for this turn to settle and then for the first delay. */
|
|
99
153
|
const arm = (ctx: ExtensionContext, prompt: string, schedule: { intervalMs: number } | { firstWakeMs: number }) => {
|
|
100
154
|
if (loop !== undefined) stop(ctx, 'replaced by a new loop.');
|
|
101
155
|
loop = 'intervalMs' in schedule ? { prompt, iteration: 0, intervalMs: schedule.intervalMs } : { prompt, iteration: 0, nextWakeMs: schedule.firstWakeMs };
|
|
102
156
|
};
|
|
103
|
-
const status = () =>
|
|
157
|
+
const status = () =>
|
|
158
|
+
loop === undefined
|
|
159
|
+
? 'No loop is running. Usage: /loop [interval] <prompt>, with the interval at either end; /loop stop ends one.'
|
|
160
|
+
: `Loop: "${loop.prompt}" (iteration ${loop.iteration}${loop.intervalMs === undefined ? ', agent-paced' : `, every ${formatMs(loop.intervalMs)}`})`;
|
|
104
161
|
const wake = (delayMs: number) => {
|
|
105
162
|
if (loop === undefined) throw new Error('No loop is running. Start one with prompt plus interval or wake_in, or ask the user to run /loop.');
|
|
106
163
|
if (loop.intervalMs !== undefined) throw new Error(`This loop runs every ${formatMs(loop.intervalMs)}; only the user can change its interval. Pass stop: true to end it.`);
|
|
@@ -181,7 +238,7 @@ export function registerLoop(pi: ExtensionAPI, options: LoopOptions): { section:
|
|
|
181
238
|
pi.on('agent_settled', (event, ctx) => controller.settled(ctx, event.aborted));
|
|
182
239
|
pi.on('session_shutdown', () => controller.clear());
|
|
183
240
|
pi.registerCommand('loop', {
|
|
184
|
-
description: 'Run a prompt repeatedly: /loop [interval] <prompt
|
|
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',
|
|
185
242
|
handler: async (args, ctx) => handleLoopCommand(controller, args, ctx),
|
|
186
243
|
});
|
|
187
244
|
registerLoopTool(pi, controller);
|
package/src/mode.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ThemeColor } from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
|
|
7
7
|
export const MODE_ENTRY = 'pstack/mode';
|
|
8
8
|
const STATUS_KEY = 'pstack-mode';
|
|
@@ -13,6 +13,32 @@ export interface ActiveMode {
|
|
|
13
13
|
path?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Cursor's custom-mode lifecycle: a mode is `entered` until a request carries it, then `active`. Leaving or switching
|
|
18
|
+
* away from an `active` mode leaves it `exited` until the next request says so once.
|
|
19
|
+
*/
|
|
20
|
+
export interface ModeState {
|
|
21
|
+
active?: ActiveMode & { lifecycle: 'entered' | 'active' };
|
|
22
|
+
exited?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const left = (state: ModeState): string | undefined => (state.active?.lifecycle === 'active' ? state.active.skill : state.exited);
|
|
26
|
+
|
|
27
|
+
export function enterMode(state: ModeState, mode: ActiveMode): ModeState {
|
|
28
|
+
const exited = left(state);
|
|
29
|
+
return { active: { ...mode, lifecycle: 'entered' }, ...(exited === undefined ? {} : { exited }) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function exitMode(state: ModeState): ModeState {
|
|
33
|
+
const exited = left(state);
|
|
34
|
+
return exited === undefined ? {} : { exited };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a request leaves behind: the mode it carried is now active, and its exit notice has been sent. */
|
|
38
|
+
export function afterRequest(state: ModeState): ModeState {
|
|
39
|
+
return state.active === undefined ? {} : { active: { ...state.active, lifecycle: 'active' } };
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
export type ModeCommand = { kind: 'status' } | { kind: 'off' } | { kind: 'set'; skill: string; task: string };
|
|
17
43
|
|
|
18
44
|
export function parseModeArgs(args: string): ModeCommand {
|
|
@@ -31,30 +57,53 @@ interface BranchEntry {
|
|
|
31
57
|
|
|
32
58
|
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
33
59
|
|
|
60
|
+
/** A stored mode as a skill name only: a session file can come from elsewhere, so a stored path is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode. */
|
|
61
|
+
function storedSkill(data: unknown): string | undefined {
|
|
62
|
+
const skill = (data as { skill?: unknown } | null)?.skill;
|
|
63
|
+
return typeof skill === 'string' && SKILL_NAME.test(skill) ? skill : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
67
|
+
* Replays the branch into the mode state: each mode entry enters or leaves a mode, and an assistant message after it
|
|
68
|
+
* shows a request carried the mode. Skills are resolved by name, never by a stored path.
|
|
37
69
|
*/
|
|
38
|
-
export function modeFromBranch(entries: readonly BranchEntry[]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (entry
|
|
42
|
-
|
|
43
|
-
|
|
70
|
+
export function modeFromBranch(entries: readonly BranchEntry[], resolve: (skill: string) => ActiveMode = (skill) => ({ skill })): ModeState {
|
|
71
|
+
let state: ModeState = {};
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.type === 'custom' && entry.customType === MODE_ENTRY) {
|
|
74
|
+
const skill = storedSkill(entry.data);
|
|
75
|
+
state = skill === undefined ? exitMode(state) : enterMode(state, resolve(skill));
|
|
76
|
+
} else if (entry.type === 'message' && (entry as { message?: { role?: string } }).message?.role === 'assistant') state = afterRequest(state);
|
|
44
77
|
}
|
|
45
|
-
return
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cursor's custom-mode notices for the next request (CLI `custom-modes` module). */
|
|
82
|
+
export function modeNotice(state: ModeState): string | undefined {
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
const { active, exited } = state;
|
|
85
|
+
if (exited !== undefined && exited !== active?.skill) lines.push(`The user has exited ${exited} custom mode. Stop following that mode's skill. Continue without those constraints.`);
|
|
86
|
+
if (active?.path === undefined && active !== undefined) lines.push(`Mode ${active.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`);
|
|
87
|
+
else if (active?.path !== undefined && !existsSync(active.path)) lines.push(`Mode ${active.skill} is on, but its skill file ${active.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`);
|
|
88
|
+
else if (active?.lifecycle === 'entered')
|
|
89
|
+
lines.push(`The user has entered ${active.skill} custom mode. Follow the skill instructions in ${JSON.stringify(active.path)} until the user exits or switches modes. Do not drift back to default agent behavior.`);
|
|
90
|
+
else if (active !== undefined)
|
|
91
|
+
lines.push(
|
|
92
|
+
`Reminder: you are still in ${active.skill} mode. Follow that mode's skill on this turn. Do not drop it because the request looks small or you already have context. Do not drift back to default agent behavior. If you cannot state the mode's hard rules, read ${JSON.stringify(active.path)} before acting.`,
|
|
93
|
+
);
|
|
94
|
+
return lines.length === 0 ? undefined : lines.join('\n\n');
|
|
46
95
|
}
|
|
47
96
|
|
|
48
97
|
/**
|
|
49
|
-
* The mode
|
|
50
|
-
*
|
|
98
|
+
* The skill a mode keeps in context on every request until the user leaves it, in the block Pi's own /skill:name
|
|
99
|
+
* expansion builds. Cursor keeps the attached skill durable in the conversation; a section keeps it in every request,
|
|
100
|
+
* through compaction and restarts, and Pi re-sends a section only when it changes.
|
|
51
101
|
*/
|
|
52
|
-
export function
|
|
53
|
-
if (mode.path === undefined
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return [`Mode ${mode.skill} is on for every turn until the user runs /mode off.`, `<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body.trim(), '</skill>'].join('\n');
|
|
102
|
+
export function modeSkill(mode: ActiveMode): string | undefined {
|
|
103
|
+
if (mode.path === undefined || !existsSync(mode.path)) return undefined;
|
|
104
|
+
// The frontmatter stays out of the model's context in a mode (MODE-004).
|
|
105
|
+
const body = stripFrontmatter(readFileSync(mode.path, 'utf8')).trim();
|
|
106
|
+
return [`<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body, '</skill>'].join('\n');
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
// A mode's badge shows the skill's `icon` (default: lightning) and `color`. Icon names map to terminal glyphs and
|
|
@@ -97,17 +146,22 @@ function loadedSkills(pi: ExtensionAPI): Map<string, string> {
|
|
|
97
146
|
return skills;
|
|
98
147
|
}
|
|
99
148
|
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
/** The mode sections of one request: Cursor's notice, and the skill the mode keeps in context. */
|
|
150
|
+
export interface ModeSections {
|
|
151
|
+
pstack_mode?: string;
|
|
152
|
+
pstack_mode_skill?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function registerMode(pi: ExtensionAPI): { sections: () => ModeSections } {
|
|
156
|
+
let state: ModeState = {};
|
|
102
157
|
const show = (ctx: ExtensionContext) => {
|
|
103
158
|
if (!ctx.hasUI) return;
|
|
104
|
-
const badge = active === undefined ? undefined : modeBadge(active);
|
|
159
|
+
const badge = state.active === undefined ? undefined : modeBadge(state.active);
|
|
105
160
|
ctx.ui.setStatus(STATUS_KEY, badge === undefined ? undefined : badge.color === undefined ? badge.text : ctx.ui.theme.fg(badge.color, badge.text));
|
|
106
161
|
};
|
|
107
162
|
// The skill is looked up by name among loaded skills; a stored path is never read, since it can be stale or crafted.
|
|
108
163
|
const restore = (ctx: ExtensionContext) => {
|
|
109
|
-
|
|
110
|
-
active = stored === undefined ? undefined : (tryResolve(stored.skill) ?? { skill: stored.skill });
|
|
164
|
+
state = modeFromBranch(ctx.sessionManager.getBranch(), (skill) => tryResolve(skill) ?? { skill });
|
|
111
165
|
show(ctx);
|
|
112
166
|
};
|
|
113
167
|
pi.on('session_start', (_event, ctx) => restore(ctx));
|
|
@@ -131,11 +185,12 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
|
|
|
131
185
|
|
|
132
186
|
const handle = (args: string, ctx: ExtensionCommandContext) => {
|
|
133
187
|
const command = parseModeArgs(args);
|
|
188
|
+
const { active } = state;
|
|
134
189
|
if (command.kind === 'status') return ctx.ui.notify(active === undefined ? 'No mode is on.' : `Mode on: ${active.skill} (${active.path ?? 'no loaded skill has that name'}).`, 'info');
|
|
135
|
-
|
|
136
|
-
pi.appendEntry(MODE_ENTRY, active === undefined ? null : { skill: active.skill });
|
|
190
|
+
state = command.kind === 'off' ? exitMode(state) : enterMode(state, resolveSkill(command.skill));
|
|
191
|
+
pi.appendEntry(MODE_ENTRY, state.active === undefined ? null : { skill: state.active.skill });
|
|
137
192
|
show(ctx);
|
|
138
|
-
ctx.ui.notify(active === undefined ? 'Mode off.' : `Mode on: ${active.skill}. /mode off exits.`, 'info');
|
|
193
|
+
ctx.ui.notify(state.active === undefined ? 'Mode off.' : `Mode on: ${state.active.skill}. /mode off exits.`, 'info');
|
|
139
194
|
if (command.kind === 'set' && command.task !== '') pi.sendUserMessage(command.task, { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
|
|
140
195
|
return undefined;
|
|
141
196
|
};
|
|
@@ -152,5 +207,12 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
|
|
|
152
207
|
},
|
|
153
208
|
});
|
|
154
209
|
|
|
155
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
sections: () => {
|
|
212
|
+
const notice = modeNotice(state);
|
|
213
|
+
const skill = state.active === undefined ? undefined : modeSkill(state.active);
|
|
214
|
+
state = afterRequest(state);
|
|
215
|
+
return { ...(notice === undefined ? {} : { pstack_mode: notice }), ...(skill === undefined ? {} : { pstack_mode_skill: skill }) };
|
|
216
|
+
},
|
|
217
|
+
};
|
|
156
218
|
}
|
package/src/paths.ts
CHANGED
|
@@ -23,6 +23,24 @@ export function routinesDir(): string {
|
|
|
23
23
|
return join(stateDir(), 'routines');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Plan-mode artifacts, the way Cursor keeps a plan file under `~/.cursor/plans`. */
|
|
27
|
+
export function plansDir(): string {
|
|
28
|
+
return join(stateDir(), 'plans');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Desired-state registry and supervisor runtime for one workspace. */
|
|
32
|
+
export function durableDir(cwd: string): string {
|
|
33
|
+
return join(stateDir(), 'durable', workspaceSlug(cwd));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function durableRegistryPath(cwd: string): string {
|
|
37
|
+
return join(durableDir(cwd), 'subscriptions.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function durableRuntimeDir(cwd: string): string {
|
|
41
|
+
return join(durableDir(cwd), 'runtime');
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Pi's rule for a session folder name, so per-workspace state lines up with `<agentDir>/sessions/<slug>`. The live
|
|
28
46
|
* `sessionManager.getSessionDir()` needs a session and names the current one, which is `<workspace>/subagents` in a
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Argv prefix for spawning Pi: the running script when available so children match
|
|
6
|
+
* the parent's Pi version, else `pi` on PATH. Same rule as task/tool.ts historically.
|
|
7
|
+
*/
|
|
8
|
+
export function piCommand(): string[] {
|
|
9
|
+
const script = process.argv[1];
|
|
10
|
+
if (script !== undefined && !script.startsWith('/$bunfs/') && existsSync(script)) return [process.execPath, script];
|
|
11
|
+
return /^(node|bun)(\.exe)?$/.test(basename(process.execPath).toLowerCase()) ? ['pi'] : [process.execPath];
|
|
12
|
+
}
|