@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.
- package/README.md +9 -6
- package/dist/agent/events.js +6 -1
- package/dist/agent/pi.js +28 -3
- package/dist/channels/chunk.js +34 -0
- package/dist/channels/control.js +6 -12
- package/dist/channels/dedup.js +45 -0
- package/dist/channels/lark-api.js +233 -0
- package/dist/channels/lark-outbound.js +101 -0
- package/dist/channels/lark-panel.js +95 -0
- package/dist/channels/lark-render.js +107 -0
- package/dist/channels/lark.js +501 -0
- package/dist/channels/lines.js +19 -0
- package/dist/channels/panel.js +4 -0
- package/dist/channels/receipts.js +15 -0
- package/dist/channels/routes.js +0 -1
- package/dist/channels/runtime.js +5 -1
- package/dist/channels/slack-api.js +4 -2
- package/dist/channels/slack-panel.js +3 -6
- package/dist/channels/slack-render.js +3 -23
- package/dist/channels/slack.js +39 -72
- package/dist/channels/telegram-api.js +4 -2
- package/dist/channels/telegram-panel.js +3 -3
- package/dist/channels/telegram.js +55 -51
- package/dist/channels/types.js +9 -0
- package/dist/cli.js +3 -1
- package/dist/core/inbox.js +67 -1
- package/dist/core/types.js +4 -0
- package/dist/db.js +83 -20
- package/dist/main.js +5 -0
- package/dist/service.js +1 -1
- package/dist/web/auth.js +36 -9
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/ghostty-web-ODXT71Ln.js +13 -0
- package/dist/web/public/assets/index-BUNGxtMe.css +2 -0
- package/dist/web/public/assets/index-QPYgeBhQ.js +90 -0
- package/dist/web/public/index.html +10 -2
- package/dist/web/server.js +86 -19
- package/dist/web/session-state.js +59 -25
- package/dist/web/terminal.js +334 -0
- package/docs/deploy.md +33 -21
- package/package.json +10 -2
- package/dist/web/public/assets/index-B3MvJUJP.js +0 -90
- 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-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
package/dist/web/server.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
95
|
-
state.
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
31
|
-
const row = this.#db.prepare(
|
|
32
|
-
return row?.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|