@timqi/pier 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +87 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack-tool.js +28 -3
  14. package/dist/channels/slack.js +20 -9
  15. package/dist/channels/telegram-api.js +3 -4
  16. package/dist/channels/telegram.js +37 -28
  17. package/dist/cli.js +177 -29
  18. package/dist/core/hub.js +36 -5
  19. package/dist/core/identity.js +5 -0
  20. package/dist/core/inbound-file.js +70 -0
  21. package/dist/core/inbox.js +32 -0
  22. package/dist/core/queue.js +9 -3
  23. package/dist/core/reply.js +20 -5
  24. package/dist/core/router.js +200 -14
  25. package/dist/core/types.js +53 -0
  26. package/dist/db.js +54 -8
  27. package/dist/drain.js +145 -0
  28. package/dist/main.js +180 -18
  29. package/dist/secrets.js +10 -6
  30. package/dist/service.js +192 -18
  31. package/dist/settings.js +77 -8
  32. package/dist/tasks/agent.js +41 -5
  33. package/dist/tasks/callbacks.js +29 -89
  34. package/dist/tasks/definitions.js +2 -6
  35. package/dist/tasks/execution.js +10 -1
  36. package/dist/tasks/groups.js +20 -49
  37. package/dist/tasks/messages.js +106 -21
  38. package/dist/tasks/outbox.js +157 -0
  39. package/dist/tasks/routes.js +6 -4
  40. package/dist/tasks/service.js +92 -22
  41. package/dist/tasks/store.js +48 -55
  42. package/dist/tasks/tool.js +19 -4
  43. package/dist/tasks/types.js +7 -0
  44. package/dist/update.js +146 -0
  45. package/dist/web/auth.js +89 -26
  46. package/dist/web/explorer.js +147 -0
  47. package/dist/web/files.js +28 -12
  48. package/dist/web/instance.js +165 -0
  49. package/dist/web/provider-flows.js +249 -0
  50. package/dist/web/providers.js +141 -0
  51. package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
  52. package/dist/web/public/assets/index-fASxMPr6.js +90 -0
  53. package/dist/web/public/icon-192.png +0 -0
  54. package/dist/web/public/icon-32.png +0 -0
  55. package/dist/web/public/icon-512.png +0 -0
  56. package/dist/web/public/icon-maskable-512.png +0 -0
  57. package/dist/web/public/icon-touch-192.png +0 -0
  58. package/dist/web/public/icon.svg +29 -11
  59. package/dist/web/public/index.html +50 -32
  60. package/dist/web/server.js +110 -120
  61. package/docs/deploy.md +142 -64
  62. package/package.json +1 -1
  63. package/skills/pier-boards/SKILL.md +16 -7
  64. package/skills/pier-help/SKILL.md +110 -0
  65. package/skills/pier-slack/SKILL.md +20 -3
  66. package/skills/pier-tasks/SKILL.md +19 -12
  67. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  68. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  69. package/dist/web/public/sw.js +0 -21
package/README.md CHANGED
@@ -17,8 +17,9 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
17
17
  ## Requirements
18
18
 
19
19
  - Node 24 or newer (`node:sqlite` is used unflagged)
20
- - A Pi provider key (Anthropic, OpenAI, …) — Pi's own config, in
21
- `~/.pi/agent/settings.json`, editable from Console → Configuration
20
+ - A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
21
+ login from Console → Settings → Providers after signing in
22
+ - A user-writable global npm prefix if `pier update` should update a service
22
23
  - Optional: the `sqlite3` CLI, for backups and password resets
23
24
 
24
25
  ## Run it
@@ -29,33 +30,87 @@ pier
29
30
  ```
30
31
 
31
32
  It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
32
- `~/.pier` (`PIER_HOME`): one SQLite database and the boards it serves.
33
+ `~/.pier` (`PIER_HOME`): one SQLite database, the boards it serves, the
34
+ `master.key` that seals stored credentials, and the Pi runtime with its
35
+ session transcripts (`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says
36
+ otherwise).
33
37
 
34
38
  **The first start generates a password and prints it once.** Every HTTP surface
35
39
  is behind it — there is no default password and no unclaimed window. Lost it?
