@spunto/build 0.4.0 → 0.5.0

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 CHANGED
@@ -148,6 +148,29 @@ time a vendor reshuffles a field.
148
148
  Here for the reason that inverts the rest of the package: **nothing has forked this yet.** Putting
149
149
  it in the shared package now costs nothing and means the second product never writes its own.
150
150
 
151
+ ### `@spunto/build/terminal` — the half of a persistent terminal that is a string
152
+
153
+ A worker's terminal is an exec attached to a session that outlives it, so closing a browser tab
154
+ doesn't kill a build. The attaching is I/O and stays with whoever owns the Docker socket; the shell
155
+ that gets attached, the layout it writes into, and the parsing of what it prints back are here.
156
+
157
+ ```ts
158
+ import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"
159
+
160
+ const name = sanitizeSessionName(fromQuery) // every name MUST come through this
161
+ await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })
162
+ ```
163
+
164
+ **dtach, not tmux.** tmux's only knob for "the wheel scrolls history" is `mouse on`, which also
165
+ makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection
166
+ back. dtach does only persistence, so the emulator keeps the mouse, its scrollback and its search.
167
+ What dtach doesn't do is remember the screen, so the session is recorded inside the container with
168
+ `script(1)` and the tail replayed on attach.
169
+
170
+ `sanitizeSessionName` is a security boundary, not tidiness: names are interpolated into the shell
171
+ scripts this module builds, so anything that doesn't come through it is a command injection into
172
+ someone's container.
173
+
151
174
  ## The rule
152
175
 
153
176
  A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
@@ -163,10 +186,11 @@ installed on its behalf. Import `./spec` without zod and resolution fails loudly
163
186
  right trade for not taxing every other entry point.
164
187
 
165
188
  Concretely: **no platform I/O**. No database, no Docker socket, no WebSocket. This package produces
166
- strings and parses strings; its only network call is outbound HTTP to a public extension registry,
167
- with no platform credential attached. Configuration arrives as function parameters — never read from
168
- the environment — so that one control plane can scope a setting per organization and another per
169
- process without either shape leaking in here.
189
+ strings and parses strings. Its only network calls are outbound HTTP on the caller's behalf — a
190
+ public extension registry, and a model vendor's catalogue endpoint when you hand
191
+ `resolveContextWindow` a credential — and it holds no credential of its own. Configuration arrives
192
+ as function parameters, never read from the environment, so that one control plane can scope a
193
+ setting per organization and another per process without either shape leaking in here.
170
194
 
171
195
  That rule is what makes the package testable, safe to import from a node agent as well as from an
172
196
  API, and the reason it can't simply be folded into the design system: a script generator needs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Spunto's shared Build engine \u2014 the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,10 @@
55
55
  "types": "./src/script/index.ts",
56
56
  "import": "./src/script/index.ts"
57
57
  },
