@dmsdc-ai/aigentry-deliberation 0.0.29 → 0.0.31

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/index.js CHANGED
@@ -74,6 +74,7 @@ import { fileURLToPath } from "url";
74
74
  import os from "os";
75
75
  import { OrchestratedBrowserPort } from "./browser-control-port.js";
76
76
  import { getModelSelectionForTurn } from "./model-router.js";
77
+ import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
77
78
  import {
78
79
  DECISION_STAGES, STAGE_TRANSITIONS,
79
80
  createDecisionSession, advanceStage, buildConflictMap,
@@ -383,6 +384,18 @@ function formatRuntimeError(error) {
383
384
  function appendRuntimeLog(level, message) {
384
385
  try {
385
386
  fs.mkdirSync(path.dirname(GLOBAL_RUNTIME_LOG), { recursive: true });
387
+
388
+ // Simple rotation: if log > 1MB, truncate it
389
+ try {
390
+ if (fs.existsSync(GLOBAL_RUNTIME_LOG)) {
391
+ const stats = fs.statSync(GLOBAL_RUNTIME_LOG);
392
+ if (stats.size > 1024 * 1024) { // 1MB
393
+ const oldLog = GLOBAL_RUNTIME_LOG + ".old";
394
+ fs.renameSync(GLOBAL_RUNTIME_LOG, oldLog);
395
+ }
396
+ }
397
+ } catch { /* ignore rotation failures */ }
398
+
386
399
  const line = `${new Date().toISOString()} [${level}] ${message}\n`;
387
400
  fs.appendFileSync(GLOBAL_RUNTIME_LOG, line, "utf-8");
388
401
  } catch {
@@ -624,80 +637,6 @@ function detectCallerSpeaker() {
624
637
  return null;
625
638
  }
626
639
 
627
- function resolveClipboardReader() {
628
- if (process.platform === "darwin" && commandExistsInPath("pbpaste")) {
629
- return { cmd: "pbpaste", args: [] };
630
- }
631
- if (process.platform === "win32") {
632
- const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
633
- .find(cmd => commandExistsInPath(cmd));
634
- if (windowsShell) {
635
- return { cmd: windowsShell, args: ["-NoProfile", "-Command", "Get-Clipboard -Raw"] };
636
- }
637
- }
638
- if (commandExistsInPath("wl-paste")) {
639
- return { cmd: "wl-paste", args: ["-n"] };
640
- }
641
- if (commandExistsInPath("xclip")) {
642
- return { cmd: "xclip", args: ["-selection", "clipboard", "-o"] };
643
- }
644
- if (commandExistsInPath("xsel")) {
645
- return { cmd: "xsel", args: ["--clipboard", "--output"] };
646
- }
647
- return null;
648
- }
649
-
650
- function resolveClipboardWriter() {
651
- if (process.platform === "darwin" && commandExistsInPath("pbcopy")) {
652
- return { cmd: "pbcopy", args: [] };
653
- }
654
- if (process.platform === "win32") {
655
- if (commandExistsInPath("clip.exe") || commandExistsInPath("clip")) {
656
- return { cmd: "clip", args: [] };
657
- }
658
- const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
659
- .find(cmd => commandExistsInPath(cmd));
660
- if (windowsShell) {
661
- return { cmd: windowsShell, args: ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"] };
662
- }
663
- }
664
- if (commandExistsInPath("wl-copy")) {
665
- return { cmd: "wl-copy", args: [] };
666
- }
667
- if (commandExistsInPath("xclip")) {
668
- return { cmd: "xclip", args: ["-selection", "clipboard"] };
669
- }
670
- if (commandExistsInPath("xsel")) {
671
- return { cmd: "xsel", args: ["--clipboard", "--input"] };
672
- }
673
- return null;
674
- }
675
-
676
- function readClipboardText() {
677
- const tool = resolveClipboardReader();
678
- if (!tool) {
679
- throw new Error("No supported clipboard read command found (pbpaste/wl-paste/xclip/xsel etc).");
680
- }
681
- return execFileSync(tool.cmd, tool.args, {
682
- encoding: "utf-8",
683
- stdio: ["ignore", "pipe", "pipe"],
684
- maxBuffer: 5 * 1024 * 1024,
685
- });
686
- }
687
-
688
- function writeClipboardText(text) {
689
- const tool = resolveClipboardWriter();
690
- if (!tool) {
691
- throw new Error("No supported clipboard write command found (pbcopy/wl-copy/xclip/xsel etc).");
692
- }
693
- execFileSync(tool.cmd, tool.args, {
694
- input: text,
695
- encoding: "utf-8",
696
- stdio: ["pipe", "ignore", "pipe"],
697
- maxBuffer: 5 * 1024 * 1024,
698
- });
699
- }
700
-
701
640
  function isLlmUrl(url = "") {
702
641
  const value = String(url || "").trim();
703
642
  if (!value) return false;
@@ -719,7 +658,10 @@ function dedupeBrowserTabs(tabs = []) {
719
658
  const title = String(tab?.title || "").trim();
720
659
  const url = String(tab?.url || "").trim();
721
660
  if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
722
- const key = `${browser}\t${title}\t${url}`;
661
+ // Dedup by title+url (ignore browser name) so that the same tab detected
662
+ // via both AppleScript and CDP is not duplicated. The first occurrence wins,
663
+ // so callers should add preferred sources first (e.g., CDP before AppleScript).
664
+ const key = `${title}\t${url}`;
723
665
  if (seen.has(key)) continue;
724
666
  seen.add(key);
725
667
  out.push({
@@ -1145,6 +1087,16 @@ async function collectBrowserLlmTabs() {
1145
1087
  };
1146
1088
  }
1147
1089
 
1090
+ // CDP first: CDP-detected tabs are preferred over AppleScript-detected ones
1091
+ // because they carry CDP metadata (tab ID, WebSocket URL) for browser_auto transport.
1092
+ // Since dedupeBrowserTabs keeps the first occurrence, CDP entries win the dedup.
1093
+ const shouldUseCdp = mode === "auto" || mode === "cdp";
1094
+ if (shouldUseCdp) {
1095
+ const cdp = await collectBrowserLlmTabsViaCdp();
1096
+ tabs.push(...cdp.tabs);
1097
+ if (cdp.note) notes.push(cdp.note);
1098
+ }
1099
+
1148
1100
  const shouldUseAppleScript = mode === "auto" || mode === "applescript";
1149
1101
  if (shouldUseAppleScript && process.platform === "darwin") {
1150
1102
  const mac = collectBrowserLlmTabsViaAppleScript();
@@ -1154,13 +1106,6 @@ async function collectBrowserLlmTabs() {
1154
1106
  notes.push("AppleScript scanning is macOS only. Switch to CDP scanning.");
1155
1107
  }
1156
1108
 
1157
- const shouldUseCdp = mode === "auto" || mode === "cdp";
1158
- if (shouldUseCdp) {
1159
- const cdp = await collectBrowserLlmTabsViaCdp();
1160
- tabs.push(...cdp.tabs);
1161
- if (cdp.note) notes.push(cdp.note);
1162
- }
1163
-
1164
1109
  const uniqTabs = dedupeBrowserTabs(tabs);
1165
1110
  return {
1166
1111
  tabs: uniqTabs,
@@ -1345,6 +1290,17 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1345
1290
  }
1346
1291
  }
1347
1292
  }
1293
+
1294
+ // Third pass: upgrade browser-detected candidates that missed the first hostname match.
1295
+ // When CDP is reachable, AppleScript-detected speakers should also get browser_auto
1296
+ // transport. The OrchestratedBrowserPort will create/navigate tabs on demand if needed.
1297
+ if (cdpReachable) {
1298
+ for (const candidate of candidates) {
1299
+ if (candidate.type !== "browser" || candidate.auto_registered) continue;
1300
+ if (candidate.cdp_available) continue; // already matched
1301
+ candidate.cdp_available = true;
1302
+ }
1303
+ }
1348
1304
  }
1349
1305
 
1350
1306
  return { candidates, browserNote };
@@ -1406,10 +1362,16 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1406
1362
  // Check for explicit type override
1407
1363
  const overrideType = overrides[speaker] || overrides[raw];
1408
1364
  if (overrideType) {
1365
+ const candidate = bySpeaker.get(speaker);
1409
1366
  profiles.push({
1410
1367
  speaker,
1411
1368
  type: overrideType,
1412
- ...(overrideType === "browser_auto" ? { provider: "chatgpt" } : {}),
1369
+ ...(overrideType === "browser_auto" || overrideType === "browser" ? {
1370
+ provider: candidate?.provider || null,
1371
+ browser: candidate?.browser || null,
1372
+ title: candidate?.title || null,
1373
+ url: candidate?.url || null,
1374
+ } : {}),
1413
1375
  });
1414
1376
  continue;
1415
1377
  }
@@ -1491,7 +1453,7 @@ function resolveTransportForSpeaker(state, speaker) {
1491
1453
  // CLI-specific invocation flags for non-interactive execution
1492
1454
  const CLI_INVOCATION_HINTS = {
1493
1455
  claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
1494
- codex: { cmd: "codex", flags: 'exec --model gpt-5.4-codex', example: 'codex exec --model gpt-5.4-codex "prompt"', modelFlag: '--model', defaultModel: 'gpt-5.4-codex', provider: 'chatgpt' },
1456
+ codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '--model', defaultModel: 'default', provider: 'chatgpt' },
1495
1457
  gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '--model', provider: 'gemini' },
1496
1458
  aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
1497
1459
  cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
@@ -1517,12 +1479,18 @@ function formatTransportGuidance(transport, state, speaker) {
1517
1479
  return `CLI speaker. Respond directly via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.${invocationGuide}${modelGuide}\n\n⛔ **No API calls**: Do not call LLM APIs directly via REST API, HTTP requests, urllib, requests, etc. Only use the CLI tools above.`;
1518
1480
  }
1519
1481
  case "clipboard":
1520
- return `Browser LLM speaker. Attempting CDP auto-connect... Chrome may need to be restarted if already running without CDP.\n\n⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
1482
+ return `Browser LLM speaker. Copy the prompt below and paste it into the browser LLM using **Cmd+V (ㅍ)**, then submit the response via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", use_clipboard: true)\` after copying the LLM's response with **Cmd+C (ㅊ)**.\n\n` +
1483
+ `📋 **Prompt has been copied to your clipboard.** (If not, copy the [turn_prompt] section below manually).\n` +
1484
+ `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
1485
+ `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
1521
1486
  case "browser_auto":
1522
1487
  return `Auto browser speaker. Proceed automatically with \`deliberation_browser_auto_turn(session_id: "${sid}")\`. Inputs directly to browser LLM via CDP and reads responses.\n\n⛔ **No API calls**: Proceeds only via CDP automation. No REST API or HTTP requests.`;
1523
1488
  case "manual":
1524
1489
  default:
1525
- return `Manual speaker. Get a response from the LLM's **web UI or CLI tool** and submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n **Absolutely no API calls**: Calling LLM APIs directly via REST API, HTTP requests (urllib, requests, fetch, etc.) is forbidden. Only use web browser UI or CLI tools. Direct API key calls will result in deliberation participation being rejected.`;
1490
+ return `Manual speaker. Get a response from the LLM's **web UI or CLI tool** and submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
1491
+ `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
1492
+ `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
1493
+ `⛔ **Absolutely no API calls**: Calling LLM APIs directly via REST API, HTTP requests (urllib, requests, fetch, etc.) is forbidden. Only use web browser UI or CLI tools. Direct API key calls will result in deliberation participation being rejected.`;
1526
1494
  }
1527
1495
  }
1528
1496
 
@@ -1763,7 +1731,15 @@ tags: [deliberation]
1763
1731
  if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
1764
1732
  md += `> _${parts.join(" | ")}_\n\n`;
1765
1733
  }
1766
- md += `${entry.content}\n\n---\n\n`;
1734
+ md += `${entry.content}\n\n`;
1735
+ if (entry.attachments && entry.attachments.length > 0) {
1736
+ for (const att of entry.attachments) {
1737
+ if (att.type === "image") {
1738
+ md += `![Attachment](${att.path})\n\n`;
1739
+ }
1740
+ }
1741
+ }
1742
+ md += `---\n\n`;
1767
1743
  }
1768
1744
  return md;
1769
1745
  }
@@ -1856,6 +1832,22 @@ function tmuxHasAttachedClients(sessionName) {
1856
1832
  }
1857
1833
  }
1858
1834
 
1835
+ function isTmuxWindowViewed(sessionName, windowName) {
1836
+ try {
1837
+ // List all clients and check for matching window name.
1838
+ // Grouped sessions (created via 'new-session -t') share the same windows,
1839
+ // so checking for the window name anywhere in the client list is sufficient.
1840
+ const output = execFileSync("tmux", ["list-clients", "-F", "#{window_name}"], {
1841
+ encoding: "utf-8",
1842
+ stdio: ["ignore", "pipe", "ignore"],
1843
+ windowsHide: true,
1844
+ });
1845
+ return String(output).split("\n").map(s => s.trim()).filter(Boolean).includes(windowName);
1846
+ } catch {
1847
+ return false;
1848
+ }
1849
+ }
1850
+
1859
1851
  function tmuxWindowCount(name) {
1860
1852
  try {
1861
1853
  const output = execFileSync("tmux", ["list-windows", "-t", name], {
@@ -1921,9 +1913,20 @@ function openPhysicalTerminal(sessionId) {
1921
1913
  // Use grouped session (new-session -t) for independent active window per client
1922
1914
  const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
1923
1915
 
1924
- // If a terminal is already attached, open a NEW grouped session instead of
1925
- // select-window (which would hijack all attached clients' views).
1926
- // Grouped sessions share windows but each has independent active window tracking.
1916
+ // Prevent duplicate windows for the SAME session:
1917
+ // If a client is already viewing this specific window, just activate Terminal.app
1918
+ if (isTmuxWindowViewed(TMUX_SESSION, winName)) {
1919
+ appendRuntimeLog("INFO", `TMUX_WINDOW_ALREADY_VIEWED: ${winName}. Activating existing Terminal.`);
1920
+ if (process.platform === "darwin") {
1921
+ try {
1922
+ execFileSync("osascript", ["-e", 'tell application "Terminal" to activate'], { stdio: "ignore" });
1923
+ } catch { /* ignore */ }
1924
+ }
1925
+ return { opened: true, windowIds: [] };
1926
+ }
1927
+
1928
+ // If a terminal is already attached to OTHER windows, open a NEW grouped session
1929
+ // instead of select-window (which would hijack all attached clients' views).
1927
1930
  if (tmuxHasAttachedClients(TMUX_SESSION)) {
1928
1931
  if (process.platform === "darwin") {
1929
1932
  const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
@@ -2055,17 +2058,20 @@ function spawnMonitorTerminal(sessionId) {
2055
2058
  if (hasTmuxSession(TMUX_SESSION)) {
2056
2059
  // Skip if a window with the same name already exists (prevents duplicates)
2057
2060
  if (hasTmuxWindow(TMUX_SESSION, winName)) {
2061
+ appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
2058
2062
  return true;
2059
2063
  }
2060
2064
  execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
2061
2065
  stdio: "ignore",
2062
2066
  windowsHide: true,
2063
2067
  });
2068
+ appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
2064
2069
  } else {
2065
2070
  execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
2066
2071
  stdio: "ignore",
2067
2072
  windowsHide: true,
2068
2073
  });
2074
+ appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
2069
2075
  }
2070
2076
  return true;
2071
2077
  } catch {
@@ -2302,7 +2308,7 @@ ${recent}
2302
2308
  `;
2303
2309
  }
2304
2310
 
2305
- function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason }) {
2311
+ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments }) {
2306
2312
  const resolved = resolveSessionId(session_id);
2307
2313
  if (!resolved) {
2308
2314
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -2367,8 +2373,9 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
2367
2373
  votes: votes.length > 0 ? votes : undefined,
2368
2374
  suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
2369
2375
  role_drift: roleDrift || undefined,
2376
+ attachments: attachments || undefined,
2370
2377
  });
2371
- appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | suggested_role: ${suggestedRole} | role_drift: ${roleDrift || false}`);
2378
+ appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | attachments: ${attachments ? attachments.length : 0}`);
2372
2379
 
2373
2380
  state.current_speaker = selectNextSpeaker(state);
2374
2381
 
@@ -2442,6 +2449,7 @@ server.tool(
2442
2449
  "Start a new deliberation. Multiple deliberations can run simultaneously.",
2443
2450
  {
2444
2451
  topic: z.string().describe("Discussion topic"),
2452
+ session_id: z.string().trim().min(1).max(64).optional().describe("Explicit session ID to use. If omitted, one is generated from topic."),
2445
2453
  rounds: z.coerce.number().optional().describe("Number of rounds (defaults to config setting, default 3)"),
2446
2454
  first_speaker: z.string().trim().min(1).max(64).optional().describe("First speaker name (defaults to first item in speakers)"),
2447
2455
  speakers: z.preprocess(
@@ -2476,9 +2484,9 @@ server.tool(
2476
2484
  z.record(z.string(), z.enum(["critic", "implementer", "mediator", "researcher", "free"])).optional()
2477
2485
  ).describe("Per-speaker role assignment (e.g., {\"claude\": \"critic\", \"codex\": \"implementer\"})"),
2478
2486
  role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
2479
- .describe("Role preset (balanced/debate/research/brainstorm/review/consensus). Ignored if speaker_roles is specified"),
2480
- },
2481
- safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
2487
+ .describe("Role preset (balanced/debate/research/brainstorm/review/consensus). Ignored if speaker_roles is specified"),
2488
+ },
2489
+ safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
2482
2490
  // ── First-time onboarding guard ──
2483
2491
  const config = loadDeliberationConfig();
2484
2492
  if (!config.setup_complete) {
@@ -2492,7 +2500,13 @@ server.tool(
2492
2500
  };
2493
2501
  }
2494
2502
 
2495
- const sessionId = generateSessionId(topic);
2503
+ const sessionId = session_id || generateSessionId(topic);
2504
+ if (session_id) {
2505
+ const existing = loadSession(session_id);
2506
+ if (existing && existing.status === "active") {
2507
+ return { content: [{ type: "text", text: `❌ Session "${session_id}" is already active. Please use a different ID or reset it first.` }] };
2508
+ }
2509
+ }
2496
2510
  const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
2497
2511
 
2498
2512
  // Resolve effective settings from config
@@ -2804,7 +2818,7 @@ server.tool(
2804
2818
  }
2805
2819
 
2806
2820
  const speaker = state.current_speaker;
2807
- const { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
2821
+ let { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
2808
2822
  const turnId = state.pending_turn_id || null;
2809
2823
 
2810
2824
  // ── Self-speaker detection ──
@@ -2816,14 +2830,25 @@ server.tool(
2816
2830
 
2817
2831
  let guidance;
2818
2832
  if (isSelfSpeaker) {
2819
- guidance = `🟢 **It's your turn.** You (${speaker}) are the current speaker.\n\n` +
2833
+ guidance = t(
2834
+ `🟢 **It's your turn.** You (${speaker}) are the current speaker.\n\n` +
2820
2835
  `Write your response and submit via \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.\n\n` +
2821
- `⚠️ **Do not use cli_auto_turn**: Recursively calling yourself will cause a timeout. You must use deliberation_respond directly.`;
2836
+ `⚠️ **Wait! Why can't I use cli_auto_turn?**\n` +
2837
+ `You are currently the **orchestrator** (the AI running this tool). If you try to spawn yourself automatically, it would create an infinite loop (you calling yourself calling yourself...) and timeout.\n\n` +
2838
+ `Please analyze the topic and history above, formulate your response, and call \`deliberation_respond\` directly.`,
2839
+ `🟢 **당신의 차례입니다.** 당신(${speaker})이 현재 발언자입니다.\n\n` +
2840
+ `응답을 작성하고 \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`를 통해 제출하세요.\n\n` +
2841
+ `⚠️ **잠깐! 왜 cli_auto_turn을 쓸 수 없나요?**\n` +
2842
+ `당신은 현재 **오케스트레이터**(이 도구를 실행 중인 AI)입니다. 자기 자신을 자동으로 spawn하려고 하면 무한 루프(자신이 자신을 호출하고 다시 호출하는...)가 발생하여 타임아웃이 됩니다.\n\n` +
2843
+ `위의 주제와 이력을 분석하여 응답을 작성한 뒤, \`deliberation_respond\`를 직접 호출해 주세요.`,
2844
+ state?.lang
2845
+ );
2822
2846
  } else {
2823
2847
  guidance = formatTransportGuidance(transport, state, speaker);
2824
2848
  }
2825
2849
 
2826
2850
  let extra = "";
2851
+ let turnPrompt = "";
2827
2852
 
2828
2853
  if (transport === "browser_auto") {
2829
2854
  // Auto-execute browser_auto_turn
@@ -2837,7 +2862,7 @@ server.tool(
2837
2862
  const modelSelection = getModelSelectionForTurn(state, turnSpeaker, turnProvider);
2838
2863
 
2839
2864
  // Build prompt
2840
- const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
2865
+ turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
2841
2866
 
2842
2867
  // Attach
2843
2868
  const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
@@ -2876,7 +2901,28 @@ server.tool(
2876
2901
  } catch (autoErr) {
2877
2902
  const errMsg = autoErr instanceof Error ? autoErr.message : String(autoErr);
2878
2903
  extra = `\n\n⚠️ Auto-execution failed (${errMsg}). Restart Chrome with --remote-debugging-port=9222.`;
2904
+ // Fallback to clipboard preparation
2905
+ transport = "clipboard";
2906
+ // Re-generate guidance for the new transport
2907
+ if (!isSelfSpeaker) {
2908
+ guidance = formatTransportGuidance(transport, state, speaker);
2909
+ }
2910
+ }
2911
+ }
2912
+
2913
+ if (transport === "clipboard" || transport === "manual") {
2914
+ // Prepare prompt for manual/clipboard transport
2915
+ turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
2916
+
2917
+ if (auto_prepare_clipboard) {
2918
+ try {
2919
+ writeClipboardText(turnPrompt);
2920
+ } catch (clipErr) {
2921
+ extra += `\n\n⚠️ Failed to copy to clipboard: ${clipErr.message}`;
2922
+ }
2879
2923
  }
2924
+
2925
+ extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
2880
2926
  }
2881
2927
 
2882
2928
  const profileInfo = profile
@@ -3044,13 +3090,19 @@ server.tool(
3044
3090
  return { content: [{ type: "text", text: t(`Speaker "${speaker}" is not a CLI type (transport: ${transport}). Browser speakers should use deliberation_browser_auto_turn.`, `speaker "${speaker}"는 CLI 타입이 아닙니다 (transport: ${transport}). 브라우저 speaker는 deliberation_browser_auto_turn을 사용하세요.`, state?.lang) }] };
3045
3091
  }
3046
3092
 
3047
- // Block recursive self-spawn: if the speaker is the same CLI as the caller,
3048
- // spawning it would create infinite recursion and timeout.
3049
3093
  const callerSpeaker = detectCallerSpeaker();
3050
3094
  if (callerSpeaker && speaker === callerSpeaker) {
3051
3095
  return { content: [{ type: "text", text: t(
3052
- `⚠️ **Recursive call blocked**: Speaker "${speaker}" is the same CLI as the current orchestrator.\n\nSpawning yourself with cli_auto_turn will cause a timeout.\nWrite your response and submit via \`deliberation_respond(session_id: "${resolved}", speaker: "${speaker}", content: "...")\`.`,
3053
- `⚠️ **재귀 호출 차단**: speaker "${speaker}"는 현재 오케스트레이터와 동일한 CLI입니다.\n\ncli_auto_turn으로 자기 자신을 spawn하면 타임아웃이 발생합니다.\n직접 응답을 작성하여 \`deliberation_respond(session_id: "${resolved}", speaker: "${speaker}", content: "...")\`로 제출하세요.`,
3096
+ `🟢 **It's your turn.** You (${speaker}) are the current speaker.\n\n` +
3097
+ `Write your response and submit via \`deliberation_respond(session_id: "${resolved}", speaker: "${speaker}", content: "...")\`.\n\n` +
3098
+ `⚠️ **Wait! Why can't I use cli_auto_turn?**\n` +
3099
+ `You are currently the **orchestrator** (the AI running this tool). If you try to spawn yourself automatically, it would create an infinite loop (you calling yourself calling yourself...) and timeout.\n\n` +
3100
+ `Please analyze the topic and history above, formulate your response, and call \`deliberation_respond\` directly.`,
3101
+ `🟢 **당신의 차례입니다.** 당신(${speaker})이 현재 발언자입니다.\n\n` +
3102
+ `응답을 작성하고 \`deliberation_respond(session_id: "${resolved}", speaker: "${speaker}", content: "...")\`를 통해 제출하세요.\n\n` +
3103
+ `⚠️ **잠깐! 왜 cli_auto_turn을 쓸 수 없나요?**\n` +
3104
+ `당신은 현재 **오케스트레이터**(이 도구를 실행 중인 AI)입니다. 자기 자신을 자동으로 spawn하려고 하면 무한 루프(자신이 자신을 호출하고 다시 호출하는...)가 발생하여 타임아웃이 됩니다.\n\n` +
3105
+ `위의 주제와 이력을 분석하여 응답을 작성한 뒤, \`deliberation_respond\`를 직접 호출해 주세요.`,
3054
3106
  state?.lang)
3055
3107
  }] };
3056
3108
  }
@@ -3094,7 +3146,9 @@ server.tool(
3094
3146
  child.stdin.end();
3095
3147
  break;
3096
3148
  case "codex":
3097
- child = spawn("codex", ["exec", "--model", "gpt-5.4-codex", turnPrompt], { env, windowsHide: true });
3149
+ child = spawn("codex", ["exec", "-"], { env, windowsHide: true });
3150
+ child.stdin.write(turnPrompt);
3151
+ child.stdin.end();
3098
3152
  break;
3099
3153
  case "gemini":
3100
3154
  child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
@@ -3123,12 +3177,23 @@ server.tool(
3123
3177
  // Clean up output noise
3124
3178
  let cleaned = stdout;
3125
3179
  if (speaker === "codex") {
3126
- cleaned = stdout.split("\n")
3127
- .filter(line => !/^(OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|mcp:|thinking$|tokens used$|^[0-9,]*$)/.test(line))
3128
- .join("\n");
3180
+ // Codex output includes the prompt and metadata.
3181
+ // Find the line starting with "codex" and take everything after it.
3182
+ const lines = stdout.split("\n");
3183
+ const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
3184
+ if (codexLineIdx !== -1) {
3185
+ cleaned = lines.slice(codexLineIdx + 1)
3186
+ .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
3187
+ .join("\n");
3188
+ } else {
3189
+ // Fallback regex cleaning
3190
+ cleaned = stdout.split("\n")
3191
+ .filter(line => !/^(OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|mcp:|thinking$|tokens used$|^[0-9,]*$)/.test(line))
3192
+ .join("\n");
3193
+ }
3129
3194
  } else if (speaker === "gemini") {
3130
3195
  cleaned = stdout.split("\n")
3131
- .filter(line => !/^(Loaded cached|Error during discovery)/.test(line))
3196
+ .filter(line => !/^(Loaded cached|Error during discovery|\[MCP error\]| {4}at| {2}errno:| {2}code:| {2}syscall:| {2}path:| {2}spawnargs:|MCP issues detected|Server .* supports tool updates)/.test(line))
3132
3197
  .join("\n");
3133
3198
  }
3134
3199
  resolve(cleaned.trim());
@@ -3184,9 +3249,11 @@ server.tool(
3184
3249
  speaker: z.string().trim().min(1).max(64).describe("Responder name"),
3185
3250
  content: z.string().optional().describe("Response content (markdown). Either content or content_file is required."),
3186
3251
  content_file: z.string().optional().describe("File path containing response content. For avoiding JSON escape issues. File content is used as-is for content."),
3252
+ use_clipboard: z.boolean().optional().describe("Read content from system clipboard (alternative to content/content_file)"),
3253
+ include_clipboard_image: z.boolean().optional().describe("Capture and include image from system clipboard"),
3187
3254
  turn_id: z.string().optional().describe("Turn verification ID (value received from deliberation_route_turn)"),
3188
3255
  },
3189
- safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, turn_id }) => {
3256
+ safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, use_clipboard, include_clipboard_image, turn_id }) => {
3190
3257
  // Guard: prevent orchestrator from fabricating responses for CLI/browser speakers
3191
3258
  const resolved = resolveSessionId(session_id);
3192
3259
  if (resolved && resolved !== "MULTIPLE") {
@@ -3212,22 +3279,125 @@ server.tool(
3212
3279
  }
3213
3280
  }
3214
3281
 
3215
- // Support reading content from file to avoid JSON escaping issues
3282
+ // Support reading content from file or clipboard to avoid JSON escaping issues
3216
3283
  let finalContent = content;
3217
- if (content_file && !content) {
3284
+ if (use_clipboard && !content) {
3285
+ try {
3286
+ finalContent = readClipboardText();
3287
+ } catch (e) {
3288
+ return { content: [{ type: "text", text: t(`❌ Failed to read from clipboard: ${e.message}`, `❌ 클립보드 읽기 실패: ${e.message}`, state?.lang) }] };
3289
+ }
3290
+ } else if (content_file && !content) {
3218
3291
  try {
3219
3292
  finalContent = fs.readFileSync(content_file, "utf-8").trim();
3220
3293
  } catch (e) {
3221
3294
  return { content: [{ type: "text", text: t(`❌ Failed to read content_file: ${e.message}`, `❌ content_file 읽기 실패: ${e.message}`, state?.lang) }] };
3222
3295
  }
3223
3296
  }
3224
- if (!finalContent) {
3225
- return { content: [{ type: "text", text: t("❌ Either content or content_file must be provided.", "❌ content 또는 content_file 중 하나를 제공해야 합니다.", "en") }] };
3297
+ if (!finalContent && !include_clipboard_image) {
3298
+ return { content: [{ type: "text", text: t("❌ Either content, content_file, or include_clipboard_image must be provided.", "❌ content, content_file 또는 include_clipboard_image 중 하나를 제공해야 합니다.", "en") }] };
3299
+ }
3300
+
3301
+ const attachments = [];
3302
+ if (include_clipboard_image) {
3303
+ if (process.platform !== "darwin") {
3304
+ return { content: [{ type: "text", text: "❌ Clipboard image capture is currently only supported on macOS." }] };
3305
+ }
3306
+ if (!hasClipboardImage()) {
3307
+ return { content: [{ type: "text", text: t("❌ No image found on clipboard.", "❌ 클립보드에서 이미지를 찾을 수 없습니다.", state?.lang) }] };
3308
+ }
3309
+
3310
+ const attachmentsDir = path.join(getSessionsDir(), `${resolved}_attachments`);
3311
+ if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true });
3312
+
3313
+ const imgName = `img_${Date.now()}.png`;
3314
+ const imgPath = path.join(attachmentsDir, imgName);
3315
+
3316
+ if (captureClipboardImage(imgPath)) {
3317
+ attachments.push({ type: "image", path: `attachments/${imgName}`, localPath: imgPath });
3318
+ } else {
3319
+ return { content: [{ type: "text", text: "❌ Failed to capture image from clipboard." }] };
3320
+ }
3321
+ }
3322
+
3323
+ return submitDeliberationTurn({ session_id, speaker, content: finalContent || "(Image response)", turn_id, channel_used: "cli_respond", attachments });
3324
+ })
3325
+ );
3326
+
3327
+ server.tool(
3328
+ "deliberation_inject_context",
3329
+ "Inject additional context or instructions into a specific active session. (Useful for local or remote context injection via Tailscale)",
3330
+ {
3331
+ session_id: z.string().describe("Session ID to inject context into"),
3332
+ context: z.string().describe("The context text to inject"),
3333
+ speaker: z.string().default("system").describe("Optional label for who injected the context (default: 'system')"),
3334
+ },
3335
+ safeToolHandler("deliberation_inject_context", async ({ session_id, context, speaker }) => {
3336
+ const resolved = resolveSessionId(session_id);
3337
+ if (!resolved) {
3338
+ return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
3226
3339
  }
3227
- return submitDeliberationTurn({ session_id, speaker, content: finalContent, turn_id, channel_used: "cli_respond" });
3340
+ if (resolved === "MULTIPLE") {
3341
+ return { content: [{ type: "text", text: multipleSessionsError() }] };
3342
+ }
3343
+
3344
+ return withSessionLock(resolved, () => {
3345
+ const state = loadSession(resolved);
3346
+ if (!state || state.status !== "active") {
3347
+ return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
3348
+ }
3349
+
3350
+ state.log.push({
3351
+ round: state.current_round,
3352
+ speaker: speaker || "system",
3353
+ content: `[Context Injection]\n${context}`,
3354
+ timestamp: new Date().toISOString(),
3355
+ event: "context_injection",
3356
+ });
3357
+
3358
+ appendRuntimeLog("INFO", `CONTEXT_INJECTION: ${state.id} | speaker: ${speaker || "system"} | length: ${context.length}`);
3359
+ saveSession(state);
3360
+
3361
+ return {
3362
+ content: [{
3363
+ type: "text",
3364
+ text: `✅ Context successfully injected into session "${state.id}".`,
3365
+ }],
3366
+ };
3367
+ });
3228
3368
  })
3229
3369
  );
3230
3370
 
3371
+ server.tool(
3372
+ "deliberation_copy_last_turn",
3373
+ "Copy the last turn's response to the system clipboard.",
3374
+ {
3375
+ session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
3376
+ },
3377
+ async ({ session_id }) => {
3378
+ const resolved = resolveSessionId(session_id);
3379
+ if (!resolved || resolved === "MULTIPLE") {
3380
+ return { content: [{ type: "text", text: t("No unique active deliberation found.", "고유한 활성 deliberation을 찾을 수 없습니다.", "en") }] };
3381
+ }
3382
+ const state = loadSession(resolved);
3383
+ if (!state || state.log.length === 0) {
3384
+ return { content: [{ type: "text", text: t("No responses yet.", "아직 응답이 없습니다.", "en") }] };
3385
+ }
3386
+ const last = state.log[state.log.length - 1];
3387
+ try {
3388
+ writeClipboardText(last.content);
3389
+ let imgMsg = "";
3390
+ if (last.attachments && last.attachments.length > 0) {
3391
+ const hasImg = last.attachments.some(a => a.type === "image");
3392
+ if (hasImg) imgMsg = "\n\n⚠️ Note: This response included images, but only text was copied to the clipboard.";
3393
+ }
3394
+ return { content: [{ type: "text", text: `📋 **[${last.speaker}]'s response copied to clipboard.** (Round ${last.round})${imgMsg}\n\nYou can now paste it into other tools using Cmd+V (ㅍ).` }] };
3395
+ } catch (e) {
3396
+ return { content: [{ type: "text", text: `❌ Failed to copy to clipboard: ${e.message}` }] };
3397
+ }
3398
+ }
3399
+ );
3400
+
3231
3401
  server.tool(
3232
3402
  "deliberation_history",
3233
3403
  "Return the deliberation history.",
@@ -3295,6 +3465,9 @@ server.tool(
3295
3465
  saveSession(loaded);
3296
3466
  archivePath = archiveState(loaded);
3297
3467
  cleanupSyncMarkdown(loaded);
3468
+ // Clean up the active session JSON file upon completion
3469
+ const sessionFile = getSessionFile(loaded.id);
3470
+ try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch { /* ignore */ }
3298
3471
  state = loaded;
3299
3472
  return null;
3300
3473
  });
@@ -3528,7 +3701,7 @@ server.tool(
3528
3701
  function invokeCliReviewer(command, prompt, timeoutMs) {
3529
3702
  const hint = CLI_INVOCATION_HINTS[command];
3530
3703
  let args;
3531
- let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 5 * 1024 * 1024, windowsHide: true };
3704
+ let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
3532
3705
  const env = { ...process.env };
3533
3706
 
3534
3707
  switch (command) {
@@ -3536,11 +3709,10 @@ function invokeCliReviewer(command, prompt, timeoutMs) {
3536
3709
  if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
3537
3710
  args = ["-p", "--output-format", "text", "--no-input"];
3538
3711
  opts.input = prompt;
3539
- opts.stdio = ["pipe", "pipe", "pipe"];
3540
3712
  break;
3541
3713
  case "codex":
3542
- args = ["exec", prompt];
3543
- opts.stdio = ["ignore", "pipe", "pipe"];
3714
+ args = ["exec", "-"];
3715
+ opts.input = prompt;
3544
3716
  break;
3545
3717
  case "gemini":
3546
3718
  args = ["-p", prompt];
@@ -3556,7 +3728,17 @@ function invokeCliReviewer(command, prompt, timeoutMs) {
3556
3728
 
3557
3729
  try {
3558
3730
  const result = execFileSync(command, args, { ...opts, env });
3559
- return { ok: true, response: result.trim() };
3731
+ let cleaned = result;
3732
+ if (command === "codex") {
3733
+ const lines = result.split("\n");
3734
+ const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
3735
+ if (codexLineIdx !== -1) {
3736
+ cleaned = lines.slice(codexLineIdx + 1)
3737
+ .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
3738
+ .join("\n");
3739
+ }
3740
+ }
3741
+ return { ok: true, response: cleaned.trim() };
3560
3742
  } catch (error) {
3561
3743
  if (error && error.killed) {
3562
3744
  return { ok: false, error: "timeout" };
@@ -3896,24 +4078,24 @@ server.tool(
3896
4078
  let stdout = "";
3897
4079
  let stderr = "";
3898
4080
 
3899
- const args = [];
4081
+ let proc;
4082
+ const env = { ...process.env, NO_COLOR: "1" };
4083
+
3900
4084
  if (speaker === "claude") {
3901
- args.push("-p", "--output-format", "text", opinionPrompt);
4085
+ proc = spawn("claude", ["-p", "--output-format", "text", "--no-input"], { env, windowsHide: true });
4086
+ proc.stdin.write(opinionPrompt);
4087
+ proc.stdin.end();
3902
4088
  } else if (speaker === "codex") {
3903
- args.push("exec", opinionPrompt);
4089
+ proc = spawn("codex", ["exec", "-"], { env, windowsHide: true });
4090
+ proc.stdin.write(opinionPrompt);
4091
+ proc.stdin.end();
3904
4092
  } else if (speaker === "gemini") {
3905
- args.push("-p", opinionPrompt);
4093
+ proc = spawn("gemini", ["-p", opinionPrompt], { env, windowsHide: true });
3906
4094
  } else {
3907
- const flags = hint?.flags || [];
3908
- args.push(...flags, opinionPrompt);
4095
+ const flags = hint?.flags ? (Array.isArray(hint.flags) ? hint.flags : hint.flags.split(/\s+/)) : [];
4096
+ proc = spawn(cmd, [...flags, opinionPrompt], { env, windowsHide: true });
3909
4097
  }
3910
4098
 
3911
- const proc = spawn(cmd, args, {
3912
- stdio: ["pipe", "pipe", "pipe"],
3913
- env: { ...process.env, NO_COLOR: "1" },
3914
- timeout: 180000,
3915
- });
3916
-
3917
4099
  proc.stdout?.on("data", (d) => { stdout += d.toString(); });
3918
4100
  proc.stderr?.on("data", (d) => { stderr += d.toString(); });
3919
4101
 
@@ -3924,7 +4106,21 @@ server.tool(
3924
4106
 
3925
4107
  proc.on("close", (code) => {
3926
4108
  clearTimeout(timer);
3927
- resolve(stdout.trim() || stderr.trim());
4109
+ let cleaned = stdout.trim() || stderr.trim();
4110
+ if (speaker === "codex") {
4111
+ const lines = cleaned.split("\n");
4112
+ const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
4113
+ if (codexLineIdx !== -1) {
4114
+ cleaned = lines.slice(codexLineIdx + 1)
4115
+ .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
4116
+ .join("\n").trim();
4117
+ }
4118
+ } else if (speaker === "gemini") {
4119
+ cleaned = cleaned.split("\n")
4120
+ .filter(line => !/^(Loaded cached|Error during discovery|\[MCP error\]| {4}at| {2}errno:| {2}code:| {2}syscall:| {2}path:| {2}spawnargs:|MCP issues detected|Server .* supports tool updates)/.test(line))
4121
+ .join("\n").trim();
4122
+ }
4123
+ resolve(cleaned);
3928
4124
  });
3929
4125
 
3930
4126
  proc.on("error", (err) => {