@timqi/pier 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/log.js CHANGED
@@ -1,52 +1,35 @@
1
- // What Pier writes to its own log, and the one place that decides how it looks.
2
- //
3
- // The destination is stdout/stderr and nothing else — no files, no rotation, no
4
- // log configuration. Under systemd that *is* the log: journald stamps the time,
5
- // keeps the history, rotates it and lets `journalctl -p warning` filter it
6
- // (docs/deploy.md), and in a terminal it is the terminal. A logger that opened
7
- // its own file would duplicate all of that and hide half the output from
8
- // `journalctl` — the one place an operator actually looks.
9
- //
10
- // Like paths.ts, this is a leaf everything may import and that imports nothing:
11
- // a log line is not a seam crossing, so no area owns it.
1
+ // What Pier writes to its own log. stdout/stderr only: under systemd journald
2
+ // is the log (timestamps, history, rotation, `journalctl -p warning`), and a
3
+ // file of our own would hide half the output from it.
12
4
  import { homedir } from "node:os";
13
5
  const ORDER = ["debug", "info", "warn", "error"];
14
6
  /** `silent` exists for test runs, which drive failure paths on purpose. */
15
7
  const THRESHOLDS = [...ORDER, "silent"];
16
8
  const isThreshold = (value) => THRESHOLDS.includes(value);
17
9
  const RANK = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
18
- /** `PIER_LOG=debug` turns on the per-message tracing; default keeps it out. */
19
10
  const raw = (process.env.PIER_LOG ?? "").toLowerCase();
20
11
  const threshold = isThreshold(raw) ? raw : "info";
21
- // systemd sets JOURNAL_STREAM when our output goes to the journal. There the
22
- // time and the level are journal fields, not text: a `<N>` prefix is how a
23
- // plain stream tells journald its priority (sd-daemon(3)), so the same line
24
- // that reads well in a terminal stays greppable *and* filterable by level.
12
+ // systemd sets JOURNAL_STREAM when output goes to the journal; a `<N>` prefix
13
+ // is how a plain stream tells journald its priority (sd-daemon(3)).
25
14
  const toJournal = process.env.JOURNAL_STREAM !== undefined;
26
15
  const PRIORITY = { debug: "<7>", info: "<6>", warn: "<4>", error: "<3>" };
27
- /** `$HOME` back to `~`: a log line is read by a human, and paths dominate.
28
- * Skipped when `$HOME` is `/` (containers do this), where it would rewrite
29
- * every slash in every message. */
16
+ /** Skipped when `$HOME` is `/` (containers), where it would rewrite every slash. */
30
17
  const home = homedir();
31
18
  const shorten = (text) => home.length > 1 ? text.replaceAll(home, "~") : text;
32
- /** An Error contributes its stack — the post-mortem is why it was logged. */
33
19
  const describe = (cause) => cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause);
34
20
  function write(level, area, message, cause) {
35
21
  if (RANK[level] < RANK[threshold])
36
22
  return;
37
23
  const text = shorten(cause === undefined ? message : `${message}: ${describe(cause)}`);
38
- // Per line, not per message: journald reads a prefix off each line, so a
39
- // stack's frames would otherwise land at the default priority — and a
40
- // newline in something a browser reported would let it forge a level.
24
+ // Per line: journald reads a prefix off each line, and a newline in
25
+ // something a browser reported would otherwise let it forge a level.
41
26
  const line = toJournal
42
27
  ? text.split("\n").map((part, i) => `${PRIORITY[level]}${i === 0 ? `${area}: ` : ""}${part}`).join("\n")
43
28
  : `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${area}: ${text}`;
44
- // Warnings and errors on stderr: it is what journald and every wrapper
45
- // already treat as the abnormal stream, with or without the prefix above.
46
29
  const stream = level === "warn" || level === "error" ? process.stderr : process.stdout;
47
30
  stream.write(`${line}\n`);
48
31
  }
