@dmsdc-ai/aigentry-deliberation 0.0.30 → 0.0.32

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;
@@ -1423,10 +1362,16 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1423
1362
  // Check for explicit type override
1424
1363
  const overrideType = overrides[speaker] || overrides[raw];
1425
1364
  if (overrideType) {
1365
+ const candidate = bySpeaker.get(speaker);
1426
1366
  profiles.push({
1427
1367
  speaker,
1428
1368
  type: overrideType,
1429
- ...(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
+ } : {}),
1430
1375
  });
1431
1376
  continue;
1432
1377
  }
@@ -1508,7 +1453,7 @@ function resolveTransportForSpeaker(state, speaker) {
1508
1453
  // CLI-specific invocation flags for non-interactive execution
1509
1454
  const CLI_INVOCATION_HINTS = {
1510
1455
  claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
1511
- 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' },
1512
1457
  gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '--model', provider: 'gemini' },
1513
1458
  aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
1514
1459
  cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
@@ -1534,12 +1479,18 @@ function formatTransportGuidance(transport, state, speaker) {
1534
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.`;
1535
1480
  }
1536
1481
  case "clipboard":
1537
- 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.`;
1538
1486
  case "browser_auto":
1539
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.`;
1540
1488
  case "manual":
1541
1489
  default:
1542
- 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.`;
1543
1494
  }
1544
1495
  }
1545
1496
 
@@ -1780,7 +1731,15 @@ tags: [deliberation]
1780
1731
  if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
1781
1732
  md += `> _${parts.join(" | ")}_\n\n`;
1782
1733
  }
1783
- 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`;
1784
1743
  }
1785
1744
  return md;
1786
1745
  }
@@ -1873,6 +1832,22 @@ function tmuxHasAttachedClients(sessionName) {
1873
1832
  }
1874
1833
  }
1875
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
+
1876
1851
  function tmuxWindowCount(name) {
1877
1852
  try {
1878
1853
  const output = execFileSync("tmux", ["list-windows", "-t", name], {
@@ -1938,9 +1913,20 @@ function openPhysicalTerminal(sessionId) {
1938
1913
  // Use grouped session (new-session -t) for independent active window per client
1939
1914
  const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
1940
1915
 
1941
- // If a terminal is already attached, open a NEW grouped session instead of
1942
- // select-window (which would hijack all attached clients' views).
1943
- // 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).
1944
1930
  if (tmuxHasAttachedClients(TMUX_SESSION)) {
1945
1931
  if (process.platform === "darwin") {
1946
1932
  const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
@@ -2072,17 +2058,20 @@ function spawnMonitorTerminal(sessionId) {
2072
2058
  if (hasTmuxSession(TMUX_SESSION)) {
2073
2059
  // Skip if a window with the same name already exists (prevents duplicates)
2074
2060
  if (hasTmuxWindow(TMUX_SESSION, winName)) {
2061
+ appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
2075
2062
  return true;
2076
2063
  }
2077
2064
  execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
2078
2065
  stdio: "ignore",
2079
2066
  windowsHide: true,
2080
2067
  });
2068
+ appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
2081
2069
  } else {
2082
2070
  execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
2083
2071
  stdio: "ignore",
2084
2072
  windowsHide: true,
2085
2073
  });
2074
+ appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
2086
2075
  }
2087
2076
  return true;
2088
2077
  } catch {
@@ -2319,7 +2308,7 @@ ${recent}
2319
2308
  `;
2320
2309
  }
2321
2310
 
2322
- 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 }) {
2323
2312
  const resolved = resolveSessionId(session_id);
2324
2313
  if (!resolved) {
2325
2314
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -2384,8 +2373,9 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
2384
2373
  votes: votes.length > 0 ? votes : undefined,
2385
2374
  suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
2386
2375
  role_drift: roleDrift || undefined,
2376
+ attachments: attachments || undefined,
2387
2377
  });
