@timqi/pier 0.0.4 → 0.0.6

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 (43) hide show
  1. package/README.md +9 -6
  2. package/dist/agent/events.js +6 -1
  3. package/dist/agent/pi.js +28 -3
  4. package/dist/channels/chunk.js +34 -0
  5. package/dist/channels/control.js +6 -12
  6. package/dist/channels/dedup.js +45 -0
  7. package/dist/channels/lark-api.js +233 -0
  8. package/dist/channels/lark-outbound.js +101 -0
  9. package/dist/channels/lark-panel.js +95 -0
  10. package/dist/channels/lark-render.js +107 -0
  11. package/dist/channels/lark.js +501 -0
  12. package/dist/channels/lines.js +19 -0
  13. package/dist/channels/panel.js +4 -0
  14. package/dist/channels/receipts.js +15 -0
  15. package/dist/channels/routes.js +0 -1
  16. package/dist/channels/runtime.js +5 -1
  17. package/dist/channels/slack-api.js +4 -2
  18. package/dist/channels/slack-panel.js +3 -6
  19. package/dist/channels/slack-render.js +3 -23
  20. package/dist/channels/slack.js +39 -72
  21. package/dist/channels/telegram-api.js +4 -2
  22. package/dist/channels/telegram-panel.js +3 -3
  23. package/dist/channels/telegram.js +55 -51
  24. package/dist/channels/types.js +9 -0
  25. package/dist/cli.js +3 -1
  26. package/dist/core/inbox.js +67 -1
  27. package/dist/core/types.js +4 -0
  28. package/dist/db.js +83 -20
  29. package/dist/main.js +5 -0
  30. package/dist/service.js +1 -1
  31. package/dist/web/auth.js +36 -9
  32. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  33. package/dist/web/public/assets/ghostty-web-ODXT71Ln.js +13 -0
  34. package/dist/web/public/assets/index-BUNGxtMe.css +2 -0
  35. package/dist/web/public/assets/index-QPYgeBhQ.js +90 -0
  36. package/dist/web/public/index.html +10 -2
  37. package/dist/web/server.js +86 -19
  38. package/dist/web/session-state.js +59 -25
  39. package/dist/web/terminal.js +334 -0
  40. package/docs/deploy.md +33 -21
  41. package/package.json +10 -2
  42. package/dist/web/public/assets/index-B3MvJUJP.js +0 -90
  43. package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
@@ -16,8 +16,8 @@
16
16
  <meta name="apple-mobile-web-app-capable" content="yes" />
17
17
  <meta name="apple-mobile-web-app-title" content="Pier" />
18
18
  <title>Pier</title>
19
- <script type="module" crossorigin src="/assets/index-B3MvJUJP.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CwBoxtXP.css">
19
+ <script type="module" crossorigin src="/assets/index-QPYgeBhQ.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BUNGxtMe.css">
21
21
  </head>
22
22
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
23
23
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
@@ -90,6 +90,13 @@
90
90
  <path d="M8 5.5v5M5.5 8h5" stroke-linecap="round" />
91
91
  </svg>
92
92
  </button>
93
+ <button type="button" id="open-terminal"
94
+ class="icon-btn" aria-label="Terminal">
95
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5">
96
+ <rect x="2.25" y="2.25" width="11.5" height="11.5" rx="2.5" />
97
+ <path d="M4.75 5.75 7 8l-2.25 2.25M8.25 10.75h3" stroke-linecap="round" stroke-linejoin="round" />
98
+ </svg>
99
+ </button>
93
100
  </div>
94
101
  <div id="project-tree" class="flex-1 overflow-y-auto text-[13.5px]"></div>
95
102
  </aside>
@@ -113,6 +120,7 @@
113
120
  <section id="boards-view" class="hidden min-h-0 flex-1 flex-col"></section>
114
121
  <section id="settings-view" class="hidden min-h-0 flex-1 flex-col"></section>
115
122
  <section id="files-view" class="hidden min-h-0 flex-1 flex-col"></section>