36
- `sqlite3 ~/.pier/pier.db 'DELETE FROM auth'` and restart; a new one is printed.
40
+ `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart; a new one is printed.
37
41
 
38
42
  Open `http://localhost:3141`, sign in, then:
39
43
 
40
- - **Console → Configuration** — provider keys and model defaults (Pi's files)
41
- - **Console Channels** — Slack (Socket Mode) or Telegram bot tokens; chats
42
- are discovered when the bot first sees traffic, and stay gated by the
43
- mention/bind rules you set
44
+ - **Console → Settings** — everything the instance is configured with, one
45
+ tab per topic: Providers (API-key/OAuth logins), Models (the menu of
46
+ favored models agents are advised with), Channels (Slack Socket-Mode or
47
+ Telegram bot tokens; chats are discovered when the bot first sees traffic,
48
+ gated by the mention/bind rules you set), Agent files (Pi configuration),
49
+ plus the public URL, password and master key
44
50
  - **New session** — pick a directory; that is where the agent's shell runs
45
51
 
52
+ ## Configure Pi
53
+
54
+ Pier gives Pi a dedicated agent directory instead of changing your normal Pi
55
+ installation. By default it is `$PIER_HOME/pi` (`~/.pier/pi`). Set
56
+ `PI_CODING_AGENT_DIR` before starting Pier to use another directory, including
57
+ an existing Pi setup:
58
+
59
+ ```sh
60
+ PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier
61
+ ```
62
+
63
+ Console → Settings is the normal setup path:
64
+
65
+ - **Providers** configures built-in or custom endpoints and API-key/OAuth login.
66
+ Stored credentials are sealed in Pier's SQLite database; they are not written
67
+ back to `models.json`.
68
+ - **Agent files** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
69
+ `models.json` structure in the Pi agent directory — globally, or per project
70
+ scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
71
+ resources. Changes apply when a session next opens; `pier reload` recycles
72
+ idle, unwatched sessions so their next message uses the current files.
73
+
74
+ On first credential access, Pier imports an existing `auth.json` into its sealed
75
+ store and renames the source to `auth.json.imported`. Literal provider keys left
76
+ in `models.json` are moved the same way, with the original retained as
77
+ `models.json.imported`. Use Providers for new secrets; the advanced editor will
78
+ not accept plaintext keys or header values.
79
+
80
+ Provider environment variables supported by Pi are inherited from the Pier
81
+ process. A service installed with `pier service install` does not inherit your
82
+ interactive shell, so put non-secret Pi environment settings in a systemd unit
83
+ override; for API keys, prefer the sealed Providers UI.
84
+
46
85
  ## Run it as a service
47
86
 
48
87
  ```sh
49
88
  pier service install # --port, --host, --pier-home, --force
50
89
  pier service status
51
90
  pier service uninstall
91
+ pier restart # drain running work, then restart
92
+ pier reload # re-read channel config and recycle idle sessions
93
+ pier backup # snapshot the database before a manual update
94
+ pier update # latest release, then hard-stop/restart the service
52
95
  ```
53
96
 
97
+ `pier restart` refuses new work, waits up to five minutes for active turns and
98
+ Task runs, then restarts; if its deadline aborts an IM turn, the next process
99
+ tells that conversation. `pier reload` stays in-process: adapters re-read their
100
+ configuration and idle, unwatched sessions reopen on their next message.
101
+ Streaming or watched sessions keep running and pick changes up after eviction.
102
+ Both commands target the installed systemd service. `pier update` is still a
103
+ hard stop because its separate updater replaces the installed code; let active
104
+ work finish before starting it.
105
+
54
106
  Linux only, because it is systemd. It writes `~/.config/systemd/user/pier.service`
55
107
  with the absolute path of the node you installed with (systemd's PATH would not
56
108
  find a version-managed one), a memory drop-in it never rewrites afterwards, and
57
- turns on linger so scheduled tasks survive your logout. On macOS run `pier` in
58
- a terminal, or under whatever supervisor you already use.
109
+ turns on linger so scheduled tasks survive your logout. Install also records the
110
+ exact npm executable in a separate updater unit. Re-run `pier service install
111
+ --force` after changing the service settings or its Node/npm installation; this
112
+ rewrites both units and restarts Pier. On macOS run `pier` in a terminal, or
113
+ under whatever supervisor you already use.
59
114
 
60
115
  `docs/deploy.md` is the same thing written out by hand, plus what the memory
61
116
  limits mean, how updates work (and why the updater is a second unit), how to
@@ -84,11 +139,31 @@ npm test # vitest
84
139
 
85
140
  ## Releases
86
141
 
87
- `main` is the only development line. `npm version minor` writes the tag, the
142
+ Pier asks `registry.npmjs.org` at boot and every 30 minutes, and the version
143
+ beside the title turns into `v0.0.1 → 0.0.2` when a release is out. Clicking it
144
+ opens the panel: the source link, **Update now**, and **Update automatically**.
145
+
146
+ Nothing here installs anything itself — the work is handed to the second
147
+ systemd unit written at install time, because an npm running as a child of the
148
+ process being restarted would be killed by that restart. Off systemd there is
149
+ nothing to hand it to, so the panel says `pier update` instead.
150
+
151
+ `pier update` typed in a terminal hard-stops the service. The Console and the
152
+ automatic path both **drain first** — new work refused, running turns given
153
+ time to finish, whatever the deadline still had to cut off written to the chat
154
+ it belonged to — and only then hand over. The automatic path additionally waits
155
+ for an idle instance: no turn streaming, no task run in flight.
156
+
157
+ Either way the updater writes `~/.pier/db/pier.db.release.bak` before npm
158
+ touches the package, updates the npm installation recorded when the service was
159
+ installed, and starts Pier again.
160
+
161
+ `main` is the only development line. `npm version patch` writes the tag, the
88
162
  tag builds and publishes a GitHub Release, and the version in the web footer is
89
163
  the one from `package.json` — so the number on screen always names a commit.
90
164
  Schema upgrades are one-way: a database migrated by a newer Pier is refused by
91
- an older one, so take the backup `docs/deploy.md` describes before upgrading.
165
+ an older one. The release backup above is the way back; `docs/deploy.md` has the
166
+ restore procedure and the additional snapshots taken before schema migrations.
92
167
 
93
168
  ## License
94
169
 
@@ -1,8 +1,10 @@
1
1
  // Pi configuration on disk, behind the ConfigStore seam. Knows Pi's directory
2
2
  // conventions (the Pier-managed global dir; <cwd>/AGENTS.md and <cwd>/.pi per
3
3
  // project) but not the Pi SDK — pure filesystem, unit-testable in a tmp dir.
4
+ import { randomUUID } from "node:crypto";
4
5
  import { promises as fs } from "node:fs";
5
6
  import { join, resolve, sep } from "node:path";
7
+ import { isProviderApi, validateEndpoint, validateProviderSetup } from "../core/types.js";
6
8
  import { pierPath } from "../paths.js";
7
9
  const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
8
10
  const PROJECT_FILES = ["AGENTS.md"];
@@ -11,13 +13,56 @@ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
11
13
  * repo, not `~/.pi`. main.ts exports this as PI_CODING_AGENT_DIR so the SDK's
12
14
  * own path resolution (auth.json, sessions, bin) lands in the same place. */
13
15
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
14
- /** Stable mask: recomputable at write time, so "unchanged" is detectable. */
15
- const maskKey = (key) => key.length > 8 ? `${key.slice(0, 4)}…${key.slice(-4)}` : "•••";
16
+ /** Stable mask: mapped back by field, without exposing key fragments. */
17
+ const maskKey = (_key) => "••••••••";
18
+ const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
19
+ const readOptional = async (path) => {
20
+ try {
21
+ return await fs.readFile(path, "utf8");
22
+ }
23
+ catch (err) {
24
+ if (missing(err))
25
+ return "";
26
+ throw err;
27
+ }
28
+ };
29
+ const pathExists = async (path) => {
30
+ try {
31
+ await fs.access(path);
32
+ return true;
33
+ }
34
+ catch (err) {
35
+ if (missing(err))
36
+ return false;
37
+ throw err;
38
+ }
39
+ };
40
+ const atomicWrite = async (path, data, mode = 0o644) => {
41
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
42
+ try {
43
+ await fs.writeFile(temp, data, { mode });
44
+ await fs.rename(temp, path);
45
+ }
46
+ catch (err) {
47
+ try {
48
+ await fs.unlink(temp);
49
+ }
50
+ catch (cleanup) {
51
+ if (!missing(cleanup))
52
+ throw new AggregateError([err, cleanup], `failed to write ${path}`);
53
+ }
54
+ throw err;
55
+ }
56
+ };
16
57
  export class PiConfigStore {
17
58
  agentDir;
59
+ #writes = Promise.resolve();
18
60
  constructor(agentDir = defaultAgentDir()) {
19
61
  this.agentDir = agentDir;
20
62
  }
63
+ get globalDir() {
64
+ return this.agentDir;
65
+ }
21
66
  /** Whitelist is the security boundary — nothing outside it is reachable. */
22
67
  fileNames(scope) {
23
68
  return scope.kind === "global" ? GLOBAL_FILES : PROJECT_FILES;
@@ -36,20 +81,128 @@ export class PiConfigStore {
36
81
  async listFiles(scope) {
37
82
  return Promise.all(this.fileNames(scope).map(async (name) => ({
38
83
  name,
39
- exists: await fs.access(this.filePath(scope, name)).then(() => true, () => false),
84
+ exists: await pathExists(this.filePath(scope, name)),
40
85
  })));
41
86
  }
42
87
  async readFile(scope, name) {
43
- const raw = await fs.readFile(this.filePath(scope, name), "utf8").catch(() => "");
88
+ const path = this.filePath(scope, name);
89
+ await this.#writes;
90
+ const raw = await readOptional(path);
44
91
  return name === "models.json" ? maskModels(raw) : raw;
45
92
  }
46
- async writeFile(scope, name, content) {
93
+ async writeFile(scope, name, content, expected) {
47
94
  const path = this.filePath(scope, name);
48
- const data = name === "models.json"
49
- ? unmaskModels(content, await fs.readFile(path, "utf8").catch(() => ""))
50
- : content;
51
- await fs.mkdir(scope.kind === "global" ? this.agentDir : scope.cwd, { recursive: true });
52
- await fs.writeFile(path, data);
95
+ return this.#withWrite(async () => {
96
+ const current = await readOptional(path);
97
+ const visible = name === "models.json" ? maskModels(current) : current;
98
+ if (expected !== undefined && visible !== expected)
99
+ throw new Error(`${name} changed on disk; reopen it`);
100
+ const data = name === "models.json" ? unmaskModels(content, current) : content;
101
+ await fs.mkdir(scope.kind === "global" ? this.agentDir : scope.cwd, { recursive: true });
102
+ await atomicWrite(path, data, name === "models.json" ? 0o600 : 0o644);
103
+ });
104
+ }
105
+ async providerStructures() {
106
+ await this.#writes;
107
+ const raw = await readOptional(join(this.agentDir, "models.json"));
108
+ const parsed = parseModels(raw);
109
+ const out = {};
110
+ for (const [id, provider] of Object.entries(parsed?.providers ?? {})) {
111
+ const api = isProviderApi(provider.api) ? provider.api : undefined;
112
+ const endpoint = typeof provider.baseUrl === "string" ? provider.baseUrl : undefined;
113
+ if (endpoint)
114
+ validateEndpoint(endpoint);
115
+ const models = Array.isArray(provider.models)
116
+ ? provider.models.flatMap((model) => {
117
+ if (typeof model !== "object" || model === null || typeof model.id !== "string") {
118
+ return [];
119
+ }
120
+ return [{
121
+ id: model.id,
122
+ reasoning: model.reasoning === true,
123
+ }];
124
+ })
125
+ : undefined;
126
+ out[id] = {
127
+ ...(typeof provider.name === "string" ? { name: provider.name } : {}),
128
+ ...(endpoint ? { endpoint } : {}),
129
+ ...(api ? { api } : {}),
130
+ ...(models ? { models } : {}),
131
+ };
132
+ }
133
+ return out;
134
+ }
135
+ async setupProvider(input, verify) {
136
+ validateProviderSetup(input);
137
+ await this.#withWrite(async () => {
138
+ const path = join(this.agentDir, "models.json");
139
+ const existed = await pathExists(path);
140
+ const raw = await readOptional(path);
141
+ const parsed = raw.trim() ? parseModels(raw) : {};
142
+ if (!parsed)
143
+ throw new Error("models.json must be valid JSON before configuring a provider");
144
+ const providers = { ...parsed.providers };
145
+ const current = { ...providers[input.id] };
146
+ if (input.kind === "builtin") {
147
+ if (input.endpoint)
148
+ current.baseUrl = input.endpoint;
149
+ else
150
+ delete current.baseUrl;
151
+ }
152
+ else {
153
+ if (input.name)
154
+ current.name = input.name;
155
+ else
156
+ delete current.name;
157
+ current.baseUrl = input.endpoint;
158
+ current.api = input.api;
159
+ const existingModels = Array.isArray(current.models) ? current.models : [];
160
+ current.models = input.models.map((model) => {
161
+ const existing = existingModels.find((candidate) => typeof candidate === "object" && candidate !== null &&
162
+ candidate.id === model.id);
163
+ const next = {
164
+ ...existing, id: model.id,
165
+ };
166
+ if (model.reasoning)
167
+ next.reasoning = true;
168
+ else
169
+ delete next.reasoning;
170
+ return next;
171
+ });
172
+ }
173
+ if (Object.keys(current).length)
174
+ providers[input.id] = current;
175
+ else
176
+ delete providers[input.id];
177
+ const candidate = `${JSON.stringify({ ...parsed, providers }, null, 2)}\n`;
178
+ await fs.mkdir(this.agentDir, { recursive: true });
179
+ await atomicWrite(path, candidate, 0o600);
180
+ try {
181
+ await verify?.();
182
+ }
183
+ catch (err) {
184
+ // Decided outside the restore try: a concurrent edit is not a failed
185
+ // restore, and must not be reported as one.
186
+ if (await readOptional(path) !== candidate) {
187
+ throw new Error(`${path} changed while provider setup was being validated`, { cause: err });
188
+ }
189
+ try {
190
+ if (existed)
191
+ await atomicWrite(path, raw, 0o600);
192
+ else
193
+ await fs.unlink(path);
194
+ }
195
+ catch (rollback) {
196
+ throw new AggregateError([err, rollback], `failed to restore ${path}`);
197
+ }
198
+ throw err;
199
+ }
200
+ });
201
+ }
202
+ #withWrite(write) {
203
+ const result = this.#writes.then(write);
204
+ this.#writes = result.then(() => undefined, () => undefined);
205
+ return result;
53
206
  }
