@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
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+
5
+ import { PACKAGE_ROOT } from '../paths.ts';
6
+ import { createSupervisor } from './supervisor.ts';
7
+
8
+ function usage(): never {
9
+ console.error('Usage: pstack-routines-supervisor --workspace <cwd>');
10
+ process.exit(2);
11
+ }
12
+
13
+ function parseArgs(argv: string[]): { workspace: string } {
14
+ let workspace: string | undefined;
15
+ for (let index = 0; index < argv.length; index += 1) {
16
+ const arg = argv[index];
17
+ if (arg === '--workspace') {
18
+ workspace = argv[index + 1];
19
+ index += 1;
20
+ continue;
21
+ }
22
+ if (arg === '--help' || arg === '-h') usage();
23
+ console.error(`Unknown argument: ${arg}`);
24
+ usage();
25
+ }
26
+ if (workspace === undefined || workspace.trim() === '') usage();
27
+ return { workspace: resolve(workspace) };
28
+ }
29
+
30
+ /** Spawns a headless Pi print run; resolves only when the child exits 0. */
31
+ function runFresh(prompt: string, name: string, meta: { subscriptionId: string; cwd: string }): Promise<void> {
32
+ return new Promise((resolvePromise, reject) => {
33
+ const raw: unknown = JSON.parse(process.env['PI_PSTACK_PI_COMMAND'] ?? '["pi"]');
34
+ if (!Array.isArray(raw) || raw.length === 0 || !raw.every((part) => typeof part === 'string' && part !== '')) {
35
+ reject(new Error('Invalid Pi command supplied to the subscription supervisor.'));
36
+ return;
37
+ }
38
+ const [executable, ...prefix] = raw as string[];
39
+ if (executable === undefined) {
40
+ reject(new Error(`Could not spawn fire for "${name}" (${meta.subscriptionId}): no pi command`));
41
+ return;
42
+ }
43
+ const child = spawn(executable, [...prefix, '--mode', 'json', '-e', PACKAGE_ROOT, '-p', '--', prompt], {
44
+ cwd: meta.cwd,
45
+ env: process.env,
46
+ stdio: 'ignore',
47
+ });
48
+ child.once('error', (error) => reject(new Error(`Could not spawn fire for "${name}" (${meta.subscriptionId}): ${error.message}`)));
49
+ child.once('exit', (code, signal) => {
50
+ if (signal != null) {
51
+ reject(new Error(`Fire for "${name}" (${meta.subscriptionId}) exited from signal ${signal}`));
52
+ return;
53
+ }
54
+ if (code !== 0) {
55
+ reject(new Error(`Fire for "${name}" (${meta.subscriptionId}) exited with code ${code ?? 'null'}`));
56
+ return;
57
+ }
58
+ resolvePromise();
59
+ });
60
+ });
61
+ }
62
+
63
+ async function main(): Promise<void> {
64
+ const { workspace } = parseArgs(process.argv.slice(2));
65
+ const supervisor = createSupervisor({ workspaceCwd: workspace, runFresh, isDaemon: true });
66
+ const result = await supervisor.start();
67
+ if (!result.started) {
68
+ console.log(`pstack-routines-supervisor already running for ${workspace} (pid ${result.pid}).`);
69
+ process.exit(0);
70
+ }
71
+ console.log(`pstack-routines-supervisor started for ${workspace} (pid ${process.pid}).`);
72
+ await new Promise<void>((resolve) => {
73
+ const shutdown = () => {
74
+ supervisor.stop();
75
+ resolve();
76
+ };
77
+ process.on('SIGINT', shutdown);
78
+ process.on('SIGTERM', shutdown);
79
+ process.on('SIGHUP', () => {
80
+ void supervisor.reconcile();
81
+ });
82
+ });
83
+ }
84
+
85
+ main().catch((error: unknown) => {
86
+ console.error(error instanceof Error ? error.message : error);
87
+ process.exit(1);
88
+ });
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { routinesDir } from '../paths.ts';
4
+ import { cronMatches, parseCron } from './cron.ts';
5
+ import { acceptEvent, acquireSupervisorLock, alive, canonicalPath, clearError, expireDesired, finishFresh, isDesired, readLock, readRegistry, recordError, releaseSupervisorLock, startFresh, writeArm } from './durable-store.ts';
6
+ import type { DurableSubscription, StoredSubscription } from './durable-types.ts';
7
+ import { defaultGhRunner, fetchPrs, type GhRunner, type PrSnapshot, pollChanges } from './github.ts';
8
+ import { credentialFile, DEFAULT_ROUTINES_PORT, ensureCredentials, RoutineServer, type WebhookEvent, webhookEventBlock } from './webhook.ts';
9
+
10
+ const MAX_STEP_MS = 24 * 24 * 3_600_000;
11
+ const MINUTE_MS = 60_000;
12
+ export type RunFresh = (prompt: string, name: string, meta: { subscriptionId: string; cwd: string }) => Promise<void> | void;
13
+ export interface SupervisorOptions {
14
+ workspaceCwd: string;
15
+ runFresh: RunFresh;
16
+ now?: () => number;
17
+ routinesPort?: number;
18
+ routinesDir?: string;
19
+ pollIntervalMs?: number;
20
+ gh?: GhRunner;
21
+ reconcileIntervalMs?: number;
22
+ onAccepted?: () => void;
23
+ isDaemon?: boolean;
24
+ ownerSessionId?: string;
25
+ }
26
+ type TimerConfig = Extract<DurableSubscription['config'], { kind: 'timer' }>;
27
+ type Armed = { sub: StoredSubscription; timers: Set<ReturnType<typeof setTimeout>>; baseline?: Map<number, PrSnapshot>; polling?: boolean };
28
+
29
+ function envPort(): number {
30
+ const raw = process.env['PSTACK_ROUTINES_PORT'];
31
+ const port = raw === undefined || raw === '' ? DEFAULT_ROUTINES_PORT : Number(raw);
32
+ return Number.isInteger(port) && port >= 0 && port < 65536 ? port : DEFAULT_ROUTINES_PORT;
33
+ }
34
+ export function delayFireIndex(createdAt: number, delaySeconds: number, at: number): number {
35
+ return Math.floor(Math.max(0, at - createdAt) / (Math.max(1, delaySeconds) * 1_000));
36
+ }
37
+ export function timerClaimKey(sub: DurableSubscription, config: TimerConfig, at: number): string {
38
+ if (config.once) return `${sub.id}:once`;
39
+ return `${sub.id}:${config.cron === undefined ? delayFireIndex(sub.createdAt, config.delaySeconds ?? 1, at) : Math.floor(at / MINUTE_MS)}`;
40
+ }
41
+
42
+ export function createSupervisor(options: SupervisorOptions) {
43
+ const cwd = canonicalPath(options.workspaceCwd);
44
+ const now = options.now ?? Date.now;
45
+ const gh = options.gh ?? defaultGhRunner;
46
+ const pollMs = options.pollIntervalMs ?? MINUTE_MS;
47
+ const dir = options.routinesDir ?? routinesDir();
48
+ const server = new RoutineServer(options.routinesPort ?? envPort());
49
+ const armed = new Map<string, Armed>();
50
+ const isDaemon = options.isDaemon ?? true;
51
+ let active = false;
52
+ let epoch: string | undefined;
53
+ let watchTimer: ReturnType<typeof setTimeout> | undefined;
54
+ let reconciling: Promise<void> | undefined;
55
+
56
+ const owns = (entry: Armed) => active && armed.get(entry.sub.id) === entry;
57
+ const at = (entry: Armed, when: number, fn: () => void) => {
58
+ const handle = setTimeout(
59
+ () => {
60
+ entry.timers.delete(handle);
61
+ if (!owns(entry)) return;
62
+ if (now() < when) at(entry, when, fn);
63
+ else fn();
64
+ },
65
+ Math.min(Math.max(0, when - now()), MAX_STEP_MS),
66
+ );
67
+ handle.unref?.();
68
+ entry.timers.add(handle);
69
+ };
70
+ const disarm = (entry: Armed) => {
71
+ for (const handle of entry.timers) clearTimeout(handle);
72
+ entry.timers.clear();
73
+ if (entry.sub.config.kind === 'webhook') {
74
+ server.routes.delete(entry.sub.config.slug);
75
+ if (server.routes.size === 0) server.close(false);
76
+ }
77
+ };
78
+ const error = (id: string, cause: unknown) => recordError(cwd, id, cause);
79
+ const offlineFresh = () => {
80
+ if (!active) return;
81
+ const registry = readRegistry(cwd);
82
+ const eligible = registry.pending.filter((event) => {
83
+ if (event.claim !== undefined && alive(event.claim.pid)) return false;
84
+ if (event.retryAt !== undefined && now() < event.retryAt) return false;
85
+ const sub = registry.subscriptions.find((s) => s.id === event.subscriptionId);
86
+ if (sub === undefined || sub.lifecycle === 'cancelled' || sub.config.kind !== 'timer' || sub.config.strategy !== 'new_session') return false;
87
+ if (!isDaemon && (options.ownerSessionId === undefined || sub.ownerSessionId !== options.ownerSessionId)) return false;
88
+ return true;
89
+ });
90
+ for (const event of eligible) {
91
+ const claim = startFresh(
92
+ cwd,
93
+ event.id,
94
+ undefined,
95
+ () => active,
96
+ (sub, pending) => options.runFresh(pending.content, sub.name, { subscriptionId: sub.id, cwd }),
97
+ );
98
+ if (claim !== undefined)
99
+ void claim.result
100
+ .then(
101
+ () => finishFresh(cwd, event.id, claim.token),
102
+ (cause: unknown) => finishFresh(cwd, event.id, claim.token, cause),
103
+ )
104
+ .catch((cause: unknown) => {
105
+ console.error(`Subscription delivery status failed. ${String(cause)}`);
106
+ });
107
+ }
108
+ };
109
+ const accepted = () => {
110
+ options.onAccepted?.();
111
+ offlineFresh();
112
+ };
113
+ const fireTimer = (entry: Armed, config: TimerConfig) => {
114
+ if (!owns(entry)) return;
115
+ try {
116
+ const prompt = config.strategy === 'wake_self' ? `[timer] ${entry.sub.name}\n\n${config.prompt}` : config.prompt;
117
+ if (acceptEvent(cwd, entry.sub.id, timerClaimKey(entry.sub, config, now()), prompt, now()) === 'accepted') accepted();
118
+ } catch (cause) {
119
+ error(entry.sub.id, cause);
120
+ }
121
+ };
122
+ const armTimer = (entry: Armed, config: TimerConfig) => {
123
+ if (config.cron !== undefined) {
124
+ const schedule = parseCron(config.cron);
125
+ const tick = () => {
126
+ if (cronMatches(schedule, new Date(now()))) fireTimer(entry, config);
127
+ const current = readRegistry(cwd).subscriptions.find((sub) => sub.id === entry.sub.id);
128
+ if (current !== undefined && isDesired(current, now())) at(entry, (Math.floor(now() / MINUTE_MS) + 1) * MINUTE_MS, tick);
129
+ };
130
+ at(entry, (Math.floor(now() / MINUTE_MS) + 1) * MINUTE_MS, tick);
131
+ } else {
132
+ const period = (config.delaySeconds ?? 1) * 1_000;
133
+ const next = () => (config.once ? entry.sub.createdAt + period : entry.sub.createdAt + (Math.floor(Math.max(0, now() - entry.sub.createdAt) / period) + 1) * period);
134
+ const fire = () => {
135
+ fireTimer(entry, config);
136
+ const current = readRegistry(cwd).subscriptions.find((sub) => sub.id === entry.sub.id);
137
+ if (current !== undefined && isDesired(current, now())) at(entry, config.once ? now() + 1_000 : next(), fire);
138
+ };
139
+ at(entry, next(), fire);
140
+ }
141
+ };
142
+ const onWebhook =
143
+ (entry: Armed, prompt: string) =>
144
+ async (event: WebhookEvent): Promise<boolean> => {
145
+ if (!owns(entry)) return false;
146
+ const content = [`[routine] ${entry.sub.name}`, prompt, webhookEventBlock(event)].join('\n\n');
147
+ const result = acceptEvent(cwd, entry.sub.id, event.eventId, content, now());
148
+ if (result === 'inactive') return false;
149
+ if (result === 'accepted') accepted();
150
+ return true;
151
+ };
152
+ const poll = async (entry: Armed, target: Extract<DurableSubscription['config'], { kind: 'github' }>['target']) => {
153
+ if (entry.polling || !owns(entry)) return;
154
+ entry.polling = true;
155
+ try {
156
+ const after = await fetchPrs(gh, target);
157
+ if (!owns(entry)) return;
158
+ if (entry.baseline !== undefined) {
159
+ const blocks = await pollChanges(gh, target, entry.baseline, after);
160
+ if (!owns(entry)) return;
161
+ if (blocks.length > 0 && acceptEvent(cwd, entry.sub.id, randomUUID(), [`[github] ${entry.sub.name}`, ...blocks].join('\n\n'), now()) === 'accepted') accepted();
162
+ }
163
+ entry.baseline = after;
164
+ clearError(cwd, entry.sub.id);
165
+ } finally {
166
+ entry.polling = false;
167
+ }
168
+ };
169
+ const arm = async (entry: Armed): Promise<string | undefined> => {
170
+ if (entry.sub.ownerSessionId === undefined) throw new Error('Owner session ID unavailable. Recreate this subscription.');
171
+ at(entry, entry.sub.expiresAt, () => {
172
+ expireDesired(cwd, now());
173
+ void reconcile().catch((cause: unknown) => error(entry.sub.id, cause));
174
+ });
175
+ const config = entry.sub.config;
176
+ if (config.kind === 'timer') armTimer(entry, config);
177
+ else if (config.kind === 'webhook') {
178
+ server.routes.set(config.slug, { slug: config.slug, credentialFile: ensureCredentials(dir, config.slug), onEvent: onWebhook(entry, config.prompt) });
179
+ return await server.url(config.slug);
180
+ } else {
181
+ const loop = () => {
182
+ void poll(entry, config.target).then(
183
+ () => {
184
+ if (owns(entry)) at(entry, now() + pollMs, loop);
185
+ },
186
+ (cause: unknown) => {
187
+ error(entry.sub.id, cause);
188
+ if (owns(entry)) at(entry, now() + pollMs, loop);
189
+ },
190
+ );
191
+ };
192
+ loop();
193
+ }
194
+ return undefined;
195
+ };
196
+ const reconcile = (): Promise<void> => {
197
+ if (!active) return Promise.resolve();
198
+ if (reconciling !== undefined) return reconciling;
199
+ reconciling = (async () => {
200
+ const registry = readRegistry(cwd);
201
+ if (registry.subscriptions.some((sub) => sub.lifecycle === 'active' && sub.expiresAt <= now())) expireDesired(cwd, now());
202
+ const desired = registry.subscriptions.filter((sub) => isDesired(sub, now()));
203
+ for (const [id, entry] of armed)
204
+ if (!desired.some((sub) => sub.id === id && sub.revision === entry.sub.revision)) {
205
+ disarm(entry);
206
+ armed.delete(id);
207
+ }
208
+ for (const sub of desired) {
209
+ if (!active) return;
210
+ if (armed.has(sub.id)) continue;
211
+ const entry: Armed = { sub, timers: new Set() };
212
+ armed.set(sub.id, entry);
213
+ try {
214
+ const url = await arm(entry);
215
+ if (!owns(entry) || epoch === undefined) continue;
216
+ clearError(cwd, sub.id);
217
+ writeArm(cwd, { state: 'ready', subscriptionId: sub.id, revision: sub.revision, epoch, ...(url === undefined ? {} : { url }) });
218
+ } catch (cause) {
219
+ disarm(entry);
220
+ armed.delete(sub.id);
221
+ if (!active || epoch === undefined) return;
222
+ const message = cause instanceof Error ? cause.message : String(cause);
223
+ error(sub.id, cause);
224
+ writeArm(cwd, { state: 'error', subscriptionId: sub.id, revision: sub.revision, epoch, message });
225
+ }
226
+ }
227
+ offlineFresh();
228
+ })().finally(() => {
229
+ reconciling = undefined;
230
+ });
231
+ return reconciling;
232
+ };
233
+ const watch = () => {
234
+ if (!active) return;
235
+ watchTimer = setTimeout(() => {
236
+ void reconcile()
237
+ .catch((cause: unknown) => console.error(`Subscription supervisor reconcile failed. ${String(cause)}`))
238
+ .finally(watch);
239
+ }, options.reconcileIntervalMs ?? 250);
240
+ };
241
+ return {
242
+ start: async (): Promise<{ started: boolean; alreadyRunning?: boolean; pid?: number }> => {
243
+ const lock = acquireSupervisorLock(cwd);
244
+ if (!lock.acquired) return { started: false, alreadyRunning: true, pid: lock.pid };
245
+ epoch = readLock(cwd)?.epoch;
246
+ active = true;
247
+ try {
248
+ await reconcile();
249
+ watch();
250
+ return { started: true };
251
+ } catch (cause) {
252
+ active = false;
253
+ releaseSupervisorLock(cwd, process.pid, epoch);
254
+ throw cause;
255
+ }
256
+ },
257
+ reconcile,
258
+ stop: () => {
259
+ active = false;
260
+ if (watchTimer !== undefined) clearTimeout(watchTimer);
261
+ for (const entry of armed.values()) disarm(entry);
262
+ armed.clear();
263
+ server.close();
264
+ if (epoch !== undefined) releaseSupervisorLock(cwd, process.pid, epoch);
265
+ epoch = undefined;
266
+ },
267
+ isListening: () => server.isListening(),
268
+ credentialFile: (slug: string) => credentialFile(dir, slug),
269
+ };
270
+ }
271
+ export type Supervisor = ReturnType<typeof createSupervisor>;
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
4
4
  import type { AddressInfo } from 'node:net';