123
+ <section id="terminal-view" class="hidden min-h-0 flex-1 flex-col"></section>
116
124
  <header id="chat-header" class="flex h-10 flex-none items-center gap-2 border-b border-neutral-200 px-4 max-md:hidden">
117
125
  <span id="chat-title" class="truncate font-medium"></span>
118
126
  <!-- Right cluster: meta chips sit flush against the ⋯ button. Two
@@ -6,6 +6,7 @@ import { join, relative } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { serveStatic } from "@hono/node-server/serve-static";
8
8
  import { Hono } from "hono";
9
+ import { compress } from "hono/compress";
9
10
  import { streamSSE } from "hono/streaming";
10
11
  import { EventHub } from "../core/hub.js";
11
12
  import { logger } from "../log.js";
@@ -18,6 +19,12 @@ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
18
19
  import { registerInstanceRoutes } from "./instance.js";
19
20
  import { registerProviderRoutes } from "./providers.js";
20
21
  const log = logger("web");
22
+ /** A transcript without the bytes nobody has asked to see yet. A step's `args`
23
+ * and `output` are ~90% of a long session's snapshot and sit inside a
24
+ * collapsed group; the client fetches one turn's worth when it is opened. */
25
+ const slim = (turn) => turn.steps
26
+ ? { ...turn, steps: turn.steps.map(({ args: _args, output: _output, ...step }) => step) }
27
+ : turn;
21
28
  /** What goes in front of `Pier` in the tab: `$PIER_TITLE`, then the machine.
22
29
  * The label leads because a tab is narrow and "which instance is this" is the
23
30
  * question it has to answer before the browser truncates — `staging - g1`. */
@@ -47,7 +54,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
47
54
  }
48
55
  if (!runningNow.delete(e.sessionId))
49
56
  return;
50
- state.set("unread", e.sessionId, true);
57
+ state.setUnread(e.sessionId, true);
51
58
  hub.emitWorkspace({ type: "sessions-changed" });
52
59
  });
53
60
  /** Background runs this session launched that are still in flight. */
@@ -58,21 +65,49 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
58
65
  // only once the first assistant message lands. Merged into the list below
59
66
  // so every client sees a new session immediately; dropped once Pi lists it.
60
67
  const nascent = new Map();
68
+ // listAll parses every transcript. Concurrent consumers share that work,
69
+ // but the result is not retained: an explicit All-sessions open stays fresh.
70
+ let listing;
71
+ let projectBackfillNeeded = state.needsProjectBackfill();
72
+ const listSessions = () => listing ??= factory.list()
73
+ .then((rows) => {
74
+ state.remember(rows);
75
+ return rows;
76
+ })
77
+ .finally(() => {
78
+ listing = undefined;
79
+ });
80
+ const present = (s, pinned, unread) => ({
81
+ ...s,
82
+ state: router.stateOf(s.id) ?? "idle",
83
+ pinned,
84
+ unread,
85
+ activeRuns: activeRuns(s.id),
86
+ });
87
+ app.get("/api/projects", async (c) => {
88
+ // Existing databases have pin booleans but no summaries. Pay one legacy
89
+ // scan, fill those rows, then every later Projects read is SQLite-only.
90
+ if (projectBackfillNeeded) {
91
+ await listSessions();
92
+ // Do not retry on every request, and do not clear a pin whose transcript
93
+ // happened to be unreadable. A later explicit full listing can repair it.
94
+ projectBackfillNeeded = false;
95
+ }
96
+ return c.json(state.projects().map((s) => present(s, true, s.unread)));
97
+ });
61
98
  app.get("/api/sessions", async (c) => {
62
- const sessions = await factory.list();
99
+ const sessions = await listSessions();
63
100
  for (const s of sessions)
64
101
  nascent.delete(s.id);
65
102
  // A session created but never prompted would otherwise be listed forever.
66
103
  for (const [id, n] of nascent)
67
104
  if (Date.now() - n.createdAt > 86_400_000)
68
105
  nascent.delete(id);
69
- return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => ({
70
- ...s,
71
- state: router.stateOf(s.id) ?? "idle",
72
- pinned: state.has("pinned", s.id),
73
- unread: state.has("unread", s.id),
74
- activeRuns: activeRuns(s.id),
75
- })));
106
+ const flags = state.flags();
107
+ return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => {
108
+ const row = flags.get(s.id);
109
+ return present(s, row?.pinned ?? false, row?.unread ?? false);
110
+ }));
76
111
  });
