@timqi/pier 0.0.21 → 0.0.23

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 (84) hide show
  1. package/README.md +4 -5
  2. package/dist/agent/config-sync.js +124 -92
  3. package/dist/boards/boards.js +6 -2
  4. package/dist/db.js +12 -3
  5. package/dist/main.js +14 -14
  6. package/dist/service.js +17 -7
  7. package/dist/settings.js +0 -26
  8. package/dist/tasks/callbacks.js +1 -1
  9. package/dist/tasks/definitions.js +4 -0
  10. package/dist/tasks/groups.js +2 -1
  11. package/dist/tasks/service.js +14 -4
  12. package/dist/tasks/tool.js +70 -13
  13. package/dist/web/auth.js +1 -22
  14. package/dist/web/fs.js +2 -2
  15. package/dist/web/instance.js +6 -12
  16. package/dist/web/public/assets/activity-atEIVnGA.js +5 -0
  17. package/dist/web/public/assets/activity-atEIVnGA.js.br +0 -0
  18. package/dist/web/public/assets/activity-atEIVnGA.js.gz +0 -0
  19. package/dist/web/public/assets/{boards-DMVbV84c.js → boards-DSkfMEvN.js} +1 -1
  20. package/dist/web/public/assets/boards-DSkfMEvN.js.br +0 -0
  21. package/dist/web/public/assets/boards-DSkfMEvN.js.gz +0 -0
  22. package/dist/web/public/assets/{code-C86xKk2k.js → code-1nUOCxAb.js} +1 -1
  23. package/dist/web/public/assets/explorer-CS1kLPMl.js +5 -0
  24. package/dist/web/public/assets/explorer-CS1kLPMl.js.br +0 -0
  25. package/dist/web/public/assets/explorer-CS1kLPMl.js.gz +0 -0
  26. package/dist/web/public/assets/index-DP1-BW-q.css +2 -0
  27. package/dist/web/public/assets/index-DP1-BW-q.css.br +0 -0
  28. package/dist/web/public/assets/index-DP1-BW-q.css.gz +0 -0
  29. package/dist/web/public/assets/index-a7A8xfoi.js +83 -0
  30. package/dist/web/public/assets/index-a7A8xfoi.js.br +0 -0
  31. package/dist/web/public/assets/index-a7A8xfoi.js.gz +0 -0
  32. package/dist/web/public/assets/runs-bHSlJwXk.js +1 -0
  33. package/dist/web/public/assets/runs-bHSlJwXk.js.br +0 -0
  34. package/dist/web/public/assets/runs-bHSlJwXk.js.gz +0 -0
  35. package/dist/web/public/assets/settings-CrC-yuXh.js +6 -0
  36. package/dist/web/public/assets/settings-CrC-yuXh.js.br +0 -0
  37. package/dist/web/public/assets/settings-CrC-yuXh.js.gz +0 -0
  38. package/dist/web/public/assets/task-runs-C2PHAhAf.js +3 -0
  39. package/dist/web/public/assets/task-runs-C2PHAhAf.js.br +0 -0
  40. package/dist/web/public/assets/task-runs-C2PHAhAf.js.gz +0 -0
  41. package/dist/web/public/assets/tasks-7S90iYr1.js +4 -0
  42. package/dist/web/public/assets/tasks-7S90iYr1.js.br +0 -0
  43. package/dist/web/public/assets/tasks-7S90iYr1.js.gz +0 -0
  44. package/dist/web/public/index.html +16 -24
  45. package/dist/web/public/index.html.br +0 -0
  46. package/dist/web/public/index.html.gz +0 -0
  47. package/dist/web/server.js +4 -4
  48. package/docs/deploy.md +23 -15
  49. package/package.json +2 -9
  50. package/skills/pier-tasks/SKILL.md +24 -13
  51. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  52. package/dist/web/public/assets/activity-Cw0LRVNI.js +0 -5
  53. package/dist/web/public/assets/activity-Cw0LRVNI.js.br +0 -0
  54. package/dist/web/public/assets/activity-Cw0LRVNI.js.gz +0 -0
  55. package/dist/web/public/assets/boards-DMVbV84c.js.br +0 -0
  56. package/dist/web/public/assets/boards-DMVbV84c.js.gz +0 -0
  57. package/dist/web/public/assets/explorer-DTvsy5Rs.js +0 -5
  58. package/dist/web/public/assets/explorer-DTvsy5Rs.js.br +0 -0
  59. package/dist/web/public/assets/explorer-DTvsy5Rs.js.gz +0 -0
  60. package/dist/web/public/assets/ghostty-web-CvSZragH.js +0 -13
  61. package/dist/web/public/assets/ghostty-web-CvSZragH.js.br +0 -0
  62. package/dist/web/public/assets/ghostty-web-CvSZragH.js.gz +0 -0
  63. package/dist/web/public/assets/index-DHxoYoeq.js +0 -79
  64. package/dist/web/public/assets/index-DHxoYoeq.js.br +0 -0
  65. package/dist/web/public/assets/index-DHxoYoeq.js.gz +0 -0
  66. package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
  67. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  68. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  69. package/dist/web/public/assets/runs-jY40JBP-.js +0 -1
  70. package/dist/web/public/assets/runs-jY40JBP-.js.br +0 -0
  71. package/dist/web/public/assets/runs-jY40JBP-.js.gz +0 -0
  72. package/dist/web/public/assets/settings-Dux7PIKF.js +0 -6
  73. package/dist/web/public/assets/settings-Dux7PIKF.js.br +0 -0
  74. package/dist/web/public/assets/settings-Dux7PIKF.js.gz +0 -0
  75. package/dist/web/public/assets/task-runs-DKvtBVxS.js +0 -3
  76. package/dist/web/public/assets/task-runs-DKvtBVxS.js.br +0 -0
  77. package/dist/web/public/assets/task-runs-DKvtBVxS.js.gz +0 -0
  78. package/dist/web/public/assets/tasks-CqfrOVIt.js +0 -4
  79. package/dist/web/public/assets/tasks-CqfrOVIt.js.br +0 -0
  80. package/dist/web/public/assets/tasks-CqfrOVIt.js.gz +0 -0
  81. package/dist/web/public/assets/terminal-DL_0yEy7.js +0 -2
  82. package/dist/web/public/assets/terminal-DL_0yEy7.js.br +0 -0
  83. package/dist/web/public/assets/terminal-DL_0yEy7.js.gz +0 -0
  84. package/dist/web/terminal.js +0 -369
