@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  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 +8 -24
  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 +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  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 +6 -14
  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 +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  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 +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.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-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  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/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.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,9 +146,7 @@ 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
- export const vtCli = {
149
+ const vtCli = {
176
150
  read: (record) => run("vt", ["read", record]),
177
151
  create: async (plaintext) => {
178
152
  const out = await run("vt", ["create"], plaintext);
@@ -181,8 +155,7 @@ export 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
- export const UPDATE_UNIT_NAME = "pier-update.service";
19
- /** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
10
+ const UPDATE_UNIT_NAME = "pier-update.service";
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,13 +119,9 @@ 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
- */
149
- export function renderLimits() {
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. */
124
+ function renderLimits() {
150
125
  return `[Service]
151
126
  # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
152
127
  # instead of killing anything. This is the one that should bite first.
@@ -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,51 +24,51 @@ 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;
51
32
  const menu = [];
52
33
  for (const item of raw) {
53
- if (typeof item !== "object" || item === null)
34
+ const ref = normalizeModelRef(item);
35
+ if (!ref)
54
36
  return null;
55
- const { provider, id, thinking, note } = item;
56
- if (typeof provider !== "string" || !provider.trim())
57
- return null;
58
- if (typeof id !== "string" || !id.trim())
59
- return null;
60
- if (thinking !== undefined && !isThinkingLevel(thinking))
37
+ const { thinking, note } = item;
38
+ // Repaired, not rejected: rows stored before the level was required have
39
+ // none, and dropping the menu over it would lose the pins.
40
+ const level = thinking === undefined ? "medium" : thinking;
41
+ if (!isThinkingLevel(level))
61
42
  return null;
62
43
  if (note !== undefined && typeof note !== "string")
63
44
  return null;
64
45
  const cleaned = note?.trim().slice(0, 200);
65
46
  menu.push({
66
- provider: provider.trim(),
67
- id: id.trim(),
68
- ...(thinking !== undefined ? { thinking } : {}),
47
+ ...ref,
48
+ thinking: level,
69
49
  ...(cleaned ? { note: cleaned } : {}),
70
50
  });
71
51
  }
72
52
  return menu;
73
53
  }
74
- /**
75
- * Shape only — an unknown name is not an error here. This file must not know
76
- * what Pier bundles (that catalog is code, and importing it would drag the Pi
77
- * SDK into the instance layer); agent/ matches the names it recognizes and
78
- * ignores the rest, which is also what keeps a downgrade from losing a
79
- * setting it cannot currently explain.
80
- */
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. */
56
+ export function normalizeModelRef(raw) {
57
+ if (typeof raw !== "object" || raw === null)
58
+ return null;
59
+ const { provider, id } = raw;
60
+ if (typeof provider !== "string" || !provider.trim())
61
+ return null;
62
+ if (typeof id !== "string" || !id.trim())
63
+ return null;
64
+ return { provider: provider.trim(), id: id.trim() };
65
+ }
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. */
81
68
  export function normalizeExtensions(raw) {
82
69
  return normalizeNames(raw);
83
70
  }
84
- /** Same shape, same contract, same cap — the managed-tool set (src/tools.ts).
85
- * Shape only again: tools.ts owns the catalog, and a name it does not know is
86
- * ignored there rather than rejected here, so a downgrade cannot lose one. */
71
+ /** Shape only, for the same reason: tools.ts owns the catalog. */
87
72
  export function normalizeTools(raw) {
88
73
  return normalizeNames(raw);
89
74
  }
@@ -107,23 +92,19 @@ export class SettingsStore {
107
92
  this.#db = db;
108
93
  }
109
94
  get() {
95
+ const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
110
96
  return {
111
97
  publicUrl: this.#value("publicUrl") ?? "",
112
98
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
99
+ ...(titleModel ? { titleModel } : {}),
113
100
  autoUpdate: this.#value("autoUpdate") === "1",
114
101
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
115
102
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
116
- // `"drop"`: a stored row whose name the bundled catalog has since taken
117
- // is redundant, not malformed — rejecting the setting over it would take
118
- // every other tool declared beside it (see normalizeCustomTools).
119
- 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}") ?? [],
120
105
  };
121
106
  }
122
- /**
123
- * A JSON-valued row, validated on the way out. Only a hand-edited row can be
124
- * malformed, and it is named rather than silently served as the empty value:
125
- * a setting that stopped applying without saying so is the bug this logs.
126
- */
107
+ /** A malformed row is named, not silently served as the empty value (§5). */
127
108
  #json(key, normalize, expected) {
128
109
  const raw = this.#value(key);
129
110
  if (!raw)
@@ -141,42 +122,38 @@ export class SettingsStore {
141
122
  log.warn(`settings.${key} is not ${expected} — ignoring it`);
142
123
  return value;
143
124
  }
144
- /** Store an already-normalized value — validation belongs at the boundary
145
- * that received it, so this never has to guess what the caller meant. */
125
+ /** Setters take already-normalized values: validation belongs at the boundary. */
146
126
  setPublicUrl(publicUrl) {
147
127
  this.#set("publicUrl", publicUrl);
148
128
  return this.get();
149
129
  }
150
- /** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
151
130
  setModelMenu(menu) {
152
131
  this.#set("modelMenu", JSON.stringify(menu));
153
132
  return this.get();
154
133
  }
134
+ /** null switches auto-titling off. */
135
+ setTitleModel(ref) {
136
+ this.#set("titleModel", ref ? JSON.stringify(ref) : "");
137
+ return this.get();
138
+ }
155
139
  setAutoUpdate(on) {
156
140
  this.#set("autoUpdate", on ? "1" : "0");
157
141
  return this.get();
158
142
  }
159
- /** Same contract again: hand this `normalizeExtensions`'s output. */
160
143
  setExtensions(names) {
161
144
  this.#set("extensions", JSON.stringify(names));
162
145
  return this.get();
163
146
  }
164
- /** Same contract again: hand this `normalizeTools`'s output. */
165
147
  setTools(names) {
166
148
  this.#set("tools", JSON.stringify(names));
167
149
  return this.get();
168
150
  }
169
- /** Same contract again: hand this `normalizeCustomTools`'s output. */
170
151
  setCustomTools(tools) {
171
152
  this.#set("customTools", JSON.stringify(tools));
172
153
  return this.get();
173
154
  }
174
- /**
175
- * Several setters as one write. A request that declares a tool *and* the
176
- * switch that turns it on must not be able to store one without the other:
177
- * half of that pair is a switch nobody can explain — on and undeclared, or
178
- * declared and invisible.
179
- */
155
+ /** A request that declares a tool and switches it on must not store one
156
+ * without the other. */
180
157
  transact(work) {
181
158
  return transact(this.#db, work);
182
159
  }