@maintainer-pro/ai-cli 0.1.4 → 0.1.6

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/index.js CHANGED
@@ -2,6 +2,43 @@
2
2
  import { execa as execa2 } from "execa";
3
3
 
4
4
  // src/prompt.ts
5
+ var PROMPT_SECTION = {
6
+ system: { begin: "<<<SYSTEM_BEGIN>>>", end: "<<<SYSTEM_END>>>" },
7
+ clientContext: {
8
+ begin: "<<<CLIENT_CONTEXT_BEGIN>>>",
9
+ end: "<<<CLIENT_CONTEXT_END>>>"
10
+ },
11
+ priorConversations: {
12
+ begin: "<<<PRIOR_CONVERSATIONS_BEGIN>>>",
13
+ end: "<<<PRIOR_CONVERSATIONS_END>>>"
14
+ },
15
+ parentChain: {
16
+ begin: "<<<PARENT_CHAIN_BEGIN>>>",
17
+ end: "<<<PARENT_CHAIN_END>>>"
18
+ },
19
+ history: {
20
+ begin: "<<<CONVERSATION_HISTORY_BEGIN>>>",
21
+ end: "<<<CONVERSATION_HISTORY_END>>>"
22
+ },
23
+ attachments: {
24
+ begin: "<<<ATTACHMENTS_BEGIN>>>",
25
+ end: "<<<ATTACHMENTS_END>>>"
26
+ },
27
+ currentRequest: {
28
+ begin: "<<<CURRENT_REQUEST_BEGIN>>>",
29
+ end: "<<<CURRENT_REQUEST_END>>>"
30
+ }
31
+ };
32
+ function wrapSection(tag, body, title) {
33
+ const trimmed = body.trim();
34
+ if (!trimmed) return "";
35
+ const header = title ? `${tag.begin} ${title}` : tag.begin;
36
+ return `${header}
37
+ ${trimmed}
38
+ ${tag.end}
39
+
40
+ `;
41
+ }
5
42
  function buildConversationPrompt(messages) {
6
43
  if (messages.length === 0) return "";
7
44
  const lines = messages.map(
@@ -43,50 +80,67 @@ function splitMessages(messages) {
43
80
  const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
44
81
  return { request, history };
45
82
  }
46
- function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
83
+ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
47
84
  const { request, history } = splitMessages(messages);
48
- const historyBlock = history.length > 0 ? `Conversation so far:
49
- ${buildConversationPrompt(history)}
50
-
51
- ` : "";
52
- const contextBlock = formatClientContext(context);
53
- const contextSection = contextBlock ? `${contextBlock}
54
-
55
- ` : "";
56
- const systemSection = systemPrompt ? `${systemPrompt}
57
-
58
- ` : "";
59
- const priorSection = priorConversationsContext?.trim() ? `${priorConversationsContext.trim()}
60
-
61
- ` : "";
62
- const attachmentsSection = attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files):
63
- ${attachmentPaths.map((p) => `- ${p}`).join("\n")}
64
-
65
- ` : "";
85
+ const systemSection = wrapSection(
86
+ PROMPT_SECTION.system,
87
+ systemPrompt ?? "",
88
+ "instructions"
89
+ );
90
+ const contextSection = wrapSection(
91
+ PROMPT_SECTION.clientContext,
92
+ formatClientContext(context),
93
+ "live UI snapshot"
94
+ );
95
+ const priorSection = wrapSection(
96
+ PROMPT_SECTION.priorConversations,
97
+ priorConversationsContext ?? "",
98
+ "other chats (secondary)"
99
+ );
100
+ const parentSection = wrapSection(
101
+ PROMPT_SECTION.parentChain,
102
+ parentChainContext ?? "",
103
+ "reply-to parents \u2014 primary thread context"
104
+ );
105
+ const historySection = wrapSection(
106
+ PROMPT_SECTION.history,
107
+ history.length > 0 ? buildConversationPrompt(history) : "",
108
+ "earlier turns in this conversation"
109
+ );
110
+ const attachmentsSection = wrapSection(
111
+ PROMPT_SECTION.attachments,
112
+ attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files; they are stored under ~/.maintainer-pro for this project, not in the host app, and you are allowed to read them):
113
+ ${attachmentPaths.map((p) => `- ${p}`).join("\n")}` : "",
114
+ "user attachments"
115
+ );
116
+ const currentSection = wrapSection(
117
+ PROMPT_SECTION.currentRequest,
118
+ request || "(See the attached screenshot(s).)",
119
+ "answer this message"
120
+ );
66
121
  const closer = technical ? "Do the work now. Follow the requested output format exactly." : "Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.";
67
- return `${systemSection}${contextSection}${priorSection}${historyBlock}${attachmentsSection}Current user request:
68
- ${request || "(See the attached screenshot(s).)"}
69
-
70
- ${closer}`;
122
+ return `${systemSection}${contextSection}${priorSection}${parentSection}${historySection}${attachmentsSection}${currentSection}${closer}`;
71
123
  }
72
- function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
124
+ function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
73
125
  return buildUserFacingPrompt(
74
126
  systemPrompt,
75
127
  messages,
76
128
  context,
77
129
  attachmentPaths,
78
130
  priorConversationsContext,
79
- technical
131
+ technical,
132
+ parentChainContext
80
133
  );
81
134
  }
82
- function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical) {
135
+ function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
83
136
  return buildUserFacingPrompt(
84
137
  null,
85
138
  messages,
86
139
  context,
87
140
  attachmentPaths,
88
141
  priorConversationsContext,
89
- technical
142
+ technical,
143
+ parentChainContext
90
144
  );
91
145
  }
92
146
 
@@ -200,7 +254,8 @@ async function callClaudeCli(command, messages, context, options) {
200
254
  context,
201
255
  options.attachmentPaths,
202
256
  options.priorConversationsContext,
203
- options.technical
257
+ options.technical,
258
+ options.parentChainContext
204
259
  );
205
260
  const resolved = resolveCliCommand("claude", command);
206
261
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -240,7 +295,8 @@ async function callCursorCli(command, messages, context, options) {
240
295
  context,
241
296
  options.attachmentPaths,
242
297
  options.priorConversationsContext,
243
- options.technical
298
+ options.technical,
299
+ options.parentChainContext
244
300
  );
245
301
  const resolved = resolveCliCommand("cursor", command);
246
302
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -282,7 +338,8 @@ async function callAntigravityCli(command, messages, context, options) {
282
338
  context,
283
339
  options.attachmentPaths,
284
340
  options.priorConversationsContext,
285
- options.technical
341
+ options.technical,
342
+ options.parentChainContext
286
343
  );
287
344
  const prompt = `You are operating as a coding agent with full permission to read and edit files in this workspace. Do not introduce yourself. Do not ask what you are. Execute the user's latest request now (edit files and/or emit runtime tool JSON as instructed).
288
345
 
@@ -323,8 +380,8 @@ async function resolveCommandPath(command) {
323
380
  }
324
381
  const result = await execa2("which", [command], { reject: false });
325
382
  if (result.exitCode !== 0) return null;
326
- const path5 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
327
- return path5 || null;
383
+ const path9 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
384
+ return path9 || null;
328
385
  } catch {
329
386
  return null;
330
387
  }
@@ -410,6 +467,51 @@ async function callAi(messages, context, options) {
410
467
  return provider.call(messages, context, options);
411
468
  }
412
469
 
470
+ // src/parent-chain.ts
471
+ function collectParentChain(rows, startId) {
472
+ if (!startId) return [];
473
+ const byId = new Map(rows.map((row) => [row.id, row]));
474
+ const chain = [];
475
+ const seen = /* @__PURE__ */ new Set();
476
+ let current = byId.get(startId);
477
+ while (current && !seen.has(current.id)) {
478
+ seen.add(current.id);
479
+ const content = String(current.content ?? "").trim();
480
+ if (content && current.provider !== "working") {
481
+ const role = current.role === "assistant" || current.role === "system" ? current.role : "user";
482
+ chain.unshift({
483
+ id: current.id,
484
+ role,
485
+ content,
486
+ ...current.senderName?.trim() ? { senderName: current.senderName.trim() } : {}
487
+ });
488
+ }
489
+ const nextId = current.parentMessageId?.trim();
490
+ current = nextId ? byId.get(nextId) : void 0;
491
+ }
492
+ return chain;
493
+ }
494
+ function parentChainForTurn(rows, currentMessageId, fallbackParentId) {
495
+ if (!currentMessageId && !fallbackParentId) return [];
496
+ const byId = new Map(rows.map((row) => [row.id, row]));
497
+ const current = currentMessageId ? byId.get(currentMessageId) : void 0;
498
+ const replyToId = current?.parentMessageId && String(current.parentMessageId).trim() || fallbackParentId && String(fallbackParentId).trim() || null;
499
+ return collectParentChain(rows, replyToId);
500
+ }
501
+ function formatParentChainContext(chain) {
502
+ if (chain.length === 0) return "";
503
+ const lines = [
504
+ "context: this message has parents hierarchy linked with it",
505
+ "Prefer this chain over unrelated queued or later messages when interpreting the current request.",
506
+ ""
507
+ ];
508
+ chain.forEach((entry, index) => {
509
+ const who = entry.senderName || (entry.role === "assistant" ? "Assistant" : entry.role === "system" ? "System" : "Human");
510
+ lines.push(`${index + 1}. [${who}] ${entry.content}`);
511
+ });
512
+ return lines.join("\n");
513
+ }
514
+
413
515
  // src/system-prompt.ts
414
516
  function createDefaultSystemPrompt(input) {
415
517
  const files = input.relevantFilesHint ? `Key UI files (use internally only; never name them in your reply):
@@ -420,6 +522,14 @@ Only when the user wants to change live in-app data (not source code), return a
420
522
  ${input.runtimeToolsHint}
421
523
 
422
524
  Only use those runtime tools for live state. Never invent runtime tools.` : "";
525
+ const access = input.ignorePaths !== void 0 ? `
526
+
527
+ ## File access boundaries (mandatory)
528
+ - You may only read or edit files inside the current project workspace directory.
529
+ - Never read, write, move, or delete files outside that workspace.
530
+ - Never touch paths that match this ignore list (relative to the workspace):
531
+ ${(input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).length ? (input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)"}
532
+ - If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.` : "";
423
533
  return `You are a helpful product assistant for an app the user is looking at right now.
424
534
 
425
535
  ${input.productDescription}
@@ -440,6 +550,7 @@ Behind the scenes you can edit this repository and update live app data, but the
440
550
  ## When to edit the codebase
441
551
  Edit source files when the user asks to change labels, layout, styling, copy, or behavior in the app.
442
552
  ${files}
553
+ ${access}
443
554
 
444
555
  ${tools}
445
556
 
@@ -447,14 +558,257 @@ Do not refuse UI/label changes \u2014 implement them in source files.
447
558
  Answer the user's latest request directly \u2014 never reply with a generic greeting.`;
448
559
  }
449
560
 
450
- // src/http/attachments.ts
451
- import fs2 from "fs/promises";
561
+ // src/access-policy.ts
452
562
  import path2 from "path";
