@aayambansal/squint 0.4.6 → 0.4.8

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.
@@ -0,0 +1,1428 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ appendDecision,
4
+ enrich
5
+ } from "./chunk-ARDV4XH6.js";
6
+ import {
7
+ applySandbox,
8
+ discardSandbox,
9
+ openSandbox,
10
+ sandboxDiffStat,
11
+ sandboxDir,
12
+ sandboxExists,
13
+ sandboxFiles
14
+ } from "./chunk-AUJJGMZG.js";
15
+ import {
16
+ diffStatSince,
17
+ isGitRepo,
18
+ restoreSnapshot,
19
+ takeSnapshot
20
+ } from "./chunk-YHRAOBI2.js";
21
+ import {
22
+ DevServer,
23
+ buildFixPrompt,
24
+ detectDevCommand,
25
+ screenshotVariants
26
+ } from "./chunk-QCHBDP46.js";
27
+ import {
28
+ applyVariant,
29
+ cleanVariants,
30
+ listVariants,
31
+ runVariants
32
+ } from "./chunk-YGSF2TSO.js";
33
+ import {
34
+ runAgent
35
+ } from "./chunk-VH7OOFQP.js";
36
+ import {
37
+ composePrompt
38
+ } from "./chunk-WAJXATCO.js";
39
+ import {
40
+ buildGatePrompt,
41
+ detectFastGates,
42
+ detectGates,
43
+ runGates
44
+ } from "./chunk-62JNF5M2.js";
45
+ import {
46
+ buildReviewPrompt,
47
+ buildRuntimeFixPrompt,
48
+ captureViewports,
49
+ comparePulseAttributed,
50
+ probeRuntime,
51
+ runtimeSummary
52
+ } from "./chunk-7RFQWOQV.js";
53
+ import {
54
+ clearState,
55
+ loadState,
56
+ saveState
57
+ } from "./chunk-7CAGWFAQ.js";
58
+ import {
59
+ findChrome
60
+ } from "./chunk-IMDRXXFU.js";
61
+ import {
62
+ getEngine
63
+ } from "./chunk-KVYGPLWW.js";
64
+
65
+ // src/session/engine.ts
66
+ import fs2 from "fs";
67
+ import path2 from "path";
68
+
69
+ // src/session/hooks.ts
70
+ import { spawn } from "child_process";
71
+ import fs from "fs";
72
+ import path from "path";
73
+ function runHook(cwd, event, payload) {
74
+ const script = path.join(cwd, ".squint", "hooks", event);
75
+ try {
76
+ fs.accessSync(script, fs.constants.X_OK);
77
+ } catch {
78
+ return false;
79
+ }
80
+ try {
81
+ const child = spawn(script, [], {
82
+ cwd,
83
+ env: { ...process.env, SQUINT_EVENT: event, ...prefixed(payload) },
84
+ stdio: "ignore",
85
+ detached: false
86
+ });
87
+ const timer = setTimeout(() => child.kill("SIGKILL"), 1e4);
88
+ child.on("close", () => clearTimeout(timer));
89
+ child.on("error", () => clearTimeout(timer));
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ function prefixed(payload) {
96
+ const out = {};
97
+ for (const [key, value] of Object.entries(payload)) {
98
+ out[`SQUINT_${key.toUpperCase()}`] = value;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // src/session/engine.ts
104
+ var MAX_AUTO_FIX_ATTEMPTS = 2;
105
+ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
106
+ var Session = class {
107
+ constructor(opts) {
108
+ this.opts = opts;
109
+ this.state = {
110
+ items: [],
111
+ liveText: "",
112
+ running: false,
113
+ runStartedAt: 0,
114
+ engineId: opts.engineId,
115
+ model: opts.model,
116
+ devState: "stopped",
117
+ devUrl: null,
118
+ totals: { costUsd: 0, turns: 0 },
119
+ queue: [],
120
+ mode: "safe",
121
+ problems: [],
122
+ sandbox: false
123
+ };
124
+ if (opts.autoDev && detectDevCommand(opts.cwd)) {
125
+ this.devServer().start();
126
+ }
127
+ const saved = loadState(opts.cwd);
128
+ if (saved) {
129
+ try {
130
+ if (getEngine(saved.engine).supportsResume) {
131
+ const mins = Math.max(1, Math.round((Date.now() - saved.at) / 6e4));
132
+ this.push(
133
+ "status",
134
+ `previous session (${mins}m ago${saved.lastAsk ? ` \xB7 "${saved.lastAsk}"` : ""}) \u2014 /resume to continue`
135
+ );
136
+ }
137
+ } catch {
138
+ }
139
+ }
140
+ }
141
+ opts;
142
+ state;
143
+ listeners = /* @__PURE__ */ new Set();
144
+ nextId = 0;
145
+ live = "";
146
+ sessionId;
147
+ dev = null;
148
+ abort = null;
149
+ /** Full problem records; state carries the summaries. */
150
+ problemPrompts = /* @__PURE__ */ new Map();
151
+ nextProblemId = 0;
152
+ checkpoints = [];
153
+ fixAttempts = 0;
154
+ reviewTipShown = false;
155
+ pendingApproval = null;
156
+ lastPulse = null;
157
+ lastPerf = null;
158
+ autoReviewedThisAsk = false;
159
+ startedAt = Date.now();
160
+ subscribe(listener) {
161
+ this.listeners.add(listener);
162
+ return () => this.listeners.delete(listener);
163
+ }
164
+ getState() {
165
+ return this.state;
166
+ }
167
+ dispose() {
168
+ this.abort?.abort();
169
+ this.dev?.stop();
170
+ }
171
+ interrupt() {
172
+ this.abort?.abort();
173
+ }
174
+ /** Frontend-originated status line (view-level commands like /theme). */
175
+ note(text) {
176
+ this.push("status", text);
177
+ }
178
+ /** Register a finding; a fresh finding from a source supersedes its old one. */
179
+ addProblem(source, summary, prompt) {
180
+ const kept = this.state.problems.filter((p) => p.source !== source);
181
+ for (const gone of this.state.problems) {
182
+ if (gone.source === source) this.problemPrompts.delete(gone.id);
183
+ }
184
+ this.nextProblemId += 1;
185
+ this.problemPrompts.set(this.nextProblemId, prompt);
186
+ this.notify({ problems: [...kept, { id: this.nextProblemId, source, summary }] });
187
+ runHook(this.opts.cwd, "on-problem", { source, summary });
188
+ }
189
+ clearProblems(source) {
190
+ const removed = this.state.problems.filter((p) => p.source === source);
191
+ if (removed.length === 0) return;
192
+ for (const problem of removed) this.problemPrompts.delete(problem.id);
193
+ this.notify({ problems: this.state.problems.filter((p) => p.source !== source) });
194
+ }
195
+ /** One prompt covering the given problems, oldest first. */
196
+ combinedFixPrompt(problems) {
197
+ const sections = problems.map(
198
+ (p) => this.problemPrompts.get(p.id) ?? `Fix this reported problem: ${p.summary}`
199
+ );
200
+ return sections.join("\n\n---\n\n");
201
+ }
202
+ dispatchFix(problems) {
203
+ if (problems.length === 0) return;
204
+ const prompt = this.combinedFixPrompt(problems);
205
+ const fixModel = this.opts.fixModel;
206
+ const display = `\u26D1 fix: ${problems.map((p) => p.source).join(" + ")}${fixModel ? ` \xB7 ${fixModel}` : ""}`;
207
+ for (const problem of problems) this.problemPrompts.delete(problem.id);
208
+ this.notify({ problems: this.state.problems.filter((p) => !problems.includes(p)) });
209
+ void this.runTurn(prompt, display, fixModel);
210
+ }
211
+ /** Launch a capped auto-fix turn over all open problems. Returns true if launched. */
212
+ maybeAutoFix() {
213
+ if (!this.opts.autoFix || this.fixAttempts >= MAX_AUTO_FIX_ATTEMPTS) return false;
214
+ if (this.state.problems.length === 0) return false;
215
+ this.fixAttempts += 1;
216
+ this.push("status", `auto-fix attempt ${this.fixAttempts}/${MAX_AUTO_FIX_ATTEMPTS}`);
217
+ this.notify({ running: false });
218
+ this.dispatchFix([...this.state.problems]);
219
+ return true;
220
+ }
221
+ /**
222
+ * A side question: read-only, no session resume, so the main thread's
223
+ * context is untouched (Cursor's /btw). Costs count; loops don't run.
224
+ */
225
+ async btw(question) {
226
+ this.push("user", `\u{1F4AC} btw: ${question}`);
227
+ this.notify({ running: true, runStartedAt: Date.now() });
228
+ const engine = getEngine(this.state.engineId);
229
+ this.abort = new AbortController();
230
+ const result = await runAgent(
231
+ engine,
232
+ {
233
+ prompt: `Answer this question about the repository. Investigate as needed but make no changes:
234
+
235
+ ${question}`,
236
+ cwd: this.execCwd(),
237
+ model: this.state.model,
238
+ mode: "plan"
239
+ },
240
+ this.handleEvent,
241
+ this.abort.signal
242
+ );
243
+ this.abort = null;
244
+ this.commitLive();
245
+ this.flushToolCollapse();
246
+ if (result.ok) {
247
+ const cost = result.costUsd !== void 0 ? ` \xB7 $${result.costUsd.toFixed(2)}` : "";
248
+ this.push("status", `btw answered${cost}`);
249
+ this.notify({
250
+ totals: { costUsd: this.state.totals.costUsd + (result.costUsd ?? 0), turns: this.state.totals.turns }
251
+ });
252
+ }
253
+ this.notify({ running: false });
254
+ this.drainQueue();
255
+ }
256
+ /**
257
+ * Unattended polish: n rounds of screenshot-review-fix. Each round is
258
+ * a full review turn (its own cost); the per-ask auto-fix cap resets
259
+ * per round so fixes still flow. Fire it and step away.
260
+ */
261
+ async polish(rounds) {
262
+ for (let round = 1; round <= rounds; round++) {
263
+ this.fixAttempts = 0;
264
+ const result = await this.capture();
265
+ if (!result) {
266
+ this.push("status", `polish stopped at round ${round}: nothing to capture`);
267
+ return;
268
+ }
269
+ await this.runTurn(
270
+ buildReviewPrompt(result.shots, void 0, result.runtime, result.a11y, result.slop, result.narration, result.phantoms, result.viewTransitions, result.components, result.webmcp, result.jank),
271
+ `\u{1F441} polish round ${round}/${rounds}`
272
+ );
273
+ }
274
+ this.push("status", `polish complete \u2014 ${rounds} round${rounds === 1 ? "" : "s"} of review and fixes`);
275
+ }
276
+ setMode(mode) {
277
+ this.notify({ mode });
278
+ const hint = mode === "plan" ? "read-only: the engine investigates and proposes, edits nothing" : mode === "yolo" ? "no approval friction \u2014 the engine can do anything" : "edits auto-approved inside the workspace";
279
+ this.push("status", `mode \u2192 ${mode} \xB7 ${hint}`);
280
+ }
281
+ cycleMode() {
282
+ const order = ["safe", "plan", "yolo"];
283
+ const next = order[(order.indexOf(this.state.mode) + 1) % order.length];
284
+ this.setMode(next);
285
+ }
286
+ /** One-line goodbye: what this session amounted to. */
287
+ summary() {
288
+ const mins = Math.max(1, Math.round((Date.now() - this.startedAt) / 6e4));
289
+ const { turns, costUsd } = this.state.totals;
290
+ const parts = [`${turns} turn${turns === 1 ? "" : "s"}`];
291
+ if (costUsd > 0) parts.push(`$${costUsd.toFixed(2)}`);
292
+ parts.push(`${mins}m`);
293
+ return `session: ${parts.join(" \xB7 ")}`;
294
+ }
295
+ /**
296
+ * Route one line of user input: slash command or an ask for the engine.
297
+ * Asks arriving mid-turn queue up and dispatch in order once the
298
+ * current turn (including its fix cycle) settles.
299
+ */
300
+ input(raw) {
301
+ const value = raw.trim();
302
+ if (value.length === 0) return;
303
+ if (this.state.running) {
304
+ if (value === "/queue clear") {
305
+ this.notify({ queue: [] });
306
+ this.push("status", "queue cleared");
307
+ return;
308
+ }
309
+ const drop = /^\/queue drop (\d+)$/.exec(value);
310
+ if (drop) {
311
+ const index = Number.parseInt(drop[1], 10) - 1;
312
+ if (index >= 0 && index < this.state.queue.length) {
313
+ const removed = this.state.queue[index];
314
+ this.notify({ queue: this.state.queue.filter((_, i) => i !== index) });
315
+ this.push("status", `dropped from queue: ${removed}`);
316
+ } else {
317
+ this.push("status", `queue has ${this.state.queue.length} item(s) \u2014 /queue drop <1-${Math.max(this.state.queue.length, 1)}>`);
318
+ }
319
+ return;
320
+ }
321
+ this.notify({ queue: [...this.state.queue, value] });
322
+ return;
323
+ }
324
+ if (value.startsWith("/")) {
325
+ this.command(value);
326
+ } else {
327
+ void this.submit(value);
328
+ }
329
+ }
330
+ /** Dispatch queued input after the current work settles. */
331
+ drainQueue() {
332
+ if (this.state.running) return;
333
+ const [next, ...rest] = this.state.queue;
334
+ if (next === void 0) return;
335
+ this.notify({ queue: rest });
336
+ this.input(next);
337
+ }
338
+ // ---------- internals ----------
339
+ notify(patch) {
340
+ this.state = { ...this.state, ...patch };
341
+ for (const listener of this.listeners) listener();
342
+ }
343
+ push(role, text) {
344
+ this.nextId += 1;
345
+ this.notify({ items: [...this.state.items, { id: this.nextId, role, text }] });
346
+ }
347
+ setLive(text) {
348
+ this.live = text;
349
+ this.notify({ liveText: text });
350
+ }
351
+ /**
352
+ * Static transcript items are immutable once rendered, so in-progress
353
+ * assistant text accumulates in a live buffer and commits as one block.
354
+ */
355
+ commitLive() {
356
+ if (this.live.length > 0) {
357
+ const text = this.live;
358
+ this.live = "";
359
+ this.state = { ...this.state, liveText: "" };
360
+ this.push("assistant", text);
361
+ }
362
+ }
363
+ /** Where engines run and servers start: the sandbox worktree when on. */
364
+ execCwd() {
365
+ return this.state.sandbox ? sandboxDir(this.opts.cwd) : this.opts.cwd;
366
+ }
367
+ devServer() {
368
+ if (!this.dev) {
369
+ this.dev = new DevServer(this.execCwd(), {
370
+ onStateChange: (devState) => this.notify({ devState }),
371
+ onUrl: (url) => this.notify({ devUrl: url })
372
+ });
373
+ }
374
+ return this.dev;
375
+ }
376
+ /** Rebind the dev server after the execution tree changes. */
377
+ resetDevServer() {
378
+ const wasRunning = this.dev && this.dev.state !== "stopped";
379
+ this.dev?.stop();
380
+ this.dev = null;
381
+ this.notify({ devUrl: null, devState: "stopped" });
382
+ if (wasRunning) {
383
+ const command = detectDevCommand(this.execCwd());
384
+ if (command) {
385
+ this.devServer().start(command);
386
+ this.push("status", `dev server restarting in ${this.state.sandbox ? "the sandbox" : "the real tree"}`);
387
+ }
388
+ }
389
+ }
390
+ turnEdits = 0;
391
+ turnTools = 0;
392
+ toolStreak = 0;
393
+ collapsedTools = 0;
394
+ /**
395
+ * Long tool cascades collapse: the first three of a consecutive burst
396
+ * render, the rest fold into one "+N more" line pushed when the burst
397
+ * ends — append-only, so the Static transcript stays valid.
398
+ */
399
+ flushToolCollapse() {
400
+ if (this.collapsedTools > 0) {
401
+ this.push("tool", `+${this.collapsedTools} more tool call${this.collapsedTools === 1 ? "" : "s"}`);
402
+ this.collapsedTools = 0;
403
+ }
404
+ this.toolStreak = 0;
405
+ }
406
+ handleEvent = (event) => {
407
+ switch (event.type) {
408
+ case "status":
409
+ this.commitLive();
410
+ this.flushToolCollapse();
411
+ this.push("status", event.text);
412
+ break;
413
+ case "delta":
414
+ this.setLive(this.live + event.text);
415
+ break;
416
+ case "text":
417
+ this.flushToolCollapse();
418
+ if (event.streamed) {
419
+ this.live = "";
420
+ this.state = { ...this.state, liveText: "" };
421
+ this.push("assistant", event.text);
422
+ } else {
423
+ this.setLive(this.live + (this.live.length > 0 ? "\n" : "") + event.text);
424
+ }
425
+ break;
426
+ case "thinking":
427
+ this.commitLive();
428
+ this.flushToolCollapse();
429
+ this.push("thinking", event.text);
430
+ break;
431
+ case "tool": {
432
+ this.commitLive();
433
+ this.turnTools += 1;
434
+ this.toolStreak += 1;
435
+ if (/edit|write|patch|apply/i.test(event.name)) this.turnEdits += 1;
436
+ if (this.toolStreak <= 3) {
437
+ this.push("tool", event.detail ? `${event.name} \xB7 ${event.detail}` : event.name);
438
+ } else {
439
+ this.collapsedTools += 1;
440
+ }
441
+ break;
442
+ }
443
+ case "error":
444
+ this.commitLive();
445
+ this.flushToolCollapse();
446
+ this.push("error", event.text);
447
+ break;
448
+ case "result":
449
+ if (event.sessionId) this.sessionId = event.sessionId;
450
+ break;
451
+ case "raw":
452
+ break;
453
+ }
454
+ };
455
+ /** Run one engine turn. `display` is what the transcript shows as the ask. */
456
+ async runTurn(prompt, display, modelOverride) {
457
+ this.push("user", display);
458
+ this.turnEdits = 0;
459
+ this.turnTools = 0;
460
+ this.notify({ running: true, runStartedAt: Date.now() });
461
+ const runStart = Date.now();
462
+ const engine = getEngine(this.state.engineId);
463
+ this.abort = new AbortController();
464
+ const result = await runAgent(
465
+ engine,
466
+ {
467
+ prompt,
468
+ cwd: this.execCwd(),
469
+ model: modelOverride ?? this.state.model,
470
+ mode: this.state.mode,
471
+ sessionId: engine.supportsResume ? this.sessionId : void 0
472
+ },
473
+ this.handleEvent,
474
+ this.abort.signal
475
+ );
476
+ this.abort = null;
477
+ this.commitLive();
478
+ this.flushToolCollapse();
479
+ if (result.ok) {
480
+ const cost = result.costUsd !== void 0 ? ` \xB7 $${result.costUsd.toFixed(2)}` : "";
481
+ const secs = result.durationMs !== void 0 ? ` \xB7 ${(result.durationMs / 1e3).toFixed(0)}s` : "";
482
+ const checkpoint = this.checkpoints.at(-1);
483
+ const stat = checkpoint ? diffStatSince(this.opts.cwd, checkpoint.snapshot) : null;
484
+ const work = stat ? ` \xB7 ${stat}` : this.turnEdits > 0 ? ` \xB7 ${this.turnEdits} edit${this.turnEdits === 1 ? "" : "s"}` : this.turnTools > 0 ? ` \xB7 ${this.turnTools} tool call${this.turnTools === 1 ? "" : "s"}` : "";
485
+ this.push("status", `done${secs}${cost}${work}`);
486
+ try {
487
+ const reqPath = path2.join(this.opts.cwd, ".squint", "approval-request.json");
488
+ if (fs2.existsSync(reqPath)) {
489
+ const req = JSON.parse(fs2.readFileSync(reqPath, "utf8"));
490
+ fs2.rmSync(reqPath, { force: true });
491
+ if (typeof req?.summary === "string" && req.summary.length > 0) {
492
+ this.pendingApproval = req.summary;
493
+ const shot = typeof req.screenshot === "string" ? path2.resolve(this.execCwd(), req.screenshot) : null;
494
+ if (shot && fs2.existsSync(shot)) this.push("image", shot);
495
+ this.push("status", `\u23F8 approval requested: ${req.summary}
496
+ /yes approves \xB7 /no rejects \xB7 or type feedback`);
497
+ }
498
+ }
499
+ } catch {
500
+ }
501
+ if (checkpoint) {
502
+ try {
503
+ const { driftSummary, loadTokenIndex, scanDrift } = await import("./tokens-XYGRRAXC.js");
504
+ const index = loadTokenIndex(this.execCwd());
505
+ const drift = scanDrift(this.execCwd(), checkpoint.snapshot.stashHash ?? "HEAD", index);
506
+ if (drift.length > 0) {
507
+ this.push("status", `token drift: ${drift.length} hardcoded color(s)
508
+ ${driftSummary(drift)}`);
509
+ }
510
+ } catch {
511
+ }
512
+ try {
513
+ const { scanEvasion, sentinelSummary } = await import("./sentinel-42NZRTTS.js");
514
+ const evasions = scanEvasion(this.execCwd(), checkpoint.snapshot.stashHash ?? "HEAD");
515
+ if (evasions.length > 0) {
516
+ this.push("error", `\u26A0 sentinel: ${evasions.length} gate-evasion pattern(s) this turn
517
+ ${sentinelSummary(evasions)}
518
+ Review before trusting green \u2014 /undo rolls the turn back.`);
519
+ runHook(this.opts.cwd, "on-sentinel", { count: String(evasions.length), summary: sentinelSummary(evasions) });
520
+ }
521
+ } catch {
522
+ }
523
+ try {
524
+ const { rulePackSummary, scanRulePacks } = await import("./rulepacks-JOA675OJ.js");
525
+ const findings = scanRulePacks(this.execCwd(), checkpoint.snapshot.stashHash ?? "HEAD");
526
+ const hard = findings.filter((f) => f.hard);
527
+ if (hard.length > 0) {
528
+ this.addProblem(
529
+ "gates",
530
+ `${hard.length} class(es)/idiom(s) from an older toolchain major
531
+ ${rulePackSummary(hard)}`,
532
+ `This project's toolchain is newer than the patterns just written. Apply the exact renames below \u2014 do not downgrade dependencies or add compatibility configs:
533
+ ${rulePackSummary(hard)}`
534
+ );
535
+ }
536
+ const soft = findings.filter((f) => !f.hard);
537
+ if (soft.length > 0) {
538
+ this.push("status", `renamed-scale traps (verify intent):
539
+ ${rulePackSummary(soft)}`);
540
+ }
541
+ } catch {
542
+ }
543
+ }
544
+ runHook(this.opts.cwd, "on-turn-end", {
545
+ cost: String(result.costUsd ?? 0),
546
+ duration_ms: String(result.durationMs ?? 0),
547
+ stat: stat ?? ""
548
+ });
549
+ const before = this.state.totals.costUsd;
550
+ this.notify({
551
+ totals: {
552
+ costUsd: before + (result.costUsd ?? 0),
553
+ turns: this.state.totals.turns + 1
554
+ }
555
+ });
556
+ const budget = this.opts.budgetUsd;
557
+ if (budget && before < budget && this.state.totals.costUsd >= budget) {
558
+ this.push(
559
+ "error",
560
+ `session cost $${this.state.totals.costUsd.toFixed(2)} crossed your $${budget.toFixed(2)} budget \u2014 squint keeps working, this is just the flag you asked for`
561
+ );
562
+ runHook(this.opts.cwd, "on-budget", { total: this.state.totals.costUsd.toFixed(2), budget: budget.toFixed(2) });
563
+ }
564
+ if (this.sessionId) {
565
+ saveState(this.opts.cwd, {
566
+ engine: this.state.engineId,
567
+ sessionId: this.sessionId,
568
+ model: this.state.model,
569
+ lastAsk: display.length > 80 ? `${display.slice(0, 79)}\u2026` : display,
570
+ totals: this.state.totals,
571
+ at: Date.now()
572
+ });
573
+ }
574
+ }
575
+ if (result.ok && this.opts.autoCheck !== false) {
576
+ const fastGates = detectFastGates(this.execCwd());
577
+ if (fastGates.length > 0) {
578
+ const gateResults = await runGates(this.execCwd(), fastGates);
579
+ const failures = gateResults.filter((r) => !r.ok);
580
+ if (failures.length > 0) {
581
+ this.addProblem(
582
+ "gates",
583
+ failures.map((f) => f.gate.id).join(" + "),
584
+ buildGatePrompt(failures)
585
+ );
586
+ this.push(
587
+ "error",
588
+ failures.map((f) => `\u2717 ${f.gate.id} \xB7 ${f.outputTail.split("\n").slice(-3).join("\n")}`).join("\n")
589
+ );
590
+ if (this.maybeAutoFix()) return;
591
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
592
+ this.notify({ running: false });
593
+ this.drainQueue();
594
+ return;
595
+ }
596
+ this.clearProblems("gates");
597
+ }
598
+ }
599
+ const dev = this.dev;
600
+ if (result.error !== "interrupted" && dev && (dev.state === "running" || dev.state === "starting")) {
601
+ await delay(1500);
602
+ const errors = dev.errorsSince(runStart);
603
+ if (this.state.devUrl) {
604
+ try {
605
+ const { hasNextMcp, probeNextMcp } = await import("./nextMcp-42Y63M7W.js");
606
+ if (hasNextMcp(this.execCwd())) {
607
+ const mcp = await probeNextMcp(this.state.devUrl);
608
+ if (mcp.available && mcp.errors.length > 0) {
609
+ for (const err of mcp.errors) errors.push(`[next mcp] ${err}`);
610
+ }
611
+ }
612
+ } catch {
613
+ }
614
+ }
615
+ if (errors.length > 0) {
616
+ this.addProblem(
617
+ "dev",
618
+ `${errors.length} dev server error line(s)`,
619
+ buildFixPrompt(errors, dev.tail(30))
620
+ );
621
+ this.push("error", `dev server: ${errors.length} error line(s)
622
+ ${errors.slice(-5).join("\n")}`);
623
+ if (this.maybeAutoFix()) return;
624
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
625
+ } else {
626
+ this.clearProblems("dev");
627
+ if (this.opts.autoProbe !== false && this.state.devUrl) {
628
+ const probe = await probeRuntime(this.state.devUrl, this.opts.cwd);
629
+ if (probe?.checkFailures && probe.checkFailures.length > 0) {
630
+ this.addProblem(
631
+ "check",
632
+ `${probe.checkFailures.length} persistent check failure(s)`,
633
+ `Repo checks in .squint/checks/ failed against the live page. Each line is <check>: <failure>:
634
+
635
+ ${probe.checkFailures.join("\n")}
636
+
637
+ Fix the page (or, only if the assertion itself is genuinely outdated, update that check file and say so).`
638
+ );
639
+ this.push("error", `checks: ${probe.checkFailures.length} failure(s)
640
+ ${probe.checkFailures.slice(0, 5).join("\n")}`);
641
+ } else {
642
+ this.clearProblems("check");
643
+ }
644
+ const summary = probe ? runtimeSummary(probe.report) : null;
645
+ if (probe && summary) {
646
+ this.addProblem("runtime", summary, buildRuntimeFixPrompt(probe.report));
647
+ this.push("error", `runtime: ${summary}`);
648
+ if (this.maybeAutoFix()) return;
649
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
650
+ } else if (probe) {
651
+ this.clearProblems("runtime");
652
+ this.perfPulse(probe.perf);
653
+ const pct = await this.visualPulse(probe.pulsePath);
654
+ if (this.opts.autoReview && pct !== null && pct >= 10 && !this.autoReviewedThisAsk) {
655
+ this.autoReviewedThisAsk = true;
656
+ this.push("status", `auto-review: the page changed ${pct.toFixed(0)}% \u2014 capturing for self-critique`);
657
+ this.notify({ running: false });
658
+ const captureResult = await this.capture();
659
+ if (captureResult) {
660
+ await this.runTurn(
661
+ buildReviewPrompt(captureResult.shots, void 0, captureResult.runtime, captureResult.a11y, captureResult.slop, captureResult.narration, captureResult.phantoms, captureResult.viewTransitions, captureResult.components, captureResult.webmcp, captureResult.jank),
662
+ "\u{1F441} auto-review rendered UI"
663
+ );
664
+ }
665
+ return;
666
+ }
667
+ }
668
+ }
669
+ if (!this.reviewTipShown && this.state.devUrl) {
670
+ this.reviewTipShown = true;
671
+ this.push("status", "tip: /review screenshots the app and has the engine critique its own work");
672
+ }
673
+ }
674
+ }
675
+ this.notify({ running: false });
676
+ this.drainQueue();
677
+ }
678
+ async submit(ask) {
679
+ this.fixAttempts = 0;
680
+ this.autoReviewedThisAsk = false;
681
+ const snapshot = this.state.sandbox ? null : takeSnapshot(this.opts.cwd);
682
+ if (snapshot) {
683
+ this.checkpoints.push({
684
+ snapshot,
685
+ label: ask.length > 60 ? `${ask.slice(0, 59)}\u2026` : ask,
686
+ at: Date.now()
687
+ });
688
+ if (this.checkpoints.length > 20) this.checkpoints.shift();
689
+ }
690
+ const isFirstTurn = this.sessionId === void 0;
691
+ let prompt = isFirstTurn ? composePrompt(ask, { cwd: this.opts.cwd, firstTurn: true }) : ask;
692
+ const enrichment = enrich(this.opts.cwd, ask);
693
+ if (enrichment.matchedSkills.length > 0) {
694
+ this.push("status", `skills: ${enrichment.matchedSkills.join(", ")}`);
695
+ }
696
+ prompt += enrichment.sections;
697
+ await this.runTurn(prompt, ask);
698
+ }
699
+ /** Restore files to the state before checkpoint `index`; drop it and everything after. */
700
+ restoreTo(index) {
701
+ const checkpoint = this.checkpoints[index];
702
+ if (!checkpoint) {
703
+ this.push("status", "no such checkpoint \u2014 /checkpoints lists them");
704
+ return;
705
+ }
706
+ const result = restoreSnapshot(this.opts.cwd, checkpoint.snapshot);
707
+ if (result.restored) {
708
+ const dropped = this.checkpoints.length - index;
709
+ appendDecision(this.opts.cwd, { decision: `rejected and rolled back: "${checkpoint.label}"`, source: "restore" });
710
+ this.checkpoints = this.checkpoints.slice(0, index);
711
+ this.push(
712
+ "status",
713
+ `restored files to before "${checkpoint.label}"${dropped > 1 ? ` \xB7 ${dropped} asks rolled back` : ""}${result.deletedFiles > 0 ? ` \xB7 removed ${result.deletedFiles} created file(s)` : ""} \u2014 the conversation continues from here`
714
+ );
715
+ } else {
716
+ this.push("error", `restore failed: ${result.detail ?? "unknown error"}`);
717
+ }
718
+ }
719
+ /** Cross-turn load-performance deltas: the perf twin of the visual pulse. */
720
+ perfPulse(perf) {
721
+ if (!perf || perf.lcpMs === void 0 && perf.transferBytes === void 0) return;
722
+ const previous = this.lastPerf;
723
+ this.lastPerf = perf;
724
+ const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;
725
+ const parts = [];
726
+ if (perf.lcpMs !== void 0) {
727
+ const delta = previous?.lcpMs !== void 0 && Math.abs(perf.lcpMs - previous.lcpMs) >= 100 ? ` (${perf.lcpMs > previous.lcpMs ? "+" : "\u2212"}${Math.abs(perf.lcpMs - previous.lcpMs)}ms)` : "";
728
+ parts.push(`LCP ${perf.lcpMs}ms${delta}`);
729
+ }
730
+ if (perf.cls !== void 0 && perf.cls > 0.05) parts.push(`CLS ${perf.cls}`);
731
+ if (perf.transferBytes !== void 0) {
732
+ const delta = previous?.transferBytes !== void 0 && Math.abs(perf.transferBytes - previous.transferBytes) > 100 * 1024 ? ` (${perf.transferBytes > previous.transferBytes ? "+" : "\u2212"}${mb(Math.abs(perf.transferBytes - previous.transferBytes))})` : "";
733
+ parts.push(`${mb(perf.transferBytes)}${delta}`);
734
+ }
735
+ if (perf.requests !== void 0) parts.push(`${perf.requests} req`);
736
+ if (parts.length > 0) this.push("status", `perf: ${parts.join(" \xB7 ")}`);
737
+ }
738
+ /**
739
+ * Cross-turn visual drift check: compare this turn's pulse screenshot
740
+ * with the previous one and report how much of the page changed.
741
+ * Informational — changes are usually intended; surprises shouldn't be.
742
+ */
743
+ async visualPulse(pulsePath) {
744
+ if (!pulsePath) return null;
745
+ let current;
746
+ try {
747
+ current = (await import("fs")).readFileSync(pulsePath);
748
+ } catch {
749
+ return null;
750
+ }
751
+ const previous = this.lastPulse;
752
+ this.lastPulse = current;
753
+ if (!previous) {
754
+ this.push("status", "visual pulse: baseline captured");
755
+ this.push("image", pulsePath);
756
+ return null;
757
+ }
758
+ const diff = await comparePulseAttributed(previous, current, this.state.devUrl ?? void 0);
759
+ if (diff === null) return null;
760
+ const pct = diff.pct;
761
+ this.push(
762
+ "status",
763
+ pct < 0.5 ? "visual pulse: stable vs last turn" : `visual pulse: ${pct.toFixed(1)}% of the page changed vs last turn${diff.sentences.length > 0 ? `
764
+ ${diff.sentences.map((s) => ` ${s}`).join("\n")}` : ""}`
765
+ );
766
+ runHook(this.opts.cwd, "on-pulse-diff", { pct: pct.toFixed(1) });
767
+ if (pct >= 0.5) this.push("image", pulsePath);
768
+ return pct;
769
+ }
770
+ /** Screenshot the running app (and watch its runtime where CDP is available). */
771
+ async capture(urlOverride) {
772
+ const url = urlOverride || this.state.devUrl;
773
+ if (!url) {
774
+ this.push("error", "dev server not running \u2014 /dev first, or /shot <url>");
775
+ return null;
776
+ }
777
+ this.push("status", "capturing screenshots\u2026");
778
+ const result = await captureViewports(this.opts.cwd, url);
779
+ if (!result) {
780
+ this.push("error", "no Chrome/Chromium found for screenshots");
781
+ return null;
782
+ }
783
+ for (const err of result.errors) this.push("error", `screenshot ${err}`);
784
+ if (result.shots.length > 0) {
785
+ this.push(
786
+ "status",
787
+ `captured ${result.shots.map((s) => s.name).join(", ")} \u2192 ${path2.dirname(result.shots[0].path)}`
788
+ );
789
+ const desktop = result.shots.find((s) => s.name === "desktop") ?? result.shots[0];
790
+ this.push("image", desktop.path);
791
+ }
792
+ if (result.runtime) {
793
+ const summary = runtimeSummary(result.runtime);
794
+ if (summary) {
795
+ this.push("error", `runtime: ${summary}`);
796
+ this.addProblem("runtime", summary, buildRuntimeFixPrompt(result.runtime));
797
+ this.push("status", "/fix sends open problems to the engine");
798
+ } else {
799
+ this.clearProblems("runtime");
800
+ this.push("status", "runtime clean \u2014 no console errors, exceptions, or failed requests");
801
+ }
802
+ }
803
+ const vtHard = (result.viewTransitions ?? []).filter((f) => f.startsWith("duplicate"));
804
+ if (vtHard.length > 0) {
805
+ this.push("error", `view transitions: ${vtHard.length} broken
806
+ ${vtHard.join("\n")}`);
807
+ this.addProblem(
808
+ "runtime",
809
+ `${vtHard.length} view transition(s) the browser will skip`,
810
+ `Duplicate view-transition-name values make the browser abort the entire transition at runtime:
811
+
812
+ ${vtHard.join("\n")}
813
+
814
+ Give each simultaneously rendered element a unique name (or scope names per item, e.g. per list key). Do not remove the transitions.`
815
+ );
816
+ }
817
+ const vtSoft = (result.viewTransitions ?? []).filter((f) => !f.startsWith("duplicate"));
818
+ if (vtSoft.length > 0) {
819
+ this.push("status", `view transitions: ${vtSoft.join("; ")}`);
820
+ }
821
+ if (result.phantoms && result.phantoms.length > 0) {
822
+ this.push("error", `phantom classes: ${result.phantoms.length} (in the DOM, absent from CSS)
823
+ ${result.phantoms.slice(0, 5).join("\n")}`);
824
+ this.addProblem(
825
+ "runtime",
826
+ `${result.phantoms.length} phantom class(es) \u2014 elements silently unstyled`,
827
+ `These class tokens appear in the DOM but match no CSS rule, so the elements are silently unstyled. Usually a misspelled or version-mismatched utility (e.g. Tailwind v3 spellings in a v4 project) or a concatenated class the scanner never compiled:
828
+
829
+ ${result.phantoms.join("\n")}
830
+
831
+ Fix the class names or define the styles, then confirm visually.`
832
+ );
833
+ this.push("status", "/fix sends open problems to the engine");
834
+ }
835
+ if (result.slop && result.slop.length > 0) {
836
+ this.push("status", `distinctiveness: ${result.slop.length} tell(s)
837
+ ${result.slop.join("\n")}`);
838
+ }
839
+ if (result.a11y && result.a11y.length > 0) {
840
+ this.push("error", `a11y: ${result.a11y.length} finding(s)
841
+ ${result.a11y.slice(0, 5).join("\n")}`);
842
+ this.addProblem(
843
+ "a11y",
844
+ `${result.a11y.length} accessibility finding(s)`,
845
+ `Fix these accessibility defects found by an automated sweep of the running app:
846
+
847
+ ${result.a11y.join("\n")}
848
+
849
+ They are objective defects, not style preferences.`
850
+ );
851
+ this.push("status", "/review folds these into the fix pass \xB7 /fix sends them directly");
852
+ } else if (result.runtime) {
853
+ this.clearProblems("a11y");
854
+ }
855
+ return result.shots.length > 0 ? result : null;
856
+ }
857
+ command(commandLine) {
858
+ const [name, ...rest] = commandLine.slice(1).split(/\s+/);
859
+ const arg = rest.join(" ").trim();
860
+ switch (name) {
861
+ case "engines": {
862
+ void import("./registry-MIJ6LSAY.js").then(({ detectEngines }) => {
863
+ const lines = detectEngines().map(({ engine, path: binaryPath }) => {
864
+ const mark = binaryPath ? "\u2713" : "\u2717";
865
+ const traits = [engine.createParser ? "stream" : "text", engine.supportsResume ? "resume" : null].filter(Boolean).join(" \xB7 ");
866
+ return `${mark} ${engine.id} \u2014 ${traits}${binaryPath ? "" : ` \xB7 install: ${engine.install}`}`;
867
+ });
868
+ this.push("status", `${lines.join("\n")}
869
+ /engine <id> switches (new session)`);
870
+ });
871
+ break;
872
+ }
873
+ case "engine":
874
+ if (!arg) {
875
+ this.command("/engines");
876
+ } else {
877
+ try {
878
+ getEngine(arg);
879
+ this.sessionId = void 0;
880
+ this.notify({ engineId: arg });
881
+ this.push("status", `engine \u2192 ${arg} (new session)`);
882
+ } catch (err) {
883
+ this.push("error", err instanceof Error ? err.message : String(err));
884
+ }
885
+ }
886
+ break;
887
+ case "model":
888
+ this.notify({ model: arg || void 0 });
889
+ this.push("status", arg ? `model \u2192 ${arg}` : "model \u2192 engine default");
890
+ break;
891
+ case "mode":
892
+ if (arg === "plan" || arg === "safe" || arg === "yolo") {
893
+ this.setMode(arg);
894
+ } else {
895
+ this.push("status", "usage: /mode plan|safe|yolo \u2014 or shift+tab to cycle");
896
+ }
897
+ break;
898
+ case "dev": {
899
+ const dev = this.devServer();
900
+ if (arg === "logs") {
901
+ const tail = dev.tail(15);
902
+ this.push("status", tail.length > 0 ? tail.join("\n") : "no dev server output captured yet");
903
+ break;
904
+ }
905
+ if (arg === "restart") {
906
+ if (dev.state !== "stopped") dev.stop();
907
+ this.notify({ devUrl: null });
908
+ const devCommand = detectDevCommand(this.opts.cwd);
909
+ if (!devCommand) {
910
+ this.push("error", "no dev/start script found in package.json");
911
+ } else {
912
+ dev.start(devCommand);
913
+ this.push("status", `dev server restarting \xB7 ${devCommand.display}`);
914
+ }
915
+ break;
916
+ }
917
+ if (dev.state === "stopped" || dev.state === "crashed") {
918
+ const devCommand = detectDevCommand(this.opts.cwd);
919
+ if (!devCommand) {
920
+ this.push("error", "no dev/start script found in package.json");
921
+ } else {
922
+ dev.start(devCommand);
923
+ this.push("status", `dev server starting \xB7 ${devCommand.display}`);
924
+ }
925
+ } else {
926
+ dev.stop();
927
+ this.notify({ devUrl: null });
928
+ this.push("status", "dev server stopped");
929
+ }
930
+ break;
931
+ }
932
+ case "fix": {
933
+ if (this.state.problems.length === 0) {
934
+ this.push("status", "nothing to fix \u2014 no open problems");
935
+ break;
936
+ }
937
+ const index = Number.parseInt(arg, 10);
938
+ if (arg && Number.isInteger(index)) {
939
+ const target = this.state.problems[index - 1];
940
+ if (!target) {
941
+ this.push("status", `usage: /fix [1\u2013${this.state.problems.length}] \u2014 see /problems`);
942
+ break;
943
+ }
944
+ this.dispatchFix([target]);
945
+ } else {
946
+ this.dispatchFix([...this.state.problems]);
947
+ }
948
+ break;
949
+ }
950
+ case "context": {
951
+ import("./contextDoctor-A26C2ZDN.js").then(({ contextReport, formatContextReport }) => {
952
+ this.push("status", formatContextReport(contextReport(this.execCwd())));
953
+ }).catch((error) => {
954
+ this.push("status", `context report failed: ${error instanceof Error ? error.message : String(error)}`);
955
+ });
956
+ break;
957
+ }
958
+ case "yes":
959
+ case "no": {
960
+ if (!this.pendingApproval) {
961
+ this.push("status", `nothing awaiting approval \u2014 /${name} answers an engine's approval request`);
962
+ break;
963
+ }
964
+ const summary = this.pendingApproval;
965
+ this.pendingApproval = null;
966
+ const approved = name === "yes";
967
+ appendDecision(this.opts.cwd, {
968
+ decision: `${approved ? "approved" : "rejected"}: ${summary}${arg ? ` \u2014 ${arg}` : ""}`,
969
+ source: "approval"
970
+ });
971
+ void this.runTurn(
972
+ approved ? `Approved: "${summary}". Proceed.${arg ? ` Note from the user: ${arg}` : ""}` : `Rejected: "${summary}". Do not proceed with it.${arg ? ` The user says: ${arg}` : " Stop and await direction."}`,
973
+ approved ? `\u2713 approved${arg ? ` \u2014 ${arg}` : ""}` : `\u2717 rejected${arg ? ` \u2014 ${arg}` : ""}`
974
+ );
975
+ break;
976
+ }
977
+ case "decide": {
978
+ if (!arg) {
979
+ this.push("status", "usage: /decide <the decision> \u2014 recorded in .squint/design-log.jsonl and injected into every future ask");
980
+ break;
981
+ }
982
+ appendDecision(this.opts.cwd, {
983
+ decision: arg,
984
+ source: "decide",
985
+ screenshot: this.lastPulse ? ".squint/preview/pulse.png" : void 0
986
+ });
987
+ this.push("status", `decision recorded: ${arg}`);
988
+ break;
989
+ }
990
+ case "find": {
991
+ if (!arg) {
992
+ this.push("status", "usage: /find <term> \u2014 searches this session and saved transcripts");
993
+ break;
994
+ }
995
+ const needle = arg.toLowerCase();
996
+ const matches = [];
997
+ for (const item of this.state.items) {
998
+ if ((item.role === "user" || item.role === "assistant") && item.text.toLowerCase().includes(needle)) {
999
+ const line = item.text.split("\n").find((l) => l.toLowerCase().includes(needle)) ?? item.text;
1000
+ matches.push(`[live] ${item.role === "user" ? "\u276F " : ""}${line.trim().slice(0, 90)}`);
1001
+ if (matches.length >= 8) break;
1002
+ }
1003
+ }
1004
+ if (matches.length < 8) {
1005
+ void (async () => {
1006
+ try {
1007
+ const fs3 = await import("fs");
1008
+ const path3 = await import("path");
1009
+ const dir = path3.join(this.opts.cwd, ".squint", "transcripts");
1010
+ const files = fs3.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse().slice(0, 20);
1011
+ for (const file of files) {
1012
+ if (matches.length >= 8) break;
1013
+ const text = fs3.readFileSync(path3.join(dir, file), "utf8");
1014
+ for (const line of text.split("\n")) {
1015
+ if (line.toLowerCase().includes(needle)) {
1016
+ matches.push(`[${file.replace(/\.md$/, "")}] ${line.trim().slice(0, 90)}`);
1017
+ break;
1018
+ }
1019
+ }
1020
+ }
1021
+ } catch {
1022
+ }
1023
+ this.push(
1024
+ "status",
1025
+ matches.length > 0 ? `${matches.join("\n")}
1026
+ /checkpoints can rewind \xB7 /save archives this session` : `no matches for "${arg}"`
1027
+ );
1028
+ })();
1029
+ } else {
1030
+ this.push("status", matches.join("\n"));
1031
+ }
1032
+ break;
1033
+ }
1034
+ case "save": {
1035
+ void (async () => {
1036
+ const fs3 = await import("fs");
1037
+ const path3 = await import("path");
1038
+ const dir = path3.join(this.opts.cwd, ".squint", "transcripts");
1039
+ fs3.mkdirSync(dir, { recursive: true });
1040
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1041
+ const file = path3.join(dir, `${stamp}.md`);
1042
+ const lines = [`# squint session \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16)}`, ""];
1043
+ for (const item of this.state.items) {
1044
+ switch (item.role) {
1045
+ case "user":
1046
+ lines.push(`## \u276F ${item.text}`, "");
1047
+ break;
1048
+ case "assistant":
1049
+ lines.push(item.text, "");
1050
+ break;
1051
+ case "tool":
1052
+ lines.push(`- \u2699 ${item.text}`);
1053
+ break;
1054
+ case "image":
1055
+ lines.push(`![screenshot](${item.text})`, "");
1056
+ break;
1057
+ case "thinking":
1058
+ break;
1059
+ default:
1060
+ lines.push(`> ${item.role === "error" ? "\u2717 " : ""}${item.text.split("\n").join("\n> ")}`);
1061
+ }
1062
+ }
1063
+ lines.push("", `> ${this.summary()}`);
1064
+ fs3.writeFileSync(file, lines.join("\n") + "\n");
1065
+ this.push("status", `saved transcript \u2192 ${path3.relative(this.opts.cwd, file)}`);
1066
+ })();
1067
+ break;
1068
+ }
1069
+ case "flows": {
1070
+ if (!this.state.devUrl) {
1071
+ this.push("error", "dev server not running \u2014 /dev first");
1072
+ break;
1073
+ }
1074
+ void (async () => {
1075
+ const { loadFlows } = await import("./flows-6LK7NGWS.js");
1076
+ const flows = loadFlows(this.opts.cwd);
1077
+ if (flows.length === 0) {
1078
+ this.push("status", "no flows \u2014 add .squint/flows/<name>.flow (goto/click/fill/press/expect/shot lines), or ask the engine to write one");
1079
+ return;
1080
+ }
1081
+ const chrome = findChrome();
1082
+ if (!chrome) {
1083
+ this.push("error", "no Chrome/Chromium found for flows");
1084
+ return;
1085
+ }
1086
+ const { runFlow } = await import("./cdp-2VVQOHWM.js");
1087
+ const { previewDir } = await import("./preview-FBNVDQIV.js");
1088
+ this.push("status", `replaying ${flows.length} flow(s)\u2026`);
1089
+ this.notify({ running: true, runStartedAt: Date.now() });
1090
+ const failures = [];
1091
+ for (const flow of flows) {
1092
+ const wanted = arg.trim();
1093
+ if (wanted && flow.name !== wanted) continue;
1094
+ const result = await runFlow(chrome, this.state.devUrl, flow, previewDir(this.opts.cwd));
1095
+ if (result.ok) {
1096
+ this.push("status", `\u2713 flow ${flow.name} \xB7 ${flow.steps.length} steps${result.shots.length > 0 ? ` \xB7 ${result.shots.length} shot(s)` : ""}`);
1097
+ for (const shot of result.shots) this.push("image", shot);
1098
+ } else {
1099
+ const where = result.failedStep ? ` at step ${result.failedStep}` : "";
1100
+ this.push("error", `\u2717 flow ${flow.name}${where}: ${result.detail}`);
1101
+ failures.push(`Flow "${flow.name}" fails${where}: ${result.detail}. The flow file is .squint/flows/${flow.name}.flow \u2014 fix the app (or the flow if the UI legitimately changed).`);
1102
+ }
1103
+ }
1104
+ this.notify({ running: false });
1105
+ if (failures.length > 0) {
1106
+ this.addProblem("flow", `${failures.length} flow(s) failing`, failures.join("\n\n"));
1107
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
1108
+ } else {
1109
+ this.clearProblems("flow");
1110
+ }
1111
+ this.drainQueue();
1112
+ })();
1113
+ break;
1114
+ }
1115
+ case "score": {
1116
+ if (!this.state.devUrl) {
1117
+ this.push("error", "dev server not running \u2014 /dev first");
1118
+ break;
1119
+ }
1120
+ void (async () => {
1121
+ const result = await this.capture();
1122
+ if (!result) return;
1123
+ const a11yCount = result.a11y?.length ?? 0;
1124
+ const slopCount = result.slop?.length ?? 0;
1125
+ const runtimeBad = result.runtime ? runtimeSummary(result.runtime) ? 1 : 0 : 0;
1126
+ const problems = this.state.problems.length;
1127
+ const score = Math.max(
1128
+ 0,
1129
+ 5 - problems * 0.75 - Math.min(a11yCount, 4) * 0.25 - Math.min(slopCount, 4) * 0.25 - runtimeBad
1130
+ );
1131
+ const lcp = this.lastPerf?.lcpMs !== void 0 ? ` \xB7 LCP ${this.lastPerf.lcpMs}ms` : "";
1132
+ this.push(
1133
+ "status",
1134
+ [
1135
+ `score: ${score.toFixed(2)}/5 (deterministic axes)`,
1136
+ ` open problems: ${problems}${problems > 0 ? ` (${this.state.problems.map((p) => p.source).join(", ")})` : ""}`,
1137
+ ` a11y findings: ${a11yCount} \xB7 distinctiveness tells: ${slopCount} \xB7 runtime ${runtimeBad ? "dirty" : "clean"}${lcp}`,
1138
+ " /review judges what numbers cannot \u2014 hierarchy, taste, coherence"
1139
+ ].join("\n")
1140
+ );
1141
+ })();
1142
+ break;
1143
+ }
1144
+ case "polish": {
1145
+ const rounds = arg ? Number.parseInt(arg, 10) : 2;
1146
+ if (!Number.isInteger(rounds) || rounds < 1 || rounds > 5) {
1147
+ this.push("status", "usage: /polish [1-5] \u2014 rounds of screenshot \u2192 critique \u2192 fix (each costs a turn)");
1148
+ break;
1149
+ }
1150
+ if (!this.state.devUrl) {
1151
+ this.push("error", "dev server not running \u2014 /dev first");
1152
+ break;
1153
+ }
1154
+ this.push("status", `polishing: ${rounds} round${rounds === 1 ? "" : "s"} of review \u2192 fix`);
1155
+ void this.polish(rounds);
1156
+ break;
1157
+ }
1158
+ case "btw":
1159
+ if (!arg) {
1160
+ this.push("status", "usage: /btw <question> \u2014 read-only side question, main thread untouched");
1161
+ } else {
1162
+ void this.btw(arg);
1163
+ }
1164
+ break;
1165
+ case "copy": {
1166
+ const last = this.state.items.findLast((i) => i.role === "assistant");
1167
+ if (!last) {
1168
+ this.push("status", "nothing to copy yet");
1169
+ break;
1170
+ }
1171
+ void import("child_process").then(({ spawn: spawn2 }) => {
1172
+ const cmd = process.platform === "darwin" ? spawn2("pbcopy") : process.platform === "win32" ? spawn2("clip") : spawn2("xclip", ["-selection", "clipboard"]);
1173
+ cmd.on("error", () => this.push("error", "no clipboard tool found"));
1174
+ cmd.on("close", (code) => {
1175
+ if (code === 0) this.push("status", `copied last reply (${last.text.length} chars)`);
1176
+ });
1177
+ cmd.stdin?.end(last.text);
1178
+ });
1179
+ break;
1180
+ }
1181
+ case "problems":
1182
+ if (this.state.problems.length === 0) {
1183
+ this.push("status", "no open problems");
1184
+ } else {
1185
+ const lines = this.state.problems.map((p, i) => `${i + 1}. [${p.source}] ${p.summary}`);
1186
+ this.push("status", `${lines.join("\n")}
1187
+ /fix sends all \xB7 /fix <n> targets one`);
1188
+ }
1189
+ break;
1190
+ case "check":
1191
+ void (async () => {
1192
+ const gates = detectGates(this.opts.cwd);
1193
+ if (gates.length === 0) {
1194
+ this.push("status", "no gates detected in this project");
1195
+ return;
1196
+ }
1197
+ this.push("status", `running gates: ${gates.map((g) => g.id).join(" \u2192 ")}`);
1198
+ this.notify({ running: true, runStartedAt: Date.now() });
1199
+ const results = await runGates(this.opts.cwd, gates, (result) => {
1200
+ this.push(
1201
+ result.ok ? "status" : "error",
1202
+ `${result.ok ? "\u2713" : "\u2717"} ${result.gate.id} \xB7 ${(result.durationMs / 1e3).toFixed(1)}s`
1203
+ );
1204
+ });
1205
+ this.notify({ running: false });
1206
+ this.drainQueue();
1207
+ const failures = results.filter((r) => !r.ok);
1208
+ if (failures.length > 0) {
1209
+ this.addProblem("gates", failures.map((f) => f.gate.id).join(" + "), buildGatePrompt(failures));
1210
+ this.push("status", "/fix sends open problems to the engine \xB7 /problems lists them");
1211
+ } else {
1212
+ this.clearProblems("gates");
1213
+ this.push("status", "all gates passed");
1214
+ }
1215
+ })();
1216
+ break;
1217
+ case "shot":
1218
+ void this.capture(arg || void 0);
1219
+ break;
1220
+ case "review":
1221
+ void (async () => {
1222
+ const result = await this.capture();
1223
+ if (result) {
1224
+ await this.runTurn(
1225
+ buildReviewPrompt(result.shots, arg || void 0, result.runtime, result.a11y, result.slop, result.narration, result.phantoms, result.viewTransitions, result.components, result.webmcp, result.jank),
1226
+ `\u{1F441} review rendered UI${arg ? ` \xB7 ${arg}` : ""}`
1227
+ );
1228
+ }
1229
+ })();
1230
+ break;
1231
+ case "undo":
1232
+ if (this.checkpoints.length === 0) {
1233
+ this.push("status", "nothing to undo \u2014 no ask this session, or not a git repo with commits");
1234
+ } else {
1235
+ this.restoreTo(this.checkpoints.length - 1);
1236
+ }
1237
+ break;
1238
+ case "checkpoints":
1239
+ if (this.checkpoints.length === 0) {
1240
+ this.push("status", "no checkpoints yet \u2014 one is taken before every ask in a git repo");
1241
+ } else {
1242
+ const lines = this.checkpoints.map((c, i) => {
1243
+ const mins = Math.max(0, Math.round((Date.now() - c.at) / 6e4));
1244
+ return `${i + 1}. ${c.label} \xB7 ${mins}m ago`;
1245
+ });
1246
+ this.push("status", `${lines.join("\n")}
1247
+ /restore <n> rewinds files to before that ask \xB7 /undo pops the last`);
1248
+ }
1249
+ break;
1250
+ case "restore": {
1251
+ const index = Number.parseInt(arg, 10);
1252
+ if (!Number.isInteger(index) || index < 1 || index > this.checkpoints.length) {
1253
+ this.push("status", `usage: /restore <1\u2013${Math.max(this.checkpoints.length, 1)}> \u2014 see /checkpoints`);
1254
+ } else {
1255
+ this.restoreTo(index - 1);
1256
+ }
1257
+ break;
1258
+ }
1259
+ case "resume": {
1260
+ const saved = loadState(this.opts.cwd);
1261
+ if (!saved) {
1262
+ this.push("status", "no previous session for this project");
1263
+ break;
1264
+ }
1265
+ try {
1266
+ if (!getEngine(saved.engine).supportsResume) {
1267
+ this.push("status", `previous engine ${saved.engine} cannot resume sessions`);
1268
+ break;
1269
+ }
1270
+ } catch {
1271
+ this.push("error", `previous engine ${saved.engine} is no longer available`);
1272
+ break;
1273
+ }
1274
+ this.sessionId = saved.sessionId;
1275
+ this.notify({
1276
+ engineId: saved.engine,
1277
+ model: saved.model ?? this.state.model,
1278
+ totals: saved.totals ?? this.state.totals
1279
+ });
1280
+ this.push("status", `resumed ${saved.engine} session${saved.lastAsk ? ` \xB7 "${saved.lastAsk}"` : ""}`);
1281
+ break;
1282
+ }
1283
+ case "sandbox": {
1284
+ if (this.state.running) {
1285
+ this.push("status", "wait for the current turn before changing sandbox state");
1286
+ break;
1287
+ }
1288
+ if (arg === "diff") {
1289
+ const stat = sandboxDiffStat(this.opts.cwd);
1290
+ if (!stat) {
1291
+ this.push("status", sandboxExists(this.opts.cwd) ? "sandbox is clean" : "no sandbox open \u2014 /sandbox on");
1292
+ } else {
1293
+ this.push("status", `${stat}
1294
+ ${sandboxFiles(this.opts.cwd).join("\n")}`);
1295
+ }
1296
+ break;
1297
+ }
1298
+ if (arg === "apply") {
1299
+ const applied = applySandbox(this.opts.cwd);
1300
+ if (applied.ok) {
1301
+ discardSandbox(this.opts.cwd);
1302
+ this.notify({ sandbox: false });
1303
+ this.resetDevServer();
1304
+ appendDecision(this.opts.cwd, { decision: "accepted the sandboxed session onto the real tree", source: "sandbox" });
1305
+ this.push("status", "sandbox applied to the real tree \u2014 review with git diff");
1306
+ } else {
1307
+ this.push("error", applied.detail ?? "apply failed");
1308
+ }
1309
+ break;
1310
+ }
1311
+ if (arg === "discard" || arg === "off") {
1312
+ const had = discardSandbox(this.opts.cwd);
1313
+ this.notify({ sandbox: false });
1314
+ this.resetDevServer();
1315
+ this.push("status", had ? "sandbox discarded \u2014 the real tree was never touched" : "no sandbox open");
1316
+ break;
1317
+ }
1318
+ if (arg === "on" || arg === "") {
1319
+ if (!isGitRepo(this.opts.cwd)) {
1320
+ this.push("error", "sandbox needs a git repo with at least one commit");
1321
+ break;
1322
+ }
1323
+ const { reused } = openSandbox(this.opts.cwd);
1324
+ this.notify({ sandbox: true });
1325
+ this.resetDevServer();
1326
+ this.push(
1327
+ "status",
1328
+ `${reused ? "rejoined the open sandbox" : "sandbox opened"} \u2014 asks now accumulate in a shadow worktree; /sandbox diff \xB7 apply \xB7 discard`
1329
+ );
1330
+ break;
1331
+ }
1332
+ this.push("status", "usage: /sandbox [on] \xB7 diff \xB7 apply \xB7 discard");
1333
+ break;
1334
+ }
1335
+ case "variants": {
1336
+ const [sub, ...subRest] = arg.split(/\s+/);
1337
+ if (sub === "apply") {
1338
+ const id = subRest[0];
1339
+ if (!id) {
1340
+ this.push("status", "usage: /variants apply <id>");
1341
+ break;
1342
+ }
1343
+ const applied = applyVariant(this.opts.cwd, id);
1344
+ if (applied.ok) {
1345
+ cleanVariants(this.opts.cwd);
1346
+ appendDecision(this.opts.cwd, { decision: `chose the ${id} direction over the other variants`, source: "variant" });
1347
+ this.push("status", `applied ${id} to the working tree \u2014 review with git diff`);
1348
+ } else {
1349
+ this.push("error", applied.detail ?? "apply failed");
1350
+ }
1351
+ break;
1352
+ }
1353
+ if (sub === "clean") {
1354
+ this.push("status", `removed ${cleanVariants(this.opts.cwd)} variant(s)`);
1355
+ break;
1356
+ }
1357
+ if (sub === "list") {
1358
+ const ids = listVariants(this.opts.cwd);
1359
+ this.push("status", ids.length > 0 ? ids.join(" \xB7 ") : "no variants \u2014 /variants <2-4> <ask>");
1360
+ break;
1361
+ }
1362
+ const n = Number.parseInt(sub ?? "", 10);
1363
+ const ask = subRest.join(" ").trim();
1364
+ if (!Number.isInteger(n) || n < 2 || n > 4 || ask.length === 0) {
1365
+ this.push("status", "usage: /variants <2-4> <ask> \xB7 /variants apply <id> \xB7 list \xB7 clean");
1366
+ break;
1367
+ }
1368
+ if (!isGitRepo(this.opts.cwd)) {
1369
+ this.push("error", "variants need a git repo with at least one commit");
1370
+ break;
1371
+ }
1372
+ void (async () => {
1373
+ cleanVariants(this.opts.cwd);
1374
+ this.push("status", `generating ${n} directions in parallel \u2014 this runs ${n} engine sessions`);
1375
+ this.notify({ running: true, runStartedAt: Date.now() });
1376
+ const engine = getEngine(this.state.engineId);
1377
+ const runs = await runVariants(
1378
+ this.opts.cwd,
1379
+ ask,
1380
+ n,
1381
+ engine,
1382
+ this.state.model,
1383
+ (familyId, text) => this.push("status", `[${familyId}] ${text}`)
1384
+ );
1385
+ const succeeded = runs.filter((r) => r.result.ok);
1386
+ if (succeeded.length > 0 && findChrome() && detectDevCommand(this.opts.cwd)) {
1387
+ this.push("status", "capturing one screenshot per variant\u2026");
1388
+ const shots = await screenshotVariants(this.opts.cwd, succeeded.map((r) => r.variant));
1389
+ for (const shot of shots) {
1390
+ this.push(
1391
+ shot.path ? "status" : "error",
1392
+ `[${shot.familyId}] ${shot.path ?? shot.error ?? "screenshot failed"}`
1393
+ );
1394
+ }
1395
+ }
1396
+ this.notify({ running: false });
1397
+ this.push(
1398
+ succeeded.length > 0 ? "status" : "error",
1399
+ `${succeeded.length}/${runs.length} variants ready \u2014 /variants apply <id> keeps one, /variants clean discards`
1400
+ );
1401
+ this.drainQueue();
1402
+ })();
1403
+ break;
1404
+ }
1405
+ case "clear":
1406
+ this.sessionId = void 0;
1407
+ clearState(this.opts.cwd);
1408
+ this.notify({ items: [], totals: { costUsd: 0, turns: 0 } });
1409
+ break;
1410
+ case "help": {
1411
+ void import("./commands-BY44HDQ6.js").then(({ commandHelp }) => this.push("status", commandHelp()));
1412
+ break;
1413
+ }
1414
+ case "quit":
1415
+ case "exit":
1416
+ this.push("status", this.summary());
1417
+ this.dispose();
1418
+ this.opts.onQuit?.();
1419
+ break;
1420
+ default:
1421
+ this.push("error", `unknown command /${name} \u2014 try /help`);
1422
+ }
1423
+ }
1424
+ };
1425
+
1426
+ export {
1427
+ Session
1428
+ };