@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
|
@@ -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 {
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
171
|
-
let
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
238
|
-
if (
|
|
239
|
-
|
|
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
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
363
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
throw
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 =
|
|
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((
|
|
402
|
-
const item = view(
|
|
403
|
-
const
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
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
|
|