@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +29 -24
  3. package/agents/ci-watcher.md +28 -0
  4. package/agents/thermo-nuclear-code-quality-review.md +23 -0
  5. package/assets/logo.png +0 -0
  6. package/automations/benny/FOR_AGENTS.md +2 -1
  7. package/automations/benny/README.md +2 -2
  8. package/automations/benny/skills/setup-benny/SKILL.md +10 -9
  9. package/automations/benny/templates/reproduce-automation-prompt.md +1 -1
  10. package/automations/benny/templates/triage-automation-prompt.md +1 -1
  11. package/bin/pstack-routines-supervisor +9 -0
  12. package/docs/guide/01-setup.md +2 -2
  13. package/docs/guide/07-overnight.md +1 -1
  14. package/docs/guide/09-make-it-yours.md +1 -1
  15. package/docs/guide/10-recipes-and-pitfalls.md +1 -1
  16. package/docs/native-gaps.md +14 -8
  17. package/docs/parity-reverse-engineering.md +94 -0
  18. package/docs/parity-status.md +128 -0
  19. package/package.json +41 -6
  20. package/packaging/install-supervisor-unit.sh +164 -0
  21. package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
  22. package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
  23. package/skills/architect/SKILL.md +1 -1
  24. package/skills/architect/references/runner-prompt.md +1 -1
  25. package/skills/arena/SKILL.md +4 -4
  26. package/skills/check-compiler-errors/SKILL.md +23 -0
  27. package/skills/create-skill/SKILL.md +515 -29
  28. package/skills/fix-ci/SKILL.md +29 -0
  29. package/skills/fix-merge-conflicts/SKILL.md +32 -0
  30. package/skills/get-pr-comments/SKILL.md +23 -0
  31. package/skills/how/SKILL.md +1 -1
  32. package/skills/ketchup/SKILL.md +2 -2
  33. package/skills/ketchup-video/SKILL.md +2 -2
  34. package/skills/loop-on-ci/SKILL.md +50 -0
  35. package/skills/make-bot-ui/SKILL.md +2 -2
  36. package/skills/make-pr-easy-to-review/SKILL.md +59 -0
  37. package/skills/new-branch-and-pr/SKILL.md +29 -0
  38. package/skills/no-inline-imports/SKILL.md +12 -0
  39. package/skills/poteto-help/SKILL.md +4 -4
  40. package/skills/poteto-mode/SKILL.md +2 -2
  41. package/skills/poteto-mode/playbooks/bug-fix.md +1 -1
  42. package/skills/poteto-mode/playbooks/feature.md +1 -1
  43. package/skills/poteto-mode/playbooks/hillclimb.md +1 -1
  44. package/skills/poteto-mode/playbooks/multi-phase-plan.md +2 -2
  45. package/skills/poteto-mode/playbooks/orchestrate.md +2 -2
  46. package/skills/poteto-mode/playbooks/perf-issue.md +1 -1
  47. package/skills/poteto-mode/playbooks/refactoring.md +1 -1
  48. package/skills/pr-review-canvas/SKILL.md +176 -0
  49. package/skills/pr-review-canvas/renderer.js +170 -0
  50. package/skills/pr-review-canvas/styles.css +96 -0
  51. package/skills/pr-review-canvas/template.html +18 -0
  52. package/skills/reflect/SKILL.md +2 -2
  53. package/skills/review-and-ship/SKILL.md +41 -0
  54. package/skills/run-smoke-tests/SKILL.md +42 -0
  55. package/skills/setup-pstack/SKILL.md +13 -13
  56. package/skills/statechart/scripts/test/cli.test.ts +1 -1
  57. package/skills/swarm/SKILL.md +2 -2
  58. package/skills/teach/SKILL.md +1 -1
  59. package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  60. package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
  61. package/skills/verify-this/SKILL.md +74 -0
  62. package/skills/weekly-review/SKILL.md +32 -0
  63. package/skills/what-did-i-get-done/SKILL.md +31 -0
  64. package/skills/why/SKILL.md +2 -2
  65. package/skills/workflow-from-chats/SKILL.md +50 -0
  66. package/src/ask.ts +24 -10
  67. package/src/child-env.ts +15 -2
  68. package/src/generate-image.ts +98 -0
  69. package/src/index.ts +28 -9
  70. package/src/loop.ts +74 -17
  71. package/src/mode.ts +89 -27
  72. package/src/paths.ts +18 -0
  73. package/src/pi-command.ts +12 -0
  74. package/src/plan.ts +285 -30
  75. package/src/rule.ts +4 -4
  76. package/src/sections.ts +27 -3
  77. package/src/subscriptions/durable-store.ts +531 -0
  78. package/src/subscriptions/durable-types.ts +69 -0
  79. package/src/subscriptions/ensure-supervisor.ts +67 -0
  80. package/src/subscriptions/index.ts +310 -282
  81. package/src/subscriptions/supervisor-main.ts +134 -0
  82. package/src/subscriptions/supervisor.ts +325 -0
  83. package/src/subscriptions/webhook.ts +82 -21
  84. package/src/task/agents.ts +39 -3
  85. package/src/task/card.ts +31 -0
  86. package/src/task/format.ts +17 -3
  87. package/src/task/models.ts +7 -0
  88. package/src/task/progress.ts +112 -0
  89. package/src/task/records.ts +7 -9
  90. package/src/task/registry.ts +9 -4
  91. package/src/task/runner.ts +116 -24
  92. package/src/task/stop.ts +3 -3
  93. package/src/task/tool.ts +158 -57
  94. package/src/task/worktree.ts +5 -3
  95. package/src/todo.ts +29 -4
  96. package/scripts/live-e2e.mjs +0 -257
  97. package/test/aliases.test.ts +0 -57
  98. package/test/ask.test.ts +0 -85
  99. package/test/autocomplete.test.ts +0 -39
  100. package/test/e2e/aliases.e2e.test.ts +0 -81
  101. package/test/e2e/ask.e2e.test.ts +0 -135
  102. package/test/e2e/harness.ts +0 -179
  103. package/test/e2e/host.e2e.test.ts +0 -193
  104. package/test/e2e/lifecycle.e2e.test.ts +0 -99
  105. package/test/e2e/mode.e2e.test.ts +0 -160
  106. package/test/e2e/package.e2e.test.ts +0 -67
  107. package/test/e2e/task.e2e.test.ts +0 -734
  108. package/test/e2e/todo.e2e.test.ts +0 -59
  109. package/test/fixtures/cursor-askquestion-calls.json +0 -806
  110. package/test/fixtures/fake-pi.mjs +0 -96
  111. package/test/fixtures/image-model.ts +0 -16
  112. package/test/fixtures/list-models.ts +0 -15
  113. package/test/fixtures/readonly-shell-exit.ts +0 -17
  114. package/test/fixtures/scripted-model.ts +0 -113
  115. package/test/loop.test.ts +0 -209
  116. package/test/mode.test.ts +0 -143
  117. package/test/plan.test.ts +0 -126
  118. package/test/readonly-shell-security.test.ts +0 -84
  119. package/test/readonly-shell.test.ts +0 -44
  120. package/test/records.test.ts +0 -143
  121. package/test/registry.test.ts +0 -18
  122. package/test/resources.test.ts +0 -162
  123. package/test/rule.test.ts +0 -92
  124. package/test/scripts.test.ts +0 -182
  125. package/test/sections.test.ts +0 -56
  126. package/test/subscriptions-cron.test.ts +0 -60
  127. package/test/subscriptions.test.ts +0 -598
  128. package/test/task-agents.test.ts +0 -49
  129. package/test/task-card.test.ts +0 -34
  130. package/test/task-fork.test.ts +0 -233
  131. package/test/task-format.test.ts +0 -65
  132. package/test/task-model.test.ts +0 -141
  133. package/test/task-runner.test.ts +0 -259
  134. package/test/task-schema.test.ts +0 -50
  135. package/test/task-status.test.ts +0 -155
  136. package/test/task-worktree.test.ts +0 -106
  137. package/test/todo.test.ts +0 -74
  138. package/test/visualize.test.ts +0 -152
  139. package/tsconfig.json +0 -5
  140. 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