package/README.md CHANGED
@@ -17,7 +17,6 @@ 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
- - Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
21
20
  - A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
22
21
  login from Console → Settings → Models after signing in
23
22
  - A user-writable global npm prefix if `pier update` should update a service
@@ -61,8 +60,8 @@ an existing Pi setup:
61
60
  PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
62
61
  ```
63
62
 
64
- Pier exports that variable for the SDK, so anything it starts inherits it — a
65
- shell in the Web Terminal included. A second Pier launched from there with its
63
+ Pier exports that variable for the SDK, so anything it starts inherits it — an
64
+ agent's shell included. A second Pier launched from there with its
66
65
  own `PIER_HOME` derives its own agent directory rather than adopting the first
67
66
  one's; setting `PI_CODING_AGENT_DIR` again on that command line still wins.
68
67
 
@@ -92,8 +91,8 @@ Console → Settings is the normal setup path:
92
91
  Below it, the same tab switches on the command-line tools Pier manages —
93
92
  `rtk`, `rg`, `fd`, `wt`, `jq`, or a tool of your own written as a
94
93
  [ubix](https://github.com/timqi/ubix) block. A switch installs the binary
95
- into `~/.pier/tools/bin`, which is first on the PATH every session, task and
96
- terminal inherits, and a task you can read keeps them current.
94
+ into `~/.pier/tools/bin`, which is first on the PATH every session and task
95
+ inherits, and a task you can read keeps them current.
97
96
 
98
97
  On first credential access, Pier imports an existing `auth.json` into its sealed
99
98
  store and renames the source to `auth.json.imported`. Literal provider keys left
@@ -1,54 +1,98 @@
1
1
  // Credential-blind projection of the portable agent configuration.
2
- const MODEL_KEYS = ["id", "name", "reasoning", "input", "cost", "contextWindow", "maxTokens", "api"];
3
- const COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
2
+ /** Never leaves the instance, at any depth of a models.json provider: the
3
+ * credentials, and the endpoint they authenticate against — a sharing link is
4
+ * public. Every other field is metadata Pi's own schema already validates, so
5
+ * it travels as-is instead of through a field list chasing Pi's versions. */
6
+ const PRIVATE_KEYS = ["apiKey", "headers", "baseUrl"];
7
+ /** The catalog is the part of a provider sync owns outright: absent on the
8
+ * source means deleted here, while a field the source never states stays. */
9
+ const CATALOG_KEYS = ["models", "modelOverrides"];
10
+ /** Dropped strings shorter than this ("", "Bearer") collide with legitimate
11
+ * metadata; a credential or an endpoint does not. */
12
+ const SECRET_CHARS = 8;
13
+ const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
4
14
  function record(value, where) {
5
- if (!value || typeof value !== "object" || Array.isArray(value) ||
6
- ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
15
+ const plain = asRecord(value);
16
+ if (!plain || ![Object.prototype, null].includes(Object.getPrototypeOf(plain))) {
7
17
  throw new Error(`${where} must be an object`);
8
18
  }
9
- return value;
19
+ return plain;
10
20
  }
11
21
  function only(value, keys, where) {
12
22
  if (Object.keys(value).some((key) => !keys.includes(key))) {
13
23
  throw new Error(`${where} contains unsupported fields`);
14
24
  }
15
25
  }
16
- function modelDefinition(value) {
17
- const model = record(value, "model");
18
- only(model, MODEL_KEYS, "model");
19
- if (typeof model.id !== "string" || !model.id.trim() || model.id !== model.id.trim()) {
20
- throw new Error("model id must be a non-empty trimmed string");
21
- }
22
- if (model.name !== undefined && (typeof model.name !== "string" || !model.name.length))
23
- throw new Error("model name must be a non-empty string");
24
- if (model.reasoning !== undefined && typeof model.reasoning !== "boolean") {
25
- throw new Error("model reasoning must be a boolean");
26
- }
27
- if (model.api !== undefined && (typeof model.api !== "string" || !/^[a-z][a-z0-9-]*$/.test(model.api))) {
28
- throw new Error("model api must be a protocol name");
26
+ /** Every string in a JSON value, so one containment test covers the whole
27
+ * projection however deeply Pi nests a future field. */
28
+ function strings(value, found = []) {
29
+ const nested = asRecord(value);
30
+ if (typeof value === "string")
31
+ found.push(value);
32
+ else if (Array.isArray(value))
33
+ for (const entry of value)
34
+ strings(entry, found);
35
+ else if (nested)
36
+ for (const entry of Object.values(nested))
37
+ strings(entry, found);
38
+ return found;
39
+ }
40
+ /** Deep copy of JSON-only data; the private fields are dropped and handed to
41
+ * `onPrivate` — collected on the way out, refused on the way in. */
42
+ function project(value, where, onPrivate) {
43
+ if (value === null || ["string", "boolean", "number"].includes(typeof value))
44
+ return value;
45
+ if (Array.isArray(value))
46
+ return value.map((entry) => project(entry, where, onPrivate));
47
+ return Object.fromEntries(Object.entries(record(value, where)).flatMap(([key, entry]) => {
48
+ if (PRIVATE_KEYS.includes(key)) {
49
+ onPrivate(key, entry);
50
+ return [];
51
+ }
52
+ return [[key, project(entry, `${where} ${key}`, onPrivate)]];
53
+ }));
54
+ }
55
+ /** The shape both sides must agree on: a provider id that cannot address
56
+ * anything but its own record, and a catalog addressable by model id. */
57
+ function portable(id, value, onPrivate) {
58
+ if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
59
+ throw new Error("invalid snapshot provider id");
29
60
  }
30
- if (model.input !== undefined && (!Array.isArray(model.input) || !model.input.length ||
31
- model.input.some((input) => input !== "text" && input !== "image") ||
32
- new Set(model.input).size !== model.input.length)) {
33
- throw new Error("model input must contain text or image without duplicates");
61
+ const provider = record(project(value, `provider ${id}`, onPrivate), `provider ${id}`);
62
+ const models = provider.models ?? [];
63
+ if (!Array.isArray(models))
64
+ throw new Error("models must be an array");
65
+ const ids = models.map((model) => record(model, "model").id);
66
+ if (ids.some((modelId) => typeof modelId !== "string" || !modelId.trim() || modelId !== modelId.trim())) {
67
+ throw new Error("model id must be a non-empty trimmed string");
34
68
  }
35
- for (const key of ["contextWindow", "maxTokens"]) {
36
- if (model[key] !== undefined && (typeof model[key] !== "number" || !Number.isSafeInteger(model[key]) || model[key] <= 0)) {
37
- throw new Error(`model ${key} must be a positive integer`);
38
- }
69
+ if (new Set(ids).size !== ids.length)
70
+ throw new Error("duplicate model ids");
71
+ const overrides = provider.modelOverrides ?? {};
72
+ for (const [overrideId, override] of Object.entries(record(overrides, "modelOverrides"))) {
73
+ record(override, `modelOverrides ${overrideId}`);
39
74
  }
40
- if (model.cost !== undefined) {
41
- const cost = record(model.cost, "model cost");
42
- only(cost, COST_KEYS, "model cost");
43
- for (const key of COST_KEYS) {
44
- if (typeof cost[key] !== "number" || !Number.isFinite(cost[key]) || cost[key] < 0) {
45
- throw new Error(`model cost ${key} must be a non-negative finite number`);
46
- }
47
- }
75
+ return provider;
76
+ }
77
+ /** Project disk metadata before validation, then fail closed if anything the
78
+ * projection removed still appears in it: a deny list cannot foresee the field
79
+ * Pi adds next, but it can refuse to publish a value it just dropped. */
80
+ export function snapshotProviders(providers = {}) {
81
+ const dropped = [];
82
+ const shared = Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
83
+ const value = portable(id, provider, (_key, entry) => {
84
+ dropped.push(...strings(entry).filter((secret) => secret.length >= SECRET_CHARS));
85
+ });
86
+ return Object.keys(value).length ? [[id, value]] : [];
87
+ }));
88
+ const exported = strings(shared);
89
+ if (dropped.some((secret) => exported.some((value) => value.includes(secret)))) {
90
+ throw new Error("models.json repeats a credential or endpoint in a shared field; remove it before sharing");
48
91
  }
49
- return structuredClone(model);
92
+ return shared;
50
93
  }
51
- /** Strict import boundary: unknown fields are rejected, never treated as masked secrets. */
94
+ /** Strict import boundary: a source that states a private field is refused,
95
+ * never trusted as a mask over the local one. */
52
96
  export function normalizeAgentSnapshot(raw) {
53
97
  const snapshot = record(raw, "agent snapshot");
54
98
  only(snapshot, ["files", "providers"], "agent snapshot");
@@ -59,65 +103,53 @@ export function normalizeAgentSnapshot(raw) {
59
103
  throw new Error(`${name} must be a string or null`);
60
104
  }
61
105
  }
62
- const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers")).map(([id, value]) => {
63
- if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
64
- throw new Error("invalid snapshot provider id");
65
- }
66
- const provider = record(value, "snapshot provider");
67
- only(provider, ["models"], "snapshot provider");
68
- if (!Array.isArray(provider.models))
69
- throw new Error("snapshot provider models must be an array");
70
- const models = provider.models.map(modelDefinition);
71
- if (new Set(models.map((model) => model.id)).size !== models.length)
72
- throw new Error("duplicate model ids");
73
- return [id, { models }];
74
- }));
75
- return { files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] }, providers };
106
+ const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers"))
107
+ .map(([id, provider]) => [id, portable(id, provider, (key) => {
108
+ throw new Error(`snapshot provider must not carry ${key}`);
109
+ })]));
110
+ return {
111
+ files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] },
112
+ providers,
113
+ };
76
114
  }
77
- /** Project disk metadata before validation, so no nested transport object can escape. */
78
- export function snapshotProviders(providers = {}) {
79
- return Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
80
- if (provider.models === undefined)
81
- return [];
82
- if (!Array.isArray(provider.models))
83
- throw new Error("models.json models must be an array");
84
- const models = provider.models.map((value) => {
85
- const source = record(value, "models.json model");
86
- const model = Object.fromEntries(MODEL_KEYS.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]]));
87
- if (source.cost !== undefined) {
88
- const cost = record(source.cost, "models.json model cost");
89
- model.cost = Object.fromEntries(COST_KEYS.flatMap((key) => cost[key] === undefined ? [] : [[key, cost[key]]]));
90
- }
91
- return modelDefinition(model);
92
- });
93
- return [[id, { models }]];
94
- }));
115
+ /** A model or override arrives whole; only the local private fields survive it. */
116
+ function withLocalPrivate(incoming, local) {
117
+ const merged = { ...incoming };
118
+ for (const key of PRIVATE_KEYS)
119
+ if (local && Object.hasOwn(local, key))
120
+ merged[key] = local[key];
121
+ return merged;
95
122
  }
96
- /** Replace only portable fields, retaining excluded fields for matching local model ids. */
123
+ const modelsById = (models) => new Map((Array.isArray(models) ? models : []).flatMap((model) => {
124
+ const value = asRecord(model);
125
+ return value ? [[value.id, value]] : [];
126
+ }));
127
+ /** The source sets what it states and replaces the catalog; credentials and
128
+ * endpoints stay local, matched by provider, model id and override id. */
97
129
  export function mergeSnapshotProviders(local = {}, incoming) {
98
- const providers = structuredClone(local);
99
- for (const [id, provider] of Object.entries(providers)) {
100
- if (!Object.hasOwn(incoming, id)) {
101
- delete provider.models;
102
- }
103
- }
104
- for (const [id, { models }] of Object.entries(incoming)) {
105
- if (!Object.hasOwn(local, id)) {
130
+ for (const id of Object.keys(incoming)) {
131
+ if (!Object.hasOwn(local, id))
106
132
  throw new Error(`provider ${id} must be configured locally before importing models`);
107
- }
108
- const provider = providers[id];
109
- if (provider.models !== undefined && !Array.isArray(provider.models))
110
- throw new Error("models.json models must be an array");
111
- const existing = new Map((provider.models ?? []).map((value) => {
112
- const model = record(value, "models.json model");
113
- return [model.id, model];
114
- }));
115
- provider.models = models.map((model) => {
116
- const retained = { ...existing.get(model.id) };
117
- for (const key of MODEL_KEYS)
118
- delete retained[key];
119
- return { ...retained, ...model };
120
- });
121
133
  }
122
- return providers;
134
+ return Object.fromEntries(Object.entries(local).map(([id, provider]) => {
135
+ const current = record(provider, "models.json provider");
136
+ const source = incoming[id];
137
+ if (!source)
138
+ return [id, Object.fromEntries(Object.entries(current).filter(([key]) => !CATALOG_KEYS.includes(key)))];
139
+ // The catalog shapes are the ones normalizeAgentSnapshot already proved.
140
+ const merged = { ...current, ...structuredClone(source) };
141
+ for (const key of CATALOG_KEYS)
142
+ if (!Object.hasOwn(source, key))
143
+ delete merged[key];
144
+ if (merged.models !== undefined) {
145
+ const previous = modelsById(current.models);
146
+ merged.models = merged.models.map((model) => withLocalPrivate(model, previous.get(model.id)));
147
+ }
148
+ if (merged.modelOverrides !== undefined) {
149
+ const previous = asRecord(current.modelOverrides) ?? {};
150
+ merged.modelOverrides = Object.fromEntries(Object.entries(merged.modelOverrides)
151
+ .map(([key, override]) => [key, withLocalPrivate(override, asRecord(previous[key]))]));
152
+ }
153
+ return [id, merged];
154
+ }));
123
155
  }
@@ -124,14 +124,18 @@ export async function listBoards(dir) {
124
124
  }
125
125
  // One board's manifest says nothing about the next one's, so the scan waits
126
126
  // once for all of them rather than once per board.
127
- const boards = await Promise.all(entries.sort().map(async (slug) => {
127
+ const boards = await Promise.all(entries.map(async (slug) => {
128
128
  const manifest = await readManifest(dir, slug);
129
129
  if (!manifest)
130
130
  return null;
131
131
  const { title, description, sessions, public: isPublic, token } = manifest;
132
132
  return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
133
133
  }));
134
- return boards.filter((board) => board !== null);
134
+ // Freshest first: the board someone just wrote is the one they came to see.
135
+ // Slug breaks ties so equal mtimes still list in a stable order.
136
+ return boards
137
+ .filter((board) => board !== null)
138
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug));
135
139
  }
136
140
  /** Containment, not normalization: the resolved realpath must sit inside the
137
141
  * board's own site dir or nothing is served. */
package/dist/db.js CHANGED
@@ -316,6 +316,13 @@ const MIGRATIONS = [
316
316
  CREATE INDEX task_runs_visible_time ON task_runs(queued_at DESC, id DESC)
317
317
  WHERE NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0);
318
318
  `,
