@timqi/pier 0.1.0 → 0.1.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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/secrets.js CHANGED
@@ -1,19 +1,9 @@
1
- // Layer-1 secret encryption: the credentials Pier must read by itself
2
- // (channel tokens, provider API keys, OAuth tokens) are stored as ciphertext
3
- // and pass through here. Two keys, standard envelope: a KEK from
4
- // `~/.pier/master.key` wraps a DEK, and only the DEK touches data — so
5
- // rotating the KEK rewrites one file and zero data rows. The KEK is either a
6
- // `vt://` record (decrypted through vt, one approval per process start) or a
7
- // raw key in the file (the no-vt fallback — same at-rest level as the
8
- // plaintext files it replaces, and the mode is the operator's explicit
9
- // choice, never a silent downgrade).
10
- //
11
- // Both keys live in `master.key` (JSON: kek, wrapped dek, dek id), not the
12
- // database: rotation is then a single atomic rename, with no crash window
13
- // where the file holds the new KEK and the database a DEK wrapped by the old
14
- // one. Layer 2 — secrets needing per-use approval — never passes through
15
- // here: those stay `vt://` strings Pier cannot read, and the agent runs vt
16
- // itself.
1
+ // Layer-1 secret encryption for the credentials Pier must read by itself.
2
+ // Standard envelope: a KEK from `master.key` (a `vt://` record, or a raw key in
3
+ // file mode — the operator's explicit choice) wraps a DEK, and only the DEK
4
+ // touches data, so rotating the KEK rewrites one file and zero rows. Both keys
5
+ // live in that one file so rotation is a single atomic rename. Layer 2
6
+ // (per-use approval) never passes here: the agent runs vt itself.
17
7
  import { spawn } from "node:child_process";