@@ -83,6 +83,7 @@ function presentedKey(request: IncomingMessage): string | undefined {
83
83
  }
84
84
 
85
85
  export interface WebhookEvent {
86
+ eventId: string;
86
87
  headers: Record<string, string>;
87
88
  bodyDigest: string;
88
89
  body: string;
@@ -99,7 +100,7 @@ export function webhookEventBlock(event: WebhookEvent): string {
99
100
  export interface RoutineRoute {
100
101
  slug: string;
101
102
  credentialFile: string;
102
- onEvent: (event: WebhookEvent) => void;
103
+ onEvent: (event: WebhookEvent) => Promise<boolean>;
103
104
  }
104
105
 
105
106
  const reply = (response: ServerResponse, status: number, message: string) => {
@@ -111,6 +112,7 @@ const reply = (response: ServerResponse, status: number, message: string) => {
111
112
  export class RoutineServer {
112
113
  private server: Server | undefined;
113
114
  private listening: Promise<number> | undefined;
115
+ private readonly closing = new Set<Server>();
114
116
  readonly routes = new Map<string, RoutineRoute>();
115
117
  private readonly port: number;
116
118
 
@@ -144,9 +146,13 @@ export class RoutineServer {
144
146
  return this.server?.listening === true;
145
147
  }
146
148
 
147
- close(): void {
148
- this.server?.close();
149
- this.server?.closeAllConnections();
149
+ close(force = true): void {
150
+ const server = this.server;
151
+ if (server !== undefined) {
152
+ this.closing.add(server);
153
+ server.close(() => this.closing.delete(server));
154
+ }
155
+ if (force) for (const closing of this.closing) closing.closeAllConnections();
150
156
  this.server = undefined;
151
157
  this.listening = undefined;
152
158
  }
@@ -179,13 +185,15 @@ export class RoutineServer {
179
185
  size += chunk.length;
180
186
  if (size > MAX_BODY_BYTES) {
181
187
  done = true;
182
- reply(response, 413, `Body over ${MAX_BODY_BYTES} bytes.`);
188
+ response.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
189
+ response.write(`${JSON.stringify({ error: `Body over ${MAX_BODY_BYTES} bytes.` })}\n`);
190
+ request.once('end', () => response.end());
183
191
  request.resume();
184
192
  return;
185
193
  }
186
194
  chunks.push(chunk);
187
195
  });
188
- request.on('end', () => {
196
+ request.on('end', async () => {
189
197
  if (done) return;
190
198
  done = true;
191
199
  const raw = Buffer.concat(chunks);
@@ -202,8 +210,13 @@ export class RoutineServer {
202
210
  const header = request.headers[name];
203
211
  if (typeof header === 'string') headers[name] = header;
204
212
  }
205
- route.onEvent({ headers, bodyDigest: createHash('sha256').update(raw).digest('hex'), body, timestampMs: Date.now() });
206
- reply(response, 200, 'ok');
213
+ if (this.routes.get(route.slug) !== route) return reply(response, 404, 'No such routine.');
214
+ try {
215
+ const accepted = await route.onEvent({ eventId: randomUUID(), headers, bodyDigest: createHash('sha256').update(raw).digest('hex'), body, timestampMs: Date.now() });
216
+ reply(response, accepted ? 200 : 404, accepted ? 'ok' : 'No such routine.');
217
+ } catch {
218
+ reply(response, 503, 'Could not durably accept this event. Retry later.');
219
+ }
207
220
  });
208
221
  }
209
222
  }
package/src/task/card.ts CHANGED
@@ -30,3 +30,32 @@ export function taskCardText(result: Pick<AgentToolResult<unknown>, 'content' |
30
30
  export function renderTaskCard(result: AgentToolResult<unknown>, options: { expanded: boolean }): Text {
31
31
  return new Text(taskCardText(result, options.expanded), 0, 0);
32
32
  }
33
+
34
+ interface NotificationCardDetails {
35
+ status: 'success' | 'error';
36
+ agentId: string;
37
+ title: string;
38
+ detail: string;
39
+ }
40
+
41
+ function isNotificationCardDetails(details: unknown): details is NotificationCardDetails {
42
+ const value = details as Partial<NotificationCardDetails> | null;
43
+ return typeof value === 'object' && value !== null && (value.status === 'success' || value.status === 'error') && typeof value.agentId === 'string' && typeof value.title === 'string' && typeof value.detail === 'string';
44
+ }
45
+
46
+ export function taskNotificationCardText(message: { content: string | { type: string; text?: string }[]; details?: unknown }, expanded: boolean): string {
47
+ if (!isNotificationCardDetails(message.details)) {
48
+ const raw = typeof message.content === 'string' ? message.content : message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('\n');
49
+ const lines = raw.split('\n');
50
+ return (expanded || lines.length <= COLLAPSED_LINES ? lines : [...lines.slice(0, COLLAPSED_LINES), `… ${lines.length - COLLAPSED_LINES} more lines`]).join('\n');
51
+ }
52
+ const { title, status, agentId, detail } = message.details;
53
+ const lines = detail.split('\n');
54
+ const body = expanded || lines.length <= COLLAPSED_LINES ? lines : [...lines.slice(0, COLLAPSED_LINES), `… ${lines.length - COLLAPSED_LINES} more lines`];
55
+ const head = `${title.trim() === '' ? 'subagent' : title.trim()} · ${status} · agent ${agentId}`;
56
+ return [head, ...body].join('\n');
57
+ }
58
+
59
+ export function renderTaskNotification(message: { content: string | { type: string; text?: string }[]; details?: unknown }, options: { expanded: boolean }): Text {
60
+ return new Text(taskNotificationCardText(message, options.expanded), 0, 0);
61
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { SessionManager } from '@earendil-works/pi-coding-agent';
4
+ import { parseSessionEntries, SessionManager } from '@earendil-works/pi-coding-agent';
5
5
  import type { ModelSpec } from './models.ts';
6
6
  import type { Worktree } from './worktree.ts';
7
7
 
@@ -81,13 +81,8 @@ export function transcriptFor(dir: string, agentId: string): string | undefined
81
81
 
82
82
  /** The working directory a Pi session file's header names, or undefined without a readable header. */
83
83
  function sessionCwd(file: string): string | undefined {
84
- try {
85
- const header = JSON.parse(readFileSync(file, 'utf8').split('\n', 1)[0] ?? '') as { type?: unknown; cwd?: unknown } | null;
86
- return header?.type === 'session' && typeof header.cwd === 'string' ? header.cwd : undefined;
87
- } catch (error) {
88
- if (error instanceof SyntaxError) return undefined;
89
- throw error;
90
- }
84
+ const [header] = parseSessionEntries(readFileSync(file, 'utf8').split('\n', 1)[0] ?? '');
85
+ return header?.type === 'session' && typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : undefined;
91
86
  }
92
87
 
93
88
  /**
@@ -2,8 +2,10 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
+ import { StringDecoder } from 'node:string_decoder';
5
6
 
6
7
  import type { ImageContent, Usage } from '@earendil-works/pi-ai';
8
+ import { detectSupportedImageMimeTypeFromFile } from '@earendil-works/pi-coding-agent';
7
9
  import { PACKAGE_ROOT } from '../paths.ts';
8
10
  import type { ModelSpec } from './models.ts';
9
11
 
@@ -61,15 +63,6 @@ export function resolveAttachments(paths: readonly string[], cwd: string, home:
61
63
  });
62
64
  }
63
65
 
64
- const isBmp = (bytes: Buffer) => bytes.length >= 30 && bytes.toString('latin1', 0, 2) === 'BM' && [12, 40, 52, 56, 108, 124].includes(bytes.readUInt32LE(14));
65
- const IMAGE_TYPES: [string, (bytes: Buffer) => boolean][] = [
66
- ['image/png', (bytes) => bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))],
67
- ['image/jpeg', (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff && bytes[3] !== 0xf7],
68
- ['image/gif', (bytes) => /^GIF8[79]a/.test(bytes.toString('latin1', 0, 6))],
69
- ['image/webp', (bytes) => bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP'],
70
- ['image/bmp', isBmp],
71
- ];
72
-
73
66
  export interface ChildInput {
74
67
  text: string;
75
68
  images: ImageContent[];
@@ -79,14 +72,14 @@ export interface ChildInput {
79
72
  * The first message Pi builds from piped input and `@file` arguments, which RPC mode does not accept: the trimmed
80
73
  * prompt, then each non-empty text file in a <file> block, and each image as an attachment with an empty <file> block.
81
74
  */
82
- export function withAttachments(prompt: string, attachments: readonly string[]): ChildInput {
75
+ export async function withAttachments(prompt: string, attachments: readonly string[]): Promise<ChildInput> {
83
76
  let text = prompt.trim();
84
77
  const images: ImageContent[] = [];
85
78
  for (const path of attachments) {
86
79
  const bytes = readFileSync(path);
87
80
  if (bytes.length === 0) continue;
88
- const mimeType = IMAGE_TYPES.find(([, matches]) => matches(bytes))?.[0];
89
- if (mimeType === undefined) {
81
+ const mimeType = await detectSupportedImageMimeTypeFromFile(path);
82
+ if (mimeType === null) {
90
83
  text += `<file name="${path}">\n${bytes.toString('utf8').replace(/^\uFEFF/, '')}\n</file>\n`;
91
84
  continue;
92
85
  }
@@ -125,7 +118,7 @@ export interface ChildHandle {
125
118
  pid: number | undefined;
126
119
  }
127
120
 
128
- export const zeroUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
121
+ export const zeroUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, totalFromProvider: true } });
129
122
 
130
123
  export function addUsage(left: Usage, right: Usage): Usage {
131
124
  return {
@@ -140,6 +133,7 @@ export function addUsage(left: Usage, right: Usage): Usage {
140
133
  cacheRead: left.cost.cacheRead + right.cost.cacheRead,
141
134
  cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
142
135
  total: left.cost.total + right.cost.total,
136
+ totalFromProvider: left.cost.totalFromProvider === true && right.cost.totalFromProvider === true,
143
137
  },
144
138
  };
145
139
  }
@@ -176,9 +170,10 @@ function readAssistant(event: Record<string, unknown>): AssistantRecord | undefi
176
170
  }
177
171
 
178
172
  function lineReader(onLine: (line: string) => void): (chunk: Buffer) => void {
173
+ const decoder = new StringDecoder('utf8');
179
174
  let buffer = '';
180
175
  return (chunk) => {
181
- buffer += chunk.toString('utf8');
176
+ buffer += decoder.write(chunk);
182
177
  for (let index = buffer.indexOf('\n'); index !== -1; index = buffer.indexOf('\n')) {
183
178
  const line = buffer.slice(0, index).trim();
184
179
  buffer = buffer.slice(index + 1);