@timqi/pier 0.0.29 → 0.1.1

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