77
112
  app.post("/api/sessions", async (c) => {
78
113
  const body = await c.req.json().catch(() => ({}));
@@ -80,10 +115,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
80
115
  if (typeof body.cwd !== "string" || !body.cwd)
81
116
  return c.json({ error: "cwd required" }, 400);
82
117
  const session = await factory.create({ cwd: body.cwd });
83
- nascent.set(session.id, { cwd: body.cwd, createdAt: Date.now() });
118
+ const createdAt = Date.now();
119
+ nascent.set(session.id, { cwd: body.cwd, createdAt });
84
120
  router.attach({ channelId: "web", conversationId: session.id }, session);
85
121
  // Created here = part of the workspace; pinning is what Projects lists.
86
- state.set("pinned", session.id, true);
122
+ state.pin({ id: session.id, cwd: body.cwd, createdAt }, true);
87
123
  hub.emitWorkspace({ type: "sessions-changed" });
88
124
  return c.json({ id: session.id }, 201);
89
125
  });
@@ -91,29 +127,43 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
91
127
  // here; the broadcast moves every other client's dot back to idle.
92
128
  app.post("/api/sessions/:id/read", (c) => {
93
129
  const id = c.req.param("id");
94
- if (state.has("unread", id)) {
95
- state.set("unread", id, false);
130
+ if (state.unread(id)) {
131
+ state.setUnread(id, false);
96
132
  hub.emitWorkspace({ type: "sessions-changed" });
97
133
  }
98
134
  return c.json({ ok: true });
99
135
  });
100
136
  app.post("/api/sessions/:id/pin", async (c) => {
101
137
  const body = await c.req.json().catch(() => null);
102
- if (typeof body?.pinned !== "boolean")
103
- return c.json({ error: "pinned required" }, 400);
104
- // The id is trusted: checking existence costs a session list per click,
105
- // and the surface is operator-authenticated. Worst case is a stray row.
106
- state.set("pinned", c.req.param("id"), body.pinned);
138
+ if (typeof body?.pinned !== "boolean" ||
139
+ typeof body.cwd !== "string" || !body.cwd ||
140
+ typeof body.createdAt !== "number" || !Number.isFinite(body.createdAt) ||
141
+ (body.title !== undefined && typeof body.title !== "string")) {
142
+ return c.json({ error: "pinned and session summary required" }, 400);
143
+ }
144
+ // The summary came from this authenticated surface's own list. Persisting
145
+ // it here makes the next Projects read independent of Pi's transcript scan.
146
+ state.pin({
147
+ id: c.req.param("id"),
148
+ cwd: body.cwd,
149
+ createdAt: body.createdAt,
150
+ ...(body.title ? { title: body.title.slice(0, 80) } : {}),
151
+ }, body.pinned);
107
152
  hub.emitWorkspace({ type: "sessions-changed" });
108
153
  return c.json({ pinned: body.pinned });
109
154
  });
155
+ // The two responses big enough to matter: a transcript, and one turn's tool
156
+ // detail. Scoped to these routes on purpose — compressing the SSE streams
157
+ // would sit on events until the encoder's buffer filled.
158
+ app.use("/api/sessions/:id/history", compress());
159
+ app.use("/api/sessions/:id/turns/:index/steps", compress());
110
160
  // Snapshot: everything a fresh client needs before it starts consuming
111
161
  // deltas from SSE — transcript, live state, pending queue, model.
112
162
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
113
163
  const id = c.req.param("id");
114
164
  const session = await ensure(id);
115
165
  return c.json({
116
- turns: await session.history(),
166
+ turns: (await session.history()).map(slim),
117
167
  lastSeq: hub.lastSeq(id),
118
168
  model: session.model ?? null,
119
169
  state: session.state,
@@ -123,6 +173,21 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
123
173
  backgroundRuns: backgroundRuns?.(id) ?? [],
124
174
  });
125
175
  });