563
+ var DEFAULT_AI_IGNORE_PATHS = [
564
+ ".env",
565
+ ".env.*",
566
+ "**/.env",
567
+ "**/.env.*"
568
+ ];
569
+ function parseIgnorePathsEnv(raw) {
570
+ if (!raw?.trim()) return [];
571
+ const trimmed = raw.trim();
572
+ try {
573
+ const parsed = JSON.parse(trimmed);
574
+ if (Array.isArray(parsed)) {
575
+ return normalizeIgnorePaths(parsed.map(String));
576
+ }
577
+ } catch {
578
+ }
579
+ return normalizeIgnorePaths(trimmed.split(/[\n,]+/));
580
+ }
581
+ function normalizeIgnorePaths(paths) {
582
+ const out = [];
583
+ const seen = /* @__PURE__ */ new Set();
584
+ for (const raw of paths) {
585
+ const p = raw.trim().replace(/\\/g, "/");
586
+ if (!p || p.startsWith("/") || p.includes("..")) continue;
587
+ if (seen.has(p)) continue;
588
+ seen.add(p);
589
+ out.push(p);
590
+ }
591
+ return out;
592
+ }
593
+ function resolveIgnorePaths(partnerPaths) {
594
+ return normalizeIgnorePaths([
595
+ ...DEFAULT_AI_IGNORE_PATHS,
596
+ ...partnerPaths
597
+ ]);
598
+ }
599
+ function isInsideWorkspace(workspaceDir, targetPath) {
600
+ const root = path2.resolve(workspaceDir);
601
+ const target = path2.resolve(targetPath);
602
+ const rel = path2.relative(root, target);
603
+ return rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel);
604
+ }
605
+ function globToRegExp(pattern) {
606
+ let p = pattern.replace(/\\/g, "/");
607
+ if (p.endsWith("/")) p = `${p}**`;
608
+ let i = 0;
609
+ let out = "^";
610
+ while (i < p.length) {
611
+ if (p.startsWith("**/", i)) {
612
+ out += "(?:.*/)?";
613
+ i += 3;
614
+ continue;
615
+ }
616
+ if (p[i] === "*" && p[i + 1] !== "*") {
617
+ out += "[^/]*";
618
+ i += 1;
619
+ continue;
620
+ }
621
+ if (p.startsWith("**", i)) {
622
+ out += ".*";
623
+ i += 2;
624
+ continue;
625
+ }
626
+ if (p[i] === "?") {
627
+ out += "[^/]";
628
+ i += 1;
629
+ continue;
630
+ }
631
+ const ch = p[i];
632
+ if (/[.+^${}()|[\]\\]/.test(ch)) out += `\\${ch}`;
633
+ else out += ch;
634
+ i += 1;
635
+ }
636
+ out += "$";
637
+ return new RegExp(out, "i");
638
+ }
639
+ function isIgnoredRelative(relativePath, patterns) {
640
+ const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
641
+ if (!rel) return false;
642
+ return patterns.some((pattern) => {
643
+ const re = globToRegExp(pattern);
644
+ if (re.test(rel)) return true;
645
+ if (!pattern.includes("*") && !pattern.endsWith("/")) {
646
+ return rel === pattern || rel.startsWith(`${pattern}/`);
647
+ }
648
+ return false;
649
+ });
650
+ }
651
+ function isPathAllowed(workspaceDir, targetPath, ignorePaths) {
652
+ if (!isInsideWorkspace(workspaceDir, targetPath)) return false;
653
+ const rel = path2.relative(path2.resolve(workspaceDir), path2.resolve(targetPath));
654
+ return !isIgnoredRelative(rel, ignorePaths);
655
+ }
656
+ function formatAccessPolicyPromptSection(input) {
657
+ const ignores = input.ignorePaths.filter((p) => !p.startsWith("!")).length ? input.ignorePaths.filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)";
658
+ return `## File access boundaries (mandatory)
659
+ - You may only read or edit files inside the current project workspace directory.
660
+ - Never write, move, or delete files outside that workspace.
661
+ - You may read screenshot files listed in the attachments section (those live in the Maintainer Pro data folder, not in the project).
662
+ - Never touch paths that match this ignore list (relative to the workspace):
663
+ ${ignores}
664
+ - If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.`;
665
+ }
666
+ var ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
667
+ var ACCESS_IGNORE_END = "# maintainer-pro:access-end";
668
+ function renderManagedIgnoreBlock(ignorePaths) {
669
+ const lines = [
670
+ ACCESS_IGNORE_BEGIN,
671
+ "# Managed by Maintainer Pro \u2014 do not edit this block by hand.",
672
+ ...ignorePaths,
673
+ ACCESS_IGNORE_END
674
+ ];
675
+ return `${lines.join("\n")}
676
+ `;
677
+ }
678
+ function upsertManagedIgnoreFile(existing, ignorePaths) {
679
+ const block = renderManagedIgnoreBlock(ignorePaths);
680
+ const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
681
+ const end = existing.indexOf(ACCESS_IGNORE_END);
682
+ if (begin >= 0 && end > begin) {
683
+ const afterEnd = end + ACCESS_IGNORE_END.length;
684
+ const before = existing.slice(0, begin).replace(/\s+$/, "");
685
+ let after = existing.slice(afterEnd).replace(/^\r?\n/, "");
686
+ const parts = [before, block.trimEnd(), after.trimStart()].filter(
687
+ (s) => s.length > 0
688
+ );
689
+ return `${parts.join("\n\n")}
690
+ `;
691
+ }
692
+ const trimmed = existing.replace(/\s+$/, "");
693
+ return trimmed ? `${trimmed}
694
+
695
+ ${block}` : block;
696
+ }
697
+
698
+ // src/http/handler.ts
699
+ import { randomUUID } from "crypto";
700
+
701
+ // src/log.ts
702
+ import pino from "pino";
703
+ function isDevMode() {
704
+ if (process.env.AI_DEV === "1" || process.env.AI_DEV === "true") return true;
705
+ const env = (process.env.NODE_ENV || process.env.AI_ENV || "").trim().toLowerCase();
706
+ return env === "development" || env === "dev" || env === "test";
707
+ }
708
+ function resolveLogLevel() {
709
+ const explicit = process.env.LOG_LEVEL?.trim() || process.env.AI_LOG_LEVEL?.trim();
710
+ if (explicit) return explicit;
711
+ return isDevMode() ? "debug" : "info";
712
+ }
713
+ function createLogger(name) {
714
+ const level = resolveLogLevel();
715
+ const pretty = process.env.LOG_PRETTY !== "0" && typeof process.stdout?.isTTY === "boolean" && process.stdout.isTTY;
716
+ if (pretty) {
717
+ return pino({
718
+ name,
719
+ level,
720
+ transport: {
721
+ target: "pino-pretty",
722
+ options: {
723
+ colorize: true,
724
+ translateTime: "HH:MM:ss",
725
+ ignore: "pid,hostname"
726
+ }
727
+ }
728
+ });
729
+ }
730
+ return pino({ name, level });
731
+ }
732
+ function createInfoLogger(name) {
733
+ const log = createLogger(name);
734
+ return (msg) => {
735
+ log.info(msg);
736
+ };
737
+ }
738
+ function previewText(text, max = 160) {
739
+ const t = String(text ?? "").replace(/\s+/g, " ").trim();
740
+ if (!t) return "";
741
+ return t.length <= max ? t : `${t.slice(0, max)}\u2026`;
742
+ }
743
+
744
+ // src/http/attachments.ts
745
+ import fs3 from "fs/promises";
746
+ import path4 from "path";
747
+
748
+ // src/project-data.ts
749
+ import { createHash } from "crypto";
750
+ import fs2 from "fs";
751
+ import os from "os";
752
+ import path3 from "path";
753
+ var MAINTAINER_PRO_HOME_DIR = ".maintainer-pro";
754
+ var HOST_APPS_FILE = "apps.json";
755
+ function maintainerProHome() {
756
+ const override = process.env.MAINTAINER_PRO_HOME?.trim();
757
+ if (override) return path3.resolve(override);
758
+ return path3.join(os.homedir(), MAINTAINER_PRO_HOME_DIR);
759
+ }
760
+ function projectIdForFolder(folder) {
761
+ const resolved = path3.resolve(folder || "");
762
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
763
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
764
+ }
765
+ function sanitizeProjectId(id) {
766
+ return String(id || "").trim().replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
767
+ }
768
+ function resolveProjectDataDir(input) {
769
+ const override = String(input.dataDir || "").trim();
770
+ if (override) return path3.resolve(override);
771
+ const sandbox = sanitizeProjectId(String(input.sandboxId || ""));
772
+ const id = sandbox || projectIdForFolder(input.workspaceDir);
773
+ return path3.join(maintainerProHome(), "projects", id);
774
+ }
775
+ function projectUploadsDir(input) {
776
+ return path3.join(resolveProjectDataDir(input), "uploads");
777
+ }
778
+ function hostAppsCachePath(input) {
779
+ return path3.join(resolveProjectDataDir(input), HOST_APPS_FILE);
780
+ }
781
+ function ensureProjectDataDir(input) {
782
+ const dir = resolveProjectDataDir(input);
783
+ fs2.mkdirSync(dir, { recursive: true });
784
+ fs2.mkdirSync(path3.join(dir, "uploads"), { recursive: true });
785
+ try {
786
+ fs2.writeFileSync(
787
+ path3.join(dir, "workspace.json"),
788
+ `${JSON.stringify(
789
+ {
790
+ workspaceDir: path3.resolve(input.workspaceDir || ""),
791
+ sandboxId: input.sandboxId ? String(input.sandboxId) : null,
792
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
793
+ },
794
+ null,
795
+ 2
796
+ )}
797
+ `,
798
+ "utf8"
799
+ );
800
+ } catch {
801
+ }
802
+ return dir;
803
+ }
804
+
805
+ // src/http/attachments.ts
453
806
  var MAX_ATTACHMENTS = 5;
454
807
  var MAX_BYTES = 4 * 1024 * 1024;
455
808
  var ALLOWED = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