54
207
  async listResources(scope) {
55
208
  return {
@@ -91,41 +244,134 @@ async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false
91
244
  }
92
245
  return out.sort((a, b) => a.name.localeCompare(b.name));
93
246
  }
94
- /**
95
- * models.json carries provider API keys the UI must never see them. A file
96
- * that fails to parse is passed through untouched: the user needs to see the
97
- * broken content to repair it, and the keys in it are their own.
98
- */
247
+ /** models.json may contain legacy keys and literal headers. Only stable
248
+ * masks cross the HTTP seam; malformed content must be repaired on disk. */
99
249
  function maskModels(raw) {
250
+ if (!raw.trim())
251
+ return "";
100
252
  const parsed = parseModels(raw);
101
253
  if (!parsed)
102
- return raw;
103
- for (const p of Object.values(parsed.providers ?? {})) {
104
- if (typeof p.apiKey === "string" && p.apiKey)
105
- p.apiKey = maskKey(p.apiKey);
254
+ throw new Error("models.json must be valid JSON; repair it on disk");
255
+ for (const provider of Object.values(parsed.providers ?? {})) {
256
+ validateBaseUrl(provider);
257
+ if (provider.apiKey !== undefined && typeof provider.apiKey !== "string") {
258
+ throw new Error("models.json apiKey must be a string");
259
+ }
260
+ if (provider.apiKey)
261
+ provider.apiKey = maskKey(provider.apiKey);
262
+ maskHeaders(provider);
263
+ for (const model of modelRecords(provider)) {
264
+ validateBaseUrl(model);
265
+ maskHeaders(model);
266
+ }
267
+ for (const override of overrideRecords(provider))
268
+ maskHeaders(override);
106
269
  }
107
270
  return JSON.stringify(parsed, null, 2);
108
271
  }
109
- /** Restore stored keys wherever the incoming value is still the mask. */
272
+ /** Restore stored secrets wherever the incoming value is still its mask. */
110
273
  function unmaskModels(content, currentRaw) {
111
274
  const incoming = parseModels(content);
112
275
  if (!incoming)
113
276
  throw new Error("models.json must be valid JSON");
114
277
  const current = parseModels(currentRaw);
115
- for (const [name, p] of Object.entries(incoming.providers ?? {})) {
116
- const stored = current?.providers?.[name]?.apiKey;
117
- if (typeof p.apiKey === "string" &&
118
- typeof stored === "string" &&
119
- p.apiKey === maskKey(stored)) {
120
- p.apiKey = stored;
278
+ for (const [name, provider] of Object.entries(incoming.providers ?? {})) {
279
+ validateBaseUrl(provider);
280
+ const stored = current?.providers?.[name];
281
+ if (provider.apiKey !== undefined) {
282
+ if (typeof provider.apiKey !== "string" || typeof stored?.apiKey !== "string" ||
283
+ provider.apiKey !== maskKey(stored.apiKey)) {
284
+ throw new Error("API keys must be configured under Providers");
285
+ }
286
+ provider.apiKey = stored.apiKey;
287
+ }
288
+ restoreHeaders(provider, stored);
289
+ const storedModels = new Map(modelRecords(stored).flatMap((model) => typeof model.id === "string" ? [[model.id, model]] : []));
290
+ for (const model of modelRecords(provider)) {
291
+ validateBaseUrl(model);
292
+ restoreHeaders(model, typeof model.id === "string" ? storedModels.get(model.id) : undefined);
293
+ }
294
+ const storedOverrides = asRecord(stored?.modelOverrides);
295
+ for (const [id, override] of overrideRecords(provider, true)) {
296
+ restoreHeaders(override, asRecord(storedOverrides?.[id]));
121
297
  }
122
298
  }
123
299
  return JSON.stringify(incoming, null, 2);
124
300
  }
301
+ const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
302
+ ? value
303
+ : undefined;
304
+ function modelRecords(provider) {
305
+ if (provider?.models === undefined)
306
+ return [];
307
+ if (!Array.isArray(provider.models))
308
+ throw new Error("models.json models must be an array");
309
+ return provider.models.map((model) => {
310
+ const record = asRecord(model);
311
+ if (!record)
312
+ throw new Error("models.json models must contain objects");
313
+ return record;
314
+ });
315
+ }
316
+ function overrideRecords(provider, entries = false) {
317
+ if (provider.modelOverrides === undefined)
318
+ return [];
319
+ const overrides = asRecord(provider.modelOverrides);
320
+ if (!overrides)
321
+ throw new Error("models.json modelOverrides must be an object");
322
+ const records = Object.entries(overrides).map(([id, value]) => {
323
+ const record = asRecord(value);
324
+ if (!record)
325
+ throw new Error("models.json modelOverrides must contain objects");
326
+ return [id, record];
327
+ });
328
+ return entries ? records : records.map(([, record]) => record);
329
+ }
330
+ function validateBaseUrl(owner) {
331
+ if (owner.baseUrl === undefined)
332
+ return;
333
+ if (typeof owner.baseUrl !== "string")
334
+ throw new Error("models.json baseUrl must be a string");
335
+ validateEndpoint(owner.baseUrl);
336
+ }
337
+ function maskHeaders(owner) {
338
+ if (owner.headers === undefined)
339
+ return;
340
+ const headers = asRecord(owner.headers);
341
+ if (!headers || Object.values(headers).some((value) => typeof value !== "string")) {
342
+ throw new Error("models.json headers must contain string values");
343
+ }
344
+ for (const [name, value] of Object.entries(headers))
345
+ headers[name] = maskKey(value);
346
+ }
347
+ function restoreHeaders(incoming, current) {
348
+ if (incoming.headers === undefined)
349
+ return;
350
+ const headers = asRecord(incoming.headers);
351
+ const stored = asRecord(current?.headers);
352
+ if (!headers || !stored)
353
+ throw new Error("Header values must be configured on disk");
354
+ for (const [name, value] of Object.entries(headers)) {
355
+ const original = stored[name];
356
+ if (typeof value !== "string" || typeof original !== "string" || value !== maskKey(original)) {
357
+ throw new Error("Header values must be configured on disk");
358
+ }
359
+ headers[name] = original;
360
+ }
361
+ }
125
362
  function parseModels(raw) {
126
363
  try {
127
364
  const parsed = JSON.parse(raw);
128
- return typeof parsed === "object" && parsed !== null ? parsed : null;
365
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
366
+ return null;
367
+ const providers = parsed.providers;
368
+ if (providers !== undefined) {
369
+ if (typeof providers !== "object" || providers === null || Array.isArray(providers))
370
+ return null;
371
+ if (Object.values(providers).some((provider) => typeof provider !== "object" || provider === null || Array.isArray(provider)))
372
+ return null;
373
+ }
374
+ return parsed;
129
375
  }
130
376
  catch {
131
377
  return null;
@@ -8,12 +8,10 @@
8
8
  // sweep below leave models.json purely structural — and safely syncable.
9
9
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
+ import { isDeepStrictEqual } from "node:util";
11
12
  import { logger } from "../log.js";
12
13
  import { defaultAgentDir } from "./config.js";
13
14
  const log = logger("credentials");
14
- /** Envelope from secrets.ts. A value that matches was sealed by us; anything
15
- * else is legacy plaintext, still honored and re-sealed on the next write. */
16
- const SEALED = /^v1:[0-9a-f]{8}:/;
17
15
  export class CredentialStore {
18
16
  db;
19
17
  secrets;
@@ -55,19 +53,25 @@ export class CredentialStore {
55
53
  return next;
56
54
  });
57
55
  }
56
+ async replaceIfCurrent(providerId, current, replacement) {
57
+ return this.#serialized(undefined, async () => {
58
+ if (!isDeepStrictEqual(this.#get(providerId), current))
59
+ return false;
60
+ if (replacement)
61
+ this.#put(providerId, replacement);
62
+ else
63
+ this.db.prepare("DELETE FROM credentials WHERE key = ?").run(providerId);
64
+ return true;
65
+ });
66
+ }
58
67
  async delete(providerId, options) {
59
68
  return this.#serialized(options, async () => {
60
69
  this.db.prepare("DELETE FROM credentials WHERE key = ?").run(providerId);
61
70
  });
62
71
  }
63
72
  #serialized(options, run) {
64
- const op = this.#chain.then(() => {
65
- options?.signal?.throwIfAborted();
66
- this.#ensureImported();
67
- return run();
68
- },
69
73
  // The chain only sequences; a predecessor's failure is its caller's news.
70
- () => {
74
+ const op = this.#chain.catch(() => { }).then(() => {
71
75
  options?.signal?.throwIfAborted();
72
76
  this.#ensureImported();
73
77
  return run();
@@ -80,8 +84,8 @@ export class CredentialStore {
80
84
  return row ? this.#parse(row.value) : undefined;
81
85
  }
82
86
  #parse(value) {
83
- const plain = SEALED.test(value) ? this.secrets.decrypt(value) : value;
84
- return JSON.parse(plain);
87
+ // Every row was sealed by #put — the table has never held plaintext.
88
+ return JSON.parse(this.secrets.decrypt(value));
85
89
  }
86
90
  #put(providerId, credential) {
87
91
  this.db
@@ -169,7 +173,9 @@ export class CredentialStore {
169
173
  if (moved.length === 0 && shadowed.length === 0)
170
174
  return;
171
175
  writeFileSync(`${path}.imported`, raw, { mode: 0o600 });
172
- writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
176
+ // Atomic swap: a crash mid-write must not truncate the file Pi reads.
177
+ writeFileSync(`${path}.tmp`, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
178
+ renameSync(`${path}.tmp`, path);
173
179
  if (moved.length)
174
180
  log.info(`moved ${moved.length} literal API key(s) from models.json into pier.db: ${moved.join(", ")}`);
175
181
  if (shadowed.length)
@@ -12,17 +12,6 @@ export function textOf(content) {
12
12
  .map((p) => p.text)
13
13
  .join("");
14
14
  }
15
- function imageParts(m) {
16
- return Array.isArray(m.content) ? m.content.filter((p) => p.type === "image") : [];
17
- }
18
- /** Renderable text of a message, with an image marker (IM can't show them). */
19
- function displayText(m) {
20
- const text = textOf(m.content);
21
- const images = imageParts(m).length;
22
- if (!images)
23
- return text;
24
- return `${text}${text ? " " : ""}[${images} image${images > 1 ? "s" : ""}]`;
25
- }
26
15
  function systemOrigin(message) {
27
16
  if (message.role !== "custom" || message.customType !== "pier.system-input")
28
17
  return null;
@@ -44,23 +33,6 @@ function systemOrigin(message) {
44
33
  return origin;
45
34
  return null;
46
35
  }
47
- /**
48
- * Bytes of the ordinal-th transcript image. Walks user/assistant messages in
49
- * the same order toChatTurns numbers them — the two must not drift.
50
- */
51
- export function imageAt(messages, ordinal) {
52
- let n = 0;
53
- for (const m of messages) {
54
- if (m.role !== "user" && m.role !== "assistant")
55
- continue;
56
- for (const p of imageParts(m)) {
57
- if (n++ !== ordinal)
58
- continue;
59
- return p.data ? { data: p.data, mimeType: p.mimeType ?? "image/png" } : undefined;
60
- }
61
- }
62
- return undefined;
63
- }
64
36
  function lastAssistant(messages) {
65
37
  if (!messages)
66
38
  return undefined;
@@ -113,8 +85,7 @@ export function toChatTurns(messages) {
113
85
  const turns = [];
114
86
  let steps = []; // activity seen since the last emitted turn
115
87
  const pendingTools = new Map();
116
- let ordinal = 0; // must advance exactly like imageAt() walks the messages
117
- const flush = (role, text, meta, origin, images) => {
88
+ const flush = (role, text, meta, origin) => {
118
89
  const turn = { role, text };
119
90
  if (meta)
120
91
  turn.meta = meta;
@@ -124,8 +95,6 @@ export function toChatTurns(messages) {
124
95
  turn.steps = steps;
125
96
  steps = [];
126
97
  }
127
- if (images?.length)
128
- turn.images = images;
129
98
  turns.push(turn);
130
99
  };
131
100
  for (const [i, m] of messages.entries()) {
@@ -140,7 +109,7 @@ export function toChatTurns(messages) {
140
109
  }
141
110
  const origin = systemOrigin(m);
142
111
  if (origin) {
143
- const text = displayText(m);
112
+ const text = textOf(m.content);
144
113
  if (text)
145
114
  flush("system", text, undefined, origin);
146
115
  continue;
@@ -165,16 +134,11 @@ export function toChatTurns(messages) {
165
134
  }
166
135
  }
167
136
  }
168
- // Refs, not bytes: the web fetches each image from its own route.
169
- const images = imageParts(m).map((p) => ({
170
- mimeType: p.mimeType ?? "image/png",
171
- ordinal: ordinal++,
172
- }));
173
137
  const text = textOf(m.content);
174
138
  // step-only assistant messages keep buffering activity
175
- if (!text && !images.length)
139
+ if (!text)
176
140
  continue;
177
- flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined, undefined, images);
141
+ flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined);
178
142
  }
179
143
  // Activity with no answer after it (aborted run) still belongs on the page.
180
144
  if (steps.length)
@@ -204,7 +168,7 @@ export function toSessionEvents(e) {
204
168
  if (!m)
205
169
  return [];
206
170
  const origin = systemOrigin(m);
207
- const text = displayText(m);
171
+ const text = textOf(m.content);
208
172
  if (origin)
209
173
  return text ? [{ type: "system-input", text, origin }] : [];
210
174
  if (m.role !== "user")