49
- /** `logger("slack")` — the area is the grep handle, so keep it stable. */
32
+ /** The area is the grep handle, so keep it stable. */
50
33
  export const logger = (area) => ({
51
34
  debug: (message, cause) => write("debug", area, message, cause),
52
35
  info: (message, cause) => write("info", area, message, cause),
package/dist/main.js CHANGED
@@ -27,6 +27,7 @@ import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
27
27
  import { bundledInfo } from "./extensions/index.js";
28
28
  import { surfacePrompt } from "./core/reply.js";
29
29
  import { Router } from "./core/router.js";
30
+ import { acquireInstanceLock } from "./lock.js";
30
31
  import { logger } from "./log.js";
31
32
  import { registerTaskRoutes } from "./tasks/routes.js";
32
33
  import { TaskService } from "./tasks/service.js";
@@ -45,51 +46,41 @@ import { PushStore, registerPushRoutes } from "./web/push.js";
45
46
  import { SessionStateStore } from "./web/session-state.js";
46
47
  import { createServer } from "./web/server.js";
47
48
  const log = logger("pier");
48
- // Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
49
- // everything Pi derives from its agent dir (auth.json, models.json, sessions,
50
- // bin) lands under PIER_HOME instead of ~/.pi.
51
- //
52
- // An operator override wins — but only a human's. Everything Pier spawns (the
53
- // agent's own shell, task sessions) inherits this variable, so a second
54
- // Pier started from inside the first with its own PIER_HOME would adopt the
55
- // first one's agent dir and write its sessions, SYSTEM.md and models.json
56
- // there: two instances sharing a directory neither was told to share, and
57
- // PIER_HOME looking like it did nothing. PIER_AGENT_DIR marks the value as
58
- // ours, and a value that is ours is not an override, it is a leak — derive it
59
- // again from this instance's own PIER_HOME.
49
+ // Before any SDK call resolves a path, so everything Pi derives from its agent
50
+ // dir lands under PIER_HOME. PIER_AGENT_DIR marks the value as ours: a second
51
+ // Pier spawned from inside the first inherits it, and an inherited value is a
52
+ // leak, not an operator override (paths.ts).
60
53
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
61
54
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
62
- // Ahead of everything Pier spawns — sessions and tasks all inherit this
63
- // process's env. A tool switched on in the Console is Pier's
64
- // copy at Pier's version, so it goes first, not last.
65
55
  prependPath(process.env);
66
- // First, and explicitly: every store below shares this one connection, and a
67
- // schema that cannot be migrated must stop the process here — before a port is
68
- // open and before anything has written a row.
56
+ // Before the database and before task recovery, which would mark the running
57
+ // instance's queued and active runs interrupted; the port is discovered far too
58
+ // late, and a second Pier on another port would never notice at all.
59
+ const lock = acquireInstanceLock();
60
+ if ("heldBy" in lock) {
61
+ log.error(`another Pier (pid ${lock.heldBy === null ? "unknown" : String(lock.heldBy)}) owns ${PIER_HOME} — refusing to start`);
62
+ process.exit(1);
63
+ }
64
+ // Every exit path at once: the drain and both shutdowns end in `process.exit`,
65
+ // and the crash that skips this is what the stale-claim takeover is for.
66
+ process.on("exit", lock.release);
67
+ // A schema that cannot be migrated must stop the process before a port is open.
69
68
  const db = pierDb();
70
- // Files earlier versions kept beside the database. Their values live in
71
- // pier.db now, and a setting that silently stops being read is a 5b violation:
72
- // the operator who wrote it deserves to hear that it no longer applies.
69
+ // A setting that silently stops being read is a §5 violation.
73
70
  for (const stale of ["settings.json", "pins.json", "unread.json"]) {
74
71
  if (existsSync(pierPath(stale))) {
75
72
  log.warn(`${pierPath(stale)} is no longer read — its value lives in pier.db now; re-enter it in the Console and delete the file`);
76
73
  }
77
74
  }
78
- // One store, two readers: the Console writes the public URL, and every session
79
- // opened after that is told the new one.
80
75
  const settings = new SettingsStore(db);
81
- // Layer-1 credential encryption (channel tokens today). Constructed here,
82
- // unlocked below: file mode is instant, vt mode waits on a human approval, and
83
- // nothing that needs a token may run before the key arrives.
76
+ // Unlocked below: vt mode waits on a human approval, and nothing that needs a
77
+ // token may run before the key arrives.
84
78
  const secrets = new Secrets();
85
79
  let tasks;
86
80
  const conversations = new ConversationStore(db);
87
81
  let resolveIm;
88
- // Declared before the store exists because the factory is built first; the tool
89
- // only ever runs long after wiring is done.
90
82
  let channelStore;
91
- // Shared by the adapter and the tool: a display name is looked up once per
92
- // process, not once per message and again per transcript.
83
+ // Shared by the adapter and the tool: one display-name lookup per process.
93
84
  const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
94
85
  let readyForConfigReload = false;
95
86
  const piConfig = new PiConfigStore();
@@ -102,15 +93,12 @@ const factory = new PiAgentFactory([
102
93
  slackToolSpec((params, callerSessionId) => handleSlackTool({
103
94
  store: channelStore,
104
95
  directory: slackDirectory,
105
- // Rebuilt per call: the Console can change the token underneath us,
106
- // and a client captured at boot would keep using the old one.
96
+ // Per call: the Console can change the token underneath us.
107
97
  client: () => {
108
98
  const config = channelStore.get("slack");
109
99
  return config.token ? new SlackApi(config.token, config.appToken) : null;
110
100
  },
111
- // Which Slack thread this session is answering, so "post here" needs
112
- // no ids. Looked up per call: the mapping is durable, the session is
113
- // not.
101
+ // Per call: the mapping is durable, the session is not.
114
102
  here: (sessionId) => {
115
103
  const key = router.conversationOf(sessionId);
116
104
  if (key?.channelId !== "slack")
@@ -119,55 +107,33 @@ const factory = new PiAgentFactory([
119
107
  return channel && threadTs ? { channel, threadTs } : null;
120
108
  },
121
109
  log: (m) => logger("slack.tool").warn(m),
122
- }, params, callerSessionId),
123
- // No Slack, no schema: an unconfigured tool would sit in every prompt of
124
- // every session and be able to answer nothing.
125
- () => slackToolAvailable(channelStore)),
110
+ }, params, callerSessionId), () => slackToolAvailable(channelStore)),
126
111
  ],
127
- // Called per session open, so a setting changed in the Console reaches the
128
- // next session without a restart.
112
+ // Getters, read per session open: a Console change reaches the next session
113
+ // without a restart.
129
114
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
130
- // Ships with Pier: documents Pier's own tools, so it loads only inside a
131
- // Pier session — not in a bare Pi session that has no task tool.
132
- [fileURLToPath(new URL("../skills", import.meta.url))],
133
- // Provider credentials live sealed in pier.db; a leftover auth.json is
134
- // imported on first use and renamed to auth.json.imported.
135
- new CredentialStore(db, secrets), piConfig,
136
- // Operator pins ride ahead of the curated catalog in every model picker.
137
- () => settings.get().modelMenu,
138
- // Bundled extensions the Console switched on; read per session open, so the
139
- // toggle reaches the next session the same way an edited agent file does.
140
- () => settings.get().extensions,
141
- // The title model, if the operator picked one; read when a first turn ends.
142
- () => settings.get().titleModel,
143
- // The search index reads Pi's transcripts, which carry the speaker header
144
- // core wrote for the model; this is where the two areas meet.
115
+ // Documents Pier's own tools, so it loads only inside a Pier session.
116
+ [fileURLToPath(new URL("../skills", import.meta.url))], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get().extensions, () => settings.get().titleModel,
117
+ // Transcripts carry the speaker header core wrote for the model.
145
118
  new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
146
119
  const hub = new EventHub();
147
120
  const router = new Router(hub, (key) => {
148
- // Web conversation ids ARE session ids; an IM conversation id is a chat or a
149
- // topic, so its session is looked up in the durable map (and created in the
150
- // cwd the chat is configured for) — a restart must not re-route a group.
121
+ // Web conversation ids are session ids; an IM id is a chat, resolved through
122
+ // the durable map so a restart does not re-route a group.
151
123
  if (key.channelId === "web" || key.channelId === "task") {
152
124
  return factory.resume(key.conversationId);
153
125
  }
154
126
  return resolveIm(key);
155
- });
156
- // An attached session holds a live Pi runtime and its transcript, and nothing
157
- // else ever lets one go: without this, one per conversation ever answered.
127
+ }, (key) => conversations.get(key));
158
128
  const stopEviction = router.startIdleEviction();