456
809
  function extForMime(mime) {
457
- switch (mime) {
810
+ const normalized = mime.split(";")[0]?.trim().toLowerCase() || "";
811
+ switch (normalized) {
458
812
  case "image/jpeg":
459
813
  case "image/jpg":
460
814
  return ".jpg";
@@ -466,11 +820,31 @@ function extForMime(mime) {
466
820
  return ".png";
467
821
  }
468
822
  }
469
- async function saveChatAttachments(attachments, workspaceDir) {
823
+ function attachmentIdFromRef(ref) {
824
+ const trimmed = String(ref || "").trim();
825
+ if (!trimmed) return null;
826
+ const m = /^maintainer-pro:\/\/(.+)$/i.exec(trimmed);
827
+ if (m?.[1]) return m[1];
828
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
829
+ trimmed
830
+ )) {
831
+ return trimmed;
832
+ }
833
+ return null;
834
+ }
835
+ function uploadsRoot(input) {
836
+ ensureProjectDataDir(input);
837
+ return projectUploadsDir(input);
838
+ }
839
+ async function saveChatAttachments(attachments, workspaceDir, extra) {
470
840
  if (!attachments?.length) return [];
471
841
  const selected = attachments.slice(0, MAX_ATTACHMENTS);
472
- const dir = path2.join(workspaceDir, ".maintainer-pro", "uploads");
473
- await fs2.mkdir(dir, { recursive: true });
842
+ const loc = {
843
+ workspaceDir,
844
+ dataDir: extra?.dataDir,
845
+ sandboxId: extra?.sandboxId
846
+ };
847
+ const dir = uploadsRoot(loc);
474
848
  const paths = [];
475
849
  const stamp = Date.now();
476
850
  for (let i = 0; i < selected.length; i++) {
@@ -488,8 +862,11 @@ async function saveChatAttachments(attachments, workspaceDir) {
488
862
  }
489
863
  const safeBase = (item.name || `screenshot-${i + 1}`).replace(/[^\w.\-]+/g, "_").slice(0, 64);
490
864
  const fileName = `${stamp}-${i + 1}-${safeBase}${extForMime(mime)}`;
491
- const filePath = path2.join(dir, fileName);
492
- await fs2.writeFile(filePath, buffer);
865
+ const filePath = path4.resolve(dir, fileName);
866
+ if (!isInsideWorkspace(dir, filePath)) {
867
+ throw new Error("Attachment path escaped the project data folder");
868
+ }
869
+ await fs3.writeFile(filePath, buffer);
493
870
  paths.push(filePath);
494
871
  }
495
872
  return paths;
@@ -575,7 +952,7 @@ function createToolValidator(schemas) {
575
952
 
576
953
  // src/http/handler.ts
577
954
  var WORKING_PROVIDER = "working";
578
- async function setWorkingMessage(db, conversationId) {
955
+ async function setWorkingMessage(db, conversationId, parentMessageId) {
579
956
  await db.ensureConversation(conversationId);
580
957
  await db.clearWorkingMessages?.(conversationId);
581
958
  await db.saveMessage({
@@ -583,9 +960,26 @@ async function setWorkingMessage(db, conversationId) {
583
960
  role: "assistant",
584
961
  content: "",
585
962
  provider: WORKING_PROVIDER,
586
- senderType: "ai"
963
+ senderType: "ai",
964
+ parentMessageId: parentMessageId ?? void 0
587
965
  });
588
966
  }
967
+ function savedMessageFields(result) {
968
+ if (!result || typeof result !== "object") return {};
969
+ const row = result;
970
+ const next = row.nextQueued && typeof row.nextQueued === "object" && typeof row.nextQueued.id === "string" ? {
971
+ id: row.nextQueued.id,
972
+ content: String(
973
+ row.nextQueued.content ?? ""
974
+ )
975
+ } : null;
976
+ return {
977
+ id: typeof row.id === "string" ? row.id : void 0,
978
+ queueStatus: typeof row.queueStatus === "string" || row.queueStatus === null ? row.queueStatus : void 0,
979
+ queuePosition: typeof row.queuePosition === "number" || row.queuePosition === null ? row.queuePosition : void 0,
980
+ nextQueued: next
981
+ };
982
+ }
589
983
  var SHARED_CONVERSATION_ID = "shared";
590
984
  var conversationTurnSeq = /* @__PURE__ */ new Map();
591
985
  function beginConversationTurn(conversationId) {
@@ -620,14 +1014,18 @@ async function resolveSharedConversationId(request, options, bodyId) {
620
1014
  return SHARED_CONVERSATION_ID;
621
1015
  }
622
1016
  function createChatHandler(options) {
1017
+ const logger = options.logger ?? createLogger("ai-cli:chat");
623
1018
  const validate = options.tools ? createToolValidator(options.tools) : null;
624
1019
  const workspaceDir = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
1020
+ const dataDir = options.dataDir;
1021
+ const sandboxId = options.sandboxId;
625
1022
  const baseCallOptions = {
626
1023
  systemPrompt: options.systemPrompt,
627
1024
  workspaceDir,
628
1025
  providerPreference: options.providerPreference,
629
1026
  providers: options.providers
630
1027
  };
1028
+ logger.debug({ workspaceDir }, "chat handler ready");
631
1029
  return {
632
1030
  async GET(request) {
633
1031
  try {
@@ -643,6 +1041,14 @@ function createChatHandler(options) {
643
1041
  if (options.db?.listMessages) {
644
1042
  messages = await options.db.listMessages(conversationId);
645
1043
  }
1044
+ logger.debug(
1045
+ {
1046
+ conversationId,
1047
+ provider: provider.id,
1048
+ messageCount: messages?.length ?? 0
1049
+ },
1050
+ "GET /api/chat"
1051
+ );
646
1052
  return Response.json({
647
1053
  provider: provider.id,
648
1054
  providerLabel: provider.label || providerLabel(provider.id),
@@ -650,6 +1056,7 @@ function createChatHandler(options) {
650
1056
  messages: messages ?? []
651
1057
  });
652
1058
  } catch (err) {
1059
+ logger.error({ err }, "GET /api/chat failed");
653
1060
  const message = err instanceof Error ? err.message : "No AI CLI provider available";
654
1061
  return Response.json(
655
1062
  { error: message, provider: null },
@@ -659,7 +1066,9 @@ function createChatHandler(options) {
659
1066
  },
660
1067
  async POST(request) {
661
1068
  let trackedConversationId;
1069
+ let trackedParentUserMessageId;
662
1070
  let trackedTurn = 0;
1071
+ const startedAt = Date.now();
663
1072
  try {
664
1073
  const body = await request.json();
665
1074
  const conversationId = await resolveSharedConversationId(
@@ -668,6 +1077,19 @@ function createChatHandler(options) {
668
1077
  body.conversationId
669
1078
  );
670
1079
  trackedConversationId = conversationId;
1080
+ logger.debug(
1081
+ {
1082
+ conversationId,
1083
+ type: body.type ?? "turn",
1084
+ skipPersistUser: Boolean(body.skipPersistUser),
1085
+ userMessageId: body.userMessageId,
1086
+ messageCount: body.messages?.length ?? 0,
1087
+ attachmentCount: body.attachments?.length ?? 0,
1088
+ queueStatus: body.queueStatus,
1089
+ preview: previewText(body.userMessage || body.content)
1090
+ },
1091
+ "POST /api/chat"
1092
+ );
671
1093
  if (body.type === "working-clear") {
672
1094
  if (!conversationId) {
673
1095
  return Response.json(
@@ -692,11 +1114,54 @@ function createChatHandler(options) {
692
1114
  await options.db.ensureConversation(conversationId);
693
1115
  await options.db.saveMessage({
694
1116
  conversationId,
1117
+ id: body.messageId,
695
1118
  role: "assistant",
696
1119
  content,
697
1120
  provider: "developer",
698
1121
  senderType: "developer",
699
- senderName: body.senderName?.trim() || void 0
1122
+ senderName: body.senderName?.trim() || void 0,
1123
+ parentMessageId: body.parentMessageId
1124
+ });
1125
+ }
1126
+ return Response.json({ ok: true, conversationId });
1127
+ }
1128
+ if (body.type === "user") {
1129
+ const content = body.content?.trim();
1130
+ if (!conversationId || !content) {
1131
+ return Response.json(
1132
+ { error: "conversationId and content are required" },
1133
+ { status: 400 }
1134
+ );
1135
+ }
1136
+ if (options.db) {
1137
+ await options.db.ensureConversation(conversationId);
1138
+ let persistAttachmentPaths2 = [];
1139
+ if (options.db.uploadAttachments && body.attachments?.length) {
1140
+ const uploaded = await options.db.uploadAttachments(
1141
+ conversationId,
1142
+ body.attachments
1143
+ );
1144
+ persistAttachmentPaths2 = uploaded.refs;
1145
+ }
1146
+ const saved = await options.db.saveMessage({
1147
+ conversationId,
1148
+ id: body.messageId,
1149
+ role: "user",
1150
+ content,
1151
+ attachmentPaths: persistAttachmentPaths2.length > 0 ? persistAttachmentPaths2 : void 0,
1152
+ senderType: body.senderType === "client" ? "client" : void 0,
1153
+ senderName: body.senderName?.trim() || void 0,
1154
+ parentMessageId: body.parentMessageId,
1155
+ intent: "queue",
1156
+ queueStatus: body.queueStatus === "queued" || body.queueStatus === "working" ? body.queueStatus : void 0
1157
+ });
1158
+ const fields = savedMessageFields(saved);
1159
+ return Response.json({
1160
+ ok: true,
1161
+ conversationId,
1162
+ message: saved,
1163
+ queueStatus: fields.queueStatus ?? null,
1164
+ queuePosition: fields.queuePosition ?? null
700
1165
  });
701
1166
  }
702
1167
  return Response.json({ ok: true, conversationId });
@@ -710,44 +1175,99 @@ function createChatHandler(options) {
710
1175
  }
711
1176
  let attachmentPaths = [];
712
1177
  let persistAttachmentPaths = [];
713
- if (options.db?.uploadAttachments) {
1178
+ if (options.db?.uploadAttachments && attachments?.length) {
714
1179
  const uploaded = await options.db.uploadAttachments(
715
1180
  conversationId,
716
1181
  attachments
717
1182
  );
718
1183
  attachmentPaths = uploaded.localPaths;
719
1184
  persistAttachmentPaths = uploaded.refs;
720
- } else {
1185
+ } else if (attachments?.length) {
721
1186
  attachmentPaths = await saveChatAttachments(
722
1187
  attachments,
723
- workspaceDir
1188
+ workspaceDir,
1189
+ { dataDir, sandboxId }
724
1190
  );
725
1191
  persistAttachmentPaths = attachmentPaths;
726
1192
  }
1193
+ const storedUserId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
1194
+ if (!attachmentPaths.length && conversationId && options.db?.materializeMessageAttachments) {
1195
+ attachmentPaths = await options.db.materializeMessageAttachments(
1196
+ conversationId,
1197
+ storedUserId,
1198
+ workspaceDir
1199
+ );
1200
+ }
1201
+ logger.debug(
1202
+ {
1203
+ conversationId,
1204
+ userMessageId: storedUserId,
1205
+ attachmentCount: attachmentPaths.length
1206
+ },
1207
+ "cli attachments"
1208
+ );
727
1209
  const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
728
1210
  trackedTurn = turn;
729
1211
  const signal = request.signal;
1212
+ let parentUserMessageId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
1213
+ trackedParentUserMessageId = parentUserMessageId;
730
1214
  if (options.db && conversationId) {
731
1215
  const latest = messages[messages.length - 1];
732
1216
  const persistContent = body.userMessage?.trim() || (latest?.role === "user" ? latest.content : "");
733
- if (persistContent) {
1217
+ if (persistContent && !body.skipPersistUser && !parentUserMessageId) {
734
1218
  await options.db.ensureConversation(conversationId);
735
- await options.db.saveMessage({
1219
+ const saved = await options.db.saveMessage({
736
1220
  conversationId,
737
1221
  role: "user",
738
1222
  content: persistContent,
739
1223
  attachmentPaths: persistAttachmentPaths.length > 0 ? persistAttachmentPaths : void 0,
740
1224
  senderType: body.senderType === "client" ? "client" : void 0,
741
- senderName: body.senderName?.trim() || void 0
1225
+ senderName: body.senderName?.trim() || void 0,
1226
+ intent: "run"
742
1227
  });
1228
+ parentUserMessageId = savedMessageFields(saved).id ?? parentUserMessageId;
1229
+ trackedParentUserMessageId = parentUserMessageId;
1230
+ }
1231
+ if (!body.skipPersistUser) {
1232
+ await setWorkingMessage(
1233
+ options.db,
1234
+ conversationId,
1235
+ parentUserMessageId
1236
+ );
743
1237
  }
744
- await setWorkingMessage(options.db, conversationId);
745
1238
  }
746
1239
  const latestUser = [...messages].reverse().find((m) => m.role === "user");
747
1240
  const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
748
1241
  excludeConversationId: conversationId,
749
1242
  currentRequest: latestUser?.content ?? ""
750
1243
  }) : "";
1244
+ let parentChainContext = "";
1245
+ if (options.db?.listMessages && conversationId) {
1246
+ const stored = await options.db.listMessages(conversationId);
1247
+ const chain = parentChainForTurn(
1248
+ stored.map((row) => ({
1249
+ id: row.id,
1250
+ role: row.role,
1251
+ content: row.content,
1252
+ parentMessageId: row.parentMessageId,
1253
+ senderName: row.senderName,
1254
+ provider: row.provider
1255
+ })),
1256
+ parentUserMessageId,
1257
+ body.parentMessageId
1258
+ );
1259
+ parentChainContext = formatParentChainContext(chain);
1260
+ if (parentChainContext) {
1261
+ logger.debug(
1262
+ {
1263
+ conversationId,
1264
+ parentUserMessageId,
1265
+ chainLength: chain.length
1266
+ },
1267
+ "parent chain context"
1268
+ );
1269
+ }
1270
+ }
751
1271
  if (!isActiveConversationTurn(
752
1272
  conversationId ?? SHARED_CONVERSATION_ID,
753
1273
  turn
@@ -758,23 +1278,59 @@ function createChatHandler(options) {
758
1278
  });
759
1279
  }
760
1280
  if (signal.aborted) {
761
- if (options.db?.clearWorkingMessages && conversationId) {
762
- await options.db.clearWorkingMessages(conversationId);
1281
+ logger.debug({ conversationId, turn }, "aborted before AI");
1282
+ if (conversationId && options.db) {
1283
+ if (options.db.releaseWorkingTurn) {
1284
+ await options.db.releaseWorkingTurn(
1285
+ conversationId,
1286
+ parentUserMessageId
1287
+ );
1288
+ } else {
1289
+ await options.db.clearWorkingMessages?.(conversationId);
1290
+ }
763
1291
  }
764
1292
  return Response.json({
765
1293
  superseded: true,
766
1294
  conversationId: conversationId ?? null
767
1295
  });
768
1296
  }
1297
+ logger.debug(
1298
+ {
1299
+ conversationId,
1300
+ turn,
1301
+ parentUserMessageId,
1302
+ historyLength: messages.length,
1303
+ attachmentCount: attachmentPaths.length,
1304
+ hasParentChain: Boolean(parentChainContext),
1305
+ preview: previewText(
1306
+ [...messages].reverse().find((m) => m.role === "user")?.content
1307
+ )
1308
+ },
1309
+ "calling AI provider"
1310
+ );
1311
+ const aiStarted = Date.now();
769
1312
  const aiResponse = await callAi(messages, context, {
770
1313
  ...baseCallOptions,
771
1314
  attachmentPaths,
772
- priorConversationsContext: priorConversationsContext || void 0
1315
+ priorConversationsContext: priorConversationsContext || void 0,
1316
+ parentChainContext: parentChainContext || void 0
773
1317
  });
1318
+ logger.debug(
1319
+ {
1320
+ conversationId,
1321
+ turn,
1322
+ provider: aiResponse.provider,
1323
+ toolCalls: aiResponse.toolCalls.length,
1324
+ ms: Date.now() - aiStarted,
1325
+ preview: previewText(aiResponse.text)
1326
+ },
1327
+ "AI provider returned"
1328
+ );
774
1329
  if (!isActiveConversationTurn(
775
1330
  conversationId ?? SHARED_CONVERSATION_ID,
776
1331
  turn
777
1332
  )) {
1333
+ logger.debug({ conversationId, turn }, "superseded after AI");
778
1334
  return Response.json({
779
1335
  superseded: true,
780
1336
  conversationId: conversationId ?? null
@@ -782,38 +1338,108 @@ function createChatHandler(options) {
782
1338
  }
783
1339
  const validatedToolCalls = validate ? aiResponse.toolCalls.filter((tc) => validate(tc).valid) : aiResponse.toolCalls;
784
1340
  if (options.onToolCalls && validatedToolCalls.length > 0) {
1341
+ logger.debug(
1342
+ { conversationId, count: validatedToolCalls.length },
1343
+ "applying tool calls"
1344
+ );
785
1345
  await options.onToolCalls(validatedToolCalls, context);
786
1346
  }
1347
+ let nextQueued = null;
1348
+ const assistantMessageId = typeof body.assistantMessageId === "string" && body.assistantMessageId ? body.assistantMessageId : randomUUID();
1349
+ let replyId = assistantMessageId;
787
1350
  if (options.db && conversationId) {
788
1351
  await options.db.ensureConversation(conversationId);
789
- await options.db.clearWorkingMessages?.(conversationId);
790
- await options.db.saveMessage({
1352
+ const savedReply = await options.db.saveMessage({
791
1353
  conversationId,
1354
+ id: assistantMessageId,
792
1355
  role: "assistant",
793
1356
  content: aiResponse.text,
794
1357
  provider: aiResponse.provider,
795
- senderType: "ai"
1358
+ senderType: "ai",
1359
+ parentMessageId: parentUserMessageId
796
1360
  });
1361
+ replyId = savedMessageFields(savedReply).id ?? assistantMessageId;
1362
+ if (options.db.releaseWorkingTurn) {
1363
+ let released = void 0;
1364
+ for (let attempt = 0; attempt < 3; attempt++) {
1365
+ try {
1366
+ released = await options.db.releaseWorkingTurn(
1367
+ conversationId,
1368
+ parentUserMessageId
1369
+ );
1370
+ break;
1371
+ } catch (err) {
1372
+ logger.warn(
1373
+ { err, conversationId, parentUserMessageId, attempt },
1374
+ "releaseWorkingTurn failed; retrying"
1375
+ );
1376
+ if (attempt === 2) throw err;
1377
+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
1378
+ }
1379
+ }
1380
+ nextQueued = released && typeof released === "object" && released.nextQueued && typeof released.nextQueued.id === "string" ? {
1381
+ id: released.nextQueued.id,
1382
+ content: String(released.nextQueued.content ?? "")
1383
+ } : null;
1384
+ logger.debug(
1385
+ { conversationId, parentUserMessageId, nextQueued: nextQueued?.id },
1386
+ "released working turn"
1387
+ );
1388
+ } else {
1389
+ await options.db.clearWorkingMessages?.(conversationId);
1390
+ nextQueued = savedMessageFields(savedReply).nextQueued ?? null;
1391
+ }
797
1392
  if (validatedToolCalls.length > 0) {
798
1393
  await options.db.saveToolEvents?.(
799
1394
  conversationId,
800
1395
  validatedToolCalls
801
1396
  );
802
1397
  }
1398
+ logger.debug(
1399
+ {
1400
+ conversationId,
1401
+ replyId,
1402
+ nextQueued: nextQueued?.id
1403
+ },
1404
+ "persisted AI reply"
1405
+ );
803
1406
  }
1407
+ logger.debug(
1408
+ { conversationId, turn, ms: Date.now() - startedAt },
1409
+ "POST /api/chat done"
1410
+ );
804
1411
  return Response.json({
805
1412
  text: aiResponse.text,
806
1413
  toolCalls: validatedToolCalls,
807
1414
  provider: aiResponse.provider,
808
1415
  providerLabel: providerLabel(aiResponse.provider),
809
- conversationId: conversationId ?? null
1416
+ conversationId: conversationId ?? null,
1417
+ messageId: replyId,
1418
+ nextQueued,
1419
+ parentMessageId: parentUserMessageId ?? null
810
1420
  });
811
1421
  } catch (err) {
812
1422
  console.error("Chat API error:", err);
813
- if (trackedConversationId && options.db?.clearWorkingMessages && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
814
- await options.db.clearWorkingMessages(trackedConversationId).catch(
815
- () => void 0
816
- );
1423
+ logger.error(
1424
+ {
1425
+ err,
1426
+ conversationId: trackedConversationId,
1427
+ turn: trackedTurn,
1428
+ ms: Date.now() - startedAt
1429
+ },
1430
+ "POST /api/chat failed"
1431
+ );
1432
+ if (trackedConversationId && options.db && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
1433
+ if (options.db.releaseWorkingTurn) {
1434
+ await options.db.releaseWorkingTurn(
1435
+ trackedConversationId,
1436
+ trackedParentUserMessageId
1437
+ ).catch(() => void 0);
1438
+ } else {
1439
+ await options.db.clearWorkingMessages?.(trackedConversationId).catch(
1440
+ () => void 0
1441
+ );
1442
+ }
817
1443
  }
818
1444
  return Response.json(
819
1445
  {
@@ -833,12 +1459,17 @@ function toNextRoute(handlers) {
833
1459
  }
834
1460
 
835
1461
  // src/http/local-store.ts
836
- import fs3 from "fs/promises";
837
- import path3 from "path";
838
- import { randomUUID } from "crypto";
1462
+ import fs4 from "fs/promises";
1463
+ import path5 from "path";
1464
+ import { randomUUID as randomUUID2 } from "crypto";
1465
+ function userHasAiReply(rows, workingId) {
1466
+ return rows.some(
1467
+ (m) => m.role === "assistant" && m.parentMessageId === workingId && m.provider !== "working" && Boolean(m.content?.trim())
1468
+ );
1469
+ }
839
1470
  async function readConversation(filePath) {
840
1471
  try {
841
- const raw = await fs3.readFile(filePath, "utf8");
1472
+ const raw = await fs4.readFile(filePath, "utf8");
842
1473
  return JSON.parse(raw);
843
1474
  } catch (err) {
844
1475
  if (err.code === "ENOENT") return null;
@@ -846,11 +1477,11 @@ async function readConversation(filePath) {
846
1477
  }
847
1478
  }
848
1479
  async function writeConversation(filePath, data) {
849
- await fs3.mkdir(path3.dirname(filePath), { recursive: true });
850
- await fs3.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
1480
+ await fs4.mkdir(path5.dirname(filePath), { recursive: true });
1481
+ await fs4.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
851
1482
  }
852
1483
  function createLocalDirectoryStore(baseDir) {
853
- const fileFor = (id) => path3.join(baseDir, `${id}.json`);
1484
+ const fileFor = (id) => path5.join(baseDir, `${id}.json`);
854
1485
  return {
855
1486
  async ensureConversation(id) {
856
1487
  const existing = await readConversation(fileFor(id));
@@ -868,21 +1499,273 @@ function createLocalDirectoryStore(baseDir) {
868
1499
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
869
1500
  messages: []
870
1501
  };
1502
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1503
+ const isClientUser = input.role === "user" && input.senderType !== "developer" && input.provider !== "developer";
1504
+ const isWorking = input.provider === "working";
1505
+ const isAi = input.role === "assistant" && input.provider !== "working" && input.senderType !== "developer" && input.provider !== "developer";
1506
+ const busy = existing.messages.some((m) => m.queueStatus === "working");
1507
+ const makeWorking = (parentMessageId) => ({
1508
+ id: randomUUID2(),
1509
+ conversationId: input.conversationId,
1510
+ role: "assistant",
1511
+ content: "",
1512
+ provider: "working",
1513
+ createdAt: now,
1514
+ senderType: "ai",
1515
+ parentMessageId
1516
+ });
1517
+ const promoteNext = () => {
1518
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1519
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1520
+ );
1521
+ const next = waiting[0];
1522
+ if (!next) return null;
1523
+ next.queueStatus = "working";
1524
+ next.queuePosition = null;
1525
+ existing.messages.push(makeWorking(next.id));
1526
+ return next;
1527
+ };
1528
+ if (isClientUser) {
1529
+ if (input.intent === "run") {
1530
+ for (const row of existing.messages) {
1531
+ if (row.queueStatus === "working") {
1532
+ row.queueStatus = null;
1533
+ row.queuePosition = null;
1534
+ }
1535
+ }
1536
+ existing.messages = existing.messages.filter(
1537
+ (m) => m.provider !== "working"
1538
+ );
1539
+ }
1540
+ const queueStatus = input.intent === "run" ? "working" : busy ? "queued" : "working";
1541
+ const existingById2 = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
1542
+ if (existingById2) {
1543
+ return { ...existingById2, nextQueued: null };
1544
+ }
1545
+ const message2 = {
1546
+ id: input.id ?? randomUUID2(),
1547
+ conversationId: input.conversationId,
1548
+ role: input.role,
1549
+ content: input.content,
1550
+ provider: input.provider ?? null,
1551
+ createdAt: now,
1552
+ attachmentPaths: input.attachmentPaths,
1553
+ senderType: input.senderType ?? null,
1554
+ senderName: input.senderName ?? null,
1555
+ parentMessageId: input.parentMessageId ?? null,
1556
+ queueStatus,
1557
+ queuePosition: null
1558
+ };
1559
+ existing.messages.push(message2);
1560
+ existing.updatedAt = now;
1561
+ await writeConversation(filePath, existing);
1562
+ return { ...message2, nextQueued: null };
1563
+ }
1564
+ if (isWorking) {
1565
+ existing.messages = existing.messages.filter(
1566
+ (m) => m.provider !== "working"
1567
+ );
1568
+ if (input.parentMessageId) {
1569
+ const parent = existing.messages.find(
1570
+ (m) => m.id === input.parentMessageId
1571
+ );
1572
+ if (parent && parent.queueStatus !== "working") {
1573
+ parent.queueStatus = "working";
1574
+ parent.queuePosition = null;
1575
+ }
1576
+ }
1577
+ const message2 = {
1578
+ id: input.id ?? randomUUID2(),
1579
+ conversationId: input.conversationId,
1580
+ role: "assistant",
1581
+ content: "",
1582
+ provider: "working",
1583
+ createdAt: now,
1584
+ senderType: "ai",
1585
+ parentMessageId: input.parentMessageId ?? null
1586
+ };
1587
+ existing.messages.push(message2);
1588
+ existing.updatedAt = now;
1589
+ await writeConversation(filePath, existing);
1590
+ return { ...message2, nextQueued: null };
1591
+ }
1592
+ const existingById = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
1593
+ if (existingById) {
1594
+ return { ...existingById, nextQueued: null };
1595
+ }
871
1596
  const message = {
872
- id: randomUUID(),
1597
+ id: input.id ?? randomUUID2(),
873
1598
  conversationId: input.conversationId,
874
1599
  role: input.role,
875
1600
  content: input.content,
876
1601
  provider: input.provider ?? null,
877
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1602
+ createdAt: now,
878
1603
  attachmentPaths: input.attachmentPaths,
879
1604
  senderType: input.senderType ?? null,
880
- senderName: input.senderName ?? null
1605
+ senderName: input.senderName ?? null,
1606
+ parentMessageId: input.parentMessageId ?? null
881
1607
  };
882
1608
  existing.messages.push(message);
883
- existing.updatedAt = message.createdAt;
1609
+ let nextQueued = null;
1610
+ if (isAi) {
1611
+ existing.messages = existing.messages.filter(
1612
+ (m) => m.provider !== "working"
1613
+ );
1614
+ if (input.parentMessageId) {
1615
+ const parent = existing.messages.find(
1616
+ (m) => m.id === input.parentMessageId
1617
+ );
1618
+ if (parent) {
1619
+ parent.queueStatus = null;
1620
+ parent.queuePosition = null;
1621
+ }
1622
+ } else {
1623
+ for (const row of existing.messages) {
1624
+ if (row.queueStatus === "working") {
1625
+ row.queueStatus = null;
1626
+ row.queuePosition = null;
1627
+ }
1628
+ }
1629
+ }
1630
+ nextQueued = promoteNext();
1631
+ }
1632
+ existing.updatedAt = now;
1633
+ await writeConversation(filePath, existing);
1634
+ return {
1635
+ ...message,
1636
+ nextQueued: nextQueued ? { id: nextQueued.id, content: nextQueued.content } : null
1637
+ };
1638
+ },
1639
+ async recoverQueue(conversationId) {
1640
+ const filePath = fileFor(conversationId);
1641
+ const existing = await readConversation(filePath);
1642
+ if (!existing) {
1643
+ return {
1644
+ action: "noop",
1645
+ workingId: null,
1646
+ promotedId: null,
1647
+ nextQueued: null
1648
+ };
1649
+ }
1650
+ const working = existing.messages.find(
1651
+ (m) => m.role === "user" && m.queueStatus === "working"
1652
+ );
1653
+ if (working) {
1654
+ if (userHasAiReply(existing.messages, working.id)) {
1655
+ existing.messages = existing.messages.filter(
1656
+ (m) => m.provider !== "working"
1657
+ );
1658
+ working.queueStatus = null;
1659
+ working.queuePosition = null;
1660
+ const waiting2 = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1661
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1662
+ );
1663
+ const next2 = waiting2[0] ?? null;
1664
+ if (next2) {
1665
+ next2.queueStatus = "working";
1666
+ next2.queuePosition = null;
1667
+ existing.messages.push({
1668
+ id: randomUUID2(),
1669
+ conversationId,
1670
+ role: "assistant",
1671
+ content: "",
1672
+ provider: "working",
1673
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1674
+ senderType: "ai",
1675
+ parentMessageId: next2.id
1676
+ });
1677
+ }
1678
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1679
+ await writeConversation(filePath, existing);
1680
+ return {
1681
+ action: "completed_and_promoted",
1682
+ workingId: working.id,
1683
+ promotedId: next2?.id ?? null,
1684
+ nextQueued: next2 ? { id: next2.id, content: next2.content } : null
1685
+ };
1686
+ }
1687
+ return {
1688
+ action: "redispatched_working",
1689
+ workingId: working.id,
1690
+ promotedId: null,
1691
+ nextQueued: null
1692
+ };
1693
+ }
1694
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1695
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1696
+ );
1697
+ const next = waiting[0] ?? null;
1698
+ if (!next) {
1699
+ return {
1700
+ action: "noop",
1701
+ workingId: null,
1702
+ promotedId: null,
1703
+ nextQueued: null
1704
+ };
1705
+ }
1706
+ next.queueStatus = "working";
1707
+ next.queuePosition = null;
1708
+ existing.messages.push({
1709
+ id: randomUUID2(),
1710
+ conversationId,
1711
+ role: "assistant",
1712
+ content: "",
1713
+ provider: "working",
1714
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1715
+ senderType: "ai",
1716
+ parentMessageId: next.id
1717
+ });
1718
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1719
+ await writeConversation(filePath, existing);
1720
+ return {
1721
+ action: "promoted_queued",
1722
+ workingId: null,
1723
+ promotedId: next.id,
1724
+ nextQueued: { id: next.id, content: next.content }
1725
+ };
1726
+ },
1727
+ async releaseWorkingTurn(conversationId, parentMessageId) {
1728
+ const filePath = fileFor(conversationId);
1729
+ const existing = await readConversation(filePath);
1730
+ if (!existing) return { nextQueued: null };
1731
+ existing.messages = existing.messages.filter(
1732
+ (m) => m.provider !== "working"
1733
+ );
1734
+ if (parentMessageId) {
1735
+ const parent = existing.messages.find((m) => m.id === parentMessageId);
1736
+ if (parent) {
1737
+ parent.queueStatus = null;
1738
+ parent.queuePosition = null;
1739
+ }
1740
+ } else {
1741
+ for (const row of existing.messages) {
1742
+ if (row.queueStatus === "working") {
1743
+ row.queueStatus = null;
1744
+ row.queuePosition = null;
1745
+ }
1746
+ }
1747
+ }
1748
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1749
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1750
+ );
1751
+ const next = waiting[0] ?? null;
1752
+ if (next) {
1753
+ next.queueStatus = "working";
1754
+ next.queuePosition = null;
1755
+ existing.messages.push({
1756
+ id: randomUUID2(),
1757
+ conversationId,
1758
+ role: "assistant",
1759
+ content: "",
1760
+ provider: "working",
1761
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1762
+ senderType: "ai",
1763
+ parentMessageId: next.id
1764
+ });
1765
+ }
1766
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
884
1767
  await writeConversation(filePath, existing);
885
- return message;
1768
+ return next ? { nextQueued: { id: next.id, content: next.content } } : { nextQueued: null };
886
1769
  },
887
1770
  async clearWorkingMessages(conversationId) {
888
1771
  const filePath = fileFor(conversationId);
@@ -899,12 +1782,12 @@ function createLocalDirectoryStore(baseDir) {
899
1782
  return existing?.messages ?? [];
900
1783
  },
901
1784
  async listConversations() {
902
- await fs3.mkdir(baseDir, { recursive: true });
903
- const entries = await fs3.readdir(baseDir);
1785
+ await fs4.mkdir(baseDir, { recursive: true });
1786
+ const entries = await fs4.readdir(baseDir);
904
1787
  const conversations = [];
905
1788
  for (const entry of entries) {
906
1789
  if (!entry.endsWith(".json")) continue;
907
- const filePath = path3.join(baseDir, entry);
1790
+ const filePath = path5.join(baseDir, entry);
908
1791
  const existing = await readConversation(filePath);
909
1792
  if (!existing?.id) continue;
910
1793
  conversations.push({
@@ -921,7 +1804,7 @@ function createLocalDirectoryStore(baseDir) {
921
1804
  if (events.length === 0) return;
922
1805
  const existing = await readConversation(fileFor(conversationId));
923
1806
  if (!existing) return;
924
- const toolFile = path3.join(baseDir, `${conversationId}.tools.jsonl`);
1807
+ const toolFile = path5.join(baseDir, `${conversationId}.tools.jsonl`);
925
1808
  const lines = events.map(
926
1809
  (e) => JSON.stringify({
927
1810
  conversationId,
@@ -929,33 +1812,386 @@ function createLocalDirectoryStore(baseDir) {
929
1812
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
930
1813
  })
931
1814
  ).join("\n");
932
- await fs3.appendFile(toolFile, `${lines}
1815
+ await fs4.appendFile(toolFile, `${lines}
933
1816
  `, "utf8");
934
1817
  }
935
1818
  };
936
1819
  }
937
1820
 
938
1821
  // src/http/maintainer-pro-store.ts
939
- import fs4 from "fs/promises";
940
- import os from "os";
941
- import path4 from "path";
942
- async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
943
- const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
944
- const res = await fetchImpl(url, {
945
- ...init,
946
- headers: {
947
- Authorization: `Bearer ${apiKey}`,
948
- "Content-Type": "application/json",
949
- ...init?.headers ?? {}
950
- }
951
- });
952
- if (!res.ok) {
953
- const text = await res.text().catch(() => "");
954
- throw new Error(
955
- `Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
956
- );
957
- }
958
- const contentType = res.headers.get("content-type") ?? "";
1822
+ import fs5 from "fs/promises";
1823
+ import path6 from "path";
1824
+
1825
+ // src/http/synced-store.ts
1826
+ function userHasAiReply2(rows, workingId) {
1827
+ return rows.some(
1828
+ (row) => row.role === "assistant" && row.parentMessageId === workingId && row.provider !== "working" && row.senderType !== "developer" && Boolean(row.content?.trim())
1829
+ );
1830
+ }
1831
+ function nowIso() {
1832
+ return (/* @__PURE__ */ new Date()).toISOString();
1833
+ }
1834
+ function toWsUrl(baseUrl, apiKey) {
1835
+ const u = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/ws`);
1836
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
1837
+ u.searchParams.set("apiKey", apiKey);
1838
+ return u.toString();
1839
+ }
1840
+ function asCachedMessage(raw) {
1841
+ const id = typeof raw.id === "string" ? raw.id : "";
1842
+ const role = typeof raw.role === "string" ? raw.role : "";
1843
+ if (!id || !role) return null;
1844
+ return {
1845
+ id,
1846
+ role,
1847
+ content: typeof raw.content === "string" ? raw.content : "",
1848
+ provider: typeof raw.provider === "string" ? raw.provider : null,
1849
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : void 0,
1850
+ attachmentPaths: Array.isArray(raw.attachmentPaths) ? raw.attachmentPaths.filter((p) => typeof p === "string") : Array.isArray(raw.attachmentIds) ? raw.attachmentIds.filter((id2) => typeof id2 === "string").map((id2) => `maintainer-pro://${id2}`) : void 0,
1851
+ senderType: raw.senderType === "client" || raw.senderType === "developer" || raw.senderType === "ai" ? raw.senderType : null,
1852
+ senderName: typeof raw.senderName === "string" ? raw.senderName : null,
1853
+ parentMessageId: typeof raw.parentMessageId === "string" ? raw.parentMessageId : null,
1854
+ queueStatus: raw.queueStatus === "working" || raw.queueStatus === "queued" ? raw.queueStatus : raw.queueStatus === null ? null : void 0,
1855
+ queuePosition: typeof raw.queuePosition === "number" ? raw.queuePosition : null
1856
+ };
1857
+ }
1858
+ function cloneMessages(messages) {
1859
+ return messages.map((m) => ({ ...m }));
1860
+ }
1861
+ function createSyncedChatStore(options) {
1862
+ const { remote, maintainerProUrl, apiKey, onWorkingTurn } = options;
1863
+ const logger = options.logger ?? createLogger("ai-cli");
1864
+ const info = (msg) => {
1865
+ if (options.log) options.log(msg);
1866
+ else logger.info(msg);
1867
+ };
1868
+ const conversations = /* @__PURE__ */ new Map();
1869
+ const inflightRefresh = /* @__PURE__ */ new Map();
1870
+ let stopped = false;
1871
+ let socket = null;
1872
+ let reconnectAttempt = 0;
1873
+ let reconnectTimer = null;
1874
+ let pingTimer = null;
1875
+ const stats = () => {
1876
+ let messages = 0;
1877
+ for (const conv of conversations.values()) messages += conv.messages.length;
1878
+ return { conversations: conversations.size, messages };
1879
+ };
1880
+ const putConversation = (id, messages, updatedAt = nowIso()) => {
1881
+ conversations.set(id, { id, updatedAt, messages: cloneMessages(messages) });
1882
+ };
1883
+ const refreshConversation = (id) => {
1884
+ const existing = inflightRefresh.get(id);
1885
+ if (existing) return existing;
1886
+ const pending = (async () => {
1887
+ const messages = remote.listMessages ? await remote.listMessages(id) : [];
1888
+ putConversation(id, messages);
1889
+ return cloneMessages(messages);
1890
+ })().finally(() => {
1891
+ inflightRefresh.delete(id);
1892
+ });
1893
+ inflightRefresh.set(id, pending);
1894
+ return pending;
1895
+ };
1896
+ const loadSnapshot = async () => {
1897
+ if (remote.listSnapshot) {
1898
+ try {
1899
+ return await remote.listSnapshot();
1900
+ } catch {
1901
+ }
1902
+ }
1903
+ if (!remote.listConversations || !remote.listMessages) return [];
1904
+ const summaries = await remote.listConversations();
1905
+ const loaded = [];
1906
+ for (const row of summaries) {
1907
+ loaded.push({
1908
+ id: row.id,
1909
+ updatedAt: row.updatedAt,
1910
+ messages: await remote.listMessages(row.id)
1911
+ });
1912
+ }
1913
+ return loaded;
1914
+ };
1915
+ const hydrate = async () => {
1916
+ const snapshot = await loadSnapshot();
1917
+ conversations.clear();
1918
+ for (const row of snapshot) {
1919
+ putConversation(row.id, row.messages, row.updatedAt);
1920
+ }
1921
+ const next = stats();
1922
+ info(
1923
+ `cache hydrated (${next.conversations} conversations, ${next.messages} messages)`
1924
+ );
1925
+ for (const conv of conversations.values()) {
1926
+ const working = conv.messages.find(
1927
+ (row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
1928
+ );
1929
+ if (!working) continue;
1930
+ if (!userHasAiReply2(conv.messages, working.id)) {
1931
+ logger.debug(
1932
+ { conversationId: conv.id, messageId: working.id },
1933
+ "resume working turn"
1934
+ );
1935
+ onWorkingTurn?.({ conversationId: conv.id, message: working });
1936
+ }
1937
+ }
1938
+ return next;
1939
+ };
1940
+ const upsertMessage = (conversationId, message) => {
1941
+ const conv = conversations.get(conversationId) ?? {
1942
+ id: conversationId,
1943
+ updatedAt: nowIso(),
1944
+ messages: []
1945
+ };
1946
+ const index = conv.messages.findIndex((row) => row.id === message.id);
1947
+ const wasWorking = index >= 0 && conv.messages[index]?.queueStatus === "working";
1948
+ if (index >= 0) {
1949
+ conv.messages[index] = { ...conv.messages[index], ...message };
1950
+ } else {
1951
+ conv.messages.push(message);
1952
+ }
1953
+ conv.updatedAt = nowIso();
1954
+ conversations.set(conversationId, conv);
1955
+ const becameWorking = message.role === "user" && message.queueStatus === "working" && message.senderType !== "developer" && !wasWorking;
1956
+ if (becameWorking) {
1957
+ logger.debug(
1958
+ { conversationId, messageId: message.id },
1959
+ "working turn"
1960
+ );
1961
+ onWorkingTurn?.({ conversationId, message });
1962
+ }
1963
+ };
1964
+ const deleteMessages = (conversationId, ids) => {
1965
+ const conv = conversations.get(conversationId);
1966
+ if (!conv || ids.length === 0) return;
1967
+ const drop = new Set(ids);
1968
+ conv.messages = conv.messages.filter((row) => !drop.has(row.id));
1969
+ conv.updatedAt = nowIso();
1970
+ };
1971
+ const applyEvent = (msg) => {
1972
+ const externalId = typeof msg.externalId === "string" && msg.externalId ? msg.externalId : typeof msg.conversationId === "string" ? msg.conversationId : "";
1973
+ if (!externalId) return;
1974
+ if (msg.type === "message.created") {
1975
+ const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
1976
+ const cached = raw ? asCachedMessage(raw) : null;
1977
+ if (cached) {
1978
+ upsertMessage(externalId, cached);
1979
+ return;
1980
+ }
1981
+ void refreshConversation(externalId).catch(() => void 0);
1982
+ return;
1983
+ }
1984
+ if (msg.type === "message.updated") {
1985
+ const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
1986
+ const cached = raw ? asCachedMessage(raw) : null;
1987
+ if (cached) {
1988
+ upsertMessage(externalId, cached);
1989
+ return;
1990
+ }
1991
+ void refreshConversation(externalId).catch(() => void 0);
1992
+ return;
1993
+ }
1994
+ if (msg.type === "message.deleted") {
1995
+ const ids = Array.isArray(msg.ids) ? msg.ids.filter((id) => typeof id === "string") : [];
1996
+ deleteMessages(externalId, ids);
1997
+ }
1998
+ };
1999
+ const stopSocket = () => {
2000
+ if (pingTimer) {
2001
+ clearInterval(pingTimer);
2002
+ pingTimer = null;
2003
+ }
2004
+ if (reconnectTimer) {
2005
+ clearTimeout(reconnectTimer);
2006
+ reconnectTimer = null;
2007
+ }
2008
+ try {
2009
+ socket?.close();
2010
+ } catch {
2011
+ }
2012
+ socket = null;
2013
+ };
2014
+ const connectRealtime = () => {
2015
+ if (stopped) return;
2016
+ const WebSocketCtor = globalThis.WebSocket;
2017
+ if (!WebSocketCtor) {
2018
+ info("WebSocket unavailable; cache will refresh after local writes only");
2019
+ return;
2020
+ }
2021
+ const ws = new WebSocketCtor(toWsUrl(maintainerProUrl, apiKey));
2022
+ socket = ws;
2023
+ ws.addEventListener("open", () => {
2024
+ reconnectAttempt = 0;
2025
+ logger.debug("store websocket open");
2026
+ if (pingTimer) clearInterval(pingTimer);
2027
+ pingTimer = setInterval(() => {
2028
+ try {
2029
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" }));
2030
+ } catch {
2031
+ }
2032
+ }, 2e4);
2033
+ });
2034
+ ws.addEventListener("message", (event) => {
2035
+ let payload;
2036
+ try {
2037
+ payload = JSON.parse(String(event.data));
2038
+ } catch {
2039
+ return;
2040
+ }
2041
+ if (payload.type === "hello") {
2042
+ const meta = payload.meta && typeof payload.meta === "object" ? payload.meta : null;
2043
+ const sandboxId = typeof meta?.sandboxId === "string" ? meta.sandboxId : "";
2044
+ if (sandboxId && ws.readyState === 1) {
2045
+ ws.send(
2046
+ JSON.stringify({ type: "subscribe", channels: [`sandbox:${sandboxId}`] })
2047
+ );
2048
+ }
2049
+ logger.debug({ sandboxId }, "store websocket hello");
2050
+ return;
2051
+ }
2052
+ if (payload.type === "message.created" || payload.type === "message.updated" || payload.type === "message.deleted") {
2053
+ logger.debug({ type: payload.type }, "store websocket event");
2054
+ applyEvent(payload);
2055
+ }
2056
+ });
2057
+ ws.addEventListener("close", () => {
2058
+ if (socket === ws) socket = null;
2059
+ if (pingTimer) {
2060
+ clearInterval(pingTimer);
2061
+ pingTimer = null;
2062
+ }
2063
+ if (stopped) return;
2064
+ const delay = Math.min(3e4, 1e3 * 2 ** Math.min(reconnectAttempt, 5));
2065
+ reconnectAttempt += 1;
2066
+ logger.debug({ delay, reconnectAttempt }, "store websocket reconnect");
2067
+ reconnectTimer = setTimeout(() => {
2068
+ reconnectTimer = null;
2069
+ void hydrate().catch((err) => {
2070
+ logger.warn(
2071
+ `cache rehydrate failed: ${err instanceof Error ? err.message : String(err)}`
2072
+ );
2073
+ }).finally(() => {
2074
+ connectRealtime();
2075
+ });
2076
+ }, delay);
2077
+ });
2078
+ };
2079
+ const ready = hydrate().catch((err) => {
2080
+ logger.warn(
2081
+ `cache hydrate failed: ${err instanceof Error ? err.message : String(err)}`
2082
+ );
2083
+ return stats();
2084
+ });
2085
+ void ready.then(() => {
2086
+ if (!stopped) connectRealtime();
2087
+ });
2088
+ const store = {
2089
+ ready,
2090
+ stop: () => {
2091
+ stopped = true;
2092
+ stopSocket();
2093
+ },
2094
+ async ensureConversation(id) {
2095
+ await remote.ensureConversation(id);
2096
+ if (!conversations.has(id)) {
2097
+ putConversation(id, []);
2098
+ }
2099
+ },
2100
+ async saveMessage(input) {
2101
+ const saved = await remote.saveMessage(input);
2102
+ await refreshConversation(input.conversationId).catch(() => void 0);
2103
+ return saved;
2104
+ },
2105
+ async listMessages(conversationId) {
2106
+ await ready.catch(() => void 0);
2107
+ const hit = conversations.get(conversationId);
2108
+ if (hit) return cloneMessages(hit.messages);
2109
+ return refreshConversation(conversationId);
2110
+ },
2111
+ async clearWorkingMessages(conversationId) {
2112
+ await remote.clearWorkingMessages?.(conversationId);
2113
+ await refreshConversation(conversationId).catch(() => void 0);
2114
+ },
2115
+ async releaseWorkingTurn(conversationId, parentMessageId) {
2116
+ const result = await remote.releaseWorkingTurn?.(
2117
+ conversationId,
2118
+ parentMessageId
2119
+ );
2120
+ await refreshConversation(conversationId).catch(() => void 0);
2121
+ return result ?? { nextQueued: null };
2122
+ },
2123
+ async recoverQueue(conversationId) {
2124
+ const result = await remote.recoverQueue?.(conversationId);
2125
+ await refreshConversation(conversationId).catch(() => void 0);
2126
+ const conv = conversations.get(conversationId);
2127
+ if (conv) {
2128
+ const working = conv.messages.find(
2129
+ (row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
2130
+ );
2131
+ if (working && !userHasAiReply2(conv.messages, working.id)) {
2132
+ logger.info(
2133
+ { conversationId, messageId: working.id, action: result?.action },
2134
+ "recoverQueue \u2192 resume working turn"
2135
+ );
2136
+ onWorkingTurn?.({ conversationId, message: working });
2137
+ }
2138
+ }
2139
+ return result ?? {
2140
+ action: "noop",
2141
+ workingId: null,
2142
+ promotedId: null,
2143
+ nextQueued: null
2144
+ };
2145
+ },
2146
+ async listConversations() {
2147
+ await ready.catch(() => void 0);
2148
+ return [...conversations.values()].map((conv) => ({
2149
+ id: conv.id,
2150
+ updatedAt: conv.updatedAt,
2151
+ messages: cloneMessages(conv.messages)
2152
+ })).sort(
2153
+ (a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
2154
+ );
2155
+ },
2156
+ async saveToolEvents(conversationId, events) {
2157
+ await remote.saveToolEvents?.(conversationId, events);
2158
+ },
2159
+ async uploadAttachments(conversationId, attachments) {
2160
+ if (!remote.uploadAttachments) {
2161
+ return { refs: [], localPaths: [] };
2162
+ }
2163
+ return remote.uploadAttachments(conversationId, attachments);
2164
+ },
2165
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2166
+ if (!remote.materializeMessageAttachments) return [];
2167
+ return remote.materializeMessageAttachments(
2168
+ conversationId,
2169
+ messageId,
2170
+ workspaceDir
2171
+ );
2172
+ }
2173
+ };
2174
+ return store;
2175
+ }
2176
+
2177
+ // src/http/maintainer-pro-store.ts
2178
+ async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
2179
+ const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
2180
+ const res = await fetchImpl(url, {
2181
+ ...init,
2182
+ headers: {
2183
+ Authorization: `Bearer ${apiKey}`,
2184
+ "Content-Type": "application/json",
2185
+ ...init?.headers ?? {}
2186
+ }
2187
+ });
2188
+ if (!res.ok) {
2189
+ const text = await res.text().catch(() => "");
2190
+ throw new Error(
2191
+ `Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
2192
+ );
2193
+ }
2194
+ const contentType = res.headers.get("content-type") ?? "";
959
2195
  if (contentType.includes("application/json")) {
960
2196
  return res.json();
961
2197
  }
@@ -965,7 +2201,8 @@ function createMaintainerProStore(options) {
965
2201
  const baseUrl = options.baseUrl;
966
2202
  const apiKey = options.apiKey;
967
2203
  const fetchImpl = options.fetchImpl ?? fetch;
968
- const tempDir = options.tempDir ?? path4.join(os.tmpdir(), "maintainer-pro-store");
2204
+ const logger = options.logger;
2205
+ const tempDir = options.tempDir ?? projectUploadsDir({ workspaceDir: process.cwd() });
969
2206
  const store = {
970
2207
  async ensureConversation(id) {
971
2208
  await mpFetch(
@@ -988,17 +2225,54 @@ function createMaintainerProStore(options) {
988
2225
  {
989
2226
  method: "POST",
990
2227
  body: JSON.stringify({
2228
+ id: input.id,
991
2229
  role: input.role,
992
2230
  content: input.content,
993
2231
  provider: input.provider,
994
2232
  senderType: input.senderType,
995
2233
  senderName: input.senderName,
996
- attachmentIds
2234
+ parentMessageId: input.parentMessageId ?? void 0,
2235
+ attachmentIds,
2236
+ intent: input.intent,
2237
+ queueStatus: input.queueStatus ?? void 0
997
2238
  })
998
2239
  },
999
2240
  fetchImpl
1000
2241
  );
1001
- return data.message;
2242
+ return {
2243
+ ...data.message,
2244
+ nextQueued: data.nextQueued ?? null
2245
+ };
2246
+ },
2247
+ async releaseWorkingTurn(conversationId, parentMessageId) {
2248
+ const data = await mpFetch(
2249
+ baseUrl,
2250
+ apiKey,
2251
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/complete-turn`,
2252
+ {
2253
+ method: "POST",
2254
+ body: JSON.stringify({
2255
+ parentMessageId: parentMessageId ?? void 0
2256
+ })
2257
+ },
2258
+ fetchImpl
2259
+ );
2260
+ return { nextQueued: data.nextQueued ?? null };
2261
+ },
2262
+ async recoverQueue(conversationId) {
2263
+ const data = await mpFetch(
2264
+ baseUrl,
2265
+ apiKey,
2266
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/recover-queue`,
2267
+ { method: "POST", body: "{}" },
2268
+ fetchImpl
2269
+ );
2270
+ return {
2271
+ action: data.action ?? "noop",
2272
+ workingId: data.workingId ?? null,
2273
+ promotedId: data.promotedId ?? null,
2274
+ nextQueued: data.nextQueued ?? null
2275
+ };
1002
2276
  },
1003
2277
  async clearWorkingMessages(conversationId) {
1004
2278
  await mpFetch(
@@ -1029,6 +2303,16 @@ function createMaintainerProStore(options) {
1029
2303
  );
1030
2304
  return data.conversations ?? [];
1031
2305
  },
2306
+ async listSnapshot() {
2307
+ const data = await mpFetch(
2308
+ baseUrl,
2309
+ apiKey,
2310
+ `/api/v1/store/snapshot`,
2311
+ { method: "GET" },
2312
+ fetchImpl
2313
+ );
2314
+ return data.conversations ?? [];
2315
+ },
1032
2316
  async saveToolEvents(conversationId, events) {
1033
2317
  if (!events.length) return;
1034
2318
  await mpFetch(
@@ -1046,7 +2330,7 @@ function createMaintainerProStore(options) {
1046
2330
  if (!attachments?.length) {
1047
2331
  return { refs: [], localPaths: [], attachmentIds: [] };
1048
2332
  }
1049
- await fs4.mkdir(tempDir, { recursive: true });
2333
+ await fs5.mkdir(tempDir, { recursive: true });
1050
2334
  const refs = [];
1051
2335
  const localPaths = [];
1052
2336
  const attachmentIds = [];
@@ -1069,28 +2353,131 @@ function createMaintainerProStore(options) {
1069
2353
  const id = data.attachment.id;
1070
2354
  attachmentIds.push(id);
1071
2355
  refs.push(data.attachment.ref || `maintainer-pro://${id}`);
1072
- const ext = item.mimeType.includes("jpeg") || item.mimeType.includes("jpg") ? ".jpg" : item.mimeType.includes("webp") ? ".webp" : item.mimeType.includes("gif") ? ".gif" : ".png";
1073
- const localPath = path4.join(
2356
+ const localPath = path6.join(
1074
2357
  tempDir,
1075
- `${conversationId}-${id}${ext}`
2358
+ `${conversationId}-${id}${extForMime(item.mimeType)}`
1076
2359
  );
1077
- await fs4.writeFile(localPath, Buffer.from(item.data, "base64"));
2360
+ await fs5.mkdir(path6.dirname(localPath), { recursive: true });
2361
+ await fs5.writeFile(localPath, Buffer.from(item.data, "base64"));
1078
2362
  localPaths.push(localPath);
1079
2363
  }
1080
2364
  return { refs, localPaths, attachmentIds };
2365
+ },
2366
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2367
+ if (!conversationId || !workspaceDir) return [];
2368
+ const data = await mpFetch(
2369
+ baseUrl,
2370
+ apiKey,
2371
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages`,
2372
+ { method: "GET" },
2373
+ fetchImpl
2374
+ );
2375
+ const messages = data.messages ?? [];
2376
+ const byId = new Map(messages.map((row) => [row.id, row]));
2377
+ const refs = [];
2378
+ const fromMessageIds = [];
2379
+ const seenMsg = /* @__PURE__ */ new Set();
2380
+ const pushRow = (row) => {
2381
+ if (!row || seenMsg.has(row.id)) return;
2382
+ seenMsg.add(row.id);
2383
+ const before = refs.length;
2384
+ refs.push(...row.attachmentPaths ?? []);
2385
+ refs.push(
2386
+ ...(row.attachmentIds ?? []).map((id) => `maintainer-pro://${id}`)
2387
+ );
2388
+ if (refs.length > before) fromMessageIds.push(row.id);
2389
+ };
2390
+ let current = messageId ? byId.get(messageId) : void 0;
2391
+ while (current && !seenMsg.has(current.id)) {
2392
+ pushRow(current);
2393
+ const parentId = current.parentMessageId?.trim();
2394
+ current = parentId ? byId.get(parentId) : void 0;
2395
+ }
2396
+ for (const row of messages) {
2397
+ if (row.role && row.role !== "user") continue;
2398
+ pushRow(row);
2399
+ }
2400
+ const seen = /* @__PURE__ */ new Set();
2401
+ const ids = [];
2402
+ for (const ref of refs) {
2403
+ const id = attachmentIdFromRef(ref);
2404
+ if (!id || seen.has(id)) continue;
2405
+ seen.add(id);
2406
+ ids.push(id);
2407
+ }
2408
+ const localPaths = [];
2409
+ for (const id of ids.slice(0, 5)) {
2410
+ const url = `${baseUrl.replace(/\/$/, "")}/api/v1/store/attachments/${encodeURIComponent(id)}`;
2411
+ const res = await fetchImpl(url, {
2412
+ headers: { Authorization: `Bearer ${apiKey}` }
2413
+ });
2414
+ if (!res.ok) {
2415
+ continue;
2416
+ }
2417
+ const mime = res.headers.get("content-type") || "image/png";
2418
+ const buffer = Buffer.from(await res.arrayBuffer());
2419
+ if (!buffer.byteLength) continue;
2420
+ const localPath = path6.join(
2421
+ tempDir,
2422
+ `${id}${extForMime(mime)}`
2423
+ );
2424
+ await fs5.mkdir(tempDir, { recursive: true });
2425
+ await fs5.writeFile(localPath, buffer);
2426
+ localPaths.push(localPath);
2427
+ }
2428
+ logger?.debug(
2429
+ {
2430
+ conversationId,
2431
+ messageId,
2432
+ fromMessageIds,
2433
+ stored: ids.length,
2434
+ written: localPaths.length
2435
+ },
2436
+ "materialize attachments"
2437
+ );
2438
+ return localPaths;
1081
2439
  }
1082
2440
  };
1083
- return store;
2441
+ if (options.sync === false) {
2442
+ return Object.assign(store, {
2443
+ ready: Promise.resolve({ conversations: 0, messages: 0 }),
2444
+ stop() {
2445
+ }
2446
+ });
2447
+ }
2448
+ return createSyncedChatStore({
2449
+ remote: store,
2450
+ maintainerProUrl: baseUrl,
2451
+ apiKey,
2452
+ logger: options.logger,
2453
+ log: options.log,
2454
+ onWorkingTurn: options.onWorkingTurn
2455
+ });
1084
2456
  }
1085
- function createMaintainerProStoreFromEnv() {
2457
+ function createMaintainerProStoreFromEnv(options) {
1086
2458
  const baseUrl = process.env.MAINTAINER_PRO_URL?.trim();
1087
2459
  const apiKey = process.env.MAINTAINER_PRO_API_KEY?.trim();
1088
2460
  if (!baseUrl || !apiKey) return null;
1089
- return createMaintainerProStore({ baseUrl, apiKey });
2461
+ return createMaintainerProStore({
2462
+ baseUrl,
2463
+ apiKey,
2464
+ tempDir: projectUploadsDir({
2465
+ workspaceDir: process.env.AI_CLI_WORKSPACE || process.cwd(),
2466
+ sandboxId: process.env.MAINTAINER_PRO_SANDBOX_ID,
2467
+ dataDir: process.env.MAINTAINER_PRO_DATA_DIR
2468
+ }),
2469
+ logger: options?.logger,
2470
+ log: options?.log,
2471
+ sync: options?.sync,
2472
+ onWorkingTurn: options?.onWorkingTurn
2473
+ });
1090
2474
  }
1091
2475
 
1092
2476
  // src/workspace-inspect.ts
2477
+ import fs6 from "fs";
2478
+ import path7 from "path";
1093
2479
  import { z } from "zod";
2480
+ var CONFIG_INSPECT_TIMEOUT_MS = 18e3;
1094
2481
  var inspectJsonSchema = z.object({
1095
2482
  kind: z.enum(["next", "vite", "html", "empty", "other"]).optional(),
1096
2483
  name: z.string().max(200).optional(),
@@ -1160,6 +2547,154 @@ ${input.extraContext ? `${input.extraContext.trim()}
1160
2547
 
1161
2548
  ` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
1162
2549
  }
2550
+ function readSnippet(file, max = 4e3) {
2551
+ try {
2552
+ const text = fs6.readFileSync(file, "utf8");
2553
+ return text.length > max ? `${text.slice(0, max)}
2554
+ \u2026` : text;
2555
+ } catch {
2556
+ return null;
2557
+ }
2558
+ }
2559
+ function envKeysOnly(file) {
2560
+ try {
2561
+ const keys = fs6.readFileSync(file, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).map((line) => line.slice(0, line.indexOf("=")).trim()).filter(Boolean);
2562
+ return keys.length ? keys.join("\n") : null;
2563
+ } catch {
2564
+ return null;
2565
+ }
2566
+ }
2567
+ function gatherConfigSnippets(workspaceDir) {
2568
+ const folder = path7.resolve(workspaceDir);
2569
+ const parts = [];
2570
+ const pkg = readSnippet(path7.join(folder, "package.json"));
2571
+ if (pkg) parts.push(`### package.json
2572
+ \`\`\`json
2573
+ ${pkg}
2574
+ \`\`\``);
2575
+ for (const lock of [
2576
+ "package-lock.json",
2577
+ "pnpm-lock.yaml",
2578
+ "yarn.lock",
2579
+ "bun.lockb"
2580
+ ]) {
2581
+ if (fs6.existsSync(path7.join(folder, lock))) {
2582
+ parts.push(`### lockfile
2583
+ ${lock}`);
2584
+ break;
2585
+ }
2586
+ }
2587
+ for (const rel of [
2588
+ "vite.config.ts",
2589
+ "vite.config.js",
2590
+ "vite.config.mjs",
2591
+ "next.config.js",
2592
+ "next.config.mjs",
2593
+ "next.config.ts"
2594
+ ]) {
2595
+ const body = readSnippet(path7.join(folder, rel), 2500);
2596
+ if (body) parts.push(`### ${rel}
2597
+ \`\`\`
2598
+ ${body}
2599
+ \`\`\``);
2600
+ }
2601
+ for (const rel of [".env.example", ".env", ".env.local", ".env.development"]) {
2602
+ const keys = envKeysOnly(path7.join(folder, rel));
2603
+ if (keys) parts.push(`### ${rel} keys (values omitted)
2604
+ \`\`\`
2605
+ ${keys}
2606
+ \`\`\``);
2607
+ }
2608
+ return parts.join("\n\n") || "(no config files found)";
2609
+ }
2610
+ function configOnlySystemPrompt(input) {
2611
+ return `You are Maintainer Pro's read-only setup advisor.
2612
+
2613
+ Workspace: ${input.workspaceDir}
2614
+ App name: ${input.appName || "the app"}
2615
+
2616
+ You are given configuration file excerpts only.
2617
+ DO NOT edit, create, or delete any files.
2618
+ DO NOT run shell commands, install packages, or start servers.
2619
+ Only infer how this project runs locally from the excerpts.
2620
+
2621
+ Script names you report MUST appear in package.json scripts. Omit chat/ai-server scripts.
2622
+
2623
+ Reply with a short human summary AND one \`\`\`json fence with:
2624
+
2625
+ {
2626
+ "kind": "next" | "vite" | "html" | "empty" | "other",
2627
+ "name": "package or folder name",
2628
+ "summary": "one sentence about this project",
2629
+ "scripts": { "ui": "dev:client", "backend": "dev:server" },
2630
+ "ports": { "ui": 5173, "backend": 4100 },
2631
+ "issues": ["uncertainties"],
2632
+ "fixes": [],
2633
+ "ready": true
2634
+ }`;
2635
+ }
2636
+ function configOnlyUserMessage(input, snippets) {
2637
+ return `${input.extraContext ? `${input.extraContext.trim()}
2638
+
2639
+ ` : ""}Analyze these config excerpts and suggest local apps/ports/scripts. Do not change anything on disk.
2640
+
2641
+ ${snippets}`;
2642
+ }
2643
+ function toInspectResult(input, text, providerId) {
2644
+ const parsed = extractInspectJson(text);
2645
+ return {
2646
+ kind: parsed?.kind ?? "other",
2647
+ name: parsed?.name?.trim() || input.appName || "",
2648
+ summary: parsed?.summary?.trim() || text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
2649
+ scripts: parsed?.scripts ?? {},
2650
+ ports: parsed?.ports ?? {},
2651
+ issues: parsed?.issues ?? [],
2652
+ fixes: parsed?.fixes ?? [],
2653
+ ready: parsed?.ready ?? parsed?.issues?.length === 0,
2654
+ provider: providerId,
2655
+ rawText: text
2656
+ };
2657
+ }
2658
+ async function inspectConfigOnly(input, opts = {}) {
2659
+ const workspaceDir = path7.resolve(input.workspaceDir);
2660
+ const timeoutMs = opts.timeoutMs ?? CONFIG_INSPECT_TIMEOUT_MS;
2661
+ const provider = await resolveProvider({ preference: "auto" });
2662
+ const snippets = gatherConfigSnippets(workspaceDir);
2663
+ const call = callAi(
2664
+ [{ role: "user", content: configOnlyUserMessage(input, snippets) }],
2665
+ {
2666
+ route: "/local-setup-propose",
2667
+ pageTitle: input.appName || "Setup suggestions",
2668
+ relevantFiles: ["package.json", ".env.example"],
2669
+ data: {
2670
+ workspaceDir,
2671
+ readOnly: true
2672
+ }
2673
+ },
2674
+ {
2675
+ systemPrompt: configOnlySystemPrompt(input),
2676
+ workspaceDir,
2677
+ technical: true
2678
+ }
2679
+ );
2680
+ const timed = await Promise.race([
2681
+ call.then((response) => ({ ok: true, response })),
2682
+ new Promise(
2683
+ (resolve) => setTimeout(
2684
+ () => resolve({ ok: false, error: `Config inspect timed out after ${timeoutMs}ms` }),
2685
+ timeoutMs
2686
+ )
2687
+ )
2688
+ ]);
2689
+ if (!timed.ok) {
2690
+ throw new Error(timed.error);
2691
+ }
2692
+ return toInspectResult(
2693
+ input,
2694
+ timed.response.text,
2695
+ timed.response.provider || provider.id
2696
+ );
2697
+ }
1163
2698
  async function inspectAndRepairWorkspace(input) {
1164
2699
  const workspaceDir = input.workspaceDir;
1165
2700
  const provider = await resolveProvider({ preference: "auto" });
@@ -1180,27 +2715,678 @@ async function inspectAndRepairWorkspace(input) {
1180
2715
  technical: true
1181
2716
  }
1182
2717
  );
1183
- const parsed = extractInspectJson(response.text);
2718
+ return toInspectResult(
2719
+ input,
2720
+ response.text,
2721
+ response.provider || provider.id
2722
+ );
2723
+ }
2724
+
2725
+ // src/host-apps.ts
2726
+ import fs7 from "fs";
2727
+ import path8 from "path";
2728
+ import { createHash as createHash2 } from "crypto";
2729
+ var COLLABORATER_DIR = ".collaborater";
2730
+ var AI_SERVER_APP_ID = "ai-server";
2731
+ var AI_SERVER_DEFAULT_PORT = 3100;
2732
+ var ENV_FILES = [".env", ".env.local", ".env.development", ".env.example"];
2733
+ function isPort(value) {
2734
+ return Number.isInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
2735
+ }
2736
+ function parsePort(value, fallback = 0) {
2737
+ if (typeof value === "number" && isPort(value)) return value;
2738
+ const text = String(value ?? "").trim();
2739
+ if (!text) return fallback;
2740
+ if (/^\d{2,5}$/.test(text)) {
2741
+ const n = Number(text);
2742
+ return isPort(n) ? n : fallback;
2743
+ }
2744
+ try {
2745
+ const port = Number(new URL(text).port);
2746
+ return isPort(port) ? port : fallback;
2747
+ } catch {
2748
+ const match = text.match(/--port(?:\s+|=)(\d{2,5})/i) || text.match(/\bPORT[=:]\s*(\d{2,5})/i) || text.match(/:(\d{2,5})\b/);
2749
+ const n = match ? Number(match[1]) : 0;
2750
+ return isPort(n) ? n : fallback;
2751
+ }
2752
+ }
2753
+ function dataInput(folder, extra) {
1184
2754
  return {
1185
- kind: parsed?.kind ?? "other",
1186
- name: parsed?.name?.trim() || input.appName || "",
1187
- summary: parsed?.summary?.trim() || response.text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
1188
- scripts: parsed?.scripts ?? {},
1189
- ports: parsed?.ports ?? {},
1190
- issues: parsed?.issues ?? [],
1191
- fixes: parsed?.fixes ?? [],
1192
- ready: parsed?.ready ?? parsed?.issues?.length === 0,
1193
- provider: response.provider || provider.id,
1194
- rawText: response.text
2755
+ workspaceDir: folder,
2756
+ sandboxId: extra?.sandboxId,
2757
+ dataDir: extra?.dataDir
2758
+ };
2759
+ }
2760
+ function hostAppsCachePath2(folder, extra) {
2761
+ return hostAppsCachePath(dataInput(folder, extra));
2762
+ }
2763
+ function defaultAiServerApp(port = AI_SERVER_DEFAULT_PORT) {
2764
+ return {
2765
+ id: AI_SERVER_APP_ID,
2766
+ name: "AI server",
2767
+ role: "ai-server",
2768
+ port: parsePort(port, AI_SERVER_DEFAULT_PORT),
2769
+ startCommand: null,
2770
+ source: "default",
2771
+ locked: true
2772
+ };
2773
+ }
2774
+ function normalizeHostApp(raw) {
2775
+ if (!raw || typeof raw !== "object") return null;
2776
+ const row = raw;
2777
+ const port = parsePort(row.port);
2778
+ if (!port) return null;
2779
+ const role = String(row.role || "custom");
2780
+ const allowed = ["ai-server", "ui", "backend", "app", "custom"];
2781
+ const id = String(row.id || "").trim() || (role === "ai-server" ? AI_SERVER_APP_ID : "");
2782
+ if (!id) return null;
2783
+ return {
2784
+ id: id.slice(0, 64),
2785
+ name: String(row.name || id).trim().slice(0, 80) || id,
2786
+ role: allowed.includes(role) ? role : "custom",
2787
+ port,
2788
+ startCommand: typeof row.startCommand === "string" && row.startCommand.trim() ? row.startCommand.trim().slice(0, 200) : null,
2789
+ source: ["default", "env", "package", "ai", "manual"].includes(
2790
+ row.source
2791
+ ) ? row.source : "manual",
2792
+ locked: row.locked === true || id === AI_SERVER_APP_ID || role === "ai-server",
2793
+ host: role !== "ai-server" && id !== AI_SERVER_APP_ID && row.host === true,
2794
+ envMaps: normalizeEnvMaps(row.envMaps)
2795
+ };
2796
+ }
2797
+ function normalizeEnvMaps(raw) {
2798
+ if (!Array.isArray(raw)) return [];
2799
+ const seen = /* @__PURE__ */ new Set();
2800
+ const out = [];
2801
+ for (const item of raw) {
2802
+ if (!item || typeof item !== "object") continue;
2803
+ const row = item;
2804
+ const key = String(row.key || "").trim();
2805
+ const sourceAppId = String(row.sourceAppId || "").trim().slice(0, 64);
2806
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.length > 80) continue;
2807
+ if (!sourceAppId || seen.has(key)) continue;
2808
+ seen.add(key);
2809
+ out.push({ key, sourceAppId });
2810
+ }
2811
+ return out.slice(0, 30);
2812
+ }
2813
+ function ensureSingleHost(apps) {
2814
+ const next = apps.map((app) => ({
2815
+ ...app,
2816
+ host: app.role !== "ai-server" && app.id !== AI_SERVER_APP_ID && app.host === true
2817
+ }));
2818
+ const marked = next.filter((app) => app.host);
2819
+ if (marked.length === 1) return next;
2820
+ if (marked.length > 1) {
2821
+ let kept = false;
2822
+ return next.map((app) => {
2823
+ if (!app.host) return app;
2824
+ if (kept) return { ...app, host: false };
2825
+ kept = true;
2826
+ return app;
2827
+ });
2828
+ }
2829
+ const fallback = next.find((app) => app.role === "ui" || app.role === "app") || next.find((app) => app.role !== "ai-server");
2830
+ return next.map(
2831
+ (app) => fallback && app.id === fallback.id ? { ...app, host: true } : app
2832
+ );
2833
+ }
2834
+ function normalizeHostApps(raw) {
2835
+ if (!Array.isArray(raw)) return [];
2836
+ const seen = /* @__PURE__ */ new Set();
2837
+ const apps = [];
2838
+ for (const item of raw) {
2839
+ const app = normalizeHostApp(item);
2840
+ if (!app || seen.has(app.id)) continue;
2841
+ seen.add(app.id);
2842
+ apps.push(app);
2843
+ }
2844
+ return apps;
2845
+ }
2846
+ function ensureAiServerApp(apps, preferredPort = AI_SERVER_DEFAULT_PORT) {
2847
+ const next = normalizeHostApps(apps);
2848
+ const existing = next.find((app) => app.id === AI_SERVER_APP_ID || app.role === "ai-server");
2849
+ if (existing) {
2850
+ existing.id = AI_SERVER_APP_ID;
2851
+ existing.role = "ai-server";
2852
+ existing.locked = true;
2853
+ existing.host = false;
2854
+ existing.name = existing.name || "AI server";
2855
+ return ensureSingleHost([existing, ...next.filter((app) => app !== existing)]);
2856
+ }
2857
+ return ensureSingleHost([defaultAiServerApp(preferredPort), ...next]);
2858
+ }
2859
+ function readText(file) {
2860
+ try {
2861
+ return fs7.readFileSync(file, "utf8");
2862
+ } catch {
2863
+ return null;
2864
+ }
2865
+ }
2866
+ function readEnvFileMap(file) {
2867
+ const text = readText(file);
2868
+ if (!text) return {};
2869
+ const map = {};
2870
+ for (const raw of text.split(/\r?\n/)) {
2871
+ const line = raw.trim();
2872
+ if (!line || line.startsWith("#")) continue;
2873
+ const eq = line.indexOf("=");
2874
+ if (eq < 1) continue;
2875
+ const key = line.slice(0, eq).trim();
2876
+ let value = line.slice(eq + 1).trim();
2877
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2878
+ value = value.slice(1, -1);
2879
+ }
2880
+ if (key) map[key] = value;
2881
+ }
2882
+ return map;
2883
+ }
2884
+ function readProjectEnvLayers(folder) {
2885
+ const resolved = path8.resolve(folder);
2886
+ const layers = [];
2887
+ const merged = {};
2888
+ for (const name of ENV_FILES) {
2889
+ const file = path8.join(resolved, name);
2890
+ const values = readEnvFileMap(file);
2891
+ if (!Object.keys(values).length) continue;
2892
+ layers.push({ file: name, values });
2893
+ if (name === ".env.example") continue;
2894
+ Object.assign(merged, values);
2895
+ }
2896
+ return { merged, layers };
2897
+ }
2898
+ function readPackageJson(folder) {
2899
+ try {
2900
+ return JSON.parse(fs7.readFileSync(path8.join(folder, "package.json"), "utf8"));
2901
+ } catch {
2902
+ return null;
2903
+ }
2904
+ }
2905
+ function isChatScript(name, command) {
2906
+ return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(`${name} ${command}`);
2907
+ }
2908
+ function isUiCommand(command) {
2909
+ return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
2910
+ command
2911
+ );
2912
+ }
2913
+ function configPort(folder, files) {
2914
+ for (const rel of files) {
2915
+ const text = readText(path8.join(folder, rel));
2916
+ if (!text) continue;
2917
+ const match = text.match(/\bport\s*[:=]\s*(\d{2,5})/i) || text.match(/--port(?:\s+|=)(\d{2,5})/i);
2918
+ const port = parsePort(match?.[1]);
2919
+ if (port) return port;
2920
+ }
2921
+ return 0;
2922
+ }
2923
+ function hostAppsFingerprint(folder) {
2924
+ const resolved = path8.resolve(folder);
2925
+ const parts = [];
2926
+ for (const rel of [
2927
+ "package.json",
2928
+ ...ENV_FILES,
2929
+ "vite.config.ts",
2930
+ "vite.config.js",
2931
+ "vite.config.mjs",
2932
+ "next.config.js",
2933
+ "next.config.mjs",
2934
+ "next.config.ts"
2935
+ ]) {
2936
+ const file = path8.join(resolved, rel);
2937
+ if (!fs7.existsSync(file)) continue;
2938
+ try {
2939
+ const st = fs7.statSync(file);
2940
+ parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
2941
+ } catch {
2942
+ }
2943
+ }
2944
+ return createHash2("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
2945
+ }
2946
+ function pickScript(scripts, names) {
2947
+ return names.find((name) => scripts[name] && !isChatScript(name, scripts[name])) || null;
2948
+ }
2949
+ function detectHostAppsFromFiles(folder, opts = {}) {
2950
+ const resolved = path8.resolve(folder);
2951
+ const reasons = [];
2952
+ const { merged, layers } = readProjectEnvLayers(resolved);
2953
+ const pkg = readPackageJson(resolved);
2954
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
2955
+ const apps = [];
2956
+ const envAi = parsePort(merged.AI_SERVER_PORT) || parsePort(merged.AI_SERVER_URL);
2957
+ const aiPort = envAi || parsePort(opts.preferredAiPort, AI_SERVER_DEFAULT_PORT);
2958
+ apps.push({
2959
+ ...defaultAiServerApp(aiPort),
2960
+ source: envAi ? "env" : "default"
2961
+ });
2962
+ const envPorts = {
2963
+ PORT: layers.filter((layer) => layer.file !== ".env.example").map((layer) => ({ file: layer.file, port: parsePort(layer.values.PORT) })).filter((row) => row.port),
2964
+ APP_URL: parsePort(merged.APP_URL || merged.NEXT_PUBLIC_APP_URL || merged.PUBLIC_URL),
2965
+ API_PORT: parsePort(merged.API_PORT || merged.API_URL || merged.VITE_API_URL)
2966
+ };
2967
+ const portValues = [...new Set(envPorts.PORT.map((row) => row.port))];
2968
+ if (portValues.length > 1) {
2969
+ reasons.push(
2970
+ `PORT differs across env files: ${envPorts.PORT.map((row) => `${row.file}=${row.port}`).join(", ")}`
2971
+ );
2972
+ }
2973
+ if (envPorts.PORT[0] && envPorts.APP_URL && envPorts.PORT[0].port !== envPorts.APP_URL) {
2974
+ reasons.push(
2975
+ `PORT=${envPorts.PORT[0].port} does not match APP_URL port ${envPorts.APP_URL}`
2976
+ );
2977
+ }
2978
+ const uiScript = pickScript(scripts, [
2979
+ "dev:client",
2980
+ "dev:ui",
2981
+ "dev:web",
2982
+ "dev:frontend",
2983
+ "client",
2984
+ "start:client"
2985
+ ]) || (scripts.dev && !isChatScript("dev", scripts.dev) ? "dev" : null);
2986
+ const apiScript = pickScript(scripts, [
2987
+ "dev:server",
2988
+ "dev:backend",
2989
+ "dev:api",
2990
+ "server",
2991
+ "start:server"
2992
+ ]);
2993
+ const vitePort = configPort(resolved, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) || 5173;
2994
+ const nextPort = configPort(resolved, ["next.config.js", "next.config.mjs", "next.config.ts"]) || 3e3;
2995
+ const portFromEnv = parsePort(merged.PORT);
2996
+ const hostFromEnv = parsePort(merged.HOST_PORT) || parsePort(merged.HOST_URL) || parsePort(merged.CORS_ORIGIN);
2997
+ const uiFromEnv = hostFromEnv || envPorts.APP_URL || (portFromEnv && portFromEnv !== aiPort ? portFromEnv : 0);
2998
+ if (uiScript) {
2999
+ const command = scripts[uiScript] || "";
3000
+ const scriptPort = parsePort(command);
3001
+ const fallback = isUiCommand(command) && /vite/i.test(command) ? vitePort : nextPort;
3002
+ const port = uiFromEnv || scriptPort || fallback;
3003
+ apps.push({
3004
+ id: "ui",
3005
+ name: opts.appName || pkg?.name || "App",
3006
+ role: isUiCommand(command) ? "ui" : "app",
3007
+ port,
3008
+ startCommand: `npm run ${uiScript}`,
3009
+ source: uiFromEnv ? "env" : scriptPort ? "package" : "package",
3010
+ host: true
3011
+ });
3012
+ } else if (uiFromEnv) {
3013
+ apps.push({
3014
+ id: "ui",
3015
+ name: opts.appName || pkg?.name || "App",
3016
+ role: "ui",
3017
+ port: uiFromEnv,
3018
+ startCommand: scripts.dev ? "npm run dev" : null,
3019
+ source: "env",
3020
+ host: true
3021
+ });
3022
+ }
3023
+ if (apiScript && apiScript !== uiScript) {
3024
+ const command = scripts[apiScript] || "";
3025
+ const port = envPorts.API_PORT || parsePort(command) || configPort(resolved, []) || 4100;
3026
+ apps.push({
3027
+ id: "backend",
3028
+ name: "Backend",
3029
+ role: "backend",
3030
+ port,
3031
+ startCommand: `npm run ${apiScript}`,
3032
+ source: envPorts.API_PORT ? "env" : "package"
3033
+ });
3034
+ }
3035
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3036
+ if (pkg && Object.keys(scripts).length && hostApps.length === 0) {
3037
+ reasons.push("Found package.json scripts but no host app port in env or config");
3038
+ }
3039
+ return {
3040
+ apps: ensureAiServerApp(apps, aiPort),
3041
+ reasons,
3042
+ confused: reasons.length > 0
3043
+ };
3044
+ }
3045
+ function readHostAppsCache(folder, extra) {
3046
+ try {
3047
+ const raw = JSON.parse(
3048
+ fs7.readFileSync(hostAppsCachePath2(folder, extra), "utf8")
3049
+ );
3050
+ const apps = ensureAiServerApp(normalizeHostApps(raw.apps));
3051
+ if (!apps.length) return null;
3052
+ return {
3053
+ apps,
3054
+ fingerprint: typeof raw.fingerprint === "string" ? raw.fingerprint : void 0,
3055
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : void 0
3056
+ };
3057
+ } catch {
3058
+ return null;
3059
+ }
3060
+ }
3061
+ var readCollaboraterApps = readHostAppsCache;
3062
+ function writeHostAppsCache(folder, apps, extra = {}, loc) {
3063
+ const resolved = path8.resolve(folder);
3064
+ ensureProjectDataDir(dataInput(resolved, loc));
3065
+ const payload = {
3066
+ version: 1,
3067
+ fingerprint: hostAppsFingerprint(resolved),
3068
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3069
+ apps: ensureAiServerApp(apps),
3070
+ ...extra
3071
+ };
3072
+ fs7.writeFileSync(
3073
+ hostAppsCachePath2(resolved, loc),
3074
+ `${JSON.stringify(payload, null, 2)}
3075
+ `,
3076
+ "utf8"
3077
+ );
3078
+ }
3079
+ var writeCollaboraterApps = writeHostAppsCache;
3080
+ function mergeDesiredHostApps(desired, detected) {
3081
+ const byId = new Map(detected.map((app) => [app.id, app]));
3082
+ const byRole = new Map(detected.map((app) => [app.role, app]));
3083
+ const merged = desired.map((app) => {
3084
+ const local = byId.get(app.id) || (app.role !== "custom" ? byRole.get(app.role) : void 0);
3085
+ return {
3086
+ ...local,
3087
+ ...app,
3088
+ startCommand: app.startCommand || local?.startCommand || null,
3089
+ locked: app.locked || app.id === AI_SERVER_APP_ID || app.role === "ai-server",
3090
+ host: app.role === "ai-server" || app.id === AI_SERVER_APP_ID ? false : app.host === true || app.host !== false && local?.host === true,
3091
+ envMaps: app.envMaps && app.envMaps.length ? app.envMaps : local?.envMaps || []
3092
+ };
3093
+ });
3094
+ return ensureAiServerApp(merged);
3095
+ }
3096
+ function appsFromInspectPorts(ports, scripts, name) {
3097
+ const apps = [];
3098
+ if (ports.ui || scripts.ui) {
3099
+ apps.push({
3100
+ id: "ui",
3101
+ name,
3102
+ role: "ui",
3103
+ port: parsePort(ports.ui, 5173),
3104
+ startCommand: scripts.ui ? `npm run ${scripts.ui}` : null,
3105
+ source: "ai"
3106
+ });
3107
+ }
3108
+ if (ports.backend || scripts.backend) {
3109
+ apps.push({
3110
+ id: "backend",
3111
+ name: "Backend",
3112
+ role: "backend",
3113
+ port: parsePort(ports.backend, 4100),
3114
+ startCommand: scripts.backend ? `npm run ${scripts.backend}` : null,
3115
+ source: "ai"
3116
+ });
3117
+ }
3118
+ if (!apps.length && (ports.app || scripts.app)) {
3119
+ apps.push({
3120
+ id: "app",
3121
+ name,
3122
+ role: "app",
3123
+ port: parsePort(ports.app, 3e3),
3124
+ startCommand: scripts.app ? `npm run ${scripts.app}` : null,
3125
+ source: "ai"
3126
+ });
3127
+ }
3128
+ return apps;
3129
+ }
3130
+ function collectScriptAlternatives(folder, primary, opts = {}) {
3131
+ const pkg = readPackageJson(folder);
3132
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
3133
+ const usedCommands = new Set(
3134
+ primary.map((app) => app.startCommand?.trim()).filter((cmd) => Boolean(cmd))
3135
+ );
3136
+ const alternatives = [];
3137
+ const hostApp = primary.find((app) => app.role === "ui" || app.role === "app") || primary.find((app) => app.role !== "ai-server");
3138
+ const backendApp = primary.find((app) => app.role === "backend");
3139
+ for (const [name, command] of Object.entries(scripts)) {
3140
+ if (isChatScript(name, command)) continue;
3141
+ if (!/^(dev|start|serve)/i.test(name) && !isUiCommand(command)) continue;
3142
+ const startCommand = `npm run ${name}`;
3143
+ if (usedCommands.has(startCommand)) continue;
3144
+ const scriptPort = parsePort(command);
3145
+ const looksBackend = /\b(server|api|backend)\b/i.test(`${name} ${command}`) && !isUiCommand(command);
3146
+ const target = looksBackend ? backendApp || hostApp : hostApp;
3147
+ if (!target) continue;
3148
+ const fallbackPort = scriptPort || (looksBackend ? 4100 : isUiCommand(command) && /vite/i.test(command) ? 5173 : 3e3);
3149
+ alternatives.push({
3150
+ appId: target.id,
3151
+ port: fallbackPort,
3152
+ startCommand,
3153
+ label: `${name} \u2192 ${fallbackPort}`,
3154
+ source: "package"
3155
+ });
3156
+ if (alternatives.length >= 8) break;
3157
+ }
3158
+ const { layers } = readProjectEnvLayers(folder);
3159
+ if (hostApp) {
3160
+ for (const layer of layers) {
3161
+ if (layer.file === ".env.example") continue;
3162
+ const port = parsePort(layer.values.PORT);
3163
+ if (!port || port === hostApp.port) continue;
3164
+ if (alternatives.some(
3165
+ (alt) => alt.appId === hostApp.id && alt.port === port && !alt.startCommand
3166
+ )) {
3167
+ continue;
3168
+ }
3169
+ alternatives.push({
3170
+ appId: hostApp.id,
3171
+ port,
3172
+ startCommand: hostApp.startCommand || null,
3173
+ label: `${layer.file} PORT=${port}`,
3174
+ source: "env"
3175
+ });
3176
+ }
3177
+ }
3178
+ void opts;
3179
+ return alternatives;
3180
+ }
3181
+ function proposalConfidence(apps, reasons, usedAi) {
3182
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3183
+ if (!hostApps.length) return "low";
3184
+ if (reasons.length === 0 && hostApps.every((app) => app.startCommand)) {
3185
+ return usedAi ? "medium" : "high";
3186
+ }
3187
+ if (reasons.some((reason) => /differ|does not match|no host app/i.test(reason))) {
3188
+ return "low";
3189
+ }
3190
+ return "medium";
3191
+ }
3192
+ function projectSummaryFromDetect(folder, apps, opts = {}) {
3193
+ const pkg = readPackageJson(folder);
3194
+ const name = opts.appName || pkg?.name || path8.basename(path8.resolve(folder));
3195
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3196
+ if (!hostApps.length) {
3197
+ return `${name}: no UI/backend ports detected from config yet.`;
3198
+ }
3199
+ return `${name}: ${hostApps.map((app) => `${app.name} on ${app.port}${app.startCommand ? ` (${app.startCommand})` : ""}`).join(", ")}.`;
3200
+ }
3201
+ async function proposeHostAppsFromConfig(input) {
3202
+ const folder = path8.resolve(input.workspaceDir);
3203
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3204
+ const fingerprint = hostAppsFingerprint(folder);
3205
+ const allowAi = input.allowAi !== false;
3206
+ const detected = detectHostAppsFromFiles(folder, {
3207
+ preferredAiPort: preferredAi,
3208
+ appName: input.appName
3209
+ });
3210
+ let apps = ensureAiServerApp(detected.apps, preferredAi);
3211
+ let reasons = [...detected.reasons];
3212
+ let usedAi = false;
3213
+ let projectSummary = projectSummaryFromDetect(folder, apps, {
3214
+ appName: input.appName
3215
+ });
3216
+ const hostCount = apps.filter((app) => app.role !== "ai-server").length;
3217
+ const needsAi = allowAi && (detected.confused || hostCount === 0 || reasons.some((reason) => /differ|does not match/i.test(reason)));
3218
+ if (needsAi) {
3219
+ try {
3220
+ const inspected = await inspectConfigOnly({
3221
+ workspaceDir: folder,
3222
+ appName: input.appName,
3223
+ extraContext: [
3224
+ "File-based detection was uncertain or incomplete.",
3225
+ ...reasons
3226
+ ].join("\n")
3227
+ });
3228
+ usedAi = true;
3229
+ const fromAi = appsFromInspectPorts(
3230
+ inspected.ports,
3231
+ inspected.scripts,
3232
+ inspected.name || input.appName || "App"
3233
+ );
3234
+ if (fromAi.length) {
3235
+ apps = ensureAiServerApp(
3236
+ [defaultAiServerApp(preferredAi), ...fromAi],
3237
+ preferredAi
3238
+ );
3239
+ }
3240
+ if (inspected.summary) projectSummary = inspected.summary;
3241
+ if (inspected.issues?.length) {
3242
+ reasons = [...reasons, ...inspected.issues];
3243
+ }
3244
+ } catch (err) {
3245
+ reasons.push(
3246
+ `AI suggest skipped: ${err instanceof Error ? err.message : String(err)}`
3247
+ );
3248
+ }
3249
+ }
3250
+ const alternatives = collectScriptAlternatives(folder, apps, {
3251
+ appName: input.appName
3252
+ });
3253
+ const confidence = proposalConfidence(apps, reasons, usedAi);
3254
+ const needsReview = confidence !== "high" || detected.confused || apps.filter((app) => app.role !== "ai-server").length === 0;
3255
+ return {
3256
+ apps,
3257
+ alternatives,
3258
+ reasons,
3259
+ confidence,
3260
+ projectSummary,
3261
+ usedAi,
3262
+ needsReview,
3263
+ fingerprint
3264
+ };
3265
+ }
3266
+ async function resolveHostApps(input) {
3267
+ const folder = path8.resolve(input.workspaceDir);
3268
+ const loc = { sandboxId: input.sandboxId, dataDir: input.dataDir };
3269
+ const fingerprint = hostAppsFingerprint(folder);
3270
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3271
+ const detected = detectHostAppsFromFiles(folder, {
3272
+ preferredAiPort: preferredAi,
3273
+ appName: input.appName
3274
+ });
3275
+ const desired = normalizeHostApps(input.desired);
3276
+ if (desired.length) {
3277
+ const apps2 = mergeDesiredHostApps(desired, detected.apps);
3278
+ try {
3279
+ writeHostAppsCache(folder, apps2, { source: "desired" }, loc);
3280
+ } catch {
3281
+ }
3282
+ return {
3283
+ apps: apps2,
3284
+ source: "desired",
3285
+ cached: false,
3286
+ usedAi: false,
3287
+ confused: false,
3288
+ reasons: [],
3289
+ fingerprint
3290
+ };
3291
+ }
3292
+ if (!input.force) {
3293
+ const cached = readHostAppsCache(folder, loc);
3294
+ if (cached && cached.fingerprint === fingerprint) {
3295
+ return {
3296
+ apps: ensureAiServerApp(cached.apps, preferredAi),
3297
+ source: "cache",
3298
+ cached: true,
3299
+ usedAi: false,
3300
+ confused: false,
3301
+ reasons: [],
3302
+ fingerprint
3303
+ };
3304
+ }
3305
+ }
3306
+ const needsAi = Boolean(input.allowAi) && (Boolean(input.force && detected.confused) || detected.confused && detected.apps.filter((app) => app.role !== "ai-server").length === 0 || detected.reasons.some((reason) => /differ|does not match/i.test(reason)));
3307
+ if (needsAi) {
3308
+ try {
3309
+ const inspected = await inspectConfigOnly({
3310
+ workspaceDir: folder,
3311
+ appName: input.appName,
3312
+ extraContext: `Port detection was uncertain:
3313
+ ${detected.reasons.join("\n")}
3314
+ Return the real local ports and npm script names.`
3315
+ });
3316
+ const fromAi = appsFromInspectPorts(
3317
+ inspected.ports,
3318
+ inspected.scripts,
3319
+ inspected.name || input.appName || "App"
3320
+ );
3321
+ const apps2 = ensureAiServerApp(
3322
+ [defaultAiServerApp(preferredAi), ...fromAi],
3323
+ preferredAi
3324
+ );
3325
+ writeHostAppsCache(
3326
+ folder,
3327
+ apps2,
3328
+ {
3329
+ source: "ai",
3330
+ reasons: detected.reasons,
3331
+ summary: inspected.summary
3332
+ },
3333
+ loc
3334
+ );
3335
+ return {
3336
+ apps: apps2,
3337
+ source: "ai",
3338
+ cached: false,
3339
+ usedAi: true,
3340
+ confused: detected.confused,
3341
+ reasons: detected.reasons,
3342
+ fingerprint
3343
+ };
3344
+ } catch (err) {
3345
+ detected.reasons.push(
3346
+ `AI inspect skipped: ${err instanceof Error ? err.message : String(err)}`
3347
+ );
3348
+ }
3349
+ }
3350
+ const apps = ensureAiServerApp(detected.apps, preferredAi);
3351
+ try {
3352
+ writeHostAppsCache(
3353
+ folder,
3354
+ apps,
3355
+ {
3356
+ source: detected.confused ? "env" : "env",
3357
+ reasons: detected.reasons
3358
+ },
3359
+ loc
3360
+ );
3361
+ } catch {
3362
+ }
3363
+ return {
3364
+ apps,
3365
+ source: apps.some((app) => app.source === "env") ? "env" : "default",
3366
+ cached: false,
3367
+ usedAi: false,
3368
+ confused: detected.confused,
3369
+ reasons: detected.reasons,
3370
+ fingerprint
1195
3371
  };
1196
3372
  }
1197
3373
  export {
3374
+ ACCESS_IGNORE_BEGIN,
3375
+ ACCESS_IGNORE_END,
3376
+ AI_SERVER_APP_ID,
3377
+ AI_SERVER_DEFAULT_PORT,
3378
+ COLLABORATER_DIR,
3379
+ DEFAULT_AI_IGNORE_PATHS,
3380
+ HOST_APPS_FILE,
3381
+ MAINTAINER_PRO_HOME_DIR,
3382
+ PROMPT_SECTION,
1198
3383
  WORKING_PROVIDER,
1199
3384
  buildClaudeUserPrompt,
1200
3385
  buildConversationPrompt,
1201
3386
  buildCursorPrompt,
1202
3387
  buildPriorConversationsContext,
1203
3388
  callAi,
3389
+ collectParentChain,
1204
3390
  commandExists,
1205
3391
  createAntigravityProvider,
1206
3392
  createBuiltinProviders,
@@ -1208,17 +3394,60 @@ export {
1208
3394
  createClaudeProvider,
1209
3395
  createCursorProvider,
1210
3396
  createDefaultSystemPrompt,
3397
+ createInfoLogger,
1211
3398
  createLocalDirectoryStore,
3399
+ createLogger,
1212
3400
  createMaintainerProStore,
1213
3401
  createMaintainerProStoreFromEnv,
3402
+ createSyncedChatStore,
1214
3403
  createToolValidator,
3404
+ defaultAiServerApp,
3405
+ detectHostAppsFromFiles,
3406
+ ensureAiServerApp,
3407
+ ensureProjectDataDir,
3408
+ ensureSingleHost,
3409
+ formatAccessPolicyPromptSection,
1215
3410
  formatClientContext,
3411
+ formatParentChainContext,
1216
3412
  getProviderPreference,
3413
+ hostAppsCachePath2 as hostAppsCachePath,
3414
+ hostAppsFingerprint,
1217
3415
  inspectAndRepairWorkspace,
3416
+ inspectConfigOnly,
3417
+ isDevMode,
3418
+ isIgnoredRelative,
3419
+ isInsideWorkspace,
3420
+ isPathAllowed,
3421
+ maintainerProHome,
3422
+ mergeDesiredHostApps,
3423
+ normalizeEnvMaps,
3424
+ normalizeHostApp,
3425
+ normalizeHostApps,
3426
+ normalizeIgnorePaths,
3427
+ parentChainForTurn,
1218
3428
  parseAiResponse,
3429
+ parseIgnorePathsEnv,
3430
+ parsePort,
3431
+ previewText,
3432
+ hostAppsCachePath as projectHostAppsPath,
3433
+ projectIdForFolder,
3434
+ projectUploadsDir,
3435
+ proposeHostAppsFromConfig,
1219
3436
  providerLabel,
3437
+ readCollaboraterApps,
3438
+ readHostAppsCache,
3439
+ readProjectEnvLayers,
3440
+ renderManagedIgnoreBlock,
1220
3441
  resolveCliBinary,
3442
+ resolveHostApps,
3443
+ resolveIgnorePaths,
3444
+ resolveLogLevel,
3445
+ resolveProjectDataDir,
1221
3446
  resolveProvider,
3447
+ sanitizeProjectId,
1222
3448
  saveChatAttachments,
1223
- toNextRoute
3449
+ toNextRoute,
3450
+ upsertManagedIgnoreFile,
3451
+ writeCollaboraterApps,
3452
+ writeHostAppsCache
1224
3453
  };