wholestack 0.4.0 → 0.5.1

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/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Session,
10
10
  announcePlanMode,
11
11
  banner,
12
+ buildTools,
12
13
  buildWebTools,
13
14
  c,
14
15
  isPermissionMode,
@@ -26,8 +27,10 @@ import {
26
27
  runProver,
27
28
  statusLine,
28
29
  supportsThinking,
29
- userBox
30
- } from "./chunk-7DJJXUV4.js";
30
+ tasks,
31
+ userBox,
32
+ visionCapable
33
+ } from "./chunk-TDSLCPQL.js";
31
34
 
32
35
  // src/cli.ts
33
36
  import { createInterface } from "readline/promises";
@@ -120,7 +123,7 @@ async function loginBrowser(opts) {
120
123
  const state = randomBytes(16).toString("hex");
121
124
  const device = hostname().slice(0, 60);
122
125
  const timeoutMs = (opts.timeoutSec ?? 240) * 1e3;
123
- return new Promise((resolve) => {
126
+ return new Promise((resolve4) => {
124
127
  let settled = false;
125
128
  const finish = (token) => {
126
129
  if (settled) return;
@@ -130,20 +133,33 @@ async function loginBrowser(opts) {
130
133
  server.close();
131
134
  } catch {
132
135
  }
133
- resolve(token);
136
+ resolve4(token);
134
137
  };
135
138
  const server = createServer((req, res) => {
136
139
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
140
+ const corsHeaders = {
141
+ "access-control-allow-origin": "*",
142
+ "access-control-allow-methods": "GET, OPTIONS",
143
+ "access-control-allow-headers": "*",
144
+ "access-control-allow-private-network": "true"
145
+ };
146
+ if (req.method === "OPTIONS") {
147
+ res.writeHead(204, corsHeaders).end();
148
+ return;
149
+ }
137
150
  if (url.pathname !== "/callback") {
138
- res.writeHead(404).end();
151
+ res.writeHead(404, corsHeaders).end();
139
152
  return;
140
153
  }
141
154
  if (url.searchParams.get("state") !== state) {
142
- res.writeHead(400).end("state mismatch");
155
+ res.writeHead(400, corsHeaders).end("state mismatch");
143
156
  return;
144
157
  }
145
158
  const token = url.searchParams.get("token")?.trim();
146
- res.writeHead(token ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
159
+ res.writeHead(token ? 200 : 400, {
160
+ ...corsHeaders,
161
+ "content-type": "text/html; charset=utf-8"
162
+ });
147
163
  res.end(token ? SUCCESS_HTML : "missing token");
148
164
  if (token) {
149
165
  const path = saveKey("ZETA_API_KEY", token);
@@ -266,7 +282,624 @@ var CheckpointStore = class {
266
282
  }
267
283
  };
268
284
 
285
+ // src/mentions.ts
286
+ import { readFile as readFile2, stat, readdir } from "fs/promises";
287
+ import { resolve, relative, join as join2, isAbsolute } from "path";
288
+ import fg from "fast-glob";
289
+ var IGNORE = [
290
+ "**/node_modules/**",
291
+ "**/.git/**",
292
+ "**/dist/**",
293
+ "**/.next/**",
294
+ "**/build/**",
295
+ "**/.turbo/**"
296
+ ];
297
+ var CHARS_PER_TOKEN = 4;
298
+ var DEFAULT_TOKEN_BUDGET = 25e3;
299
+ var PER_FILE_TOKEN_CAP = 8e3;
300
+ var MAX_FILES_PER_MENTION = 60;
301
+ var MAX_TREE_ENTRIES = 200;
302
+ function extractMentions(line2) {
303
+ const out = [];
304
+ const re = /(^|\s)@([~A-Za-z0-9._\-/*]+)/g;
305
+ for (let m = re.exec(line2); m; m = re.exec(line2)) {
306
+ let p = m[2];
307
+ p = p.replace(/[.,;:)]+$/g, (tail) => tail.length ? "" : tail);
308
+ if (p) out.push(p);
309
+ }
310
+ return [...new Set(out)];
311
+ }
312
+ function estTokens(s) {
313
+ return Math.ceil(s.length / CHARS_PER_TOKEN);
314
+ }
315
+ function looksBinary(buf) {
316
+ if (buf.includes("\0")) return true;
317
+ let ctrl = 0;
318
+ const n = Math.min(buf.length, 1e3);
319
+ for (let i = 0; i < n; i++) {
320
+ const code = buf.charCodeAt(i);
321
+ if (code < 9 || code > 13 && code < 32) ctrl++;
322
+ }
323
+ return ctrl / Math.max(1, n) > 0.3;
324
+ }
325
+ async function renderTree(absDir, relDir) {
326
+ const lines = [];
327
+ let count = 0;
328
+ async function walk(dir, prefix, depth) {
329
+ if (count >= MAX_TREE_ENTRIES || depth > 3) return;
330
+ let entries;
331
+ try {
332
+ entries = await readdir(dir, { withFileTypes: true });
333
+ } catch {
334
+ return;
335
+ }
336
+ const filtered = entries.filter((e) => !["node_modules", ".git", "dist", ".next", "build", ".turbo"].includes(e.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
337
+ for (const e of filtered) {
338
+ if (count >= MAX_TREE_ENTRIES) {
339
+ lines.push(`${prefix}\u2026 (truncated)`);
340
+ return;
341
+ }
342
+ count++;
343
+ lines.push(`${prefix}${e.name}${e.isDirectory() ? "/" : ""}`);
344
+ if (e.isDirectory()) await walk(join2(dir, e.name), prefix + " ", depth + 1);
345
+ }
346
+ }
347
+ await walk(absDir, "", 0);
348
+ return `${relDir || "."}/ (directory tree)
349
+ ${lines.join("\n")}`;
350
+ }
351
+ async function expandMentions(message, cwd2, tokenBudget = DEFAULT_TOKEN_BUDGET) {
352
+ const mentions = extractMentions(message);
353
+ if (mentions.length === 0) return { context: "", notice: null };
354
+ const blocks = [];
355
+ const attached = [];
356
+ const seen = /* @__PURE__ */ new Set();
357
+ let spent = 0;
358
+ for (const mention of mentions) {
359
+ if (spent >= tokenBudget) break;
360
+ const raw = mention.startsWith("~/") ? join2(process.env.HOME ?? "", mention.slice(2)) : mention;
361
+ const direct = isAbsolute(raw) ? raw : resolve(cwd2, raw);
362
+ let isDir = false;
363
+ try {
364
+ isDir = (await stat(direct)).isDirectory();
365
+ } catch {
366
+ isDir = false;
367
+ }
368
+ if (isDir) {
369
+ const rel = relative(cwd2, direct);
370
+ const tree = await renderTree(direct, rel);
371
+ const cost = estTokens(tree);
372
+ if (spent + cost <= tokenBudget) {
373
+ blocks.push(tree);
374
+ spent += cost;
375
+ attached.push(`${rel || "."}/ (tree)`);
376
+ }
377
+ continue;
378
+ }
379
+ let files = [];
380
+ try {
381
+ files = await fg(raw, {
382
+ cwd: cwd2,
383
+ ignore: IGNORE,
384
+ onlyFiles: true,
385
+ dot: true,
386
+ suppressErrors: true,
387
+ absolute: false
388
+ });
389
+ } catch {
390
+ files = [];
391
+ }
392
+ if (files.length === 0) {
393
+ try {
394
+ if ((await stat(direct)).isFile()) files = [relative(cwd2, direct)];
395
+ } catch {
396
+ }
397
+ }
398
+ files = files.sort().slice(0, MAX_FILES_PER_MENTION);
399
+ for (const rel of files) {
400
+ if (spent >= tokenBudget) break;
401
+ if (seen.has(rel)) continue;
402
+ seen.add(rel);
403
+ let buf;
404
+ try {
405
+ buf = await readFile2(resolve(cwd2, rel), "utf8");
406
+ } catch {
407
+ continue;
408
+ }
409
+ if (looksBinary(buf)) continue;
410
+ const cap = Math.min(PER_FILE_TOKEN_CAP, tokenBudget - spent);
411
+ let body = buf;
412
+ let truncated = false;
413
+ if (estTokens(body) > cap) {
414
+ body = body.slice(0, cap * CHARS_PER_TOKEN);
415
+ truncated = true;
416
+ }
417
+ const fence = body.includes("```") ? "````" : "```";
418
+ const header = truncated ? `${rel} (truncated to ~${cap} tokens of ${estTokens(buf)})` : rel;
419
+ blocks.push(`${header}
420
+ ${fence}
421
+ ${body}
422
+ ${fence}`);
423
+ spent += estTokens(body);
424
+ attached.push(truncated ? `${rel} (truncated)` : rel);
425
+ }
426
+ }
427
+ if (blocks.length === 0) return { context: "", notice: null };
428
+ const context = "The user attached these files with @-mentions. Use them as primary context for this turn:\n\n" + blocks.join("\n\n");
429
+ const notice = `attached ${attached.length} item(s): ${attached.slice(0, 8).join(", ")}${attached.length > 8 ? `, +${attached.length - 8} more` : ""} (~${spent} tokens)`;
430
+ return { context, notice };
431
+ }
432
+
433
+ // src/subagent.ts
434
+ import { generateText, stepCountIs, tool } from "ai";
435
+ import { z } from "zod";
436
+ var READONLY_TOOLS = ["read_file", "glob", "grep", "list_dir"];
437
+ function readonlyToolset(ctx) {
438
+ const all = buildTools(ctx);
439
+ const out = {};
440
+ for (const k of READONLY_TOOLS) if (all[k]) out[k] = all[k];
441
+ return out;
442
+ }
443
+ var SUBAGENT_SYSTEM = "You are a focused sub-agent spawned by a lead engineer. You have READ-ONLY tools (read_file, glob, grep, list_dir). Investigate ONLY the task you are given, then return a tight, factual answer: what you found, with file:line evidence where it applies. Do not pad, do not speculate beyond the evidence, do not attempt edits.";
444
+ function buildSubagentTool(deps) {
445
+ const maxSteps = deps.maxSteps ?? 12;
446
+ const maxAgents = deps.maxAgents ?? 6;
447
+ const subtools = readonlyToolset(deps.ctx);
448
+ return {
449
+ spawn_agents: tool({
450
+ description: "Run several READ-ONLY sub-agents IN PARALLEL, each on its own focused task, and get their answers back together. Use for breadth \u2014 searching/reviewing/investigating multiple areas at once is far faster than doing them one by one yourself. Each sub-agent can read, glob, and grep but CANNOT edit. Returns one result per task.",
451
+ inputSchema: z.object({
452
+ tasks: z.array(
453
+ z.object({
454
+ label: z.string().describe("Short label for this slice, e.g. 'auth' or 'billing routes'."),
455
+ task: z.string().describe("The self-contained instruction for this sub-agent.")
456
+ })
457
+ ).min(1).max(maxAgents).describe(`1\u2013${maxAgents} independent tasks to run concurrently.`)
458
+ }),
459
+ execute: async ({ tasks: tasks2 }) => {
460
+ line(c.dim(` \u21C9 spawning ${tasks2.length} sub-agent(s) in parallel`));
461
+ const results = await Promise.all(
462
+ tasks2.map(async ({ label, task }) => {
463
+ try {
464
+ const r = await generateText({
465
+ model: deps.model,
466
+ system: SUBAGENT_SYSTEM,
467
+ prompt: task,
468
+ tools: subtools,
469
+ stopWhen: stepCountIs(maxSteps)
470
+ });
471
+ line(c.dim(` \u21B3 ${c.cyan(label)} done`));
472
+ return { label, ok: true, result: r.text.trim() };
473
+ } catch (e) {
474
+ line(c.dim(` \u21B3 ${c.cyan(label)} ${c.red("failed")}`));
475
+ return { label, ok: false, error: e.message };
476
+ }
477
+ })
478
+ );
479
+ return { ok: true, count: results.length, results };
480
+ }
481
+ })
482
+ };
483
+ }
484
+
485
+ // src/images.ts
486
+ import { readFile as readFile3, stat as stat2 } from "fs/promises";
487
+ import { resolve as resolve2, extname, relative as relative2, isAbsolute as isAbsolute2, join as join3 } from "path";
488
+ import { execFile } from "child_process";
489
+ import { tmpdir, platform as platform2 } from "os";
490
+ var EXT_TO_MEDIA = {
491
+ ".png": "image/png",
492
+ ".jpg": "image/jpeg",
493
+ ".jpeg": "image/jpeg",
494
+ ".gif": "image/gif",
495
+ ".webp": "image/webp"
496
+ };
497
+ var PER_IMAGE_BYTES = 6 * 1024 * 1024;
498
+ var TOTAL_BYTES = 15 * 1024 * 1024;
499
+ function wantsClipboard(message) {
500
+ return /(^|\s)@(clip|clipboard|paste)\b/i.test(message);
501
+ }
502
+ function run(cmd, args) {
503
+ return new Promise((res) => {
504
+ execFile(cmd, args, (err) => res(!err));
505
+ });
506
+ }
507
+ async function grabClipboardImage() {
508
+ const out = join3(tmpdir(), `zeta-clip-${process.pid}-${process.hrtime.bigint()}.png`);
509
+ const os = platform2();
510
+ if (os === "darwin") {
511
+ const script = `set thePath to "${out}"
512
+ try
513
+ set pngData to (the clipboard as \xABclass PNGf\xBB)
514
+ on error
515
+ return "NOIMAGE"
516
+ end try
517
+ set fp to open for access (POSIX file thePath) with write permission
518
+ write pngData to fp
519
+ close access fp
520
+ return "OK"`;
521
+ const ok = await run("osascript", ["-e", script]);
522
+ if (!ok) return null;
523
+ } else if (os === "linux") {
524
+ const ok = await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${out}"`]);
525
+ if (!ok) return null;
526
+ } else {
527
+ return null;
528
+ }
529
+ try {
530
+ const st = await stat2(out);
531
+ return st.isFile() && st.size > 0 ? out : null;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+ function imageTokens(message) {
537
+ const out = /* @__PURE__ */ new Set();
538
+ const re = /@?([~A-Za-z0-9._\-/]+\.(?:png|jpe?g|gif|webp))\b/gi;
539
+ for (let m = re.exec(message); m; m = re.exec(message)) out.add(m[1]);
540
+ return [...out];
541
+ }
542
+ async function scanImages(message, cwd2) {
543
+ const tokens = imageTokens(message);
544
+ const images = [];
545
+ const skipped = [];
546
+ let total = 0;
547
+ if (wantsClipboard(message)) {
548
+ const clip = await grabClipboardImage();
549
+ if (clip) {
550
+ try {
551
+ const buf = await readFile3(clip);
552
+ if (buf.length <= PER_IMAGE_BYTES) {
553
+ images.push({
554
+ path: "clipboard",
555
+ dataUrl: `data:image/png;base64,${buf.toString("base64")}`,
556
+ mediaType: "image/png",
557
+ bytes: buf.length
558
+ });
559
+ total += buf.length;
560
+ } else {
561
+ skipped.push("clipboard (over the size cap)");
562
+ }
563
+ } catch {
564
+ skipped.push("clipboard (unreadable)");
565
+ }
566
+ } else {
567
+ skipped.push("clipboard (no image found)");
568
+ }
569
+ }
570
+ for (const tok of tokens) {
571
+ const raw = tok.startsWith("~/") ? join3(process.env.HOME ?? "", tok.slice(2)) : tok;
572
+ const abs = isAbsolute2(raw) ? raw : resolve2(cwd2, raw);
573
+ const mediaType = EXT_TO_MEDIA[extname(abs).toLowerCase()];
574
+ if (!mediaType) continue;
575
+ let bytes;
576
+ try {
577
+ const st = await stat2(abs);
578
+ if (!st.isFile()) continue;
579
+ bytes = st.size;
580
+ } catch {
581
+ continue;
582
+ }
583
+ const label = isAbsolute2(raw) ? raw : relative2(cwd2, abs);
584
+ if (bytes > PER_IMAGE_BYTES || total + bytes > TOTAL_BYTES) {
585
+ skipped.push(`${label} (${Math.round(bytes / 1024)}KB \u2014 over the size cap)`);
586
+ continue;
587
+ }
588
+ try {
589
+ const buf = await readFile3(abs);
590
+ const dataUrl = `data:${mediaType};base64,${buf.toString("base64")}`;
591
+ images.push({ path: label, dataUrl, mediaType, bytes });
592
+ total += bytes;
593
+ } catch {
594
+ skipped.push(`${label} (unreadable)`);
595
+ }
596
+ }
597
+ return { images, skipped };
598
+ }
599
+
600
+ // src/weburl.ts
601
+ var CHARS_PER_TOKEN2 = 4;
602
+ var DEFAULT_TOKEN_BUDGET2 = 12e3;
603
+ var PER_URL_TOKEN_CAP = 6e3;
604
+ var FETCH_TIMEOUT_MS = 12e3;
605
+ var MAX_BYTES = 4 * 1024 * 1024;
606
+ function estTokens2(s) {
607
+ return Math.ceil(s.length / CHARS_PER_TOKEN2);
608
+ }
609
+ function extractUrls(message) {
610
+ const out = /* @__PURE__ */ new Set();
611
+ const re = /@?(https?:\/\/[^\s<>")]+)/gi;
612
+ for (let m = re.exec(message); m; m = re.exec(message)) {
613
+ out.add(m[1].replace(/[.,;:)\]]+$/, ""));
614
+ }
615
+ return [...out];
616
+ }
617
+ function htmlToText(html) {
618
+ return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/(p|div|li|h[1-6]|tr|br|section|article)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
619
+ }
620
+ async function fetchReadable(url) {
621
+ const ctrl = new AbortController();
622
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
623
+ try {
624
+ const res = await fetch(url, {
625
+ signal: ctrl.signal,
626
+ redirect: "follow",
627
+ headers: { "user-agent": "zeta-g-cli/0.4 (+web-context)" }
628
+ });
629
+ if (!res.ok) return null;
630
+ const ctype = res.headers.get("content-type") ?? "";
631
+ const raw = await res.text();
632
+ const body = raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) : raw;
633
+ const text = /html/i.test(ctype) ? htmlToText(body) : body.trim();
634
+ return { text, finalUrl: res.url || url };
635
+ } catch {
636
+ return null;
637
+ } finally {
638
+ clearTimeout(timer);
639
+ }
640
+ }
641
+ async function expandUrls(message, tokenBudget = DEFAULT_TOKEN_BUDGET2) {
642
+ const urls = extractUrls(message);
643
+ if (urls.length === 0) return { context: "", notice: null };
644
+ const blocks = [];
645
+ const fetched = [];
646
+ const failed = [];
647
+ let spent = 0;
648
+ for (const url of urls) {
649
+ if (spent >= tokenBudget) break;
650
+ const r = await fetchReadable(url);
651
+ if (!r || !r.text) {
652
+ failed.push(url);
653
+ continue;
654
+ }
655
+ const cap = Math.min(PER_URL_TOKEN_CAP, tokenBudget - spent);
656
+ let body = r.text;
657
+ let truncated = false;
658
+ if (estTokens2(body) > cap) {
659
+ body = body.slice(0, cap * CHARS_PER_TOKEN2);
660
+ truncated = true;
661
+ }
662
+ const header = truncated ? `${r.finalUrl} (truncated to ~${cap} tokens)` : r.finalUrl;
663
+ blocks.push(`${header}
664
+ ${body}`);
665
+ spent += estTokens2(body);
666
+ fetched.push(r.finalUrl);
667
+ }
668
+ if (blocks.length === 0) {
669
+ return { context: "", notice: failed.length ? `couldn't fetch: ${failed.join(", ")}` : null };
670
+ }
671
+ const context = "The user linked these pages. Use them as context for this turn:\n\n" + blocks.join("\n\n---\n\n");
672
+ const noticeParts = [`fetched ${fetched.length} URL(s) (~${spent} tokens)`];
673
+ if (failed.length) noticeParts.push(`failed: ${failed.length}`);
674
+ return { context, notice: noticeParts.join(" \xB7 ") };
675
+ }
676
+
677
+ // src/export.ts
678
+ import { writeFileSync as writeFileSync2 } from "fs";
679
+ import { resolve as resolve3 } from "path";
680
+ function renderContent(content) {
681
+ if (typeof content === "string") return content.trim();
682
+ if (!Array.isArray(content)) return "";
683
+ const parts = [];
684
+ for (const p of content) {
685
+ const type = p.type;
686
+ if (type === "text" && typeof p.text === "string") parts.push(p.text.trim());
687
+ else if (type === "image") parts.push("_[image]_");
688
+ else if (type === "tool-call") parts.push(`\`\u2192 ${String(p.toolName ?? "tool")}\``);
689
+ else if (type === "tool-result") parts.push(`\`\u2713 ${String(p.toolName ?? "tool")}\``);
690
+ }
691
+ return parts.filter(Boolean).join("\n\n");
692
+ }
693
+ function renderTranscript(messages, meta) {
694
+ const head = [
695
+ `# zeta-g transcript`,
696
+ ``,
697
+ `- model: ${meta.model}`,
698
+ meta.startedAt ? `- started: ${meta.startedAt}` : "",
699
+ `- messages: ${messages.length}`,
700
+ ``,
701
+ `---`,
702
+ ``
703
+ ].filter((l) => l !== "").join("\n");
704
+ const body = messages.map((m) => {
705
+ const text = renderContent(m.content);
706
+ if (!text) return "";
707
+ const who = m.role === "user" ? "\u{1F9D1} User" : m.role === "assistant" ? "\u{1F916} Zeta-G" : m.role === "tool" ? "\u{1F527} Tool" : m.role;
708
+ return `### ${who}
709
+
710
+ ${text}`;
711
+ }).filter(Boolean).join("\n\n");
712
+ return `${head}
713
+
714
+ ${body}
715
+ `;
716
+ }
717
+ function writeTranscript(messages, meta, file, cwd2) {
718
+ const abs = resolve3(cwd2, file);
719
+ writeFileSync2(abs, renderTranscript(messages, meta), "utf8");
720
+ return abs;
721
+ }
722
+
269
723
  // src/cli.ts
724
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
725
+ import { homedir as homedir4 } from "os";
726
+ import { join as join6 } from "path";
727
+
728
+ // src/update-check.ts
729
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
730
+ import { homedir as homedir2 } from "os";
731
+ import { join as join4 } from "path";
732
+ var PKG = "wholestack";
733
+ var CACHE = join4(homedir2(), ".zeta-g", "update-check.json");
734
+ var DAY_MS = 24 * 60 * 60 * 1e3;
735
+ function readCache() {
736
+ try {
737
+ return JSON.parse(readFileSync2(CACHE, "utf8"));
738
+ } catch {
739
+ return { lastCheck: 0, latest: null };
740
+ }
741
+ }
742
+ function writeCache(c2) {
743
+ try {
744
+ mkdirSync2(join4(homedir2(), ".zeta-g"), { recursive: true });
745
+ writeFileSync3(CACHE, JSON.stringify(c2));
746
+ } catch {
747
+ }
748
+ }
749
+ function isNewer(a, b) {
750
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
751
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
752
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
753
+ const x = pa[i] ?? 0;
754
+ const y = pb[i] ?? 0;
755
+ if (x !== y) return x > y;
756
+ }
757
+ return false;
758
+ }
759
+ async function checkForUpdate(current) {
760
+ if (process.env.ZETA_NO_UPDATE_CHECK || !existsSync2) return null;
761
+ const cache = readCache();
762
+ const fresh = Date.now() - cache.lastCheck < DAY_MS;
763
+ let latest = cache.latest;
764
+ if (!fresh) {
765
+ try {
766
+ const ctrl = new AbortController();
767
+ const t = setTimeout(() => ctrl.abort(), 1200);
768
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
769
+ signal: ctrl.signal,
770
+ headers: { accept: "application/json" }
771
+ });
772
+ clearTimeout(t);
773
+ if (res.ok) {
774
+ const json = await res.json();
775
+ latest = json.version ?? null;
776
+ }
777
+ } catch {
778
+ latest = cache.latest;
779
+ }
780
+ writeCache({ lastCheck: Date.now(), latest });
781
+ }
782
+ return latest && isNewer(latest, current) ? latest : null;
783
+ }
784
+
785
+ // src/access.ts
786
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync4 } from "fs";
787
+ import { homedir as homedir3 } from "os";
788
+ import { join as join5 } from "path";
789
+ var CACHE2 = join5(homedir3(), ".zeta-g", "access.json");
790
+ var TTL_MS = 60 * 60 * 1e3;
791
+ function readCache2() {
792
+ try {
793
+ return JSON.parse(readFileSync3(CACHE2, "utf8"));
794
+ } catch {
795
+ return null;
796
+ }
797
+ }
798
+ function writeCache2(c2) {
799
+ try {
800
+ mkdirSync3(join5(homedir3(), ".zeta-g"), { recursive: true });
801
+ writeFileSync4(CACHE2, JSON.stringify(c2));
802
+ } catch {
803
+ }
804
+ }
805
+ function clearAccessCache() {
806
+ try {
807
+ rmSync(CACHE2, { force: true });
808
+ } catch {
809
+ }
810
+ }
811
+ async function verifyAccess(webUrl, token, force = false) {
812
+ if (!token) return { active: false, tier: null, degraded: false };
813
+ const cached = force ? null : readCache2();
814
+ if (cached && Date.now() - cached.checkedAt < TTL_MS) {
815
+ return { active: cached.active, tier: cached.tier, degraded: false };
816
+ }
817
+ try {
818
+ const ctrl = new AbortController();
819
+ const t = setTimeout(() => ctrl.abort(), 2500);
820
+ const res = await fetch(`${webUrl.replace(/\/$/, "")}/api/cli/verify`, {
821
+ headers: { authorization: `Bearer ${token}` },
822
+ signal: ctrl.signal
823
+ });
824
+ clearTimeout(t);
825
+ if (res.status >= 500) {
826
+ return { active: true, tier: cached?.tier ?? null, degraded: true };
827
+ }
828
+ if (!res.ok) {
829
+ writeCache2({ checkedAt: Date.now(), active: false, tier: null });
830
+ return { active: false, tier: null, degraded: false };
831
+ }
832
+ const json = await res.json();
833
+ const active = Boolean(json.active);
834
+ writeCache2({ checkedAt: Date.now(), active, tier: json.tier ?? null });
835
+ return { active, tier: json.tier ?? null, degraded: false };
836
+ } catch {
837
+ return { active: true, tier: cached?.tier ?? null, degraded: true };
838
+ }
839
+ }
840
+ function showPaywall(loggedIn, webUrl) {
841
+ const w = 52;
842
+ line();
843
+ line(" " + c.cyan("\u256D" + "\u2500".repeat(w) + "\u256E"));
844
+ const row = (s) => line(" " + c.cyan("\u2502") + " " + s.padEnd(w - 2) + " " + c.cyan("\u2502"));
845
+ row(c.bold("zeta needs a subscription"));
846
+ row("");
847
+ if (!loggedIn) {
848
+ row(c.dim("You're not signed in."));
849
+ row("Run " + c.cyan("zeta login") + c.dim(" then subscribe."));
850
+ } else {
851
+ row(c.dim("No active paid plan on this account."));
852
+ row("Subscribe to use zeta in the terminal.");
853
+ }
854
+ row("");
855
+ row(c.dim("Plans: ") + c.cyan(`${webUrl.replace(/\/$/, "")}/pricing`));
856
+ line(" " + c.cyan("\u2570" + "\u2500".repeat(w) + "\u256F"));
857
+ line();
858
+ }
859
+
860
+ // src/art.ts
861
+ var ART_PACK = [
862
+ [" \u2588\u2588\u2588\u2588\u2588\u2588\u2588", " \u2588\u2588\u2554\u255D", " \u2588\u2588\u2554\u255D ", " \u2588\u2588\u2554\u255D ", " \u2588\u2588\u2588\u2588\u2588\u2588\u2588 "],
863
+ [" \u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", " \u2502 \u2571\u2571 \u2502", " \u2502 \u2571\u2571 \u2502", " \u2502\u2571\u2571 \u2502", " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"],
864
+ [" \u259F\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2599", " \u259F\u2588\u259B ", " \u259F\u2588\u259B ", " \u259F\u2588\u259B ", " \u259C\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u259B "],
865
+ [" \u250C\u2500\u2510\u250C\u2500\u2510\u250C\u252C\u2510\u250C\u2500\u2510", " \u250C\u2500\u2518\u251C\u2524 \u2502 \u251C\u2500\u2524", " \u2514\u2500\u2518\u2514\u2500\u2518 \u2534 \u2534 \u2534"],
866
+ [" \u2571\u2572 ", " \u2571\u2500\u2500\u2572 ", " \u2571 \u2571\u2571 \u2572 ", "\u2571 \u2571\u2571 \u2572 ", "\u2572\u2571\u2571\u2500\u2500\u2500\u2500\u2500\u2572"]
867
+ ];
868
+ function randomArt(seed) {
869
+ const i = seed != null ? seed % ART_PACK.length : Math.floor(Math.random() * ART_PACK.length);
870
+ return ART_PACK[(i % ART_PACK.length + ART_PACK.length) % ART_PACK.length];
871
+ }
872
+ var vlen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
873
+ function fetchPanel(fields, art = randomArt()) {
874
+ const shown = fields.filter((f) => f.value);
875
+ const labelW = Math.max(0, ...shown.map((f) => f.label.length));
876
+ const artW = Math.max(0, ...art.map(vlen));
877
+ const rows = Math.max(art.length, shown.length);
878
+ const out = [];
879
+ out.push("");
880
+ for (let i = 0; i < rows; i++) {
881
+ const left = (art[i] ?? "").padEnd(artW + 2);
882
+ const f = shown[i];
883
+ const right = f ? c.dim(f.label.padStart(labelW) + " ") + c.bold(f.value) : "";
884
+ out.push(" " + c.cyan(left) + right);
885
+ }
886
+ out.push("");
887
+ for (const l of out) process.stdout.write(l + "\n");
888
+ }
889
+
890
+ // src/cli.ts
891
+ import { readFileSync as readFileSync4 } from "fs";
892
+ import { basename } from "path";
893
+ var VERSION = "0.5.0";
894
+ function gitBranch(dir) {
895
+ try {
896
+ const head = readFileSync4(join6(dir, ".git", "HEAD"), "utf8").trim();
897
+ const m = head.match(/ref:\s*refs\/heads\/(.+)$/);
898
+ return m ? m[1] : head.slice(0, 7);
899
+ } catch {
900
+ return "";
901
+ }
902
+ }
270
903
  function parse(raw) {
271
904
  const a = {
272
905
  zetaUrl: process.env.ZETA_API_URL ?? "https://wholestack.ai",
@@ -294,6 +927,8 @@ function parse(raw) {
294
927
  const m = raw[++i];
295
928
  if (isPermissionMode(m)) a.mode = m;
296
929
  } else if (t === "--plan") a.plan = true;
930
+ else if (t === "--persona") a.persona = raw[++i];
931
+ else if (t === "--nzt48" || t === "--nzt-48") a.persona = "nzt-48";
297
932
  else if (t === "--think") a.think = true;
298
933
  else if (t === "--no-think") a.think = false;
299
934
  else if (t === "--mcp") a.mcp = (raw[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -324,12 +959,15 @@ ${c.bold("Web3 security")} ${c.dim("(forge \xB7 slither \xB7 firewall \xB7 halmo
324
959
  zeta-g prove <dir> <Contract> --property <k> prove one invariant
325
960
  zeta-g verify <dir> <Contract> --cert c.json passthrough + signed certificate
326
961
  ${c.dim("kinds: reentrancy-safety, access-control, conservation, no-value-extraction, \u2026")}
962
+ zeta-g --nzt48 "audit ./contracts" NZT-48 web3 special-ops persona
327
963
 
328
964
  ${c.bold("Session")}
329
- -m, --model <key> zeta-g1-lite | zeta-g1 | zeta-g1-max
330
- (lite = light \xB7 g1 = default \xB7 max = most capable)
965
+ -m, --model <key> zeta-g1-lite | zeta-g1 | zeta-g1-max | vision
966
+ (lite = light \xB7 g1 = default \xB7 max = most capable \xB7 vision = sees images)
331
967
  --mode <m> default | acceptEdits | plan | yolo (permission mode)
332
968
  --plan start in read-only plan mode
969
+ --nzt48 run as NZT-48, the web3 special-ops persona
970
+ --persona <id> layer a named persona on the base agent
333
971
  --think/--no-think toggle extended thinking (when a tier supports it)
334
972
  --continue resume the most recent session
335
973
  --resume [id] resume a session (id, or the latest)
@@ -352,7 +990,14 @@ ${c.bold("Auth")} ${c.dim("(set once, then just run zeta-g)")}
352
990
  zeta-g login --build <key> ZETA build-engine key (for /build)
353
991
 
354
992
  ${c.bold("In a session")} ${c.dim("(type /help for the full list)")}
355
- /model /cost /tools /undo /redo /checkpoints /compact /resume /memory /mcp /mode /think /init /doctor /clear /exit`;
993
+ /model /cost /tools /undo /redo /checkpoints /compact /resume /memory /mcp /mode /think /init /doctor /clear /exit
994
+ /diff /commit /pr ${c.dim("git flow \u2014 review, commit (auto-written msg), open a PR")}
995
+ /tasks ${c.dim("background jobs \u2014 run_background runs builds/tests while you work")}
996
+ /export [file.md] ${c.dim("write the conversation transcript to markdown")}
997
+ ${c.dim("@path \xB7 @dir/ \xB7 @glob attach files to the turn (token-budgeted, Tab-completes)")}
998
+ ${c.dim("@shot.png \xB7 @clip attach an image / paste from clipboard (auto-switches to Vision)")}
999
+ ${c.dim("@https://\u2026 fetch a web page and attach its text to the turn")}
1000
+ ${c.dim("spawn_agents tool fan a task out to parallel read-only sub-agents")}`;
356
1001
  async function runSecuritySubcommand(raw) {
357
1002
  const verb = raw[0];
358
1003
  if (verb !== "audit" && verb !== "prove" && verb !== "verify") return null;
@@ -375,6 +1020,19 @@ async function runSecuritySubcommand(raw) {
375
1020
  line(r.ok ? c.green(" \u2713 verified") : c.red(" \u2717 NOT verified"));
376
1021
  return r.code;
377
1022
  }
1023
+ function maybeYoloNotice() {
1024
+ try {
1025
+ const dir = join6(homedir4(), ".zeta-g");
1026
+ const marker = join6(dir, "yolo-notice-seen");
1027
+ if (existsSync3(marker)) return;
1028
+ line(
1029
+ " " + c.yellow("\u26A1 yolo mode") + c.dim(" \u2014 actions run without confirmation. ") + c.cyan("/mode default") + c.dim(" to require approvals \xB7 /undo reverts.") + "\n"
1030
+ );
1031
+ mkdirSync4(dir, { recursive: true });
1032
+ writeFileSync5(marker, (/* @__PURE__ */ new Date()).toISOString());
1033
+ } catch {
1034
+ }
1035
+ }
378
1036
  function webBaseUrl() {
379
1037
  return process.env.ZETA_WEB_URL?.trim() || process.env.ZETA_API_URL?.trim() || "https://wholestack.ai";
380
1038
  }
@@ -382,6 +1040,7 @@ async function runLogin(raw) {
382
1040
  if (raw[0] !== "login" && raw[0] !== "logout") return null;
383
1041
  const rest = raw.slice(1);
384
1042
  if (raw[0] === "logout") {
1043
+ clearAccessCache();
385
1044
  saveKey("ZETA_API_KEY", "");
386
1045
  line(c.green(" \u2713 logged out") + c.dim(" (token cleared)"));
387
1046
  return 0;
@@ -393,7 +1052,9 @@ async function runLogin(raw) {
393
1052
  line(c.red(" usage: zeta login --token <vbfl_\u2026>"));
394
1053
  return 1;
395
1054
  }
396
- return loginWithToken(tok) ? 0 : 1;
1055
+ const ok = loginWithToken(tok);
1056
+ if (ok) clearAccessCache();
1057
+ return ok ? 0 : 1;
397
1058
  }
398
1059
  const pick = (flag) => {
399
1060
  const i = rest.indexOf(flag);
@@ -408,6 +1069,7 @@ async function runLogin(raw) {
408
1069
  if (!hit(memberFlags) && !hit(brainFlags) && !hit(buildFlags)) {
409
1070
  const noBrowser = rest.includes("--no-browser");
410
1071
  const token = await loginBrowser({ webBase: webBaseUrl(), noBrowser });
1072
+ if (token) clearAccessCache();
411
1073
  return token ? 0 : 1;
412
1074
  }
413
1075
  if (hit(memberFlags)) {
@@ -439,6 +1101,7 @@ async function runLogin(raw) {
439
1101
  }
440
1102
  const what = name === "ZETA_API_KEY" ? "membership (keep + deploy your apps)" : name === "OPENROUTER_API_KEY" ? "brain (Zeta-G1.0 tiers)" : "ZETA build engine";
441
1103
  const path = saveKey(name, value);
1104
+ if (name === "ZETA_API_KEY") clearAccessCache();
442
1105
  line(c.green(` \u2713 saved key for the ${what}`) + c.dim(` \u2192 ${path} (chmod 600)`));
443
1106
  line(c.dim(" now just run `zeta-g`."));
444
1107
  return 0;
@@ -473,13 +1136,36 @@ async function main() {
473
1136
  if (sub !== null) exit(sub);
474
1137
  const args = parse(rawArgs);
475
1138
  if (args.version) {
476
- line("wholestack 0.4.0");
1139
+ line(`wholestack ${VERSION}`);
477
1140
  return;
478
1141
  }
479
1142
  if (args.help) {
480
1143
  line(HELP);
481
1144
  return;
482
1145
  }
1146
+ {
1147
+ let token = process.env.ZETA_API_KEY;
1148
+ let access = await verifyAccess(webBaseUrl(), token);
1149
+ if (!access.active && stdin.isTTY) {
1150
+ const rl = createInterface({ input: stdin, output: stdout });
1151
+ const ans = (await rl.question(
1152
+ "\n " + (token ? c.dim("No active plan on this account. ") + "Log in to a different account? " : "Log in now? ") + c.dim("[Y/n] ")
1153
+ )).trim().toLowerCase();
1154
+ rl.close();
1155
+ if (ans === "" || ans === "y" || ans === "yes") {
1156
+ const minted = await loginBrowser({ webBase: webBaseUrl(), noBrowser: false });
1157
+ if (minted) {
1158
+ token = minted;
1159
+ process.env.ZETA_API_KEY = minted;
1160
+ access = await verifyAccess(webBaseUrl(), token, true);
1161
+ }
1162
+ }
1163
+ }
1164
+ if (!access.active) {
1165
+ showPaywall(Boolean(token), webBaseUrl());
1166
+ exit(1);
1167
+ }
1168
+ }
483
1169
  let modelKey;
484
1170
  try {
485
1171
  modelKey = resolveModelKey(args.model);
@@ -497,7 +1183,11 @@ async function main() {
497
1183
  const isTty = !!stdin.isTTY;
498
1184
  const oneShot = args.prompt.length > 0;
499
1185
  const workdir = cwd();
500
- const mode = args.plan ? "plan" : args.mode ? args.mode : args.yes ? "yolo" : isTty ? "default" : "acceptEdits";
1186
+ const persistedMode = isPermissionMode(
1187
+ process.env.ZETA_PERMISSION_MODE
1188
+ ) ? process.env.ZETA_PERMISSION_MODE : void 0;
1189
+ const yoloIsDefault = !args.plan && !args.mode && !args.yes && !persistedMode && isTty;
1190
+ const mode = args.plan ? "plan" : args.mode ? args.mode : args.yes ? "yolo" : persistedMode ?? (isTty ? "yolo" : "acceptEdits");
501
1191
  const memory = await loadProjectMemory(workdir);
502
1192
  const plugins = args.noPlugins ? EMPTY_PLUGINS : loadPlugins(workdir);
503
1193
  const memoryText = [memory.text, plugins.systemText].filter(Boolean).join("\n\n") || void 0;
@@ -507,7 +1197,6 @@ async function main() {
507
1197
  disabled: args.noMcp,
508
1198
  extraServers: plugins.mcpServers
509
1199
  });
510
- const extraTools = { ...buildWebTools(), ...mcp.tools };
511
1200
  const hooks = new HookRunner(mergeHookSets(loadHookFiles(workdir), plugins.hooks), workdir);
512
1201
  const registry = new CommandRegistry();
513
1202
  registry.loadCustom(workdir);
@@ -518,6 +1207,7 @@ async function main() {
518
1207
  if (shuttingDown) return;
519
1208
  shuttingDown = true;
520
1209
  killRunningApps();
1210
+ tasks.killAll();
521
1211
  await mcp.close().catch(() => {
522
1212
  });
523
1213
  ic?.close();
@@ -529,12 +1219,33 @@ async function main() {
529
1219
  onExit: () => {
530
1220
  line(c.dim(" bye."));
531
1221
  void shutdown(0);
1222
+ },
1223
+ // Starship-style context segments above the prompt box.
1224
+ contextBar: () => {
1225
+ const seg = [c.cyan(modelLabel(modelKey)), c.dim(basename(workdir))];
1226
+ const br = gitBranch(workdir);
1227
+ if (br) seg.push(c.dim("\u2387 " + br));
1228
+ seg.push(mode === "yolo" ? c.yellow(mode) : c.dim(mode));
1229
+ return seg.join(c.dim(" \xB7 "));
532
1230
  }
533
1231
  });
534
1232
  }
535
1233
  const confirm = isTty && ic ? ic.confirm.bind(ic) : void 0;
536
- const permissions = new Permissions(mode, confirm);
1234
+ const persistMode = (m) => {
1235
+ try {
1236
+ saveKey("ZETA_PERMISSION_MODE", m);
1237
+ } catch {
1238
+ }
1239
+ };
1240
+ const permissions = new Permissions(mode, confirm, persistMode);
537
1241
  const checkpoints = new CheckpointStore();
1242
+ const toolCtx = { cwd: workdir, zetaApiUrl: args.zetaUrl, buildMode: args.buildMode, permissions, checkpoints };
1243
+ const extraTools = {
1244
+ ...buildWebTools(),
1245
+ ...mcp.tools,
1246
+ // Parallel read-only sub-agents (needs the model handle the base tools lack).
1247
+ ...buildSubagentTool({ model, modelKey, ctx: toolCtx })
1248
+ };
538
1249
  let session;
539
1250
  let resumedMessages = null;
540
1251
  if (args.resume !== void 0 || args.cont) {
@@ -554,19 +1265,27 @@ async function main() {
554
1265
  const agent = new Agent({
555
1266
  model,
556
1267
  modelKey,
557
- ctx: { cwd: workdir, zetaApiUrl: args.zetaUrl, buildMode: args.buildMode, permissions, checkpoints },
1268
+ ctx: toolCtx,
558
1269
  extraTools,
559
1270
  hooks,
560
1271
  memoryText,
1272
+ persona: args.persona,
561
1273
  thinking,
1274
+ yolo: mode === "yolo",
562
1275
  maxSteps: 16,
563
1276
  contextWindow: modelContextWindow(modelKey),
564
1277
  session
565
1278
  });
566
1279
  if (resumedMessages) agent.replaceHistory(resumedMessages);
567
1280
  if (oneShot) {
568
- if (ic) await ic.runInterruptible((sig) => agent.send(args.prompt, sig));
569
- else await agent.send(args.prompt);
1281
+ const m = await expandMentions(args.prompt, workdir);
1282
+ if (m.notice) line(c.dim(` @ ${m.notice}`) + "\n");
1283
+ const oneShotText = m.context ? `${args.prompt}
1284
+
1285
+ ---
1286
+ ${m.context}` : args.prompt;
1287
+ if (ic) await ic.runInterruptible((sig) => agent.send(oneShotText, sig));
1288
+ else await agent.send(oneShotText);
570
1289
  await shutdown(0);
571
1290
  return;
572
1291
  }
@@ -575,10 +1294,28 @@ async function main() {
575
1294
  await shutdown(1);
576
1295
  return;
577
1296
  }
578
- banner();
1297
+ await banner();
1298
+ fetchPanel([
1299
+ { label: "model", value: modelLabel(modelKey) },
1300
+ { label: "mode", value: mode },
1301
+ { label: "dir", value: basename(workdir) },
1302
+ { label: "git", value: gitBranch(workdir) },
1303
+ { label: "node", value: process.version },
1304
+ { label: "memory", value: memory.sources.length ? `${memory.sources.length} file(s)` : "" },
1305
+ { label: "mcp", value: mcp.mounted.length ? `${mcp.mounted.length} mounted` : "" },
1306
+ { label: "zeta", value: VERSION }
1307
+ ]);
579
1308
  bootSummary(memory, plugins, mcp, mode, thinking);
580
1309
  if (mode === "plan") announcePlanMode();
1310
+ else if (yoloIsDefault) maybeYoloNotice();
581
1311
  if (resumedMessages) line(" " + c.dim(`resumed ${session.meta.id} \xB7 ${resumedMessages.length} messages`) + "\n");
1312
+ void checkForUpdate(VERSION).then((latest) => {
1313
+ if (latest) {
1314
+ line(
1315
+ " " + c.yellow(`\u2191 wholestack ${latest} available`) + c.dim(` (you have ${VERSION}) \xB7 `) + c.cyan("npm i -g wholestack") + "\n"
1316
+ );
1317
+ }
1318
+ });
582
1319
  const baseCtx = () => ({
583
1320
  cwd: workdir,
584
1321
  modelKey,
@@ -593,9 +1330,9 @@ async function main() {
593
1330
  checkpoints,
594
1331
  print: (s = "") => line(s)
595
1332
  });
596
- const runTurn = async (text) => {
1333
+ const runTurn = async (text, images) => {
597
1334
  const t0 = Date.now();
598
- await ic.runInterruptible((sig) => agent.send(text, sig));
1335
+ await ic.runInterruptible((sig) => agent.send(text, sig, images));
599
1336
  statusLine({
600
1337
  model: modelLabel(modelKey),
601
1338
  tokens: agent.usage.totalTokens,
@@ -607,6 +1344,11 @@ async function main() {
607
1344
  });
608
1345
  };
609
1346
  for (; ; ) {
1347
+ for (const done of tasks.drainCompleted()) {
1348
+ const mark = done.status === "exited" ? c.green("\u2713") : c.red("\u2717");
1349
+ const code = done.exitCode == null ? "" : ` (exit ${done.exitCode})`;
1350
+ line(` ${mark} ${c.dim("background")} ${c.cyan(done.id)} ${done.display}${c.dim(code)}`);
1351
+ }
610
1352
  const input = await ic.readLine(c.cyan(" \u203A "));
611
1353
  if (!input) continue;
612
1354
  if (input.startsWith("/")) {
@@ -636,7 +1378,12 @@ async function main() {
636
1378
  }
637
1379
  } else if (res.type === "setMode") {
638
1380
  permissions.setMode(res.mode);
639
- line(" " + c.dim(`mode: ${res.mode}`) + "\n");
1381
+ if (res.mode !== "plan") {
1382
+ persistMode(res.mode);
1383
+ line(" " + c.dim(`mode: ${res.mode} \xB7 remembered for next session`) + "\n");
1384
+ } else {
1385
+ line(" " + c.dim(`mode: ${res.mode}`) + "\n");
1386
+ }
640
1387
  if (res.mode === "plan") announcePlanMode();
641
1388
  } else if (res.type === "toggleThinking") {
642
1389
  if (supportsThinking(modelKey)) {
@@ -655,13 +1402,54 @@ async function main() {
655
1402
  } else {
656
1403
  line(" " + c.red(`no session ${res.sessionId}`) + "\n");
657
1404
  }
1405
+ } else if (res.type === "export") {
1406
+ const file = res.file ?? `zeta-transcript-${gitBranch(workdir) || "session"}.md`;
1407
+ try {
1408
+ const abs = writeTranscript(agent.snapshot(), { model: modelLabel(modelKey) }, file, workdir);
1409
+ line(" " + c.green(`\u2713 transcript \u2192 ${abs}`) + "\n");
1410
+ } catch (e) {
1411
+ line(" " + c.red(e.message) + "\n");
1412
+ }
658
1413
  }
659
1414
  continue;
660
1415
  }
661
- userBox(input);
662
- await runTurn(input);
1416
+ if (!process.stdin.isTTY || process.env.NO_COLOR) userBox(input);
1417
+ const mentioned = await expandMentions(input, workdir);
1418
+ if (mentioned.notice) line(c.dim(` @ ${mentioned.notice}`) + "\n");
1419
+ const web = await expandUrls(input);
1420
+ if (web.notice) line(c.dim(` \u2301 ${web.notice}`) + "\n");
1421
+ const extraContext = [mentioned.context, web.context].filter(Boolean).join("\n\n---\n\n");
1422
+ const imgs = await scanImages(input, workdir);
1423
+ let attachments;
1424
+ if (imgs.images.length > 0) {
1425
+ if (!visionCapable(modelKey) && process.env.OPENROUTER_API_KEY) {
1426
+ try {
1427
+ const vkey = resolveModelKey("vision");
1428
+ modelKey = vkey;
1429
+ agent.setModel(resolveModel(vkey), vkey, modelContextWindow(vkey));
1430
+ agent.setThinking(false);
1431
+ line(c.dim(` \u29C9 image attached \u2192 switched to ${modelLabel(vkey)} (/model zeta-g1 to switch back)`) + "\n");
1432
+ } catch (e) {
1433
+ line(c.red(` \u29C9 couldn't switch to the vision lane: ${e.message}`) + "\n");
1434
+ }
1435
+ }
1436
+ if (visionCapable(modelKey)) {
1437
+ attachments = imgs.images.map((i) => ({ dataUrl: i.dataUrl }));
1438
+ line(c.dim(` \u29C9 attached ${imgs.images.length} image(s): ${imgs.images.map((i) => i.path).join(", ")}`) + "\n");
1439
+ } else {
1440
+ line(
1441
+ c.yellow(` \u29C9 ${imgs.images.length} image(s) ignored \u2014 ${modelLabel(modelKey)} is text-only.`) + c.dim(" set OPENROUTER_API_KEY (or /model vision) to enable Zeta-G1.0 Vision") + "\n"
1442
+ );
1443
+ }
1444
+ }
1445
+ if (imgs.skipped.length) line(c.dim(` \u29C9 skipped: ${imgs.skipped.join(", ")}`) + "\n");
1446
+ await runTurn(extraContext ? `${input}
1447
+
1448
+ ---
1449
+ ${extraContext}` : input, attachments);
663
1450
  }
664
1451
  killRunningApps();
1452
+ tasks.killAll();
665
1453
  await mcp.close().catch(() => {
666
1454
  });
667
1455
  ic.close();