159
129
  tasks = new TaskService(new TaskStore(db), factory, router, hub, {
160
130
  modelMenu: () => settings.get().modelMenu,
161
131
  systemActions: { "config-sync": (signal) => configSync.sync(signal) },
162
132
  });
163
133
  const configurationSync = configSyncTask(tasks, configSync);
164
- // The managed CLI tools (src/tools.ts), and the daily task that keeps them
165
- // current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
166
- // wired here because tools.ts may not import tasks/.
167
134
  const managedTools = new ManagedTools();
168
135
  const toolsUpdate = toolsTask(tasks);
169
- // Before any route exists: two first flips could otherwise both find no task
170
- // and create one each. A failure here is logged, and the next flip retries.
136
+ // Before any route exists: two first flips could otherwise both create a task.
171
137
  const reconciled = await toolsUpdate.reconcile();
172
138
  if ("problem" in reconciled)
173
139
  log.error(`tools cannot be managed: ${reconciled.problem}`);
@@ -175,31 +141,25 @@ channelStore = new ChannelStore(db, secrets);
175
141
  const control = createControl({ router, factory, conversations, store: channelStore });
176
142
  const channels = new ChannelRuntime(channelStore, router, control);
177
143
  resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
178
- // Channels connect only once tokens are readable. A refused unlock (vt denial,
179
- // corrupt master.key) must not take the web surface down — it is where the
180
- // operator goes to repair — but it is named loudly, not served as silence.
181
- // Once they are up, the chats a previous restart cut off are told (drain.ts) —
182
- // on this path and on a later Console unlock alike, because a note held back
183
- // by locked secrets must not wait for yet another restart.
144
+ // Channels connect once tokens are readable; a refused unlock must not take
145
+ // down the web surface, which is where the operator repairs it. The chats a
146
+ // previous restart cut off are told as soon as channels are up (drain.ts).
184
147
  const restartLedger = new RestartLedger(db);