18
8
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
19
9
  import { chmodSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -22,6 +12,9 @@ import { pierPath } from "./paths.js";
22
12
  const log = logger("secrets");
23
13
  const KEY_BYTES = 32; // AES-256
24
14
  const IV_BYTES = 12; // GCM standard nonce
15
+ /** Anything not matching predates sealing: honored as plaintext, and re-sealed
16
+ * by whichever store owns the row. */
17
+ export const isSealed = (blob) => /^v1:[0-9a-f]{8}:/.test(blob);
25
18
  export class Secrets {
26
19
  path;
27
20
  vt;
@@ -39,18 +32,12 @@ export class Secrets {
39
32
  get mode() {
40
33
  return this.#file ? (this.#file.kek.startsWith("vt://") ? "vt" : "file") : undefined;
41
34
  }
42
- /** Why decrypt is refused right now — "" once unlocked. Shown in the
43
- * Console's Security tab, which is where a refused unlock gets repaired. */
44
35
  get lockedReason() {
45
36
  return this.#lockedReason;
46
37
  }
47
- /**
48
- * Load master.key — created on first boot, file mode, so an unattended
49
- * start needs no ceremony; vt mode is entered later via rotate. Throws on a
50
- * failed vt approval or a corrupt file, and remembers why: the process must
51
- * keep serving (web is how the operator unlocks or repairs), but every
52
- * refused decrypt names the reason instead of pretending to be empty.
53
- */
38
+ /** Created on first boot in file mode, so an unattended start needs no
39
+ * ceremony. Throws and remembers why: the process must keep serving (web is
40
+ * how the operator repairs), and every refused decrypt names the reason. */
54
41
  async unlock() {
55
42
  try {
56
43
  let raw;
@@ -58,9 +45,8 @@ export class Secrets {
58
45
  raw = readFileSync(this.path, "utf8");
59
46
  }
60
47
  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.
48
+ // Only ENOENT is first boot: any other error falling through to
49
+ // #create() would rename a fresh key over the existing one.
64
50
  if (err.code !== "ENOENT")
65
51
  throw err;
66
52
  this.#file = this.#create();
@@ -98,12 +84,8 @@ export class Secrets {
98
84
  throw new Error(`sealed by unknown key ${dekId}, have ${file.dekId}`);
99
85
  return open(dek, rest.join(":"), `v1:${dekId}`).toString("utf8");
100
86
  }
101
- /**
102
- * New KEK, same DEK: every stored envelope stays valid. `mode` switches how
103
- * the new KEK is protected (entering vt mode runs `vt create`, one
104
- * approval); omitted, the current mode is kept. The rewrapped file lands by
105
- * atomic rename — a crash leaves either the old working file or the new one.
106
- */
87
+ /** New KEK, same DEK: every stored envelope stays valid. Omitted `mode`
88
+ * keeps the current one. */
107
89
  async rotateKek(mode = this.mode ?? "file") {
108
90
  const { file } = this.#unlocked();
109
91
  const kek = randomBytes(KEY_BYTES);
@@ -119,16 +101,10 @@ export class Secrets {
119
101
  this.#file = next;
120
102
  log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
121
103
  }
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
- */
104
+ /** Read-only, safe while locked; vt's own report is the repair instruction. */
128
105
  doctor() {
129
106
  return this.vt.doctor();
130
107
  }
131
- /** First boot: random KEK and DEK, file mode. */
132
108
  #create() {
133
109
  const kek = randomBytes(KEY_BYTES);
134
110
  const dekId = randomBytes(4).toString("hex");
@@ -170,8 +146,6 @@ function open(key, sealed, aad) {
170
146
  decipher.setAuthTag(tag);
171
147
  return Buffer.concat([decipher.update(ct), decipher.final()]);
172
148
  }
173
- /** The real vt CLI. Absent binary or denied approval both surface as the
174
- * spawn/exit error — unlock() records it and the operator reads it. */
175
149
  const vtCli = {
176
150
  read: (record) => run("vt", ["read", record]),
177
151
  create: async (plaintext) => {
@@ -181,8 +155,7 @@ const vtCli = {
181
155
  throw new Error("vt create printed no vt:// record");
182
156
  return record;
183
157
  },
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.
158
+ // doctor probes over the network; a hung probe would hang the Console.
186
159
  doctor: () => run("vt", ["doctor"], undefined, 15_000),
187
160
  };
188
161
  function run(cmd, args, stdin, timeoutMs) {
package/dist/service.js CHANGED
@@ -1,32 +1,22 @@
1
- // The systemd user unit Pier writes for itself.
2
- //
3
- // A *user* unit, not a system one: Pier runs as you, reads your Pi
4
- // configuration and drives sessions in your own directories. As root or a
5
- // dedicated service user it would be an agent that cannot touch the files you
6
- // wanted it to work on.
7
- //
8
- // Writing this file is a command rather than a page of documentation to copy
9
- // because two of its lines are only knowable at runtime: the absolute path of
10
- // the node that is running (systemd starts with a minimal PATH, so a node
11
- // installed by fnm/nvm/asdf is not on it) and the absolute path of the
12
- // installed entry point.
1
+ // The systemd *user* unit Pier writes for itself: Pier runs as you, so it can
2
+ // touch the files you wanted it to work on. Written by a command because two
3
+ // lines are only knowable at runtime — the absolute node (systemd's minimal
4
+ // PATH has no fnm/nvm node) and the installed entry point.
13
5
  import { execFileSync } from "node:child_process";
14
6
  import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
15
7
  import { homedir, userInfo } from "node:os";
16
8
  import { dirname, join } from "node:path";
17
9
  export const UNIT_NAME = "pier.service";
18
10
  const UPDATE_UNIT_NAME = "pier-update.service";
19
- /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
20
11
  export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
21
- /** The drop-in Pier writes once and never touches again: what the unit may
22
- * consume is the operator's call, not ours. */
12
+ /** Written once and never touched again: what the unit may consume is the
13
+ * operator's call. */
23
14
  export const limitsPath = (home = homedir()) => join(dirname(unitPath(home)), `${UNIT_NAME}.d`, "limits.conf");
24
- /** The oneshot that installs a new version, beside the unit it restarts. */
25
15
  export const updateUnitPath = (home = homedir()) => join(dirname(unitPath(home)), UPDATE_UNIT_NAME);
26
- /** Runtime-only effective state for the updater, regenerated before each run. */
16
+ /** Regenerated before each updater run. */
27
17
  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. */
18
+ /** Percent is doubled because specifier expansion runs after parsing; dollar
19
+ * only for command lines. */
30
20
  function quote(value, command = false) {
31
21
  if (/[\0\r\n]/.test(value))
32
22
  throw new Error("systemd values cannot contain control characters");
@@ -36,14 +26,9 @@ function quote(value, command = false) {
36
26
  return `"${escaped}"`;
37
27
  }
38
28
  const environment = (key, value) => `Environment=${quote(`${key}=${value}`)}`;
39
- /** The PATH both units carry: the recorded node first, then the shell that ran
40
- * the install (`pier service install` is typed in that shell, so its own PATH
41
- * *is* the login one), with the standard directories as a floor.
42
- *
43
- * Recorded at install rather than sourced from a login shell at start, which
44
- * would hand a dotfile the power to decide whether Pier boots and which node
45
- * npm installs into. Relative entries are dropped: they would resolve against
46
- * WorkingDirectory, which is not where the operator was standing. */
29
+ /** Recorded at install rather than sourced from a login shell at start, which
30
+ * would hand a dotfile the power to decide whether Pier boots. Relative
31
+ * entries would resolve against WorkingDirectory, so they are dropped. */
47
32
  function pathEnv(execPath, shellPath) {
48
33
  const seen = new Set();
49
34
  return [dirname(execPath), ...(shellPath ?? "").split(":"), "/usr/local/bin", "/usr/bin", "/bin"]
@@ -86,8 +71,7 @@ SyslogIdentifier=pier
86
71
  WantedBy=default.target
87
72
  `;
88
73
  }
89
- /** The 0.0.1 unit did not record npm. This one-time bridge can only use npm
90
- * beside its recorded Node; a forced reinstall writes the exact executable. */
74
+ /** A unit that recorded no npm: the bridge assumes npm beside its Node. */
91
75
  function legacyOptions(home) {
92
76
  const text = readFileSync(unitPath(home), "utf8");
93
77
  const start = text.match(/^ExecStart=(\S+) (\S+)$/m);
@@ -105,14 +89,9 @@ function legacyOptions(home) {
105
89
  ...(pierHome ? { pierHome } : {}),
106
90
  };
107
91
  }
108
- /** A separate cgroup snapshots the database, updates the exact npm
109
- * installation recorded at install time, and only then restarts Pier.
110
- *
111
- * Both slow steps run while the service is still up, so the downtime is one
112
- * stop and one start rather than the ~10s an install takes. The snapshot is
113
- * consistent on a live database — `VACUUM INTO` off a read-only connection
114
- * (db.ts) — and it still runs from the tree npm is about to replace, so the
115
- * copy carries the version whose schema it pairs with. */
92
+ /** Snapshot and install run while the service is still up, so downtime is one
93
+ * stop and one start. The snapshot runs from the tree npm is about to replace,
94
+ * so the copy carries the version whose schema it pairs with. */
116
95
  export function renderUpdateUnit(options) {
117
96
  const { execPath, npmPath, entry, pierHome, shellPath } = options;
118
97
  const cli = join(dirname(entry), "cli.js");
@@ -140,12 +119,8 @@ ExecStart=systemctl --user stop ${UNIT_NAME}
140
119
  ExecStopPost=systemctl --user start ${UNIT_NAME}
141
120
  `;
142
121
  }
143
- /**
144
- * Sized as a share of the machine, not as "how much should Pier need": the
145
- * limit covers node, every subagent and every command a turn ran, and it
146
- * exists to protect the OS and sshd outside it. Written commented so the
147
- * operator tuning it can see what each line buys.
148
- */
122
+ /** A share of the machine, not "how much Pier needs": the limit covers every
123
+ * command a turn ran, and exists to protect the OS and sshd outside it. */
149
124
  function renderLimits() {
150
125
  return `[Service]
151
126
  # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
@@ -164,8 +139,6 @@ OOMScoreAdjust=200
164
139
  OOMPolicy=continue
165
140
  `;
166
141
  }
167
- /** A command failure is printed here and propagated by its caller. Linger is
168
- * the only deliberately best-effort step. */
169
142
  const runner = (say) => (argv) => {
170
143
  try {
171
144
  execFileSync(argv[0], argv.slice(1), { stdio: "pipe" });
@@ -202,8 +175,8 @@ export function install(options) {
202
175
  }
203
176
  if (!run(["systemctl", "--user", "daemon-reload"]))
204
177
  return false;
205
- // Without lingering, the user manager stops at logout and takes every
206
- // scheduled task with it. It can need a polkit prompt, hence best-effort.
178
+ // Without lingering the user manager stops at logout, taking every scheduled
179
+ // task with it. It can need a polkit prompt, hence best-effort.
207
180
  if (!run(["loginctl", "enable-linger", userInfo().username])) {
208
181
  say(` run it yourself so Pier survives logout: loginctl enable-linger ${userInfo().username}`);
209
182
  }
@@ -218,20 +191,13 @@ export function install(options) {
218
191
  say(`started. The first run prints a password once: journalctl --user -u pier -e`);
219
192
  return true;
220
193
  }
221
- /**
222
- * Why the installed updater could not do its job, or `null` when nothing is
223
- * wrong. Checked while Pier is still alive, because the alternative is finding
224
- * out at the next restart, from a service that no longer starts.
225
- *
226
- * The absolute node and npm paths in the unit are deliberate — systemd's PATH
227
- * has neither — but they pin the unit to one directory of one version manager.
228
- * `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
229
- * gone; the running process survives (Linux keeps a deleted binary mapped),
230
- * so nothing would notice until the update, or the next boot, failed.
231
- */
194
+ /** The absolute node and npm paths pin the unit to one version manager's
195
+ * directory: `fnm uninstall 24` leaves ExecStart naming a Node that is gone,
196
+ * and the running process survives it (Linux keeps a deleted binary mapped),
197
+ * so nothing would notice until the next boot failed. */
232
198
  export function updaterProblem(home = homedir()) {
233
199
  if (!existsSync(unitPath(home)))
234
- return null; // not a service install; nothing to check
200
+ return null; // not a service install
235
201
  const path = updateUnitPath(home);
236
202
  let unit;
237
203
  if (existsSync(path)) {
@@ -243,9 +209,7 @@ export function updaterProblem(home = homedir()) {
243
209
  }
244
210
  }
245
211
  else {
246
- // A 0.0.1 install: startUpdate generates the updater from the main unit,
247
- // so a missing file is only a problem when that bridge cannot either —
248
- // and the generated text gets the same executable check below.
212
+ // startUpdate generates a missing updater from the main unit; check that text.
249
213
  try {
250
214
  unit = renderUpdateUnit(legacyOptions(home));
251
215
  }
@@ -253,10 +217,8 @@ export function updaterProblem(home = homedir()) {
253
217
  return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
254
218
  }
255
219
  }
256
- // The one line that names both executables, quoted and escaped by quote().
257
- // Unparseable means hand-edited, which is not this function's business to
258
- // judge; the escaping is undone before existsSync sees a path (a `%` or `$`
259
- // in it would otherwise read as gone on a working updater).
220
+ // Unparseable means hand-edited, not this function's to judge. Unescaped
221
+ // before existsSync: a `%` or `$` in the path would otherwise read as gone.
260
222
  const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
261
223
  if (!install)
262
224
  return null;
@@ -278,16 +240,14 @@ function runningPierHome(home) {
278
240
  ?.slice("PIER_HOME=".length);
279
241
  return value || join(home, ".pier");
280
242
  }
281
- // Installed but stopped: the unit file records any override (quoted and
282
- // escaped since 0.0.2, bare in the 0.0.1 shape) — undo quote()'s escaping
283
- // or the drop-in would carry `%%`/`\\"` into a real path.
243
+ // Installed but stopped: the unit records any override, quoted or bare;
244
+ // undo quote()'s escaping or the drop-in would carry `%%` into a real path.
284
245
  const raw = readFileSync(unitPath(home), "utf8")
285
246
  .match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
286
247
  const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
287
248
  return fromUnit || join(home, ".pier");
288
249
  }
289
- /** Start the updater recorded at install time. Its tiny drop-in captures the
290
- * running service's effective home, including an operator override. */
250
+ /** The drop-in captures the running service's effective home, override included. */
291
251
  export function startUpdate(options) {
292
252
  const { home = homedir(), say } = options;
293
253
  const run = options.exec ?? runner(say);
@@ -334,8 +294,6 @@ export function uninstall(home = homedir(), say = console.log, exec) {
334
294
  }
335
295
  }
336
296
  const ok = run(["systemctl", "--user", "daemon-reload"]);
337
- // Left alone on purpose: the database, the boards, and linger — none of them
338
- // are this command's to decide about.
339
297
  say(`$PIER_HOME is untouched; linger is still enabled.`);
340
298
  return ok;
341
299
  }
package/dist/settings.js CHANGED
@@ -1,33 +1,18 @@
1
1
  // Instance settings: the facts about *this* Pier that are neither a credential
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.
4
- //
5
- // A key-value table, so the next setting is not the next table and not a third
6
- // kind of storage. It used to be a JSON file, justified by "the agent reads it
7
- // too" — the agent is told the URL in its system prompt (core/reply.ts), and
8
- // nothing outside this process ever opened that file.
2
+ // nor per-session. A key-value table, so the next setting is not the next table.
9
3
  import { isThinkingLevel } from "./core/types.js";
10
4
  import { pierDb, transact } from "./db.js";
11
5
  import { logger } from "./log.js";
12
- // The one place the custom-tool vocabulary lives (names, ubix sources, the
13
- // names Pier already owns). Imported rather than copied: a second validator
14
- // would be the third-copy bug one release later, and tools.ts is root-layer
15
- // like this file, so nothing crosses a seam.
16
6
  import { normalizeCustomTools } from "./tools.js";
17
7
  const log = logger("settings");
18
- /**
19
- * `""` clears it, `null` rejects it. Rejecting rather than repairing: a
20
- * mistyped host quietly turned into a URL produces board links that 404 for
21
- * the person they were sent to, and the sender never finds out.
22
- */
8
+ /** `""` clears it, `null` rejects it: a mistyped host quietly turned into a URL
9
+ * produces board links that 404 for the person they were sent to. */
23
10
  export function normalizePublicUrl(raw) {
24
11
  const text = raw.trim();
25
12
  if (!text)
26
13
  return "";
27
14
  let url;
28
15
  try {
29
- // Scheme-less input is the common way to type a host, and https is the
30
- // only guess worth making for something on the internet.
31
16
  url = new URL(text.includes("://") ? text : `https://${text}`);
32
17
  }
33
18
  catch {
@@ -39,12 +24,8 @@ export function normalizePublicUrl(raw) {
39
24
  return null;
40
25
  return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
41
26
  }
42
- /**
43
- * Boundary check for a menu, rejecting rather than repairing (same contract as
44
- * `normalizePublicUrl`): a silently "fixed" entry would advertise a model the
45
- * operator never picked. Notes are capped — they are one line of intent, and
46
- * every session that asks for the menu pays for their tokens.
47
- */
27
+ /** Rejecting rather than repairing: a "fixed" entry would advertise a model the
28
+ * operator never picked. Notes are capped; every session pays for their tokens. */
48
29
  export function normalizeModelMenu(raw) {
49
30
  if (!Array.isArray(raw) || raw.length > 32)
50
31
  return null;
@@ -54,9 +35,8 @@ export function normalizeModelMenu(raw) {
54
35
  if (!ref)
55
36
  return null;
56
37
  const { thinking, note } = item;
57
- // The one thing repaired rather than rejected, because it is not input:
58
- // entries stored (or exported by an instance) before the level was
59
- // required have none, and dropping the menu over it would lose the pins.
38
+ // Repaired, not rejected: rows stored before the level was required have
39
+ // none, and dropping the menu over it would lose the pins.
60
40
  const level = thinking === undefined ? "medium" : thinking;
61
41
  if (!isThinkingLevel(level))
62
42
  return null;
@@ -71,9 +51,8 @@ export function normalizeModelMenu(raw) {
71
51
  }
72
52
  return menu;
73
53
  }
74
- /** One model reference, rejecting rather than repairing — the menu above is
75
- * built from these. Existence is not checked here: the catalog is the
76
- * agent's, and a model that went away is reported by the call that fails. */
54
+ /** Existence is not checked: the catalog is the agent's, and a model that went
55
+ * away is reported by the call that fails. */
77
56
  export function normalizeModelRef(raw) {
78
57
  if (typeof raw !== "object" || raw === null)
79
58
  return null;
@@ -84,19 +63,12 @@ export function normalizeModelRef(raw) {
84
63
  return null;
85
64
  return { provider: provider.trim(), id: id.trim() };
86
65
  }
87
- /**
88
- * Shape only — an unknown name is not an error here. This file must not know
89
- * what Pier bundles (that catalog is code, and importing it would drag the Pi
90
- * SDK into the instance layer); agent/ matches the names it recognizes and
91
- * ignores the rest, which is also what keeps a downgrade from losing a
92
- * setting it cannot currently explain.
93
- */
66
+ /** Shape only: the catalog is code behind the Pi SDK, and an unknown name is
67
+ * ignored there, so a downgrade cannot lose a setting it cannot explain. */
94
68
  export function normalizeExtensions(raw) {
95
69
  return normalizeNames(raw);
96
70
  }
97
- /** Same shape, same contract, same cap — the managed-tool set (src/tools.ts).
98
- * Shape only again: tools.ts owns the catalog, and a name it does not know is
99
- * ignored there rather than rejected here, so a downgrade cannot lose one. */
71
+ /** Shape only, for the same reason: tools.ts owns the catalog. */
100
72
  export function normalizeTools(raw) {
101
73
  return normalizeNames(raw);
102
74
  }
@@ -128,17 +100,11 @@ export class SettingsStore {
128
100
  autoUpdate: this.#value("autoUpdate") === "1",
129
101
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
130
102
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
131
- // `"drop"`: a stored row whose name the bundled catalog has since taken
132
- // is redundant, not malformed — rejecting the setting over it would take
133
- // every other tool declared beside it (see normalizeCustomTools).
134
- customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, spec}") ?? [],
103
+ // `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
104
+ customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, toml}") ?? [],
135
105
  };
136
106
  }
137
- /**
138
- * A JSON-valued row, validated on the way out. Only a hand-edited row can be
139
- * malformed, and it is named rather than silently served as the empty value:
140
- * a setting that stopped applying without saying so is the bug this logs.
141
- */
107
+ /** A malformed row is named, not silently served as the empty value (§5). */
142
108
  #json(key, normalize, expected) {
143
109
  const raw = this.#value(key);
144
110
  if (!raw)
@@ -156,19 +122,16 @@ export class SettingsStore {
156
122
  log.warn(`settings.${key} is not ${expected} — ignoring it`);
157
123
  return value;
158
124
  }
159
- /** Store an already-normalized value — validation belongs at the boundary
160
- * that received it, so this never has to guess what the caller meant. */
125
+ /** Setters take already-normalized values: validation belongs at the boundary. */
161
126
  setPublicUrl(publicUrl) {
162
127
  this.#set("publicUrl", publicUrl);
163
128
  return this.get();
164
129
  }
165
- /** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
166
130
  setModelMenu(menu) {
167
131
  this.#set("modelMenu", JSON.stringify(menu));
168
132
  return this.get();
169
133
  }
170
- /** Same contract: hand this `normalizeModelRef`'s output; null switches
171
- * auto-titling off. */
134
+ /** null switches auto-titling off. */
172
135
  setTitleModel(ref) {
173
136
  this.#set("titleModel", ref ? JSON.stringify(ref) : "");
174
137
  return this.get();
@@ -177,27 +140,20 @@ export class SettingsStore {
177
140
  this.#set("autoUpdate", on ? "1" : "0");
178
141
  return this.get();
179
142
  }
180
- /** Same contract again: hand this `normalizeExtensions`'s output. */
181
143
  setExtensions(names) {
182
144
  this.#set("extensions", JSON.stringify(names));
183
145
  return this.get();
184
146
  }
185
- /** Same contract again: hand this `normalizeTools`'s output. */
186
147
  setTools(names) {
187
148
  this.#set("tools", JSON.stringify(names));
188
149
  return this.get();
189
150
  }
190
- /** Same contract again: hand this `normalizeCustomTools`'s output. */
191
151
  setCustomTools(tools) {
192
152
  this.#set("customTools", JSON.stringify(tools));
193
153
  return this.get();
194
154
  }
195
- /**
196
- * Several setters as one write. A request that declares a tool *and* the
197
- * switch that turns it on must not be able to store one without the other:
198
- * half of that pair is a switch nobody can explain — on and undeclared, or
199
- * declared and invisible.
200
- */
155
+ /** A request that declares a tool and switches it on must not store one
156
+ * without the other. */
201
157
  transact(work) {
202
158
  return transact(this.#db, work);
203
159
  }
@@ -1,17 +1,15 @@
1
- // A run that *is* a Pi session: which session it opens (fresh or reused),
2
- // what the child is told before the prompt, and how many may run at once. The
3
- // concurrency caps are here rather than in execution.ts because they bound
4
- // agents specifically — a bash run costs a process, an agent run costs a
5
- // model's context and someone's rate limit.
1
+ // A run that *is* a Pi session: which session it opens, what the child is told
2
+ // before the prompt, and how many may run at once (an agent run costs a
3
+ // model's context and someone's rate limit, so the caps are here).
6
4
  import { quietLabel, splitReply } from "../core/reply.js";
7
5
  import { logger } from "../log.js";
8
6
  import { runSource } from "./callbacks.js";
9
- const MAX_ACTIVE_AGENTS = 4;
7
+ // Agent runs are I/O-bound: the cap is there for API pressure and runaway
8
+ // fan-out, not for this machine's CPU.
9
+ const MAX_ACTIVE_AGENTS = 6;
10
10
  const log = logger("tasks");
11
- /** What a child cannot know unless told. Every session gets the chat-surface
12
- * contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
13
- * says which of it does not apply here, and a supervised run how to reach the
14
- * agent that is waiting on it. Skipped on resume: the session already saw it. */
11
+ /** Every session gets the chat-surface contract, task runs included, so the
12
+ * delegation prompt says which of it does not apply. Skipped on resume. */
15
13
  const preamble = (run) => {
16
14
  // A cron/watch task with a session callback is read by an agent too.
17
15
  const audience = run.invokedBySessionId
@@ -55,35 +53,25 @@ export class AgentTaskRunner {
55
53
  // A reused session may have become busy while we waited for a slot.
56
54
  await this.waitUntilIdle(session, signal);
57
55
  signal.throwIfAborted();
58
- // Task requests come seconds apart — the 1h Anthropic cache-write premium
59
- // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
60
- // resolveSession: this covers create, reuse and resume alike, on
61
- // every attempt — and only after the session is idle, so a reused
62
- // interactive session's in-flight turn keeps its 1h writes.
56
+ // Task requests come seconds apart, so the 1h cache-write premium never
57
+ // earns back; after idle, so a reused session's in-flight turn keeps its 1h.
63
58
  session.setCacheRetention("short");
64
59
  start();
65
- // No input is no block: `<task_input>\nnull\n</task_input>` is four
66
- // lines telling the agent nothing, on every run that has no input.
67
- // Compact for the same reason tool results are (agent/pi.ts), and
68
- // `<\/` is the same JSON — a value cannot close the fence early.
60
+ // No input is no block. `<\/` is the same JSON, so a value cannot close
61
+ // the fence early.
69
62
  const input = run.input === undefined || run.input === null
70
63
  ? ""
71
64
  : `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
72
65
  const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
73
66
  run.context.sessionId = session.id;
74
67
  run.context.model = session.model;
75
- // The level the session settled on, not the one the task asked for:
76
- // an unspecified effort inherits the caller's, and the card in the
77
- // subagent's own transcript should say which one that was.
68
+ // The level the session settled on: an unspecified effort inherits the caller's.
78
69
  run.context.thinking = session.thinkingLevel;
79
70
  run.context.renderedPrompt = prompt;
80
71
  this.store.saveRun(run);
81
72
  let text = "";
82
- // Not only what the turn said but how it ended: a provider outage ends
83
- // it with an empty reply, and a run that settles on that reports "no
84
- // reply" — the one wording for a turn that *chose* to say nothing, so
85
- // the outage arrives at the caller looking like an answer (§5b). Pi has
86
- // already retried by the time this lands; the run only reports.
73
+ // How it ended, too: a provider outage ends with an empty reply, which
74
+ // would otherwise report as a turn that chose to say nothing (§5).
87
75
  let failure;
88
76
  const unsubscribe = session.subscribe((event) => {
89
77
  if (event.type === "turn-end") {
@@ -113,27 +101,21 @@ export class AgentTaskRunner {
113
101
  await this.untilAborted(turn, signal);
114
102
  if (signal.aborted)
115
103
  throw new Error("cancelled");
116
- // Before the fallback below: on a reused session that reads the
117
- // *previous* turn's answer back out of history and reports it as
118
- // this run's result.
104
+ // Before the fallback: on a reused session it would read the previous
105
+ // turn's answer back as this run's result.
119
106
  if (failure)
120
107
  throw new Error(failure);
121
108
  if (!text) {
122
109
  const history = await this.untilAborted(session.history(), signal);
123
110
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
124
111
  }
125
- // The chat contract is injected into task sessions too, so a child's
126
- // reply may carry chat-only markup. The result is read by a
127
- // supervisor or the Console, never a chat renderer: buttons are
128
- // dropped, and a turn that said nothing names which kind of nothing
129
- // it was (principle 5b) instead of storing an empty result.
112
+ // The result is read by a supervisor, never a chat renderer: buttons
113
+ // are dropped, and an empty turn names which kind of nothing (§5).
130
114
  const reply = splitReply(text);
131
115
  return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
132
116
  }
133
117
  finally {
134
- // The run is what earns "short"; a reused interactive session goes
135
- // back to chat afterwards. For task-created sessions this is moot —
136
- // idle until the next run downgrades them again.
118
+ // A reused interactive session goes back to chat afterwards.
137
119
  session.setCacheRetention("long");
138
120
  signal.removeEventListener("abort", abort);
139
121
  unsubscribe();
@@ -150,9 +132,8 @@ export class AgentTaskRunner {
150
132
  return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
151
133
  }
152
134
  const policy = action.session;
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.
135
+ // Stored definitions may still say `"fork"`. Refused by name: any directory
136
+ // picked instead is a guess, and a child in the wrong tree edits real files.
156
137
  if (run.sessionMode === "fork" || policy.mode === "fork") {
157
138
  throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
158
139
  }
@@ -172,10 +153,8 @@ export class AgentTaskRunner {
172
153
  thinking: action.launch?.thinking,
173
154
  };
174
155
  const opening = this.factory.create(opts).then(async (session) => {
175
- // SDK creation cannot be cancelled. A late session still belongs to
176
- // this run; read back its terminal row so delivery updates are preserved.
177
- // Remember it on the live run too: cancellation's final save can race
178
- // this callback before it finishes writing the terminal record.
156
+ // SDK creation cannot be cancelled; a late session still belongs to this
157
+ // run. Cancellation's final save can race this callback, so both records get it.
179
158
  run.targetSessionId = session.id;
180
159
  run.context.sessionId = session.id;
181
160
  run.context.cwd = cwd;