2388
- 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}`);
2389
2379
 
2390
2380
  state.current_speaker = selectNextSpeaker(state);
2391
2381
 
@@ -2459,6 +2449,7 @@ server.tool(
2459
2449
  "Start a new deliberation. Multiple deliberations can run simultaneously.",
2460
2450
  {
2461
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."),
2462
2453
  rounds: z.coerce.number().optional().describe("Number of rounds (defaults to config setting, default 3)"),
2463
2454
  first_speaker: z.string().trim().min(1).max(64).optional().describe("First speaker name (defaults to first item in speakers)"),
2464
2455
  speakers: z.preprocess(
@@ -2493,9 +2484,9 @@ server.tool(
2493
2484
  z.record(z.string(), z.enum(["critic", "implementer", "mediator", "researcher", "free"])).optional()
2494
2485
  ).describe("Per-speaker role assignment (e.g., {\"claude\": \"critic\", \"codex\": \"implementer\"})"),
2495
2486
  role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
2496
- .describe("Role preset (balanced/debate/research/brainstorm/review/consensus). Ignored if speaker_roles is specified"),
2497
- },
2498
- 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 }) => {
2499
2490
  // ── First-time onboarding guard ──
2500
2491
  const config = loadDeliberationConfig();
2501
2492
  if (!config.setup_complete) {
@@ -2509,7 +2500,13 @@ server.tool(
2509
2500
  };
2510
2501
  }
2511
2502
 
2512
- 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
+ }
2513
2510
  const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
2514
2511
 
2515
2512
  // Resolve effective settings from config
@@ -2821,7 +2818,7 @@ server.tool(
2821
2818
  }
2822
2819
 
2823
2820
  const speaker = state.current_speaker;
2824
- const { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
2821
+ let { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
2825
2822
  const turnId = state.pending_turn_id || null;
2826
2823
 
2827
2824
  // ── Self-speaker detection ──
@@ -2833,14 +2830,25 @@ server.tool(
2833
2830
 
2834
2831
  let guidance;
2835
2832
  if (isSelfSpeaker) {
2836
- 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` +
2837
2835
  `Write your response and submit via \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.\n\n` +
2838
- `⚠️ **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
+ );
2839
2846
  } else {
2840
2847
  guidance = formatTransportGuidance(transport, state, speaker);
2841
2848
  }
2842
2849
 
2843
2850
  let extra = "";
2851
+ let turnPrompt = "";
2844
2852
 
2845
2853
  if (transport === "browser_auto") {
2846
2854
  // Auto-execute browser_auto_turn
@@ -2854,7 +2862,7 @@ server.tool(
2854
2862
  const modelSelection = getModelSelectionForTurn(state, turnSpeaker, turnProvider);
2855
2863
 
2856
2864
  // Build prompt
2857
- const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
2865
+ turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
2858
2866
 
2859
2867
  // Attach
2860
2868
  const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
@@ -2893,9 +2901,30 @@ server.tool(
2893
2901
  } catch (autoErr) {
2894
2902
  const errMsg = autoErr instanceof Error ? autoErr.message : String(autoErr);
2895
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
+ }
2896
2910
  }
2897
2911
  }
2898
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
+ }
2923
+ }
2924
+
2925
+ extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
2926
+ }
2927
+
2899
2928
  const profileInfo = profile
2900
2929
  ? `\n**Profile:** ${profile.type}${profile.url ? ` | ${profile.url}` : ""}${profile.command ? ` | command: ${profile.command}` : ""}`
2901
2930
  : "";
@@ -3061,13 +3090,19 @@ server.tool(
3061
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) }] };
3062
3091
  }
3063
3092
 
3064
- // Block recursive self-spawn: if the speaker is the same CLI as the caller,
3065
- // spawning it would create infinite recursion and timeout.
3066
3093
  const callerSpeaker = detectCallerSpeaker();
3067
3094
  if (callerSpeaker && speaker === callerSpeaker) {
3068
3095
  return { content: [{ type: "text", text: t(
3069
- `⚠️ **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: "...")\`.`,
3070
- `⚠️ **재귀 호출 차단**: 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\`를 직접 호출해 주세요.`,
3071
3106
  state?.lang)
3072
3107
  }] };
3073
3108
  }
