@vecteur/cli 0.8.0 → 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/session.js ADDED
@@ -0,0 +1,689 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ import { open, writeFile } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { basename, relative, resolve } from "node:path";
6
+ import { VecteurClient, CliError, configuration, delay, isTokenShaped, requestTimeout,
7
+ resolveOrigin, shouldOpenBrowser, waitForDeviceAuthorization } from "./client.js";
8
+ import { readCredentials, writeCredentials, clearCredentials } from "./credentials.js";
9
+ import { FolderRegistry, UNSIGNED_ACCOUNT } from "./folders.js";
10
+ import { runView } from "./blocks.js";
11
+ import { downloadArtifact, exportRun } from "./download.js";
12
+ import { humanCost, runSections } from "./format.js";
13
+ import { CATALOGUE_ENVELOPE_SHA256, MODEL_CATALOGUE, PRODUCT_ROUTES } from "./contract.js";
14
+ import { openBrowser } from "./browser.js";
15
+
16
+ const AUTH_ERRORS = new Set(["pat_expired", "pat_invalid", "pat_revoked"]);
17
+ const TRANSIENT = new Set(["request_failed", "request_timeout", "terminal_invalid"]);
18
+ const MEDIA = { ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".json": "application/json",
19
+ ".pdf": "application/pdf", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" };
20
+
21
+ /** One cloud session, two real renderers. It owns no terminal, device or native-agent runtime. */
22
+ export class Session extends EventEmitter {
23
+ constructor({ cwd = process.cwd(), env = process.env, origin, noBrowser = false, offline = false, clientFactory = config => new VecteurClient(config) } = {}) {
24
+ super();
25
+ this.cwd = cwd; this.env = env; this.noBrowser = noBrowser; this.clientFactory = clientFactory;
26
+ this.origin = resolveOrigin(env, undefined, origin);
27
+ this.status = "Starting"; this.draft = ""; this.project = null; this.run = null; this.binding = null;
28
+ this.attachments = []; this.model = null; this.closed = false; this.busy = false;
29
+ this.followController = null; this.prompt = null; this.generation = 0;
30
+ this.lifecycle = new AbortController();
31
+ this.offline = offline; this.loginGeneration = 0; this.promptGeneration = 0;
32
+ }
33
+
34
+ view(now = Date.now()) {
35
+ const canEdit = this.project?.role !== "VIEWER";
36
+ const activity = !canEdit && this.status === "Waiting for your answer" ? "Waiting for an editor" : this.status;
37
+ this.projectionCache ??= {};
38
+ const projection = runView(this.run, this.projectionCache, now);
39
+ const requested = this.model ?? "auto";
40
+ const modelLabel = MODEL_CATALOGUE.find(row => row.profile_id === requested)?.label
41
+ ?? PRODUCT_ROUTES.find(row => row.route_id === requested)?.label ?? requested;
42
+ const bound = this.binding?.state === "bound" && this.binding.projectId === this.project?.id;
43
+ return { origin: this.origin, folder: bound ? relative(this.binding.folder.path, this.cwd) || "." : this.cwd,
44
+ account: this.account, project: this.project,
45
+ projectLabel: this.project?.name ?? (this.binding ? "Linked Project unavailable" : "New Project on first send"),
46
+ status: activity + (canEdit ? "" : " · Read-only — Viewer") + (projection.terminal ? ` · ${projection.elapsed}` : ""), draft: this.draft, prompt: this.prompt, busy: this.busy,
47
+ offline: this.offline, issue: this.issue, login: this.loginChallenge, release: this.release,
48
+ attachments: this.attachments.map(item => ({ path: item.path, size: item.bytes.length })),
49
+ lead: "Laplace", location: "Vecteur cloud", model: modelLabel,
50
+ run: projection, available: { submit: !this.offline && !!this.account && canEdit && !this.busy && !this.run?.open,
51
+ cancel: !this.offline && !!this.account && canEdit && !!this.run?.open, steer: !this.offline && !!this.account && canEdit && !!this.run?.open && !projection.question,
52
+ localExecution: false, nativeAgent: false } };
53
+ }
54
+
55
+ change() { if (!this.closed) this.emit("change", this.view()); }
56
+ message(text, kind = "info", extra = {}) { if (!this.closed) this.emit("message", { ...extra, id: randomUUID(), kind, text }); }
57
+ report(error) {
58
+ if (["action_cancelled", "session_closed"].includes(error.code)) { this.status = "Cancelled — draft kept"; this.change(); return; }
59
+ if (AUTH_ERRORS.has(error.code)) this.account = null;
60
+ const recovery = AUTH_ERRORS.has(error.code) ? " — /login; draft and Run kept"
61
+ : TRANSIENT.has(error.code) ? " — /resume; draft and submission identity kept"
62
+ : ["device_expired", "device_denied"].includes(error.code) ? " — /login starts again; draft kept" : "";
63
+ this.status = `${error.code ?? "session_error"}: ${error.message}${recovery}`;
64
+ if (this.issue?.code === error.code) this.issue.count++;
65
+ else this.issue = { code: error.code, text: this.status, count: 1 };
66
+ this.change();
67
+ }
68
+
69
+ async choose(title, options, { typed = false, multiple = false } = {}) {
70
+ if (this.prompt) throw new CliError("prompt_busy", "Answer the current question first");
71
+ return new Promise((resolveChoice, reject) => {
72
+ this.prompt = { id: ++this.promptGeneration, title, options, typed, multiple };
73
+ this.resolvePrompt = answer => { this.prompt = null; this.resolvePrompt = null; this.rejectPrompt = null; this.change(); resolveChoice(answer); };
74
+ this.rejectPrompt = reject;
75
+ this.change();
76
+ });
77
+ }
78
+
79
+ cancelPrompt() {
80
+ const reject = this.rejectPrompt;
81
+ this.prompt = null; this.resolvePrompt = null; this.rejectPrompt = null; this.change();
82
+ reject?.(new CliError("action_cancelled", "Action cancelled; draft kept"));
83
+ }
84
+
85
+ cancelLogin() {
86
+ this.loginGeneration++; this.loginController?.abort(); this.loginChallenge = null;
87
+ }
88
+
89
+ workOffline() {
90
+ this.cancelLogin(); this.cancelPrompt(); this.followController?.abort(); this.generation++;
91
+ this.offline = true; this.issue = null;
92
+ if (!this.registry) {
93
+ const saved = this.env.VECTEUR_TOKEN ? null : readCredentials(this.env);
94
+ const token = this.signedOut ? null : this.env.VECTEUR_TOKEN ?? saved?.logins?.[this.origin]?.token;
95
+ this.registry = token ? FolderRegistry.forCredential({ origin: this.origin, token, env: this.env, cwd: this.cwd }) : null;
96
+ if (this.registry && !this.draft) this.draft = this.registry.state().draft;
97
+ if (!this.registry) this.unsignedDraft();
98
+ }
99
+ this.status = this.run?.open ? "Offline draft — the cloud Run continues; /resume to reconnect" : "Offline draft — /login when ready";
100
+ this.change();
101
+ }
102
+
103
+ answerPrompt(text) {
104
+ if (!this.prompt) return false;
105
+ const { options, typed, multiple } = this.prompt;
106
+ if (multiple) {
107
+ const indices = text.trim().split(/[\s,]+/).map(Number);
108
+ if (indices.some(index => !Number.isInteger(index) || index < 1 || index > options.length)
109
+ || new Set(indices).size !== indices.length) {
110
+ this.message("Enter choice numbers separated by commas, for example 1,3."); return true;
111
+ }
112
+ this.resolvePrompt(indices.map(index => options[index - 1].value)); return true;
113
+ }
114
+ const selected = typed ? text : options[Number(text) - 1]?.value;
115
+ if (selected === undefined) { this.message("Enter one of the numbered choices."); return true; }
116
+ this.resolvePrompt(selected); return true;
117
+ }
118
+
119
+ async transition(action) {
120
+ if (this.closed) throw new CliError("session_closed", "Session closed");
121
+ if (this.busy || this.prompt) throw new CliError("session_busy", "Finish the current action first; your draft is kept");
122
+ this.busy = true; this.change();
123
+ try { return await action(); }
124
+ finally { this.busy = false; this.change(); }
125
+ }
126
+
127
+ unsignedDraft({ restore = true } = {}) {
128
+ if (this.registry) return;
129
+ this.registry = new FolderRegistry({ origin: this.origin, accountId: UNSIGNED_ACCOUNT, cwd: this.cwd, env: this.env });
130
+ if (this.draft || !restore) this.registry.newDraft();
131
+ else this.draft = this.registry.state().draft;
132
+ }
133
+
134
+ initialize(options = {}) { return this.transition(async () => {
135
+ try { return await this.initializeAccount(options); }
136
+ finally { this.loginController = null; }
137
+ }); }
138
+
139
+ async initializeAccount({ login = false } = {}) {
140
+ if (this.offline && !login) { this.workOffline(); return; }
141
+ this.cancelLogin(); const generation = this.loginGeneration;
142
+ const controller = new AbortController(); this.loginController = controller;
143
+ const current = () => !this.closed && !this.offline && generation === this.loginGeneration && !controller.signal.aborted;
144
+ this.offline = false; this.issue = null;
145
+ const saved = this.env.VECTEUR_TOKEN ? null : readCredentials(this.env);
146
+ let token = this.signedOut ? null : this.env.VECTEUR_TOKEN ?? saved?.logins?.[this.origin]?.token;
147
+ let transferUnsigned = login;
148
+ if (!isTokenShaped(token)) this.unsignedDraft();
149
+ const publicClient = this.clientFactory({ origin: this.origin, timeout: Math.min(3_000, requestTimeout(this.env)) });
150
+ this.status = "Checking connection"; this.change();
151
+ try { this.release = await publicClient.readiness(controller.signal); }
152
+ catch (error) {
153
+ if (!current()) return;
154
+ if (!this.registry) {
155
+ this.registry = FolderRegistry.forCredential({ origin: this.origin, token, env: this.env, cwd: this.cwd });
156
+ if (this.registry && this.draft) this.registry.newDraft();
157
+ else if (this.registry) this.draft = this.registry.state().draft;
158
+ else this.unsignedDraft({ restore: false });
159
+ }
160
+ throw error;
161
+ }
162
+ if (!current()) return;
163
+ if (!this.release.compatible) throw new CliError("client_outdated",
164
+ `This origin requires ${this.release.requiredVersion || this.release.cli ? `Vecteur CLI ${this.release.requiredVersion ?? this.release.cli.version}` : "a different CLI contract"}. /update checks this installation; /offline keeps your draft.`);
165
+ if (login || !isTokenShaped(token)) {
166
+ this.status = "Sign in to Vecteur"; this.change();
167
+ const action = login ? "login" : await this.choose(`Sign in to ${this.origin}?`, [
168
+ { label: "Sign in through your browser", value: "login" }, { label: "Keep drafting offline", value: "offline" },
169
+ ]);
170
+ if (!current()) return;
171
+ if (action !== "login") { this.workOffline(); return; }
172
+ transferUnsigned = true; this.followController?.abort(); this.generation++; this.account = null;
173
+ try {
174
+ const grant = await publicClient.deviceMint(controller.signal);
175
+ if (!current()) return;
176
+ this.loginChallenge = grant; this.status = "Waiting for browser approval — Esc works offline"; this.change();
177
+ this.message(`Sign in: ${grant.verification_uri_complete}\nCode: ${grant.user_code}`);
178
+ if (shouldOpenBrowser(this.noBrowser, this.env)) {
179
+ void openBrowser(grant.verification_uri_complete).then(ok => {
180
+ if (current() && !ok) { this.status = "Browser could not open — use the approval URL above"; this.change(); }
181
+ });
182
+ }
183
+ const result = await waitForDeviceAuthorization(publicClient, grant, { signal: controller.signal });
184
+ if (!current()) return;
185
+ const previous = saved?.logins?.[this.origin]?.token;
186
+ if (previous) {
187
+ try { await this.clientFactory({ origin: this.origin, token: previous, timeout: requestTimeout(this.env) }).credentialSelfRevoke(controller.signal); }
188
+ catch (error) { if (!AUTH_ERRORS.has(error.code)) throw error; }
189
+ if (!current()) return;
190
+ }
191
+ writeCredentials({ origin: this.origin, token: result.token }, this.env);
192
+ this.env = { ...this.env }; delete this.env.VECTEUR_TOKEN;
193
+ token = result.token; this.signedOut = false;
194
+ this.client = this.clientFactory({ origin: this.origin, token: result.token, timeout: requestTimeout(this.env) });
195
+ } catch (error) { if (!current()) return; throw error; }
196
+ finally { if (generation === this.loginGeneration) { this.loginChallenge = null; this.status = "Loading account"; this.change(); } }
197
+ } else {
198
+ this.followController?.abort(); this.generation++; this.account = null; this.client = this.clientFactory(configuration(this.env, saved, this.origin));
199
+ }
200
+ const previousAccount = this.registry?.accountId ?? this.account?.id;
201
+ let account;
202
+ try { account = await this.client.accountMe(controller.signal); }
203
+ catch (error) {
204
+ if (!current()) return;
205
+ // This cache restores only an account-scoped draft, never authorization or Project data.
206
+ if (!this.registry && (TRANSIENT.has(error.code) || AUTH_ERRORS.has(error.code))) {
207
+ this.registry = FolderRegistry.forCredential({ origin: this.origin, token, env: this.env, cwd: this.cwd });
208
+ if (this.draft && this.registry) this.registry.newDraft();
209
+ else if (this.registry) this.draft = this.registry.state().draft;
210
+ else this.unsignedDraft({ restore: false });
211
+ }
212
+ this.account = null;
213
+ this.status = `${error.code}: offline or unavailable — draft kept; /resume to reconnect`;
214
+ this.change(); throw error;
215
+ }
216
+ if (!current()) return;
217
+ if (account.id === UNSIGNED_ACCOUNT) throw new CliError("account_invalid", "Reserved local draft identity cannot authenticate");
218
+ const unsigned = previousAccount === UNSIGNED_ACCOUNT ? this.registry : null;
219
+ if (previousAccount && previousAccount !== account.id && !(unsigned && transferUnsigned)) {
220
+ clearTimeout(this.draftTimer); this.registry?.saveDraft(this.draft); this.draft = "";
221
+ this.followController?.abort(); this.generation++; this.run = null; this.project = null; this.binding = null; this.attachments = [];
222
+ this.model = null; this.projectName = null; this.lastPrintedRun = null;
223
+ }
224
+ this.account = account;
225
+ const newRegistry = !this.registry || this.registry.accountId !== account.id;
226
+ if (newRegistry) this.registry = new FolderRegistry({ origin: this.origin, accountId: account.id, cwd: this.cwd, env: this.env });
227
+ if (newRegistry && this.draft) {
228
+ if (unsigned && transferUnsigned) unsigned.transferDraftTo(this.registry, this.draft);
229
+ else this.registry.newDraft();
230
+ }
231
+ this.registry.rememberLogin(token);
232
+ this.cwd = this.registry.folder.path;
233
+ if (!this.draft) {
234
+ const drafts = this.registry.state().drafts;
235
+ if (newRegistry && new Set(drafts.map(row => row.text)).size > 1) {
236
+ const id = await this.choose("Recover a saved draft", drafts.map(row => ({
237
+ label: `${new Date(row.at).toISOString()} · ${row.text.slice(0, 100)}`, value: row.id,
238
+ })));
239
+ if (!current()) return;
240
+ this.draft = this.registry.selectDraft(id);
241
+ } else this.draft = this.registry.state().draft;
242
+ }
243
+ this.change(); // Publish the account/draft boundary before any more asynchronous reads.
244
+ const projects = await this.client.projectList(controller.signal);
245
+ if (!current()) return;
246
+ this.projects = projects;
247
+ const binding = this.registry.binding(); this.binding = binding;
248
+ if (binding && binding.state !== "bound") {
249
+ this.status = "Folder identity changed — /unlink before relinking"; this.change(); return;
250
+ }
251
+ if (binding) {
252
+ this.project = this.projects.find(project => project.id === binding.projectId) ?? null;
253
+ if (!this.project) { this.status = "Linked Project unavailable — /project or /unlink"; this.change(); return; }
254
+ }
255
+ this.status = this.project ? "Ready" : "New folder — send an ask or /link an existing Project";
256
+ const pending = this.registry.state();
257
+ if (pending.pendingRun || pending.pendingCreate) {
258
+ this.followController?.abort(); this.generation++; this.run = null;
259
+ if (pending.pendingRun) this.project = this.projects.find(row => row.id === pending.pendingRun.projectId) ?? null;
260
+ this.status = "Submission interrupted — /resume reconciles the same request";
261
+ this.change(); return;
262
+ }
263
+ this.change();
264
+ await this.loadNewest();
265
+ }
266
+
267
+ async loadNewest() {
268
+ if (this.project) {
269
+ this.followController?.abort(); const generation = ++this.generation;
270
+ const newest = await this.client.projectNewestRun(this.project.id);
271
+ if (this.closed || generation !== this.generation) return;
272
+ if (newest) {
273
+ this.run = { ...newest, open: !newest.terminal };
274
+ if (this.run.open) {
275
+ this.status = runView(this.run, this.projectionCache).question ? "Waiting for your answer" : "Running";
276
+ this.change(); void this.follow().catch(error => this.report(error));
277
+ }
278
+ else { this.status = newest.terminal; this.announceRun(); this.change(); }
279
+ } else { this.run = null; this.change(); }
280
+ }
281
+ }
282
+
283
+ announceRun() {
284
+ if (this.lastPrintedRun === this.run?.run_id) return;
285
+ this.message(runView(this.run).transcript, "answer", { runId: this.run?.run_id, sections: runSections(this.run) }); this.lastPrintedRun = this.run?.run_id;
286
+ }
287
+
288
+ recoverDraft() {
289
+ return this.transition(async () => {
290
+ if (!this.registry) throw new CliError("login_required", "Use /login before recovering saved drafts");
291
+ clearTimeout(this.draftTimer); this.registry.saveDraft(this.draft);
292
+ const drafts = this.registry.state().drafts;
293
+ if (!drafts.length) { this.message("No saved drafts."); return; }
294
+ const id = await this.choose("Recover a saved draft", drafts.map(row => ({
295
+ label: `${new Date(row.at).toISOString()} · ${row.text.slice(0, 100)}`, value: row.id,
296
+ })));
297
+ this.draft = this.registry.selectDraft(id); this.change();
298
+ });
299
+ }
300
+
301
+ setDraft(text) {
302
+ this.draft = text;
303
+ clearTimeout(this.draftTimer);
304
+ if (this.registry) this.draftTimer = setTimeout(() => {
305
+ try { this.registry.saveDraft(this.draft); } catch (error) { this.report(error); }
306
+ }, 200);
307
+ this.change();
308
+ }
309
+
310
+ async ensureProject() {
311
+ if (!this.account) throw new CliError("login_required", "Sign in with /login; your draft is kept");
312
+ if (this.project) return this.project;
313
+ if (this.registry.binding()) throw new CliError("binding_unavailable", "Resolve the existing binding with /project or /unlink");
314
+ if (this.registry.broadRoot() && !this.registry.state().pendingCreate) {
315
+ const answer = await this.choose(`Starting here links this ${this.registry.broadRoot()} and its descendants. Type ${this.cwd} to confirm, or leave blank to cancel.`, [], { typed: true });
316
+ if (answer !== this.cwd) throw new CliError("binding_cancelled", "Broad directory binding was not confirmed");
317
+ }
318
+ let workspace = this.account.workspaces[0]?.id;
319
+ if (!workspace) throw new CliError("workspace_unavailable", "Create a Workspace in the web app first");
320
+ if (this.account.workspaces.length > 1 && !this.registry.state().pendingCreate) {
321
+ workspace = await this.choose("Choose the Workspace for this Project", this.account.workspaces.map(row => ({ label: row.name, value: row.id })));
322
+ }
323
+ const pending = this.registry.reserveCreation(this.projectName ?? basename(this.cwd), workspace);
324
+ const project = pending.projectId ? await this.client.projectGet(pending.projectId)
325
+ : await this.client.projectCreate(pending.name, pending.workspaceId, undefined, pending.key);
326
+ if (!pending.projectId) this.registry.completeCreation(pending.key, project.id);
327
+ this.project = project; this.binding = this.registry.binding();
328
+ return project;
329
+ }
330
+
331
+ async submit(text = this.draft) {
332
+ if (this.busy) throw new CliError("session_busy", "The current submission is still being reconciled");
333
+ if (!text.trim()) return;
334
+ if (this.offline || !this.account) {
335
+ this.setDraft(text);
336
+ const action = await this.choose("Sign in to send this ask. Your draft will not be sent automatically.", [
337
+ { label: "Continue in browser", value: "login" }, { label: "Keep drafting offline", value: "offline" },
338
+ ]);
339
+ if (action === "login") await this.initialize({ login: true }); else this.workOffline();
340
+ return;
341
+ }
342
+ if (this.run?.open) {
343
+ this.setDraft(text); const run = this.run;
344
+ const action = await this.choose("This Run is still working", [
345
+ { label: "Keep drafting the next question", value: "draft" }, { label: "Send as steering to this Run", value: "steer" },
346
+ ]);
347
+ if (action === "steer" && this.run === run && run.open) await this.steer(text);
348
+ return;
349
+ }
350
+ this.setDraft(text);
351
+ this.registry?.saveDraft(text);
352
+ const draftRef = structuredClone(this.registry?.draftRef ?? null);
353
+ this.busy = true; this.status = "Submitting"; this.change();
354
+ try {
355
+ if (this.registry?.state().pendingRun) throw new CliError("submission_pending", "Use /resume to resolve the interrupted submission; your new draft is kept");
356
+ const project = await this.ensureProject();
357
+ if (project.role === "VIEWER") throw new CliError("role_forbidden", "Editor access is required to ask Vecteur");
358
+ const newest = await this.client.projectNewestRun(project.id);
359
+ if (newest && !newest.terminal) {
360
+ this.run = { ...newest, open: true }; void this.follow().catch(error => this.report(error));
361
+ throw new CliError("run_busy", "Another terminal has an open Run. Following it; your draft is kept.");
362
+ }
363
+ const selections = [];
364
+ for (const item of this.attachments) {
365
+ item.source ??= await this.client.inputAdd(project.id, item.bytes, item.media);
366
+ const offer = item.source.offer ?? await this.client.inputOffer(project.id, item.source);
367
+ if (!offer.extracts.length) throw new CliError("extraction_unavailable", "This file has no selectable extracts");
368
+ selections.push({ source_handle: { cas_ref: item.source.cas_ref, acquisition_ref: item.source.acquisition_ref },
369
+ offer_id: offer.id, selected_extract_ids: offer.extracts.map(extract => extract.id).sort(), method: "exact-extract" });
370
+ }
371
+ selections.sort((a, b) => a.source_handle.cas_ref.localeCompare(b.source_handle.cas_ref)
372
+ || a.source_handle.acquisition_ref.localeCompare(b.source_handle.acquisition_ref));
373
+ const body = { ask: text, attachments: selections.map(item => item.source_handle),
374
+ parent_state_hash: newest?.state_hash ?? null, ui_context: null,
375
+ ...(selections.length ? { selections } : {}), ...(this.model ? { model_choice: this.model } : {}) };
376
+ const pending = this.registry.reserveRun(project.id, body, draftRef);
377
+ await this.admitPending(pending);
378
+ } finally { this.busy = false; this.change(); }
379
+ }
380
+
381
+ async admitPending(pending) {
382
+ if (this.project?.id !== pending.projectId) {
383
+ const project = await this.client.projectGet(pending.projectId);
384
+ this.followController?.abort(); this.generation++; this.run = null; this.project = project; this.change();
385
+ }
386
+ let ack;
387
+ try { ack = await this.client.runAdmit(pending.projectId, pending.body, pending.key); }
388
+ catch (error) {
389
+ if (["state_busy", "state_fork"].includes(error.code)) {
390
+ this.registry.rejectRunAdmission(pending.key);
391
+ await this.loadNewest();
392
+ throw new CliError(error.code, "Submission refused; draft kept. Review the latest Run and explicitly send again.");
393
+ }
394
+ // The owner marked this key as never admittable (nothing dispatched): release only this
395
+ // reservation and keep the draft. Any other failure stays pending for /resume.
396
+ if (error.admission === "refused") {
397
+ this.registry.rejectRunAdmission(pending.key);
398
+ throw new CliError(error.code, "Submission refused before it ran; draft kept. Fix it and send again.");
399
+ }
400
+ throw error;
401
+ }
402
+ this.registry.finishRunAdmission(pending.key);
403
+ if (this.draft === pending.body.ask) { clearTimeout(this.draftTimer); this.draft = ""; }
404
+ this.attachments = [];
405
+ this.message(pending.body.ask, "user");
406
+ this.run = { ...ack, events: [], terminal: "", open: true };
407
+ this.status = "Running"; this.change();
408
+ void this.follow().catch(error => this.report(error));
409
+ }
410
+
411
+ async resume() {
412
+ if (this.offline) { this.offline = false; return this.initialize(); }
413
+ if (this.busy) throw new CliError("session_busy", "This submission is already being reconciled");
414
+ if (!this.account || !this.registry) return this.initialize();
415
+ const state = this.registry.state();
416
+ if (state.pendingRun) {
417
+ this.busy = true; this.change();
418
+ try { return await this.admitPending(state.pendingRun); }
419
+ finally { this.busy = false; this.change(); }
420
+ }
421
+ if (state.pendingCreate) return this.submit();
422
+ if (this.run?.open) { void this.follow().catch(error => this.report(error)); return; }
423
+ return this.initialize();
424
+ }
425
+
426
+ async follow() {
427
+ this.followController?.abort();
428
+ const controller = new AbortController(); this.followController = controller;
429
+ const run = this.run; const generation = ++this.generation;
430
+ const projectId = this.project.id;
431
+ let attempts = 0;
432
+ while (!this.closed && generation === this.generation && run?.open) {
433
+ try {
434
+ const direct = await this.client.runEvents(run.run_id, event => {
435
+ if (generation !== this.generation) return;
436
+ run.events.push(event);
437
+ this.status = runView(run, this.projectionCache).question ? "Waiting for your answer" : "Running";
438
+ this.change();
439
+ }, controller.signal, [...run.events]);
440
+ if (generation !== this.generation) return;
441
+ this.run = { ...direct, accepted_at: direct.run_id === run.run_id ? run.accepted_at : undefined, open: !direct.terminal };
442
+ this.status = direct.terminal; this.announceRun();
443
+ try {
444
+ const cost = await this.client.customerCost(projectId);
445
+ if (generation === this.generation) this.message(humanCost(cost), "cost", { value: cost });
446
+ } catch (error) { if (generation === this.generation) this.message(`Usage unavailable: ${error.code}`); }
447
+ this.change(); return;
448
+ } catch (error) {
449
+ if (controller.signal.aborted || this.closed || generation !== this.generation) return;
450
+ if (AUTH_ERRORS.has(error.code)) { this.report(error); return; }
451
+ if (!TRANSIENT.has(error.code) || ++attempts > 5) throw error;
452
+ this.status = "Connection lost — reconnecting to the same Run"; this.change();
453
+ await delay(Math.min(500 * 2 ** attempts, 8_000), controller.signal);
454
+ }
455
+ }
456
+ }
457
+
458
+ attach(path) { return this.transition(() => this.selectAttachment(path)); }
459
+
460
+ async selectAttachment(path) {
461
+ if (this.registry?.state().pendingRun) throw new CliError("submission_pending", "Resolve the pending submission before changing its files");
462
+ const filename = resolve(this.cwd, path);
463
+ const handle = await open(filename, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
464
+ let bytes;
465
+ try {
466
+ const info = await handle.stat();
467
+ if (!info.isFile() || info.size <= 0 || info.size > 8 * 1024 * 1024) throw new CliError("attachment_invalid", "Select one non-empty file no larger than 8 MiB");
468
+ bytes = await handle.readFile();
469
+ const after = await handle.stat();
470
+ if (bytes.length !== info.size || after.size !== info.size || after.mtimeMs !== info.mtimeMs
471
+ || after.ctimeMs !== info.ctimeMs) throw new CliError("attachment_changed", "File changed while reading; select it again");
472
+ } finally { await handle.close(); }
473
+ if (this.attachments.length >= 8 || this.attachments.reduce((sum, item) => sum + item.bytes.length, bytes.length) > 32 * 1024 * 1024) {
474
+ throw new CliError("attachments_full", "Select at most 8 files and 32 MiB per turn; /clear-files removes pending files");
475
+ }
476
+ const ok = await this.choose(`Attach ${filename} (${bytes.length} bytes) to ${this.project?.name ?? "the Project created on send"} at ${this.origin}? Selected extracts go to the chosen model route.`, [
477
+ { label: "Attach on next send", value: true }, { label: "Cancel", value: false },
478
+ ]);
479
+ if (!ok || this.closed) return;
480
+ const extension = /\.[^.]+$/.exec(filename)?.[0].toLowerCase();
481
+ this.attachments.push({ path: filename, bytes, media: MEDIA[extension] ?? "application/octet-stream" }); this.change();
482
+ }
483
+
484
+ link(options = {}) { return this.transition(() => this.selectProject(options)); }
485
+
486
+ async selectProject({ persist = true } = {}) {
487
+ const state = this.registry.state();
488
+ if (state.pendingCreate || state.pendingRun) throw new CliError("operation_unresolved", "Use /resume before changing Project; the pending request keeps its original Project");
489
+ if (this.run?.open) throw new CliError("run_busy", "Detach or finish the current Run before changing Project");
490
+ this.projects = await this.client.projectList();
491
+ if (this.closed) return;
492
+ if (!this.projects.length) throw new CliError("project_unavailable", "No Projects yet; send an ask to create one");
493
+ const id = await this.choose("Choose an existing Project", this.projects.map(row => ({ label: `${row.name} (${row.role})`, value: row.id })));
494
+ if (this.closed) return;
495
+ const project = this.projects.find(row => row.id === id);
496
+ if (persist) {
497
+ try { this.registry.bind(id); }
498
+ catch (error) {
499
+ if (!["home_binding_confirmation", "root_binding_confirmation"].includes(error.code)) throw error;
500
+ const answer = await this.choose(error.message, [], { typed: true });
501
+ if (answer !== this.cwd) throw new CliError("binding_cancelled", "Broad directory binding was not confirmed");
502
+ this.registry.bind(id, { confirmBroad: true });
503
+ }
504
+ }
505
+ this.followController?.abort(); this.generation++;
506
+ this.binding = this.registry.binding();
507
+ this.attachments = []; this.project = project; this.run = null; this.status = "Ready"; this.change();
508
+ await this.loadNewest();
509
+ }
510
+
511
+ requireLogin() {
512
+ if (this.offline) throw new CliError("offline", "Working offline; /resume reconnects. Your draft is kept.");
513
+ if (!this.account) throw new CliError("login_required", "Use /login first; your draft and Run are kept");
514
+ }
515
+
516
+ async cancel() {
517
+ this.requireLogin();
518
+ if (!this.run?.open) throw new CliError("run_not_open", "There is no open Run to cancel");
519
+ const run = this.run, client = this.client;
520
+ await client.runCancel(run.run_id);
521
+ if (this.run !== run || this.client !== client) return;
522
+ this.status = "Cancellation requested — completed work stays"; this.change();
523
+ }
524
+
525
+ history(before) { return this.transition(() => this.selectHistory(before)); }
526
+
527
+ async selectHistory(before) {
528
+ if (!this.project) throw new CliError("project_unavailable", "Choose a Project with /project first");
529
+ if (this.run?.open) throw new CliError("run_busy", "Finish the current Run before viewing older history; /web opens it separately");
530
+ while (true) {
531
+ const page = await this.client.projectHistory(this.project.id, { before });
532
+ if (this.closed) return;
533
+ if (!page.runs.length && !page.next_before) { this.message("No older Runs."); return; }
534
+ const choices = page.runs.map(row => ({ label: `${row.response.terminal || "Running"} · ${row.request.ask.slice(0, 160)}`, value: row.response.run_id }));
535
+ if (page.next_before) choices.push({ label: "Older Runs", value: "older" });
536
+ choices.push({ label: "Back to composer", value: null });
537
+ const id = await this.choose("Project history", choices);
538
+ if (this.closed) return;
539
+ if (id === "older") { before = page.next_before; continue; }
540
+ if (!id) return;
541
+ const selected = page.runs.find(row => row.response.run_id === id);
542
+ this.followController?.abort(); this.generation++;
543
+ this.run = { ...selected.response, accepted_at: selected.accepted_at, open: !selected.response.terminal };
544
+ this.message(selected.request.ask, "user");
545
+ if (this.run.open) void this.follow().catch(error => this.report(error));
546
+ else this.message(runView(this.run).transcript, "answer", { runId: this.run.run_id, sections: runSections(this.run) });
547
+ this.status = "Viewing history — next send continues the current Project head"; this.change();
548
+ return;
549
+ }
550
+ }
551
+
552
+ async steer(text) {
553
+ this.requireLogin();
554
+ if (!this.run?.open) throw new CliError("run_not_open", "There is no open Run to steer");
555
+ const control = { id: randomUUID(), kind: text ? "steer" : "answer_now", ...(text ? { text } : {}) };
556
+ const run = this.run, client = this.client;
557
+ const value = await client.runControl(run.run_id, control);
558
+ if (this.run !== run || this.client !== client) return;
559
+ this.message(`Instruction ${value.state}; acceptance does not mean it was executed.`);
560
+ }
561
+
562
+ async answer() {
563
+ this.requireLogin();
564
+ const run = this.run, client = this.client;
565
+ const question = runView(run).question;
566
+ if (!question?.questions?.length) throw new CliError("question_unavailable", "There is no pending clarification");
567
+ const current = () => {
568
+ this.requireLogin();
569
+ if (this.run !== run || this.client !== client || runView(this.run).question?.id !== question.id) throw new CliError("question_resolved", "This clarification has already been resolved; your answer was not sent");
570
+ };
571
+ if (this.clarificationDraft?.run !== run || this.clarificationDraft?.client !== client || this.clarificationDraft?.id !== question.id) {
572
+ this.clarificationDraft = { run, client, id: question.id, answers: [] };
573
+ }
574
+ const { answers } = this.clarificationDraft;
575
+ while (true) {
576
+ for (const item of question.questions.slice(answers.length)) {
577
+ const options = [...item.options.map(option => ({ label: `${option.label}${option.description ? ` — ${option.description}` : ""}`, value: option.label })),
578
+ { label: "Write an answer", value: null }];
579
+ const response = item.options.length ? await this.choose(
580
+ item.question + (item.multiSelect ? " (choose numbers separated by commas)" : ""), options,
581
+ { multiple: item.multiSelect === true }) : null;
582
+ const selected = Array.isArray(response) ? response : [response];
583
+ const answer = { id: item.id, selected: selected.filter(value => value !== null) };
584
+ if (selected.includes(null)) {
585
+ do { answer.custom = await this.choose(item.question, [], { typed: true }); }
586
+ while (!answer.custom.trim());
587
+ }
588
+ answers.push(answer);
589
+ current();
590
+ }
591
+ current();
592
+ this.message(answers.map((answer, i) => `${question.questions[i].question}\n${[...answer.selected, answer.custom].filter(Boolean).join('; ')}`).join("\n\n"));
593
+ const action = await this.choose("Review your answers before sending", [
594
+ { label: "Submit these answers", value: "submit" }, { label: "Change answers", value: "edit" },
595
+ { label: "Keep answers for later in this session", value: "later" },
596
+ ]);
597
+ current();
598
+ if (action === "later") return;
599
+ if (action === "submit") break;
600
+ answers.length = 0;
601
+ }
602
+ await this.client.clarificationAnswer(this.run.run_id, question.id, answers);
603
+ this.clarificationDraft = null;
604
+ this.status = "Answer submitted"; this.change();
605
+ }
606
+
607
+ modelChoice() { return this.transition(() => this.selectModel()); }
608
+
609
+ async selectModel() {
610
+ if (!this.account || this.account.model_cost_estimates.catalogue_envelope_sha256 !== CATALOGUE_ENVELOPE_SHA256) {
611
+ throw new CliError("catalogue_outdated", "Model catalogue differs from this CLI; install the origin's current version");
612
+ }
613
+ const options = [{ label: "Auto — Vecteur selects the model", value: null },
614
+ ...PRODUCT_ROUTES.filter(row => row.route_id !== "auto").map(row => ({ label: `${row.label} · ${row.candidates.map(item => item.jurisdiction).join(" → ")}`, value: row.route_id })),
615
+ ...MODEL_CATALOGUE.map(row => ({ label: `${row.label} · ${row.provider} · ${row.data_residency}`, value: row.profile_id }))];
616
+ this.model = await this.choose("Model for the next Run (Vecteur billing)", options); this.change();
617
+ }
618
+
619
+ modeDetails() {
620
+ const requested = this.model ?? "auto";
621
+ const route = PRODUCT_ROUTES.find(row => row.route_id === requested);
622
+ const profile = MODEL_CATALOGUE.find(row => row.profile_id === requested);
623
+ const matched = this.account?.model_cost_estimates.catalogue_envelope_sha256 === CATALOGUE_ENVELOPE_SHA256;
624
+ const destination = !matched ? "Catalogue unavailable or outdated — /login or update this CLI"
625
+ : route ? route.candidates.map(row => `${row.provider_id}/${row.model_id} (${row.jurisdiction})`).join(" → ")
626
+ : profile ? `${profile.provider} (${profile.data_residency})` : "Unknown";
627
+ return `Lead: Laplace\nExecution: Vecteur cloud\nPayer: Project organization ${this.account?.org_id ?? "(sign in first)"}\nNext model: ${this.view().model}\nData destination: ${destination}\nLast Run profile: ${this.run?.profile || "not known"}\nNative Claude/Codex modes and local execution are unavailable in this version.`;
628
+ }
629
+
630
+ statusDetails() {
631
+ return `Vecteur · ${this.offline ? "Offline" : this.account ? "Signed in" : "Not signed in"}\nOrigin: ${this.origin}\nAccount: ${this.account?.email ?? "Unavailable"}\nProject: ${this.view().projectLabel}\nFolder: ${this.cwd}\nBinding: ${this.binding?.folder?.path ?? "None"}\nRole: ${this.project?.role ?? "No Project"}\n${this.modeDetails()}\n${this.status}${this.run ? `\n${this.view().run.elapsed}` : ""}`;
632
+ }
633
+
634
+ async logout() {
635
+ return this.transition(async () => {
636
+ this.requireLogin();
637
+ const yes = await this.choose("Sign out here? Saved drafts stay with this account; a cloud Run keeps running.", [
638
+ { label: "Keep working", value: false }, { label: "Sign out", value: true },
639
+ ]);
640
+ if (!yes) return;
641
+ const client = this.client;
642
+ await client.credentialSelfRevoke();
643
+ if (client !== this.client || this.closed) return;
644
+ clearTimeout(this.draftTimer); this.registry?.saveDraft(this.draft);
645
+ if (!this.env.VECTEUR_TOKEN) clearCredentials(this.env, this.origin);
646
+ this.signedOut = true;
647
+ this.env = { ...this.env }; delete this.env.VECTEUR_TOKEN;
648
+ this.followController?.abort(); this.generation++; this.account = null; this.project = null;
649
+ this.binding = null; this.run = null; this.registry = null; this.draft = ""; this.attachments = [];
650
+ this.workOffline();
651
+ });
652
+ }
653
+
654
+ async download(index, output) {
655
+ this.requireLogin();
656
+ const item = runView(this.run).artifacts[index - 1];
657
+ if (!item) throw new CliError("artifact_unavailable", "Choose an artifact number from /files");
658
+ const result = await downloadArtifact(this.client, item.cas_ref, this.project.id, output ? resolve(this.cwd, output) : undefined);
659
+ this.message(`Downloaded ${result.bytes} verified bytes to ${result.path}`);
660
+ }
661
+
662
+ async exportRun(output) {
663
+ this.requireLogin();
664
+ if (!this.run || this.run.open) throw new CliError("run_unavailable", "Export needs a completed Run");
665
+ const result = await exportRun(this.client, this.run, this.project.id,
666
+ resolve(this.cwd, output ?? `vecteur-run-${this.run.run_id.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 12)}`));
667
+ this.message(`Exported ${result.files.length} files to ${result.directory}\n${result.files.map(file => `${basename(file.path)} · ${file.bytes} bytes${file.cas_ref ? " · verified" : ""}`).join("\n")}`);
668
+ }
669
+
670
+ async copy(output) {
671
+ const answer = runView(this.run).answer;
672
+ if (!answer) throw new CliError("answer_unavailable", "No completed answer to copy");
673
+ if (!output) { this.emit("copy", answer); return; }
674
+ await writeFile(resolve(this.cwd, output), answer, { flag: "wx", mode: 0o600 });
675
+ this.message(`Saved exact answer bytes to ${output}`);
676
+ }
677
+
678
+ close() {
679
+ if (this.closed) return;
680
+ try {
681
+ if (!this.registry && this.draft) this.unsignedDraft({ restore: false });
682
+ if (this.registry) this.registry.saveDraft(this.draft);
683
+ }
684
+ catch (error) { this.message(`Draft could not be saved: ${error.code ?? "store_error"}`, "error"); }
685
+ this.closed = true; this.generation++; this.cancelLogin(); this.lifecycle.abort(); this.followController?.abort(); clearTimeout(this.draftTimer);
686
+ this.rejectPrompt?.(new CliError("session_closed", "Session closed")); this.prompt = null;
687
+ this.emit("close");
688
+ }
689
+ }