@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.cjs CHANGED
@@ -30,12 +30,22 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ACCESS_IGNORE_BEGIN: () => ACCESS_IGNORE_BEGIN,
34
+ ACCESS_IGNORE_END: () => ACCESS_IGNORE_END,
35
+ AI_SERVER_APP_ID: () => AI_SERVER_APP_ID,
36
+ AI_SERVER_DEFAULT_PORT: () => AI_SERVER_DEFAULT_PORT,
37
+ COLLABORATER_DIR: () => COLLABORATER_DIR,
38
+ DEFAULT_AI_IGNORE_PATHS: () => DEFAULT_AI_IGNORE_PATHS,
39
+ HOST_APPS_FILE: () => HOST_APPS_FILE,
40
+ MAINTAINER_PRO_HOME_DIR: () => MAINTAINER_PRO_HOME_DIR,
41
+ PROMPT_SECTION: () => PROMPT_SECTION,
33
42
  WORKING_PROVIDER: () => WORKING_PROVIDER,
34
43
  buildClaudeUserPrompt: () => buildClaudeUserPrompt,
35
44
  buildConversationPrompt: () => buildConversationPrompt,
36
45
  buildCursorPrompt: () => buildCursorPrompt,
37
46
  buildPriorConversationsContext: () => buildPriorConversationsContext,
38
47
  callAi: () => callAi,
48
+ collectParentChain: () => collectParentChain,
39
49
  commandExists: () => commandExists,
40
50
  createAntigravityProvider: () => createAntigravityProvider,
41
51
  createBuiltinProviders: () => createBuiltinProviders,
@@ -43,19 +53,62 @@ __export(index_exports, {
43
53
  createClaudeProvider: () => createClaudeProvider,
44
54
  createCursorProvider: () => createCursorProvider,
45
55
  createDefaultSystemPrompt: () => createDefaultSystemPrompt,
56
+ createInfoLogger: () => createInfoLogger,
46
57
  createLocalDirectoryStore: () => createLocalDirectoryStore,
58
+ createLogger: () => createLogger,
47
59
  createMaintainerProStore: () => createMaintainerProStore,
48
60
  createMaintainerProStoreFromEnv: () => createMaintainerProStoreFromEnv,
61
+ createSyncedChatStore: () => createSyncedChatStore,
49
62
  createToolValidator: () => createToolValidator,
63
+ defaultAiServerApp: () => defaultAiServerApp,
64
+ detectHostAppsFromFiles: () => detectHostAppsFromFiles,
65
+ ensureAiServerApp: () => ensureAiServerApp,
66
+ ensureProjectDataDir: () => ensureProjectDataDir,
67
+ ensureSingleHost: () => ensureSingleHost,
68
+ formatAccessPolicyPromptSection: () => formatAccessPolicyPromptSection,
50
69
  formatClientContext: () => formatClientContext,
70
+ formatParentChainContext: () => formatParentChainContext,
51
71
  getProviderPreference: () => getProviderPreference,
72
+ hostAppsCachePath: () => hostAppsCachePath2,
73
+ hostAppsFingerprint: () => hostAppsFingerprint,
52
74
  inspectAndRepairWorkspace: () => inspectAndRepairWorkspace,
75
+ inspectConfigOnly: () => inspectConfigOnly,
76
+ isDevMode: () => isDevMode,
77
+ isIgnoredRelative: () => isIgnoredRelative,
78
+ isInsideWorkspace: () => isInsideWorkspace,
79
+ isPathAllowed: () => isPathAllowed,
80
+ maintainerProHome: () => maintainerProHome,
81
+ mergeDesiredHostApps: () => mergeDesiredHostApps,
82
+ normalizeEnvMaps: () => normalizeEnvMaps,
83
+ normalizeHostApp: () => normalizeHostApp,
84
+ normalizeHostApps: () => normalizeHostApps,
85
+ normalizeIgnorePaths: () => normalizeIgnorePaths,
86
+ parentChainForTurn: () => parentChainForTurn,
53
87
  parseAiResponse: () => parseAiResponse,
88
+ parseIgnorePathsEnv: () => parseIgnorePathsEnv,
89
+ parsePort: () => parsePort,
90
+ previewText: () => previewText,
91
+ projectHostAppsPath: () => hostAppsCachePath,
92
+ projectIdForFolder: () => projectIdForFolder,
93
+ projectUploadsDir: () => projectUploadsDir,
94
+ proposeHostAppsFromConfig: () => proposeHostAppsFromConfig,
54
95
  providerLabel: () => providerLabel,
96
+ readCollaboraterApps: () => readCollaboraterApps,
97
+ readHostAppsCache: () => readHostAppsCache,
98
+ readProjectEnvLayers: () => readProjectEnvLayers,
99
+ renderManagedIgnoreBlock: () => renderManagedIgnoreBlock,
55
100
  resolveCliBinary: () => resolveCliBinary,
101
+ resolveHostApps: () => resolveHostApps,
102
+ resolveIgnorePaths: () => resolveIgnorePaths,
103
+ resolveLogLevel: () => resolveLogLevel,
104
+ resolveProjectDataDir: () => resolveProjectDataDir,
56
105
  resolveProvider: () => resolveProvider,
106
+ sanitizeProjectId: () => sanitizeProjectId,
57
107
  saveChatAttachments: () => saveChatAttachments,
58
- toNextRoute: () => toNextRoute
108
+ toNextRoute: () => toNextRoute,
109
+ upsertManagedIgnoreFile: () => upsertManagedIgnoreFile,
110
+ writeCollaboraterApps: () => writeCollaboraterApps,
111
+ writeHostAppsCache: () => writeHostAppsCache
59
112
  });
60
113
  module.exports = __toCommonJS(index_exports);
61
114
 
@@ -63,6 +116,43 @@ module.exports = __toCommonJS(index_exports);
63
116
  var import_execa2 = require("execa");
64
117
 
65
118
  // src/prompt.ts
119
+ var PROMPT_SECTION = {
120
+ system: { begin: "<<<SYSTEM_BEGIN>>>", end: "<<<SYSTEM_END>>>" },
121
+ clientContext: {
122
+ begin: "<<<CLIENT_CONTEXT_BEGIN>>>",
123
+ end: "<<<CLIENT_CONTEXT_END>>>"
124
+ },
125
+ priorConversations: {
126
+ begin: "<<<PRIOR_CONVERSATIONS_BEGIN>>>",
127
+ end: "<<<PRIOR_CONVERSATIONS_END>>>"
128
+ },
129
+ parentChain: {
130
+ begin: "<<<PARENT_CHAIN_BEGIN>>>",
131
+ end: "<<<PARENT_CHAIN_END>>>"
132
+ },
133
+ history: {
134
+ begin: "<<<CONVERSATION_HISTORY_BEGIN>>>",
135
+ end: "<<<CONVERSATION_HISTORY_END>>>"
136
+ },
137
+ attachments: {
138
+ begin: "<<<ATTACHMENTS_BEGIN>>>",
139
+ end: "<<<ATTACHMENTS_END>>>"
140
+ },
141
+ currentRequest: {
142
+ begin: "<<<CURRENT_REQUEST_BEGIN>>>",
143
+ end: "<<<CURRENT_REQUEST_END>>>"
144
+ }
145
+ };
146
+ function wrapSection(tag, body, title) {
147
+ const trimmed = body.trim();
148
+ if (!trimmed) return "";
149
+ const header = title ? `${tag.begin} ${title}` : tag.begin;
150
+ return `${header}
151
+ ${trimmed}
152
+ ${tag.end}
153
+
154
+ `;
155
+ }
66
156
  function buildConversationPrompt(messages) {
67
157
  if (messages.length === 0) return "";
68
158
  const lines = messages.map(
@@ -104,50 +194,67 @@ function splitMessages(messages) {
104
194
  const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
105
195
  return { request, history };
106
196
  }
107
- function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
197
+ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
108
198
  const { request, history } = splitMessages(messages);
109
- const historyBlock = history.length > 0 ? `Conversation so far:
110
- ${buildConversationPrompt(history)}
111
-
112
- ` : "";
113
- const contextBlock = formatClientContext(context);
114
- const contextSection = contextBlock ? `${contextBlock}
115
-
116
- ` : "";
117
- const systemSection = systemPrompt ? `${systemPrompt}
118
-
119
- ` : "";
120
- const priorSection = priorConversationsContext?.trim() ? `${priorConversationsContext.trim()}
121
-
122
- ` : "";
123
- const attachmentsSection = attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files):
124
- ${attachmentPaths.map((p) => `- ${p}`).join("\n")}
125
-
126
- ` : "";
199
+ const systemSection = wrapSection(
200
+ PROMPT_SECTION.system,
201
+ systemPrompt ?? "",
202
+ "instructions"
203
+ );
204
+ const contextSection = wrapSection(
205
+ PROMPT_SECTION.clientContext,
206
+ formatClientContext(context),
207
+ "live UI snapshot"
208
+ );
209
+ const priorSection = wrapSection(
210
+ PROMPT_SECTION.priorConversations,
211
+ priorConversationsContext ?? "",
212
+ "other chats (secondary)"
213
+ );
214
+ const parentSection = wrapSection(
215
+ PROMPT_SECTION.parentChain,
216
+ parentChainContext ?? "",
217
+ "reply-to parents \u2014 primary thread context"
218
+ );
219
+ const historySection = wrapSection(
220
+ PROMPT_SECTION.history,
221
+ history.length > 0 ? buildConversationPrompt(history) : "",
222
+ "earlier turns in this conversation"
223
+ );
224
+ const attachmentsSection = wrapSection(
225
+ PROMPT_SECTION.attachments,
226
+ 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):
227
+ ${attachmentPaths.map((p) => `- ${p}`).join("\n")}` : "",
228
+ "user attachments"
229
+ );
230
+ const currentSection = wrapSection(
231
+ PROMPT_SECTION.currentRequest,
232
+ request || "(See the attached screenshot(s).)",
233
+ "answer this message"
234
+ );
127
235
  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.";
128
- return `${systemSection}${contextSection}${priorSection}${historyBlock}${attachmentsSection}Current user request:
129
- ${request || "(See the attached screenshot(s).)"}
130
-
131
- ${closer}`;
236
+ return `${systemSection}${contextSection}${priorSection}${parentSection}${historySection}${attachmentsSection}${currentSection}${closer}`;
132
237
  }
133
- function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
238
+ function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
134
239
  return buildUserFacingPrompt(
135
240
  systemPrompt,
136
241
  messages,
137
242
  context,
138
243
  attachmentPaths,
139
244
  priorConversationsContext,
140
- technical
245
+ technical,
246
+ parentChainContext
141
247
  );
142
248
  }
143
- function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical) {
249
+ function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
144
250
  return buildUserFacingPrompt(
145
251
  null,
146
252
  messages,
147
253
  context,
148
254
  attachmentPaths,
149
255
  priorConversationsContext,
150
- technical
256
+ technical,
257
+ parentChainContext
151
258
  );
152
259
  }
153
260
 
@@ -261,7 +368,8 @@ async function callClaudeCli(command, messages, context, options) {
261
368
  context,
262
369
  options.attachmentPaths,
263
370
  options.priorConversationsContext,
264
- options.technical
371
+ options.technical,
372
+ options.parentChainContext
265
373
  );
266
374
  const resolved = resolveCliCommand("claude", command);
267
375
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -301,7 +409,8 @@ async function callCursorCli(command, messages, context, options) {
301
409
  context,
302
410
  options.attachmentPaths,
303
411
  options.priorConversationsContext,
304
- options.technical
412
+ options.technical,
413
+ options.parentChainContext
305
414
  );
306
415
  const resolved = resolveCliCommand("cursor", command);
307
416
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -343,7 +452,8 @@ async function callAntigravityCli(command, messages, context, options) {
343
452
  context,
344
453
  options.attachmentPaths,
345
454
  options.priorConversationsContext,
346
- options.technical
455
+ options.technical,
456
+ options.parentChainContext
347
457
  );
348
458
  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).
349
459
 
@@ -384,8 +494,8 @@ async function resolveCommandPath(command) {
384
494
  }
385
495
  const result = await (0, import_execa2.execa)("which", [command], { reject: false });
386
496
  if (result.exitCode !== 0) return null;
387
- const path5 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
388
- return path5 || null;
497
+ const path9 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
498
+ return path9 || null;
389
499
  } catch {
390
500
  return null;
391
501
  }
@@ -471,6 +581,51 @@ async function callAi(messages, context, options) {
471
581
  return provider.call(messages, context, options);
472
582
  }
473
583
 
584
+ // src/parent-chain.ts
585
+ function collectParentChain(rows, startId) {
586
+ if (!startId) return [];
587
+ const byId = new Map(rows.map((row) => [row.id, row]));
588
+ const chain = [];
589
+ const seen = /* @__PURE__ */ new Set();
590
+ let current = byId.get(startId);
591
+ while (current && !seen.has(current.id)) {
592
+ seen.add(current.id);
593
+ const content = String(current.content ?? "").trim();
594
+ if (content && current.provider !== "working") {
595
+ const role = current.role === "assistant" || current.role === "system" ? current.role : "user";
596
+ chain.unshift({
597
+ id: current.id,
598
+ role,
599
+ content,
600
+ ...current.senderName?.trim() ? { senderName: current.senderName.trim() } : {}
601
+ });
602
+ }
603
+ const nextId = current.parentMessageId?.trim();
604
+ current = nextId ? byId.get(nextId) : void 0;
605
+ }
606
+ return chain;
607
+ }
608
+ function parentChainForTurn(rows, currentMessageId, fallbackParentId) {
609
+ if (!currentMessageId && !fallbackParentId) return [];
610
+ const byId = new Map(rows.map((row) => [row.id, row]));
611
+ const current = currentMessageId ? byId.get(currentMessageId) : void 0;
612
+ const replyToId = current?.parentMessageId && String(current.parentMessageId).trim() || fallbackParentId && String(fallbackParentId).trim() || null;
613
+ return collectParentChain(rows, replyToId);
614
+ }
615
+ function formatParentChainContext(chain) {
616
+ if (chain.length === 0) return "";
617
+ const lines = [
618
+ "context: this message has parents hierarchy linked with it",
619
+ "Prefer this chain over unrelated queued or later messages when interpreting the current request.",
620
+ ""
621
+ ];
622
+ chain.forEach((entry, index) => {
623
+ const who = entry.senderName || (entry.role === "assistant" ? "Assistant" : entry.role === "system" ? "System" : "Human");
624
+ lines.push(`${index + 1}. [${who}] ${entry.content}`);
625
+ });
626
+ return lines.join("\n");
627
+ }
628
+
474
629
  // src/system-prompt.ts
475
630
  function createDefaultSystemPrompt(input) {
476
631
  const files = input.relevantFilesHint ? `Key UI files (use internally only; never name them in your reply):
@@ -481,6 +636,14 @@ Only when the user wants to change live in-app data (not source code), return a
481
636
  ${input.runtimeToolsHint}
482
637
 
483
638
  Only use those runtime tools for live state. Never invent runtime tools.` : "";
639
+ const access = input.ignorePaths !== void 0 ? `
640
+
641
+ ## File access boundaries (mandatory)
642
+ - You may only read or edit files inside the current project workspace directory.
643
+ - Never read, write, move, or delete files outside that workspace.
644
+ - Never touch paths that match this ignore list (relative to the workspace):
645
+ ${(input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).length ? (input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)"}
646
+ - 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.` : "";
484
647
  return `You are a helpful product assistant for an app the user is looking at right now.
485
648
 
486
649
  ${input.productDescription}
@@ -501,6 +664,7 @@ Behind the scenes you can edit this repository and update live app data, but the
501
664
  ## When to edit the codebase
502
665
  Edit source files when the user asks to change labels, layout, styling, copy, or behavior in the app.
503
666
  ${files}
667
+ ${access}
504
668
 
505
669
  ${tools}
506
670
 
@@ -508,14 +672,257 @@ Do not refuse UI/label changes \u2014 implement them in source files.
508
672
  Answer the user's latest request directly \u2014 never reply with a generic greeting.`;
509
673
  }