@@ -3111,7 +3146,9 @@ server.tool(
3111
3146
  child.stdin.end();
3112
3147
  break;
3113
3148
  case "codex":
3114
- 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();
3115
3152
  break;
3116
3153
  case "gemini":
3117
3154
  child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
@@ -3140,12 +3177,23 @@ server.tool(
3140
3177
  // Clean up output noise
3141
3178
  let cleaned = stdout;
3142
3179
  if (speaker === "codex") {
3143
- cleaned = stdout.split("\n")
3144
- .filter(line => !/^(OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|mcp:|thinking$|tokens used$|^[0-9,]*$)/.test(line))
3145
- .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
+ }
3146
3194
  } else if (speaker === "gemini") {
3147
3195
  cleaned = stdout.split("\n")
3148
- .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))
3149
3197
  .join("\n");
3150
3198
  }
3151
3199
  resolve(cleaned.trim());
@@ -3201,9 +3249,11 @@ server.tool(
3201
3249
  speaker: z.string().trim().min(1).max(64).describe("Responder name"),
3202
3250
  content: z.string().optional().describe("Response content (markdown). Either content or content_file is required."),
3203
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"),
3204
3254
  turn_id: z.string().optional().describe("Turn verification ID (value received from deliberation_route_turn)"),
3205
3255
  },
3206
- 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 }) => {
3207
3257
  // Guard: prevent orchestrator from fabricating responses for CLI/browser speakers
3208
3258
  const resolved = resolveSessionId(session_id);
3209
3259
  if (resolved && resolved !== "MULTIPLE") {
@@ -3229,22 +3279,148 @@ server.tool(
3229
3279
  }
3230
3280
  }
3231
3281
 
3232
- // Support reading content from file to avoid JSON escaping issues
3282
+ // Support reading content from file or clipboard to avoid JSON escaping issues
3233
3283
  let finalContent = content;
3234
- 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) {
3235
3291
  try {
3236
3292
  finalContent = fs.readFileSync(content_file, "utf-8").trim();
3237
3293
  } catch (e) {
3238
3294
  return { content: [{ type: "text", text: t(`❌ Failed to read content_file: ${e.message}`, `❌ content_file 읽기 실패: ${e.message}`, state?.lang) }] };
3239
3295
  }
3240
3296
  }
3241
- if (!finalContent) {
3242
- 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
+ }
3243
3321
  }
3244
- return submitDeliberationTurn({ session_id, speaker, content: finalContent, turn_id, channel_used: "cli_respond" });
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
+ remote_url: z.string().optional().describe("Optional Tailscale IP/Host and port (e.g., '100.100.100.5:3847') of the remote machine running the session. If provided, context is injected remotely."),
3335
+ },
3336
+ safeToolHandler("deliberation_inject_context", async ({ session_id, context, speaker, remote_url }) => {
3337
+ if (remote_url) {
3338
+ try {
3339
+ const baseUrl = remote_url.startsWith("http") ? remote_url : `http://${remote_url}`;
3340
+ // Ensure trailing slash is removed
3341
+ const cleanBaseUrl = baseUrl.replace(/\/$/, "");
3342
+ const response = await fetch(`${cleanBaseUrl}/api/sessions/${encodeURIComponent(session_id)}/context`, {
3343
+ method: "POST",
3344
+ headers: { "Content-Type": "application/json" },
3345
+ body: JSON.stringify({ context, speaker: speaker || "system" })
3346
+ });
3347
+
3348
+ if (!response.ok) {
3349
+ let errText = await response.text();
3350
+ try { errText = JSON.parse(errText).error || errText; } catch { /* ignore */ }
3351
+ return { content: [{ type: "text", text: `❌ Remote context injection failed (${response.status}): ${errText}` }] };
3352
+ }
3353
+ return { content: [{ type: "text", text: `✅ Context successfully injected remotely into session "${session_id}" at ${remote_url}.` }] };
3354
+ } catch (e) {
3355
+ return { content: [{ type: "text", text: `❌ Error connecting to remote observer at ${remote_url}: ${e.message}` }] };
3356
+ }
3357
+ }
3358
+
3359
+ const resolved = resolveSessionId(session_id);
3360
+ if (!resolved) {
3361
+ return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
3362
+ }
3363
+ if (resolved === "MULTIPLE") {
3364
+ return { content: [{ type: "text", text: multipleSessionsError() }] };
3365
+ }
3366
+
3367
+ return withSessionLock(resolved, () => {
3368
+ const state = loadSession(resolved);
3369
+ if (!state || state.status !== "active") {
3370
+ return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
3371
+ }
3372
+
3373
+ state.log.push({
3374
+ round: state.current_round,
3375
+ speaker: speaker || "system",
3376
+ content: `[Context Injection]\n${context}`,
3377
+ timestamp: new Date().toISOString(),
3378
+ event: "context_injection",
3379
+ });
3380
+
3381
+ appendRuntimeLog("INFO", `CONTEXT_INJECTION: ${state.id} | speaker: ${speaker || "system"} | length: ${context.length}`);
3382
+ saveSession(state);
3383
+
3384
+ return {
3385
+ content: [{
3386
+ type: "text",
3387
+ text: `✅ Context successfully injected into session "${state.id}".`,
3388
+ }],
3389
+ };
3390
+ });
3245
3391
  })
