@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.7

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 (114) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +15 -12
  3. package/agents/ci-watcher.md +27 -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 +1 -1
  7. package/automations/benny/README.md +2 -2
  8. package/automations/benny/skills/setup-benny/SKILL.md +5 -5
  9. package/bin/pstack-routines-supervisor +9 -0
  10. package/docs/guide/07-overnight.md +1 -1
  11. package/docs/guide/09-make-it-yours.md +1 -1
  12. package/docs/guide/10-recipes-and-pitfalls.md +1 -1
  13. package/docs/native-gaps.md +11 -5
  14. package/docs/parity-reverse-engineering.md +94 -0
  15. package/package.json +41 -6
  16. package/packaging/install-supervisor-unit.sh +164 -0
  17. package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
  18. package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
  19. package/skills/architect/SKILL.md +1 -1
  20. package/skills/architect/references/runner-prompt.md +1 -1
  21. package/skills/arena/SKILL.md +3 -3
  22. package/skills/check-compiler-errors/SKILL.md +23 -0
  23. package/skills/create-skill/SKILL.md +515 -29
  24. package/skills/fix-ci/SKILL.md +29 -0
  25. package/skills/fix-merge-conflicts/SKILL.md +32 -0
  26. package/skills/get-pr-comments/SKILL.md +23 -0
  27. package/skills/how/SKILL.md +1 -1
  28. package/skills/ketchup-video/SKILL.md +2 -2
  29. package/skills/loop-on-ci/SKILL.md +50 -0
  30. package/skills/make-bot-ui/SKILL.md +2 -2
  31. package/skills/make-pr-easy-to-review/SKILL.md +59 -0
  32. package/skills/new-branch-and-pr/SKILL.md +29 -0
  33. package/skills/no-inline-imports/SKILL.md +12 -0
  34. package/skills/poteto-help/SKILL.md +4 -4
  35. package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
  36. package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
  37. package/skills/pr-review-canvas/SKILL.md +176 -0
  38. package/skills/pr-review-canvas/renderer.js +170 -0
  39. package/skills/pr-review-canvas/styles.css +96 -0
  40. package/skills/pr-review-canvas/template.html +18 -0
  41. package/skills/reflect/SKILL.md +1 -1
  42. package/skills/review-and-ship/SKILL.md +41 -0
  43. package/skills/run-smoke-tests/SKILL.md +42 -0
  44. package/skills/statechart/scripts/test/cli.test.ts +1 -1
  45. package/skills/swarm/SKILL.md +2 -2
  46. package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  47. package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
  48. package/skills/verify-this/SKILL.md +74 -0
  49. package/skills/weekly-review/SKILL.md +32 -0
  50. package/skills/what-did-i-get-done/SKILL.md +31 -0
  51. package/skills/why/SKILL.md +1 -1
  52. package/skills/workflow-from-chats/SKILL.md +50 -0
  53. package/src/mode.ts +3 -3
  54. package/src/paths.ts +13 -0
  55. package/src/pi-command.ts +12 -0
  56. package/src/rule.ts +4 -4
  57. package/src/sections.ts +2 -1
  58. package/src/subscriptions/durable-store.ts +482 -0
  59. package/src/subscriptions/durable-types.ts +41 -0
  60. package/src/subscriptions/ensure-supervisor.ts +67 -0
  61. package/src/subscriptions/index.ts +231 -271
  62. package/src/subscriptions/supervisor-main.ts +88 -0
  63. package/src/subscriptions/supervisor.ts +271 -0
  64. package/src/subscriptions/webhook.ts +22 -9
  65. package/src/task/card.ts +29 -0
  66. package/src/task/records.ts +3 -8
  67. package/src/task/runner.ts +9 -14
  68. package/src/task/tool.ts +18 -21
  69. package/src/todo.ts +1 -2
  70. package/scripts/live-e2e.mjs +0 -257
  71. package/test/aliases.test.ts +0 -57
  72. package/test/ask.test.ts +0 -85
  73. package/test/autocomplete.test.ts +0 -39
  74. package/test/e2e/aliases.e2e.test.ts +0 -81
  75. package/test/e2e/ask.e2e.test.ts +0 -135
  76. package/test/e2e/harness.ts +0 -179
  77. package/test/e2e/host.e2e.test.ts +0 -193
  78. package/test/e2e/lifecycle.e2e.test.ts +0 -99
  79. package/test/e2e/mode.e2e.test.ts +0 -160
  80. package/test/e2e/package.e2e.test.ts +0 -67
  81. package/test/e2e/task.e2e.test.ts +0 -734
  82. package/test/e2e/todo.e2e.test.ts +0 -59
  83. package/test/fixtures/cursor-askquestion-calls.json +0 -806
  84. package/test/fixtures/fake-pi.mjs +0 -96
  85. package/test/fixtures/image-model.ts +0 -16
  86. package/test/fixtures/list-models.ts +0 -15
  87. package/test/fixtures/readonly-shell-exit.ts +0 -17
  88. package/test/fixtures/scripted-model.ts +0 -113
  89. package/test/loop.test.ts +0 -209
  90. package/test/mode.test.ts +0 -143
  91. package/test/plan.test.ts +0 -126
  92. package/test/readonly-shell-security.test.ts +0 -84
  93. package/test/readonly-shell.test.ts +0 -44
  94. package/test/records.test.ts +0 -143
  95. package/test/registry.test.ts +0 -18
  96. package/test/resources.test.ts +0 -162
  97. package/test/rule.test.ts +0 -92
  98. package/test/scripts.test.ts +0 -182
  99. package/test/sections.test.ts +0 -56
  100. package/test/subscriptions-cron.test.ts +0 -60
  101. package/test/subscriptions.test.ts +0 -598
  102. package/test/task-agents.test.ts +0 -49
  103. package/test/task-card.test.ts +0 -34
  104. package/test/task-fork.test.ts +0 -233
  105. package/test/task-format.test.ts +0 -65
  106. package/test/task-model.test.ts +0 -141
  107. package/test/task-runner.test.ts +0 -259
  108. package/test/task-schema.test.ts +0 -50
  109. package/test/task-status.test.ts +0 -155
  110. package/test/task-worktree.test.ts +0 -106
  111. package/test/todo.test.ts +0 -74
  112. package/test/visualize.test.ts +0 -152
  113. package/tsconfig.json +0 -5
  114. package/vitest.config.ts +0 -16
