@vecteur/cli 0.7.1 → 0.9.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/src/store.js ADDED
@@ -0,0 +1,82 @@
1
+ import { chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync,
2
+ renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { CliError } from "./errors.js";
6
+
7
+ const held = new Set();
8
+ const pause = new Int32Array(new SharedArrayBuffer(4));
9
+ const MAX_STORE_BYTES = 16 * 1024 * 1024;
10
+
11
+ function privateDirectory(file) {
12
+ const directory = dirname(file);
13
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
14
+ const info = lstatSync(directory);
15
+ if (!info.isDirectory() || info.isSymbolicLink()
16
+ || (process.getuid && info.uid !== process.getuid())) {
17
+ throw new CliError("store_permissions", `${directory} must be a private directory owned by you`);
18
+ }
19
+ if (process.platform !== "win32" && (info.mode & 0o077)) chmodSync(directory, 0o700);
20
+ }
21
+
22
+ /** Locks cover only synchronous read-modify-write, never network or user input. */
23
+ export function withStoreLock(file, action) {
24
+ if (held.has(file)) return action();
25
+ privateDirectory(file);
26
+ const lock = `${file}.lock`;
27
+ const deadline = Date.now() + 3_000;
28
+ let fd;
29
+ while (fd === undefined) {
30
+ try { fd = openSync(lock, "wx", 0o600); }
31
+ catch (error) {
32
+ if (error.code !== "EEXIST") throw error;
33
+ if (Date.now() >= deadline) throw new CliError("store_locked",
34
+ `${file} is locked; close the other Vecteur writer. If it crashed, inspect ${lock} before removing the lock.`);
35
+ Atomics.wait(pause, 0, 0, 20);
36
+ }
37
+ }
38
+ try {
39
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: Date.now() }));
40
+ fsyncSync(fd);
41
+ held.add(file);
42
+ return action();
43
+ } finally {
44
+ held.delete(file);
45
+ closeSync(fd);
46
+ unlinkSync(lock);
47
+ }
48
+ }
49
+
50
+ /** Rename publishes complete bytes; a crash leaves the previous complete version readable. */
51
+ export function writeStore(file, value) {
52
+ const bytes = `${JSON.stringify(value, null, 2)}\n`;
53
+ if (Buffer.byteLength(bytes) > MAX_STORE_BYTES) throw new CliError("store_full", "Local store exceeds 16 MiB; existing bytes were preserved");
54
+ privateDirectory(file);
55
+ const temporary = `${file}.${randomUUID()}.tmp`;
56
+ const fd = openSync(temporary, "wx", 0o600);
57
+ try { writeFileSync(fd, bytes); fsyncSync(fd); }
58
+ catch (error) { unlinkSync(temporary); throw error; }
59
+ finally { closeSync(fd); }
60
+ try { renameSync(temporary, file); }
61
+ finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } }
62
+ if (process.platform !== "win32") {
63
+ const directory = openSync(dirname(file), "r");
64
+ try { fsyncSync(directory); } finally { closeSync(directory); }
65
+ }
66
+ return file;
67
+ }
68
+
69
+ export function readStore(file) {
70
+ let fd;
71
+ try {
72
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
73
+ const info = fstatSync(fd);
74
+ if (!info.isFile() || info.size > MAX_STORE_BYTES) throw new Error("Not a bounded regular file");
75
+ const value = JSON.parse(readFileSync(fd, "utf8"));
76
+ if (value === null) throw new Error("Store is null");
77
+ return value;
78
+ } catch (error) {
79
+ if (error.code === "ENOENT") return null;
80
+ throw new CliError("store_unreadable", `${file} is not a readable regular JSON store; existing bytes were preserved`);
81
+ } finally { if (fd !== undefined) closeSync(fd); }
82
+ }
@@ -0,0 +1,335 @@
1
+ import { ComposerInput } from "./input.js";
2
+ import { command, completions, SHORTCUTS } from "./commands.js";
3
+ import { Session } from "./session.js";
4
+ import { terminalText as safe, taskLines } from "./format.js";
5
+ import { preferences } from "./preferences.js";
6
+ import { openBrowser } from "./browser.js";
7
+ import { readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+
10
+ /** The only stdout writer. It never clears the screen/scrollback or enters alternate screen. */
11
+ export class TerminalWriter {
12
+ constructor(stdout, plain) { this.stdout = stdout; this.plain = plain; this.height = 0; }
13
+ erase() {
14
+ if (!this.height) return "";
15
+ const move = `\x1b[${this.height}A\r`, bytes = move + "\x1b[2K\n".repeat(this.height) + move;
16
+ this.height = 0;
17
+ return bytes;
18
+ }
19
+ clearLive() { const bytes = this.erase(); if (bytes) this.stdout.write(bytes); }
20
+ progress(view) {
21
+ const subject = view.run?.subject?.id;
22
+ if (subject !== this.subject) { this.subject = subject; this.lastProgress = null; this.lastQuestion = null; }
23
+ if (!subject || view.run.terminal) return;
24
+ if (view.run.progress && view.run.progress !== this.lastProgress) {
25
+ this.append(safe(`${view.run.progress} · ${view.run.elapsed ?? "Elapsed unavailable"}`)); this.lastProgress = view.run.progress;
26
+ }
27
+ const question = view.run.question;
28
+ if (question?.id !== this.lastQuestion) {
29
+ this.lastQuestion = question?.id;
30
+ if (question) this.append(safe(`Vecteur needs your answer — /answer\n${question.questions.map(row => row.question).join("\n")}`));
31
+ }
32
+ }
33
+ append(text) { this.stdout.write(`${this.erase()}${text.replace(/\n+$/, "")}\n`); }
34
+ draw(text) {
35
+ if (this.plain) return;
36
+ // One write per frame: erase and redraw land together, so the spinner never flickers.
37
+ const lines = text.replace(/\n+$/, "");
38
+ this.stdout.write(`${this.erase()}${lines}\n`); this.height = lines.split("\n").length;
39
+ }
40
+ resized(plain) {
41
+ // Reflow can collapse old wrapped lines: their old height is no longer a safe erase bound.
42
+ this.height = 0; this.plain = plain; this.stdout.write("\n");
43
+ }
44
+ }
45
+
46
+ export async function startTerminal(options = {}, { stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
47
+ let prefs;
48
+ try { prefs = preferences(env); } catch (error) {
49
+ if (!options.plain) throw error;
50
+ prefs = { plain: true, reducedMotion: true };
51
+ }
52
+ let rich = !options.plain && !prefs.plain && env.TERM !== "dumb";
53
+ const color = !options.noColor && env.NO_COLOR === undefined;
54
+ const layout = await import("./tui/view.js");
55
+ const isPlain = () => !rich || ((stdout.columns ?? 80) < 40 || (stdout.rows ?? 24) < 8);
56
+ const writer = new TerminalWriter(stdout, isPlain());
57
+ const session = new Session({ ...options, env });
58
+ const input = new ComposerInput();
59
+ session.terminalFlags = [...(options.plain ? ["--plain"] : []), ...(options.noColor ? ["--no-color"] : [])];
60
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
61
+ const { version } = pkg, channel = pkg.vecteur?.channel;
62
+ let suppressed = false, activePrompt = null, selected = 0, paletteSelected = 0, paletteDismissed = false;
63
+ let lastPlainStatus = "", lastPlainIdentity = "", plainDraft = "", frameTimer, clockTimer, cancelAt = 0, cancelRun, exitAt = 0;
64
+ let snapshot = null, localPicker = null, loginAction = 0, updateChecked = false, tick = 0;
65
+ // While a vendor owns the TTY (handoff) Vecteur writes nothing: frames, clocks, streamed
66
+ // steps and messages wait, and are flushed once when the vendor exits.
67
+ let vendorOwnsTerminal = false, resizedDuringHandoff = false; const heldMessages = [];
68
+ // Rich scrollback streams each finished step as one line; the answer block then skips them.
69
+ const streamed = { run: null, events: 0, steps: 0 };
70
+ const history = []; let historyIndex = 0, historyDraft = "", historyAccount;
71
+ const syncInput = (value, cursor = value === input.text ? input.cursor : value.length) => { suppressed = true; input.set(value); input.cursor = cursor; suppressed = false; };
72
+ const focusChanged = () => {
73
+ input.focusEpoch++; cancelAt = 0;
74
+ // Number/Enter typeahead from a dismissed numbered prompt is never a new ask.
75
+ if (/^[\d,\s]*$/.test(input.pending)) input.pending = "";
76
+ };
77
+ const palette = () => !paletteDismissed && !session.prompt && !session.loginChallenge && !localPicker
78
+ && /^\/[^\s/]*$/.test(input.text) ? completions(input.text, session) : [];
79
+ const choices = () => {
80
+ const prompt = session.prompt ?? localPicker;
81
+ if (!prompt || prompt.typed) return [];
82
+ if (!input.text || /^[\d, ]+$/.test(input.text)) return prompt.options;
83
+ return prompt.options.filter(item => item.label.toLowerCase().includes(input.text.toLowerCase()));
84
+ };
85
+ const frameView = () => ({ ...session.view(), version, channel,
86
+ focus: localPicker ?? session.prompt, choices: choices(), selected,
87
+ palette: palette(), paletteSelected, loginAction });
88
+ const draw = () => {
89
+ clearTimeout(frameTimer); frameTimer = null;
90
+ if (vendorOwnsTerminal) return;
91
+ if (!session.closed && !writer.plain) writer.draw(layout.richFrame(frameView(), input.text, input.cursor,
92
+ { columns: stdout.columns ?? 80, rows: stdout.rows ?? 24, color, tick: prefs.reducedMotion ? null : tick }));
93
+ };
94
+ const schedule = () => { if (!frameTimer && !vendorOwnsTerminal) frameTimer = setTimeout(() => { frameTimer = null; draw(); }, 40); };
95
+ const printPlainPrompt = prompt => writer.append(safe(`${prompt.title}\n${prompt.options.map((row, i) => `${i + 1}. ${row.label}`).join("\n")}\nEnter a choice; Esc returns to your draft.`));
96
+ const maybeUpdate = () => {
97
+ if (updateChecked || session.closed || session.offline || session.busy || session.prompt || session.loginChallenge || session.run?.open || session.registry?.state().pendingRun || session.registry?.state().pendingCreate || !session.release) return;
98
+ updateChecked = true;
99
+ void import("./update.js").then(({ interactiveUpdate }) => interactiveUpdate(session, { automatic: true })).catch(error => session.report(error));
100
+ };
101
+ session.on("change", view => {
102
+ if (vendorOwnsTerminal) return;
103
+ const ticking = !writer.plain && !view.offline && session.run?.open;
104
+ if (ticking && !clockTimer) clockTimer = setInterval(() => { tick++; draw(); }, prefs.reducedMotion ? 1000 : 200);
105
+ if (!ticking) { clearInterval(clockTimer); clockTimer = null; }
106
+ const run = session.run;
107
+ if (!writer.plain && run?.open && Array.isArray(run.events)) {
108
+ if (streamed.run !== run.run_id) Object.assign(streamed, { run: run.run_id, events: 0, steps: 0 });
109
+ for (; streamed.events < run.events.length; streamed.events++) {
110
+ const event = run.events[streamed.events];
111
+ if (event?.kind !== "step_finished") continue;
112
+ const [row] = taskLines([event]);
113
+ if (!row) continue;
114
+ if (!streamed.steps++) writer.append("");
115
+ writer.append(layout.richMessage({ kind: "step", text: row.split("\n").map(part => part.trim().replace(/^[▸·]\s*/, "")).filter(Boolean).join(" · ") }, stdout.columns ?? 80, color));
116
+ }
117
+ }
118
+ if (process.connected && process.send && env.VECTEUR_UPDATE_SESSION && session.release?.compatible
119
+ && session.registry && (!view.busy || view.prompt)) {
120
+ const nonce = env.VECTEUR_UPDATE_SESSION; delete env.VECTEUR_UPDATE_SESSION;
121
+ process.send({ type: "vecteur-ready", nonce, version, contract: session.release.contract, origin: session.origin }, () => {});
122
+ }
123
+ const accountKey = `${view.account?.id ?? "unsigned"}:${view.project?.id ?? "unbound"}`;
124
+ if (historyAccount !== accountKey) {
125
+ if (historyAccount !== undefined && !view.prompt) { localPicker = null; focusChanged(); syncInput(view.draft); }
126
+ history.length = 0; historyIndex = 0; historyDraft = ""; historyAccount = accountKey; input.undo = []; input.killed = ""; snapshot = null; }
127
+ if (writer.plain) {
128
+ writer.progress(view);
129
+ const identity = `${view.account?.email ?? "Not signed in"} · ${view.projectLabel}\n${view.folder}\n${view.lead} · ${view.location} · ${view.model}`;
130
+ if (identity !== lastPlainIdentity) { writer.append(safe(identity)); lastPlainIdentity = identity; }
131
+ }
132
+ if (view.prompt !== activePrompt) {
133
+ if (view.prompt && !activePrompt) snapshot = { text: input.text.startsWith("/") ? session.draft : input.text, cursor: input.cursor, undo: input.undo, killed: input.killed };
134
+ activePrompt = view.prompt; selected = 0; focusChanged();
135
+ if (activePrompt) {
136
+ input.undo = []; input.killed = ""; syncInput(""); if (writer.plain) printPlainPrompt(activePrompt);
137
+ } else {
138
+ const restored = snapshot && snapshot.text === session.draft ? snapshot : { text: session.draft, cursor: session.draft.length };
139
+ syncInput(restored.text, Math.min(restored.cursor, restored.text.length)); input.undo = restored.undo ?? []; input.killed = restored.killed ?? ""; snapshot = null;
140
+ }
141
+ }
142
+ if (writer.plain && view.status !== lastPlainStatus) {
143
+ writer.append(safe(view.status)); lastPlainStatus = view.status; plainDraft = "";
144
+ }
145
+ schedule(); queueMicrotask(maybeUpdate);
146
+ });
147
+ session.on("preferences", value => {
148
+ prefs = value; rich = !options.plain && !prefs.plain && env.TERM !== "dumb";
149
+ writer.resized(isPlain()); session.change(); draw();
150
+ });
151
+ session.on("message", message => {
152
+ if (vendorOwnsTerminal) { heldMessages.push(message); return; }
153
+ writer.append(writer.plain ? safe(message.text) : layout.richMessage(message, stdout.columns ?? 80, color,
154
+ { stepsShown: message.runId && message.runId === streamed.run ? streamed.steps : 0 }));
155
+ plainDraft = ""; draw();
156
+ });
157
+ session.on("copy", answer => {
158
+ if (vendorOwnsTerminal) return;
159
+ if (writer.plain || env.TERM === "dumb") { session.message("Use /copy <file path> to save the exact answer bytes."); return; }
160
+ writer.clearLive(); stdout.write(`\x1b]52;c;${Buffer.from(answer).toString("base64")}\x07`);
161
+ session.message("Copy requested through the terminal clipboard; use /copy <path> if it is disabled.");
162
+ });
163
+ input.on("change", value => {
164
+ if (suppressed) return;
165
+ if (!session.prompt && !localPicker && !value.startsWith("/")) session.setDraft(value);
166
+ if (writer.plain) {
167
+ if (value.startsWith(plainDraft)) stdout.write(safe(value.slice(plainDraft.length)));
168
+ else stdout.write(`\n> ${safe(value)}`);
169
+ plainDraft = value;
170
+ }
171
+ paletteDismissed = false; paletteSelected = 0; selected = 0; schedule();
172
+ });
173
+ const restoreLocal = text => {
174
+ localPicker = null; focusChanged();
175
+ const restored = text ?? historyDraft; syncInput(restored);
176
+ if (!restored.startsWith("/")) session.setDraft(restored);
177
+ draw();
178
+ };
179
+ const execute = async text => {
180
+ if (localPicker) {
181
+ const match = /^[1-9]\d*$/.test(text.trim()) ? localPicker.options[Number(text) - 1] : choices()[selected];
182
+ if (match) restoreLocal(match.value); else session.message("Enter one of the numbered choices.");
183
+ return;
184
+ }
185
+ if (session.prompt) {
186
+ const prompt = session.prompt;
187
+ if (!prompt.typed && !prompt.multiple && !/^[1-9]\d*$/.test(text.trim())) {
188
+ const item = choices()[selected]; if (!item) return;
189
+ text = String(prompt.options.indexOf(item) + 1);
190
+ }
191
+ session.answerPrompt(text); return;
192
+ }
193
+ if (!text.trim()) return;
194
+ const suggestions = palette();
195
+ if (suggestions.length) {
196
+ const exact = suggestions.find(row => `/${row.name}` === text);
197
+ const row = exact ?? suggestions[paletteSelected];
198
+ if (row.disabled) { session.message(row.disabled); return; }
199
+ if (!exact && row.argument) { syncInput(`/${row.name} `); focusChanged(); draw(); return; }
200
+ text = `/${row.name}`;
201
+ }
202
+ history.push(text); if (history.length > 50) history.shift(); historyIndex = history.length;
203
+ if (writer.plain) stdout.write("\n");
204
+ if (text.startsWith("/")) {
205
+ syncInput(session.draft);
206
+ if (text === "/clear") input.checkpoint();
207
+ }
208
+ focusChanged(); paletteDismissed = true;
209
+ try { await command(session, text); if (!session.prompt && !session.closed) syncInput(session.draft); }
210
+ catch (error) { session.report(error); }
211
+ draw();
212
+ };
213
+ // Native-terminal handoff (stage 2, narrower than integrated mode): the vendor owns this TTY
214
+ // until it exits; the cloud Run, draft and session survive untouched.
215
+ session.on("handoff", async vendor => {
216
+ if (vendorOwnsTerminal) return;
217
+ vendorOwnsTerminal = true;
218
+ clearTimeout(frameTimer); frameTimer = null; clearInterval(clockTimer); clockTimer = null;
219
+ stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
220
+ process.off("SIGINT", onSignal);
221
+ writer.clearLive(); if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
222
+ const { VENDORS, executingServer, runVendor, vendorArgv } = await import("./vendors.js");
223
+ writer.append(`Opening your ${VENDORS[vendor]} terminal with Vecteur attached for this session only. Exit it to return.`);
224
+ let note;
225
+ // The child's identity is the session's CURRENT one: a re-login removed any startup token.
226
+ try { note = `${VENDORS[vendor]} exited (${await runVendor(vendorArgv(vendor, "handoff", executingServer()), { ...session.env, VECTEUR_BASE_URL: session.origin })}). Back in Vecteur.`; }
227
+ catch (error) { note = `${error.code ?? "vendor_failed"}: ${error.message}`; }
228
+ finally {
229
+ vendorOwnsTerminal = false;
230
+ process.once("SIGINT", onSignal);
231
+ if (env.TERM !== "dumb") stdout.write("\x1b[?2004h");
232
+ stdin.setRawMode(true); stdin.resume(); stdin.on("data", onData);
233
+ }
234
+ if (resizedDuringHandoff) { resizedDuringHandoff = false; onResize(); }
235
+ for (const message of heldMessages.splice(0)) session.emit("message", message);
236
+ session.message(note); session.change();
237
+ });
238
+ input.on("submit", text => { void execute(text); });
239
+ input.on("notice", text => session.message(text));
240
+ input.on("history", direction => {
241
+ if (historyIndex === history.length) historyDraft = input.text;
242
+ historyIndex = Math.max(0, Math.min(history.length, historyIndex + direction));
243
+ input.set(history[historyIndex] ?? historyDraft);
244
+ });
245
+ input.on("search", () => {
246
+ if (session.prompt || session.loginChallenge) return;
247
+ historyDraft = input.text; localPicker = { title: "Search this session's input history", options: [...history].reverse().map(text => ({ label: text, value: text })) };
248
+ selected = 0; focusChanged(); syncInput(""); if (writer.plain) printPlainPrompt(localPicker); draw();
249
+ });
250
+ input.on("complete", () => {
251
+ const row = palette()[paletteSelected]; if (row) { syncInput(`/${row.name}${row.argument ? " " : ""}`); draw(); }
252
+ });
253
+ const cancel = () => {
254
+ if (session.prompt) { session.cancelPrompt(); return; }
255
+ if (localPicker) { restoreLocal(); return; }
256
+ if (session.loginController && session.busy) { session.workOffline(); focusChanged(); return; }
257
+ if (palette().length) { paletteDismissed = true; focusChanged(); draw(); return; }
258
+ if (!session.run?.open) { session.status = "Draft kept · Ctrl+D exits"; session.change(); return; }
259
+ if (cancelRun === session.run.run_id && Date.now() - cancelAt < 2_500) {
260
+ cancelAt = 0; void session.cancel().catch(error => session.report(error));
261
+ } else { cancelRun = session.run.run_id; cancelAt = Date.now(); session.status = "Press Esc again to cancel this Run; completed work stays"; session.change(); }
262
+ };
263
+ input.on("cancel", cancel);
264
+ input.on("interrupt", () => {
265
+ if (session.prompt || localPicker || session.loginChallenge) { cancel(); return; }
266
+ if (input.text) { input.replace(0, input.text.length); session.status = "Draft cleared · Ctrl+_ restores"; session.change(); }
267
+ else if (session.run?.open) cancel();
268
+ else if (Date.now() - exitAt < 2_000) session.close();
269
+ else { exitAt = Date.now(); session.status = "Press Ctrl+C again to exit; draft saved"; session.change(); }
270
+ });
271
+ input.handleKey = key => {
272
+ const rows = choices(), list = palette();
273
+ if (["\x1b[A", "\x1b[B"].includes(key) && (session.prompt && !session.prompt.typed || localPicker || list.length)) {
274
+ const delta = key === "\x1b[A" ? -1 : 1;
275
+ if (list.length) paletteSelected = (paletteSelected + delta + list.length) % list.length;
276
+ else selected = rows.length ? (selected + delta + rows.length) % rows.length : 0;
277
+ draw(); return true;
278
+ }
279
+ if (key === " " && session.prompt?.multiple) {
280
+ const item = rows[selected], index = session.prompt.options.indexOf(item) + 1;
281
+ const values = new Set(input.text.trim().split(/[, ]+/).filter(Boolean));
282
+ if (values.has(String(index))) values.delete(String(index)); else values.add(String(index));
283
+ syncInput([...values].join(",")); draw(); return true;
284
+ }
285
+ if (key === "?" && !input.text && !session.prompt) { session.message(SHORTCUTS); return true; }
286
+ if (session.loginChallenge && ["\t", "\x1b[Z"].includes(key)) {
287
+ loginAction = (loginAction + (key === "\t" ? 1 : 2)) % 3; draw(); return true;
288
+ }
289
+ if (session.loginChallenge && key === "\r" && !input.text) {
290
+ if (loginAction === 0) void openBrowser(session.loginChallenge.verification_uri_complete);
291
+ else if (loginAction === 1) session.emit("copy", session.loginChallenge.verification_uri_complete);
292
+ else session.workOffline();
293
+ focusChanged(); return true;
294
+ }
295
+ return false;
296
+ };
297
+ input.on("details", () => { if (!session.prompt) void execute("/details"); });
298
+ input.on("redraw", () => { writer.clearLive(); draw(); });
299
+ input.on("exit", () => { try { session.close(); } catch (error) { session.report(error); } });
300
+ const onData = chunk => {
301
+ if (env.TERM === "dumb" && !input.pasting && (String(chunk).match(/[\r\n]/g)?.length ?? 0) > 1) {
302
+ input.insert(String(chunk).replace(/\r\n?/g, "\n")); return;
303
+ }
304
+ input.feed(chunk);
305
+ };
306
+ const onResize = () => {
307
+ // The vendor owns the TTY: not one byte; the new geometry is applied once on restore.
308
+ if (vendorOwnsTerminal) { resizedDuringHandoff = true; return; }
309
+ const plain = isPlain(), changed = plain !== writer.plain;
310
+ writer.resized(plain); lastPlainIdentity = "";
311
+ if (changed) writer.append(plain ? "Plain view — terminal is small; all actions remain available." : "Rich view restored.");
312
+ session.change(); draw();
313
+ };
314
+ const onSignal = () => { try { session.close(); } catch {} };
315
+ const done = new Promise(resolve => session.once("close", resolve));
316
+ stdin.setRawMode(true); stdin.resume(); stdin.on("data", onData); stdout.on("resize", onResize);
317
+ process.once("SIGTERM", onSignal); process.once("SIGHUP", onSignal); process.once("SIGINT", onSignal);
318
+ if (env.TERM !== "dumb") stdout.write("\x1b[?2004h");
319
+ writer.append(writer.plain ? `Vecteur ${version}${channel === "preview" ? " · Preview" : ""} · ${safe(session.origin)}` : layout.welcome(version, session.origin, stdout.columns ?? 80, color, channel,
320
+ session.cwd.startsWith(homedir() + "/") ? "~" + session.cwd.slice(homedir().length) : session.cwd));
321
+ draw();
322
+ void session.initialize().then(() => { if (!input.text) syncInput(session.draft); draw(); }).catch(error => session.report(error));
323
+ try { await done; }
324
+ finally {
325
+ clearTimeout(frameTimer); clearInterval(clockTimer); input.close(); stdin.off("data", onData); stdout.off("resize", onResize);
326
+ process.off("SIGTERM", onSignal); process.off("SIGHUP", onSignal); process.off("SIGINT", onSignal);
327
+ writer.clearLive(); if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
328
+ stdin.setRawMode(false); stdin.pause();
329
+ stdout.write(session.restart ? "Restarting Vecteur…\n" : session.run?.open ? "Detached. The cloud Run continues; run vecteur here to follow it.\n" : "Session closed.\n");
330
+ }
331
+ if (session.restart) {
332
+ const { restartUpdate } = await import("./update.js");
333
+ process.exitCode = await restartUpdate(session.restart);
334
+ }
335
+ }