@timqi/pier 0.1.7 → 0.1.8

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 (93) hide show
  1. package/dist/agent/credentials.js +5 -2
  2. package/dist/agent/pi.js +1 -1
  3. package/dist/boards/pier.css +58 -39
  4. package/dist/channels/handoff.js +1 -1
  5. package/dist/cli.js +8 -0
  6. package/dist/db.js +13 -0
  7. package/dist/main.js +7 -2
  8. package/dist/secrets.js +15 -1
  9. package/dist/settings.js +28 -0
  10. package/dist/socket.js +13 -1
  11. package/dist/web/auth.js +152 -29
  12. package/dist/web/instance.js +19 -4
  13. package/dist/web/passkeys.js +438 -0
  14. package/dist/web/public/assets/activity-CImcJ4xF.js +5 -0
  15. package/dist/web/public/assets/activity-CImcJ4xF.js.br +0 -0
  16. package/dist/web/public/assets/activity-CImcJ4xF.js.gz +0 -0
  17. package/dist/web/public/assets/boards-43luxbPd.js +1 -0
  18. package/dist/web/public/assets/boards-43luxbPd.js.br +0 -0
  19. package/dist/web/public/assets/boards-43luxbPd.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-uSHcxQMy.js +4 -0
  21. package/dist/web/public/assets/explorer-uSHcxQMy.js.br +0 -0
  22. package/dist/web/public/assets/explorer-uSHcxQMy.js.gz +0 -0
  23. package/dist/web/public/assets/{index-b8jrYmUt.js → index-Dje7tHE4.js} +15 -15
  24. package/dist/web/public/assets/index-Dje7tHE4.js.br +0 -0
  25. package/dist/web/public/assets/index-Dje7tHE4.js.gz +0 -0
  26. package/dist/web/public/assets/index-sQWFDUPu.css +2 -0
  27. package/dist/web/public/assets/index-sQWFDUPu.css.br +0 -0
  28. package/dist/web/public/assets/index-sQWFDUPu.css.gz +0 -0
  29. package/dist/web/public/assets/runs-rggAaD0k.js +1 -0
  30. package/dist/web/public/assets/runs-rggAaD0k.js.br +0 -0
  31. package/dist/web/public/assets/runs-rggAaD0k.js.gz +0 -0
  32. package/dist/web/public/assets/settings-DN20Mj9n.js +5 -0
  33. package/dist/web/public/assets/settings-DN20Mj9n.js.br +0 -0
  34. package/dist/web/public/assets/settings-DN20Mj9n.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-BF79aZgL.js +3 -0
  36. package/dist/web/public/assets/task-runs-BF79aZgL.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-BF79aZgL.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-MCHgTBcD.js +4 -0
  39. package/dist/web/public/assets/tasks-MCHgTBcD.js.br +0 -0
  40. package/dist/web/public/assets/tasks-MCHgTBcD.js.gz +0 -0
  41. package/dist/web/public/icon-32-amber.png +0 -0
  42. package/dist/web/public/icon-32-emerald.png +0 -0
  43. package/dist/web/public/icon-32-rose.png +0 -0
  44. package/dist/web/public/icon-32-teal.png +0 -0
  45. package/dist/web/public/icon-32-violet.png +0 -0
  46. package/dist/web/public/icon-32.png +0 -0
  47. package/dist/web/public/icon-touch-192-amber.png +0 -0
  48. package/dist/web/public/icon-touch-192-emerald.png +0 -0
  49. package/dist/web/public/icon-touch-192-rose.png +0 -0
  50. package/dist/web/public/icon-touch-192-teal.png +0 -0
  51. package/dist/web/public/icon-touch-192-violet.png +0 -0
  52. package/dist/web/public/icon-touch-192.png +0 -0
  53. package/dist/web/public/index.html +14 -10
  54. package/dist/web/public/index.html.br +0 -0
  55. package/dist/web/public/index.html.gz +0 -0
  56. package/dist/web/public/manifest.webmanifest +10 -10
  57. package/dist/web/public/sw.js +5 -5
  58. package/dist/web/public/sw.js.br +0 -0
  59. package/dist/web/public/sw.js.gz +0 -0
  60. package/dist/web/push.js +15 -3
  61. package/dist/web/server.js +70 -11
  62. package/docs/deploy.md +19 -2
  63. package/package.json +1 -1
  64. package/dist/web/public/assets/activity-DR5gTwpG.js +0 -5
  65. package/dist/web/public/assets/activity-DR5gTwpG.js.br +0 -0
  66. package/dist/web/public/assets/activity-DR5gTwpG.js.gz +0 -0
  67. package/dist/web/public/assets/boards-DBO30B54.js +0 -1
  68. package/dist/web/public/assets/boards-DBO30B54.js.br +0 -0
  69. package/dist/web/public/assets/boards-DBO30B54.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-ZeVls41v.js +0 -4
  71. package/dist/web/public/assets/explorer-ZeVls41v.js.br +0 -0
  72. package/dist/web/public/assets/explorer-ZeVls41v.js.gz +0 -0
  73. package/dist/web/public/assets/index-DMq4awU8.css +0 -2
  74. package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
  75. package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
  76. package/dist/web/public/assets/index-b8jrYmUt.js.br +0 -0
  77. package/dist/web/public/assets/index-b8jrYmUt.js.gz +0 -0
  78. package/dist/web/public/assets/runs-C0GaHPSU.js +0 -1
  79. package/dist/web/public/assets/runs-C0GaHPSU.js.br +0 -0
  80. package/dist/web/public/assets/runs-C0GaHPSU.js.gz +0 -0
  81. package/dist/web/public/assets/settings-avLEQHdY.js +0 -5
  82. package/dist/web/public/assets/settings-avLEQHdY.js.br +0 -0
  83. package/dist/web/public/assets/settings-avLEQHdY.js.gz +0 -0
  84. package/dist/web/public/assets/task-runs-BCbKn_zb.js +0 -3
  85. package/dist/web/public/assets/task-runs-BCbKn_zb.js.br +0 -0
  86. package/dist/web/public/assets/task-runs-BCbKn_zb.js.gz +0 -0
  87. package/dist/web/public/assets/tasks-CReIwEMw.js +0 -4
  88. package/dist/web/public/assets/tasks-CReIwEMw.js.br +0 -0
  89. package/dist/web/public/assets/tasks-CReIwEMw.js.gz +0 -0
  90. package/dist/web/public/icon.svg.br +0 -0
  91. package/dist/web/public/icon.svg.gz +0 -0
  92. package/dist/web/public/manifest.webmanifest.br +0 -0
  93. package/dist/web/public/manifest.webmanifest.gz +0 -0