@@ -1,14 +1,18 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
 
3
- import { StringEnum } from '@earendil-works/pi-ai';
4
3
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
4
+ import { Text } from '@earendil-works/pi-tui';
5
5
  import { type Static, Type } from 'typebox';
6
6
  import { Value } from 'typebox/value';
7
7
  import { isOneShot } from '../modes.ts';
8
8
  import { routinesDir } from '../paths.ts';
9
- import { type CronSchedule, cronMatches, parseCron } from './cron.ts';
10
- import { defaultGhRunner, fetchPrs, type GhRunner, type PrSnapshot, pollChanges, targetText } from './github.ts';
11
- import { credentialFile, DEFAULT_ROUTINES_PORT, ensureCredentials, RoutineServer, routineSlug, type WebhookEvent, webhookEventBlock } from './webhook.ts';
9
+ import { parseCron } from './cron.ts';
10
+ import { acquireLease, cancelDesired, canonicalPath, createDesired, deliverOwner, finishFresh, isDesired, isSupervisorLive, type Lease, ownerLockPath, readArm, readRegistry, startFresh, visibleSubscriptions } from './durable-store.ts';
11
+ import type { StoredSubscription } from './durable-types.ts';
12
+ import { defaultEnsureSupervisor, waitForArm, waitForSupervisor } from './ensure-supervisor.ts';
13
+ import { type GhRunner, targetText } from './github.ts';
14
+ import { createSupervisor, type Supervisor } from './supervisor.ts';
15
+ import { credentialFile, routineSlug } from './webhook.ts';
12
16
 
13
17
  export const SUBSCRIPTIONS_ENTRY = 'pstack/subscriptions';
14
18
  export const WAKE_TYPE = 'pstack/subscription';
@@ -16,9 +20,6 @@ export const WAKE_TYPE = 'pstack/subscription';
16
20
  const HOUR_MS = 3_600_000;
17
21
  const DEFAULT_EXPIRY_MS = 7 * 24 * HOUR_MS;
18
22
  const MAX_EXPIRY_HOURS = 30 * 24;
19
- // setTimeout fires at once past 2^31-1 ms, so long waits are chained in steps of at most this.
20
- const MAX_STEP_MS = 24 * 24 * HOUR_MS;
21
- const MINUTE_MS = 60_000;
22
23
 
