@timqi/pier 0.0.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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
package/dist/db.js ADDED
@@ -0,0 +1,268 @@
1
+ // The one connection, and the one place the schema is written down.
2
+ //
3
+ // Every store used to open `pier.db` for itself and create its own tables with
4
+ // `CREATE TABLE IF NOT EXISTS`. That works exactly once: it can add a table but
5
+ // never change one, so the first column an upgrade needed would have left every
6
+ // existing instance with a schema nothing could repair. `user_version` is a
7
+ // single number per *database*, not per table, which is why the schema cannot
8
+ // stay spread across five modules — and five connections to one file is also
9
+ // five writers competing for the same lock.
10
+ //
11
+ // So: one connection, one ordered list of migrations, applied in one
12
+ // transaction before any store exists. A store receives the handle and owns
13
+ // only its queries.
14
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
15
+ import { basename, dirname, join } from "node:path";
16
+ import { DatabaseSync } from "node:sqlite";
17
+ import { logger } from "./log.js";
18
+ import { PIER_DB } from "./paths.js";
19
+ const log = logger("db");
20
+ /** Pre-migration snapshots to keep. Three is two upgrades of regret plus one:
21
+ * they are full copies of the database, and the one that matters is the
22
+ * newest. */
23
+ const KEEP_BACKUPS = 3;
24
+ /** How long a second process may wait for the write lock before failing. Two
25
+ * Pier processes on one PIER_HOME contend exactly once — at boot, when both
26
+ * want to migrate — and failing instantly there turns a restart race into a
27
+ * crash loop. */
28
+ const BUSY_TIMEOUT_MS = 5_000;
29
+ /**
30
+ * Append-only, never edited: index + 1 is the `user_version` a database is at
31
+ * once that entry has run. An entry that shipped is history — fix a mistake
32
+ * with the next one, because somebody's database already ran the old one.
33
+ *
34
+ * Migration 1 is the whole schema as of 0.0.1 and assumes nothing before it:
35
+ * pre-release databases are not upgraded, they are deleted.
36
+ */
37
+ const MIGRATIONS = [
38
+ // 1 — the 0.0.1 schema.
39
+ `
40
+ -- The single credential in front of every HTTP surface (web/auth.ts).
41
+ CREATE TABLE auth (
42
+ id INTEGER PRIMARY KEY CHECK (id = 1),
43
+ salt TEXT NOT NULL,
44
+ hash TEXT NOT NULL,
45
+ created_at INTEGER NOT NULL
46
+ );
47
+
48
+ -- Instance facts that are neither a credential nor per-session; one row per
49
+ -- setting, so the next setting is not the next table.
50
+ CREATE TABLE settings (
51
+ key TEXT PRIMARY KEY,
52
+ value TEXT NOT NULL
53
+ );
54
+
55
+ -- Workbench bookkeeping: pinned = listed under Projects, unread = a turn
56
+ -- finished that no client has acknowledged.
57
+ CREATE TABLE session_state (
58
+ session_id TEXT PRIMARY KEY,
59
+ pinned INTEGER NOT NULL DEFAULT 0,
60
+ unread INTEGER NOT NULL DEFAULT 0
61
+ );
62
+
63
+ -- One document per platform: credentials, defaults, chats, bound users.
64
+ CREATE TABLE channels (
65
+ platform TEXT PRIMARY KEY,
66
+ json TEXT NOT NULL
67
+ );
68
+
69
+ -- Durable conversation → session routing for IM channels.
70
+ CREATE TABLE conversations (
71
+ channel_id TEXT NOT NULL,
72
+ conversation_id TEXT NOT NULL,
73
+ session_id TEXT NOT NULL,
74
+ updated_at INTEGER NOT NULL,
75
+ PRIMARY KEY (channel_id, conversation_id)
76
+ );
77
+
78
+ -- Reaction receipts still to be cleared. message_id is TEXT: a Slack ts is
79
+ -- 1761234567.123456, which no float holds exactly.
80
+ CREATE TABLE receipts (
81
+ platform TEXT NOT NULL,
82
+ conversation_id TEXT NOT NULL,
83
+ chat_id TEXT NOT NULL,
84
+ message_id TEXT NOT NULL,
85
+ created_at INTEGER NOT NULL,
86
+ PRIMARY KEY (platform, chat_id, message_id)
87
+ );
88
+
89
+ -- Scheduled work. The row keeps its whole JSON document; the columns beside
90
+ -- it are only what a query filters or orders by.
91
+ CREATE TABLE tasks (
92
+ id TEXT PRIMARY KEY,
93
+ updated_at INTEGER NOT NULL,
94
+ json TEXT NOT NULL
95
+ );
96
+ CREATE TABLE task_runs (
97
+ id TEXT PRIMARY KEY,
98
+ task_id TEXT NOT NULL,
99
+ queued_at INTEGER NOT NULL,
100
+ state TEXT NOT NULL,
101
+ callback_state TEXT,
102
+ json TEXT NOT NULL
103
+ );
104
+ CREATE INDEX task_runs_task_time ON task_runs(task_id, queued_at DESC);
105
+ CREATE TABLE task_messages (
106
+ id TEXT PRIMARY KEY,
107
+ run_id TEXT NOT NULL,
108
+ state TEXT NOT NULL,
109
+ created_at INTEGER NOT NULL,
110
+ json TEXT NOT NULL
111
+ );
112
+ CREATE INDEX task_messages_run_time ON task_messages(run_id, created_at);
113
+ CREATE TABLE task_groups (
114
+ id TEXT PRIMARY KEY,
115
+ created_at INTEGER NOT NULL,
116
+ callback_state TEXT,
117
+ finished_at INTEGER,
118
+ json TEXT NOT NULL
119
+ );
120
+ `,
121
+ // 2 — provider credentials move from <agentDir>/auth.json into the database.
122
+ `
123
+ -- One row per provider (key = provider id), value sealed by secrets.ts.
124
+ -- Owned by agent/credentials.ts.
125
+ CREATE TABLE credentials (
126
+ key TEXT PRIMARY KEY,
127
+ value TEXT NOT NULL
128
+ );
129
+ `,
130
+ ];
131
+ let shared;
132
+ /**
133
+ * The process's one connection, opened and migrated on first use. Every store
134
+ * defaults to it; a test passes `openDb(":memory:")` instead.
135
+ */
136
+ export const pierDb = () => (shared ??= openDb(PIER_DB));
137
+ /** Open a database, bring it to the current schema, and lock down its files.
138
+ * `migrations` is injectable only so tests can exercise an upgrade — there is
139
+ * exactly one real list. */
140
+ export function openDb(path, migrations = MIGRATIONS) {
141
+ if (path !== ":memory:")
142
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
143
+ const db = new DatabaseSync(path);
144
+ // Outside the transaction below: journal_mode is a property of the file, and
145
+ // SQLite refuses to change it inside one.
146
+ db.exec("PRAGMA journal_mode = WAL");
147
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
148
+ migrate(db, path, migrations);
149
+ if (path !== ":memory:")
150
+ restrict(path);
151
+ return db;
152
+ }
153
+ /**
154
+ * Upgrades only. `user_version` counts up and nothing counts it back down, so a
155
+ * database from a newer Pier is refused rather than served: the old code would
156
+ * happily write the new schema's tables and lose whatever it did not know
157
+ * about. The way back is the `.bak` this function writes before upgrading.
158
+ */
159
+ function migrate(db, path, migrations) {
160
+ const { user_version: at } = db.prepare("PRAGMA user_version").get();
161
+ const target = migrations.length;
162
+ if (at > target) {
163
+ // Name the snapshot that exists rather than a pattern: the operator is
164
+ // reading this because the service will not start.
165
+ const newest = path === ":memory:" ? undefined : backups(path)[0]?.file;
166
+ throw new Error(`${path} is at schema ${at}, this Pier speaks ${target}: a database is ` +
167
+ `never downgraded. Restore ${newest ?? `${path}.v*.bak`}, or run the newer Pier.`);
168
+ }
169
+ // Version 0 with tables is a database from before versioning existed.
170
+ // Migration 1 assumes an empty file, so the collision it would hit says
171
+ // "table already exists" — this says what is actually wrong and what to do.
172
+ if (at === 0 && db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' LIMIT 1").get()) {
173
+ throw new Error(`${path} predates schema versioning and cannot be upgraded — nothing was changed. ` +
174
+ `Move the file aside and restart; channel credentials, tasks and the password start over.`);
175
+ }
176
+ if (at === target)
177
+ return;
178
+ if (at > 0 && path !== ":memory:")
179
+ snapshot(db, path, at);
180
+ // One transaction for the statements *and* the version number: a crash
181
+ // between them would leave a database whose version describes a schema it
182
+ // does not have, which is worse than a crash.
183
+ db.exec("BEGIN IMMEDIATE");
184
+ // Re-read inside the lock. Two processes starting together both saw work to
185
+ // do; the one that waited for the lock would otherwise replay migrations the
186
+ // winner already committed and die on "table already exists", with a healthy
187
+ // database in front of it.
188
+ const { user_version: locked } = db.prepare("PRAGMA user_version").get();
189
+ if (locked >= target) {
190
+ db.exec("ROLLBACK");
191
+ log.info(`schema already at ${locked}, migrated by another process`);
192
+ return;
193
+ }
194
+ let step = locked;
195
+ try {
196
+ for (; step < target; step++)
197
+ db.exec(migrations[step]);
198
+ db.exec(`PRAGMA user_version = ${target}`);
199
+ db.exec("COMMIT");
200
+ }
201
+ catch (err) {
202
+ db.exec("ROLLBACK");
203
+ // Which one, and that the database is untouched: the operator's next move
204
+ // is to restore a backup or to report the migration, and a bare SQLite
205
+ // error says neither.
206
+ throw new Error(`migration ${step + 1} failed on ${path} — nothing was changed: ${String(err)}`, { cause: err });
207
+ }
208
+ log.info(locked === 0 ? `schema created at version ${target}` : `schema ${locked} → ${target}`);
209
+ if (path !== ":memory:")
210
+ prune(path);
211
+ }
212
+ /**
213
+ * The copy that exists because `user_version` only counts up: the transaction
214
+ * above protects against a migration that *failed*, and this against one that
215
+ * succeeded and should not have. `VACUUM INTO`, not `cp`: under WAL the
216
+ * committed tail of the database lives in the `-wal` sidecar.
217
+ *
218
+ * Written under a temporary name and renamed into place. `VACUUM INTO` refuses
219
+ * an existing target, so the alternative is deleting the previous snapshot
220
+ * first — which means the likely failure here, a full disk, leaves neither the
221
+ * old snapshot nor a complete new one. A rename is atomic: the `.bak` name only
222
+ * ever refers to a finished copy.
223
+ */
224
+ function snapshot(db, path, at) {
225
+ const bak = `${path}.v${at}.bak`;
226
+ const tmp = `${bak}.tmp`;
227
+ rmSync(tmp, { force: true }); // a previous crash may have left one
228
+ db.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
229
+ chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
230
+ renameSync(tmp, bak);
231
+ log.info(`pre-migration backup: ${bak}`);
232
+ }
233
+ /** Snapshots beside the database, newest schema first. */
234
+ function backups(path) {
235
+ const prefix = `${basename(path)}.v`;
236
+ return readdirSync(dirname(path))
237
+ .filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
238
+ .map((name) => ({
239
+ version: Number(name.slice(prefix.length, -".bak".length)),
240
+ file: join(dirname(path), name),
241
+ }))
242
+ .filter(({ version }) => Number.isInteger(version))
243
+ .sort((a, b) => b.version - a.version);
244
+ }
245
+ /** Keep the newest few. Nobody restores a database from four upgrades ago, and
246
+ * every one of these is the size of the whole database. */
247
+ function prune(path) {
248
+ for (const { file } of backups(path).slice(KEEP_BACKUPS)) {
249
+ rmSync(file, { force: true });
250
+ log.info(`removed superseded backup: ${file}`);
251
+ }
252
+ }
253
+ /**
254
+ * The database holds the password hash, so it is not world-readable — and
255
+ * neither are the sidecars, where a 0644 `-wal` would leak exactly what the
256
+ * 0600 database is hiding. Done after the migration, so the sidecars that
257
+ * writing created exist by now; SQLite gives later ones the database's mode.
258
+ * The directory too: it exists only to hold this database and its sidecars
259
+ * (paths.ts puts them under their own `db/`, away from the boards PIER_HOME
260
+ * also holds), so nothing else needs to see into it.
261
+ */
262
+ function restrict(path) {
263
+ for (const file of [path, `${path}-wal`, `${path}-shm`]) {
264
+ if (existsSync(file))
265
+ chmodSync(file, 0o600);
266
+ }
267
+ chmodSync(dirname(path), 0o700);
268
+ }
package/dist/log.js ADDED
@@ -0,0 +1,55 @@
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.
12
+ import { homedir } from "node:os";
13
+ const ORDER = ["debug", "info", "warn", "error"];
14
+ /** `silent` exists for test runs, which drive failure paths on purpose. */
15
+ const THRESHOLDS = [...ORDER, "silent"];
16
+ const isThreshold = (value) => THRESHOLDS.includes(value);
17
+ 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
+ const raw = (process.env.PIER_LOG ?? "").toLowerCase();
20
+ 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.
25
+ const toJournal = process.env.JOURNAL_STREAM !== undefined;
26
+ 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. */
30
+ const home = homedir();
31
+ 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
+ const describe = (cause) => cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause);
34
+ function write(level, area, message, cause) {
35
+ if (RANK[level] < RANK[threshold])
36
+ return;
37
+ 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.
41
+ const line = toJournal
42
+ ? text.split("\n").map((part, i) => `${PRIORITY[level]}${i === 0 ? `${area}: ` : ""}${part}`).join("\n")
43
+ : `${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
+ const stream = level === "warn" || level === "error" ? process.stderr : process.stdout;
47
+ stream.write(`${line}\n`);
48
+ }
49
+ /** `logger("slack")` — the area is the grep handle, so keep it stable. */
50
+ export const logger = (area) => ({
51
+ debug: (message, cause) => write("debug", area, message, cause),
52
+ info: (message, cause) => write("info", area, message, cause),
53
+ warn: (message, cause) => write("warn", area, message, cause),
54
+ error: (message, cause) => write("error", area, message, cause),
55
+ });
package/dist/main.js ADDED
@@ -0,0 +1,183 @@
1
+ // Wiring only — no logic lives here. See docs/architecture.md.
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { serve } from "@hono/node-server";
5
+ import { Hono } from "hono";
6
+ import { PiConfigStore } from "./agent/config.js";
7
+ import { CredentialStore } from "./agent/credentials.js";
8
+ import { PiAgentFactory } from "./agent/pi.js";
9
+ import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
10
+ import { ChannelStore } from "./channels/config.js";
11
+ import { createControl } from "./channels/control.js";
12
+ import { ConversationStore, resolveConversation } from "./channels/conversations.js";
13
+ import { registerChannelRoutes } from "./channels/routes.js";
14
+ import { ChannelRuntime } from "./channels/runtime.js";
15
+ import { SlackApi } from "./channels/slack-api.js";
16
+ import { SlackDirectory } from "./channels/slack-directory.js";
17
+ import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
18
+ import { parseConversation as parseSlackConversation } from "./channels/slack.js";
19
+ import { EventHub } from "./core/hub.js";
20
+ import { pierDb } from "./db.js";
21
+ import { surfacePrompt } from "./core/reply.js";
22
+ import { Router } from "./core/router.js";
23
+ import { logger } from "./log.js";
24
+ import { registerTaskRoutes } from "./tasks/routes.js";
25
+ import { TaskService } from "./tasks/service.js";
26
+ import { TaskStore } from "./tasks/store.js";
27
+ import { taskToolSpec } from "./tasks/tool.js";
28
+ import { PIER_HOME, pierPath } from "./paths.js";
29
+ import { Secrets } from "./secrets.js";
30
+ import { SettingsStore } from "./settings.js";
31
+ import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
32
+ import { SessionStateStore } from "./web/session-state.js";
33
+ import { createServer } from "./web/server.js";
34
+ const log = logger("pier");
35
+ // Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
36
+ // everything Pi derives from its agent dir (auth.json, models.json, sessions,
37
+ // bin) lands under PIER_HOME instead of ~/.pi. An operator override wins.
38
+ process.env.PI_CODING_AGENT_DIR ??= pierPath("pi");
39
+ // First, and explicitly: every store below shares this one connection, and a
40
+ // schema that cannot be migrated must stop the process here — before a port is
41
+ // open and before anything has written a row.
42
+ const db = pierDb();
43
+ // Files earlier versions kept beside the database. Their values live in
44
+ // pier.db now, and a setting that silently stops being read is a 5b violation:
45
+ // the operator who wrote it deserves to hear that it no longer applies.
46
+ for (const stale of ["settings.json", "pins.json", "unread.json"]) {
47
+ if (existsSync(pierPath(stale))) {
48
+ 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`);
49
+ }
50
+ }
51
+ // One store, two readers: the Console writes the public URL, and every session
52
+ // opened after that is told the new one.
53
+ const settings = new SettingsStore(db);
54
+ // Layer-1 credential encryption (channel tokens today). Constructed here,
55
+ // unlocked below: file mode is instant, vt mode waits on a human approval, and
56
+ // nothing that needs a token may run before the key arrives.
57
+ const secrets = new Secrets();
58
+ let tasks;
59
+ const conversations = new ConversationStore(db);
60
+ let resolveIm;
61
+ // Declared before the store exists because the factory is built first; the tool
62
+ // only ever runs long after wiring is done.
63
+ let channelStore;
64
+ // Shared by the adapter and the tool: a display name is looked up once per
65
+ // process, not once per message and again per transcript.
66
+ const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
67
+ const factory = new PiAgentFactory([
68
+ taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
69
+ slackToolSpec((params, callerSessionId) => handleSlackTool({
70
+ store: channelStore,
71
+ directory: slackDirectory,
72
+ // Rebuilt per call: the Console can change the token underneath us,
73
+ // and a client captured at boot would keep using the old one.
74
+ client: () => {
75
+ const config = channelStore.get("slack");
76
+ return config.token ? new SlackApi(config.token, config.appToken) : null;
77
+ },
78
+ // Which Slack thread this session is answering, so "post here" needs no
79
+ // ids. Looked up per call: the mapping is durable, the session is not.
80
+ here: (sessionId) => {
81
+ const key = router.conversationOf(sessionId);
82
+ if (key?.channelId !== "slack")
83
+ return null;
84
+ const { channel, threadTs } = parseSlackConversation(key.conversationId);
85
+ return channel && threadTs ? { channel, threadTs } : null;
86
+ },
87
+ log: (m) => logger("slack.tool").warn(m),
88
+ }, params, callerSessionId)),
89
+ ],
90
+ // Called per session open, so a setting changed in the Console reaches the
91
+ // next session without a restart.
92
+ () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
93
+ // Ships with Pier: documents Pier's own tools, so it loads only inside a
94
+ // Pier session — not in a bare Pi session that has no task tool.
95
+ [fileURLToPath(new URL("../skills", import.meta.url))],
96
+ // Provider credentials live sealed in pier.db; a leftover auth.json is
97
+ // imported on first use and renamed to auth.json.imported.
98
+ new CredentialStore(db, secrets));
99
+ const hub = new EventHub();
100
+ const router = new Router(hub, (key) => {
101
+ // Web conversation ids ARE session ids; an IM conversation id is a chat or a
102
+ // topic, so its session is looked up in the durable map (and created in the
103
+ // cwd the chat is configured for) — a restart must not re-route a group.
104
+ if (key.channelId === "web" || key.channelId === "task") {
105
+ return factory.resume(key.conversationId);
106
+ }
107
+ return resolveIm(key);
108
+ });
109
+ tasks = new TaskService(new TaskStore(db), factory, router, hub);
110
+ tasks.start();
111
+ channelStore = new ChannelStore(db, secrets);
112
+ const control = createControl({ router, factory, conversations, store: channelStore });
113
+ const channels = new ChannelRuntime(channelStore, router, control);
114
+ resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
115
+ // Channels connect only once tokens are readable. A refused unlock (vt denial,
116
+ // corrupt master.key) must not take the web surface down — it is where the
117
+ // operator goes to repair — but it is named loudly, not served as silence.
118
+ void secrets.unlock().then(() => channels.reload(), (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
119
+ // Composition happens here so web/ and tasks/ never import each other.
120
+ const app = new Hono();
121
+ // A route that threw would otherwise answer 500 and leave no trace anywhere:
122
+ // Hono's default handler writes nothing to the log, so the operator sees a
123
+ // failed request in the browser and an empty journal.
124
+ app.onError((err, c) => {
125
+ log.error(`${c.req.method} ${c.req.path} failed`, err);
126
+ return c.json({ error: String(err) }, 500);
127
+ });
128
+ // Before every route on purpose: Hono runs middleware in registration order,
129
+ // so a surface added later is covered without knowing this exists. Built
130
+ // before the listener: a first run generates and prints its password here.
131
+ const auth = new AuthStore(db);
132
+ registerAuthRoutes(app, auth);
133
+ app.use("*", requireAuth(auth));
134
+ registerTaskRoutes(app, tasks, { factory, router });
135
+ registerChannelRoutes(app, channelStore, channels);
136
+ registerBoardRoutes(app);
137
+ app.route("/", createServer({
138
+ factory,
139
+ router,
140
+ hub,
141
+ sessions: new SessionStateStore(db),
142
+ config: new PiConfigStore(),
143
+ settings,
144
+ secrets,
145
+ // Unlocked from the Console: start the channels boot held back.
146
+ onUnlocked: () => void channels.reload(),
147
+ backgroundRuns: (id) => tasks.backgroundRuns(id),
148
+ }));
149
+ const port = Number(process.env.PORT ?? 3141);
150
+ const hostname = process.env.HOST ?? "127.0.0.1";
151
+ const server = serve({ fetch: app.fetch, port, hostname }, () => {
152
+ log.info(`workbench on http://${hostname}:${port}`);
153
+ log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
154
+ });
155
+ // A crash and a clean stop must be distinguishable after the fact, and both
156
+ // left nothing behind before this.
157
+ process.on("uncaughtException", (err) => {
158
+ log.error("uncaught exception, exiting", err);
159
+ process.exit(1); // Node's own default outcome, with the area named
160
+ });
161
+ // This one *does* change behaviour: Node's default is to crash. A stray
162
+ // rejection in one adapter's background work must not take every session and
163
+ // every scheduled task down with it — so it is logged loudly and Pier serves on.
164
+ process.on("unhandledRejection", (reason) => {
165
+ log.error("unhandled rejection", reason);
166
+ });
167
+ for (const signal of ["SIGTERM", "SIGINT"]) {
168
+ process.once(signal, () => {
169
+ log.info(`${signal} received, shutting down`);
170
+ // Best-effort, and bounded: a socket an adapter cannot close must not turn
171
+ // `systemctl restart` into a 90-second wait for SIGKILL.
172
+ setTimeout(() => process.exit(0), 3000).unref();
173
+ tasks.stop();
174
+ void channels.stop().finally(() => {
175
+ server.close(() => process.exit(0));
176
+ // Every workbench tab holds an SSE stream open, so `close()` alone would
177
+ // always wait out the timer above. (`in` because the served type is a
178
+ // union with HTTP/2, which has no such method — and no such problem.)
179
+ if ("closeAllConnections" in server)
180
+ server.closeAllConnections();
181
+ });
182
+ });
183
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,17 @@
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.
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ /** `$PIER_HOME`, or `~/.pier`. Fixed for the life of the process. */
11
+ export const PIER_HOME = process.env.PIER_HOME ?? join(homedir(), ".pier");
12
+ /** A path inside it — `pierPath("boards")`. */
13
+ export const pierPath = (...parts) => join(PIER_HOME, ...parts);
14
+ /** The one SQLite file; every store opens this same path. In its own
15
+ * directory so db.ts can lock that directory down to 0700 without touching
16
+ * the boards PIER_HOME also holds. */
17
+ export const PIER_DB = pierPath("db", "pier.db");