- /** In a subagent, the agent definition body joins the system prompt as the agent's own instructions. */
28
+ // What Cursor tells every subagent in a system reminder, minus its sentence about Cursor Canvas files.
29
+ const SUBAGENT_REMINDER = 'You are running as a subagent under a parent agent. Do not spawn additional subagents unless requested by the user or by your instructions.';
30
+
31
+ /** In a subagent, Cursor's subagent reminder, and the agent definition body as the agent's own instructions. */
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
- const task = registerTask(pi, { agents, depth: childIdentity.depth, planMode: plan.enabled });
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, { runFresh: async (prompt, name) => void (await task.startBackground(`timer ${name}`, prompt)) });
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
- modeText === undefined ? {} : { pstack_mode: modeText },
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
- if (!DURATION.test(text)) return undefined;
15
- const total = [...text.matchAll(/(\d+)([smhd])/g)].reduce((sum, [, count, unit]) => sum + Number(count) * (UNIT_MS[unit ?? 's'] ?? 0), 0);
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 intervalMs = parseDuration(first);
34
- if (intervalMs === undefined) return { kind: 'start', prompt: trimmed };
35
- if (intervalMs > MAX_DELAY_MS) return { kind: 'error', message: TOO_LONG };
36
- return rest.trim() === '' ? { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' } : { kind: 'start', prompt: rest.trim(), intervalMs };
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 fire = () => {
112
+ const run = () => {
78
113
  if (loop === undefined) return;
79
- const { timer: _timer, nextWakeMs: _wake, ...rest } = loop;
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, ...(lastCtx === undefined || lastCtx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
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 || loop.timer !== undefined) return;
132
+ if (loop === undefined) return;
87
133
  if (aborted) return stop(ctx, 'the run was aborted.');
88
- const delay = loop.intervalMs ?? loop.nextWakeMs;
89
- if (delay !== undefined) loop = { ...loop, timer: setTimeout(fire, delay) };
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
- fire();
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 = () => (loop === undefined ? 'No loop is running.' : `Loop: "${loop.prompt}" (iteration ${loop.iteration}${loop.intervalMs === undefined ? ', agent-paced' : `, every ${formatMs(loop.intervalMs)}`})`);
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> (interval like 30s, 5m, 1h, at most 24d); /loop stop',
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
- * The last mode entry on the branch, as a skill name only: a session file can come from elsewhere, so a stored path
36
- * is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode.
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[]): ActiveMode | undefined {
39
- for (let index = entries.length - 1; index >= 0; index -= 1) {
40
- const entry = entries[index];
41
- if (entry?.type !== 'custom' || entry.customType !== MODE_ENTRY) continue;
42
- const skill = (entry.data as { skill?: unknown } | null)?.skill;
43
- return typeof skill === 'string' && SKILL_NAME.test(skill) ? { skill } : undefined;
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 undefined;
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 text for every request: the skill block in the same shape as Pi's own
50
- * `/skill:name` expansion (agent-session.js `_expandSkillCommand`), which Pi does not export as a function.
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 modeSection(mode: ActiveMode): string {
53
- if (mode.path === undefined) return `Mode ${mode.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`;
54
- if (!existsSync(mode.path)) return `Mode ${mode.skill} is on, but its skill file ${mode.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`;
55
- // The frontmatter, including `reminder`, stays out of the model's context in a mode (MODE-004).
56
- const { body } = parseFrontmatter(readFileSync(mode.path, 'utf8'));
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
- export function registerMode(pi: ExtensionAPI): { section: () => string | undefined } {
101
- let active: ActiveMode | undefined;
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
- const stored = modeFromBranch(ctx.sessionManager.getBranch());
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
- active = command.kind === 'off' ? undefined : resolveSkill(command.skill);
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 { section: () => (active === undefined ? undefined : modeSection(active)) };
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
+ }