176
+ // One turn's activity in full, for the group the user just opened. Indexed
177
+ // like the edit route below; the steps carry their own id and tool name so a
178
+ // client whose snapshot has since been rewound can tell it is looking at a
179
+ // different turn instead of showing the wrong tool's output.
180
+ guarded(app, "GET", "/api/sessions/:id/turns/:index/steps", 404, async (c) => {
181
+ const index = Number(c.req.param("index"));
182
+ if (!Number.isInteger(index) || index < 0)
183
+ return c.json({ error: "index required" }, 400);
184
+ const turn = (await (await ensure(c.req.param("id"))).history())[index];
185
+ if (!turn)
186
+ return c.json({ error: `no turn at index ${index}` }, 404);
187
+ // Already capped at MAX_STEP_OUTPUT by the transcript rebuild: this route
188
+ // hands back what a surface shows, not the untruncated tool result.
189
+ return c.json({ steps: turn.steps ?? [] });
190
+ });
126
191
  // Composer attachments: bytes land in the inbox, the message carries the
127
192
  // path as a `[name](file:///…)` line the client builds itself — upload
128
193
  // first, so the text it sends (and optimistically renders) is final.
@@ -194,6 +259,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
194
259
  text: body.text,
195
260
  mode,
196
261
  });
262
+ if (state.title(id, body.text))
263
+ hub.emitWorkspace({ type: "sessions-changed" });
197
264
  return c.json({ sessionId }, 202);
198
265
  });
199
266
  // Edit a user turn: rewind the transcript to just before it, then re-send
@@ -1,39 +1,73 @@
1
- // Workbench organization state: which sessions show up under Projects (created
2
- // in Pier means pinned, everything else waits in All sessions) and which have
3
- // a finished turn no client has looked at yet.
1
+ // Workbench organization state: which sessions show up under Projects, the
2
+ // summaries needed to render them without scanning Pi, and which have a
3
+ // finished turn no client has looked at yet.
4
4
  //
5
5
  // One row per session rather than two JSON files: the unread flag is written at
6
6
  // the end of every turn, and rewriting a whole file on each of those writes
7
7
  // loses the entire set when the process dies mid-write — a truncated file reads
8
8
  // back as "no pins", which is indistinguishable from a fresh install.
9
9
  import { pierDb } from "../db.js";