510
674
 
675
+ // src/access-policy.ts
676
+ var import_node_path2 = __toESM(require("path"), 1);
677
+ var DEFAULT_AI_IGNORE_PATHS = [
678
+ ".env",
679
+ ".env.*",
680
+ "**/.env",
681
+ "**/.env.*"
682
+ ];
683
+ function parseIgnorePathsEnv(raw) {
684
+ if (!raw?.trim()) return [];
685
+ const trimmed = raw.trim();
686
+ try {
687
+ const parsed = JSON.parse(trimmed);
688
+ if (Array.isArray(parsed)) {
689
+ return normalizeIgnorePaths(parsed.map(String));
690
+ }
691
+ } catch {
692
+ }
693
+ return normalizeIgnorePaths(trimmed.split(/[\n,]+/));
694
+ }
695
+ function normalizeIgnorePaths(paths) {
696
+ const out = [];
697
+ const seen = /* @__PURE__ */ new Set();
698
+ for (const raw of paths) {
699
+ const p = raw.trim().replace(/\\/g, "/");
700
+ if (!p || p.startsWith("/") || p.includes("..")) continue;
701
+ if (seen.has(p)) continue;
702
+ seen.add(p);
703
+ out.push(p);
704
+ }
705
+ return out;
706
+ }
707
+ function resolveIgnorePaths(partnerPaths) {
708
+ return normalizeIgnorePaths([
709
+ ...DEFAULT_AI_IGNORE_PATHS,
710
+ ...partnerPaths
711
+ ]);
712
+ }
713
+ function isInsideWorkspace(workspaceDir, targetPath) {
714
+ const root = import_node_path2.default.resolve(workspaceDir);
715
+ const target = import_node_path2.default.resolve(targetPath);
716
+ const rel = import_node_path2.default.relative(root, target);
717
+ return rel === "" || !rel.startsWith("..") && !import_node_path2.default.isAbsolute(rel);
718
+ }
719
+ function globToRegExp(pattern) {
720
+ let p = pattern.replace(/\\/g, "/");
721
+ if (p.endsWith("/")) p = `${p}**`;
722
+ let i = 0;
723
+ let out = "^";
724
+ while (i < p.length) {
725
+ if (p.startsWith("**/", i)) {
726
+ out += "(?:.*/)?";
727
+ i += 3;
728
+ continue;
729
+ }
730
+ if (p[i] === "*" && p[i + 1] !== "*") {
731
+ out += "[^/]*";
732
+ i += 1;
733
+ continue;
734
+ }
735
+ if (p.startsWith("**", i)) {
736
+ out += ".*";
737
+ i += 2;
738
+ continue;
739
+ }
740
+ if (p[i] === "?") {
741
+ out += "[^/]";
742
+ i += 1;
743
+ continue;
744
+ }
745
+ const ch = p[i];
746
+ if (/[.+^${}()|[\]\\]/.test(ch)) out += `\\${ch}`;
747
+ else out += ch;
748
+ i += 1;
749
+ }
750
+ out += "$";
751
+ return new RegExp(out, "i");
752
+ }
753
+ function isIgnoredRelative(relativePath, patterns) {
754
+ const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
755
+ if (!rel) return false;
756
+ return patterns.some((pattern) => {
757
+ const re = globToRegExp(pattern);
758
+ if (re.test(rel)) return true;
759
+ if (!pattern.includes("*") && !pattern.endsWith("/")) {
760
+ return rel === pattern || rel.startsWith(`${pattern}/`);
761
+ }
762
+ return false;
763
+ });
764
+ }
765
+ function isPathAllowed(workspaceDir, targetPath, ignorePaths) {
766
+ if (!isInsideWorkspace(workspaceDir, targetPath)) return false;
767
+ const rel = import_node_path2.default.relative(import_node_path2.default.resolve(workspaceDir), import_node_path2.default.resolve(targetPath));
768
+ return !isIgnoredRelative(rel, ignorePaths);
769
+ }
770
+ function formatAccessPolicyPromptSection(input) {
771
+ 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)";
772
+ return `## File access boundaries (mandatory)
773
+ - You may only read or edit files inside the current project workspace directory.
774
+ - Never write, move, or delete files outside that workspace.
775
+ - You may read screenshot files listed in the attachments section (those live in the Maintainer Pro data folder, not in the project).
776
+ - Never touch paths that match this ignore list (relative to the workspace):
777
+ ${ignores}
778
+ - 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.`;
779
+ }
780
+ var ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
781
+ var ACCESS_IGNORE_END = "# maintainer-pro:access-end";
782
+ function renderManagedIgnoreBlock(ignorePaths) {
783
+ const lines = [
784
+ ACCESS_IGNORE_BEGIN,
785
+ "# Managed by Maintainer Pro \u2014 do not edit this block by hand.",
786
+ ...ignorePaths,
787
+ ACCESS_IGNORE_END
788
+ ];
789
+ return `${lines.join("\n")}
790
+ `;
791
+ }
792
+ function upsertManagedIgnoreFile(existing, ignorePaths) {
793
+ const block = renderManagedIgnoreBlock(ignorePaths);
794
+ const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
795
+ const end = existing.indexOf(ACCESS_IGNORE_END);
796
+ if (begin >= 0 && end > begin) {
797
+ const afterEnd = end + ACCESS_IGNORE_END.length;
798
+ const before = existing.slice(0, begin).replace(/\s+$/, "");
799
+ let after = existing.slice(afterEnd).replace(/^\r?\n/, "");
800
+ const parts = [before, block.trimEnd(), after.trimStart()].filter(
801
+ (s) => s.length > 0
802
+ );
803
+ return `${parts.join("\n\n")}
804
+ `;
805
+ }
806
+ const trimmed = existing.replace(/\s+$/, "");
807
+ return trimmed ? `${trimmed}
808
+
809
+ ${block}` : block;
810
+ }
811
+
812
+ // src/http/handler.ts
813
+ var import_node_crypto2 = require("crypto");
814
+
815
+ // src/log.ts
816
+ var import_pino = __toESM(require("pino"), 1);
817
+ function isDevMode() {
818
+ if (process.env.AI_DEV === "1" || process.env.AI_DEV === "true") return true;
819
+ const env = (process.env.NODE_ENV || process.env.AI_ENV || "").trim().toLowerCase();
820
+ return env === "development" || env === "dev" || env === "test";
821
+ }
822
+ function resolveLogLevel() {
823
+ const explicit = process.env.LOG_LEVEL?.trim() || process.env.AI_LOG_LEVEL?.trim();
824
+ if (explicit) return explicit;
825
+ return isDevMode() ? "debug" : "info";
826
+ }
827
+ function createLogger(name) {
828
+ const level = resolveLogLevel();
829
+ const pretty = process.env.LOG_PRETTY !== "0" && typeof process.stdout?.isTTY === "boolean" && process.stdout.isTTY;
830
+ if (pretty) {
831
+ return (0, import_pino.default)({
832
+ name,
833
+ level,
834
+ transport: {
835
+ target: "pino-pretty",
836
+ options: {
837
+ colorize: true,
838
+ translateTime: "HH:MM:ss",
839
+ ignore: "pid,hostname"
840
+ }
841
+ }
842
+ });
843
+ }
844
+ return (0, import_pino.default)({ name, level });
845
+ }
846
+ function createInfoLogger(name) {
847
+ const log = createLogger(name);
848
+ return (msg) => {
849
+ log.info(msg);
850
+ };
851
+ }
852
+ function previewText(text, max = 160) {
853
+ const t = String(text ?? "").replace(/\s+/g, " ").trim();
854
+ if (!t) return "";
855
+ return t.length <= max ? t : `${t.slice(0, max)}\u2026`;
856
+ }
857
+
511
858
  // src/http/attachments.ts
512
859
  var import_promises = __toESM(require("fs/promises"), 1);
513
- var import_node_path2 = __toESM(require("path"), 1);
860
+ var import_node_path4 = __toESM(require("path"), 1);
861
+
862
+ // src/project-data.ts
863
+ var import_node_crypto = require("crypto");
864
+ var import_node_fs2 = __toESM(require("fs"), 1);
865
+ var import_node_os = __toESM(require("os"), 1);
866
+ var import_node_path3 = __toESM(require("path"), 1);
867
+ var MAINTAINER_PRO_HOME_DIR = ".maintainer-pro";
868
+ var HOST_APPS_FILE = "apps.json";
869
+ function maintainerProHome() {
870
+ const override = process.env.MAINTAINER_PRO_HOME?.trim();
871
+ if (override) return import_node_path3.default.resolve(override);
872
+ return import_node_path3.default.join(import_node_os.default.homedir(), MAINTAINER_PRO_HOME_DIR);
873
+ }
874
+ function projectIdForFolder(folder) {
875
+ const resolved = import_node_path3.default.resolve(folder || "");
876
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
877
+ return (0, import_node_crypto.createHash)("sha256").update(key).digest("hex").slice(0, 16);
878
+ }
879
+ function sanitizeProjectId(id) {
880
+ return String(id || "").trim().replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
881
+ }
882
+ function resolveProjectDataDir(input) {
883
+ const override = String(input.dataDir || "").trim();
884
+ if (override) return import_node_path3.default.resolve(override);
885
+ const sandbox = sanitizeProjectId(String(input.sandboxId || ""));
886
+ const id = sandbox || projectIdForFolder(input.workspaceDir);
887
+ return import_node_path3.default.join(maintainerProHome(), "projects", id);
888
+ }
889
+ function projectUploadsDir(input) {
890
+ return import_node_path3.default.join(resolveProjectDataDir(input), "uploads");
891
+ }
892
+ function hostAppsCachePath(input) {
893
+ return import_node_path3.default.join(resolveProjectDataDir(input), HOST_APPS_FILE);
894
+ }
895
+ function ensureProjectDataDir(input) {
896
+ const dir = resolveProjectDataDir(input);
897
+ import_node_fs2.default.mkdirSync(dir, { recursive: true });
898
+ import_node_fs2.default.mkdirSync(import_node_path3.default.join(dir, "uploads"), { recursive: true });
899
+ try {
900
+ import_node_fs2.default.writeFileSync(
901
+ import_node_path3.default.join(dir, "workspace.json"),
902
+ `${JSON.stringify(
903
+ {
904
+ workspaceDir: import_node_path3.default.resolve(input.workspaceDir || ""),
905
+ sandboxId: input.sandboxId ? String(input.sandboxId) : null,
906
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
907
+ },
908
+ null,
909
+ 2
910
+ )}
911
+ `,
912
+ "utf8"
913
+ );
914
+ } catch {
915
+ }
916
+ return dir;
917
+ }
918
+
919
+ // src/http/attachments.ts
514
920
  var MAX_ATTACHMENTS = 5;
515
921
  var MAX_BYTES = 4 * 1024 * 1024;
516
922
  var ALLOWED = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