319
+ // 18 — opening a session lists every run it delegated, not the last hour's.
320
+ `
321
+ -- The run cards are the messages a session sent, so the transcript wants
322
+ -- all of them: the query is by the delegating session, which lived only in
323
+ -- the JSON — without this every session open was a full scan of task_runs.
324
+ CREATE INDEX task_runs_invoked_by ON task_runs(json_extract(json, '$.invokedBySessionId'), queued_at DESC);
325
+ `,
319
326
  ];
320
327
  /**
321
328
  * Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
@@ -361,9 +368,11 @@ let shared;
361
368
  * defaults to it; a test passes `openDb(":memory:")` instead.
362
369
  */
363
370
  export const pierDb = () => (shared ??= openDb(PIER_DB));
364
- /** A release-level restore point, taken while the service is stopped even when
365
- * the release has no schema migration. The previous complete copies stay put if
366
- * writing this one fails.
371
+ /** A release-level restore point, taken for every release even when it has no
372
+ * schema migration. The previous complete copies stay put if writing this one
373
+ * fails, and the service may be running while it is written: `copyDatabase`
374
+ * reads through a read-only connection, so what it writes is one consistent
375
+ * snapshot of a live database rather than a torn `cp`.
367
376
  *
368
377
  * `version` is the Pier that produced this database, not the one being
369
378
  * installed: the updater runs this from the tree it is about to replace, and
package/dist/main.js CHANGED
@@ -42,14 +42,13 @@ import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config
42
42
  import { PushStore, registerPushRoutes } from "./web/push.js";
43
43
  import { SessionStateStore } from "./web/session-state.js";
44
44
  import { createServer } from "./web/server.js";
45
- import { attachTerminal } from "./web/terminal.js";
46
45
  const log = logger("pier");
47
46
  // Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
48
47
  // everything Pi derives from its agent dir (auth.json, models.json, sessions,
49
48
  // bin) lands under PIER_HOME instead of ~/.pi.
50
49
  //
51
50
  // An operator override wins — but only a human's. Everything Pier spawns (the
52
- // Web Terminal, the agent's own shell) inherits this variable, so a second
51
+ // agent's own shell, task sessions) inherits this variable, so a second
53
52
  // Pier started from inside the first with its own PIER_HOME would adopt the
54
53
  // first one's agent dir and write its sessions, SYSTEM.md and models.json
55
54
  // there: two instances sharing a directory neither was told to share, and
@@ -58,8 +57,8 @@ const log = logger("pier");
58
57
  // again from this instance's own PIER_HOME.
59
58
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
60
59
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
61
- // Ahead of everything Pier spawns — sessions, tasks, the Web Terminal all
62
- // inherit this process's env. A tool switched on in the Console is Pier's
60
+ // Ahead of everything Pier spawns — sessions and tasks all inherit this
61
+ // process's env. A tool switched on in the Console is Pier's
63
62
  // copy at Pier's version, so it goes first, not last.
64
63
  prependPath(process.env);
65
64
  // First, and explicitly: every store below shares this one connection, and a
@@ -205,8 +204,9 @@ await configurationSync.reconcile();
205
204
  tasks.start();
206
205
  readyForConfigReload = true;
207
206
  void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
208
- // Replacing Pier is systemd's job, not this process's: the oneshot unit stops
209
- // the service, snapshots the database, installs and starts it again. Without
207
+ // Replacing Pier is systemd's job, not this process's: the oneshot unit
208
+ // snapshots the database, installs, then stops the service and starts it again
209
+ // on the new version — in that order, so only the last two are downtime. Without
210
210
  // that unit there is nothing to hand the work to, and the Console says so
211
211
  // instead of offering a button that cannot work.
212
212
  const updates = new UpdateCheck();
@@ -247,8 +247,14 @@ const takeWorkAgain = (why) => {
247
247
  };
248
248
  /** How long the handover has to actually stop us. `systemctl start --no-block`
249
249
  * returns when the job is *queued*, so "started" is not proof of anything;
250
- * the real outcome is a SIGTERM a second or two later. */
251
- const HANDOVER_GRACE_MS = 60_000;
250
+ * the real outcome is a SIGTERM — but only after the updater has snapshotted
251
+ * the database and installed the new version, which is a registry download on
252
+ * someone else's network — ten seconds on a good day, minutes on a bad one.
253
+ * So this waits far longer than the stop itself needs, because reopening the
254
+ * gate mid-install would take work we are about to be SIGTERM'd out of, and
255
+ * still short enough that a handover which never happens does not refuse
256
+ * messages all afternoon. */
257
+ const HANDOVER_GRACE_MS = 5 * 60_000;
252
258
  const handOverToUpdater = async () => {
253
259
  // One handover at a time, and never on top of a restart: the Console button,
254
260
  // the auto-update tick and SIGUSR2 would otherwise drain the same Pier
@@ -389,11 +395,6 @@ const server = serve({ fetch: app.fetch, port, hostname }, () => {
389
395
  log.info(`agent dir ${process.env.PI_CODING_AGENT_DIR} (PI_CODING_AGENT_DIR)`);
390
396
  }
391
397
  });