58
+ "./terminal": {
59
+ "types": "./src/terminal/index.ts",
60
+ "import": "./src/terminal/index.ts"
61
+ },
58
62
  "./agent-stream": {
59
63
  "types": "./src/agent-stream/index.ts",
60
64
  "import": "./src/agent-stream/index.ts"
@@ -39,4 +39,16 @@ export type {
39
39
  UsageInput,
40
40
  } from "./agent-stream"
41
41
 
42
- export { contextWindowOf } from "./model-window"
42
+ // Model context windows. `liveContextWindow`/`resolveContextWindow` take a credential as a
43
+ // *parameter* and ask the model vendor's own API — the package still reads no environment and
44
+ // holds no key of its own, but it is the one place here that talks to something other than a
45
+ // public registry, and it does so on the caller's behalf. `resetModelWindowCache` exists for
46
+ // tests: the lookup memoises per process.
47
+ export {
48
+ apiModelId,
49
+ contextWindowOf,
50
+ liveContextWindow,
51
+ resetModelWindowCache,
52
+ resolveContextWindow,
53
+ } from "./model-window"
54
+ export type { ModelCredential } from "./model-window"
package/src/index.ts CHANGED
@@ -12,4 +12,5 @@ export * from "./spec/index"
12
12
  export * from "./types/index"
13
13
  export * from "./catalogs/index"
14
14
  export * from "./script/index"
15
+ export * from "./terminal/index"
15
16
  export * from "./agent-stream/index"
@@ -0,0 +1,302 @@
1
+ // The half of a persistent terminal that is a string, not a socket.
2
+ //
3
+ // A worker's terminal is a `docker exec` attached to a session that outlives it — so that closing a
4
+ // browser tab does not kill a build. The *attaching* is I/O and stays with whoever owns the Docker
5
+ // socket; everything here is the shell that gets attached, the layout it writes into, and the
6
+ // parsing of what it prints back.
7
+ //
8
+ // **dtach, not tmux** (RFC 0019). tmux is a full multiplexer, and its only knob for "the wheel
9
+ // scrolls history" is `mouse on` — which also makes tmux swallow every drag, so the frontend has to
10
+ // forge Shift-drags to get a native selection back. dtach does only persistence: it owns a pty,
11
+ // lets clients attach and detach, and forwards every byte, mouse included. The terminal emulator
12
+ // keeps the mouse, its own scrollback and its own search.
13
+ //
14
+ // What dtach does not do is remember what was on screen. `-r winch` makes the running program
15
+ // redraw on attach, but the history is gone. So the session is recorded inside the container with
16
+ // script(1) and the tail of that recording is replayed just before attaching:
17
+ //
18
+ // docker exec ──> sh ──> [replay: tail of the log] ──> exec dtach -A ──> script -f log ──> $SHELL
19
+ // │
20
+ // persistent master
21
+ //
22
+ // Recording inside the container rather than in an agent-side ring buffer is what makes the buffer
23
+ // survive a control-plane restart with nothing permanently attached.
24
+
25
+ /**
26
+ * Where a session's socket and recording live. One directory, two files per session.
27
+ *
28
+ * The name deliberately avoids the substring "dtach": the listing and kill scripts match processes
29
+ * by the socket path, and a path containing "dtach" would make every helper's own shell look like a
30
+ * dtach process to its own grep.
31
+ */
32
+ export const TERMINAL_DIR = "/tmp/spunto-term"
33
+
34
+ /** Bytes of recording replayed on attach. ~64 lines of 120 cols is 8 KB; 256 KB is a few screens of a chatty build. */
35
+ export const REPLAY_BYTES = 262144
36
+
37
+ /**
38
+ * Below this there is nothing worth replaying: a session created from a "+" button has already
39
+ * recorded its prompt, and replaying that would greet the user with a "replay" banner on a terminal
40
+ * they just opened. The attach redraw paints the prompt anyway.
41
+ */
42
+ export const REPLAY_MIN_BYTES = 512
43
+
44
+ /** Above this, the recording is trimmed to `TRIM_TO` bytes on the next attach. */
45
+ export const MAX_LOG_BYTES = 4194304
46
+ export const TRIM_TO = 1048576
47
+
48
+ /**
49
+ * Session name a control plane may safely interpolate into the shell scripts below.
50
+ *
51
+ * Restricted to `[A-Za-z0-9_-]` — which is both what a socket filename can hold without quoting and
52
+ * what the listing script's `awk` can match on. **Every name reaching the builders below must come
53
+ * through here**: they interpolate it into shell, so anything else is an injection.
54
+ */
55
+ export function sanitizeSessionName(name: string | undefined): string {
56
+ const cleaned = (name ?? "main").replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)
57
+ return cleaned || "main"
58
+ }
59
+
60
+ export function buildDtachCommand(): string {
61
+ return [
62
+ `D=${TERMINAL_DIR}`,
63
+ 'N="$MP_TERM_SESSION"',
64
+ 'SOCK="$D/$N.sock"',
65
+ 'LOG="$D/$N.log"',
66
+ 'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
67
+ '_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
68
+ // No dtach → say so plainly and degrade to a non-persistent shell.
69
+ "if ! command -v dtach >/dev/null 2>&1; then",
70
+ ` printf '\\033[33m[spunto] dtach absent de ce conteneur — shell simple, sans persistance.\\033[0m\\r\\n'`,
71
+ ' exec "$_SH" -l',
72
+ "fi",
73
+ // A session is live only if some process still holds its socket: a leftover
74
+ // socket file (container restarted, program exited) means the recorded log
75
+ // belongs to a session that no longer exists, so it is dropped rather than
76
+ // replayed into a brand new shell.
77
+ 'LIVE=0',
78
+ 'if [ -S "$SOCK" ] && ps -eo args= 2>/dev/null | grep -qF "$SOCK"; then LIVE=1; fi',
79
+ 'if [ "$LIVE" = 0 ]; then rm -f "$SOCK" 2>/dev/null; : > "$LOG" 2>/dev/null; fi',
80
+ // ── replay buffer ────────────────────────────────────────────────────────
81
+ // Trim first (the log is append-only and would otherwise grow forever), then
82
+ // dump its tail. The dump is preceded by a reset because the tail can start
83
+ // mid-escape-sequence or while the alternate screen / mouse tracking was on;
84
+ // `tail -n +2` drops the (possibly truncated) first line.
85
+ 'if [ "$LIVE" = 1 ] && [ -f "$LOG" ]; then',
86
+ ' SZ=$(wc -c < "$LOG" 2>/dev/null || echo 0)',
87
+ ` if [ "$SZ" -gt ${MAX_LOG_BYTES} ] 2>/dev/null; then`,
88
+ ` tail -c ${TRIM_TO} "$LOG" > "$LOG.trim" 2>/dev/null && cat "$LOG.trim" > "$LOG" 2>/dev/null; rm -f "$LOG.trim"`,
89
+ " fi",
90
+ ` if [ "$SZ" -gt ${REPLAY_MIN_BYTES} ] 2>/dev/null; then`,
91
+ ` printf '\\033[?1049l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033c'`,
92
+ ` printf '\\033[2m── replay ──\\033[0m\\r\\n'`,
93
+ ` tail -c ${REPLAY_BYTES} "$LOG" 2>/dev/null | tail -n +2`,
94
+ " fi",
95
+ "fi",
96
+ // ── attach ───────────────────────────────────────────────────────────────
97
+ // script(1) records the session's raw output for the replay above. Without it
98
+ // the session still works, it just comes back blank.
99
+ "if command -v script >/dev/null 2>&1; then",
100
+ ' exec dtach -A "$SOCK" -r winch -E -z script -q -a -f "$LOG" -c "$_SH -l"',
101
+ "else",
102
+ ' exec dtach -A "$SOCK" -r winch -E -z "$_SH" -l',
103
+ "fi",
104
+ ].join("\n")
105
+ }
106
+
107
+ // A dtach session is a socket file, nothing more — no server to ask. Name and
108
+ // creation date come from the socket itself; the foreground command has to be
109
+ // recovered from the container's process table (this is the metadata tmux gives
110
+ // away for free, see the RFC): walk the process tree down from the dtach master
111
+ // and keep the deepest descendant that sits in its pty's foreground process
112
+ // group (the `+` in ps's STAT), which is exactly what `pane_current_command` means.
113
+ export const LIST_SESSIONS_SCRIPT = [
114
+ `D=${TERMINAL_DIR}`,
115
+ '[ -d "$D" ] || exit 0',
116
+ 'PSOUT=$(ps -eo pid=,ppid=,stat=,args= 2>/dev/null || true)',
117
+ 'ESC=$(printf "\\033"); BEL=$(printf "\\007")',
118
+ 'for s in "$D"/*.sock; do',
119
+ ' [ -S "$s" ] || continue',
120
+ ' n=$(basename "$s" .sock)',
121
+ ' created=$(stat -c %Y "$s" 2>/dev/null || echo 0)',
122
+ ' cmd=$(printf "%s\\n" "$PSOUT" | awk -v sock="$s" \'',
123
+ " {",
124
+ " pid=$1; ppid=$2; st=$3;",
125
+ ' args=""; for (i=4; i<=NF; i++) args = args (i>4 ? " " : "") $i;',
126
+ " n++; order[n]=pid; P[pid]=ppid; S[pid]=st; A[pid]=args;",
127
+ ' if (index(args, sock) > 0) d[pid]=0;',
128
+ " }",
129
+ " END {",
130
+ " for (iter=0; iter<12; iter++)",
131
+ " for (i=1; i<=n; i++) { p=order[i]; if (!(p in d) && (P[p] in d)) d[p]=d[P[p]]+1 }",
132
+ ' best=""; bestd=0;',
133
+ " for (i=1; i<=n; i++) {",
134
+ " p=order[i];",
135
+ " if (!(p in d) || d[p]==0) continue;",
136
+ ' if (S[p] !~ /\\+/) continue;',
137
+ ' c=A[p]; sub(/ .*/, "", c); sub(/.*\\//, "", c); sub(/^-/, "", c);',
138
+ ' if (c=="script" || c=="dtach") continue;',
139
+ " if (d[p] >= bestd) { bestd=d[p]; best=c }",
140
+ " }",
141
+ " print best",
142
+ " }')",
143
+ // Reported title (OSC 0/2, see terminal-title.ts). tmux keeps it in
144
+ // `pane_title`; dtach forwards every byte to the terminal and remembers
145
+ // nothing, so it is dug out of the replay log: the last `ESC ] 0|2 ; … BEL`
146
+ // written is the title currently on screen. Best effort — a container whose
147
+ // grep lacks `-a` simply yields no title and the UI falls back to the name.
148
+ ' t=$(tail -c 65536 "$D/$n.log" 2>/dev/null | LC_ALL=C grep -ao "$ESC][02];[^$BEL$ESC]*" 2>/dev/null | tail -n 1 | LC_ALL=C cut -c5-)',
149
+ ' echo "$n|1|0|$created|$cmd|$t"',
150
+ "done",
151
+ ].join("\n")
152
+
153
+ /** Longest title kept — anything past that is a program dumping data, not naming itself. */
154
+ const MAX_TITLE_LENGTH = 120
155
+
156
+ /**
157
+ * Normalize a reported title, or return "" when there is nothing worth showing.
158
+ *
159
+ * `placeholders` are the values that mean "no one reported anything": tmux seeds
160
+ * `pane_title` with the container hostname, so a title still equal to it says
161
+ * only that the session never set one.
162
+ */
163
+ export function sanitizeReportedTitle(raw: string | undefined, placeholders: string[] = []): string {
164
+ const title = (raw ?? "")
165
+ // Control characters (a stray BEL/ESC from a truncated read, a newline) would
166
+ // land as-is in a JSON payload and then in a React tree.
167
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
168
+ .replace(/\s+/g, " ")
169
+ .trim()
170
+ .slice(0, MAX_TITLE_LENGTH)
171
+ if (!title) return ""
172
+ if (placeholders.some((p) => p && p.trim() === title)) return ""
173
+ return title
174
+ }
175
+
176
+ /**
177
+ * dtach clears the screen as soon as a client attaches — a bare `ESC[H ESC[J` ("Clear the screen.
178
+ * This assumes VT100." in dtach's own source). That write lands *after* the replay the attach
179
+ * script just printed, and wipes exactly what the replay exists to show.
180
+ *
181
+ * So it is dropped: once, and only in the first seconds of an attach — long enough to cover
182
+ * dtach's write, short enough that a program legitimately clearing its screen later is never
183
+ * touched. (`clear` emits `ESC[H ESC[2J ESC[3J`, different bytes, so it would not match anyway.)
184
+ *
185
+ * `Uint8Array` in and out rather than Node's Buffer: a Buffer is one, so a Node caller passes its
186
+ * chunks straight through, and the package stays runnable anywhere.
187
+ */
188
+ export function makeAttachClearStripper(
189
+ windowMs = 5000,
190
+ now: () => number = Date.now,
191
+ ): (chunk: Uint8Array) => Uint8Array {
192
+ const CLEAR = new TextEncoder().encode("\x1b[H\x1b[J")
193
+ const deadline = now() + windowMs
194
+ let done = false
195
+ return (chunk) => {
196
+ if (done) return chunk
197
+ if (now() > deadline) {
198
+ done = true
199
+ return chunk
200
+ }
201
+ const i = indexOfBytes(chunk, CLEAR)
202
+ if (i === -1) return chunk
203
+ done = true
204
+ const out = new Uint8Array(chunk.length - CLEAR.length)
205
+ out.set(chunk.subarray(0, i), 0)
206
+ out.set(chunk.subarray(i + CLEAR.length), i)
207
+ return out
208
+ }
209
+ }
210
+
211
+ /** `Buffer.indexOf` for a plain Uint8Array — the needle is 6 bytes, so the naive scan is the right one. */
212
+ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array): number {
213
+ outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
214
+ for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer
215
+ return i
216
+ }
217
+ return -1
218
+ }
219
+
220
+ /** One persistent session of a worker, as a control plane reports it. */
221
+ export type TerminalSession = {
222
+ name: string
223
+ /** Always 1 — dtach has no windows. Kept so a UI written against a multiplexer still renders. */
224
+ windows: number
225
+ /**
226
+ * Decided by the caller, not by this parse: a socket says nothing about who is connected, and the
227
+ * process that attaches is the one that knows.
228
+ */
229
+ attached: boolean
230
+ createdAt: number
231
+ command: string
232
+ /** Title the running program reported over OSC 0/2 ("" when it reported none). */
233
+ title: string
234
+ }
235
+
236
+ /**
237
+ * Parse what `LIST_SESSIONS_SCRIPT` printed into sessions.
238
+ *
239
+ * Separate from running it: executing a command inside a container is the caller's business, and
240
+ * keeping the parse here is what lets it be tested without a container.
241
+ */
242
+ export function parseSessionList(output: string, isAttached: (session: string) => boolean): TerminalSession[] {
243
+ return output
244
+ .trim()
245
+ .split("\n")
246
+ .filter(Boolean)
247
+ .map((line) => {
248
+ // The title is free-form and last, so it keeps any "|" it contains.
249
+ const parts = line.split("|")
250
+ const [name = "", , , created = "0", command = ""] = parts
251
+ return {
252
+ name: name.trim(),
253
+ windows: 1,
254
+ attached: isAttached(name.trim()),
255
+ createdAt: (parseInt(created) || 0) * 1000,
256
+ command: command.trim(),
257
+ title: sanitizeReportedTitle(parts.slice(5).join("|")),
258
+ }
259
+ })
260
+ .filter((s) => s.name)
261
+ }
262
+
263
+ /**
264
+ * Shell that creates a session without attaching to it — `dtach -n` daemonizes the master.
265
+ *
266
+ * `session` must already have been through `sanitizeSessionName`: it is interpolated into the
267
+ * script.
268
+ */
269
+ export function buildCreateSessionCommand(session: string): string {
270
+ return [
271
+ `D=${TERMINAL_DIR}`,
272
+ `N="${session}"`,
273
+ 'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
274
+ "command -v dtach >/dev/null 2>&1 || exit 0",
275
+ '[ -S "$D/$N.sock" ] && exit 0',
276
+ '_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
277
+ // stdio redirected: the daemonized master inherits the exec's fds and would otherwise hold its
278
+ // stdout open long after the session exists.
279
+ "if command -v script >/dev/null 2>&1; then",
280
+ ' dtach -n "$D/$N.sock" -r winch -E -z script -q -a -f "$D/$N.log" -c "$_SH -l" </dev/null >/dev/null 2>&1',
281
+ "else",
282
+ ' dtach -n "$D/$N.sock" -r winch -E -z "$_SH" -l </dev/null >/dev/null 2>&1',
283
+ "fi",
284
+ ].join("\n")
285
+ }
286
+
287
+ /**
288
+ * Shell that kills a session: SIGTERM every process holding its socket (master and any client),
289
+ * then drop the socket and the recording.
290
+ *
291
+ * `session` must already have been through `sanitizeSessionName`.
292
+ */
293
+ export function buildKillSessionCommand(session: string): string {
294
+ return [
295
+ `SOCK=${TERMINAL_DIR}/${session}.sock`,
296
+ 'for p in $(ps -eo pid=,args= 2>/dev/null | awk -v sock="$SOCK" -v me=$$ \'$1 != me && index($0, sock) > 0 && index($0, "dtach") > 0 { print $1 }\'); do',
297
+ ' kill "$p" 2>/dev/null',
298
+ "done",
299
+ `rm -f "$SOCK" ${TERMINAL_DIR}/${session}.log 2>/dev/null`,
300
+ "true",
301
+ ].join("\n")
302
+ }
@@ -0,0 +1,22 @@
1
+ // The half of a persistent terminal that is a string, not a socket: the shell that gets attached,
2
+ // the layout it writes into, and the parsing of what it prints back. Attaching is I/O and stays
3
+ // with whoever owns the Docker socket.
4
+ //
5
+ // See `./dtach` for why dtach and not tmux (RFC 0019).
6
+
7
+ export {
8
+ buildCreateSessionCommand,
9
+ buildDtachCommand,
10
+ buildKillSessionCommand,
11
+ LIST_SESSIONS_SCRIPT,
12
+ makeAttachClearStripper,
13
+ MAX_LOG_BYTES,
14
+ parseSessionList,
15
+ REPLAY_BYTES,
16
+ REPLAY_MIN_BYTES,
17
+ sanitizeReportedTitle,
18
+ sanitizeSessionName,
19
+ TERMINAL_DIR,
20
+ TRIM_TO,
21
+ } from "./dtach"
22
+ export type { TerminalSession } from "./dtach"