@timqi/pier 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +87 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack-tool.js +28 -3
  14. package/dist/channels/slack.js +20 -9
  15. package/dist/channels/telegram-api.js +3 -4
  16. package/dist/channels/telegram.js +37 -28
  17. package/dist/cli.js +177 -29
  18. package/dist/core/hub.js +36 -5
  19. package/dist/core/identity.js +5 -0
  20. package/dist/core/inbound-file.js +70 -0
  21. package/dist/core/inbox.js +32 -0
  22. package/dist/core/queue.js +9 -3
  23. package/dist/core/reply.js +20 -5
  24. package/dist/core/router.js +200 -14
  25. package/dist/core/types.js +53 -0
  26. package/dist/db.js +54 -8
  27. package/dist/drain.js +145 -0
  28. package/dist/main.js +180 -18
  29. package/dist/secrets.js +10 -6
  30. package/dist/service.js +192 -18
  31. package/dist/settings.js +77 -8
  32. package/dist/tasks/agent.js +41 -5
  33. package/dist/tasks/callbacks.js +29 -89
  34. package/dist/tasks/definitions.js +2 -6
  35. package/dist/tasks/execution.js +10 -1
  36. package/dist/tasks/groups.js +20 -49
  37. package/dist/tasks/messages.js +106 -21
  38. package/dist/tasks/outbox.js +157 -0
  39. package/dist/tasks/routes.js +6 -4
  40. package/dist/tasks/service.js +92 -22
  41. package/dist/tasks/store.js +48 -55
  42. package/dist/tasks/tool.js +19 -4
  43. package/dist/tasks/types.js +7 -0
  44. package/dist/update.js +146 -0
  45. package/dist/web/auth.js +89 -26
  46. package/dist/web/explorer.js +147 -0
  47. package/dist/web/files.js +28 -12
  48. package/dist/web/instance.js +165 -0
  49. package/dist/web/provider-flows.js +249 -0
  50. package/dist/web/providers.js +141 -0
  51. package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
  52. package/dist/web/public/assets/index-fASxMPr6.js +90 -0
  53. package/dist/web/public/icon-192.png +0 -0
  54. package/dist/web/public/icon-32.png +0 -0
  55. package/dist/web/public/icon-512.png +0 -0
  56. package/dist/web/public/icon-maskable-512.png +0 -0
  57. package/dist/web/public/icon-touch-192.png +0 -0
  58. package/dist/web/public/icon.svg +29 -11
  59. package/dist/web/public/index.html +50 -32
  60. package/dist/web/server.js +110 -120
  61. package/docs/deploy.md +142 -64
  62. package/package.json +1 -1
  63. package/skills/pier-boards/SKILL.md +16 -7
  64. package/skills/pier-help/SKILL.md +110 -0
  65. package/skills/pier-slack/SKILL.md +20 -3
  66. package/skills/pier-tasks/SKILL.md +19 -12
  67. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  68. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  69. package/dist/web/public/sw.js +0 -21
package/dist/service.js CHANGED
@@ -11,16 +11,33 @@
11
11
  // installed by fnm/nvm/asdf is not on it) and the absolute path of the
12
12
  // installed entry point.
13
13
  import { execFileSync } from "node:child_process";
14
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { homedir, userInfo } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  export const UNIT_NAME = "pier.service";
18
+ export const UPDATE_UNIT_NAME = "pier-update.service";
18
19
  /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
19
20
  export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
20
21
  /** The drop-in Pier writes once and never touches again: what the unit may
21
22
  * consume is the operator's call, not ours. */
22
23
  export const limitsPath = (home = homedir()) => join(dirname(unitPath(home)), `${UNIT_NAME}.d`, "limits.conf");
