@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.
- package/CHANGELOG.md +39 -0
- package/README.md +15 -12
- package/agents/ci-watcher.md +27 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +5 -5
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +11 -5
- package/docs/parity-reverse-engineering.md +94 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
- package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +1 -1
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +1 -1
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/mode.ts +3 -3
- package/src/paths.ts +13 -0
- package/src/pi-command.ts +12 -0
- package/src/rule.ts +4 -4
- package/src/sections.ts +2 -1
- package/src/subscriptions/durable-store.ts +482 -0
- package/src/subscriptions/durable-types.ts +41 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +231 -271
- package/src/subscriptions/supervisor-main.ts +88 -0
- package/src/subscriptions/supervisor.ts +271 -0
- package/src/subscriptions/webhook.ts +22 -9
- package/src/task/card.ts +29 -0
- package/src/task/records.ts +3 -8
- package/src/task/runner.ts +9 -14
- package/src/task/tool.ts +18 -21
- package/src/todo.ts +1 -2
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
|
@@ -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) =>
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
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.
|
|
206
|
-
|
|
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
|
+
}
|
package/src/task/records.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|
/**
|
package/src/task/runner.ts
CHANGED
|
@@ -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 =
|
|
89
|
-
if (mimeType ===
|
|
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 +=
|
|
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);
|