@@ -22,8 +22,11 @@ export class CredentialStore {
22
22
  this.secrets = secrets;
23
23
  this.agentDir = agentDir;
24
24
  }
25
- /** encrypt() throws the `secrets locked: ...` error a session open must fail with. */
26
- assertUnlocked() {
25
+ /** encrypt() throws the `secrets locked: ...` error a session open must fail
26
+ * with — after the boot's unlock attempt has settled, so a message that
27
+ * arrives while vt is still reading the KEK waits instead of failing. */
28
+ async assertUnlocked() {
29
+ await this.secrets.settled();
27
30
  if (this.secrets.state === "locked")
28
31
  this.secrets.encrypt("");
29
32
  }
package/dist/agent/pi.js CHANGED
@@ -616,7 +616,7 @@ export class PiAgentFactory {
616
616
  async openSnapshot(cwd, sessionManager, opts) {
617
617
  // A locked store is a refusal with a reason here, not "provider not
618
618
  // configured" later. Before appendSessionInfo, so nothing is written.
619
- this.credentials?.assertUnlocked();
619
+ await this.credentials?.assertUnlocked();
620
620
  if (opts.name)
621
621
  sessionManager.appendSessionInfo(opts.name);
622
622
  // This runtime serves one session, so shadowing streamSimple is the
@@ -4,33 +4,39 @@
4
4
  named helpers cover the shapes a report actually asks for. */
5
5
 
6
6
  :root {
7
- --bg: #fbfbfa;
8
- --fg: #1f2328;
9
- --muted: #6a737d;
10
- --line: #e4e4e2;
7
+ /* Warm paper rather than cool grey: a board is read like a printed page,
8
+ and the neutrals stay warm in dark mode too. */
9
+ --bg: #faf9f5;
10
+ --fg: #1f1e1d;
11
+ --muted: #6d6a62;
12
+ --line: #e6e2d8;
11
13
  --card: #ffffff;
12
- --accent: #4f46e5;
13
- --code-bg: #f4f4f3;
14
+ /* Clay accent: 5:1 on --bg and --card so plain links pass AA. */
15
+ --accent: #b5522f;
16
+ --code-bg: #f2efe6;
14
17
  /* Semantic palette: colour encodes meaning (good / attention / problem),
15
- never decoration. Boards are presentations, so these are first-class. */
18
+ never decoration. Boards are presentations, so these are first-class.
19
+ `bad` is the one hue near the clay accent; it stays the cooler, more
20
+ saturated red so status never reads as decoration. */
16
21
  --good: #0f7b4f;
17
22
  --warn: #a25b00;
18
23
  --bad: #b42318;
24
+ --serif: ui-serif, Georgia, Cambria, "Songti SC", "Noto Serif CJK SC", serif;
19
25
  color-scheme: light dark;
20
26
  }
21
27
 
22
28
  @media (prefers-color-scheme: dark) {
23
29
  :root {
24
- --bg: #16181c;
25
- --fg: #e6e7e9;
26
- --muted: #9aa0a6;
27
- --line: #2c2f36;
28
- --card: #1c1f24;
29
- --accent: #8b87f7;
30
- --code-bg: #22262c;
30
+ --bg: #22211e;
31
+ --fg: #f0eee7;
32
+ --muted: #a5a096;
33
+ --line: #3a3833;
34
+ --card: #2b2a26;
35
+ --accent: #e08a68;
36
+ --code-bg: #302e2a;
31
37
  --good: #4ade80;
32
38
  --warn: #fbbf24;
33
- --bad: #f87171;
39
+ --bad: #f08a80;
34
40
  }
35
41
  }
36
42
 
@@ -49,13 +55,13 @@ html {
49
55
  while running prose keeps a readable measure (--measure) instead of
50
56
  stretching into 150-character lines. */
51
57
  body {
52
- --measure: 46rem;
58
+ --measure: 44rem;
53
59
  margin: 0 auto;
54
- max-width: 46rem;
55
- padding: 2rem 1.15rem 5rem;
60
+ max-width: 44rem;
61
+ padding: 3rem 1.25rem 6rem;
56
62
  background: var(--bg);
57
63
  color: var(--fg);
58
- font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
64
+ font: 17px/1.7 -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
59
65
  "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
60
66
  }
61
67
 
@@ -87,28 +93,40 @@ body > blockquote {
87
93
  max-width: var(--measure);
88
94
  }
89
95
 
96
+ /* Serif headings against a sans body: the contrast is what makes a plain
97
+ document look typeset instead of like an app screen. */
98
+ h1,
99
+ h2,
100
+ h3 {
101
+ font-family: var(--serif);
102
+ /* Static serif fallbacks only have 400/700; a mid weight would snap anyway. */
103
+ font-weight: 600;
104
+ }
105
+
90
106
  h1,
91
107
  h2,
92
108
  h3,
93
109
  h4 {
94
110
  line-height: 1.25;
95
- margin: 2.2rem 0 0.6rem;
96
- font-weight: 640;
97
- letter-spacing: -0.01em;
111
+ margin: 2.4rem 0 0.7rem;
112
+ letter-spacing: -0.012em;
98
113
  }
99
114
 
100
115
  h1 {
101
116
  margin-top: 0;
102
- font-size: 1.75rem;
117
+ font-size: 2.1rem;
103
118
  }
104
119
  h2 {
105
- font-size: 1.3rem;
120
+ font-size: 1.45rem;
106
121
  }
107
122
  h3 {
108
- font-size: 1.08rem;
123
+ font-size: 1.14rem;
109
124
  }
110
125
  h4 {
111
- font-size: 1rem;
126
+ font-size: 0.95rem;
127
+ font-weight: 640;
128
+ letter-spacing: 0.02em;
129
+ text-transform: uppercase;
112
130
  color: var(--muted);
113
131
  }
114
132
 
@@ -165,10 +183,10 @@ samp {
165
183
 
166
184
  pre {
167
185
  overflow-x: auto;
168
- padding: 0.85rem 1rem;
169
- border-radius: 8px;
186
+ padding: 0.9rem 1.1rem;
187
+ border-radius: 10px;
170
188
  background: var(--code-bg);
171
- line-height: 1.5;
189
+ line-height: 1.55;
172
190
  }
173
191
 
174
192
  pre code {
@@ -227,9 +245,9 @@ video {
227
245
 
228
246
  details {
229
247
  margin: 0 0 1rem;
230
- padding: 0.7rem 0.9rem;
248
+ padding: 0.8rem 1rem;
231
249
  border: 1px solid var(--line);
232
- border-radius: 8px;
250
+ border-radius: 12px;
233
251
  background: var(--card);
234
252
  }
235
253
 
@@ -280,10 +298,10 @@ footer {
280
298
  }
281
299
 
282
300
  .card {
283
- padding: 0.9rem 1.1rem;
301
+ padding: 1.05rem 1.25rem;
284
302
  margin: 0 0 1rem;
285
303
  border: 1px solid var(--line);
286
- border-radius: 10px;
304
+ border-radius: 12px;
287
305
  background: var(--card);
288
306
  }
289
307
 
@@ -310,10 +328,10 @@ footer {
310
328
 
311
329
  /* Opening block: title, one-line answer, and the numbers that matter. */
312
330
  .hero {
313
- margin: 0 0 1.75rem;
314
- padding: 1.4rem 1.5rem;
315
- border-radius: 14px;
316
- background: color-mix(in oklab, var(--accent) 9%, var(--card));
331
+ margin: 0 0 2rem;
332
+ padding: 1.6rem 1.75rem;
333
+ border-radius: 16px;
334
+ background: color-mix(in oklab, var(--accent) 8%, var(--card));
317
335
  }
318
336
  .hero h1 {
319
337
  margin-bottom: 0.4rem;
@@ -340,8 +358,9 @@ footer {
340
358
 
341
359
  .kpi {
342
360
  display: block;
343
- font-size: 1.65rem;
344
- font-weight: 650;
361
+ font-family: var(--serif);
362
+ font-size: 1.8rem;
363
+ font-weight: 700;
345
364
  line-height: 1.15;
346
365
  letter-spacing: -0.02em;
347
366
  font-variant-numeric: tabular-nums;
@@ -56,7 +56,7 @@ export function createHandoff(deps) {
56
56
  const base = publicUrl();
57
57
  const note = {
58
58
  title: sessionLabel(summary),
59
- url: base ? `${base}/#/session/${encodeURIComponent(sessionId)}` : "",
59
+ url: base ? `${base}/app/#/session/${encodeURIComponent(sessionId)}` : "",
60
60
  };
61
61
  let conversationId;
62
62
  try {
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ Usage
23
23
  pier restart finish running turns first, then restart the service
24
24
  pier reload re-read channel config and recycle idle sessions
25
25
  pier backup snapshot pier.db before a manual update
26
+ pier login print a one-time sign-in link for the workbench (2 minutes)
26
27
  pier vault run [ENV=NAME | NAME]... -- <command> [args...]
27
28
  run a command with named secrets in its env
28
29
  pier slack <subcommand> ... Slack from a shell, token from the vault (pier slack --help)
@@ -133,6 +134,13 @@ else if (command === "web") {
133
134
  const { runWebCli } = await import("./websearch/cli.js");
134
135
  process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
135
136
  }
137
+ else if (command === "login") {
138
+ if (subcommand)
139
+ fail(`unexpected argument "${subcommand}"`);
140
+ allowOnly([], "pier login");
141
+ const { status, body } = await askPier("/login", {});
142
+ say(status === 200 && body.url ? body.url : fail(body.error ?? `socket answered ${String(status)}`));
143
+ }
136
144
  else if (command === "restart" || command === "reload") {
137
145
  if (subcommand)
138
146
  fail(`unexpected argument "${subcommand}"`);
package/dist/db.js CHANGED
@@ -304,6 +304,19 @@ const MIGRATIONS = [
304
304
  `
305
305
  ALTER TABLE conversations ADD COLUMN launch TEXT;
306
306
  CREATE INDEX conversations_session ON conversations(session_id);
307
+ `,
308
+ // 27 — passkeys (web/passkeys.ts): while one row exists the password is off.
309
+ `
310
+ -- id is the credential id (base64url); public_key a JWK, never private material.
311
+ CREATE TABLE passkeys (
312
+ id TEXT PRIMARY KEY,
313
+ public_key TEXT NOT NULL,
314
+ sign_count INTEGER NOT NULL,
315
+ transports TEXT NOT NULL,
316
+ label TEXT NOT NULL,
317
+ created_at INTEGER NOT NULL,
318
+ last_used_at INTEGER
319
+ );
307
320
  `,
308
321
  ];
309
322
  /** `BEGIN IMMEDIATE`: taking the write lock up front turns a race with another
package/dist/main.js CHANGED
@@ -40,6 +40,7 @@ import { currentVersion, startAutoUpdate, UpdateCheck } from "./update.js";
40
40
  import { Vault } from "./vault.js";
41
41
  import { servePier } from "./socket.js";
42
42
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
43
+ import { PasskeyStore, registerPasskeyRoutes } from "./web/passkeys.js";
43
44
  import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
44
45
  import { PushStore, registerPushRoutes } from "./web/push.js";
45
46
  import { SessionStateStore } from "./web/session-state.js";
@@ -264,9 +265,11 @@ app.onError((err, c) => {
264
265
  // Before every route: Hono runs middleware in registration order, so a surface
265
266
  // added later is covered without knowing this exists.
266
267
  const auth = new AuthStore(db);
268
+ const passkeys = new PasskeyStore(db);
267
269
  registerConfigShareRoute(app, configSync);
268
270
  app.use("*", requireAuth(auth));
269
- registerAuthRoutes(app, auth);
271
+ registerAuthRoutes(app, auth, () => passkeys.any());
272
+ registerPasskeyRoutes(app, { store: passkeys, auth, publicUrl: () => settings.get().publicUrl });
270
273
  registerConfigSyncRoutes(app, {
271
274
  sync: configSync,
272
275
  status: () => ({ ...configurationSync.status(), publicUrl: settings.get().publicUrl }),
@@ -308,6 +311,7 @@ app.route("/", createServer({
308
311
  return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
309
312
  },
310
313
  secrets,
314
+ passkeys,
311
315
  updates,
312
316
  updater,
313
317
  onUnlocked: () => void startChannels(),
@@ -323,7 +327,7 @@ servePier({
323
327
  vault,
324
328
  // The deep link an agent's "no secret named X" error carries; loopback when
325
329
  // no public URL is set, since nothing in the process can discover one.
326
- fileUrl: (name) => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/#/settings/vault?name=${name}`,
330
+ fileUrl: (name) => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/app/#/settings/vault?name=${name}`,
327
331
  task: (params, callerSessionId) => tasks.handle(params, callerSessionId),
328
332
  // Progress and cost go to the log: the CLI's answer is the text alone.
329
333
  web: async (params, callerSessionId) => {
@@ -335,6 +339,7 @@ servePier({
335
339
  },
336
340
  // Live in the router, or on disk: the same two places a callback target is looked for.
337
341
  knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
342
+ login: () => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/login/${auth.mintLink()}`,
338
343
  });
339
344
  // Every command a turn runs inherits this env: `NODE_ENV=production` makes an
340
345
  // agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
package/dist/secrets.js CHANGED
@@ -22,6 +22,11 @@ export class Secrets {
22
22
  #file;
23
23
  /** Why decrypt is refused right now — "" once unlocked. */
24
24
  #lockedReason = "unlock() has not run";
25
+ /** The unlock in flight. Boot starts it without waiting (the web has to
26
+ * serve so a broken key can be repaired), so vt mode leaves a window the
27
+ * length of a `vt read` in which a session open would refuse a store that
28
+ * is about to be fine. */
29
+ #attempt;
25
30
  constructor(path = pierPath("master.key"), vt = vtCli) {
26
31
  this.path = path;
27
32
  this.vt = vt;
@@ -38,7 +43,16 @@ export class Secrets {
38
43
  /** Created on first boot in file mode, so an unattended start needs no
39
44
  * ceremony. Throws and remembers why: the process must keep serving (web is
40
45
  * how the operator repairs), and every refused decrypt names the reason. */
41
- async unlock() {
46
+ unlock() {
47
+ this.#attempt = this.#unlock();
48
+ return this.#attempt;
49
+ }
50
+ /** Settles with the boot's unlock attempt, succeed or fail; immediate once it
51
+ * has. A caller still asks `state` afterwards — this waits, it never grants. */
52
+ async settled() {
53
+ await this.#attempt?.catch(() => undefined);
54
+ }
55
+ async #unlock() {
42
56
  try {
43
57
  let raw;
44
58
  try {
package/dist/settings.js CHANGED
@@ -5,6 +5,26 @@ import { pierDb, transact } from "./db.js";
5
5
  import { logger } from "./log.js";
6
6
  import { normalizeCustomTools } from "./tools.js";
7
7
  const log = logger("settings");
8
+ /** The presets, each with the hex of its 600 step — what the manifest and the
9
+ * icon paint with, since neither can read a CSS variable. The ramps
10
+ * themselves are style.css's; this table must name the same colours. */
11
+ export const ACCENTS = {
12
+ indigo: "#0066df",
13
+ teal: "#037f75",
14
+ emerald: "#028355",
15
+ amber: "#b17000",
16
+ rose: "#c51b53",
17
+ violet: "#7a49d3",
18
+ };
19
+ export const DEFAULT_ACCENT = "indigo";
20
+ /** `""` and the default's own name both mean "no override"; anything not in
21
+ * the table is rejected, never stored as a name the stylesheet lacks. */
22
+ export function normalizeAccent(raw) {
23
+ const name = raw.trim();
24
+ if (!name || name === DEFAULT_ACCENT)
25
+ return "";
26
+ return Object.hasOwn(ACCENTS, name) ? name : null;
27
+ }
8
28
  /** `""` clears it, `null` rejects it: a mistyped host quietly turned into a URL
9
29
  * produces board links that 404 for the person they were sent to. */
10
30
  export function normalizePublicUrl(raw) {
@@ -89,6 +109,9 @@ export class SettingsStore {
89
109
  }
90
110
  get() {
91
111
  const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
112
+ const accent = normalizeAccent(this.#value("accent") ?? "");
113
+ if (accent === null)
114
+ log.warn("settings.accent is not a preset — ignoring it");
92
115
  return {
93
116
  publicUrl: this.#value("publicUrl") ?? "",
94
117
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
@@ -98,6 +121,7 @@ export class SettingsStore {
98
121
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
99
122
  // `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
100
123
  customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, "drop"), "a list of {name, toml}") ?? [],
124
+ accent: accent ?? "",
101
125
  };
102
126
  }
103
127
  /** A malformed row is named, not silently served as the empty value (§5). */
@@ -123,6 +147,10 @@ export class SettingsStore {
123
147
  this.#set("publicUrl", publicUrl);
124
148
  return this.get();
125
149
  }
150
+ setAccent(accent) {
151
+ this.#set("accent", accent);
152
+ return this.get();
153
+ }
126
154
  setModelMenu(menu) {
127
155
  this.#set("modelMenu", JSON.stringify(menu));
128
156
  return this.get();
package/dist/socket.js CHANGED
@@ -11,6 +11,9 @@ import { isVaultName, UnknownSecret, VaultLocked } from "./vault.js";
11
11
  const log = logger("socket");
12
12
  /** Generous for a task draft; a `--prompt -` past it is told so, not hung up on. */
13
13
  const MAX_BODY = 64 * 1024;
14
+ /** Typed by the operator in a terminal, not by an agent in a turn: the one
15
+ * route with no session behind it. */
16
+ const ANONYMOUS = new Set(["/login"]);
14
17
  const ROUTES = {
15
18
  async "/resolve"({ vault, fileUrl }, { names }, sessionId, answer) {
16
19
  if (!Array.isArray(names) || !names.length || !names.every((n) => typeof n === "string" && isVaultName(n))) {
@@ -30,6 +33,11 @@ const ROUTES = {
30
33
  },
31
34
  "/task": operation("task"),
32
35
  "/web": operation("web"),
36
+ async "/login"({ login }, _body, _sessionId, answer) {
37
+ const url = login();
38
+ log.info("sign-in link minted for pier login");
39
+ answer(200, { url });
40
+ },
33
41
  };
34
42
  /** A CLI verb's params under the caller's session. 422, not 400: the request
35
43
  * was well-formed; what the operation refused is the caller's to read. */
@@ -90,7 +98,11 @@ async function handle(hosts, req, res) {
90
98
  catch {
91
99
  return answer(400, { error: "body must be a JSON object" });
92
100
  }
93
- const { sessionId, ...fields } = typeof body === "object" && body !== null ? body : {};
101
+ if (typeof body !== "object" || body === null || Array.isArray(body))
102
+ return answer(400, { error: "body must be a JSON object" });
103
+ const { sessionId, ...fields } = body;
104
+ if (ANONYMOUS.has(req.url ?? ""))
105
+ return route(hosts, fields, "", answer);
94
106
  if (typeof sessionId !== "string" || !sessionId)
95
107
  return answer(400, { error: "PIER_SESSION_ID is required" });
96
108
  if (!(await hosts.knows(sessionId)))