10
- // Written out per flag rather than interpolated: the union type only holds at
11
- // compile time, and a cast at some future route is all it would take to put
12
- // request text into SQL.
13
- const SQL = {
14
- pinned: {
15
- has: "SELECT pinned AS on_ FROM session_state WHERE session_id = ?",
16
- set: `INSERT INTO session_state(session_id, pinned) VALUES (?, ?)
17
- ON CONFLICT(session_id) DO UPDATE SET pinned = excluded.pinned`,
18
- },
19
- unread: {
20
- has: "SELECT unread AS on_ FROM session_state WHERE session_id = ?",
21
- set: `INSERT INTO session_state(session_id, unread) VALUES (?, ?)
22
- ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`,
23
- },
24
- };
25
10
  export class SessionStateStore {
26
11
  #db;
27
12
  constructor(db = pierDb()) {
28
13
  this.#db = db;
29
14
  }
30
- has(flag, sessionId) {
31
- const row = this.#db.prepare(SQL[flag].has).get(sessionId);
32
- return row?.on_ === 1;
15
+ unread(sessionId) {
16
+ const row = this.#db.prepare("SELECT unread FROM session_state WHERE session_id = ?").get(sessionId);
17
+ return row?.unread === 1;
33
18
  }
34
- set(flag, sessionId, on) {
35
- // Upsert on the flag alone: the row may already exist for the other one,
36
- // and a session's two flags are set from unrelated places.
37
- this.#db.prepare(SQL[flag].set).run(sessionId, on ? 1 : 0);
19
+ setUnread(sessionId, unread) {
20
+ this.#db.prepare(`INSERT INTO session_state(session_id, unread) VALUES (?, ?)
21
+ ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`).run(sessionId, unread ? 1 : 0);
22
+ }
23
+ /** Pin plus the summary Projects needs, atomically in one row. */
24
+ pin(summary, pinned) {
25
+ this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at)
26
+ VALUES (?, ?, ?, ?, ?)
27
+ ON CONFLICT(session_id) DO UPDATE SET
28
+ pinned = excluded.pinned,
29
+ cwd = excluded.cwd,
30
+ title = COALESCE(excluded.title, session_state.title),
31
+ created_at = excluded.created_at`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt);
32
+ }
33
+ /** Project rows only; unlike AgentFactory.list(), this never touches disk. */
34
+ projects() {
35
+ const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread
36
+ FROM session_state
37
+ WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
38
+ ORDER BY created_at DESC`).all();
39
+ return rows.map(({ title, unread, ...row }) => ({
40
+ ...row,
41
+ ...(title ? { title } : {}),
42
+ unread: unread === 1,
43
+ }));
44
+ }
45
+ needsProjectBackfill() {
46
+ return this.#db.prepare("SELECT 1 FROM session_state WHERE pinned = 1 AND (cwd IS NULL OR created_at IS NULL) LIMIT 1").get() !== undefined;
47
+ }
48
+ /** A full listing is rare; use it to repair metadata for rows we already own. */
49
+ remember(summaries) {
50
+ const update = this.#db.prepare(`UPDATE session_state SET
51
+ cwd = ?, title = COALESCE(?, title), created_at = ?
52
+ WHERE session_id = ?`);
53
+ this.#db.exec("BEGIN");
54
+ try {
55
+ for (const s of summaries)
56
+ update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
57
+ this.#db.exec("COMMIT");
58
+ }
59
+ catch (err) {
60
+ this.#db.exec("ROLLBACK");
61
+ throw err;
62
+ }
63
+ }
64
+ flags() {
65
+ const rows = this.#db.prepare("SELECT session_id AS id, pinned, unread FROM session_state WHERE pinned = 1 OR unread = 1").all();
66
+ return new Map(rows.map((r) => [r.id, { pinned: r.pinned === 1, unread: r.unread === 1 }]));
67
+ }
68
+ /** The first prompt supplies the title of a newly-created pinned session. */
69
+ title(sessionId, text) {
70
+ const result = this.#db.prepare("UPDATE session_state SET title = ? WHERE session_id = ? AND pinned = 1 AND title IS NULL").run(text.trim().slice(0, 80), sessionId);
71
+ return result.changes > 0;
38
72
  }
39
73
  }
@@ -0,0 +1,334 @@
1
+ // Terminal backend: one shell pty per project cwd, mirrored to every attached
2
+ // WebSocket. The pty outlives the page — closing a tab only detaches, and a
3
+ // later attach replays the recent output ring; every attached page sees the
4
+ // same shell, and any of them may type (last resize wins). Detached for an
5
+ // hour → the shell is reaped; a Pier restart kills every pty (run tmux inside
6
+ // to survive that — durability is tmux's job, not ours). This is the one
7
+ // WebSocket surface: a keystroke is a round trip, which SSE cannot carry
8
+ // upstream. Everything else stays on the event stream.
9
+ import { realpath, stat } from "node:fs/promises";
10
+ import { isAbsolute } from "node:path";
11
+ import { spawn } from "node-pty";
12
+ import { WebSocketServer } from "ws";
13
+ import { logger } from "../log.js";
14
+ import { upgradeAuthorized } from "./auth.js";
15
+ const log = logger("terminal");
16
+ /** Live shells at once — a guard against forgotten spawns, not a quota. */
17
+ const MAX_TERMS = 8;
18
+ /** Recent output kept for reattach. Scrollback beyond it lives in the page. */
19
+ const RING_BYTES = 1024 * 1024;
20
+ const IDLE_MS = 60 * 60_000;
21
+ const SWEEP_MS = 60_000;
22
+ const HEARTBEAT_MS = 30_000;
23
+ /** A slow mirror is detached before its send queue grows without bound; a
24
+ * healthy page must never be paused behind it. Reattach replays the ring. */
25
+ const HIGH_WATER = 4 * 1024 * 1024;
26
+ const MAX_FRAME_BYTES = 1024 * 1024;
27
+ // A Web Terminal is a new local tty, not the tmux/SSH client Pier happened to
28
+ // start under. Inheriting these can attach the shell back into Pier's parent
29
+ // tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
30
+ const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
31
+ const send = (sock, data) => {
32
+ try {
33
+ sock.send(data);
34
+ return true;
35
+ }
36
+ catch (err) {
37
+ log.warn(`terminal socket send failed: ${String(err)}`);
38
+ return false;
39
+ }
40
+ };
41
+ const control = (sock, msg) => send(sock, JSON.stringify(msg));
42
+ export class TerminalHub {
43
+ #terms = new Map();
44
+ #shell;
45
+ #idleMs;
46
+ #maxTerms;
47
+ #sweeper;
48
+ #closeListeners = new Set();
49
+ #closed = false;
50
+ constructor(opts = {}) {
51
+ this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
52
+ this.#idleMs = opts.idleMs ?? IDLE_MS;
53
+ this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
54
+ this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
55
+ this.#sweeper.unref();
56
+ }
57
+ /** Attach a socket to `cwd`'s shell, spawning one if needed. A refusal is
58
+ * never silent: the socket gets a `{t:"error"}` frame and a close, and the
59
+ * caller gets `null`. */
60
+ async attach(cwd, sock) {
61
+ let key;
62
+ try {
63
+ if (!isAbsolute(cwd))
64
+ throw new Error("cwd must be an absolute directory");
65
+ key = await realpath(cwd);
66
+ if (!(await stat(key)).isDirectory())
67
+ throw new Error("cwd must be an absolute directory");
68
+ }
69
+ catch (err) {
70
+ control(sock, { t: "error", message: String(err) });
71
+ sock.close(1008, "bad cwd");
72
+ return null;
73
+ }
74
+ if (this.#closed) {
75
+ control(sock, { t: "error", message: "terminal server is stopping" });
76
+ sock.close(1012, "server stopping");
77
+ return null;
78
+ }
79
+ let term = this.#terms.get(key);
80
+ if (!term) {
81
+ if (this.#terms.size >= this.#maxTerms) {
82
+ control(sock, { t: "error", message: `${this.#maxTerms} shells already running — close one first` });
83
+ sock.close(1013, "too many shells");
84
+ return null;
85
+ }
86
+ try {
87
+ term = this.#spawn(key);
88
+ }
89
+ catch (err) {
90
+ log.error(`spawn ${this.#shell} in ${key} failed`, err);
91
+ control(sock, { t: "error", message: `could not start ${this.#shell}: ${String(err)}` });
92
+ sock.close(1011, "spawn failed");
93
+ return null;
94
+ }
95
+ }
96
+ term.clients.add(sock);
97
+ term.idleSince = Infinity;
98
+ const conn = {
99
+ message: (text) => this.#message(key, sock, text),
100
+ detach: () => {
101
+ const t = this.#terms.get(key);
102
+ if (!t || !t.clients.delete(sock))
103
+ return;
104
+ if (t.clients.size === 0)
105
+ t.idleSince = Date.now();
106
+ },
107
+ };
108
+ try {
109
+ if (term.ringBytes)
110
+ sock.send(Buffer.concat(term.ring));
111
+ }
112
+ catch (err) {
113
+ conn.detach();
114
+ throw err;
115
+ }
116
+ return conn;
117
+ }
118
+ #spawn(cwd) {
119
+ const env = { ...process.env };
120
+ for (const key of PARENT_TERMINAL_ENV)
121
+ delete env[key];
122
+ const pty = spawn(this.#shell, [], {
123
+ name: "xterm-256color",
124
+ cols: 120,
125
+ rows: 30,
126
+ cwd,
127
+ env,
128
+ });
129
+ const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
130
+ this.#terms.set(cwd, term);
131
+ log.info(`shell ${pty.pid} for ${cwd}`);
132
+ pty.onData((data) => {
133
+ const chunk = Buffer.from(data);
134
+ term.ring.push(chunk);
135
+ term.ringBytes += chunk.byteLength;
136
+ while (term.ringBytes > RING_BYTES && term.ring.length > 1) {
137
+ term.ringBytes -= term.ring.shift().byteLength;
138
+ }
139
+ for (const client of term.clients) {
140
+ if (client.bufferedAmount <= HIGH_WATER && send(client, chunk))
141
+ continue;
142
+ term.clients.delete(client);
143
+ client.close(1013, "client too slow");
144
+ log.warn(`detached slow terminal client for ${cwd}`);
145
+ }
146
+ if (term.clients.size === 0 && term.idleSince === Infinity)
147
+ term.idleSince = Date.now();
148
+ });
149
+ pty.onExit(({ exitCode }) => {
150
+ log.info(`shell ${pty.pid} for ${cwd} exited (${exitCode})`);
151
+ this.#terms.delete(cwd);
152
+ for (const c of term.clients) {
153
+ control(c, { t: "exit", code: exitCode });
154
+ c.close(1000, "shell exited");
155
+ }
156
+ term.clients.clear();
157
+ });
158
+ return term;
159
+ }
160
+ /** One inbound frame. Malformed input is logged and dropped, never half-run. */
161
+ #message(cwd, sock, text) {
162
+ const term = this.#terms.get(cwd);
163
+ if (!term || !term.clients.has(sock))
164
+ return;
165
+ let raw;
166
+ try {
167
+ raw = JSON.parse(text);
168
+ }
169
+ catch {
170
+ log.warn(`dropped unparseable frame for ${cwd}`);
171
+ return;
172
+ }
173
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
174
+ log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
175
+ return;
176
+ }
177
+ const msg = raw;
178
+ if (msg.t === "in" && typeof msg.d === "string") {
179
+ term.pty.write(msg.d);
180
+ return;
181
+ }
182
+ if (msg.t === "resize" &&
183
+ typeof msg.cols === "number" && typeof msg.rows === "number" &&
184
+ Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
185
+ msg.cols >= 2 && msg.cols <= 500 && msg.rows >= 2 && msg.rows <= 200) {
186
+ term.pty.resize(msg.cols, msg.rows); // last resize wins, by design
187
+ return;
188
+ }
189
+ log.warn(`dropped malformed frame for ${cwd}: ${text.slice(0, 80)}`);
190
+ }
191
+ /** Reap shells nobody has been attached to for `idleMs`. */
192
+ sweep(now) {
193
+ for (const [cwd, term] of this.#terms) {
194
+ if (now - term.idleSince < this.#idleMs)
195
+ continue;
196
+ log.info(`reaping idle shell ${term.pty.pid} for ${cwd}`);
197
+ term.pty.kill(); // onExit above deletes and notifies
198
+ }
199
+ }
200
+ get size() {
201
+ return this.#terms.size;
202
+ }
203
+ onClose(listener) {
204
+ this.#closeListeners.add(listener);
205
+ }
206
+ /** Shutdown: kill every shell so none outlives the workbench. */
207
+ close() {
208
+ if (this.#closed)
209
+ return;
210
+ this.#closed = true;
211
+ clearInterval(this.#sweeper);
212
+ for (const listener of this.#closeListeners) {
213
+ try {
214
+ listener();
215
+ }
216
+ catch (err) {
217
+ log.error("terminal shutdown cleanup failed", err);
218
+ }
219
+ }
220
+ this.#closeListeners.clear();
221
+ for (const term of this.#terms.values())
222
+ term.pty.kill();
223
+ }
224
+ }
225
+ /** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
226
+ * as every route. SameSite=Lax already withholds the cookie cross-site; the
227
+ * Origin check is the explicit copy of that fact. */
228
+ export function attachTerminal(server, auth, heartbeatMs = HEARTBEAT_MS) {
229
+ const hub = new TerminalHub();
230
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
231
+ const alive = new WeakSet();
232
+ const heartbeat = setInterval(() => {
233
+ for (const client of wss.clients) {
234
+ if (client.readyState !== client.OPEN || !alive.delete(client))
235
+ client.terminate();
236
+ else
237
+ client.ping(); // browsers and proxies both see traffic
238
+ }
239
+ }, heartbeatMs);
240
+ heartbeat.unref();
241
+ hub.onClose(() => {
242
+ clearInterval(heartbeat);
243
+ for (const client of wss.clients)
244
+ client.terminate();
245
+ wss.close();
246
+ });
247
+ server.once("close", () => hub.close());
248
+ wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
249
+ auth.onRotation(() => {
250
+ log.info("password changed; closing terminal clients");
251
+ for (const client of wss.clients)
252
+ client.close(1008, "password changed");
253
+ });
254
+ server.on("upgrade", (req, socket, head) => {
255
+ let url;
256
+ try {
257
+ url = new URL(req.url ?? "/", "http://localhost");
258
+ }
259
+ catch {
260
+ log.warn(`refused malformed terminal upgrade target: ${req.url ?? ""}`);
261
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
262
+ socket.destroy();
263
+ return;
264
+ }
265
+ if (url.pathname !== "/api/terminal") {
266
+ socket.destroy();
267
+ return;
268
+ }
269
+ if (!upgradeAuthorized(auth, req)) {
270
+ log.warn("refused terminal upgrade (unauthorized)");
271
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
272
+ socket.destroy();
273
+ return;
274
+ }
275
+ const authKey = auth.cookieKey;
276
+ wss.handleUpgrade(req, socket, head, (ws) => {
277
+ alive.add(ws);
278
+ ws.on("pong", () => alive.add(ws));
279
+ // Frames can land while attach() is resolving realpath; hold a bounded
280
+ // handful. Close is registered first so an early disconnect cannot leave
281
+ // a phantom client that prevents the one-hour reap.
282
+ let conn = null;
283
+ let closed = false;
284
+ let pendingBytes = 0;
285
+ const pending = [];
286
+ ws.on("close", () => {
287
+ closed = true;
288
+ conn?.detach();
289
+ });
290
+ ws.on("message", (data, binary) => {
291
+ if (closed)
292
+ return;
293
+ if (auth.cookieKey !== authKey) {
294
+ closed = true;
295
+ ws.close(1008, "password changed");
296
+ return;
297
+ }
298
+ if (binary) {
299
+ closed = true;
300
+ ws.close(1003, "text frames only");
301
+ return;
302
+ }
303
+ const text = String(data);
304
+ if (conn)
305
+ conn.message(text);
306
+ else if ((pendingBytes += Buffer.byteLength(text)) <= MAX_FRAME_BYTES)
307
+ pending.push(text);
308
+ else {
309
+ closed = true;
310
+ ws.close(1009, "too much pending input");
311
+ }
312
+ });
313
+ ws.on("error", (err) => log.warn(`terminal socket error: ${String(err)}`));
314
+ void hub.attach(url.searchParams.get("cwd") ?? "", ws).then((c) => {
315
+ if (!c)
316
+ return;
317
+ if (closed) {
318
+ c.detach();
319
+ return;
320
+ }
321
+ conn = c;
322
+ for (const text of pending)
323
+ conn.message(text);
324
+ }).catch((err) => {
325
+ log.error("terminal attach failed", err);
326
+ if (closed)
327
+ return;
328
+ control(ws, { t: "error", message: `terminal attach failed: ${String(err)}` });
329
+ ws.close(1011, "attach failed");
330
+ });
331
+ });
332
+ });
333
+ return hub;
334
+ }