@timqi/pier 0.0.17 → 0.0.20
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/dist/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/main.js
CHANGED
|
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { serve } from "@hono/node-server";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { PiConfigStore } from "./agent/config.js";
|
|
7
|
+
import { normalizeAgentSnapshot } from "./agent/config-sync.js";
|
|
8
|
+
import { ConfigSync } from "./config-sync.js";
|
|
9
|
+
import { configSyncTask } from "./config-sync-task.js";
|
|
7
10
|
import { CredentialStore } from "./agent/credentials.js";
|
|
8
11
|
import { PiAgentFactory } from "./agent/pi.js";
|
|
9
12
|
import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
|
|
@@ -35,6 +38,7 @@ import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
|
35
38
|
import { SettingsStore } from "./settings.js";
|
|
36
39
|
import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
37
40
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
41
|
+
import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
|
|
38
42
|
import { PushStore, registerPushRoutes } from "./web/push.js";
|
|
39
43
|
import { SessionStateStore } from "./web/session-state.js";
|
|
40
44
|
import { createServer } from "./web/server.js";
|
|
@@ -86,7 +90,12 @@ let channelStore;
|
|
|
86
90
|
// Shared by the adapter and the tool: a display name is looked up once per
|
|
87
91
|
// process, not once per message and again per transcript.
|
|
88
92
|
const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
|
|
93
|
+
let readyForConfigReload = false;
|
|
89
94
|
const piConfig = new PiConfigStore();
|
|
95
|
+
const configSync = new ConfigSync({
|
|
96
|
+
db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
|
|
97
|
+
reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
|
|
98
|
+
});
|
|
90
99
|
const factory = new PiAgentFactory([
|
|
91
100
|
taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
|
|
92
101
|
slackToolSpec((params, callerSessionId) => handleSlackTool({
|
|
@@ -143,8 +152,9 @@ const router = new Router(hub, (key) => {
|
|
|
143
152
|
const stopEviction = router.startIdleEviction();
|
|
144
153
|
tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
145
154
|
modelMenu: () => settings.get().modelMenu,
|
|
155
|
+
systemActions: { "config-sync": (signal) => configSync.sync(signal) },
|
|
146
156
|
});
|
|
147
|
-
tasks
|
|
157
|
+
const configurationSync = configSyncTask(tasks, configSync);
|
|
148
158
|
// The managed CLI tools (src/tools.ts), and the daily task that keeps them
|
|
149
159
|
// current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
|
|
150
160
|
// wired here because tools.ts may not import tasks/.
|
|
@@ -181,6 +191,19 @@ const reloadInstance = async (includeWatched = false) => {
|
|
|
181
191
|
await channels.reload();
|
|
182
192
|
return router.evictIdle(0, Date.now(), { includeWatched });
|
|
183
193
|
};
|
|
194
|
+
// Bound the startup check before sessions and scheduled work can start.
|
|
195
|
+
// Remote outages keep the last local configuration available.
|
|
196
|
+
if (configSync.status().enabled) {
|
|
197
|
+
try {
|
|
198
|
+
await configSync.sync();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
log.error("Startup configuration sync failed; using the last local configuration");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await configurationSync.reconcile();
|
|
205
|
+
tasks.start();
|
|
206
|
+
readyForConfigReload = true;
|
|
184
207
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
185
208
|
// Replacing Pier is systemd's job, not this process's: the oneshot unit stops
|
|
186
209
|
// the service, snapshots the database, installs and starts it again. Without
|
|
@@ -289,8 +312,15 @@ app.onError((err, c) => {
|
|
|
289
312
|
// so a surface added later is covered without knowing this exists. Built
|
|
290
313
|
// before the listener: a first run generates and prints its password here.
|
|
291
314
|
const auth = new AuthStore(db);
|
|
315
|
+
registerConfigShareRoute(app, configSync);
|
|
292
316
|
app.use("*", requireAuth(auth));
|
|
293
317
|
registerAuthRoutes(app, auth);
|
|
318
|
+
registerConfigSyncRoutes(app, {
|
|
319
|
+
sync: configSync,
|
|
320
|
+
status: () => ({ ...configurationSync.status(), publicUrl: settings.get().publicUrl }),
|
|
321
|
+
reconcile: configurationSync.reconcile,
|
|
322
|
+
run: configurationSync.run,
|
|
323
|
+
});
|
|
294
324
|
registerTaskRoutes(app, tasks, { factory, router });
|
|
295
325
|
registerChannelRoutes(app, channelStore, channels);
|
|
296
326
|
registerBoardRoutes(app);
|
|
@@ -345,6 +375,7 @@ app.route("/", createServer({
|
|
|
345
375
|
onUnlocked: () => void startChannels(),
|
|
346
376
|
reload: () => reloadInstance(true),
|
|
347
377
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
378
|
+
activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
|
|
348
379
|
channelOf: (id) => conversations.channelOf(id),
|
|
349
380
|
}));
|
|
350
381
|
const port = Number(process.env.PORT ?? 3141);
|
package/dist/secrets.js
CHANGED
|
@@ -119,6 +119,15 @@ export class Secrets {
|
|
|
119
119
|
this.#file = next;
|
|
120
120
|
log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* What vt says about itself. A refused or impossible unlock is almost never
|
|
124
|
+
* Pier's fault — missing binary, no agent listening, config pointing
|
|
125
|
+
* elsewhere — and `lockedReason` only carries the last error. Read-only, so
|
|
126
|
+
* it is safe to run while locked; vt's own report is the repair instruction.
|
|
127
|
+
*/
|
|
128
|
+
doctor() {
|
|
129
|
+
return this.vt.doctor();
|
|
130
|
+
}
|
|
122
131
|
/** First boot: random KEK and DEK, file mode. */
|
|
123
132
|
#create() {
|
|
124
133
|
const kek = randomBytes(KEY_BYTES);
|
|
@@ -172,20 +181,23 @@ export const vtCli = {
|
|
|
172
181
|
throw new Error("vt create printed no vt:// record");
|
|
173
182
|
return record;
|
|
174
183
|
},
|
|
184
|
+
// Bounded: doctor probes an agent socket and a worker over the network, and
|
|
185
|
+
// a hung probe would leave the Console waiting on a diagnosis forever.
|
|
186
|
+
doctor: () => run("vt", ["doctor"], undefined, 15_000),
|
|
175
187
|
};
|
|
176
|
-
function run(cmd, args, stdin) {
|
|
188
|
+
function run(cmd, args, stdin, timeoutMs) {
|
|
177
189
|
return new Promise((resolvePromise, reject) => {
|
|
178
|
-
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
190
|
+
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs });
|
|
179
191
|
let out = "";
|
|
180
192
|
let err = "";
|
|
181
193
|
child.stdout.on("data", (d) => (out += d.toString()));
|
|
182
194
|
child.stderr.on("data", (d) => (err += d.toString()));
|
|
183
195
|
child.on("error", reject);
|
|
184
|
-
child.on("close", (code) => {
|
|
196
|
+
child.on("close", (code, signal) => {
|
|
185
197
|
if (code === 0)
|
|
186
|
-
resolvePromise(out.trim());
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
return resolvePromise(out.trim());
|
|
199
|
+
const how = signal ? `timed out after ${timeoutMs}ms (${signal})` : `exited ${code}`;
|
|
200
|
+
reject(new Error(`${cmd} ${args[0]} ${how}: ${err.trim() || out.trim()}`));
|
|
189
201
|
});
|
|
190
202
|
child.stdin.on("error", reject); // EPIPE if vt exits before reading
|
|
191
203
|
if (stdin !== undefined)
|
package/dist/tasks/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A run that *is* a Pi session: which session it opens (
|
|
1
|
+
// A run that *is* a Pi session: which session it opens (fresh or reused),
|
|
2
2
|
// what the child is told before the prompt, and how many may run at once. The
|
|
3
3
|
// concurrency caps are here rather than in execution.ts because they bound
|
|
4
4
|
// agents specifically — a bash run costs a process, an agent run costs a
|
|
@@ -52,7 +52,7 @@ export class AgentTaskRunner {
|
|
|
52
52
|
await this.waitUntilIdle(session, signal);
|
|
53
53
|
// Task requests come seconds apart — the 1h Anthropic cache-write premium
|
|
54
54
|
// never earns its 2× back, so task runs use the 5m TTL. Set here, not in
|
|
55
|
-
// resolveSession: this covers create,
|
|
55
|
+
// resolveSession: this covers create, reuse and resume alike, on
|
|
56
56
|
// every attempt — and only after the session is idle, so a reused
|
|
57
57
|
// interactive session's in-flight turn keeps its 1h writes.
|
|
58
58
|
session.setCacheRetention("short");
|
|
@@ -74,9 +74,17 @@ export class AgentTaskRunner {
|
|
|
74
74
|
run.context.renderedPrompt = prompt;
|
|
75
75
|
this.store.saveRun(run);
|
|
76
76
|
let text = "";
|
|
77
|
+
// Not only what the turn said but how it ended: a provider outage ends
|
|
78
|
+
// it with an empty reply, and a run that settles on that reports "no
|
|
79
|
+
// reply" — the one wording for a turn that *chose* to say nothing, so
|
|
80
|
+
// the outage arrives at the caller looking like an answer (§5b). Pi has
|
|
81
|
+
// already retried by the time this lands; the run only reports.
|
|
82
|
+
let failure;
|
|
77
83
|
const unsubscribe = session.subscribe((event) => {
|
|
78
|
-
if (event.type === "turn-end")
|
|
84
|
+
if (event.type === "turn-end") {
|
|
79
85
|
text = event.text;
|
|
86
|
+
failure = event.error;
|
|
87
|
+
}
|
|
80
88
|
});
|
|
81
89
|
// The race below also settles this attempt if Pi ignores the abort:
|
|
82
90
|
// a hung turn must not hold one of the 4 slots (and its waiters)
|
|
@@ -106,6 +114,11 @@ export class AgentTaskRunner {
|
|
|
106
114
|
await Promise.race([turn, abortedTurn]);
|
|
107
115
|
if (signal.aborted)
|
|
108
116
|
throw new Error("cancelled");
|
|
117
|
+
// Before the fallback below: on a reused session that reads the
|
|
118
|
+
// *previous* turn's answer back out of history and reports it as
|
|
119
|
+
// this run's result.
|
|
120
|
+
if (failure)
|
|
121
|
+
throw new Error(failure);
|
|
109
122
|
if (!text) {
|
|
110
123
|
const history = await session.history();
|
|
111
124
|
text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
|
|
@@ -136,25 +149,16 @@ export class AgentTaskRunner {
|
|
|
136
149
|
if (run.targetSessionId) {
|
|
137
150
|
return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
|
|
138
151
|
}
|
|
139
|
-
const source = run.sourceSessionId
|
|
140
|
-
? await this.factory.find(run.sourceSessionId)
|
|
141
|
-
: undefined;
|
|
142
152
|
const policy = action.session;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
else if (policy.mode === "fresh") {
|
|
150
|
-
cwd = policy.cwd;
|
|
151
|
-
}
|
|
152
|
-
else if (policy.mode === "reuse") {
|
|
153
|
-
cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
cwd = policy.cwd ?? source?.cwd ?? "";
|
|
153
|
+
// Definitions stored before fork was removed still say `"fork"`. Refused by
|
|
154
|
+
// name: every directory this could pick instead is a guess at what the
|
|
155
|
+
// author meant, and a child in the wrong tree edits real files.
|
|
156
|
+
if (run.sessionMode === "fork" || policy.mode === "fork") {
|
|
157
|
+
throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
|
|
157
158
|
}
|
|
159
|
+
const cwd = policy.mode === "fresh"
|
|
160
|
+
? policy.cwd
|
|
161
|
+
: (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
158
162
|
if (!cwd)
|
|
159
163
|
throw new Error("could not resolve child working directory");
|
|
160
164
|
const opts = {
|
|
@@ -166,9 +170,7 @@ export class AgentTaskRunner {
|
|
|
166
170
|
(run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
|
|
167
171
|
thinking: action.launch?.thinking,
|
|
168
172
|
};
|
|
169
|
-
const session =
|
|
170
|
-
? await this.factory.fork(run.sourceSessionId, opts)
|
|
171
|
-
: await this.factory.create(opts);
|
|
173
|
+
const session = await this.factory.create(opts);
|
|
172
174
|
run.targetSessionId = session.id;
|
|
173
175
|
run.context.sessionId = session.id;
|
|
174
176
|
run.context.cwd = cwd;
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -19,7 +19,7 @@ export const runSource = (run) => ({
|
|
|
19
19
|
});
|
|
20
20
|
export function runResultText(run) {
|
|
21
21
|
let result = run.error ?? "No result";
|
|
22
|
-
if (run.result?.type === "agent")
|
|
22
|
+
if (run.result?.type === "agent" || run.result?.type === "system")
|
|
23
23
|
result = run.result.text;
|
|
24
24
|
if (run.result?.type === "bash")
|
|
25
25
|
result = run.result.stdout || run.result.stderr || `exit ${String(run.result.exitCode)}`;
|
|
@@ -127,11 +127,13 @@ export class TaskDefinitions {
|
|
|
127
127
|
factory;
|
|
128
128
|
router;
|
|
129
129
|
hub;
|
|
130
|
-
|
|
130
|
+
systemActions;
|
|
131
|
+
constructor(store, factory, router, hub, systemActions = {}) {
|
|
131
132
|
this.store = store;
|
|
132
133
|
this.factory = factory;
|
|
133
134
|
this.router = router;
|
|
134
135
|
this.hub = hub;
|
|
136
|
+
this.systemActions = systemActions;
|
|
135
137
|
}
|
|
136
138
|
list() { return this.store.listTasks(); }
|
|
137
139
|
get(id) {
|
|
@@ -145,7 +147,7 @@ export class TaskDefinitions {
|
|
|
145
147
|
// means manual. Update keeps requiring it: replacing a cron task with a
|
|
146
148
|
// draft that forgot its trigger must not silently unschedule it.
|
|
147
149
|
const value = record(raw);
|
|
148
|
-
const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw);
|
|
150
|
+
const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw, creator);
|
|
149
151
|
const now = Date.now();
|
|
150
152
|
const task = {
|
|
151
153
|
id: newId(),
|
|
@@ -176,7 +178,7 @@ export class TaskDefinitions {
|
|
|
176
178
|
this.assertOwner(old, by, "edited");
|
|
177
179
|
if (old.archived)
|
|
178
180
|
throw new Error("archived tasks cannot be edited");
|
|
179
|
-
const draft = await this.parseDraft(raw);
|
|
181
|
+
const draft = await this.parseDraft(raw, ownerOf(old) ? by : undefined);
|
|
180
182
|
const now = Date.now();
|
|
181
183
|
const task = {
|
|
182
184
|
...old,
|
|
@@ -228,7 +230,9 @@ export class TaskDefinitions {
|
|
|
228
230
|
}
|
|
229
231
|
claimDue(now) {
|
|
230
232
|
const due = [];
|
|
231
|
-
|
|
233
|
+
// The store narrows to the tasks with a next run at or before `now`; the
|
|
234
|
+
// JSON is still the record, so its own fields decide.
|
|
235
|
+
for (const task of this.store.listDueTasks(now)) {
|
|
232
236
|
if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
|
|
233
237
|
continue;
|
|
234
238
|
task.nextRunAt = nextRunAt(task.trigger, now);
|
|
@@ -250,7 +254,17 @@ export class TaskDefinitions {
|
|
|
250
254
|
throw new Error(`"${task.name}" is Pier's own ${owner} task: it is reconciled by Pier, not ${what}`);
|
|
251
255
|
}
|
|
252
256
|
}
|
|
253
|
-
|
|
257
|
+
/** Checked again at execution: persisted definitions can outlive registration. */
|
|
258
|
+
systemAction(name, owner) {
|
|
259
|
+
if (!owner || owner === "http" || owner.startsWith("session:") || owner !== name) {
|
|
260
|
+
throw new Error(`system action "${name}" requires its trusted owner`);
|
|
261
|
+
}
|
|
262
|
+
if (!Object.hasOwn(this.systemActions, name) || typeof this.systemActions[name] !== "function") {
|
|
263
|
+
throw new Error(`unregistered system action: ${name}`);
|
|
264
|
+
}
|
|
265
|
+
return this.systemActions[name];
|
|
266
|
+
}
|
|
267
|
+
async parseDraft(raw, owner) {
|
|
254
268
|
const value = record(raw);
|
|
255
269
|
if (!value)
|
|
256
270
|
throw new Error("task definition required");
|
|
@@ -277,8 +291,13 @@ export class TaskDefinitions {
|
|
|
277
291
|
this.get(taskId);
|
|
278
292
|
action = { type: "task", taskId };
|
|
279
293
|
}
|
|
294
|
+
else if (actionRaw.type === "system") {
|
|
295
|
+
const name = requiredString(actionRaw.name, "system action name");
|
|
296
|
+
this.systemAction(name, owner);
|
|
297
|
+
action = { type: "system", name };
|
|
298
|
+
}
|
|
280
299
|
else if (actionRaw.type === "agent") {
|
|
281
|
-
action = await this.parseAgentAction(actionRaw
|
|
300
|
+
action = await this.parseAgentAction(actionRaw);
|
|
282
301
|
}
|
|
283
302
|
else
|
|
284
303
|
throw new Error("unknown action type");
|
|
@@ -292,7 +311,7 @@ export class TaskDefinitions {
|
|
|
292
311
|
timeoutSeconds,
|
|
293
312
|
};
|
|
294
313
|
}
|
|
295
|
-
async parseAgentAction(raw
|
|
314
|
+
async parseAgentAction(raw) {
|
|
296
315
|
const prompt = requiredString(raw.prompt, "agent prompt");
|
|
297
316
|
const launch = parseLaunch(raw.launch);
|
|
298
317
|
const input = record(raw.session);
|
|
@@ -308,21 +327,13 @@ export class TaskDefinitions {
|
|
|
308
327
|
await this.assertDirectory(cwd);
|
|
309
328
|
session = { mode: "fresh", cwd };
|
|
310
329
|
}
|
|
311
|
-
else if (input?.mode === "fork") {
|
|
312
|
-
if (trigger.type !== "manual")
|
|
313
|
-
throw new Error("fork Agent tasks must use a manual trigger");
|
|
314
|
-
const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : undefined;
|
|
315
|
-
if (cwd)
|
|
316
|
-
await this.assertDirectory(cwd);
|
|
317
|
-
session = { mode: "fork", ...(cwd ? { cwd } : {}) };
|
|
318
|
-
}
|
|
319
330
|
else {
|
|
320
331
|
// Validation never mutates: a dedicated session is created explicitly
|
|
321
332
|
// (POST /api/sessions) and then referenced with mode:"reuse".
|
|
322
|
-
throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"
|
|
333
|
+
throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"reuse","sessionId":"..."}');
|
|
323
334
|
}
|
|
324
335
|
if (session.mode === "reuse" && launch)
|
|
325
|
-
throw new Error("launch policy only applies to fresh
|
|
336
|
+
throw new Error("launch policy only applies to fresh sessions");
|
|
326
337
|
return { type: "agent", session, prompt, ...(launch ? { launch } : {}) };
|
|
327
338
|
}
|
|
328
339
|
async parseCallback(raw) {
|
package/dist/tasks/execution.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// One queued run carried to a result: dispatched by action kind (a bash
|
|
2
|
-
// script, an agent session, another task
|
|
3
|
-
// exactly once.
|
|
4
|
-
// agent.ts; this file owns
|
|
2
|
+
// script, an agent session, another task or an owned system action), abortable
|
|
3
|
+
// while it goes, settled exactly once. Command and agent work lives in
|
|
4
|
+
// command.ts and agent.ts; this file owns the lifecycle they share.
|
|
5
5
|
import { logger } from "../log.js";
|
|
6
6
|
import { AgentTaskRunner } from "./agent.js";
|
|
7
7
|
import { TaskCallbacks } from "./callbacks.js";
|
|
@@ -125,6 +125,14 @@ export class TaskExecution {
|
|
|
125
125
|
}
|
|
126
126
|
return output;
|
|
127
127
|
}
|
|
128
|
+
if (action.type === "system") {
|
|
129
|
+
this.markRunning(run);
|
|
130
|
+
const handler = this.definitions.systemAction(action.name, run.context.definition.creator);
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
const text = await handler(signal);
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
return { type: "system", text };
|
|
135
|
+
}
|
|
128
136
|
if (action.type === "task") {
|
|
129
137
|
this.markRunning(run);
|
|
130
138
|
const child = this.host.runChild(action.taskId, run);
|
package/dist/tasks/messages.js
CHANGED
|
@@ -84,8 +84,7 @@ export class TaskMessenger {
|
|
|
84
84
|
* `inject` dedupes on the recipient transcript, so a retry cannot double
|
|
85
85
|
* deliver. Controls aimed at a finished run are dead and expire here. */
|
|
86
86
|
retryUndelivered(now = Date.now()) {
|
|
87
|
-
for (const message of this.store.listUndeliveredMessages()) {
|
|
88
|
-
const run = this.store.getRun(message.runId);
|
|
87
|
+
for (const { message, run } of this.store.listUndeliveredMessages()) {
|
|
89
88
|
if (!run)
|
|
90
89
|
continue;
|
|
91
90
|
// Expiring a dead control is not a retry, so it ignores the backoff.
|
|
@@ -96,6 +95,30 @@ export class TaskMessenger {
|
|
|
96
95
|
this.changed(message);
|
|
97
96
|
continue;
|
|
98
97
|
}
|
|
98
|
+
// A reply that resumed a terminal run was never a system input: its text
|
|
99
|
+
// is the continuation's prompt, so the transcript read below would find
|
|
100
|
+
// nothing and re-inject a second copy, starting a turn no run owns. The
|
|
101
|
+
// continuation is the proof instead. `startedAt` is written one statement
|
|
102
|
+
// before the prompt reaches the session (agent.ts), so `delivered` here
|
|
103
|
+
// means "handed to the run that reports for it" — a `systemInput` that
|
|
104
|
+
// then throws fails *that* run, and the failure reaches the replier
|
|
105
|
+
// through its callback.
|
|
106
|
+
if (message.kind === "reply" && isTerminal(run.state)) {
|
|
107
|
+
const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
|
|
108
|
+
if (resumed?.startedAt)
|
|
109
|
+
this.confirmed(message.id);
|
|
110
|
+
else if (!resumed)
|
|
111
|
+
this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
|
|
112
|
+
else if (isTerminal(resumed.state)) {
|
|
113
|
+
// Never started, and never will: say so rather than wait for a
|
|
114
|
+
// ceiling that would only report the same thing four minutes later.
|
|
115
|
+
// Both ends hear it here; the supervisor may also get the
|
|
116
|
+
// continuation's own cancelled callback, and a duplicate beats a
|
|
117
|
+
// special case that could suppress the only report either gets.
|
|
118
|
+
this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
99
122
|
if ((message.nextAttemptAt ?? 0) > now)
|
|
100
123
|
continue;
|
|
101
124
|
const target = message.toSessionId || run.targetSessionId;
|
|
@@ -149,9 +172,11 @@ export class TaskMessenger {
|
|
|
149
172
|
this.deliver(reply, run, run.targetSessionId);
|
|
150
173
|
}
|
|
151
174
|
else if (isTerminal(run.state)) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
175
|
+
// Creating the continuation is not delivering the reply: the run is
|
|
176
|
+
// queued, and a restart or a cancel before it starts loses the prompt
|
|
177
|
+
// that carries the text. So the id is recorded and the sweep below
|
|
178
|
+
// settles it against the continuation's own `startedAt`.
|
|
179
|
+
reply.resumeRunId = this.resumeRun(run.id, this.format(reply, run), fromSessionId).id;
|
|
155
180
|
this.store.saveMessage(reply);
|
|
156
181
|
this.changed(reply);
|
|
157
182
|
}
|
|
@@ -209,6 +234,17 @@ export class TaskMessenger {
|
|
|
209
234
|
this.store.saveMessage(message);
|
|
210
235
|
this.changed(message);
|
|
211
236
|
}
|
|
237
|
+
/** Waiting is not a failed attempt: the message is in flight or the turn it
|
|
238
|
+
* has to wait for is still running, so it is tried again shortly and the
|
|
239
|
+
* ceiling is left for the deliveries that actually failed (the rule
|
|
240
|
+
* outbox.ts keeps for callbacks). */
|
|
241
|
+
defer(id) {
|
|
242
|
+
const message = this.store.getMessage(id);
|
|
243
|
+
if (!message || message.state !== "pending")
|
|
244
|
+
return;
|
|
245
|
+
message.nextAttemptAt = Date.now() + 1000;
|
|
246
|
+
this.store.saveMessage(message);
|
|
247
|
+
}
|
|
212
248
|
/** Counts one hand-off and says whether it may happen: a recipient that
|
|
213
249
|
* never records the message must not be re-sent once a second forever.
|
|
214
250
|
* False means the ceiling was reached and the message is now expired. */
|
|
@@ -248,10 +284,12 @@ export class TaskMessenger {
|
|
|
248
284
|
this.store.saveRun(run);
|
|
249
285
|
}
|
|
250
286
|
}
|
|
251
|
-
/**
|
|
252
|
-
*
|
|
287
|
+
/** Steer whatever someone is blocked on: a decision, and the reply that
|
|
288
|
+
* answers it — a follow-up lands only once the recipient runs out of tool
|
|
289
|
+
* calls, so each would wait out a whole turn (the reply waited 3 minutes
|
|
290
|
+
* behind one on 2026-09-07). Progress is a follow-up because nobody waits. */
|
|
253
291
|
mode(message) {
|
|
254
|
-
return message.kind === "
|
|
292
|
+
return message.kind === "follow_up" || message.kind === "progress" ? "follow_up" : "steer";
|
|
255
293
|
}
|
|
256
294
|
failed(id, error, spent) {
|
|
257
295
|
const message = this.store.getMessage(id);
|
|
@@ -286,14 +324,13 @@ export class TaskMessenger {
|
|
|
286
324
|
const recorded = async () => (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
|
|
287
325
|
if (await recorded())
|
|
288
326
|
return this.confirmed(message.id);
|
|
289
|
-
// Accepted and waiting in
|
|
290
|
-
// not recorded yet, and sending again would deliver the same
|
|
291
|
-
// Unlike a callback this is not deferred
|
|
292
|
-
//
|
|
293
|
-
// waiting for it
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return;
|
|
327
|
+
// Accepted and waiting in the recipient's queue for the running turn to
|
|
328
|
+
// drain it: not recorded yet, and sending again would deliver the same
|
|
329
|
+
// guidance twice. Unlike a callback this is not deferred on a busy
|
|
330
|
+
// target — a steer's whole point is to reach the turn already running —
|
|
331
|
+
// so the queue is where it sits, and waiting for it costs no attempt.
|
|
332
|
+
if (await this.queued(session, message.id))
|
|
333
|
+
return this.defer(message.id);
|
|
297
334
|
spent = this.spend(message.id);
|
|
298
335
|
if (!spent)
|
|
299
336
|
return;
|
|
@@ -305,6 +342,11 @@ export class TaskMessenger {
|
|
|
305
342
|
this.failed(message.id, error, spent);
|
|
306
343
|
}
|
|
307
344
|
}
|
|
345
|
+
/** In the recipient's queue, by the id its origin carries — the transcript
|
|
346
|
+
* read above answers "landed", this answers "handed over and waiting". */
|
|
347
|
+
async queued(session, id) {
|
|
348
|
+
return (await session.pendingSystemInputs()).some((origin) => origin.kind === "task-message" && origin.messageId === id);
|
|
349
|
+
}
|
|
308
350
|
format(message, run) {
|
|
309
351
|
const title = run.context.definition.name;
|
|
310
352
|
if (message.kind === "progress") {
|
package/dist/tasks/routes.js
CHANGED
|
@@ -3,6 +3,47 @@
|
|
|
3
3
|
// its body, names the caller and hands the decision to TaskService — policy
|
|
4
4
|
// that lives here would be policy the task tool does not get.
|
|
5
5
|
import { record, requiredString } from "./definitions.js";
|
|
6
|
+
/** Reject malformed filters instead of silently widening a global query. */
|
|
7
|
+
function runQuery(params) {
|
|
8
|
+
const query = {};
|
|
9
|
+
const states = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
|
|
10
|
+
const sources = ["manual", "cron", "watch", "agent", "task"];
|
|
11
|
+
if (params.state) {
|
|
12
|
+
if (!states.includes(params.state))
|
|
13
|
+
throw new Error("invalid state");
|
|
14
|
+
query.state = params.state;
|
|
15
|
+
}
|
|
16
|
+
if (params.source) {
|
|
17
|
+
if (!sources.includes(params.source))
|
|
18
|
+
throw new Error("invalid source");
|
|
19
|
+
query.source = params.source;
|
|
20
|
+
}
|
|
21
|
+
if (params.taskId)
|
|
22
|
+
query.taskId = params.taskId;
|
|
23
|
+
for (const key of ["since", "until", "limit"]) {
|
|
24
|
+
if (params[key] === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
const value = Number(params[key]);
|
|
27
|
+
if (!params[key] || !Number.isSafeInteger(value) || value < 0 || (key === "limit" && (value < 1 || value > 200)))
|
|
28
|
+
throw new Error(`invalid ${key}`);
|
|
29
|
+
query[key] = value;
|
|
30
|
+
}
|
|
31
|
+
if (query.since !== undefined && query.until !== undefined && query.since > query.until)
|
|
32
|
+
throw new Error("since exceeds until");
|
|
33
|
+
if (params.showUnmatched !== undefined) {
|
|
34
|
+
if (!["true", "false"].includes(params.showUnmatched))
|
|
35
|
+
throw new Error("invalid showUnmatched");
|
|
36
|
+
query.showUnmatched = params.showUnmatched === "true";
|
|
37
|
+
}
|
|
38
|
+
if (params.cursor) {
|
|
39
|
+
const cursor = JSON.parse(params.cursor);
|
|
40
|
+
const value = record(cursor);
|
|
41
|
+
if (!value || !Number.isSafeInteger(value.queuedAt) || Number(value.queuedAt) < 0 || typeof value.id !== "string" || !value.id)
|
|
42
|
+
throw new Error("invalid cursor");
|
|
43
|
+
query.cursor = { queuedAt: Number(value.queuedAt), id: value.id };
|
|
44
|
+
}
|
|
45
|
+
return query;
|
|
46
|
+
}
|
|
6
47
|
const jsonBody = async (req) => req.json().catch(() => null);
|
|
7
48
|
export function registerTaskRoutes(app, tasks, activity) {
|
|
8
49
|
if (activity)
|
|
@@ -50,13 +91,21 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
50
91
|
messages,
|
|
51
92
|
});
|
|
52
93
|
});
|
|
94
|
+
app.get("/api/task-runs", (c) => {
|
|
95
|
+
try {
|
|
96
|
+
return c.json(tasks.queryRuns(runQuery(c.req.query())));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return c.json({ error: String(err) }, 400);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
53
102
|
app.get("/api/tasks", (c) => {
|
|
54
103
|
const trigger = c.req.query("trigger");
|
|
55
104
|
const state = c.req.query("state");
|
|
56
105
|
const kind = c.req.query("kind");
|
|
57
106
|
let rows = tasks.list();
|
|
58
107
|
// Subagent one-shots are hidden unless explicitly requested via ?kind=subagent.
|
|
59
|
-
rows =
|
|
108
|
+
rows = rows.filter((task) => task.kind === (kind ?? "task"));
|
|
60
109
|
if (trigger)
|
|
61
110
|
rows = rows.filter((task) => task.trigger.type === trigger);
|
|
62
111
|
if (state === "archived")
|
|
@@ -93,10 +142,7 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
93
142
|
});
|
|
94
143
|
app.get("/api/task-runs/:id", (c) => {
|
|
95
144
|
try {
|
|
96
|
-
|
|
97
|
-
// Same view as the task tool: a pending decision is the one run fact that
|
|
98
|
-
// lives on the messages, and HTTP callers need it too.
|
|
99
|
-
return c.json({ ...run, pendingDecisionId: tasks.openDecisionId(run.id) });
|
|
145
|
+
return c.json(tasks.getRunView(c.req.param("id")));
|
|
100
146
|
}
|
|
101
147
|
catch (err) {
|
|
102
148
|
return c.json({ error: String(err) }, 404);
|
|
@@ -181,13 +227,15 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
181
227
|
const body = record(await jsonBody(c.req));
|
|
182
228
|
const input = body && "input" in body ? body.input : null;
|
|
183
229
|
try {
|
|
184
|
-
|
|
230
|
+
// A caller that names a mode gets an answer about it: dropping an
|
|
231
|
+
// unsupported override would run the definition's own policy instead —
|
|
232
|
+
// on a reuse definition, work injected into a live session.
|
|
233
|
+
if (body?.sessionMode !== undefined && body.sessionMode !== "fresh") {
|
|
234
|
+
throw new Error(`unsupported sessionMode: ${String(body.sessionMode)}`);
|
|
235
|
+
}
|
|
236
|
+
const sessionMode = body?.sessionMode;
|
|
185
237
|
const sourceSessionId = typeof body?.sourceSessionId === "string" && body.sourceSessionId ? body.sourceSessionId : null;
|
|
186
238
|
const task = tasks.get(c.req.param("id"));
|
|
187
|
-
const effectiveMode = task.action.type === "agent" ? sessionMode ?? task.action.session.mode : null;
|
|
188
|
-
if (effectiveMode === "fork" && (!sourceSessionId || !(await tasks.sessionExists(sourceSessionId)))) {
|
|
189
|
-
throw new Error("fork requires a known sourceSessionId");
|
|
190
|
-
}
|
|
191
239
|
return c.json({ runId: tasks.run(task.id, input, "manual", null, {
|
|
192
240
|
invokedBySessionId: sourceSessionId,
|
|
193
241
|
sourceSessionId,
|
package/dist/tasks/runs.js
CHANGED
|
@@ -38,11 +38,6 @@ export class TaskRunQueue {
|
|
|
38
38
|
const sessionMode = definition.action.type === "agent"
|
|
39
39
|
? provenance.sessionMode ?? definition.action.session.mode
|
|
40
40
|
: null;
|
|
41
|
-
if (sessionMode === "fork" && !sourceSessionId)
|
|
42
|
-
throw new Error("fork requires a source session");
|
|
43
|
-
if (sessionMode === "fork" && (source === "cron" || source === "watch")) {
|
|
44
|
-
throw new Error("scheduled and watch runs cannot fork a caller session");
|
|
45
|
-
}
|
|
46
41
|
const targetSessionId = provenance.targetSessionId ?? (definition.action.type === "agent" && sessionMode === "reuse" && definition.action.session.mode === "reuse"
|
|
47
42
|
? definition.action.session.sessionId
|
|
48
43
|
: null);
|