517
923
  function extForMime(mime) {
518
- switch (mime) {
924
+ const normalized = mime.split(";")[0]?.trim().toLowerCase() || "";
925
+ switch (normalized) {
519
926
  case "image/jpeg":
520
927
  case "image/jpg":
521
928
  return ".jpg";
@@ -527,11 +934,31 @@ function extForMime(mime) {
527
934
  return ".png";
528
935
  }
529
936
  }
530
- async function saveChatAttachments(attachments, workspaceDir) {
937
+ function attachmentIdFromRef(ref) {
938
+ const trimmed = String(ref || "").trim();
939
+ if (!trimmed) return null;
940
+ const m = /^maintainer-pro:\/\/(.+)$/i.exec(trimmed);
941
+ if (m?.[1]) return m[1];
942
+ 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(
943
+ trimmed
944
+ )) {
945
+ return trimmed;
946
+ }
947
+ return null;
948
+ }
949
+ function uploadsRoot(input) {
950
+ ensureProjectDataDir(input);
951
+ return projectUploadsDir(input);
952
+ }
953
+ async function saveChatAttachments(attachments, workspaceDir, extra) {
531
954
  if (!attachments?.length) return [];
532
955
  const selected = attachments.slice(0, MAX_ATTACHMENTS);
533
- const dir = import_node_path2.default.join(workspaceDir, ".maintainer-pro", "uploads");
534
- await import_promises.default.mkdir(dir, { recursive: true });
956
+ const loc = {
957
+ workspaceDir,
958
+ dataDir: extra?.dataDir,
959
+ sandboxId: extra?.sandboxId
960
+ };
961
+ const dir = uploadsRoot(loc);
535
962
  const paths = [];
536
963
  const stamp = Date.now();
537
964
  for (let i = 0; i < selected.length; i++) {
@@ -549,7 +976,10 @@ async function saveChatAttachments(attachments, workspaceDir) {
549
976
  }
550
977
  const safeBase = (item.name || `screenshot-${i + 1}`).replace(/[^\w.\-]+/g, "_").slice(0, 64);
551
978
  const fileName = `${stamp}-${i + 1}-${safeBase}${extForMime(mime)}`;
552
- const filePath = import_node_path2.default.join(dir, fileName);
979
+ const filePath = import_node_path4.default.resolve(dir, fileName);
980
+ if (!isInsideWorkspace(dir, filePath)) {
981
+ throw new Error("Attachment path escaped the project data folder");
982
+ }
553
983
  await import_promises.default.writeFile(filePath, buffer);
554
984
  paths.push(filePath);
555
985
  }
@@ -636,7 +1066,7 @@ function createToolValidator(schemas) {
636
1066
 
637
1067
  // src/http/handler.ts
638
1068
  var WORKING_PROVIDER = "working";
639
- async function setWorkingMessage(db, conversationId) {
1069
+ async function setWorkingMessage(db, conversationId, parentMessageId) {
640
1070
  await db.ensureConversation(conversationId);
641
1071
  await db.clearWorkingMessages?.(conversationId);
642
1072
  await db.saveMessage({
@@ -644,9 +1074,26 @@ async function setWorkingMessage(db, conversationId) {
644
1074
  role: "assistant",
645
1075
  content: "",
646
1076
  provider: WORKING_PROVIDER,
647
- senderType: "ai"
1077
+ senderType: "ai",
1078
+ parentMessageId: parentMessageId ?? void 0
648
1079
  });
649
1080
  }
1081
+ function savedMessageFields(result) {
1082
+ if (!result || typeof result !== "object") return {};
1083
+ const row = result;
1084
+ const next = row.nextQueued && typeof row.nextQueued === "object" && typeof row.nextQueued.id === "string" ? {
1085
+ id: row.nextQueued.id,
1086
+ content: String(
1087
+ row.nextQueued.content ?? ""
1088
+ )
1089
+ } : null;
1090
+ return {
1091
+ id: typeof row.id === "string" ? row.id : void 0,
1092
+ queueStatus: typeof row.queueStatus === "string" || row.queueStatus === null ? row.queueStatus : void 0,
1093
+ queuePosition: typeof row.queuePosition === "number" || row.queuePosition === null ? row.queuePosition : void 0,
1094
+ nextQueued: next
1095
+ };
1096
+ }
650
1097
  var SHARED_CONVERSATION_ID = "shared";
651
1098
  var conversationTurnSeq = /* @__PURE__ */ new Map();
652
1099
  function beginConversationTurn(conversationId) {
@@ -681,14 +1128,18 @@ async function resolveSharedConversationId(request, options, bodyId) {
681
1128
  return SHARED_CONVERSATION_ID;
682
1129
  }
683
1130
  function createChatHandler(options) {
1131
+ const logger = options.logger ?? createLogger("ai-cli:chat");
684
1132
  const validate = options.tools ? createToolValidator(options.tools) : null;
685
1133
  const workspaceDir = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
1134
+ const dataDir = options.dataDir;
1135
+ const sandboxId = options.sandboxId;
686
1136
  const baseCallOptions = {
687
1137
  systemPrompt: options.systemPrompt,
688
1138
  workspaceDir,
689
1139
  providerPreference: options.providerPreference,
690
1140
  providers: options.providers
691
1141
  };
1142
+ logger.debug({ workspaceDir }, "chat handler ready");
692
1143
  return {
693
1144
  async GET(request) {
694
1145
  try {
@@ -704,6 +1155,14 @@ function createChatHandler(options) {
704
1155
  if (options.db?.listMessages) {
705
1156
  messages = await options.db.listMessages(conversationId);
706
1157
  }
1158
+ logger.debug(
1159
+ {
1160
+ conversationId,
1161
+ provider: provider.id,
1162
+ messageCount: messages?.length ?? 0
1163
+ },
1164
+ "GET /api/chat"
1165
+ );
707
1166
  return Response.json({
708
1167
  provider: provider.id,
709
1168
  providerLabel: provider.label || providerLabel(provider.id),
@@ -711,6 +1170,7 @@ function createChatHandler(options) {
711
1170
  messages: messages ?? []
712
1171
  });
713
1172
  } catch (err) {
1173
+ logger.error({ err }, "GET /api/chat failed");
714
1174
  const message = err instanceof Error ? err.message : "No AI CLI provider available";
715
1175
  return Response.json(
716
1176
  { error: message, provider: null },
@@ -720,7 +1180,9 @@ function createChatHandler(options) {
720
1180
  },
721
1181
  async POST(request) {
722
1182
  let trackedConversationId;
1183
+ let trackedParentUserMessageId;
723
1184
  let trackedTurn = 0;
1185
+ const startedAt = Date.now();
724
1186
  try {
725
1187
  const body = await request.json();
726
1188
  const conversationId = await resolveSharedConversationId(
@@ -729,6 +1191,19 @@ function createChatHandler(options) {
729
1191
  body.conversationId
730
1192
  );
731
1193
  trackedConversationId = conversationId;
1194
+ logger.debug(
1195
+ {
1196
+ conversationId,
1197
+ type: body.type ?? "turn",
1198
+ skipPersistUser: Boolean(body.skipPersistUser),
1199
+ userMessageId: body.userMessageId,
1200
+ messageCount: body.messages?.length ?? 0,
1201
+ attachmentCount: body.attachments?.length ?? 0,
1202
+ queueStatus: body.queueStatus,
1203
+ preview: previewText(body.userMessage || body.content)
1204
+ },
1205
+ "POST /api/chat"
1206
+ );
732
1207
  if (body.type === "working-clear") {
733
1208
  if (!conversationId) {
734
1209
  return Response.json(
@@ -753,11 +1228,54 @@ function createChatHandler(options) {
753
1228
  await options.db.ensureConversation(conversationId);
754
1229
  await options.db.saveMessage({
755
1230
  conversationId,
1231
+ id: body.messageId,
756
1232
  role: "assistant",
757
1233
  content,
758
1234
  provider: "developer",
759
1235
  senderType: "developer",
760
- senderName: body.senderName?.trim() || void 0
1236
+ senderName: body.senderName?.trim() || void 0,
1237
+ parentMessageId: body.parentMessageId
1238
+ });
1239
+ }
1240
+ return Response.json({ ok: true, conversationId });
1241
+ }
1242
+ if (body.type === "user") {
1243
+ const content = body.content?.trim();
1244
+ if (!conversationId || !content) {
1245
+ return Response.json(
1246
+ { error: "conversationId and content are required" },
1247
+ { status: 400 }
1248
+ );
1249
+ }
1250
+ if (options.db) {
1251
+ await options.db.ensureConversation(conversationId);
1252
+ let persistAttachmentPaths2 = [];
1253
+ if (options.db.uploadAttachments && body.attachments?.length) {
1254
+ const uploaded = await options.db.uploadAttachments(
1255
+ conversationId,
1256
+ body.attachments
1257
+ );
1258
+ persistAttachmentPaths2 = uploaded.refs;
1259
+ }
1260
+ const saved = await options.db.saveMessage({
1261
+ conversationId,
1262
+ id: body.messageId,
1263
+ role: "user",
1264
+ content,
1265
+ attachmentPaths: persistAttachmentPaths2.length > 0 ? persistAttachmentPaths2 : void 0,
1266
+ senderType: body.senderType === "client" ? "client" : void 0,
1267
+ senderName: body.senderName?.trim() || void 0,
1268
+ parentMessageId: body.parentMessageId,
1269
+ intent: "queue",
1270
+ queueStatus: body.queueStatus === "queued" || body.queueStatus === "working" ? body.queueStatus : void 0
1271
+ });
1272
+ const fields = savedMessageFields(saved);
1273
+ return Response.json({
1274
+ ok: true,
1275
+ conversationId,
1276
+ message: saved,
1277
+ queueStatus: fields.queueStatus ?? null,
1278
+ queuePosition: fields.queuePosition ?? null
761
1279
  });
762
1280
  }
763
1281
  return Response.json({ ok: true, conversationId });
@@ -771,44 +1289,99 @@ function createChatHandler(options) {
771
1289
  }
772
1290
  let attachmentPaths = [];
773
1291
  let persistAttachmentPaths = [];
774
- if (options.db?.uploadAttachments) {
1292
+ if (options.db?.uploadAttachments && attachments?.length) {
775
1293
  const uploaded = await options.db.uploadAttachments(
776
1294
  conversationId,
777
1295
  attachments
778
1296
  );
779
1297
  attachmentPaths = uploaded.localPaths;
780
1298
  persistAttachmentPaths = uploaded.refs;
781
- } else {
1299
+ } else if (attachments?.length) {
782
1300
  attachmentPaths = await saveChatAttachments(
783
1301
  attachments,
784
- workspaceDir
1302
+ workspaceDir,
1303
+ { dataDir, sandboxId }
785
1304
  );
786
1305
  persistAttachmentPaths = attachmentPaths;
787
1306
  }
1307
+ const storedUserId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
1308
+ if (!attachmentPaths.length && conversationId && options.db?.materializeMessageAttachments) {
1309
+ attachmentPaths = await options.db.materializeMessageAttachments(
1310
+ conversationId,
1311
+ storedUserId,
1312
+ workspaceDir
1313
+ );
1314
+ }
1315
+ logger.debug(
1316
+ {
1317
+ conversationId,
1318
+ userMessageId: storedUserId,
1319
+ attachmentCount: attachmentPaths.length
1320
+ },
1321
+ "cli attachments"
1322
+ );
788
1323
  const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
789
1324
  trackedTurn = turn;
790
1325
  const signal = request.signal;
1326
+ let parentUserMessageId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
1327
+ trackedParentUserMessageId = parentUserMessageId;
791
1328
  if (options.db && conversationId) {
792
1329
  const latest = messages[messages.length - 1];
793
1330
  const persistContent = body.userMessage?.trim() || (latest?.role === "user" ? latest.content : "");
794
- if (persistContent) {
1331
+ if (persistContent && !body.skipPersistUser && !parentUserMessageId) {
795
1332
  await options.db.ensureConversation(conversationId);
796
- await options.db.saveMessage({
1333
+ const saved = await options.db.saveMessage({
797
1334
  conversationId,
798
1335
  role: "user",
799
1336
  content: persistContent,
800
1337
  attachmentPaths: persistAttachmentPaths.length > 0 ? persistAttachmentPaths : void 0,
801
1338
  senderType: body.senderType === "client" ? "client" : void 0,
802
- senderName: body.senderName?.trim() || void 0
1339
+ senderName: body.senderName?.trim() || void 0,
1340
+ intent: "run"
803
1341
  });
1342
+ parentUserMessageId = savedMessageFields(saved).id ?? parentUserMessageId;
1343
+ trackedParentUserMessageId = parentUserMessageId;
1344
+ }
1345
+ if (!body.skipPersistUser) {
1346
+ await setWorkingMessage(
1347
+ options.db,
1348
+ conversationId,
1349
+ parentUserMessageId
1350
+ );
804
1351
  }
805
- await setWorkingMessage(options.db, conversationId);
806
1352
  }
807
1353
  const latestUser = [...messages].reverse().find((m) => m.role === "user");
808
1354
  const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
809
1355
  excludeConversationId: conversationId,
810
1356
  currentRequest: latestUser?.content ?? ""
811
1357
  }) : "";
1358
+ let parentChainContext = "";
1359
+ if (options.db?.listMessages && conversationId) {
1360
+ const stored = await options.db.listMessages(conversationId);
1361
+ const chain = parentChainForTurn(
1362
+ stored.map((row) => ({
1363
+ id: row.id,
1364
+ role: row.role,
1365
+ content: row.content,
1366
+ parentMessageId: row.parentMessageId,
1367
+ senderName: row.senderName,
1368
+ provider: row.provider
1369
+ })),
1370
+ parentUserMessageId,
1371
+ body.parentMessageId
1372
+ );
1373
+ parentChainContext = formatParentChainContext(chain);
1374
+ if (parentChainContext) {
1375
+ logger.debug(
1376
+ {
1377
+ conversationId,
1378
+ parentUserMessageId,
1379
+ chainLength: chain.length
1380
+ },
1381
+ "parent chain context"
1382
+ );
1383
+ }
1384
+ }
812
1385
  if (!isActiveConversationTurn(
813
1386
  conversationId ?? SHARED_CONVERSATION_ID,
814
1387
  turn
@@ -819,23 +1392,59 @@ function createChatHandler(options) {
819
1392
  });
820
1393
  }
821
1394
  if (signal.aborted) {
822
- if (options.db?.clearWorkingMessages && conversationId) {
823
- await options.db.clearWorkingMessages(conversationId);
1395
+ logger.debug({ conversationId, turn }, "aborted before AI");
1396
+ if (conversationId && options.db) {
1397
+ if (options.db.releaseWorkingTurn) {
1398
+ await options.db.releaseWorkingTurn(
1399
+ conversationId,
1400
+ parentUserMessageId
1401
+ );
1402
+ } else {
1403
+ await options.db.clearWorkingMessages?.(conversationId);
1404
+ }
824
1405
  }
825
1406
  return Response.json({
826
1407
  superseded: true,
827
1408
  conversationId: conversationId ?? null
828
1409
  });
829
1410
  }
1411
+ logger.debug(
1412
+ {
1413
+ conversationId,
1414
+ turn,
1415
+ parentUserMessageId,
1416
+ historyLength: messages.length,
1417
+ attachmentCount: attachmentPaths.length,
1418
+ hasParentChain: Boolean(parentChainContext),
1419
+ preview: previewText(
1420
+ [...messages].reverse().find((m) => m.role === "user")?.content
1421
+ )
1422
+ },
1423
+ "calling AI provider"
1424
+ );
1425
+ const aiStarted = Date.now();
830
1426
  const aiResponse = await callAi(messages, context, {
831
1427
  ...baseCallOptions,
832
1428
  attachmentPaths,
833
- priorConversationsContext: priorConversationsContext || void 0
1429
+ priorConversationsContext: priorConversationsContext || void 0,
1430
+ parentChainContext: parentChainContext || void 0
834
1431
  });
1432
+ logger.debug(
1433
+ {
1434
+ conversationId,
1435
+ turn,
1436
+ provider: aiResponse.provider,
1437
+ toolCalls: aiResponse.toolCalls.length,
1438
+ ms: Date.now() - aiStarted,
1439
+ preview: previewText(aiResponse.text)
1440
+ },
1441
+ "AI provider returned"
1442
+ );
835
1443
  if (!isActiveConversationTurn(
836
1444
  conversationId ?? SHARED_CONVERSATION_ID,
837
1445
  turn
838
1446
  )) {
1447
+ logger.debug({ conversationId, turn }, "superseded after AI");
839
1448
  return Response.json({
840
1449
  superseded: true,
841
1450
  conversationId: conversationId ?? null
@@ -843,38 +1452,108 @@ function createChatHandler(options) {
843
1452
  }
844
1453
  const validatedToolCalls = validate ? aiResponse.toolCalls.filter((tc) => validate(tc).valid) : aiResponse.toolCalls;
845
1454
  if (options.onToolCalls && validatedToolCalls.length > 0) {
1455
+ logger.debug(
1456
+ { conversationId, count: validatedToolCalls.length },
1457
+ "applying tool calls"
1458
+ );
846
1459
  await options.onToolCalls(validatedToolCalls, context);
847
1460
  }
1461
+ let nextQueued = null;
1462
+ const assistantMessageId = typeof body.assistantMessageId === "string" && body.assistantMessageId ? body.assistantMessageId : (0, import_node_crypto2.randomUUID)();
1463
+ let replyId = assistantMessageId;
848
1464
  if (options.db && conversationId) {
849
1465
  await options.db.ensureConversation(conversationId);
850
- await options.db.clearWorkingMessages?.(conversationId);
851
- await options.db.saveMessage({
1466
+ const savedReply = await options.db.saveMessage({
852
1467
  conversationId,
1468
+ id: assistantMessageId,
853
1469
  role: "assistant",
854
1470
  content: aiResponse.text,
855
1471
  provider: aiResponse.provider,
856
- senderType: "ai"
1472
+ senderType: "ai",
1473
+ parentMessageId: parentUserMessageId
857
1474
  });
1475
+ replyId = savedMessageFields(savedReply).id ?? assistantMessageId;
1476
+ if (options.db.releaseWorkingTurn) {
1477
+ let released = void 0;
1478
+ for (let attempt = 0; attempt < 3; attempt++) {
1479
+ try {
1480
+ released = await options.db.releaseWorkingTurn(
1481
+ conversationId,
1482
+ parentUserMessageId
1483
+ );
1484
+ break;
1485
+ } catch (err) {
1486
+ logger.warn(
1487
+ { err, conversationId, parentUserMessageId, attempt },
1488
+ "releaseWorkingTurn failed; retrying"
1489
+ );
1490
+ if (attempt === 2) throw err;
1491
+ await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
1492
+ }
1493
+ }
1494
+ nextQueued = released && typeof released === "object" && released.nextQueued && typeof released.nextQueued.id === "string" ? {
1495
+ id: released.nextQueued.id,
1496
+ content: String(released.nextQueued.content ?? "")
1497
+ } : null;
1498
+ logger.debug(
1499
+ { conversationId, parentUserMessageId, nextQueued: nextQueued?.id },
1500
+ "released working turn"
1501
+ );
1502
+ } else {
1503
+ await options.db.clearWorkingMessages?.(conversationId);
1504
+ nextQueued = savedMessageFields(savedReply).nextQueued ?? null;
1505
+ }
858
1506
  if (validatedToolCalls.length > 0) {
859
1507
  await options.db.saveToolEvents?.(
860
1508
  conversationId,
861
1509
  validatedToolCalls
862
1510
  );
863
1511
  }
1512
+ logger.debug(
1513
+ {
1514
+ conversationId,
1515
+ replyId,
1516
+ nextQueued: nextQueued?.id
1517
+ },
1518
+ "persisted AI reply"
1519
+ );
864
1520
  }
1521
+ logger.debug(
1522
+ { conversationId, turn, ms: Date.now() - startedAt },
1523
+ "POST /api/chat done"
1524
+ );
865
1525
  return Response.json({
866
1526
  text: aiResponse.text,
867
1527
  toolCalls: validatedToolCalls,
868
1528
  provider: aiResponse.provider,
869
1529
  providerLabel: providerLabel(aiResponse.provider),
870
- conversationId: conversationId ?? null
1530
+ conversationId: conversationId ?? null,
1531
+ messageId: replyId,
1532
+ nextQueued,
1533
+ parentMessageId: parentUserMessageId ?? null
871
1534
  });
872
1535
  } catch (err) {
873
1536
  console.error("Chat API error:", err);
874
- if (trackedConversationId && options.db?.clearWorkingMessages && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
875
- await options.db.clearWorkingMessages(trackedConversationId).catch(
876
- () => void 0
877
- );
1537
+ logger.error(
1538
+ {
1539
+ err,
1540
+ conversationId: trackedConversationId,
1541
+ turn: trackedTurn,
1542
+ ms: Date.now() - startedAt
1543
+ },
1544
+ "POST /api/chat failed"
1545
+ );
1546
+ if (trackedConversationId && options.db && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
1547
+ if (options.db.releaseWorkingTurn) {
1548
+ await options.db.releaseWorkingTurn(
1549
+ trackedConversationId,
1550
+ trackedParentUserMessageId
1551
+ ).catch(() => void 0);
1552
+ } else {
1553
+ await options.db.clearWorkingMessages?.(trackedConversationId).catch(
1554
+ () => void 0
1555
+ );
1556
+ }
878
1557
  }
879
1558
  return Response.json(
880
1559
  {
@@ -895,8 +1574,13 @@ function toNextRoute(handlers) {
895
1574
 
896
1575
  // src/http/local-store.ts
897
1576
  var import_promises2 = __toESM(require("fs/promises"), 1);
898
- var import_node_path3 = __toESM(require("path"), 1);
899
- var import_node_crypto = require("crypto");
1577
+ var import_node_path5 = __toESM(require("path"), 1);
1578
+ var import_node_crypto3 = require("crypto");
1579
+ function userHasAiReply(rows, workingId) {
1580
+ return rows.some(
1581
+ (m) => m.role === "assistant" && m.parentMessageId === workingId && m.provider !== "working" && Boolean(m.content?.trim())
1582
+ );
1583
+ }
900
1584
  async function readConversation(filePath) {
901
1585
  try {
902
1586
  const raw = await import_promises2.default.readFile(filePath, "utf8");
@@ -907,11 +1591,11 @@ async function readConversation(filePath) {
907
1591
  }
908
1592
  }
909
1593
  async function writeConversation(filePath, data) {
910
- await import_promises2.default.mkdir(import_node_path3.default.dirname(filePath), { recursive: true });
1594
+ await import_promises2.default.mkdir(import_node_path5.default.dirname(filePath), { recursive: true });
911
1595
  await import_promises2.default.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
912
1596
  }
913
1597
  function createLocalDirectoryStore(baseDir) {
914
- const fileFor = (id) => import_node_path3.default.join(baseDir, `${id}.json`);
1598
+ const fileFor = (id) => import_node_path5.default.join(baseDir, `${id}.json`);
915
1599
  return {
916
1600
  async ensureConversation(id) {
917
1601
  const existing = await readConversation(fileFor(id));
@@ -929,21 +1613,273 @@ function createLocalDirectoryStore(baseDir) {
929
1613
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
930
1614
  messages: []
931
1615
  };
1616
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1617
+ const isClientUser = input.role === "user" && input.senderType !== "developer" && input.provider !== "developer";
1618
+ const isWorking = input.provider === "working";
1619
+ const isAi = input.role === "assistant" && input.provider !== "working" && input.senderType !== "developer" && input.provider !== "developer";
1620
+ const busy = existing.messages.some((m) => m.queueStatus === "working");
1621
+ const makeWorking = (parentMessageId) => ({
1622
+ id: (0, import_node_crypto3.randomUUID)(),
1623
+ conversationId: input.conversationId,
1624
+ role: "assistant",
1625
+ content: "",
1626
+ provider: "working",
1627
+ createdAt: now,
1628
+ senderType: "ai",
1629
+ parentMessageId
1630
+ });
1631
+ const promoteNext = () => {
1632
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1633
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1634
+ );
1635
+ const next = waiting[0];
1636
+ if (!next) return null;
1637
+ next.queueStatus = "working";
1638
+ next.queuePosition = null;
1639
+ existing.messages.push(makeWorking(next.id));
1640
+ return next;
1641
+ };
1642
+ if (isClientUser) {
1643
+ if (input.intent === "run") {
1644
+ for (const row of existing.messages) {
1645
+ if (row.queueStatus === "working") {
1646
+ row.queueStatus = null;
1647
+ row.queuePosition = null;
1648
+ }
1649
+ }
1650
+ existing.messages = existing.messages.filter(
1651
+ (m) => m.provider !== "working"
1652
+ );
1653
+ }
1654
+ const queueStatus = input.intent === "run" ? "working" : busy ? "queued" : "working";
1655
+ const existingById2 = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
1656
+ if (existingById2) {
1657
+ return { ...existingById2, nextQueued: null };
1658
+ }
1659
+ const message2 = {
1660
+ id: input.id ?? (0, import_node_crypto3.randomUUID)(),
1661
+ conversationId: input.conversationId,
1662
+ role: input.role,
1663
+ content: input.content,
1664
+ provider: input.provider ?? null,
1665
+ createdAt: now,
1666
+ attachmentPaths: input.attachmentPaths,
1667
+ senderType: input.senderType ?? null,
1668
+ senderName: input.senderName ?? null,
1669
+ parentMessageId: input.parentMessageId ?? null,
1670
+ queueStatus,
1671
+ queuePosition: null
1672
+ };
1673
+ existing.messages.push(message2);
1674
+ existing.updatedAt = now;
1675
+ await writeConversation(filePath, existing);
1676
+ return { ...message2, nextQueued: null };
1677
+ }
1678
+ if (isWorking) {
1679
+ existing.messages = existing.messages.filter(
1680
+ (m) => m.provider !== "working"
1681
+ );
1682
+ if (input.parentMessageId) {
1683
+ const parent = existing.messages.find(
1684
+ (m) => m.id === input.parentMessageId
1685
+ );
1686
+ if (parent && parent.queueStatus !== "working") {
1687
+ parent.queueStatus = "working";
1688
+ parent.queuePosition = null;
1689
+ }
1690
+ }
1691
+ const message2 = {
1692
+ id: input.id ?? (0, import_node_crypto3.randomUUID)(),
1693
+ conversationId: input.conversationId,
1694
+ role: "assistant",
1695
+ content: "",
1696
+ provider: "working",
1697
+ createdAt: now,
1698
+ senderType: "ai",
1699
+ parentMessageId: input.parentMessageId ?? null
1700
+ };
1701
+ existing.messages.push(message2);
1702
+ existing.updatedAt = now;
1703
+ await writeConversation(filePath, existing);
1704
+ return { ...message2, nextQueued: null };
1705
+ }
1706
+ const existingById = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
1707
+ if (existingById) {
1708
+ return { ...existingById, nextQueued: null };
1709
+ }
932
1710
  const message = {
933
- id: (0, import_node_crypto.randomUUID)(),
1711
+ id: input.id ?? (0, import_node_crypto3.randomUUID)(),
934
1712
  conversationId: input.conversationId,
935
1713
  role: input.role,
936
1714
  content: input.content,
937
1715
  provider: input.provider ?? null,
938
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1716
+ createdAt: now,
939
1717
  attachmentPaths: input.attachmentPaths,
940
1718
  senderType: input.senderType ?? null,
941
- senderName: input.senderName ?? null
1719
+ senderName: input.senderName ?? null,
1720
+ parentMessageId: input.parentMessageId ?? null
942
1721
  };
943
1722
  existing.messages.push(message);
944
- existing.updatedAt = message.createdAt;
1723
+ let nextQueued = null;
1724
+ if (isAi) {
1725
+ existing.messages = existing.messages.filter(
1726
+ (m) => m.provider !== "working"
1727
+ );
1728
+ if (input.parentMessageId) {
1729
+ const parent = existing.messages.find(
1730
+ (m) => m.id === input.parentMessageId
1731
+ );
1732
+ if (parent) {
1733
+ parent.queueStatus = null;
1734
+ parent.queuePosition = null;
1735
+ }
1736
+ } else {
1737
+ for (const row of existing.messages) {
1738
+ if (row.queueStatus === "working") {
1739
+ row.queueStatus = null;
1740
+ row.queuePosition = null;
1741
+ }
1742
+ }
1743
+ }
1744
+ nextQueued = promoteNext();
1745
+ }
1746
+ existing.updatedAt = now;
1747
+ await writeConversation(filePath, existing);
1748
+ return {
1749
+ ...message,
1750
+ nextQueued: nextQueued ? { id: nextQueued.id, content: nextQueued.content } : null
1751
+ };
1752
+ },
1753
+ async recoverQueue(conversationId) {
1754
+ const filePath = fileFor(conversationId);
1755
+ const existing = await readConversation(filePath);
1756
+ if (!existing) {
1757
+ return {
1758
+ action: "noop",
1759
+ workingId: null,
1760
+ promotedId: null,
1761
+ nextQueued: null
1762
+ };
1763
+ }
1764
+ const working = existing.messages.find(
1765
+ (m) => m.role === "user" && m.queueStatus === "working"
1766
+ );
1767
+ if (working) {
1768
+ if (userHasAiReply(existing.messages, working.id)) {
1769
+ existing.messages = existing.messages.filter(
1770
+ (m) => m.provider !== "working"
1771
+ );
1772
+ working.queueStatus = null;
1773
+ working.queuePosition = null;
1774
+ const waiting2 = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1775
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1776
+ );
1777
+ const next2 = waiting2[0] ?? null;
1778
+ if (next2) {
1779
+ next2.queueStatus = "working";
1780
+ next2.queuePosition = null;
1781
+ existing.messages.push({
1782
+ id: (0, import_node_crypto3.randomUUID)(),
1783
+ conversationId,
1784
+ role: "assistant",
1785
+ content: "",
1786
+ provider: "working",
1787
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1788
+ senderType: "ai",
1789
+ parentMessageId: next2.id
1790
+ });
1791
+ }
1792
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1793
+ await writeConversation(filePath, existing);
1794
+ return {
1795
+ action: "completed_and_promoted",
1796
+ workingId: working.id,
1797
+ promotedId: next2?.id ?? null,
1798
+ nextQueued: next2 ? { id: next2.id, content: next2.content } : null
1799
+ };
1800
+ }
1801
+ return {
1802
+ action: "redispatched_working",
1803
+ workingId: working.id,
1804
+ promotedId: null,
1805
+ nextQueued: null
1806
+ };
1807
+ }
1808
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1809
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1810
+ );
1811
+ const next = waiting[0] ?? null;
1812
+ if (!next) {
1813
+ return {
1814
+ action: "noop",
1815
+ workingId: null,
1816
+ promotedId: null,
1817
+ nextQueued: null
1818
+ };
1819
+ }
1820
+ next.queueStatus = "working";
1821
+ next.queuePosition = null;
1822
+ existing.messages.push({
1823
+ id: (0, import_node_crypto3.randomUUID)(),
1824
+ conversationId,
1825
+ role: "assistant",
1826
+ content: "",
1827
+ provider: "working",
1828
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1829
+ senderType: "ai",
1830
+ parentMessageId: next.id
1831
+ });
1832
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1833
+ await writeConversation(filePath, existing);
1834
+ return {
1835
+ action: "promoted_queued",
1836
+ workingId: null,
1837
+ promotedId: next.id,
1838
+ nextQueued: { id: next.id, content: next.content }
1839
+ };
1840
+ },
1841
+ async releaseWorkingTurn(conversationId, parentMessageId) {
1842
+ const filePath = fileFor(conversationId);
1843
+ const existing = await readConversation(filePath);
1844
+ if (!existing) return { nextQueued: null };
1845
+ existing.messages = existing.messages.filter(
1846
+ (m) => m.provider !== "working"
1847
+ );
1848
+ if (parentMessageId) {
1849
+ const parent = existing.messages.find((m) => m.id === parentMessageId);
1850
+ if (parent) {
1851
+ parent.queueStatus = null;
1852
+ parent.queuePosition = null;
1853
+ }
1854
+ } else {
1855
+ for (const row of existing.messages) {
1856
+ if (row.queueStatus === "working") {
1857
+ row.queueStatus = null;
1858
+ row.queuePosition = null;
1859
+ }
1860
+ }
1861
+ }
1862
+ const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
1863
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
1864
+ );
1865
+ const next = waiting[0] ?? null;
1866
+ if (next) {
1867
+ next.queueStatus = "working";
1868
+ next.queuePosition = null;
1869
+ existing.messages.push({
1870
+ id: (0, import_node_crypto3.randomUUID)(),
1871
+ conversationId,
1872
+ role: "assistant",
1873
+ content: "",
1874
+ provider: "working",
1875
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1876
+ senderType: "ai",
1877
+ parentMessageId: next.id
1878
+ });
1879
+ }
1880
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
945
1881
  await writeConversation(filePath, existing);
946
- return message;
1882
+ return next ? { nextQueued: { id: next.id, content: next.content } } : { nextQueued: null };
947
1883
  },
948
1884
  async clearWorkingMessages(conversationId) {
949
1885
  const filePath = fileFor(conversationId);
@@ -965,7 +1901,7 @@ function createLocalDirectoryStore(baseDir) {
965
1901
  const conversations = [];
966
1902
  for (const entry of entries) {
967
1903
  if (!entry.endsWith(".json")) continue;
968
- const filePath = import_node_path3.default.join(baseDir, entry);
1904
+ const filePath = import_node_path5.default.join(baseDir, entry);
969
1905
  const existing = await readConversation(filePath);
970
1906
  if (!existing?.id) continue;
971
1907
  conversations.push({
@@ -982,7 +1918,7 @@ function createLocalDirectoryStore(baseDir) {
982
1918
  if (events.length === 0) return;
983
1919
  const existing = await readConversation(fileFor(conversationId));
984
1920
  if (!existing) return;
985
- const toolFile = import_node_path3.default.join(baseDir, `${conversationId}.tools.jsonl`);
1921
+ const toolFile = import_node_path5.default.join(baseDir, `${conversationId}.tools.jsonl`);
986
1922
  const lines = events.map(
987
1923
  (e) => JSON.stringify({
988
1924
  conversationId,
@@ -998,35 +1934,389 @@ function createLocalDirectoryStore(baseDir) {
998
1934
 
999
1935
  // src/http/maintainer-pro-store.ts
1000
1936
  var import_promises3 = __toESM(require("fs/promises"), 1);
1001
- var import_node_os = __toESM(require("os"), 1);
1002
- var import_node_path4 = __toESM(require("path"), 1);
1003
- async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
1004
- const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
1005
- const res = await fetchImpl(url, {
1006
- ...init,
1007
- headers: {
1008
- Authorization: `Bearer ${apiKey}`,
1009
- "Content-Type": "application/json",
1010
- ...init?.headers ?? {}
1011
- }
1012
- });
1013
- if (!res.ok) {
1014
- const text = await res.text().catch(() => "");
1015
- throw new Error(
1016
- `Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
1017
- );
1018
- }
1019
- const contentType = res.headers.get("content-type") ?? "";
1020
- if (contentType.includes("application/json")) {
1021
- return res.json();
1022
- }
1023
- return res;
1937
+ var import_node_path6 = __toESM(require("path"), 1);
1938
+
1939
+ // src/http/synced-store.ts
1940
+ function userHasAiReply2(rows, workingId) {
1941
+ return rows.some(
1942
+ (row) => row.role === "assistant" && row.parentMessageId === workingId && row.provider !== "working" && row.senderType !== "developer" && Boolean(row.content?.trim())
1943
+ );
1944
+ }
1945
+ function nowIso() {
1946
+ return (/* @__PURE__ */ new Date()).toISOString();
1947
+ }
1948
+ function toWsUrl(baseUrl, apiKey) {
1949
+ const u = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/ws`);
1950
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
1951
+ u.searchParams.set("apiKey", apiKey);
1952
+ return u.toString();
1953
+ }
1954
+ function asCachedMessage(raw) {
1955
+ const id = typeof raw.id === "string" ? raw.id : "";
1956
+ const role = typeof raw.role === "string" ? raw.role : "";
1957
+ if (!id || !role) return null;
1958
+ return {
1959
+ id,
1960
+ role,
1961
+ content: typeof raw.content === "string" ? raw.content : "",
1962
+ provider: typeof raw.provider === "string" ? raw.provider : null,
1963
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : void 0,
1964
+ 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,
1965
+ senderType: raw.senderType === "client" || raw.senderType === "developer" || raw.senderType === "ai" ? raw.senderType : null,
1966
+ senderName: typeof raw.senderName === "string" ? raw.senderName : null,
1967
+ parentMessageId: typeof raw.parentMessageId === "string" ? raw.parentMessageId : null,
1968
+ queueStatus: raw.queueStatus === "working" || raw.queueStatus === "queued" ? raw.queueStatus : raw.queueStatus === null ? null : void 0,
1969
+ queuePosition: typeof raw.queuePosition === "number" ? raw.queuePosition : null
1970
+ };
1971
+ }
1972
+ function cloneMessages(messages) {
1973
+ return messages.map((m) => ({ ...m }));
1974
+ }
1975
+ function createSyncedChatStore(options) {
1976
+ const { remote, maintainerProUrl, apiKey, onWorkingTurn } = options;
1977
+ const logger = options.logger ?? createLogger("ai-cli");
1978
+ const info = (msg) => {
1979
+ if (options.log) options.log(msg);
1980
+ else logger.info(msg);
1981
+ };
1982
+ const conversations = /* @__PURE__ */ new Map();
1983
+ const inflightRefresh = /* @__PURE__ */ new Map();
1984
+ let stopped = false;
1985
+ let socket = null;
1986
+ let reconnectAttempt = 0;
1987
+ let reconnectTimer = null;
1988
+ let pingTimer = null;
1989
+ const stats = () => {
1990
+ let messages = 0;
1991
+ for (const conv of conversations.values()) messages += conv.messages.length;
1992
+ return { conversations: conversations.size, messages };
1993
+ };
1994
+ const putConversation = (id, messages, updatedAt = nowIso()) => {
1995
+ conversations.set(id, { id, updatedAt, messages: cloneMessages(messages) });
1996
+ };
1997
+ const refreshConversation = (id) => {
1998
+ const existing = inflightRefresh.get(id);
1999
+ if (existing) return existing;
2000
+ const pending = (async () => {
2001
+ const messages = remote.listMessages ? await remote.listMessages(id) : [];
2002
+ putConversation(id, messages);
2003
+ return cloneMessages(messages);
2004
+ })().finally(() => {
2005
+ inflightRefresh.delete(id);
2006
+ });
2007
+ inflightRefresh.set(id, pending);
2008
+ return pending;
2009
+ };
2010
+ const loadSnapshot = async () => {
2011
+ if (remote.listSnapshot) {
2012
+ try {
2013
+ return await remote.listSnapshot();
2014
+ } catch {
2015
+ }
2016
+ }
2017
+ if (!remote.listConversations || !remote.listMessages) return [];
2018
+ const summaries = await remote.listConversations();
2019
+ const loaded = [];
2020
+ for (const row of summaries) {
2021
+ loaded.push({
2022
+ id: row.id,
2023
+ updatedAt: row.updatedAt,
2024
+ messages: await remote.listMessages(row.id)
2025
+ });
2026
+ }
2027
+ return loaded;
2028
+ };
2029
+ const hydrate = async () => {
2030
+ const snapshot = await loadSnapshot();
2031
+ conversations.clear();
2032
+ for (const row of snapshot) {
2033
+ putConversation(row.id, row.messages, row.updatedAt);
2034
+ }
2035
+ const next = stats();
2036
+ info(
2037
+ `cache hydrated (${next.conversations} conversations, ${next.messages} messages)`
2038
+ );
2039
+ for (const conv of conversations.values()) {
2040
+ const working = conv.messages.find(
2041
+ (row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
2042
+ );
2043
+ if (!working) continue;
2044
+ if (!userHasAiReply2(conv.messages, working.id)) {
2045
+ logger.debug(
2046
+ { conversationId: conv.id, messageId: working.id },
2047
+ "resume working turn"
2048
+ );
2049
+ onWorkingTurn?.({ conversationId: conv.id, message: working });
2050
+ }
2051
+ }
2052
+ return next;
2053
+ };
2054
+ const upsertMessage = (conversationId, message) => {
2055
+ const conv = conversations.get(conversationId) ?? {
2056
+ id: conversationId,
2057
+ updatedAt: nowIso(),
2058
+ messages: []
2059
+ };
2060
+ const index = conv.messages.findIndex((row) => row.id === message.id);
2061
+ const wasWorking = index >= 0 && conv.messages[index]?.queueStatus === "working";
2062
+ if (index >= 0) {
2063
+ conv.messages[index] = { ...conv.messages[index], ...message };
2064
+ } else {
2065
+ conv.messages.push(message);
2066
+ }
2067
+ conv.updatedAt = nowIso();
2068
+ conversations.set(conversationId, conv);
2069
+ const becameWorking = message.role === "user" && message.queueStatus === "working" && message.senderType !== "developer" && !wasWorking;
2070
+ if (becameWorking) {
2071
+ logger.debug(
2072
+ { conversationId, messageId: message.id },
2073
+ "working turn"
2074
+ );
2075
+ onWorkingTurn?.({ conversationId, message });
2076
+ }
2077
+ };
2078
+ const deleteMessages = (conversationId, ids) => {
2079
+ const conv = conversations.get(conversationId);
2080
+ if (!conv || ids.length === 0) return;
2081
+ const drop = new Set(ids);
2082
+ conv.messages = conv.messages.filter((row) => !drop.has(row.id));
2083
+ conv.updatedAt = nowIso();
2084
+ };
2085
+ const applyEvent = (msg) => {
2086
+ const externalId = typeof msg.externalId === "string" && msg.externalId ? msg.externalId : typeof msg.conversationId === "string" ? msg.conversationId : "";
2087
+ if (!externalId) return;
2088
+ if (msg.type === "message.created") {
2089
+ const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
2090
+ const cached = raw ? asCachedMessage(raw) : null;
2091
+ if (cached) {
2092
+ upsertMessage(externalId, cached);
2093
+ return;
2094
+ }
2095
+ void refreshConversation(externalId).catch(() => void 0);
2096
+ return;
2097
+ }
2098
+ if (msg.type === "message.updated") {
2099
+ const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
2100
+ const cached = raw ? asCachedMessage(raw) : null;
2101
+ if (cached) {
2102
+ upsertMessage(externalId, cached);
2103
+ return;
2104
+ }
2105
+ void refreshConversation(externalId).catch(() => void 0);
2106
+ return;
2107
+ }
2108
+ if (msg.type === "message.deleted") {
2109
+ const ids = Array.isArray(msg.ids) ? msg.ids.filter((id) => typeof id === "string") : [];
2110
+ deleteMessages(externalId, ids);
2111
+ }
2112
+ };
2113
+ const stopSocket = () => {
2114
+ if (pingTimer) {
2115
+ clearInterval(pingTimer);
2116
+ pingTimer = null;
2117
+ }
2118
+ if (reconnectTimer) {
2119
+ clearTimeout(reconnectTimer);
2120
+ reconnectTimer = null;
2121
+ }
2122
+ try {
2123
+ socket?.close();
2124
+ } catch {
2125
+ }
2126
+ socket = null;
2127
+ };
2128
+ const connectRealtime = () => {
2129
+ if (stopped) return;
2130
+ const WebSocketCtor = globalThis.WebSocket;
2131
+ if (!WebSocketCtor) {
2132
+ info("WebSocket unavailable; cache will refresh after local writes only");
2133
+ return;
2134
+ }
2135
+ const ws = new WebSocketCtor(toWsUrl(maintainerProUrl, apiKey));
2136
+ socket = ws;
2137
+ ws.addEventListener("open", () => {
2138
+ reconnectAttempt = 0;
2139
+ logger.debug("store websocket open");
2140
+ if (pingTimer) clearInterval(pingTimer);
2141
+ pingTimer = setInterval(() => {
2142
+ try {
2143
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" }));
2144
+ } catch {
2145
+ }
2146
+ }, 2e4);
2147
+ });
2148
+ ws.addEventListener("message", (event) => {
2149
+ let payload;
2150
+ try {
2151
+ payload = JSON.parse(String(event.data));
2152
+ } catch {
2153
+ return;
2154
+ }
2155
+ if (payload.type === "hello") {
2156
+ const meta = payload.meta && typeof payload.meta === "object" ? payload.meta : null;
2157
+ const sandboxId = typeof meta?.sandboxId === "string" ? meta.sandboxId : "";
2158
+ if (sandboxId && ws.readyState === 1) {
2159
+ ws.send(
2160
+ JSON.stringify({ type: "subscribe", channels: [`sandbox:${sandboxId}`] })
2161
+ );
2162
+ }
2163
+ logger.debug({ sandboxId }, "store websocket hello");
2164
+ return;
2165
+ }
2166
+ if (payload.type === "message.created" || payload.type === "message.updated" || payload.type === "message.deleted") {
2167
+ logger.debug({ type: payload.type }, "store websocket event");
2168
+ applyEvent(payload);
2169
+ }
2170
+ });
2171
+ ws.addEventListener("close", () => {
2172
+ if (socket === ws) socket = null;
2173
+ if (pingTimer) {
2174
+ clearInterval(pingTimer);
2175
+ pingTimer = null;
2176
+ }
2177
+ if (stopped) return;
2178
+ const delay = Math.min(3e4, 1e3 * 2 ** Math.min(reconnectAttempt, 5));
2179
+ reconnectAttempt += 1;
2180
+ logger.debug({ delay, reconnectAttempt }, "store websocket reconnect");
2181
+ reconnectTimer = setTimeout(() => {
2182
+ reconnectTimer = null;
2183
+ void hydrate().catch((err) => {
2184
+ logger.warn(
2185
+ `cache rehydrate failed: ${err instanceof Error ? err.message : String(err)}`
2186
+ );
2187
+ }).finally(() => {
2188
+ connectRealtime();
2189
+ });
2190
+ }, delay);
2191
+ });
2192
+ };
2193
+ const ready = hydrate().catch((err) => {
2194
+ logger.warn(
2195
+ `cache hydrate failed: ${err instanceof Error ? err.message : String(err)}`
2196
+ );
2197
+ return stats();
2198
+ });
2199
+ void ready.then(() => {
2200
+ if (!stopped) connectRealtime();
2201
+ });
2202
+ const store = {
2203
+ ready,
2204
+ stop: () => {
2205
+ stopped = true;
2206
+ stopSocket();
2207
+ },
2208
+ async ensureConversation(id) {
2209
+ await remote.ensureConversation(id);
2210
+ if (!conversations.has(id)) {
2211
+ putConversation(id, []);
2212
+ }
2213
+ },
2214
+ async saveMessage(input) {
2215
+ const saved = await remote.saveMessage(input);
2216
+ await refreshConversation(input.conversationId).catch(() => void 0);
2217
+ return saved;
2218
+ },
2219
+ async listMessages(conversationId) {
2220
+ await ready.catch(() => void 0);
2221
+ const hit = conversations.get(conversationId);
2222
+ if (hit) return cloneMessages(hit.messages);
2223
+ return refreshConversation(conversationId);
2224
+ },
2225
+ async clearWorkingMessages(conversationId) {
2226
+ await remote.clearWorkingMessages?.(conversationId);
2227
+ await refreshConversation(conversationId).catch(() => void 0);
2228
+ },
2229
+ async releaseWorkingTurn(conversationId, parentMessageId) {
2230
+ const result = await remote.releaseWorkingTurn?.(
2231
+ conversationId,
2232
+ parentMessageId
2233
+ );
2234
+ await refreshConversation(conversationId).catch(() => void 0);
2235
+ return result ?? { nextQueued: null };
2236
+ },
2237
+ async recoverQueue(conversationId) {
2238
+ const result = await remote.recoverQueue?.(conversationId);
2239
+ await refreshConversation(conversationId).catch(() => void 0);
2240
+ const conv = conversations.get(conversationId);
2241
+ if (conv) {
2242
+ const working = conv.messages.find(
2243
+ (row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
2244
+ );
2245
+ if (working && !userHasAiReply2(conv.messages, working.id)) {
2246
+ logger.info(
2247
+ { conversationId, messageId: working.id, action: result?.action },
2248
+ "recoverQueue \u2192 resume working turn"
2249
+ );
2250
+ onWorkingTurn?.({ conversationId, message: working });
2251
+ }
2252
+ }
2253
+ return result ?? {
2254
+ action: "noop",
2255
+ workingId: null,
2256
+ promotedId: null,
2257
+ nextQueued: null
2258
+ };
2259
+ },
2260
+ async listConversations() {
2261
+ await ready.catch(() => void 0);
2262
+ return [...conversations.values()].map((conv) => ({
2263
+ id: conv.id,
2264
+ updatedAt: conv.updatedAt,
2265
+ messages: cloneMessages(conv.messages)
2266
+ })).sort(
2267
+ (a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
2268
+ );
2269
+ },
2270
+ async saveToolEvents(conversationId, events) {
2271
+ await remote.saveToolEvents?.(conversationId, events);
2272
+ },
2273
+ async uploadAttachments(conversationId, attachments) {
2274
+ if (!remote.uploadAttachments) {
2275
+ return { refs: [], localPaths: [] };
2276
+ }
2277
+ return remote.uploadAttachments(conversationId, attachments);
2278
+ },
2279
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2280
+ if (!remote.materializeMessageAttachments) return [];
2281
+ return remote.materializeMessageAttachments(
2282
+ conversationId,
2283
+ messageId,
2284
+ workspaceDir
2285
+ );
2286
+ }
2287
+ };
2288
+ return store;
2289
+ }
2290
+
2291
+ // src/http/maintainer-pro-store.ts
2292
+ async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
2293
+ const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
2294
+ const res = await fetchImpl(url, {
2295
+ ...init,
2296
+ headers: {
2297
+ Authorization: `Bearer ${apiKey}`,
2298
+ "Content-Type": "application/json",
2299
+ ...init?.headers ?? {}
2300
+ }
2301
+ });
2302
+ if (!res.ok) {
2303
+ const text = await res.text().catch(() => "");
2304
+ throw new Error(
2305
+ `Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
2306
+ );
2307
+ }
2308
+ const contentType = res.headers.get("content-type") ?? "";
2309
+ if (contentType.includes("application/json")) {
2310
+ return res.json();
2311
+ }
2312
+ return res;
1024
2313
  }
1025
2314
  function createMaintainerProStore(options) {
1026
2315
  const baseUrl = options.baseUrl;
1027
2316
  const apiKey = options.apiKey;
1028
2317
  const fetchImpl = options.fetchImpl ?? fetch;
1029
- const tempDir = options.tempDir ?? import_node_path4.default.join(import_node_os.default.tmpdir(), "maintainer-pro-store");
2318
+ const logger = options.logger;
2319
+ const tempDir = options.tempDir ?? projectUploadsDir({ workspaceDir: process.cwd() });
1030
2320
  const store = {
1031
2321
  async ensureConversation(id) {
1032
2322
  await mpFetch(
@@ -1049,17 +2339,54 @@ function createMaintainerProStore(options) {
1049
2339
  {
1050
2340
  method: "POST",
1051
2341
  body: JSON.stringify({
2342
+ id: input.id,
1052
2343
  role: input.role,
1053
2344
  content: input.content,
1054
2345
  provider: input.provider,
1055
2346
  senderType: input.senderType,
1056
2347
  senderName: input.senderName,
1057
- attachmentIds
2348
+ parentMessageId: input.parentMessageId ?? void 0,
2349
+ attachmentIds,
2350
+ intent: input.intent,
2351
+ queueStatus: input.queueStatus ?? void 0
1058
2352
  })
1059
2353
  },
1060
2354
  fetchImpl
1061
2355
  );
1062
- return data.message;
2356
+ return {
2357
+ ...data.message,
2358
+ nextQueued: data.nextQueued ?? null
2359
+ };
2360
+ },
2361
+ async releaseWorkingTurn(conversationId, parentMessageId) {
2362
+ const data = await mpFetch(
2363
+ baseUrl,
2364
+ apiKey,
2365
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/complete-turn`,
2366
+ {
2367
+ method: "POST",
2368
+ body: JSON.stringify({
2369
+ parentMessageId: parentMessageId ?? void 0
2370
+ })
2371
+ },
2372
+ fetchImpl
2373
+ );
2374
+ return { nextQueued: data.nextQueued ?? null };
2375
+ },
2376
+ async recoverQueue(conversationId) {
2377
+ const data = await mpFetch(
2378
+ baseUrl,
2379
+ apiKey,
2380
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/recover-queue`,
2381
+ { method: "POST", body: "{}" },
2382
+ fetchImpl
2383
+ );
2384
+ return {
2385
+ action: data.action ?? "noop",
2386
+ workingId: data.workingId ?? null,
2387
+ promotedId: data.promotedId ?? null,
2388
+ nextQueued: data.nextQueued ?? null
2389
+ };
1063
2390
  },
1064
2391
  async clearWorkingMessages(conversationId) {
1065
2392
  await mpFetch(
@@ -1090,6 +2417,16 @@ function createMaintainerProStore(options) {
1090
2417
  );
1091
2418
  return data.conversations ?? [];
1092
2419
  },
2420
+ async listSnapshot() {
2421
+ const data = await mpFetch(
2422
+ baseUrl,
2423
+ apiKey,
2424
+ `/api/v1/store/snapshot`,
2425
+ { method: "GET" },
2426
+ fetchImpl
2427
+ );
2428
+ return data.conversations ?? [];
2429
+ },
1093
2430
  async saveToolEvents(conversationId, events) {
1094
2431
  if (!events.length) return;
1095
2432
  await mpFetch(
@@ -1130,28 +2467,131 @@ function createMaintainerProStore(options) {
1130
2467
  const id = data.attachment.id;
1131
2468
  attachmentIds.push(id);
1132
2469
  refs.push(data.attachment.ref || `maintainer-pro://${id}`);
1133
- const ext = item.mimeType.includes("jpeg") || item.mimeType.includes("jpg") ? ".jpg" : item.mimeType.includes("webp") ? ".webp" : item.mimeType.includes("gif") ? ".gif" : ".png";
1134
- const localPath = import_node_path4.default.join(
2470
+ const localPath = import_node_path6.default.join(
1135
2471
  tempDir,
1136
- `${conversationId}-${id}${ext}`
2472
+ `${conversationId}-${id}${extForMime(item.mimeType)}`
1137
2473
  );
2474
+ await import_promises3.default.mkdir(import_node_path6.default.dirname(localPath), { recursive: true });
1138
2475
  await import_promises3.default.writeFile(localPath, Buffer.from(item.data, "base64"));
1139
2476
  localPaths.push(localPath);
1140
2477
  }
1141
2478
  return { refs, localPaths, attachmentIds };
2479
+ },
2480
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2481
+ if (!conversationId || !workspaceDir) return [];
2482
+ const data = await mpFetch(
2483
+ baseUrl,
2484
+ apiKey,
2485
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages`,
2486
+ { method: "GET" },
2487
+ fetchImpl
2488
+ );
2489
+ const messages = data.messages ?? [];
2490
+ const byId = new Map(messages.map((row) => [row.id, row]));
2491
+ const refs = [];
2492
+ const fromMessageIds = [];
2493
+ const seenMsg = /* @__PURE__ */ new Set();
2494
+ const pushRow = (row) => {
2495
+ if (!row || seenMsg.has(row.id)) return;
2496
+ seenMsg.add(row.id);
2497
+ const before = refs.length;
2498
+ refs.push(...row.attachmentPaths ?? []);
2499
+ refs.push(
2500
+ ...(row.attachmentIds ?? []).map((id) => `maintainer-pro://${id}`)
2501
+ );
2502
+ if (refs.length > before) fromMessageIds.push(row.id);
2503
+ };
2504
+ let current = messageId ? byId.get(messageId) : void 0;
2505
+ while (current && !seenMsg.has(current.id)) {
2506
+ pushRow(current);
2507
+ const parentId = current.parentMessageId?.trim();
2508
+ current = parentId ? byId.get(parentId) : void 0;
2509
+ }
2510
+ for (const row of messages) {
2511
+ if (row.role && row.role !== "user") continue;
2512
+ pushRow(row);
2513
+ }
2514
+ const seen = /* @__PURE__ */ new Set();
2515
+ const ids = [];
2516
+ for (const ref of refs) {
2517
+ const id = attachmentIdFromRef(ref);
2518
+ if (!id || seen.has(id)) continue;
2519
+ seen.add(id);
2520
+ ids.push(id);
2521
+ }
2522
+ const localPaths = [];
2523
+ for (const id of ids.slice(0, 5)) {
2524
+ const url = `${baseUrl.replace(/\/$/, "")}/api/v1/store/attachments/${encodeURIComponent(id)}`;
2525
+ const res = await fetchImpl(url, {
2526
+ headers: { Authorization: `Bearer ${apiKey}` }
2527
+ });
2528
+ if (!res.ok) {
2529
+ continue;
2530
+ }
2531
+ const mime = res.headers.get("content-type") || "image/png";
2532
+ const buffer = Buffer.from(await res.arrayBuffer());
2533
+ if (!buffer.byteLength) continue;
2534
+ const localPath = import_node_path6.default.join(
2535
+ tempDir,
2536
+ `${id}${extForMime(mime)}`
2537
+ );
2538
+ await import_promises3.default.mkdir(tempDir, { recursive: true });
2539
+ await import_promises3.default.writeFile(localPath, buffer);
2540
+ localPaths.push(localPath);
2541
+ }
2542
+ logger?.debug(
2543
+ {
2544
+ conversationId,
2545
+ messageId,
2546
+ fromMessageIds,
2547
+ stored: ids.length,
2548
+ written: localPaths.length
2549
+ },
2550
+ "materialize attachments"
2551
+ );
2552
+ return localPaths;
1142
2553
  }
1143
2554
  };
1144
- return store;
2555
+ if (options.sync === false) {
2556
+ return Object.assign(store, {
2557
+ ready: Promise.resolve({ conversations: 0, messages: 0 }),
2558
+ stop() {
2559
+ }
2560
+ });
2561
+ }
2562
+ return createSyncedChatStore({
2563
+ remote: store,
2564
+ maintainerProUrl: baseUrl,
2565
+ apiKey,
2566
+ logger: options.logger,
2567
+ log: options.log,
2568
+ onWorkingTurn: options.onWorkingTurn
2569
+ });
1145
2570
  }
1146
- function createMaintainerProStoreFromEnv() {
2571
+ function createMaintainerProStoreFromEnv(options) {
1147
2572
  const baseUrl = process.env.MAINTAINER_PRO_URL?.trim();
1148
2573
  const apiKey = process.env.MAINTAINER_PRO_API_KEY?.trim();
1149
2574
  if (!baseUrl || !apiKey) return null;
1150
- return createMaintainerProStore({ baseUrl, apiKey });
2575
+ return createMaintainerProStore({
2576
+ baseUrl,
2577
+ apiKey,
2578
+ tempDir: projectUploadsDir({
2579
+ workspaceDir: process.env.AI_CLI_WORKSPACE || process.cwd(),
2580
+ sandboxId: process.env.MAINTAINER_PRO_SANDBOX_ID,
2581
+ dataDir: process.env.MAINTAINER_PRO_DATA_DIR
2582
+ }),
2583
+ logger: options?.logger,
2584
+ log: options?.log,
2585
+ sync: options?.sync,
2586
+ onWorkingTurn: options?.onWorkingTurn
2587
+ });
1151
2588
  }
1152
2589
 
1153
2590
  // src/workspace-inspect.ts
2591
+ var import_node_fs3 = __toESM(require("fs"), 1);
2592
+ var import_node_path7 = __toESM(require("path"), 1);
1154
2593
  var import_zod = require("zod");
2594
+ var CONFIG_INSPECT_TIMEOUT_MS = 18e3;
1155
2595
  var inspectJsonSchema = import_zod.z.object({
1156
2596
  kind: import_zod.z.enum(["next", "vite", "html", "empty", "other"]).optional(),
1157
2597
  name: import_zod.z.string().max(200).optional(),
@@ -1221,6 +2661,154 @@ ${input.extraContext ? `${input.extraContext.trim()}
1221
2661
 
1222
2662
  ` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
1223
2663
  }
2664
+ function readSnippet(file, max = 4e3) {
2665
+ try {
2666
+ const text = import_node_fs3.default.readFileSync(file, "utf8");
2667
+ return text.length > max ? `${text.slice(0, max)}
2668
+ \u2026` : text;
2669
+ } catch {
2670
+ return null;
2671
+ }
2672
+ }
2673
+ function envKeysOnly(file) {
2674
+ try {
2675
+ const keys = import_node_fs3.default.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);
2676
+ return keys.length ? keys.join("\n") : null;
2677
+ } catch {
2678
+ return null;
2679
+ }
2680
+ }
2681
+ function gatherConfigSnippets(workspaceDir) {
2682
+ const folder = import_node_path7.default.resolve(workspaceDir);
2683
+ const parts = [];
2684
+ const pkg = readSnippet(import_node_path7.default.join(folder, "package.json"));
2685
+ if (pkg) parts.push(`### package.json
2686
+ \`\`\`json
2687
+ ${pkg}
2688
+ \`\`\``);
2689
+ for (const lock of [
2690
+ "package-lock.json",
2691
+ "pnpm-lock.yaml",
2692
+ "yarn.lock",
2693
+ "bun.lockb"
2694
+ ]) {
2695
+ if (import_node_fs3.default.existsSync(import_node_path7.default.join(folder, lock))) {
2696
+ parts.push(`### lockfile
2697
+ ${lock}`);
2698
+ break;
2699
+ }
2700
+ }
2701
+ for (const rel of [
2702
+ "vite.config.ts",
2703
+ "vite.config.js",
2704
+ "vite.config.mjs",
2705
+ "next.config.js",
2706
+ "next.config.mjs",
2707
+ "next.config.ts"
2708
+ ]) {
2709
+ const body = readSnippet(import_node_path7.default.join(folder, rel), 2500);
2710
+ if (body) parts.push(`### ${rel}
2711
+ \`\`\`
2712
+ ${body}
2713
+ \`\`\``);
2714
+ }
2715
+ for (const rel of [".env.example", ".env", ".env.local", ".env.development"]) {
2716
+ const keys = envKeysOnly(import_node_path7.default.join(folder, rel));
2717
+ if (keys) parts.push(`### ${rel} keys (values omitted)
2718
+ \`\`\`
2719
+ ${keys}
2720
+ \`\`\``);
2721
+ }
2722
+ return parts.join("\n\n") || "(no config files found)";
2723
+ }
2724
+ function configOnlySystemPrompt(input) {
2725
+ return `You are Maintainer Pro's read-only setup advisor.
2726
+
2727
+ Workspace: ${input.workspaceDir}
2728
+ App name: ${input.appName || "the app"}
2729
+
2730
+ You are given configuration file excerpts only.
2731
+ DO NOT edit, create, or delete any files.
2732
+ DO NOT run shell commands, install packages, or start servers.
2733
+ Only infer how this project runs locally from the excerpts.
2734
+
2735
+ Script names you report MUST appear in package.json scripts. Omit chat/ai-server scripts.
2736
+
2737
+ Reply with a short human summary AND one \`\`\`json fence with:
2738
+
2739
+ {
2740
+ "kind": "next" | "vite" | "html" | "empty" | "other",
2741
+ "name": "package or folder name",
2742
+ "summary": "one sentence about this project",
2743
+ "scripts": { "ui": "dev:client", "backend": "dev:server" },
2744
+ "ports": { "ui": 5173, "backend": 4100 },
2745
+ "issues": ["uncertainties"],
2746
+ "fixes": [],
2747
+ "ready": true
2748
+ }`;
2749
+ }
2750
+ function configOnlyUserMessage(input, snippets) {
2751
+ return `${input.extraContext ? `${input.extraContext.trim()}
2752
+
2753
+ ` : ""}Analyze these config excerpts and suggest local apps/ports/scripts. Do not change anything on disk.
2754
+
2755
+ ${snippets}`;
2756
+ }
2757
+ function toInspectResult(input, text, providerId) {
2758
+ const parsed = extractInspectJson(text);
2759
+ return {
2760
+ kind: parsed?.kind ?? "other",
2761
+ name: parsed?.name?.trim() || input.appName || "",
2762
+ summary: parsed?.summary?.trim() || text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
2763
+ scripts: parsed?.scripts ?? {},
2764
+ ports: parsed?.ports ?? {},
2765
+ issues: parsed?.issues ?? [],
2766
+ fixes: parsed?.fixes ?? [],
2767
+ ready: parsed?.ready ?? parsed?.issues?.length === 0,
2768
+ provider: providerId,
2769
+ rawText: text
2770
+ };
2771
+ }
2772
+ async function inspectConfigOnly(input, opts = {}) {
2773
+ const workspaceDir = import_node_path7.default.resolve(input.workspaceDir);
2774
+ const timeoutMs = opts.timeoutMs ?? CONFIG_INSPECT_TIMEOUT_MS;
2775
+ const provider = await resolveProvider({ preference: "auto" });
2776
+ const snippets = gatherConfigSnippets(workspaceDir);
2777
+ const call = callAi(
2778
+ [{ role: "user", content: configOnlyUserMessage(input, snippets) }],
2779
+ {
2780
+ route: "/local-setup-propose",
2781
+ pageTitle: input.appName || "Setup suggestions",
2782
+ relevantFiles: ["package.json", ".env.example"],
2783
+ data: {
2784
+ workspaceDir,
2785
+ readOnly: true
2786
+ }
2787
+ },
2788
+ {
2789
+ systemPrompt: configOnlySystemPrompt(input),
2790
+ workspaceDir,
2791
+ technical: true
2792
+ }
2793
+ );
2794
+ const timed = await Promise.race([
2795
+ call.then((response) => ({ ok: true, response })),
2796
+ new Promise(
2797
+ (resolve) => setTimeout(
2798
+ () => resolve({ ok: false, error: `Config inspect timed out after ${timeoutMs}ms` }),
2799
+ timeoutMs
2800
+ )
2801
+ )
2802
+ ]);
2803
+ if (!timed.ok) {
2804
+ throw new Error(timed.error);
2805
+ }
2806
+ return toInspectResult(
2807
+ input,
2808
+ timed.response.text,
2809
+ timed.response.provider || provider.id
2810
+ );
2811
+ }
1224
2812
  async function inspectAndRepairWorkspace(input) {
1225
2813
  const workspaceDir = input.workspaceDir;
1226
2814
  const provider = await resolveProvider({ preference: "auto" });
@@ -1241,28 +2829,679 @@ async function inspectAndRepairWorkspace(input) {
1241
2829
  technical: true
1242
2830
  }
1243
2831
  );
1244
- const parsed = extractInspectJson(response.text);
2832
+ return toInspectResult(
2833
+ input,
2834
+ response.text,
2835
+ response.provider || provider.id
2836
+ );
2837
+ }
2838
+
2839
+ // src/host-apps.ts
2840
+ var import_node_fs4 = __toESM(require("fs"), 1);
2841
+ var import_node_path8 = __toESM(require("path"), 1);
2842
+ var import_node_crypto4 = require("crypto");
2843
+ var COLLABORATER_DIR = ".collaborater";
2844
+ var AI_SERVER_APP_ID = "ai-server";
2845
+ var AI_SERVER_DEFAULT_PORT = 3100;
2846
+ var ENV_FILES = [".env", ".env.local", ".env.development", ".env.example"];
2847
+ function isPort(value) {
2848
+ return Number.isInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
2849
+ }
2850
+ function parsePort(value, fallback = 0) {
2851
+ if (typeof value === "number" && isPort(value)) return value;
2852
+ const text = String(value ?? "").trim();
2853
+ if (!text) return fallback;
2854
+ if (/^\d{2,5}$/.test(text)) {
2855
+ const n = Number(text);
2856
+ return isPort(n) ? n : fallback;
2857
+ }
2858
+ try {
2859
+ const port = Number(new URL(text).port);
2860
+ return isPort(port) ? port : fallback;
2861
+ } catch {
2862
+ const match = text.match(/--port(?:\s+|=)(\d{2,5})/i) || text.match(/\bPORT[=:]\s*(\d{2,5})/i) || text.match(/:(\d{2,5})\b/);
2863
+ const n = match ? Number(match[1]) : 0;
2864
+ return isPort(n) ? n : fallback;
2865
+ }
2866
+ }
2867
+ function dataInput(folder, extra) {
1245
2868
  return {
1246
- kind: parsed?.kind ?? "other",
1247
- name: parsed?.name?.trim() || input.appName || "",
1248
- summary: parsed?.summary?.trim() || response.text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
1249
- scripts: parsed?.scripts ?? {},
1250
- ports: parsed?.ports ?? {},
1251
- issues: parsed?.issues ?? [],
1252
- fixes: parsed?.fixes ?? [],
1253
- ready: parsed?.ready ?? parsed?.issues?.length === 0,
1254
- provider: response.provider || provider.id,
1255
- rawText: response.text
2869
+ workspaceDir: folder,
2870
+ sandboxId: extra?.sandboxId,
2871
+ dataDir: extra?.dataDir
2872
+ };
2873
+ }
2874
+ function hostAppsCachePath2(folder, extra) {
2875
+ return hostAppsCachePath(dataInput(folder, extra));
2876
+ }
2877
+ function defaultAiServerApp(port = AI_SERVER_DEFAULT_PORT) {
2878
+ return {
2879
+ id: AI_SERVER_APP_ID,
2880
+ name: "AI server",
2881
+ role: "ai-server",
2882
+ port: parsePort(port, AI_SERVER_DEFAULT_PORT),
2883
+ startCommand: null,
2884
+ source: "default",
2885
+ locked: true
2886
+ };
2887
+ }
2888
+ function normalizeHostApp(raw) {
2889
+ if (!raw || typeof raw !== "object") return null;
2890
+ const row = raw;
2891
+ const port = parsePort(row.port);
2892
+ if (!port) return null;
2893
+ const role = String(row.role || "custom");
2894
+ const allowed = ["ai-server", "ui", "backend", "app", "custom"];
2895
+ const id = String(row.id || "").trim() || (role === "ai-server" ? AI_SERVER_APP_ID : "");
2896
+ if (!id) return null;
2897
+ return {
2898
+ id: id.slice(0, 64),
2899
+ name: String(row.name || id).trim().slice(0, 80) || id,
2900
+ role: allowed.includes(role) ? role : "custom",
2901
+ port,
2902
+ startCommand: typeof row.startCommand === "string" && row.startCommand.trim() ? row.startCommand.trim().slice(0, 200) : null,
2903
+ source: ["default", "env", "package", "ai", "manual"].includes(
2904
+ row.source
2905
+ ) ? row.source : "manual",
2906
+ locked: row.locked === true || id === AI_SERVER_APP_ID || role === "ai-server",
2907
+ host: role !== "ai-server" && id !== AI_SERVER_APP_ID && row.host === true,
2908
+ envMaps: normalizeEnvMaps(row.envMaps)
2909
+ };
2910
+ }
2911
+ function normalizeEnvMaps(raw) {
2912
+ if (!Array.isArray(raw)) return [];
2913
+ const seen = /* @__PURE__ */ new Set();
2914
+ const out = [];
2915
+ for (const item of raw) {
2916
+ if (!item || typeof item !== "object") continue;
2917
+ const row = item;
2918
+ const key = String(row.key || "").trim();
2919
+ const sourceAppId = String(row.sourceAppId || "").trim().slice(0, 64);
2920
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.length > 80) continue;
2921
+ if (!sourceAppId || seen.has(key)) continue;
2922
+ seen.add(key);
2923
+ out.push({ key, sourceAppId });
2924
+ }
2925
+ return out.slice(0, 30);
2926
+ }
2927
+ function ensureSingleHost(apps) {
2928
+ const next = apps.map((app) => ({
2929
+ ...app,
2930
+ host: app.role !== "ai-server" && app.id !== AI_SERVER_APP_ID && app.host === true
2931
+ }));
2932
+ const marked = next.filter((app) => app.host);
2933
+ if (marked.length === 1) return next;
2934
+ if (marked.length > 1) {
2935
+ let kept = false;
2936
+ return next.map((app) => {
2937
+ if (!app.host) return app;
2938
+ if (kept) return { ...app, host: false };
2939
+ kept = true;
2940
+ return app;
2941
+ });
2942
+ }
2943
+ const fallback = next.find((app) => app.role === "ui" || app.role === "app") || next.find((app) => app.role !== "ai-server");
2944
+ return next.map(
2945
+ (app) => fallback && app.id === fallback.id ? { ...app, host: true } : app
2946
+ );
2947
+ }
2948
+ function normalizeHostApps(raw) {
2949
+ if (!Array.isArray(raw)) return [];
2950
+ const seen = /* @__PURE__ */ new Set();
2951
+ const apps = [];
2952
+ for (const item of raw) {
2953
+ const app = normalizeHostApp(item);
2954
+ if (!app || seen.has(app.id)) continue;
2955
+ seen.add(app.id);
2956
+ apps.push(app);
2957
+ }
2958
+ return apps;
2959
+ }
2960
+ function ensureAiServerApp(apps, preferredPort = AI_SERVER_DEFAULT_PORT) {
2961
+ const next = normalizeHostApps(apps);
2962
+ const existing = next.find((app) => app.id === AI_SERVER_APP_ID || app.role === "ai-server");
2963
+ if (existing) {
2964
+ existing.id = AI_SERVER_APP_ID;
2965
+ existing.role = "ai-server";
2966
+ existing.locked = true;
2967
+ existing.host = false;
2968
+ existing.name = existing.name || "AI server";
2969
+ return ensureSingleHost([existing, ...next.filter((app) => app !== existing)]);
2970
+ }
2971
+ return ensureSingleHost([defaultAiServerApp(preferredPort), ...next]);
2972
+ }
2973
+ function readText(file) {
2974
+ try {
2975
+ return import_node_fs4.default.readFileSync(file, "utf8");
2976
+ } catch {
2977
+ return null;
2978
+ }
2979
+ }
2980
+ function readEnvFileMap(file) {
2981
+ const text = readText(file);
2982
+ if (!text) return {};
2983
+ const map = {};
2984
+ for (const raw of text.split(/\r?\n/)) {
2985
+ const line = raw.trim();
2986
+ if (!line || line.startsWith("#")) continue;
2987
+ const eq = line.indexOf("=");
2988
+ if (eq < 1) continue;
2989
+ const key = line.slice(0, eq).trim();
2990
+ let value = line.slice(eq + 1).trim();
2991
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2992
+ value = value.slice(1, -1);
2993
+ }
2994
+ if (key) map[key] = value;
2995
+ }
2996
+ return map;
2997
+ }
2998
+ function readProjectEnvLayers(folder) {
2999
+ const resolved = import_node_path8.default.resolve(folder);
3000
+ const layers = [];
3001
+ const merged = {};
3002
+ for (const name of ENV_FILES) {
3003
+ const file = import_node_path8.default.join(resolved, name);
3004
+ const values = readEnvFileMap(file);
3005
+ if (!Object.keys(values).length) continue;
3006
+ layers.push({ file: name, values });
3007
+ if (name === ".env.example") continue;
3008
+ Object.assign(merged, values);
3009
+ }
3010
+ return { merged, layers };
3011
+ }
3012
+ function readPackageJson(folder) {
3013
+ try {
3014
+ return JSON.parse(import_node_fs4.default.readFileSync(import_node_path8.default.join(folder, "package.json"), "utf8"));
3015
+ } catch {
3016
+ return null;
3017
+ }
3018
+ }
3019
+ function isChatScript(name, command) {
3020
+ return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(`${name} ${command}`);
3021
+ }
3022
+ function isUiCommand(command) {
3023
+ return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
3024
+ command
3025
+ );
3026
+ }
3027
+ function configPort(folder, files) {
3028
+ for (const rel of files) {
3029
+ const text = readText(import_node_path8.default.join(folder, rel));
3030
+ if (!text) continue;
3031
+ const match = text.match(/\bport\s*[:=]\s*(\d{2,5})/i) || text.match(/--port(?:\s+|=)(\d{2,5})/i);
3032
+ const port = parsePort(match?.[1]);
3033
+ if (port) return port;
3034
+ }
3035
+ return 0;
3036
+ }
3037
+ function hostAppsFingerprint(folder) {
3038
+ const resolved = import_node_path8.default.resolve(folder);
3039
+ const parts = [];
3040
+ for (const rel of [
3041
+ "package.json",
3042
+ ...ENV_FILES,
3043
+ "vite.config.ts",
3044
+ "vite.config.js",
3045
+ "vite.config.mjs",
3046
+ "next.config.js",
3047
+ "next.config.mjs",
3048
+ "next.config.ts"
3049
+ ]) {
3050
+ const file = import_node_path8.default.join(resolved, rel);
3051
+ if (!import_node_fs4.default.existsSync(file)) continue;
3052
+ try {
3053
+ const st = import_node_fs4.default.statSync(file);
3054
+ parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
3055
+ } catch {
3056
+ }
3057
+ }
3058
+ return (0, import_node_crypto4.createHash)("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
3059
+ }
3060
+ function pickScript(scripts, names) {
3061
+ return names.find((name) => scripts[name] && !isChatScript(name, scripts[name])) || null;
3062
+ }
3063
+ function detectHostAppsFromFiles(folder, opts = {}) {
3064
+ const resolved = import_node_path8.default.resolve(folder);
3065
+ const reasons = [];
3066
+ const { merged, layers } = readProjectEnvLayers(resolved);
3067
+ const pkg = readPackageJson(resolved);
3068
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
3069
+ const apps = [];
3070
+ const envAi = parsePort(merged.AI_SERVER_PORT) || parsePort(merged.AI_SERVER_URL);
3071
+ const aiPort = envAi || parsePort(opts.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3072
+ apps.push({
3073
+ ...defaultAiServerApp(aiPort),
3074
+ source: envAi ? "env" : "default"
3075
+ });
3076
+ const envPorts = {
3077
+ PORT: layers.filter((layer) => layer.file !== ".env.example").map((layer) => ({ file: layer.file, port: parsePort(layer.values.PORT) })).filter((row) => row.port),
3078
+ APP_URL: parsePort(merged.APP_URL || merged.NEXT_PUBLIC_APP_URL || merged.PUBLIC_URL),
3079
+ API_PORT: parsePort(merged.API_PORT || merged.API_URL || merged.VITE_API_URL)
3080
+ };
3081
+ const portValues = [...new Set(envPorts.PORT.map((row) => row.port))];
3082
+ if (portValues.length > 1) {
3083
+ reasons.push(
3084
+ `PORT differs across env files: ${envPorts.PORT.map((row) => `${row.file}=${row.port}`).join(", ")}`
3085
+ );
3086
+ }
3087
+ if (envPorts.PORT[0] && envPorts.APP_URL && envPorts.PORT[0].port !== envPorts.APP_URL) {
3088
+ reasons.push(
3089
+ `PORT=${envPorts.PORT[0].port} does not match APP_URL port ${envPorts.APP_URL}`
3090
+ );
3091
+ }
3092
+ const uiScript = pickScript(scripts, [
3093
+ "dev:client",
3094
+ "dev:ui",
3095
+ "dev:web",
3096
+ "dev:frontend",
3097
+ "client",
3098
+ "start:client"
3099
+ ]) || (scripts.dev && !isChatScript("dev", scripts.dev) ? "dev" : null);
3100
+ const apiScript = pickScript(scripts, [
3101
+ "dev:server",
3102
+ "dev:backend",
3103
+ "dev:api",
3104
+ "server",
3105
+ "start:server"
3106
+ ]);
3107
+ const vitePort = configPort(resolved, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) || 5173;
3108
+ const nextPort = configPort(resolved, ["next.config.js", "next.config.mjs", "next.config.ts"]) || 3e3;
3109
+ const portFromEnv = parsePort(merged.PORT);
3110
+ const hostFromEnv = parsePort(merged.HOST_PORT) || parsePort(merged.HOST_URL) || parsePort(merged.CORS_ORIGIN);
3111
+ const uiFromEnv = hostFromEnv || envPorts.APP_URL || (portFromEnv && portFromEnv !== aiPort ? portFromEnv : 0);
3112
+ if (uiScript) {
3113
+ const command = scripts[uiScript] || "";
3114
+ const scriptPort = parsePort(command);
3115
+ const fallback = isUiCommand(command) && /vite/i.test(command) ? vitePort : nextPort;
3116
+ const port = uiFromEnv || scriptPort || fallback;
3117
+ apps.push({
3118
+ id: "ui",
3119
+ name: opts.appName || pkg?.name || "App",
3120
+ role: isUiCommand(command) ? "ui" : "app",
3121
+ port,
3122
+ startCommand: `npm run ${uiScript}`,
3123
+ source: uiFromEnv ? "env" : scriptPort ? "package" : "package",
3124
+ host: true
3125
+ });
3126
+ } else if (uiFromEnv) {
3127
+ apps.push({
3128
+ id: "ui",
3129
+ name: opts.appName || pkg?.name || "App",
3130
+ role: "ui",
3131
+ port: uiFromEnv,
3132
+ startCommand: scripts.dev ? "npm run dev" : null,
3133
+ source: "env",
3134
+ host: true
3135
+ });
3136
+ }
3137
+ if (apiScript && apiScript !== uiScript) {
3138
+ const command = scripts[apiScript] || "";
3139
+ const port = envPorts.API_PORT || parsePort(command) || configPort(resolved, []) || 4100;
3140
+ apps.push({
3141
+ id: "backend",
3142
+ name: "Backend",
3143
+ role: "backend",
3144
+ port,
3145
+ startCommand: `npm run ${apiScript}`,
3146
+ source: envPorts.API_PORT ? "env" : "package"
3147
+ });
3148
+ }
3149
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3150
+ if (pkg && Object.keys(scripts).length && hostApps.length === 0) {
3151
+ reasons.push("Found package.json scripts but no host app port in env or config");
3152
+ }
3153
+ return {
3154
+ apps: ensureAiServerApp(apps, aiPort),
3155
+ reasons,
3156
+ confused: reasons.length > 0
3157
+ };
3158
+ }
3159
+ function readHostAppsCache(folder, extra) {
3160
+ try {
3161
+ const raw = JSON.parse(
3162
+ import_node_fs4.default.readFileSync(hostAppsCachePath2(folder, extra), "utf8")
3163
+ );
3164
+ const apps = ensureAiServerApp(normalizeHostApps(raw.apps));
3165
+ if (!apps.length) return null;
3166
+ return {
3167
+ apps,
3168
+ fingerprint: typeof raw.fingerprint === "string" ? raw.fingerprint : void 0,
3169
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : void 0
3170
+ };
3171
+ } catch {
3172
+ return null;
3173
+ }
3174
+ }
3175
+ var readCollaboraterApps = readHostAppsCache;
3176
+ function writeHostAppsCache(folder, apps, extra = {}, loc) {
3177
+ const resolved = import_node_path8.default.resolve(folder);
3178
+ ensureProjectDataDir(dataInput(resolved, loc));
3179
+ const payload = {
3180
+ version: 1,
3181
+ fingerprint: hostAppsFingerprint(resolved),
3182
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3183
+ apps: ensureAiServerApp(apps),
3184
+ ...extra
3185
+ };
3186
+ import_node_fs4.default.writeFileSync(
3187
+ hostAppsCachePath2(resolved, loc),
3188
+ `${JSON.stringify(payload, null, 2)}
3189
+ `,
3190
+ "utf8"
3191
+ );
3192
+ }
3193
+ var writeCollaboraterApps = writeHostAppsCache;
3194
+ function mergeDesiredHostApps(desired, detected) {
3195
+ const byId = new Map(detected.map((app) => [app.id, app]));
3196
+ const byRole = new Map(detected.map((app) => [app.role, app]));
3197
+ const merged = desired.map((app) => {
3198
+ const local = byId.get(app.id) || (app.role !== "custom" ? byRole.get(app.role) : void 0);
3199
+ return {
3200
+ ...local,
3201
+ ...app,
3202
+ startCommand: app.startCommand || local?.startCommand || null,
3203
+ locked: app.locked || app.id === AI_SERVER_APP_ID || app.role === "ai-server",
3204
+ host: app.role === "ai-server" || app.id === AI_SERVER_APP_ID ? false : app.host === true || app.host !== false && local?.host === true,
3205
+ envMaps: app.envMaps && app.envMaps.length ? app.envMaps : local?.envMaps || []
3206
+ };
3207
+ });
3208
+ return ensureAiServerApp(merged);
3209
+ }
3210
+ function appsFromInspectPorts(ports, scripts, name) {
3211
+ const apps = [];
3212
+ if (ports.ui || scripts.ui) {
3213
+ apps.push({
3214
+ id: "ui",
3215
+ name,
3216
+ role: "ui",
3217
+ port: parsePort(ports.ui, 5173),
3218
+ startCommand: scripts.ui ? `npm run ${scripts.ui}` : null,
3219
+ source: "ai"
3220
+ });
3221
+ }
3222
+ if (ports.backend || scripts.backend) {
3223
+ apps.push({
3224
+ id: "backend",
3225
+ name: "Backend",
3226
+ role: "backend",
3227
+ port: parsePort(ports.backend, 4100),
3228
+ startCommand: scripts.backend ? `npm run ${scripts.backend}` : null,
3229
+ source: "ai"
3230
+ });
3231
+ }
3232
+ if (!apps.length && (ports.app || scripts.app)) {
3233
+ apps.push({
3234
+ id: "app",
3235
+ name,
3236
+ role: "app",
3237
+ port: parsePort(ports.app, 3e3),
3238
+ startCommand: scripts.app ? `npm run ${scripts.app}` : null,
3239
+ source: "ai"
3240
+ });
3241
+ }
3242
+ return apps;
3243
+ }
3244
+ function collectScriptAlternatives(folder, primary, opts = {}) {
3245
+ const pkg = readPackageJson(folder);
3246
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
3247
+ const usedCommands = new Set(
3248
+ primary.map((app) => app.startCommand?.trim()).filter((cmd) => Boolean(cmd))
3249
+ );
3250
+ const alternatives = [];
3251
+ const hostApp = primary.find((app) => app.role === "ui" || app.role === "app") || primary.find((app) => app.role !== "ai-server");
3252
+ const backendApp = primary.find((app) => app.role === "backend");
3253
+ for (const [name, command] of Object.entries(scripts)) {
3254
+ if (isChatScript(name, command)) continue;
3255
+ if (!/^(dev|start|serve)/i.test(name) && !isUiCommand(command)) continue;
3256
+ const startCommand = `npm run ${name}`;
3257
+ if (usedCommands.has(startCommand)) continue;
3258
+ const scriptPort = parsePort(command);
3259
+ const looksBackend = /\b(server|api|backend)\b/i.test(`${name} ${command}`) && !isUiCommand(command);
3260
+ const target = looksBackend ? backendApp || hostApp : hostApp;
3261
+ if (!target) continue;
3262
+ const fallbackPort = scriptPort || (looksBackend ? 4100 : isUiCommand(command) && /vite/i.test(command) ? 5173 : 3e3);
3263
+ alternatives.push({
3264
+ appId: target.id,
3265
+ port: fallbackPort,
3266
+ startCommand,
3267
+ label: `${name} \u2192 ${fallbackPort}`,
3268
+ source: "package"
3269
+ });
3270
+ if (alternatives.length >= 8) break;
3271
+ }
3272
+ const { layers } = readProjectEnvLayers(folder);
3273
+ if (hostApp) {
3274
+ for (const layer of layers) {
3275
+ if (layer.file === ".env.example") continue;
3276
+ const port = parsePort(layer.values.PORT);
3277
+ if (!port || port === hostApp.port) continue;
3278
+ if (alternatives.some(
3279
+ (alt) => alt.appId === hostApp.id && alt.port === port && !alt.startCommand
3280
+ )) {
3281
+ continue;
3282
+ }
3283
+ alternatives.push({
3284
+ appId: hostApp.id,
3285
+ port,
3286
+ startCommand: hostApp.startCommand || null,
3287
+ label: `${layer.file} PORT=${port}`,
3288
+ source: "env"
3289
+ });
3290
+ }
3291
+ }
3292
+ void opts;
3293
+ return alternatives;
3294
+ }
3295
+ function proposalConfidence(apps, reasons, usedAi) {
3296
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3297
+ if (!hostApps.length) return "low";
3298
+ if (reasons.length === 0 && hostApps.every((app) => app.startCommand)) {
3299
+ return usedAi ? "medium" : "high";
3300
+ }
3301
+ if (reasons.some((reason) => /differ|does not match|no host app/i.test(reason))) {
3302
+ return "low";
3303
+ }
3304
+ return "medium";
3305
+ }
3306
+ function projectSummaryFromDetect(folder, apps, opts = {}) {
3307
+ const pkg = readPackageJson(folder);
3308
+ const name = opts.appName || pkg?.name || import_node_path8.default.basename(import_node_path8.default.resolve(folder));
3309
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3310
+ if (!hostApps.length) {
3311
+ return `${name}: no UI/backend ports detected from config yet.`;
3312
+ }
3313
+ return `${name}: ${hostApps.map((app) => `${app.name} on ${app.port}${app.startCommand ? ` (${app.startCommand})` : ""}`).join(", ")}.`;
3314
+ }
3315
+ async function proposeHostAppsFromConfig(input) {
3316
+ const folder = import_node_path8.default.resolve(input.workspaceDir);
3317
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3318
+ const fingerprint = hostAppsFingerprint(folder);
3319
+ const allowAi = input.allowAi !== false;
3320
+ const detected = detectHostAppsFromFiles(folder, {
3321
+ preferredAiPort: preferredAi,
3322
+ appName: input.appName
3323
+ });
3324
+ let apps = ensureAiServerApp(detected.apps, preferredAi);
3325
+ let reasons = [...detected.reasons];
3326
+ let usedAi = false;
3327
+ let projectSummary = projectSummaryFromDetect(folder, apps, {
3328
+ appName: input.appName
3329
+ });
3330
+ const hostCount = apps.filter((app) => app.role !== "ai-server").length;
3331
+ const needsAi = allowAi && (detected.confused || hostCount === 0 || reasons.some((reason) => /differ|does not match/i.test(reason)));
3332
+ if (needsAi) {
3333
+ try {
3334
+ const inspected = await inspectConfigOnly({
3335
+ workspaceDir: folder,
3336
+ appName: input.appName,
3337
+ extraContext: [
3338
+ "File-based detection was uncertain or incomplete.",
3339
+ ...reasons
3340
+ ].join("\n")
3341
+ });
3342
+ usedAi = true;
3343
+ const fromAi = appsFromInspectPorts(
3344
+ inspected.ports,
3345
+ inspected.scripts,
3346
+ inspected.name || input.appName || "App"
3347
+ );
3348
+ if (fromAi.length) {
3349
+ apps = ensureAiServerApp(
3350
+ [defaultAiServerApp(preferredAi), ...fromAi],
3351
+ preferredAi
3352
+ );
3353
+ }
3354
+ if (inspected.summary) projectSummary = inspected.summary;
3355
+ if (inspected.issues?.length) {
3356
+ reasons = [...reasons, ...inspected.issues];
3357
+ }
3358
+ } catch (err) {
3359
+ reasons.push(
3360
+ `AI suggest skipped: ${err instanceof Error ? err.message : String(err)}`
3361
+ );
3362
+ }
3363
+ }
3364
+ const alternatives = collectScriptAlternatives(folder, apps, {
3365
+ appName: input.appName
3366
+ });
3367
+ const confidence = proposalConfidence(apps, reasons, usedAi);
3368
+ const needsReview = confidence !== "high" || detected.confused || apps.filter((app) => app.role !== "ai-server").length === 0;
3369
+ return {
3370
+ apps,
3371
+ alternatives,
3372
+ reasons,
3373
+ confidence,
3374
+ projectSummary,
3375
+ usedAi,
3376
+ needsReview,
3377
+ fingerprint
3378
+ };
3379
+ }
3380
+ async function resolveHostApps(input) {
3381
+ const folder = import_node_path8.default.resolve(input.workspaceDir);
3382
+ const loc = { sandboxId: input.sandboxId, dataDir: input.dataDir };
3383
+ const fingerprint = hostAppsFingerprint(folder);
3384
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3385
+ const detected = detectHostAppsFromFiles(folder, {
3386
+ preferredAiPort: preferredAi,
3387
+ appName: input.appName
3388
+ });
3389
+ const desired = normalizeHostApps(input.desired);
3390
+ if (desired.length) {
3391
+ const apps2 = mergeDesiredHostApps(desired, detected.apps);
3392
+ try {
3393
+ writeHostAppsCache(folder, apps2, { source: "desired" }, loc);
3394
+ } catch {
3395
+ }
3396
+ return {
3397
+ apps: apps2,
3398
+ source: "desired",
3399
+ cached: false,
3400
+ usedAi: false,
3401
+ confused: false,
3402
+ reasons: [],
3403
+ fingerprint
3404
+ };
3405
+ }
3406
+ if (!input.force) {
3407
+ const cached = readHostAppsCache(folder, loc);
3408
+ if (cached && cached.fingerprint === fingerprint) {
3409
+ return {
3410
+ apps: ensureAiServerApp(cached.apps, preferredAi),
3411
+ source: "cache",
3412
+ cached: true,
3413
+ usedAi: false,
3414
+ confused: false,
3415
+ reasons: [],
3416
+ fingerprint
3417
+ };
3418
+ }
3419
+ }
3420
+ 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)));
3421
+ if (needsAi) {
3422
+ try {
3423
+ const inspected = await inspectConfigOnly({
3424
+ workspaceDir: folder,
3425
+ appName: input.appName,
3426
+ extraContext: `Port detection was uncertain:
3427
+ ${detected.reasons.join("\n")}
3428
+ Return the real local ports and npm script names.`
3429
+ });
3430
+ const fromAi = appsFromInspectPorts(
3431
+ inspected.ports,
3432
+ inspected.scripts,
3433
+ inspected.name || input.appName || "App"
3434
+ );
3435
+ const apps2 = ensureAiServerApp(
3436
+ [defaultAiServerApp(preferredAi), ...fromAi],
3437
+ preferredAi
3438
+ );
3439
+ writeHostAppsCache(
3440
+ folder,
3441
+ apps2,
3442
+ {
3443
+ source: "ai",
3444
+ reasons: detected.reasons,
3445
+ summary: inspected.summary
3446
+ },
3447
+ loc
3448
+ );
3449
+ return {
3450
+ apps: apps2,
3451
+ source: "ai",
3452
+ cached: false,
3453
+ usedAi: true,
3454
+ confused: detected.confused,
3455
+ reasons: detected.reasons,
3456
+ fingerprint
3457
+ };
3458
+ } catch (err) {
3459
+ detected.reasons.push(
3460
+ `AI inspect skipped: ${err instanceof Error ? err.message : String(err)}`
3461
+ );
3462
+ }
3463
+ }
3464
+ const apps = ensureAiServerApp(detected.apps, preferredAi);
3465
+ try {
3466
+ writeHostAppsCache(
3467
+ folder,
3468
+ apps,
3469
+ {
3470
+ source: detected.confused ? "env" : "env",
3471
+ reasons: detected.reasons
3472
+ },
3473
+ loc
3474
+ );
3475
+ } catch {
3476
+ }
3477
+ return {
3478
+ apps,
3479
+ source: apps.some((app) => app.source === "env") ? "env" : "default",
3480
+ cached: false,
3481
+ usedAi: false,
3482
+ confused: detected.confused,
3483
+ reasons: detected.reasons,
3484
+ fingerprint
1256
3485
  };
1257
3486
  }
1258
3487
  // Annotate the CommonJS export names for ESM import in node:
1259
3488
  0 && (module.exports = {
3489
+ ACCESS_IGNORE_BEGIN,
3490
+ ACCESS_IGNORE_END,
3491
+ AI_SERVER_APP_ID,
3492
+ AI_SERVER_DEFAULT_PORT,
3493
+ COLLABORATER_DIR,
3494
+ DEFAULT_AI_IGNORE_PATHS,
3495
+ HOST_APPS_FILE,
3496
+ MAINTAINER_PRO_HOME_DIR,
3497
+ PROMPT_SECTION,
1260
3498
  WORKING_PROVIDER,
1261
3499
  buildClaudeUserPrompt,
1262
3500
  buildConversationPrompt,
1263
3501
  buildCursorPrompt,
1264
3502
  buildPriorConversationsContext,
1265
3503
  callAi,
3504
+ collectParentChain,
1266
3505
  commandExists,
1267
3506
  createAntigravityProvider,
1268
3507
  createBuiltinProviders,
@@ -1270,17 +3509,60 @@ async function inspectAndRepairWorkspace(input) {
1270
3509
  createClaudeProvider,
1271
3510
  createCursorProvider,
1272
3511
  createDefaultSystemPrompt,
3512
+ createInfoLogger,
1273
3513
  createLocalDirectoryStore,
3514
+ createLogger,
1274
3515
  createMaintainerProStore,
1275
3516
  createMaintainerProStoreFromEnv,
3517
+ createSyncedChatStore,
1276
3518
  createToolValidator,
3519
+ defaultAiServerApp,
3520
+ detectHostAppsFromFiles,
3521
+ ensureAiServerApp,
3522
+ ensureProjectDataDir,
3523
+ ensureSingleHost,
3524
+ formatAccessPolicyPromptSection,
1277
3525
  formatClientContext,
3526
+ formatParentChainContext,
1278
3527
  getProviderPreference,
3528
+ hostAppsCachePath,
3529
+ hostAppsFingerprint,
1279
3530
  inspectAndRepairWorkspace,
3531
+ inspectConfigOnly,
3532
+ isDevMode,
3533
+ isIgnoredRelative,
3534
+ isInsideWorkspace,
3535
+ isPathAllowed,
3536
+ maintainerProHome,
3537
+ mergeDesiredHostApps,
3538
+ normalizeEnvMaps,
3539
+ normalizeHostApp,
3540
+ normalizeHostApps,
3541
+ normalizeIgnorePaths,
3542
+ parentChainForTurn,
1280
3543
  parseAiResponse,
3544
+ parseIgnorePathsEnv,
3545
+ parsePort,
3546
+ previewText,
3547
+ projectHostAppsPath,
3548
+ projectIdForFolder,
3549
+ projectUploadsDir,
3550
+ proposeHostAppsFromConfig,
1281
3551
  providerLabel,
3552
+ readCollaboraterApps,
3553
+ readHostAppsCache,
3554
+ readProjectEnvLayers,
3555
+ renderManagedIgnoreBlock,
1282
3556
  resolveCliBinary,
3557
+ resolveHostApps,
3558
+ resolveIgnorePaths,
3559
+ resolveLogLevel,
3560
+ resolveProjectDataDir,
1283
3561
  resolveProvider,
3562
+ sanitizeProjectId,
1284
3563
  saveChatAttachments,
1285
- toNextRoute
3564
+ toNextRoute,
3565
+ upsertManagedIgnoreFile,
3566
+ writeCollaboraterApps,
3567
+ writeHostAppsCache
1286
3568
  });