@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 +28 -4
- package/package.json +5 -1
- package/src/agent-stream/index.ts +13 -1
- package/src/index.ts +1 -0
- package/src/terminal/dtach.ts +302 -0
- package/src/terminal/index.ts +22 -0
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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"
|