23
- export function renderUnit({ execPath, entry, host, port, pierHome }) {
24
+ /** The oneshot that installs a new version, beside the unit it restarts. */
25
+ export const updateUnitPath = (home = homedir()) => join(dirname(unitPath(home)), UPDATE_UNIT_NAME);
26
+ /** Runtime-only effective state for the updater, regenerated before each run. */
27
+ export const updateRuntimePath = (home = homedir()) => join(dirname(unitPath(home)), `${UPDATE_UNIT_NAME}.d`, "runtime.conf");
28
+ /** Quote one systemd word. Percent is doubled because specifier expansion runs
29
+ * after parsing; dollar is doubled only for command lines. */
30
+ function quote(value, command = false) {
31
+ if (/[\0\r\n]/.test(value))
32
+ throw new Error("systemd values cannot contain control characters");
33
+ let escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%");
34
+ if (command)
35
+ escaped = escaped.replaceAll("$", () => "$$");
36
+ return `"${escaped}"`;
37
+ }
38
+ const environment = (key, value) => `Environment=${quote(`${key}=${value}`)}`;
39
+ export function renderUnit(options) {
40
+ const { execPath, entry, host, port, pierHome } = options;
24
41
  return `[Unit]
25
42
  Description=Pier — agent workspace
26
43
  Documentation=https://github.com/timqi/pier
@@ -32,13 +49,13 @@ Type=simple
32
49
  WorkingDirectory=%h
33
50
  # Absolute paths on purpose: systemd starts with a minimal PATH, and the node
34
51
  # that installed Pier is usually not on it.
35
- ExecStart=${execPath} ${entry}
36
- Environment=NODE_ENV=production
52
+ ExecStart=${quote(execPath, true)} ${quote(entry, true)}
53
+ ${environment("NODE_ENV", "production")}
37
54
  # Loopback by default. Put a reverse proxy in front before widening this —
38
55
  # whoever reaches this port can drive an agent that runs a shell.
39
- Environment=HOST=${host}
40
- Environment=PORT=${port}
41
- ${pierHome ? `# Where the database, the boards and the password hash live.\nEnvironment=PIER_HOME=${pierHome}\n` : ""}Restart=always
56
+ ${environment("HOST", host)}
57
+ ${environment("PORT", String(port))}
58
+ ${pierHome ? `# Where the database, the boards and the password hash live.\n${environment("PIER_HOME", pierHome)}\n` : ""}Restart=always
42
59
  RestartSec=2
43
60
  # The journal is where the first-run password is printed, so keep it readable.
44
61
  StandardOutput=journal
@@ -50,6 +67,46 @@ SyslogIdentifier=pier
50
67
  WantedBy=default.target
51
68
  `;
52
69
  }
70
+ /** The 0.0.1 unit did not record npm. This one-time bridge can only use npm
71
+ * beside its recorded Node; a forced reinstall writes the exact executable. */
72
+ function legacyOptions(home) {
73
+ const text = readFileSync(unitPath(home), "utf8");
74
+ const start = text.match(/^ExecStart=(\S+) (\S+)$/m);
75
+ const host = text.match(/^Environment=HOST=(\S+)$/m)?.[1];
76
+ const rawPort = text.match(/^Environment=PORT=(\d+)$/m)?.[1];
77
+ const pierHome = text.match(/^Environment=PIER_HOME=(\S+)$/m)?.[1];
78
+ if (!start?.[1] || !start[2] || !host || !rawPort)
79
+ throw new Error("unit is not the 0.0.1 Pier shape");
80
+ return {
81
+ execPath: start[1],
82
+ npmPath: join(dirname(start[1]), "npm"),
83
+ entry: start[2],
84
+ host,
85
+ port: Number(rawPort),
86
+ ...(pierHome ? { pierHome } : {}),
87
+ };
88
+ }
89
+ /** A separate cgroup stops Pier, takes a consistent backup, updates the exact
90
+ * npm installation recorded at install time, and always starts Pier again. */
91
+ export function renderUpdateUnit(options) {
92
+ const { execPath, npmPath, entry, pierHome } = options;
93
+ const cli = join(dirname(entry), "cli.js");
94
+ return `[Unit]
95
+ Description=Update Pier to the latest published version
96
+ Documentation=https://github.com/timqi/pier
97
+
98
+ [Service]
99
+ Type=oneshot
100
+ ${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=systemctl --user stop ${UNIT_NAME}
101
+ ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
102
+ # npm runs under the recorded node: its shebang needs a node on PATH, and
103
+ # systemd's minimal PATH has none for fnm/nvm installs.
104
+ ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
105
+ # ExecStopPost runs on success and failure, so a failed backup or npm install
106
+ # does not leave the previously working service stopped.
107
+ ExecStopPost=systemctl --user start ${UNIT_NAME}
108
+ `;
109
+ }
53
110
  /**
54
111
  * Sized as a share of the machine, not as "how much should Pier need": the
55
112
  * limit covers node, every subagent and every command a turn ran, and it
@@ -74,8 +131,8 @@ OOMScoreAdjust=200
74
131
  OOMPolicy=continue
75
132
  `;
76
133
  }
77
- /** Best-effort: a step that fails says so and the rest still runs, because a
78
- * half-installed service the operator knows about beats a stack trace. */
134
+ /** A command failure is printed here and propagated by its caller. Linger is
135
+ * the only deliberately best-effort step. */
79
136
  const runner = (say) => (argv) => {
80
137
  try {
81
138
  execFileSync(argv[0], argv.slice(1), { stdio: "pipe" });
@@ -91,13 +148,16 @@ export function install(options) {
91
148
  const { force, home = homedir(), say, exec, ...unit } = options;
92
149
  const run = exec ?? runner(say);
93
150
  const path = unitPath(home);
94
- if (existsSync(path) && !force) {
151
+ const replacing = existsSync(path);
152
+ if (replacing && !force) {
95
153
  say(`${path} already exists — nothing written. Re-run with --force to replace it.`);
96
- return;
154
+ return true;
97
155
  }
98
156
  mkdirSync(dirname(path), { recursive: true });
99
157
  writeFileSync(path, renderUnit(unit), { mode: 0o644 });
158
+ writeFileSync(updateUnitPath(home), renderUpdateUnit(unit), { mode: 0o644 });
100
159
  say(`wrote ${path}`);
160
+ say(`wrote ${updateUnitPath(home)}`);
101
161
  const limits = limitsPath(home);
102
162
  if (existsSync(limits)) {
103
163
  say(`kept ${limits}`); // tuned by hand, by definition
@@ -107,28 +167,142 @@ export function install(options) {
107
167
  writeFileSync(limits, renderLimits(), { mode: 0o644 });
108
168
  say(`wrote ${limits}`);
109
169
  }
110
- run(["systemctl", "--user", "daemon-reload"]);
170
+ if (!run(["systemctl", "--user", "daemon-reload"]))
171
+ return false;
111
172
  // Without lingering, the user manager stops at logout and takes every
112
173
  // scheduled task with it. It can need a polkit prompt, hence best-effort.
113
174
  if (!run(["loginctl", "enable-linger", userInfo().username])) {
114
175
  say(` run it yourself so Pier survives logout: loginctl enable-linger ${userInfo().username}`);
115
176
  }
116
- if (run(["systemctl", "--user", "enable", "--now", UNIT_NAME])) {
117
- say(`started. The first run prints a password once: journalctl --user -u pier -e`);
177
+ if (replacing) {
178
+ if (!run(["systemctl", "--user", "enable", UNIT_NAME]))
179
+ return false;
180
+ if (!run(["systemctl", "--user", "restart", UNIT_NAME]))
181
+ return false;
118
182
  }
183
+ else if (!run(["systemctl", "--user", "enable", "--now", UNIT_NAME]))
184
+ return false;
185
+ say(`started. The first run prints a password once: journalctl --user -u pier -e`);
186
+ return true;
187
+ }
188
+ /**
189
+ * Why the installed updater could not do its job, or `null` when nothing is
190
+ * wrong. Checked while Pier is still alive, because the alternative is finding
191
+ * out at the next restart, from a service that no longer starts.
192
+ *
193
+ * The absolute node and npm paths in the unit are deliberate — systemd's PATH
194
+ * has neither — but they pin the unit to one directory of one version manager.
195
+ * `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
196
+ * gone; the running process survives (Linux keeps a deleted binary mapped),
197
+ * so nothing would notice until the update, or the next boot, failed.
198
+ */
199
+ export function updaterProblem(home = homedir()) {
200
+ if (!existsSync(unitPath(home)))
201
+ return null; // not a service install; nothing to check
202
+ const path = updateUnitPath(home);
203
+ let unit;
204
+ if (existsSync(path)) {
205
+ try {
206
+ unit = readFileSync(path, "utf8");
207
+ }
208
+ catch (err) {
209
+ return `${path} cannot be read: ${String(err)}`;
210
+ }
211
+ }
212
+ else {
213
+ // A 0.0.1 install: startUpdate generates the updater from the main unit,
214
+ // so a missing file is only a problem when that bridge cannot either —
215
+ // and the generated text gets the same executable check below.
216
+ try {
217
+ unit = renderUpdateUnit(legacyOptions(home));
218
+ }
219
+ catch {
220
+ return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
221
+ }
222
+ }
223
+ // The one line that names both executables, quoted and escaped by quote().
224
+ // Unparseable means hand-edited, which is not this function's business to
225
+ // judge; the escaping is undone before existsSync sees a path (a `%` or `$`
226
+ // in it would otherwise read as gone on a working updater).
227
+ const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
228
+ if (!install)
229
+ return null;
230
+ const unescape = (word) => word.replaceAll("$$", "$").replaceAll("%%", "%").replace(/\\(.)/g, "$1");
231
+ for (const [what, bin] of [["node", unescape(install[1])], ["npm", unescape(install[2])]]) {
232
+ if (!existsSync(bin)) {
233
+ return `the ${what} the updater would use is gone (${bin}) — a version manager removed it; run: pier service install --force`;
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+ function runningPierHome(home) {
239
+ const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
240
+ if (Number.isInteger(pid) && pid >= 1) {
241
+ const value = readFileSync(`/proc/${pid}/environ`)
242
+ .toString()
243
+ .split("\0")
244
+ .find((item) => item.startsWith("PIER_HOME="))
245
+ ?.slice("PIER_HOME=".length);
246
+ return value || join(home, ".pier");
247
+ }
248
+ // Installed but stopped: the unit file records any override (quoted and
249
+ // escaped since 0.0.2, bare in the 0.0.1 shape) — undo quote()'s escaping
250
+ // or the drop-in would carry `%%`/`\\"` into a real path.
251
+ const raw = readFileSync(unitPath(home), "utf8")
252
+ .match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
253
+ const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
254
+ return fromUnit || join(home, ".pier");
255
+ }
256
+ /** Start the updater recorded at install time. Its tiny drop-in captures the
257
+ * running service's effective home, including an operator override. */
258
+ export function startUpdate(options) {
259
+ const { home = homedir(), say } = options;
260
+ const run = options.exec ?? runner(say);
261
+ if (!existsSync(unitPath(home)))
262
+ return "not-installed";
263
+ try {
264
+ if (!existsSync(updateUnitPath(home))) {
265
+ writeFileSync(updateUnitPath(home), renderUpdateUnit(legacyOptions(home)), { mode: 0o644 });
266
+ say(`generated a legacy updater; run "pier service install --force" after this update to record the exact npm path.`);
267
+ }
268
+ const runtime = updateRuntimePath(home);
269
+ mkdirSync(dirname(runtime), { recursive: true });
270
+ writeFileSync(runtime, `[Service]\n${environment("PIER_HOME", (options.effectiveHome ?? (() => runningPierHome(home)))())}\n`, { mode: 0o644 });
271
+ }
272
+ catch (err) {
273
+ say(`! cannot prepare update — ${String(err)}`);
274
+ return "failed";
275
+ }
276
+ if (!run(["systemctl", "--user", "daemon-reload"]))
277
+ return "failed";
278
+ if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
279
+ return "failed";
280
+ say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
281
+ say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
282
+ return "started";
119
283
  }
120
284
  export function uninstall(home = homedir(), say = console.log, exec) {
121
285
  const run = exec ?? runner(say);
122
- run(["systemctl", "--user", "disable", "--now", UNIT_NAME]);
123
- for (const path of [unitPath(home), limitsPath(home)]) {
286
+ if (!run(["systemctl", "--user", "disable", "--now", UNIT_NAME]))
287
+ return false;
288
+ for (const path of [unitPath(home), limitsPath(home), updateUnitPath(home), updateRuntimePath(home)]) {
124
289
  if (!existsSync(path))
125
290
  continue;
126
291
  rmSync(path, { force: true });
127
292
  say(`removed ${path}`);
128
293
  }
129
- rmSync(dirname(limitsPath(home)), { force: true, recursive: true });
130
- run(["systemctl", "--user", "daemon-reload"]);
294
+ for (const dir of [dirname(limitsPath(home)), dirname(updateRuntimePath(home))]) {
295
+ try {
296
+ rmdirSync(dir); // non-recursive: drop-ins the operator added are not ours to delete
297
+ }
298
+ catch {
299
+ if (existsSync(dir))
300
+ say(`kept ${dir} — it has files Pier did not write`);
301
+ }
302
+ }
303
+ const ok = run(["systemctl", "--user", "daemon-reload"]);
131
304
  // Left alone on purpose: the database, the boards, and linger — none of them
132
305
  // are this command's to decide about.
133
306
  say(`$PIER_HOME is untouched; linger is still enabled.`);
307
+ return ok;
134
308
  }
package/dist/settings.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Instance settings: the facts about *this* Pier that are neither a credential
2
- // nor per-session. Today there is one — the URL it is reached at from outside,
3
- // which nothing in the process can discover for itself: a request's Host header
4
- // is whatever a proxy chose to pass on, and an agent writing a board has no
5
- // request at all.
2
+ // nor per-session — the public URL (nothing in the process can discover it:
3
+ // a Host header is whatever a proxy passed on) and the operator's model menu.
6
4
  //
7
5
  // A key-value table, so the next setting is not the next table and not a third
8
6
  // kind of storage. It used to be a JSON file, justified by "the agent reads it
9
7
  // too" — the agent is told the URL in its system prompt (core/reply.ts), and
10
8
  // nothing outside this process ever opened that file.
9
+ import { isThinkingLevel } from "./core/types.js";
11
10
  import { pierDb } from "./db.js";
11
+ import { logger } from "./log.js";
12
+ const log = logger("settings");
12
13
  /**
13
14
  * `""` clears it, `null` rejects it. Rejecting rather than repairing: a
14
15
  * mistyped host quietly turned into a URL produces board links that 404 for
@@ -33,22 +34,90 @@ export function normalizePublicUrl(raw) {
33
34
  return null;
34
35
  return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
35
36
  }
37
+ /**
38
+ * Boundary check for a menu, rejecting rather than repairing (same contract as
39
+ * `normalizePublicUrl`): a silently "fixed" entry would advertise a model the
40
+ * operator never picked. Notes are capped — they are one line of intent, and
41
+ * every session that asks for the menu pays for their tokens.
42
+ */
43
+ export function normalizeModelMenu(raw) {
44
+ if (!Array.isArray(raw) || raw.length > 32)
45
+ return null;
46
+ const menu = [];
47
+ for (const item of raw) {
48
+ if (typeof item !== "object" || item === null)
49
+ return null;
50
+ const { provider, id, thinking, note } = item;
51
+ if (typeof provider !== "string" || !provider.trim())
52
+ return null;
53
+ if (typeof id !== "string" || !id.trim())
54
+ return null;
55
+ if (thinking !== undefined && !isThinkingLevel(thinking))
56
+ return null;
57
+ if (note !== undefined && typeof note !== "string")
58
+ return null;
59
+ const cleaned = note?.trim().slice(0, 200);
60
+ menu.push({
61
+ provider: provider.trim(),
62
+ id: id.trim(),
63
+ ...(thinking !== undefined ? { thinking } : {}),
64
+ ...(cleaned ? { note: cleaned } : {}),
65
+ });
66
+ }
67
+ return menu;
68
+ }
36
69
  export class SettingsStore {
37
70
  #db;
38
71
  constructor(db = pierDb()) {
39
72
  this.#db = db;
40
73
  }
41
74
  get() {
42
- return { publicUrl: this.#value("publicUrl") ?? "" };
75
+ return {
76
+ publicUrl: this.#value("publicUrl") ?? "",
77
+ modelMenu: this.#menu(),
78
+ autoUpdate: this.#value("autoUpdate") === "1",
79
+ };
80
+ }
81
+ #menu() {
82
+ const raw = this.#value("modelMenu");
83
+ if (!raw)
84
+ return [];
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(raw);
88
+ }
89
+ catch {
90
+ // Only a hand-edited row can get here; named, not silently served as [].
91
+ log.warn("settings.modelMenu is not JSON — ignoring it");
92
+ return [];
93
+ }
94
+ const menu = normalizeModelMenu(parsed);
95
+ if (!menu) {
96
+ log.warn("settings.modelMenu is not a valid menu — ignoring it");
97
+ return [];
98
+ }
99
+ return menu;
43
100
  }
44
101
  /** Store an already-normalized value — validation belongs at the boundary
45
102
  * that received it, so this never has to guess what the caller meant. */
46
103
  setPublicUrl(publicUrl) {
104
+ this.#set("publicUrl", publicUrl);
105
+ return this.get();
106
+ }
107
+ /** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
108
+ setModelMenu(menu) {
109
+ this.#set("modelMenu", JSON.stringify(menu));
110
+ return this.get();
111
+ }
112
+ setAutoUpdate(on) {
113
+ this.#set("autoUpdate", on ? "1" : "0");
114
+ return this.get();
115
+ }
116
+ #set(key, value) {
47
117
  this.#db.prepare(`
48
- INSERT INTO settings(key, value) VALUES ('publicUrl', ?)
118
+ INSERT INTO settings(key, value) VALUES (?, ?)
49
119
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
50
- `).run(publicUrl);
51
- return this.get();
120
+ `).run(key, value);
52
121
  }
53
122
  #value(key) {
54
123
  const row = this.#db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
@@ -1,8 +1,27 @@
1
+ import { quietLabel, splitReply } from "../core/reply.js";
1
2
  import { Router } from "../core/router.js";
2
3
  import { TaskMessenger } from "./messages.js";
3
4
  import { TaskStore } from "./store.js";
4
5
  const MAX_ACTIVE_AGENTS = 4;
5
6
  const MAX_ACTIVE_PER_ROOT = 4;
7
+ /** What a child cannot know unless told. Every session gets the chat-surface
8
+ * contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
9
+ * says which of it does not apply here, and a supervised run how to reach the
10
+ * agent that is waiting on it. Skipped on resume: the session already saw it. */
11
+ const preamble = (run) => {
12
+ // A cron/watch task with a session callback is read by an agent too.
13
+ const audience = run.invokedBySessionId
14
+ ? "read by the agent that delegated this run"
15
+ : run.callbackSessionId
16
+ ? "read by the agent session it is delivered to"
17
+ : "read by the operator";
18
+ const contact = run.invokedBySessionId
19
+ ? ' Mid-run, the task tool\'s contact operation reaches that agent: reason "progress" is fire-and-forget, "decision" waits for a reply — state what you await and end your turn.'
20
+ : "";
21
+ return `[Pier task run ${run.id} — "${run.context.definition.name}"] ` +
22
+ `Your final reply is recorded verbatim as the run result, ${audience}; ` +
23
+ `next-step buttons and file:// attachments do not render there.${contact}\n\n`;
24
+ };
6
25
  export class AgentTaskRunner {
7
26
  factory;
8
27
  router;
@@ -28,10 +47,12 @@ export class AgentTaskRunner {
28
47
  start();
29
48
  // No input is no block: `<task_input>\nnull\n</task_input>` is four
30
49
  // lines telling the agent nothing, on every run that has no input.
50
+ // Compact for the same reason tool results are (agent/pi.ts), and
51
+ // `<\/` is the same JSON — a value cannot close the fence early.
31
52
  const input = run.input === undefined || run.input === null
32
53
  ? ""
33
- : `\n\n<task_input>\n${JSON.stringify(run.input, null, 2)}\n</task_input>`;
34
- const prompt = run.context.resumePrompt ?? `${action.prompt}${input}`;
54
+ : `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
55
+ const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
35
56
  run.context.sessionId = session.id;
36
57
  run.context.model = session.model;
37
58
  run.context.renderedPrompt = prompt;
@@ -41,7 +62,16 @@ export class AgentTaskRunner {
41
62
  if (event.type === "turn-end")
42
63
  text = event.text;
43
64
  });
44
- const abort = () => void session.abort();
65
+ // The race below also settles this attempt if Pi ignores the abort:
66
+ // a hung turn must not hold one of the 4 slots (and its waiters)
67
+ // forever — the same guard execution.ts gives task-type children.
68
+ let rejectAborted = () => { };
69
+ const abortedTurn = new Promise((_, reject) => { rejectAborted = reject; });
70
+ abortedTurn.catch(() => { }); // handled via the race; never unhandled
71
+ const abort = () => {
72
+ void session.abort();
73
+ rejectAborted(new Error("cancelled"));
74
+ };
45
75
  signal.addEventListener("abort", abort, { once: true });
46
76
  // A pre-aborted signal never fires the listener: check before the
47
77
  // prompt starts or a cancelled run would hang until its timeout.
@@ -56,14 +86,20 @@ export class AgentTaskRunner {
56
86
  }, "prompt");
57
87
  await Promise.resolve();
58
88
  this.messages.deliverPendingControls(run);
59
- await turn;
89
+ await Promise.race([turn, abortedTurn]);
60
90
  if (signal.aborted)
61
91
  throw new Error("cancelled");
62
92
  if (!text) {
63
93
  const history = await session.history();
64
94
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
65
95
  }
66
- return { type: "agent", text, sessionId: session.id };
96
+ // The chat contract is injected into task sessions too, so a child's
97
+ // reply may carry chat-only markup. The result is read by a
98
+ // supervisor or the Console, never a chat renderer: buttons are
99
+ // dropped, and a turn that said nothing names which kind of nothing
100
+ // it was (principle 5b) instead of storing an empty result.
101
+ const reply = splitReply(text);
102
+ return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
67
103
  }
68
104
  finally {
69
105
  signal.removeEventListener("abort", abort);
@@ -1,7 +1,8 @@
1
+ // What a finished run says to the session that delegated it. Delivery itself
2
+ // belongs to outbox.ts; this file owns the run vocabulary and the batching.
1
3
  import { Router } from "../core/router.js";
2
- import { logger } from "../log.js";
4
+ import { Outbox } from "./outbox.js";
3
5
  import { TaskStore } from "./store.js";
4
- const log = logger("tasks");
5
6
  export function runResultText(run) {
6
7
  let result = run.error ?? "No result";
7
8
  if (run.result?.type === "agent")
@@ -13,18 +14,31 @@ export function runResultText(run) {
13
14
  if (run.result?.type === "watch")
14
15
  result = "Watch condition did not match";
15
16
  if (result.length > 8000)
16
- result = `${result.slice(0, 8000)}\n[truncated; open run ${run.id}]`;
17
+ result = `${result.slice(0, 8000)}\n[truncated task tool get run_id ${run.id} returns the full text]`;
17
18
  return result;
18
19
  }
19
20
  export class TaskCallbacks {
20
21
  store;
21
- router;
22
- changed;
23
- delivering = new Set();
24
- constructor(store, router, changed) {
22
+ outbox;
23
+ constructor(store, router, changed, unreachable) {
25
24
  this.store = store;
26
- this.router = router;
27
- this.changed = changed;
25
+ this.outbox = new Outbox(router, {
26
+ id: (run) => run.id,
27
+ reload: (id) => this.store.getRun(id),
28
+ save: (run) => { this.store.saveRun(run); },
29
+ changed,
30
+ input: (runs) => ({
31
+ text: this.text(runs),
32
+ origin: {
33
+ kind: "task-callback",
34
+ taskId: runs[0].taskId,
35
+ runId: runs[0].id,
36
+ sourceSessionId: runs[0].targetSessionId,
37
+ runIds: runs.map((run) => run.id),
38
+ },
39
+ }),
40
+ describe: (run) => `the result of "${run.context.definition.name}"`,
41
+ }, unreachable);
28
42
  }
29
43
  target(callback, origin) {
30
44
  if (callback.type === "session")
@@ -37,94 +51,20 @@ export class TaskCallbacks {
37
51
  for (const run of this.store.listPendingCallbacks(now))
38
52
  void this.deliver(run);
39
53
  }
40
- /** Delivers the candidate and, in the same system input, every other
41
- * deliverable callback aimed at the same session: one model turn drains the
42
- * backlog instead of one turn per run. */
54
+ /** Delivers the candidate together with every other deliverable callback
55
+ * aimed at the same session. */
43
56
  async deliver(candidate) {
44
- if (this.delivering.has(candidate.id))
45
- return;
46
57
  const first = this.store.getRun(candidate.id);
47
58
  if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
48
59
  return;
49
60
  const sessionId = first.callbackSessionId;
50
- // Ignore retry due-times when sweeping the batch: once one callback is
61
+ // Retry due-times are ignored when sweeping the batch: once one callback is
51
62
  // deliverable, everything pending for the session rides along.
52
- const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER).filter((run) => run.callbackSessionId === sessionId && !this.delivering.has(run.id));
63
+ const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
64
+ .filter((run) => run.callbackSessionId === sessionId);
53
65
  if (!batch.some((run) => run.id === first.id))
54
66
  return;
55
- for (const run of batch)
56
- this.delivering.add(run.id);
57
- try {
58
- const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
59
- // Crash-window idempotency: any run id already present in a persisted
60
- // callback input (single or batched) must not be sent again.
61
- const seen = new Set();
62
- for (const turn of await session.history()) {
63
- if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
64
- continue;
65
- for (const id of turn.origin.runIds ?? [turn.origin.runId])
66
- seen.add(id);
67
- }
68
- const fresh = batch.filter((run) => !seen.has(run.id));
69
- // Waiting for a busy target is not a delivery attempt: counting it would
70
- // inflate `callbackAttempts` once per second and skip the real failure
71
- // backoff straight to its ceiling.
72
- if (fresh.length > 0 && session.state === "streaming") {
73
- for (const run of batch) {
74
- run.callbackNextAttemptAt = Date.now() + 1000;
75
- this.store.saveRun(run);
76
- }
77
- return;
78
- }
79
- for (const run of batch) {
80
- run.callbackAttempts += 1;
81
- run.callbackState = "pending";
82
- run.callbackError = null;
83
- this.store.saveRun(run);
84
- }
85
- // `systemInput` resolves when the turn it triggers settles, not when Pi
86
- // accepts the input — so mark delivered first and let a rejection below
87
- // flip it to failed. Otherwise a recipient turn that runs for minutes
88
- // leaves the run "pending" and a restart in that window re-delivers.
89
- const sent = fresh.length > 0
90
- ? session.systemInput(this.text(fresh), {
91
- kind: "task-callback",
92
- taskId: fresh[0].taskId,
93
- runId: fresh[0].id,
94
- sourceSessionId: fresh[0].targetSessionId,
95
- runIds: fresh.map((run) => run.id),
96
- }, "followUp")
97
- : Promise.resolve();
98
- for (const run of batch) {
99
- run.callbackState = "delivered";
100
- run.callbackNextAttemptAt = null;
101
- this.store.saveRun(run);
102
- this.changed(run);
103
- }
104
- if (fresh.length > 0) {
105
- log.debug(`callback for ${fresh.map((run) => run.id).join(", ")} → session ${sessionId}`);
106
- }
107
- await sent;
108
- }
109
- catch (error) {
110
- // The delegating agent is waiting for an answer that is now late: the
111
- // retry is silent, so this line is the only sign it is being retried.
112
- log.warn(`callback to session ${sessionId} failed, will retry`, error);
113
- for (const stale of batch) {
114
- const run = this.store.getRun(stale.id);
115
- if (!run)
116
- continue;
117
- run.callbackState = "failed";
118
- run.callbackError = String(error);
119
- run.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(run.callbackAttempts, 6));
120
- this.store.saveRun(run);
121
- this.changed(run);
122
- }
123
- }
124
- finally {
125
- for (const run of batch)
126
- this.delivering.delete(run.id);
127
- }
67
+ await this.outbox.deliver(sessionId, batch);
128
68
  }
129
69
  text(runs) {
130
70
  const sections = runs.map((run) => [
@@ -45,6 +45,8 @@ function parseTrigger(raw) {
45
45
  }
46
46
  throw new Error("unknown trigger type");
47
47
  }
48
+ /** Always computed from `from` (boot recomputes from *now*): cron runs missed
49
+ * while Pier was down are skipped, never caught up — no double fire. */
48
50
  export function nextRunAt(trigger, from) {
49
51
  if (trigger.type === "manual")
50
52
  return null;
@@ -266,12 +268,6 @@ export class TaskDefinitions {
266
268
  await this.assertDirectory(cwd);
267
269
  session = { mode: "fork", ...(cwd ? { cwd } : {}) };
268
270
  }
269
- else if (typeof raw.sessionId === "string" && raw.sessionId.trim()) {
270
- const sessionId = raw.sessionId.trim();
271
- if (!(await this.sessionExists(sessionId)))
272
- throw new Error(`unknown session: ${sessionId}`);
273
- session = { mode: "reuse", sessionId };
274
- }
275
271
  else {
276
272
  // Validation never mutates: a dedicated session is created explicitly
277
273
  // (POST /api/sessions) and then referenced with mode:"reuse".