392
- // The one WebSocket surface (see web/terminal.ts); `serve` above builds a
393
- // plain node:http server, which is the only shape with an upgrade event.
394
- const terminals = attachTerminal(server, auth, {
395
- initCommand: () => settings.get().terminalInitCommand,
396
- });
397
398
  // A crash and a clean stop must be distinguishable after the fact, and both
398
399
  // left nothing behind before this.
399
400
  process.on("uncaughtException", (err) => {
@@ -416,7 +417,6 @@ const shutdown = (stopTasks = true) => {
416
417
  // `systemctl restart` into a 90-second wait for SIGKILL.
417
418
  setTimeout(() => process.exit(0), 3000).unref();
418
419
  stopEviction();
419
- terminals.close(); // no shell outlives the workbench
420
420
  // The drain path leaves task runs alone: aborting them here would record
421
421
  // them cancelled and race their callbacks against dying channels, when the
422
422
  // boot-time interrupted marking is the recovery that was promised.
package/dist/service.js CHANGED
@@ -104,8 +104,14 @@ function legacyOptions(home) {
104
104
  ...(pierHome ? { pierHome } : {}),
105
105
  };
106
106
  }
107
- /** A separate cgroup stops Pier, takes a consistent backup, updates the exact
108
- * npm installation recorded at install time, and always starts Pier again. */
107
+ /** A separate cgroup snapshots the database, updates the exact npm
108
+ * installation recorded at install time, and only then restarts Pier.
109
+ *
110
+ * Both slow steps run while the service is still up, so the downtime is one
111
+ * stop and one start rather than the ~10s an install takes. The snapshot is
112
+ * consistent on a live database — `VACUUM INTO` off a read-only connection
113
+ * (db.ts) — and it still runs from the tree npm is about to replace, so the
114
+ * copy carries the version whose schema it pairs with. */
109
115
  export function renderUpdateUnit(options) {
110
116
  const { execPath, npmPath, entry, pierHome, shellPath } = options;
111
117
  const cli = join(dirname(entry), "cli.js");
@@ -120,12 +126,16 @@ Type=oneshot
120
126
  # resolves node from PATH, and systemd's minimal PATH has no fnm/nvm node —
121
127
  # the install then dies with "node: not found" with the tree half written.
122
128
  ${environment("PATH", pathEnv(execPath, shellPath))}
123
- ${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=systemctl --user stop ${UNIT_NAME}
124
- ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
129
+ ${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
125
130
  # npm runs under the recorded node: its shebang needs a node on PATH too.
126
131
  ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
127
- # ExecStopPost runs on success and failure, so a failed backup or npm install
128
- # does not leave the previously working service stopped.
132
+ # Last, because everything above it is downtime the running Pier does not owe
133
+ # anyone. A failed backup or install never gets here: the service stays up on
134
+ # the code it already loaded.
135
+ ExecStart=systemctl --user stop ${UNIT_NAME}
136
+ # ExecStopPost runs on success and failure. After a failed step above, Pier was
137
+ # never stopped and this is a no-op; it is here for the window that is still
138
+ # fatal — the unit dying between that stop and the start of the new version.
129
139
  ExecStopPost=systemctl --user start ${UNIT_NAME}
130
140
  `;
131
141
  }
@@ -300,7 +310,7 @@ export function startUpdate(options) {
300
310
  if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
301
311
  return "failed";
302
312
  say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
303
- say(`Pier stops, snapshots the database into db/backups/, installs, then starts again.`);
313
+ say(`Pier snapshots the database into db/backups/ and installs while still up, then stops and starts on the new version.`);
304
314
  return "started";
305
315
  }
306
316
  export function uninstall(home = homedir(), say = console.log, exec) {
package/dist/settings.js CHANGED
@@ -71,26 +71,6 @@ export function normalizeModelMenu(raw) {
71
71
  }
72
72
  return menu;
73
73
  }
74
- /**
75
- * One line, typed into a shell. Same reject-don't-repair contract: a command
76
- * silently truncated at a newline would run half of what the operator wrote,
77
- * in every project shell, with no sign of what was dropped.
78
- */
79
- export function normalizeTerminalInitCommand(raw) {
80
- if (typeof raw !== "string")
81
- return null;
82
- const text = raw.trim();
83
- if (!text)
84
- return "";
85
- if (text.length > 500)
86
- return null;
87
- // A tty reads these as keys, not text: an embedded newline is a second
88
- // command nobody would see in the field it was typed in.
89
- for (const ch of text)
90
- if (ch < " " || ch === "\u007f")
91
- return null;
92
- return text;
93
- }
94
74
  /**
95
75
  * Shape only — an unknown name is not an error here. This file must not know
96
76
  * what Pier bundles (that catalog is code, and importing it would drag the Pi
@@ -131,7 +111,6 @@ export class SettingsStore {
131
111
  publicUrl: this.#value("publicUrl") ?? "",
132
112
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
133
113
  autoUpdate: this.#value("autoUpdate") === "1",
134
- terminalInitCommand: this.#value("terminalInitCommand") ?? "",
135
114
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
136
115
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
137
116
  customTools: this.#json("customTools", normalizeCustomTools, "a list of {name, spec}") ?? [],
@@ -170,11 +149,6 @@ export class SettingsStore {
170
149
  this.#set("modelMenu", JSON.stringify(menu));
171
150
  return this.get();
172
151
  }
173
- /** Same contract again: hand this `normalizeTerminalInitCommand`'s output. */
174
- setTerminalInitCommand(command) {
175
- this.#set("terminalInitCommand", command);
176
- return this.get();
177
- }
178
152
  setAutoUpdate(on) {
179
153
  this.#set("autoUpdate", on ? "1" : "0");
180
154
  return this.get();
@@ -53,7 +53,7 @@ export class TaskCallbacks {
53
53
  // one delivery, and the first one's name and model as the card's
54
54
  // caption would attribute every other result to it. The text names
55
55
  // each run; the card says nothing rather than something false.
56
- ...(runs.length === 1 ? { source: runSource(runs[0]) } : {}),
56
+ ...(runs.length === 1 ? { source: runSource(runs[0]), state: runs[0].state } : {}),
57
57
  },
58
58
  }),
59
59
  describe: (run) => `the result of "${run.context.definition.name}"`,
@@ -247,6 +247,10 @@ export class TaskDefinitions {
247
247
  return this.router.stateOf(sessionId) !== undefined ||
248
248
  (await this.factory.find(sessionId)) !== undefined;
249
249
  }
250
+ /** A caller's own directory — what a relative or omitted cwd resolves against. */
251
+ async sessionCwd(sessionId) {
252
+ return (await this.factory.find(sessionId))?.cwd;
253
+ }
250
254
  /** The guard `ownerOf` exists for, on the three ways a definition changes. */
251
255
  assertOwner(task, by, what) {
252
256
  const owner = ownerOf(task);
@@ -27,7 +27,8 @@ export class TaskGroups {
27
27
  save: (group) => { this.store.saveGroup(group); },
28
28
  changed,
29
29
  // A group names itself in the origin where a run names its run id, so the
30
- // engine's transcript proof works unchanged.
30
+ // engine's transcript proof works unchanged; the console's run route
31
+ // resolves that id to the group's members (web/ui/task-runs.ts).
31
32
  input: (groups) => ({
32
33
  text: this.text(groups[0]),
33
34
  origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
@@ -18,6 +18,15 @@ import { TaskStore } from "./store.js";
18
18
  import { handleTaskTool } from "./tool.js";
19
19
  import { isTerminal } from "./types.js";
20
20
  const log = logger("tasks");
21
+ /** The text a run was given, as the operator wrote it: a resume's follow-up,
22
+ * else the action's own prompt or script. Not `renderedPrompt` — that is the
23
+ * preamble and the input wrapper, which the delegating session did not send. */
24
+ const runPrompt = (run) => {
25
+ if (run.context.resumePrompt)
26
+ return run.context.resumePrompt;
27
+ const action = run.context.definition.action;
28
+ return action.type === "agent" ? action.prompt : action.type === "bash" ? action.script : null;
29
+ };
21
30
  export class TaskService {
22
31
  store;
23
32
  factory;
@@ -195,11 +204,11 @@ export class TaskService {
195
204
  recentMessages(since) {
196
205
  return this.messages.recent(since);
197
206
  }
207
+ /** Every run this session delegated: the card is the message that started
208
+ * it, and a message does not leave the transcript because the run ended. */
198
209
  backgroundRuns(sessionId) {
199
- const cutoff = Date.now() - 60 * 60 * 1000;
200
- return this.store.listRunsForSession(sessionId, 50)
201
- .filter((run) => run.background && (!isTerminal(run.state) || run.queuedAt >= cutoff))
202
- .slice(0, 20)
210
+ return this.store.listRunsForSession(sessionId, 200)
211
+ .filter((run) => run.background)
203
212
  .reverse()
204
213
  .map((run) => this.backgroundRun(run));
205
214
  }
@@ -364,6 +373,7 @@ export class TaskService {
364
373
  targetSessionId: run.targetSessionId,
365
374
  sessionMode: run.sessionMode,
366
375
  depth: run.depth,
376
+ prompt: runPrompt(run),
367
377
  queuedAt: run.queuedAt,
368
378
  startedAt: run.startedAt,
369
379
  finishedAt: run.finishedAt,