185
148
  const startChannels = async () => {
186
149
  await channels.reload();
187
150
  await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
188
151
  .catch((err) => log.error("restart-note delivery failed", err));
189
152
  };
190
- /** What "reload" means, in one place: the adapters re-read their configuration
191
- * and sessions are let go, so the next message re-opens them with the current
192
- * skills, extensions, prompts and credentials — all applied at attach, none
193
- * stored in a transcript. SIGHUP (`pier reload`) and the Console's Reload are
194
- * both this call; `includeWatched` is the only difference, and only because the
195
- * Console knows a person asked from the session they are looking at. */
153
+ /** Adapters re-read their configuration and sessions are let go, so the next
154
+ * message re-opens them with current skills, extensions, prompts and
155
+ * credentials. SIGHUP and the Console's Reload are both this call. */
196
156
  const reloadInstance = async (includeWatched = false) => {
197
157
  await channels.reload();
198
158
  return router.evictIdle(0, Date.now(), { includeWatched });
199
159
  };
200
- // Bound the startup check before sessions and scheduled work can start.
201
160
  // Remote outages keep the last local configuration available.
202
161
  if (configSync.status().enabled) {
162
+ // sync() already logged the cause; this line says what the boot did about it.
203
163
  try {
204
164
  await configSync.sync();
205
165
  }
@@ -211,28 +171,15 @@ await configurationSync.reconcile();
211
171
  tasks.start();
212
172
  readyForConfigReload = true;
213
173
  void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
214
- // Replacing Pier is systemd's job, not this process's: the oneshot unit
215
- // snapshots the database, installs, then stops the service and starts it again
216
- // on the new version — in that order, so only the last two are downtime. Without
217
- // that unit there is nothing to hand the work to, and the Console says so
218
- // instead of offering a button that cannot work.
174
+ // Replacing Pier is systemd's job: the oneshot unit snapshots the database,
175
+ // installs, then stops and starts the service. Without that unit the Console
176
+ // says so instead of offering a button that cannot work.
219
177
  const updates = new UpdateCheck();
220
- // Asked once at boot, not lazily on the first page load: a restart is exactly
221
- // when "am I current?" is worth knowing, and it puts the answer in the journal
222
- // of a Pier nobody has a browser open on.
178
+ // At boot, not lazily: it puts the answer in the journal of a Pier nobody has
179
+ // a browser open on.
223
180
  void updates.refresh();
224
- /**
225
- * Hand over, but not onto a running turn. The updater's first act is
226
- * `systemctl stop`, i.e. a SIGTERM, which is the *fast* teardown — so anything
227
- * that started since the idle check would be killed with no note anywhere. The
228
- * gate closes first and the drain waits, exactly as `pier restart` does,
229
- * ledger included; only then is the install handed over. A handover that never
230
- * starts reopens the gate, because a Pier that silently refuses every message
231
- * forever is worse than the race it was avoiding.
232
- */
233
- // Shared restart state. The updater's handover, the SIGUSR2 drain (below) and
234
- // the final teardown must see each other: without this, two paths drain the
235
- // same Pier at once, and a failure on one reopens the gate the other still
181
+ // The updater's handover, the SIGUSR2 drain and the teardown must see each
182
+ // other, or two paths drain the same Pier and one reopens the gate the other
236
183
  // needs shut.
237
184
  let handingOver = false;
238
185
  let draining = false;
@@ -240,32 +187,23 @@ let shuttingDown = false;
240
187
  const takeWorkAgain = (why) => {
241
188
  handingOver = false;
242
189
  log.error(`${why} — taking work again`);
243
- // Not ours to reopen: a SIGUSR2 restart or the teardown owns the gate now,
244
- // and reopening it would hand new work to a process that is exiting.
190
+ // Not ours to reopen: a restart or the teardown owns the gate now.
245
191
  if (draining || shuttingDown)
246
192
  return;
247
193
  router.endDrain();
248
194
  tasks.unpause();
249
- // The drain may have deadline-aborted turns into the ledger. Without the
250
- // restart that was supposed to follow, that debt would wait for one days
251
- // away (§5b) — so the chats are told now, by the process that cut them off.
195
+ // Turns the drain deadline-aborted must not wait for a restart days away (§5).
252
196
  void deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
253
197
  .catch((err) => log.error("restart-note delivery failed", err));
254
198
  };
255
- /** How long the handover has to actually stop us. `systemctl start --no-block`
256
- * returns when the job is *queued*, so "started" is not proof of anything;
257
- * the real outcome is a SIGTERM — but only after the updater has snapshotted
258
- * the database and installed the new version, which is a registry download on
259
- * someone else's network — ten seconds on a good day, minutes on a bad one.
260
- * So this waits far longer than the stop itself needs, because reopening the
261
- * gate mid-install would take work we are about to be SIGTERM'd out of, and
262
- * still short enough that a handover which never happens does not refuse
263
- * messages all afternoon. */
199
+ /** `systemctl start --no-block` returns when the job is queued; the real
200
+ * outcome is a SIGTERM after a registry download on someone else's network.
201
+ * Long enough not to reopen the gate mid-install, short enough that a handover
202
+ * that never happens does not refuse messages all afternoon. */
264
203
  const HANDOVER_GRACE_MS = 5 * 60_000;
265
204
  const handOverToUpdater = async () => {
266
- // One handover at a time, and never on top of a restart: the Console button,
267
- // the auto-update tick and SIGUSR2 would otherwise drain the same Pier
268
- // twice, each believing the gate is its own to reopen on failure.
205
+ // The updater's first act is `systemctl stop`, the fast teardown: the gate
206
+ // closes and the drain waits first, as `pier restart` does.
269
207
  if (handingOver || draining || shuttingDown)
270
208
  return "busy";
271
209
  handingOver = true;
@@ -275,11 +213,8 @@ const handOverToUpdater = async () => {
275
213
  takeWorkAgain(`update not started (${started})`);
276
214
  return started;
277
215
  }
278
- // The gate is closed and nothing in this process will open it again, so a
279
- // handover that queues and then goes nowhere — npm failed, the unit was
280
- // masked, the job sat behind another — would leave Pier alive and refusing
281
- // every message with no way back. Unref'd: this must not be what keeps the
282
- // process up while systemd is trying to stop it.
216
+ // A handover that queues and goes nowhere would leave Pier refusing every
217
+ // message with no way back. Unref'd: must not keep the process up under stop.
283
218
  setTimeout(() => {
284
219
  takeWorkAgain(`still running ${String(HANDOVER_GRACE_MS / 1000)}s after handing over — pier-update.service never stopped Pier` +
285
220
  ` (check: journalctl --user -u pier-update.service -e)`);
@@ -289,20 +224,17 @@ const handOverToUpdater = async () => {
289
224
  const updater = process.platform === "linux" && existsSync(unitPath())
290
225
  ? { apply: handOverToUpdater, problem: () => updaterProblem() }
291
226
  : null;
292
- // Unattended only when the operator asked for it *and* nothing is running.
293
227
  if (updater) {
294
228
  const problem = updaterProblem();
295
- // Loudly, at boot: this is the one moment the operator is looking, and the
296
- // alternative is a restart that fails months from now.
229
+ // At boot, when the operator is looking; the alternative is a restart that
230
+ // fails months from now.
297
231
  if (problem)
298
232
  log.warn(`the updater cannot run: ${problem}`);
299
233
  startAutoUpdate(updates, {
300
234
  enabled: () => settings.get().autoUpdate,
301
235
  idle: () => router.busy().length === 0 && tasks.activeRunCount() === 0,
302
236
  apply: async () => {
303
- // Re-checked here, not only at boot: a version manager can remove the
304
- // recorded Node months into an uptime, and draining for a handover that
305
- // cannot happen would take the whole instance down with it.
237
+ // A version manager can remove the recorded Node months into an uptime.
306
238
  const now = updaterProblem();
307
239
  if (now) {
308
240
  log.error(`auto-update skipped: ${now}`);
@@ -312,18 +244,14 @@ if (updater) {
312
244
  },
313
245
  });
314
246
  }
315
- // Composition happens here so web/ and tasks/ never import each other.
316
247
  const app = new Hono();
317
- // A route that threw would otherwise answer 500 and leave no trace anywhere:
318
- // Hono's default handler writes nothing to the log, so the operator sees a
319
- // failed request in the browser and an empty journal.
248
+ // Hono's default handler writes nothing to the log.
320
249
  app.onError((err, c) => {
321
250
  log.error(`${c.req.method} ${c.req.path} failed`, err);
322
251
  return c.json({ error: String(err) }, 500);
323
252
  });
324
- // Before every route on purpose: Hono runs middleware in registration order,
325
- // so a surface added later is covered without knowing this exists. Built
326
- // before the listener: a first run generates and prints its password here.
253
+ // Before every route: Hono runs middleware in registration order, so a surface
254
+ // added later is covered without knowing this exists.
327
255
  const auth = new AuthStore(db);
328
256
  registerConfigShareRoute(app, configSync);
329
257
  app.use("*", requireAuth(auth));
@@ -338,11 +266,8 @@ registerTaskRoutes(app, tasks, { factory, router });
338
266
  registerChannelRoutes(app, channelStore, channels);
339
267
  registerBoardRoutes(app);
340
268
  const sessionState = new SessionStateStore(db);
341
- // The workbench's notifications to a browser that is not open. Composed here,
342
- // beside the other surfaces: it consumes the same event stream the web server
343
- // does, and neither one runs the other.
344
269
  registerPushRoutes(app, {
345
- store: new PushStore(db),
270
+ store: new PushStore(db, secrets),
346
271
  hub,
347
272
  unread: (id) => sessionState.unread(id),
348
273
  channelOf: (id) => router.conversationOf(id)?.channelId,
@@ -357,11 +282,8 @@ app.route("/", createServer({
357
282
  config: piConfig,
358
283
  providers: factory,
359
284
  settings,
360
- // The catalog is code, so the composition root is where it is read: web/
361
- // gets names and summaries, not a module that imports the Pi SDK.
362
- // One list, assembled where both halves are visible: the extensions Pier
363
- // loads from inside itself and the binaries it installs are the same kind of
364
- // switch to the person flipping it, and rtk is both.
285
+ // Assembled here so web/ gets names and summaries, not a module that
286
+ // imports the Pi SDK; extensions and binaries are one kind of switch.
365
287
  catalog: async () => {
366
288
  const { extensions, tools, customTools } = settings.get();
367
289
  return {
@@ -369,14 +291,10 @@ app.route("/", createServer({
369
291
  toolsTaskId: toolsUpdate.id(),
370
292
  };
371
293
  },
372
- // Names only, and the same two lists the catalog above is built from: the
373
- // route validates a switch against what this Pier *can* switch, which is
374
- // code, never against a catalog whose custom half the request may be
375
- // rewriting.
294
+ // A switch is validated against what this Pier *can* switch, never against
295
+ // a catalog whose custom half the request may be rewriting.
376
296
  names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
377
297
  onToolsChanged: toolsUpdate.changed,
378
- // The rule lives with the installer; the names the bundled catalog already
379
- // owns live with the extensions. Only here are both in scope.
380
298
  validateCustomTools: (raw) => {
381
299
  const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
382
300
  return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
@@ -384,7 +302,6 @@ app.route("/", createServer({
384
302
  secrets,
385
303
  updates,
386
304
  updater,
387
- // Unlocked from the Console: start the channels boot held back.
388
305
  onUnlocked: () => void startChannels(),
389
306
  reload: () => reloadInstance(true),
390
307
  backgroundRuns: (id) => tasks.backgroundRuns(id),
@@ -394,56 +311,45 @@ app.route("/", createServer({
394
311
  }));
395
312
  const port = Number(process.env.PORT ?? 3141);
396
313
  const hostname = process.env.HOST ?? "127.0.0.1";
397
- // Read above, and dropped here so nothing else reads them: these three
398
- // configure *this* process, and every command a turn runs inherits its env.
399
- // `NODE_ENV=production` makes an agent's `npm install` skip devDependencies and
400
- // silently changes what half the ecosystem builds; PORT and HOST would aim an
401
- // agent's own dev server at Pier's socket. Deleted at runtime rather than only
402
- // dropped from the unit, because an installed unit is rewritten by
403
- // `pier service install --force` and by nothing else.
314
+ // Every command a turn runs inherits this env: `NODE_ENV=production` makes an
315
+ // agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
316
+ // server at Pier's socket.
404
317
  for (const leak of ["NODE_ENV", "PORT", "HOST"])
405
318
  delete process.env[leak];
406
319
  const server = serve({ fetch: app.fetch, port, hostname }, () => {
407
320
  log.info(`workbench on http://${hostname}:${port}`);
408
321
  log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
409
- // Only when it is not the derived default: an agent dir outside PIER_HOME is
410
- // the one thing about this process's paths that cannot be guessed from it.
322
+ // The one path that cannot be guessed from PIER_HOME.
411
323
  if (process.env.PI_CODING_AGENT_DIR !== pierPath("pi")) {
412
324
  log.info(`agent dir ${process.env.PI_CODING_AGENT_DIR} (PI_CODING_AGENT_DIR)`);
413
325
  }
414
326
  });
415
- // A crash and a clean stop must be distinguishable after the fact, and both
416
- // left nothing behind before this.
417
327
  process.on("uncaughtException", (err) => {
418
328
  log.error("uncaught exception, exiting", err);
419
329
  process.exit(1); // Node's own default outcome, with the area named
420
330
  });
421
- // This one *does* change behaviour: Node's default is to crash. A stray
422
- // rejection in one adapter's background work must not take every session and
423
- // every scheduled task down with it — so it is logged loudly and Pier serves on.
331
+ // Node's default is to crash; a stray rejection in one adapter's background
332
+ // work must not take every session and scheduled task down with it.
424
333
  process.on("unhandledRejection", (reason) => {
425
334
  log.error("unhandled rejection", reason);
426
335
  });
427
336
  const shutdown = (stopTasks = true) => {
428
- // Once: SIGTERM can land while a drain is finishing, and two teardowns
429
- // racing each other close the same sockets twice.
337
+ // SIGTERM can land while a drain is finishing.
430
338
  if (shuttingDown)
431
339
  return;
432
340
  shuttingDown = true;
433
- // Best-effort, and bounded: a socket an adapter cannot close must not turn
434
- // `systemctl restart` into a 90-second wait for SIGKILL.
341
+ // A socket an adapter cannot close must not turn `systemctl restart` into a
342
+ // 90-second wait for SIGKILL.
435
343
  setTimeout(() => process.exit(0), 3000).unref();
436
344
  stopEviction();
437
- // The drain path leaves task runs alone: aborting them here would record
438
- // them cancelled and race their callbacks against dying channels, when the
439
- // boot-time interrupted marking is the recovery that was promised.
345
+ // The drain path leaves task runs alone: aborting would record them cancelled,
346
+ // when the boot-time interrupted marking is the recovery that was promised.
440
347
  if (stopTasks)
441
348
  tasks.stop();
442
349
  void channels.stop().finally(() => {
443
350
  server.close(() => process.exit(0));
444
351
  // Every workbench tab holds an SSE stream open, so `close()` alone would
445
- // always wait out the timer above. (`in` because the served type is a
446
- // union with HTTP/2, which has no such method — and no such problem.)
352
+ // wait out the timer above. (`in`: the served type is a union with HTTP/2.)
447
353
  if ("closeAllConnections" in server)
448
354
  server.closeAllConnections();
449
355
  });
@@ -454,10 +360,8 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
454
360
  shutdown();
455
361
  });
456
362
  }
457
- // The slow restart (`pier restart`): refuse new work, let running turns finish
458
- // — bounded by the drain deadline — then exit for `Restart=always` to bring the
459
- // next process up. SIGTERM above stays the fast path systemd expects. `on`,
460
- // not `once`: a second SIGUSR2 with no handler would fall back to Node's
363
+ // The slow restart (`pier restart`): drain, then exit for `Restart=always`.
364
+ // `on`, not `once`: a second SIGUSR2 with no handler would fall back to Node's
461
365
  // default and kill the drain it meant to hurry.
462
366
  process.on("SIGUSR2", () => {
463
367
  if (draining) {
@@ -470,11 +374,8 @@ process.on("SIGUSR2", () => {
470
374
  .catch((err) => log.error("drain failed — shutting down anyway", err))
471
375
  .then(() => shutdown(false));
472
376
  });
473
- // Reload without a restart (`pier reload`): reloadInstance above, leaving the
474
- // sessions someone is watching alone — nobody asked from a browser here.
475
- // Only under systemd (the CLI signals through systemctl): a foreground `pier
476
- // serve` keeps SIGHUP's default, dying with its terminal instead of surviving
477
- // as an orphan that holds the port.
377
+ // Only under systemd: a foreground `pier serve` keeps SIGHUP's default, dying
378
+ // with its terminal instead of surviving as an orphan that holds the port.
478
379
  if (process.env.INVOCATION_ID) {
479
380
  process.on("SIGHUP", () => {
480
381
  log.info("SIGHUP received, reloading channels and recycling idle sessions");
package/dist/paths.js CHANGED
@@ -1,36 +1,20 @@
1
- // Where Pier keeps its state, resolved once.
2
- //
3
- // This is process configuration, not a per-call decision: the same
4
- // `PIER_HOME ?? ~/.pier` line had grown six copies, one per module that needed
5
- // a file, and no area could own the fix — channels/ must not import tasks/,
6
- // web/ must not import channels/. So it lives in a leaf that everything may
7
- // depend on and that depends on nothing.
1
+ // Where Pier keeps its state, resolved once, in a leaf every area may import.
8
2
  import { homedir } from "node:os";
9
3
  import { join } from "node:path";
10
- /** Empty is unset, not a value: `PIER_HOME=` in a shell would otherwise
11
- * resolve every path below relative to the working directory, and the
12
- * database, the boards and the master key would land wherever the process
13
- * happened to start. Pure, because that rule is worth a test. */
4
+ /** Empty is unset, not a value: `PIER_HOME=` would otherwise resolve every
5
+ * path relative to the working directory. */
14
6
  export const resolveHome = (value, home = homedir()) => value || join(home, ".pier");
15
- /** `$PIER_HOME`, or `~/.pier`. Fixed for the life of the process. */
16
7
  export const PIER_HOME = resolveHome(process.env.PIER_HOME);
17
- /** A path inside it — `pierPath("boards")`. */
18
8
  export const pierPath = (...parts) => join(PIER_HOME, ...parts);
19
- /** The one SQLite file; every store opens this same path. In its own
20
- * directory so db.ts can lock that directory down to 0700 without touching
9
+ /** In its own directory so db.ts can lock it down to 0700 without touching
21
10
  * the boards PIER_HOME also holds. */
22
11
  export const PIER_DB = pierPath("db", "pier.db");
23
- /**
24
- * Which Pi agent dir this process should use, given its environment.
25
- *
26
- * `PI_CODING_AGENT_DIR` is an operator override and wins — but Pier exports it
27
- * for the SDK, so everything Pier spawns inherits it, and a Pier started from
28
- * inside another one would take the parent's agent dir however different its
29
- * own PIER_HOME is. `PIER_AGENT_DIR` carries the value Pier itself set: when
30
- * the two match, the variable is a leak rather than an instruction, and this
31
- * instance derives its own. Pure, because the rule is worth a test and the
32
- * process only gets to apply it once (main.ts).
33
- */
12
+ /** At the root of PIER_HOME: the claim is the whole instance directory, not the
13
+ * database (lock.ts). */
14
+ export const PIER_LOCK = pierPath("pier.lock");
15
+ /** `PI_CODING_AGENT_DIR` is an operator override — unless it equals
16
+ * `PIER_AGENT_DIR`, the value Pier itself set: then a Pier spawned from inside
17
+ * another inherited it, and a leak is not an instruction. */
34
18
  export function resolveAgentDir(env, derived = pierPath("pi")) {
35
19
  const given = env.PI_CODING_AGENT_DIR;
36
20
  return !given || given === env.PIER_AGENT_DIR ? derived : given;