23
24
  const TargetSchema = Type.Union([
24
25
  Type.Object({ scope: Type.Literal('pr'), prUrl: Type.String() }),
@@ -38,23 +39,6 @@ type Config = Subscription['config'];
38
39
  type TimerConfig = Extract<Config, { kind: 'timer' }>;
39
40
  type Strategy = TimerConfig['strategy'];
40
41
 
41
- interface Note {
42
- at: number;
43
- text: string;
44
- }
45
-
46
- interface Live {
47
- sub: Subscription;
48
- timers: Set<ReturnType<typeof setTimeout>>;
49
- lastEvent?: Note;
50
- lastError?: Note;
51
- baseline?: Map<number, PrSnapshot>;
52
- polling?: boolean;
53
- url?: string;
54
- cron?: CronSchedule;
55
- lastCronMinute?: number;
56
- }
57
-
58
42
  export interface SubscriptionOptions {
59
43
  /** Runs a prompt in a fresh background session (a `new_session` timer fire). */
60
44
  runFresh: (prompt: string, name: string) => Promise<void> | void;
@@ -63,6 +47,13 @@ export interface SubscriptionOptions {
63
47
  /** Routines listener port; defaults to PSTACK_ROUTINES_PORT, else 47913. 0 picks a free port. */
64
48
  routinesPort?: number;
65
49
  routinesDir?: string;
50
+ /**
51
+ * Starts the durable supervisor for this workspace when no live lock exists.
52
+ * Default spawns `pstack-routines-supervisor` detached so timers and routines survive chat exit.
53
+ */
54
+ ensureSupervisor?: (cwd: string) => Promise<void> | void;
55
+ /** How long create/restore wait for supervisor.lock after ensure (ms). Default 2000. */
56
+ supervisorReadyMs?: number;
66
57
  }
67
58
 
68
59
  const iso = (ms: number) => new Date(ms).toISOString();
@@ -72,12 +63,6 @@ const oneLine = (text: string, max = 200) => {
72
63
  };
73
64
  const errorText = (error: unknown) => oneLine(error instanceof Error ? error.message : String(error), 500);
74
65
 
75
- function envPort(): number {
76
- const raw = process.env['PSTACK_ROUTINES_PORT'];
77
- const port = raw === undefined || raw === '' ? DEFAULT_ROUTINES_PORT : Number(raw);
78
- return Number.isInteger(port) && port >= 0 && port < 65536 ? port : DEFAULT_ROUTINES_PORT;
79
- }
80
-
81
66
  function oneShotError(tool: string): Error {
82
67
  return new Error(`${tool} needs an interactive or RPC session: print and JSON runs exit when the agent settles, so nothing could wake this run or fire this subscription later. Do the work now instead.`);
83
68
  }
@@ -130,7 +115,7 @@ function timerTarget(config: TimerConfig): string {
130
115
  }
131
116
 
132
117
  const GithubParams = Type.Object({
133
- scope: StringEnum(['pr', 'repo', 'author'] as const, { description: 'pr: one pull request (prUrl). repo: every open PR in repo, including PRs opened, closed, or merged. author: open PRs in repo by author.' }),
118
+ scope: Type.Enum(['pr', 'repo', 'author'] as const, { type: 'string', description: 'pr: one pull request (prUrl). repo: every open PR in repo, including PRs opened, closed, or merged. author: open PRs in repo by author.' }),
134
119
  prUrl: Type.Optional(Type.String({ description: 'scope pr: the pull request URL, such as https://github.com/owner/repo/pull/123.' })),
135
120
  repo: Type.Optional(Type.String({ description: 'scope repo or author: owner/name.' })),
136
121
  author: Type.Optional(Type.String({ description: 'scope author: a GitHub login, or @me.' })),
@@ -145,8 +130,9 @@ const TimerParams = Type.Object({
145
130
  delaySeconds: Type.Optional(Type.Integer({ minimum: 1, description: 'Fire this many seconds from now, and again every delaySeconds after that unless once is true.' })),
146
131
  once: Type.Optional(Type.Boolean({ description: 'Fire only once, then end the timer. Default false.' })),
147
132
  sessionStrategy: Type.Optional(
148
- StringEnum(['wake_self', 'new_session'] as const, {
149
- description: 'wake_self (default): wake this session with the prompt. new_session: run the prompt in a fresh background subagent each fire; its result arrives as a Task notification.',
133
+ Type.Enum(['wake_self', 'new_session'] as const, {
134
+ type: 'string',
135
+ description: 'wake_self (default) queues the prompt for this owning session. new_session starts fresh work each fire. A live owner gets a background Task notification; offline execution records its outcome in list_subscriptions.',
150
136
  }),
151
137
  ),
152
138
  expiresInHours: Type.Optional(Type.Number({ description: `Hours until the timer ends; default 168 (7 days), at most ${MAX_EXPIRY_HOURS}.` })),
@@ -163,225 +149,217 @@ const UnsubscribeParams = Type.Object({ subscription: Type.String({ description:
163
149
  const text = (value: unknown) => ({ content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }], details: value });
164
150
 
165
151
  export function createSubscriptions(pi: ExtensionAPI, options: SubscriptionOptions) {
166
- const live = new Map<string, Live>();
167
- const gh = options.gh ?? defaultGhRunner;
168
- const pollMs = options.pollIntervalMs ?? MINUTE_MS;
169
152
  const dir = options.routinesDir ?? routinesDir();
170
- const server = new RoutineServer(options.routinesPort ?? envPort());
171
- let active = false;
172
-
173
- const persist = () => pi.appendEntry(SUBSCRIPTIONS_ENTRY, { subscriptions: [...live.values()].map((entry) => entry.sub) });
174
-
175
- const byName = (name: string) => [...live.values()].find((entry) => entry.sub.name === name);
176
-
177
- /** Runs fn at `at`, chaining timeouts so waits past setTimeout's limit stay correct. */
178
- const at = (entry: Live, when: number, fn: () => void) => {
179
- const handle = setTimeout(
180
- () => {
181
- entry.timers.delete(handle);
182
- if (!active || !owns(entry)) return;
183
- if (Date.now() < when) at(entry, when, fn);
184
- else fn();
185
- },
186
- Math.min(Math.max(0, when - Date.now()), MAX_STEP_MS),
187
- );
188
- handle.unref?.();
189
- entry.timers.add(handle);
190
- };
191
-
192
- const owns = (entry: Live) => live.get(entry.sub.id) === entry;
193
-
194
- const disarm = (entry: Live) => {
195
- for (const handle of entry.timers) clearTimeout(handle);
196
- entry.timers.clear();
197
- if (entry.sub.config.kind === 'webhook') {
198
- server.routes.delete(entry.sub.config.slug);
199
- if (server.routes.size === 0) server.close();
153
+ let workspaceCwd = canonicalPath(process.cwd());
154
+ let owner: { id: string; cwd: string; lease: Lease; notify: (message: string) => void } | undefined;
155
+ let ownerPoll: ReturnType<typeof setInterval> | undefined;
156
+ let fallback: Supervisor | undefined;
157
+ let starting: Promise<void> | undefined;
158
+ let draining = false;
159
+ let lastDrainError: string | undefined;
160
+
161
+ const persist = () => pi.appendEntry(SUBSCRIPTIONS_ENTRY, { subscriptions: readRegistry(workspaceCwd).subscriptions.filter((sub) => isDesired(sub)) });
162
+
163
+ const drain = () => {
164
+ const session = owner;
165
+ if (session === undefined || draining) return;
166
+ draining = true;
167
+ try {
168
+ const eligible = () => owner === session;
169
+ const registry = readRegistry(session.cwd);
170
+ const pending = registry.pending.filter((event) => {
171
+ if (event.retryAt !== undefined && Date.now() < event.retryAt) return false;
172
+ return registry.subscriptions.some((sub) => sub.id === event.subscriptionId && sub.ownerSessionId === session.id);
173
+ });
174
+ if (pending.length === 0) return;
175
+ deliverOwner(session.cwd, session.id, eligible, (sub, event) => {
176
+ pi.sendMessage({ customType: WAKE_TYPE, content: event.content, display: true, details: { id: sub.id, eventId: event.id, kind: sub.config.kind, name: sub.name } }, { triggerTurn: true, deliverAs: 'followUp' });
177
+ });
178
+ for (const event of pending) {
179
+ const claim = startFresh(session.cwd, event.id, session.id, eligible, (sub, pending) => options.runFresh(pending.content, sub.name));
180
+ if (claim !== undefined)
181
+ void claim.result
182
+ .then(
183
+ () => finishFresh(session.cwd, event.id, claim.token),
184
+ (cause: unknown) => finishFresh(session.cwd, event.id, claim.token, cause),
185
+ )
186
+ .catch((cause: unknown) => session.notify(`Subscription result could not be saved. ${errorText(cause)}`));
187
+ }
188
+ lastDrainError = undefined;
189
+ } catch (cause) {
190
+ const message = errorText(cause);
191
+ if (message !== lastDrainError) session.notify(`Subscription owner delivery failed. ${message}`);
192
+ lastDrainError = message;
193
+ } finally {
194
+ draining = false;
200
195
  }
201
196
  };
202
197
 
203
- const remove = (entry: Live, save = true) => {
204
- disarm(entry);
205
- // A restored entry reuses the id. Only that object may drop the subscription.
206
- if (!owns(entry)) return;
207
- live.delete(entry.sub.id);
208
- if (save) persist();
198
+ const ensureEngine = async () => {
199
+ if (starting !== undefined) return starting;
200
+ const cwd = workspaceCwd;
201
+ const session = owner;
202
+ const attempt = (async () => {
203
+ if (fallback !== undefined) {
204
+ await fallback.reconcile();
205
+ return;
206
+ }
207
+ if (isSupervisorLive(cwd)) return;
208
+ const canDetach = options.routinesDir === undefined || options.routinesDir === routinesDir();
209
+ if (canDetach) {
210
+ const ensure = options.ensureSupervisor ?? ((workspace: string) => defaultEnsureSupervisor(workspace, options.routinesPort === undefined ? {} : { routinesPort: options.routinesPort }));
211
+ await ensure(cwd);
212
+ if (owner !== session || session === undefined) return;
213
+ if (isSupervisorLive(cwd) || (await waitForSupervisor(cwd, options.supervisorReadyMs ?? 2_000))) return;
214
+ }
215
+ if (owner !== session || session === undefined) return;
216
+ const engine = createSupervisor({
217
+ workspaceCwd: cwd,
218
+ runFresh: options.runFresh,
219
+ isDaemon: false,
220
+ ownerSessionId: session.id,
221
+ ...(options.gh === undefined ? {} : { gh: options.gh }),
222
+ ...(options.pollIntervalMs === undefined ? {} : { pollIntervalMs: options.pollIntervalMs }),
223
+ ...(options.routinesPort === undefined ? {} : { routinesPort: options.routinesPort }),
224
+ ...(options.routinesDir === undefined ? {} : { routinesDir: options.routinesDir }),
225
+ onAccepted: drain,
226
+ });
227
+ fallback = engine;
228
+ try {
229
+ const result = await engine.start();
230
+ if (owner !== session) engine.stop();
231
+ if (!result.started && fallback === engine) fallback = undefined;
232
+ } catch (cause) {
233
+ engine.stop();
234
+ if (fallback === engine) fallback = undefined;
235
+ throw cause;
236
+ }
237
+ })().finally(() => {
238
+ if (starting === attempt) starting = undefined;
239
+ });
240
+ starting = attempt;
241
+ return attempt;
209
242
  };
210
243
 
211
- /** True (and the subscription gone) once it has expired. */
212
- const expired = (entry: Live) => {
213
- if (Date.now() < entry.sub.expiresAt) return false;
214
- remove(entry);
215
- return true;
244
+ const view = (sub: StoredSubscription) => {
245
+ const config = sub.config;
246
+ const base = { id: sub.id, name: sub.name, kind: config.kind, createdAt: iso(sub.createdAt), expiresAt: iso(sub.expiresAt) };
247
+ if (config.kind === 'timer')
248
+ return { ...base, target: timerTarget(config), prompt: config.prompt, sessionStrategy: config.strategy, ...(config.cron === undefined ? { delaySeconds: config.delaySeconds } : { cron: config.cron }), once: config.once };
249
+ if (config.kind === 'github') return { ...base, target: targetText(config.target), ...config.target, sessionStrategy: 'wake_self' as Strategy };
250
+ const arm = readArm(workspaceCwd, sub);
251
+ const url = arm?.state === 'ready' ? arm.url : undefined;
252
+ return { ...base, target: url ?? `(not listening) /routines/${config.slug}`, url, credentialFile: credentialFile(dir, config.slug), slug: config.slug, prompt: config.prompt, sessionStrategy: 'wake_self' as Strategy };
216
253
  };
217
254
 
218
- const wake = (entry: Live, content: string, summary: string) => {
219
- if (!active) return;
220
- entry.lastEvent = { at: Date.now(), text: oneLine(summary) };
221
- pi.sendMessage({ customType: WAKE_TYPE, content, display: true, details: { id: entry.sub.id, kind: entry.sub.config.kind, name: entry.sub.name } }, { triggerTurn: true, deliverAs: 'followUp' });
222
- };
255
+ const status = (sub: StoredSubscription) => ({
256
+ ...(sub.lastEvent === undefined ? {} : { lastEvent: `${iso(sub.lastEvent.at)} ${sub.lastEvent.text}` }),
257
+ ...(sub.lastError === undefined ? {} : { lastError: `${iso(sub.lastError.at)} ${sub.lastError.text}` }),
258
+ ...(sub.lastOutcome === undefined ? {} : { lastOutcome: `${iso(sub.lastOutcome.at)} ${sub.lastOutcome.text}` }),
259
+ });
223
260
 
224
- const fireTimer = (entry: Live, config: TimerConfig) => {
225
- if (config.strategy === 'wake_self') wake(entry, `[timer] ${entry.sub.name}\n\n${config.prompt}`, 'fired');
226
- else {
227
- entry.lastEvent = { at: Date.now(), text: 'started a fresh session' };
228
- Promise.resolve()
229
- .then(() => options.runFresh(config.prompt, entry.sub.name))
230
- .catch((error: unknown) => {
231
- entry.lastError = { at: Date.now(), text: errorText(error) };
232
- });
233
- }
234
- if (config.once) remove(entry);
261
+ const shutdown = () => {
262
+ if (ownerPoll !== undefined) clearInterval(ownerPoll);
263
+ ownerPoll = undefined;
264
+ starting = undefined;
265
+ const previous = owner;
266
+ owner = undefined;
267
+ previous?.lease.release();
268
+ fallback?.stop();
269
+ fallback = undefined;
235
270
  };
236
271
 
237
- const armTimer = (entry: Live, config: TimerConfig) => {
238
- if (config.cron !== undefined) {
239
- const schedule = parseCron(config.cron);
240
- const tick = () => {
241
- if (expired(entry)) return;
242
- const now = Date.now();
243
- const minute = Math.floor(now / MINUTE_MS);
244
- if (minute !== entry.lastCronMinute && cronMatches(schedule, new Date(now))) {
245
- entry.lastCronMinute = minute;
246
- fireTimer(entry, config);
247
- if (!owns(entry)) return;
248
- }
249
- at(entry, (minute + 1) * MINUTE_MS, tick);
250
- };
251
- at(entry, (Math.floor(Date.now() / MINUTE_MS) + 1) * MINUTE_MS, tick);
272
+ const restore = (ctx: ExtensionContext) => {
273
+ if (isOneShot(ctx.mode)) {
274
+ shutdown();
252
275
  return;
253
276
  }
254
- const period = (config.delaySeconds ?? 1) * 1_000;
255
- const next = () => {
256
- const { createdAt } = entry.sub;
257
- // A once timer that came due while Pi was not running fires on restore; a repeating one resumes its cadence.
258
- if (config.once) return createdAt + period;
259
- return createdAt + (Math.floor(Math.max(0, Date.now() - createdAt) / period) + 1) * period;
260
- };
261
- const fire = () => {
262
- if (expired(entry)) return;
263
- fireTimer(entry, config);
264
- if (owns(entry)) at(entry, next(), fire);
265
- };
266
- at(entry, next(), fire);
267
- };
268
-
269
- const poll = async (entry: Live, target: Static<typeof TargetSchema>) => {
270
- if (entry.polling === true || !active || expired(entry)) return;
271
- entry.polling = true;
272
- try {
273
- const after = await fetchPrs(gh, target);
274
- if (!active || !owns(entry)) return;
275
- if (entry.baseline !== undefined) {
276
- const blocks = await pollChanges(gh, target, entry.baseline, after);
277
- if (blocks.length > 0 && owns(entry))
278
- wake(
279
- entry,
280
- [`[github] ${entry.sub.name}`, ...blocks].join('\n\n'),
281
- blocks
282
- .map((block) =>
283
- block
284
- .replace(/ \(https?:\/\/\S+\)/, '')
285
- .replace(/\n- /g, ': ')
286
- .replace(/\n/g, '; '),
287
- )
288
- .join(' | '),
289
- );
277
+ const cwd = canonicalPath(ctx.cwd || process.cwd());
278
+ const id = ctx.sessionManager.getSessionId();
279
+ if (owner?.id === id && owner.cwd === cwd) {
280
+ drain();
281
+ return;
282
+ }
283
+ shutdown();
284
+ workspaceCwd = cwd;
285
+ const lease = acquireLease(ownerLockPath(cwd, id));
286
+ if (lease === undefined) {
287
+ ctx.ui.notify('Subscription owner delivery unavailable. Another live process owns this exact session.', 'error');
288
+ if (ownerPoll === undefined) {
289
+ ownerPoll = setInterval(() => {
290
+ if (owner === undefined) restore(ctx);
291
+ else drain();
292
+ }, 250);
293
+ ownerPoll.unref?.();
290
294
  }
291
- if (!owns(entry)) return;
292
- entry.baseline = after;
293
- delete entry.lastError;
294
- } catch (error) {
295
- entry.lastError = { at: Date.now(), text: errorText(error) };
296
- } finally {
297
- entry.polling = false;
295
+ return;
298
296
  }
299
- };
300
-
301
- const armGithub = async (entry: Live, target: Static<typeof TargetSchema>) => {
302
- const loop = () => {
303
- void poll(entry, target).then(() => {
304
- if (active && owns(entry)) at(entry, Date.now() + pollMs, loop);
305
- });
306
- };
307
- await poll(entry, target);
308
- if (active && owns(entry)) at(entry, Date.now() + pollMs, loop);
309
- };
310
-
311
- const onWebhook = (entry: Live, prompt: string) => (event: WebhookEvent) => {
312
- if (!owns(entry) || expired(entry)) return;
313
- wake(entry, [`[routine] ${entry.sub.name}`, prompt, webhookEventBlock(event)].join('\n\n'), `POST, body sha256 ${event.bodyDigest.slice(0, 12)}`);
314
- };
315
-
316
- const armWebhook = async (entry: Live, slug: string, prompt: string) => {
317
- const credentialFile = ensureCredentials(dir, slug);
318
- server.routes.set(slug, { slug, credentialFile, onEvent: onWebhook(entry, prompt) });
319
- try {
320
- entry.url = await server.url(slug);
321
- delete entry.lastError;
322
- } catch (error) {
323
- server.routes.delete(slug);
324
- entry.lastError = { at: Date.now(), text: errorText(error) };
325
- throw error;
297
+ const notify = ctx.ui.notify.bind(ctx.ui);
298
+ owner = { id, cwd, lease, notify: (message) => notify(message, 'error') };
299
+ drain();
300
+ ownerPoll = setInterval(drain, 250);
301
+ ownerPoll.unref?.();
302
+ const registry = readRegistry(cwd);
303
+ if (subscriptionsFromBranch(ctx.sessionManager.getBranch()).some((sub) => !registry.subscriptions.some((stored) => stored.id === sub.id))) {
304
+ notify('Historical subscription receipts are inactive. Recreate missing subscriptions with the subscribe tools.', 'info');
305
+ }
306
+ if (registry.subscriptions.some((sub) => isDesired(sub))) {
307
+ void ensureEngine().catch((cause: unknown) => notify(`Subscriptions could not start. ${errorText(cause)}`, 'error'));
326
308
  }
327
- };
328
-
329
- const expiryTimer = (entry: Live) => at(entry, entry.sub.expiresAt, () => expired(entry));
330
-
331
- const arm = async (entry: Live) => {
332
- const { config } = entry.sub;
333
- expiryTimer(entry);
334
- if (config.kind === 'timer') armTimer(entry, config);
335
- else if (config.kind === 'github') await armGithub(entry, config.target);
336
- else await armWebhook(entry, config.slug, config.prompt);
337
- };
338
-
339
- const view = (entry: Live) => {
340
- const { sub } = entry;
341
- const { config } = sub;
342
- const base = { id: sub.id, name: sub.name, kind: config.kind, createdAt: iso(sub.createdAt), expiresAt: iso(sub.expiresAt) };
343
- if (config.kind === 'timer')
344
- return { ...base, target: timerTarget(config), prompt: config.prompt, sessionStrategy: config.strategy, ...(config.cron === undefined ? { delaySeconds: config.delaySeconds } : { cron: config.cron }), once: config.once };
345
- if (config.kind === 'github') return { ...base, target: targetText(config.target), ...config.target, sessionStrategy: 'wake_self' as Strategy };
346
- return { ...base, target: entry.url ?? `(not listening) /routines/${config.slug}`, url: entry.url, credentialFile: credentialFile(dir, config.slug), slug: config.slug, prompt: config.prompt, sessionStrategy: 'wake_self' as Strategy };
347
309
  };
348
310
 
349
311
  const create = async (ctx: ExtensionContext, tool: string, name: string, config: Config, hours: number | undefined) => {
350
312
  if (isOneShot(ctx.mode)) throw oneShotError(tool);
351
313
  if (name.trim() === '') throw new Error('name must not be empty.');
352
- const existing = byName(name);
353
- if (existing !== undefined) {
354
- if (existing.sub.config.kind !== config.kind) throw new Error(`A ${existing.sub.config.kind} subscription is already named "${name}". Pick another name or unsubscribe it first.`);
355
- return { created: false, ...view(existing), ...status(existing) };
356
- }
357
- if (config.kind === 'webhook') {
358
- const clash = [...live.values()].find((entry) => entry.sub.config.kind === 'webhook' && entry.sub.config.slug === config.slug);
359
- if (clash !== undefined) throw new Error(`Routine "${clash.sub.name}" already uses the slug ${config.slug}.`);
360
- }
314
+ const cwd = canonicalPath(ctx.cwd || process.cwd());
315
+ const id = ctx.sessionManager.getSessionId();
316
+ if (owner?.id !== id || owner.cwd !== cwd) restore(ctx);
317
+ if (owner === undefined) throw new Error('Subscription owner delivery unavailable for this session.');
318
+ const session = owner;
361
319
  const now = Date.now();
362
- const entry: Live = { sub: { id: `sub-${randomBytes(4).toString('hex')}`, name, createdAt: now, expiresAt: expiryOf(now, hours), config }, timers: new Set() };
363
- live.set(entry.sub.id, entry);
320
+ const file = ctx.sessionManager.getSessionFile();
321
+ const result = createDesired(cwd, {
322
+ id: `sub-${randomBytes(12).toString('hex')}`,
323
+ name,
324
+ createdAt: now,
325
+ expiresAt: expiryOf(now, hours),
326
+ config,
327
+ ownerSessionId: id,
328
+ ...(file === undefined ? {} : { ownerSessionFile: canonicalPath(file) }),
329
+ });
330
+ let sub = result.subscription;
331
+ if (sub.config.kind !== config.kind) throw new Error(`A ${sub.config.kind} subscription is already named "${name}". Pick another name or unsubscribe it first.`);
364
332
  try {
365
- await arm(entry);
366
- } catch (error) {
367
- remove(entry, false);
368
- throw error;
333
+ await ensureEngine();
334
+ if (owner !== session) throw new Error(`Subscription ${sub.id} owner session changed while arming.`);
335
+ await waitForArm(cwd, sub, options.supervisorReadyMs ?? 2_000);
336
+ if (owner !== session) throw new Error(`Subscription ${sub.id} owner session changed while arming.`);
337
+ } catch (cause) {
338
+ if (result.created) {
339
+ cancelDesired(cwd, sub.id);
340
+ if (owner === session) await fallback?.reconcile();
341
+ }
342
+ throw cause;
369
343
  }
370
- persist();
371
- return { created: true, ...view(entry), ...status(entry) };
344
+ sub = readRegistry(cwd).subscriptions.find((entry) => entry.id === sub.id) ?? sub;
345
+ if (result.created) persist();
346
+ return {
347
+ created: result.created,
348
+ ...view(sub),
349
+ ...status(sub),
350
+ ...(fallback === undefined ? {} : { durability: 'in-process; start pstack-routines-supervisor for chat-exit survival' }),
351
+ ...(sub.ownerSessionFile === undefined && !(sub.config.kind === 'timer' && sub.config.strategy === 'new_session') ? { ownerWarning: 'Owner session is unsaved. Offline owner wakes cannot resume after this process exits.' } : {}),
352
+ };
372
353
  };
373
354
 
374
- const status = (entry: Live) => ({
375
- ...(entry.lastEvent === undefined ? {} : { lastEvent: `${iso(entry.lastEvent.at)} ${entry.lastEvent.text}` }),
376
- ...(entry.lastError === undefined ? {} : { lastError: `${iso(entry.lastError.at)} ${entry.lastError.text}` }),
377
- });
378
-
379
355
  const listText = () => {
380
- if (live.size === 0) return 'No subscriptions.';
381
- return [...live.values()]
382
- .map((entry) => {
383
- const item = view(entry);
384
- const state = status(entry);
356
+ const registry = readRegistry(workspaceCwd);
357
+ const visible = visibleSubscriptions(registry);
358
+ if (visible.length === 0) return 'No subscriptions.';
359
+ return visible
360
+ .map((sub) => {
361
+ const item = view(sub);
362
+ const state = status(sub);
385
363
  return [
386
364
  `${item.id} ${item.kind} "${item.name}"`,
387
365
  ` target: ${item.target}`,
@@ -389,71 +367,49 @@ export function createSubscriptions(pi: ExtensionAPI, options: SubscriptionOptio
389
367
  ` created: ${item.createdAt} expires: ${item.expiresAt}`,
390
368
  ` last event: ${state.lastEvent ?? 'none'}`,
391
369
  ...(state.lastError === undefined ? [] : [` last error: ${state.lastError}`]),
370
+ ...(state.lastOutcome === undefined ? [] : [` last outcome: ${state.lastOutcome}`]),
371
+ ...(registry.pending.some((event) => event.subscriptionId === sub.id) ? [' delivery: pending'] : []),
392
372
  ].join('\n');
393
373
  })
394
374
  .join('\n');
395
375
  };
396
376
 
397
377
  const routinesText = () => {
398
- const routines = [...live.values()].filter((entry) => entry.sub.config.kind === 'webhook');
378
+ const routines = visibleSubscriptions(readRegistry(workspaceCwd)).filter((sub) => sub.config.kind === 'webhook');
399
379
  if (routines.length === 0) return 'No webhook routines.';
400
380
  return routines
401
- .map((entry) => {
402
- const item = view(entry) as ReturnType<typeof view> & { url?: string; credentialFile?: string };
403
- const enabled = entry.url !== undefined && server.isListening() ? 'enabled' : `disabled${entry.lastError === undefined ? '' : ` (${entry.lastError.text})`}`;
381
+ .map((sub) => {
382
+ const item = view(sub) as ReturnType<typeof view> & { url?: string; credentialFile?: string };
383
+ const arm = readArm(workspaceCwd, sub);
384
+ const enabled = isDesired(sub) && arm?.state === 'ready' ? 'enabled' : `disabled${sub.lastError === undefined ? '' : ` (${sub.lastError.text})`}`;
404
385
  return [`${item.name} [${enabled}]`, ` url: ${item.url ?? 'none'}`, ` credential file: ${item.credentialFile}`, ` expires: ${item.expiresAt}`].join('\n');
405
386
  })
406
387
  .join('\n');
407
388
  };
408
389
 
409
- const stopAll = () => {
410
- for (const entry of live.values()) disarm(entry);
411
- live.clear();
412
- server.close();
390
+ const unsubscribe = async (key: string) => {
391
+ const sub = cancelDesired(workspaceCwd, key);
392
+ if (sub === undefined) throw new Error(`No subscription has the id or name "${key}". list_subscriptions shows them.`);
393
+ await fallback?.reconcile();
394
+ persist();
395
+ return { removed: true, id: sub.id, name: sub.name, kind: sub.config.kind };
413
396
  };
414
397
 
415
- const restore = (ctx: ExtensionContext) => {
416
- const baselines = new Map<string, Map<number, PrSnapshot>>();
417
- for (const [id, entry] of live) if (entry.baseline !== undefined) baselines.set(id, new Map(entry.baseline));
418
- stopAll();
419
- active = true;
420
- if (isOneShot(ctx.mode)) return;
421
- const stored = subscriptionsFromBranch(ctx.sessionManager.getBranch());
422
- const now = Date.now();
423
- for (const sub of stored) {
424
- if (sub.expiresAt <= now) continue;
425
- const baseline = baselines.get(sub.id);
426
- live.set(sub.id, { sub, timers: new Set(), ...(baseline === undefined ? {} : { baseline }) });
427
- }
428
- if (live.size !== stored.length) persist();
429
- for (const entry of live.values()) {
430
- arm(entry).catch(() => undefined);
431
- }
432
- };
398
+ return { create, unsubscribe, listText, routinesText, restore, shutdown };
399
+ }
433
400
 
434
- const unsubscribe = (key: string) => {
435
- const entry = live.get(key) ?? byName(key);
436
- if (entry === undefined) throw new Error(`No subscription has the id or name "${key}". list_subscriptions shows them.`);
437
- remove(entry);
438
- return { removed: true, id: entry.sub.id, name: entry.sub.name, kind: entry.sub.config.kind };
439
- };
401
+ const WAKE_COLLAPSED_LINES = 10;
440
402
 
441
- return {
442
- create,
443
- unsubscribe,
444
- listText,
445
- routinesText,
446
- restore,
447
- shutdown: () => {
448
- active = false;
449
- stopAll();
450
- },
451
- };
403
+ export function subscriptionWakeText(message: { content: string | { type: string; text?: string }[] }, expanded: boolean): string {
404
+ const raw = typeof message.content === 'string' ? message.content : message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('\n');
405
+ const lines = raw.split('\n');
406
+ return (expanded || lines.length <= WAKE_COLLAPSED_LINES ? lines : [...lines.slice(0, WAKE_COLLAPSED_LINES), `… ${lines.length - WAKE_COLLAPSED_LINES} more lines`]).join('\n');
452
407
  }
453
408
 
454
409
  /** Subscriptions (GitHub PR polls, timers, webhook routines) that wake this session or start fresh ones. */
455
410
  export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOptions): ReturnType<typeof createSubscriptions> {
456
411
  const subs = createSubscriptions(pi, options);
412
+ pi.registerMessageRenderer?.(WAKE_TYPE, (message, renderOptions) => new Text(subscriptionWakeText(message, renderOptions.expanded), 0, 0));
457
413
  pi.on('session_start', (_event, ctx) => subs.restore(ctx));
458
414
  pi.on('session_tree', (_event, ctx) => subs.restore(ctx));
459
415
  pi.on('session_shutdown', () => subs.shutdown());
@@ -465,6 +421,7 @@ export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOpt
465
421
  'Watch GitHub pull requests with gh and wake this session when one changes: state (merged, closed, reopened), head SHA, mergeability, review decision, new reviews or comments, or a check concluding. The first poll only records a baseline. Polls every minute until unsubscribed or expired.',
466
422
  promptSnippet: 'Wake this session when a GitHub PR changes',
467
423
  parameters: GithubParams,
424
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
468
425
  async execute(_id, params, _signal, _onUpdate, ctx) {
469
426
  const target =
470
427
  params.scope === 'pr'
@@ -482,9 +439,10 @@ export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOpt
482
439
  name: 'subscribe_timer',
483
440
  label: 'Subscribe: timer',
484
441
  description:
485
- 'Fire a prompt on a 5-field cron schedule (local time) or after delaySeconds (repeating unless once). wake_self wakes this session with the prompt; new_session runs it in a fresh background subagent. Re-subscribing an existing name returns created: false and changes nothing.',
442
+ 'Fire a prompt on a 5-field cron schedule (local time) or after delaySeconds (repeating unless once). wake_self queues the prompt for this owning session, including while offline. new_session uses a background Task while the owner is live, or a detached run with recorded status while offline. Re-subscribing an existing name returns created: false and changes nothing.',
486
443
  promptSnippet: 'Schedule a prompt with cron or a delay',
487
444
  parameters: TimerParams,
445
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
488
446
  async execute(_id, params, _signal, _onUpdate, ctx) {
489
447
  if (isOneShot(ctx.mode)) throw oneShotError('subscribe_timer');
490
448
  if ((params.cron === undefined) === (params.delaySeconds === undefined)) throw new Error('Pass exactly one of cron or delaySeconds.');
@@ -507,6 +465,7 @@ export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOpt
507
465
  'Create a webhook routine: a local URL (http://127.0.0.1:<port>/routines/<slug>) that wakes this session with a [routine] turn when a sender POSTs a JSON object with the routine key in X-Automation-Key or Authorization: Bearer. The result gives the URL and the credential file holding the key (field "key"); the key itself is never shown. Copy it from that file into the sender without printing it.',
508
466
  promptSnippet: 'Create a webhook routine that wakes this session',
509
467
  parameters: WebhookParams,
468
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
510
469
  async execute(_id, params, _signal, _onUpdate, ctx) {
511
470
  if (isOneShot(ctx.mode)) throw oneShotError('subscribe_webhook');
512
471
  return text(await subs.create(ctx, 'subscribe_webhook', params.name, { kind: 'webhook', prompt: params.prompt, slug: routineSlug(params.name) }, params.expiresInHours));
@@ -516,7 +475,7 @@ export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOpt
516
475
  pi.registerTool({
517
476
  name: 'list_subscriptions',
518
477
  label: 'Subscriptions',
519
- description: "List this session's subscriptions (GitHub PR watches, timers, webhook routines) with id, kind, target, strategy, created and expiry times, and the last event or error.",
478
+ description: "List this workspace's subscriptions (GitHub PR watches, timers, webhook routines) with id, kind, target, strategy, created and expiry times, and the last event or error.",
520
479
  promptSnippet: 'List subscriptions',
521
480
  parameters: Type.Object({}),
522
481
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -531,8 +490,9 @@ export function registerSubscriptions(pi: ExtensionAPI, options: SubscriptionOpt
531
490
  description: 'End a subscription by id or name.',
532
491
  promptSnippet: 'End a subscription',
533
492
  parameters: UnsubscribeParams,
493
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
534
494
  async execute(_id, params) {
535
- return text(subs.unsubscribe(params.subscription));
495
+ return text(await subs.unsubscribe(params.subscription));
536
496
  },
537
497
  });
538
498