@timqi/pier 0.0.1 → 0.0.2

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 (67) hide show
  1. package/README.md +76 -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.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
package/dist/main.js CHANGED
@@ -18,6 +18,7 @@ import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
18
18
  import { parseConversation as parseSlackConversation } from "./channels/slack.js";
19
19
  import { EventHub } from "./core/hub.js";
20
20
  import { pierDb } from "./db.js";
21
+ import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
21
22
  import { surfacePrompt } from "./core/reply.js";
22
23
  import { Router } from "./core/router.js";
23
24
  import { logger } from "./log.js";
@@ -28,6 +29,7 @@ import { taskToolSpec } from "./tasks/tool.js";
28
29
  import { PIER_HOME, pierPath } from "./paths.js";
29
30
  import { Secrets } from "./secrets.js";
30
31
  import { SettingsStore } from "./settings.js";
32
+ import { UpdateCheck } from "./update.js";
31
33
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
32
34
  import { SessionStateStore } from "./web/session-state.js";
33
35
  import { createServer } from "./web/server.js";
@@ -64,6 +66,7 @@ let channelStore;
64
66
  // Shared by the adapter and the tool: a display name is looked up once per
65
67
  // process, not once per message and again per transcript.
66
68
  const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
69
+ const piConfig = new PiConfigStore();
67
70
  const factory = new PiAgentFactory([
68
71
  taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
69
72
  slackToolSpec((params, callerSessionId) => handleSlackTool({
@@ -95,7 +98,9 @@ const factory = new PiAgentFactory([
95
98
  [fileURLToPath(new URL("../skills", import.meta.url))],
96
99
  // Provider credentials live sealed in pier.db; a leftover auth.json is
97
100
  // imported on first use and renamed to auth.json.imported.
98
- new CredentialStore(db, secrets));
101
+ new CredentialStore(db, secrets), piConfig,
102
+ // Operator pins ride ahead of the curated catalog in every model picker.
103
+ () => settings.get().modelMenu);
99
104
  const hub = new EventHub();
100
105
  const router = new Router(hub, (key) => {
101
106
  // Web conversation ids ARE session ids; an IM conversation id is a chat or a
@@ -106,7 +111,12 @@ const router = new Router(hub, (key) => {
106
111
  }
107
112
  return resolveIm(key);
108
113
  });
109
- tasks = new TaskService(new TaskStore(db), factory, router, hub);
114
+ // An attached session holds a live Pi runtime and its transcript, and nothing
115
+ // else ever lets one go: without this, one per conversation ever answered.
116
+ const stopEviction = router.startIdleEviction();
117
+ tasks = new TaskService(new TaskStore(db), factory, router, hub, {
118
+ modelMenu: () => settings.get().modelMenu,
119
+ });
110
120
  tasks.start();
111
121
  channelStore = new ChannelStore(db, secrets);
112
122
  const control = createControl({ router, factory, conversations, store: channelStore });
@@ -115,7 +125,16 @@ resolveIm = resolveConversation(conversations, factory, control.launchFor, (mess
115
125
  // Channels connect only once tokens are readable. A refused unlock (vt denial,
116
126
  // corrupt master.key) must not take the web surface down — it is where the
117
127
  // operator goes to repair — but it is named loudly, not served as silence.
118
- void secrets.unlock().then(() => channels.reload(), (err) => log.error("secrets locked channels not started; unlock from Console → Settings → Security, or repair master.key", err));
128
+ // Once they are up, the chats a previous restart cut off are told (drain.ts)
129
+ // on this path and on a later Console unlock alike, because a note held back
130
+ // by locked secrets must not wait for yet another restart.
131
+ const restartLedger = new RestartLedger(db);
132
+ const startChannels = async () => {
133
+ await channels.reload();
134
+ await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
135
+ .catch((err) => log.error("restart-note delivery failed", err));
136
+ };
137
+ void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
119
138
  // Composition happens here so web/ and tasks/ never import each other.
120
139
  const app = new Hono();
121
140
  // A route that threw would otherwise answer 500 and leave no trace anywhere:
@@ -129,8 +148,8 @@ app.onError((err, c) => {
129
148
  // so a surface added later is covered without knowing this exists. Built
130
149
  // before the listener: a first run generates and prints its password here.
131
150
  const auth = new AuthStore(db);
132
- registerAuthRoutes(app, auth);
133
151
  app.use("*", requireAuth(auth));
152
+ registerAuthRoutes(app, auth);
134
153
  registerTaskRoutes(app, tasks, { factory, router });
135
154
  registerChannelRoutes(app, channelStore, channels);
136
155
  registerBoardRoutes(app);
@@ -139,11 +158,13 @@ app.route("/", createServer({
139
158
  router,
140
159
  hub,
141
160
  sessions: new SessionStateStore(db),
142
- config: new PiConfigStore(),
161
+ config: piConfig,
162
+ providers: factory,
143
163
  settings,
144
164
  secrets,
165
+ updates: new UpdateCheck(),
145
166
  // Unlocked from the Console: start the channels boot held back.
146
- onUnlocked: () => void channels.reload(),
167
+ onUnlocked: () => void startChannels(),
147
168
  backgroundRuns: (id) => tasks.backgroundRuns(id),
148
169
  }));
149
170
  const port = Number(process.env.PORT ?? 3141);
@@ -164,20 +185,67 @@ process.on("uncaughtException", (err) => {
164
185
  process.on("unhandledRejection", (reason) => {
165
186
  log.error("unhandled rejection", reason);
166
187
  });
188
+ let shuttingDown = false;
189
+ const shutdown = (stopTasks = true) => {
190
+ // Once: SIGTERM can land while a drain is finishing, and two teardowns
191
+ // racing each other close the same sockets twice.
192
+ if (shuttingDown)
193
+ return;
194
+ shuttingDown = true;
195
+ // Best-effort, and bounded: a socket an adapter cannot close must not turn
196
+ // `systemctl restart` into a 90-second wait for SIGKILL.
197
+ setTimeout(() => process.exit(0), 3000).unref();
198
+ stopEviction();
199
+ // The drain path leaves task runs alone: aborting them here would record
200
+ // them cancelled and race their callbacks against dying channels, when the
201
+ // boot-time interrupted marking is the recovery that was promised.
202
+ if (stopTasks)
203
+ tasks.stop();
204
+ void channels.stop().finally(() => {
205
+ server.close(() => process.exit(0));
206
+ // Every workbench tab holds an SSE stream open, so `close()` alone would
207
+ // always wait out the timer above. (`in` because the served type is a
208
+ // union with HTTP/2, which has no such method — and no such problem.)
209
+ if ("closeAllConnections" in server)
210
+ server.closeAllConnections();
211
+ });
212
+ };
167
213
  for (const signal of ["SIGTERM", "SIGINT"]) {
168
214
  process.once(signal, () => {
169
215
  log.info(`${signal} received, shutting down`);
170
- // Best-effort, and bounded: a socket an adapter cannot close must not turn
171
- // `systemctl restart` into a 90-second wait for SIGKILL.
172
- setTimeout(() => process.exit(0), 3000).unref();
173
- tasks.stop();
174
- void channels.stop().finally(() => {
175
- server.close(() => process.exit(0));
176
- // Every workbench tab holds an SSE stream open, so `close()` alone would
177
- // always wait out the timer above. (`in` because the served type is a
178
- // union with HTTP/2, which has no such method — and no such problem.)
179
- if ("closeAllConnections" in server)
180
- server.closeAllConnections();
181
- });
216
+ shutdown();
217
+ });
218
+ }
219
+ // The slow restart (`pier restart`): refuse new work, let running turns finish
220
+ // bounded by the drain deadline — then exit for `Restart=always` to bring the
221
+ // next process up. SIGTERM above stays the fast path systemd expects. `on`,
222
+ // not `once`: a second SIGUSR2 with no handler would fall back to Node's
223
+ // default and kill the drain it meant to hurry.
224
+ let draining = false;
225
+ process.on("SIGUSR2", () => {
226
+ if (draining) {
227
+ log.info("SIGUSR2 received again — already draining");
228
+ return;
229
+ }
230
+ draining = true;
231
+ log.info("SIGUSR2 received, draining for restart");
232
+ void drainForRestart({ router, tasks, ledger: restartLedger })
233
+ .catch((err) => log.error("drain failed — shutting down anyway", err))
234
+ .then(() => shutdown(false));
235
+ });
236
+ // Reload without a restart (`pier reload`): adapters re-read their config, and
237
+ // idle sessions are let go so the next message re-opens them with the current
238
+ // skills, extensions and prompts — all applied at attach, none stored in a
239
+ // transcript. Streaming or watched sessions pick the change up at their next
240
+ // natural eviction. Only under systemd (the CLI signals through systemctl):
241
+ // a foreground `pier` keeps SIGHUP's default, dying with its terminal instead
242
+ // of surviving as an orphan that holds the port.
243
+ if (process.env.INVOCATION_ID) {
244
+ process.on("SIGHUP", () => {
245
+ log.info("SIGHUP received, reloading channels and recycling idle sessions");
246
+ void channels.reload();
247
+ void router.evictIdle(0)
248
+ .then((n) => log.info(`recycled ${String(n)} idle session(s)`))
249
+ .catch((err) => log.error("session recycle failed", err));
182
250
  });
183
251
  }
package/dist/secrets.js CHANGED
@@ -26,7 +26,6 @@ export class Secrets {
26
26
  path;
27
27
  vt;
28
28
  #dek;
29
- #kek;
30
29
  #file;
31
30
  /** Why decrypt is refused right now — "" once unlocked. */
32
31
  #lockedReason = "unlock() has not run";
@@ -58,7 +57,12 @@ export class Secrets {
58
57
  try {
59
58
  raw = readFileSync(this.path, "utf8");
60
59
  }
61
- catch {
60
+ catch (err) {
61
+ // Only a missing file means first boot. Any other read error (EACCES,
62
+ // EISDIR…) must not fall through to #create(), which would rename a
63
+ // fresh key over the existing one and destroy every sealed credential.
64
+ if (err.code !== "ENOENT")
65
+ throw err;
62
66
  this.#file = this.#create();
63
67
  log.info(`created ${this.path} (file mode)`);
64
68
  raw = readFileSync(this.path, "utf8");
@@ -66,12 +70,12 @@ export class Secrets {
66
70
  const file = JSON.parse(raw);
67
71
  if (!file.kek || !file.dek || !file.dekId)
68
72
  throw new Error(`${this.path} is malformed`);
69
- this.#kek = file.kek.startsWith("vt://")
73
+ const kek = file.kek.startsWith("vt://")
70
74
  ? Buffer.from(await this.vt.read(file.kek), "base64")
71
75
  : Buffer.from(file.kek, "base64");
72
- if (this.#kek.length !== KEY_BYTES)
76
+ if (kek.length !== KEY_BYTES)
73
77
  throw new Error(`${this.path} KEK is not ${KEY_BYTES} bytes`);
74
- this.#dek = open(this.#kek, file.dek, `kek:${file.dekId}`);
78
+ this.#dek = open(kek, file.dek, `kek:${file.dekId}`);
75
79
  this.#file = file;
76
80
  this.#lockedReason = "";
77
81
  log.info(`secrets unlocked (${this.mode} mode, dek ${file.dekId})`);
@@ -112,7 +116,6 @@ export class Secrets {
112
116
  throw new Error("vt create did not return a vt:// record");
113
117
  }
114
118
  this.#write(next);
115
- this.#kek = kek;
116
119
  this.#file = next;
117
120
  log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
118
121
  }
@@ -184,6 +187,7 @@ function run(cmd, args, stdin) {
184
187
  else
185
188
  reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
186
189
  });
190
+ child.stdin.on("error", reject); // EPIPE if vt exits before reading
187
191
  if (stdin !== undefined)
188
192
  child.stdin.write(stdin);
189
193
  child.stdin.end();
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,92 @@ 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
+ function runningPierHome(home) {
189
+ const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
190
+ if (Number.isInteger(pid) && pid >= 1) {
191
+ const value = readFileSync(`/proc/${pid}/environ`)
192
+ .toString()
193
+ .split("\0")
194
+ .find((item) => item.startsWith("PIER_HOME="))
195
+ ?.slice("PIER_HOME=".length);
196
+ return value || join(home, ".pier");
197
+ }
198
+ // Installed but stopped: the unit file records any override (quoted and
199
+ // escaped since 0.0.2, bare in the 0.0.1 shape) — undo quote()'s escaping
200
+ // or the drop-in would carry `%%`/`\\"` into a real path.
201
+ const raw = readFileSync(unitPath(home), "utf8")
202
+ .match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
203
+ const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
204
+ return fromUnit || join(home, ".pier");
205
+ }
206
+ /** Start the updater recorded at install time. Its tiny drop-in captures the
207
+ * running service's effective home, including an operator override. */
208
+ export function startUpdate(options) {
209
+ const { home = homedir(), say } = options;
210
+ const run = options.exec ?? runner(say);
211
+ if (!existsSync(unitPath(home)))
212
+ return "not-installed";
213
+ try {
214
+ if (!existsSync(updateUnitPath(home))) {
215
+ writeFileSync(updateUnitPath(home), renderUpdateUnit(legacyOptions(home)), { mode: 0o644 });
216
+ say(`generated a legacy updater; run "pier service install --force" after this update to record the exact npm path.`);
217
+ }
218
+ const runtime = updateRuntimePath(home);
219
+ mkdirSync(dirname(runtime), { recursive: true });
220
+ writeFileSync(runtime, `[Service]\n${environment("PIER_HOME", (options.effectiveHome ?? (() => runningPierHome(home)))())}\n`, { mode: 0o644 });
221
+ }
222
+ catch (err) {
223
+ say(`! cannot prepare update — ${String(err)}`);
224
+ return "failed";
225
+ }
226
+ if (!run(["systemctl", "--user", "daemon-reload"]))
227
+ return "failed";
228
+ if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
229
+ return "failed";
230
+ say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
231
+ say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
232
+ return "started";
119
233
  }
120
234
  export function uninstall(home = homedir(), say = console.log, exec) {
121
235
  const run = exec ?? runner(say);
122
- run(["systemctl", "--user", "disable", "--now", UNIT_NAME]);
123
- for (const path of [unitPath(home), limitsPath(home)]) {
236
+ if (!run(["systemctl", "--user", "disable", "--now", UNIT_NAME]))
237
+ return false;
238
+ for (const path of [unitPath(home), limitsPath(home), updateUnitPath(home), updateRuntimePath(home)]) {
124
239
  if (!existsSync(path))
125
240
  continue;
126
241
  rmSync(path, { force: true });
127
242
  say(`removed ${path}`);
128
243
  }
129
- rmSync(dirname(limitsPath(home)), { force: true, recursive: true });
130
- run(["systemctl", "--user", "daemon-reload"]);
244
+ for (const dir of [dirname(limitsPath(home)), dirname(updateRuntimePath(home))]) {
245
+ try {
246
+ rmdirSync(dir); // non-recursive: drop-ins the operator added are not ours to delete
247
+ }
248
+ catch {
249
+ if (existsSync(dir))
250
+ say(`kept ${dir} — it has files Pier did not write`);
251
+ }
252
+ }
253
+ const ok = run(["systemctl", "--user", "daemon-reload"]);
131
254
  // Left alone on purpose: the database, the boards, and linger — none of them
132
255
  // are this command's to decide about.
133
256
  say(`$PIER_HOME is untouched; linger is still enabled.`);
257
+ return ok;
134
258
  }
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,82 @@ 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 { publicUrl: this.#value("publicUrl") ?? "", modelMenu: this.#menu() };
76
+ }
77
+ #menu() {
78
+ const raw = this.#value("modelMenu");
79
+ if (!raw)
80
+ return [];
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ // Only a hand-edited row can get here; named, not silently served as [].
87
+ log.warn("settings.modelMenu is not JSON — ignoring it");
88
+ return [];
89
+ }
90
+ const menu = normalizeModelMenu(parsed);
91
+ if (!menu) {
92
+ log.warn("settings.modelMenu is not a valid menu — ignoring it");
93
+ return [];
94
+ }
95
+ return menu;
43
96
  }
44
97
  /** Store an already-normalized value — validation belongs at the boundary
45
98
  * that received it, so this never has to guess what the caller meant. */
46
99
  setPublicUrl(publicUrl) {
100
+ this.#set("publicUrl", publicUrl);
101
+ return this.get();
102
+ }
103
+ /** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
104
+ setModelMenu(menu) {
105
+ this.#set("modelMenu", JSON.stringify(menu));
106
+ return this.get();
107
+ }
108
+ #set(key, value) {
47
109
  this.#db.prepare(`
48
- INSERT INTO settings(key, value) VALUES ('publicUrl', ?)
110
+ INSERT INTO settings(key, value) VALUES (?, ?)
49
111
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
50
- `).run(publicUrl);
51
- return this.get();
112
+ `).run(key, value);
52
113
  }
53
114
  #value(key) {
54
115
  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);