3246
3392
  );
3247
3393
 
3394
+ server.tool(
3395
+ "deliberation_copy_last_turn",
3396
+ "Copy the last turn's response to the system clipboard.",
3397
+ {
3398
+ session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
3399
+ },
3400
+ async ({ session_id }) => {
3401
+ const resolved = resolveSessionId(session_id);
3402
+ if (!resolved || resolved === "MULTIPLE") {
3403
+ return { content: [{ type: "text", text: t("No unique active deliberation found.", "고유한 활성 deliberation을 찾을 수 없습니다.", "en") }] };
3404
+ }
3405
+ const state = loadSession(resolved);
3406
+ if (!state || state.log.length === 0) {
3407
+ return { content: [{ type: "text", text: t("No responses yet.", "아직 응답이 없습니다.", "en") }] };
3408
+ }
3409
+ const last = state.log[state.log.length - 1];
3410
+ try {
3411
+ writeClipboardText(last.content);
3412
+ let imgMsg = "";
3413
+ if (last.attachments && last.attachments.length > 0) {
3414
+ const hasImg = last.attachments.some(a => a.type === "image");
3415
+ if (hasImg) imgMsg = "\n\n⚠️ Note: This response included images, but only text was copied to the clipboard.";
3416
+ }
3417
+ 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 (ㅍ).` }] };
3418
+ } catch (e) {
3419
+ return { content: [{ type: "text", text: `❌ Failed to copy to clipboard: ${e.message}` }] };
3420
+ }
3421
+ }
3422
+ );
3423
+
3248
3424
  server.tool(
3249
3425
  "deliberation_history",
3250
3426
  "Return the deliberation history.",
@@ -3312,6 +3488,9 @@ server.tool(
3312
3488
  saveSession(loaded);
3313
3489
  archivePath = archiveState(loaded);
3314
3490
  cleanupSyncMarkdown(loaded);
3491
+ // Clean up the active session JSON file upon completion
3492
+ const sessionFile = getSessionFile(loaded.id);
3493
+ try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch { /* ignore */ }
3315
3494
  state = loaded;
3316
3495
  return null;
3317
3496
  });
@@ -3545,7 +3724,7 @@ server.tool(
3545
3724
  function invokeCliReviewer(command, prompt, timeoutMs) {
3546
3725
  const hint = CLI_INVOCATION_HINTS[command];
3547
3726
  let args;
3548
- let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 5 * 1024 * 1024, windowsHide: true };
3727
+ let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
3549
3728
  const env = { ...process.env };
3550
3729
 
3551
3730
  switch (command) {
@@ -3553,11 +3732,10 @@ function invokeCliReviewer(command, prompt, timeoutMs) {
3553
3732
  if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
3554
3733
  args = ["-p", "--output-format", "text", "--no-input"];
3555
3734
  opts.input = prompt;
3556
- opts.stdio = ["pipe", "pipe", "pipe"];
3557
3735
  break;
3558
3736
  case "codex":
3559
- args = ["exec", prompt];
3560
- opts.stdio = ["ignore", "pipe", "pipe"];
3737
+ args = ["exec", "-"];
3738
+ opts.input = prompt;
3561
3739
  break;
3562
3740
  case "gemini":
3563
3741
  args = ["-p", prompt];
@@ -3573,7 +3751,17 @@ function invokeCliReviewer(command, prompt, timeoutMs) {
3573
3751
 
3574
3752
  try {
3575
3753
  const result = execFileSync(command, args, { ...opts, env });
3576
- return { ok: true, response: result.trim() };
3754
+ let cleaned = result;
3755
+ if (command === "codex") {
3756
+ const lines = result.split("\n");
3757
+ const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
3758
+ if (codexLineIdx !== -1) {
3759
+ cleaned = lines.slice(codexLineIdx + 1)
3760
+ .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
3761
+ .join("\n");
3762
+ }
3763
+ }
3764
+ return { ok: true, response: cleaned.trim() };
3577
3765
  } catch (error) {
3578
3766
  if (error && error.killed) {
3579
3767
  return { ok: false, error: "timeout" };
@@ -3913,24 +4101,24 @@ server.tool(
3913
4101
  let stdout = "";
3914
4102
  let stderr = "";
3915
4103
 
3916
- const args = [];
4104
+ let proc;
4105
+ const env = { ...process.env, NO_COLOR: "1" };
4106
+
3917
4107
  if (speaker === "claude") {
3918
- args.push("-p", "--output-format", "text", opinionPrompt);
4108
+ proc = spawn("claude", ["-p", "--output-format", "text", "--no-input"], { env, windowsHide: true });
4109
+ proc.stdin.write(opinionPrompt);
4110
+ proc.stdin.end();
3919
4111
  } else if (speaker === "codex") {
3920
- args.push("exec", opinionPrompt);
4112
+ proc = spawn("codex", ["exec", "-"], { env, windowsHide: true });
4113
+ proc.stdin.write(opinionPrompt);
4114
+ proc.stdin.end();
3921
4115
  } else if (speaker === "gemini") {
3922
- args.push("-p", opinionPrompt);
4116
+ proc = spawn("gemini", ["-p", opinionPrompt], { env, windowsHide: true });
3923
4117
  } else {
3924
- const flags = hint?.flags || [];
3925
- args.push(...flags, opinionPrompt);
4118
+ const flags = hint?.flags ? (Array.isArray(hint.flags) ? hint.flags : hint.flags.split(/\s+/)) : [];
4119
+ proc = spawn(cmd, [...flags, opinionPrompt], { env, windowsHide: true });
3926
4120
  }
3927
4121
 
3928
- const proc = spawn(cmd, args, {
3929
- stdio: ["pipe", "pipe", "pipe"],
3930
- env: { ...process.env, NO_COLOR: "1" },
3931
- timeout: 180000,
3932
- });
3933
-
3934
4122
  proc.stdout?.on("data", (d) => { stdout += d.toString(); });
3935
4123
  proc.stderr?.on("data", (d) => { stderr += d.toString(); });
3936
4124
 
@@ -3941,7 +4129,21 @@ server.tool(
3941
4129
 
3942
4130
  proc.on("close", (code) => {
3943
4131
  clearTimeout(timer);
3944
- resolve(stdout.trim() || stderr.trim());
4132
+ let cleaned = stdout.trim() || stderr.trim();
4133
+ if (speaker === "codex") {
4134
+ const lines = cleaned.split("\n");
4135
+ const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
4136
+ if (codexLineIdx !== -1) {
4137
+ cleaned = lines.slice(codexLineIdx + 1)
4138
+ .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
4139
+ .join("\n").trim();
4140
+ }
4141
+ } else if (speaker === "gemini") {
4142
+ cleaned = cleaned.split("\n")
4143
+ .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))
4144
+ .join("\n").trim();
4145
+ }
4146
+ resolve(cleaned);
3945
4147
  });
3946
4148
 
3947
4149
  proc.on("error", (err) => {