@dmsdc-ai/aigentry-deliberation 0.0.38 → 0.0.40

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
@@ -22,9 +22,10 @@ if (_cliArg === "--help" || _cliArg === "-h") {
22
22
  MCP Deliberation Server
23
23
 
24
24
  Usage:
25
- npx @dmsdc-ai/aigentry-deliberation install Install (register MCP server)
26
- npx @dmsdc-ai/aigentry-deliberation uninstall Uninstall
25
+ npx --yes --package @dmsdc-ai/aigentry-deliberation deliberation-install Install (preferred)
26
+ npx --yes --package @dmsdc-ai/aigentry-deliberation deliberation-install --uninstall
27
27
  npx @dmsdc-ai/aigentry-deliberation Run MCP server (stdio)
28
+ npx --yes --package @dmsdc-ai/aigentry-deliberation deliberation-doctor Diagnose MCP wiring
28
29
 
29
30
  After installation, restart Claude Code to start using it.
30
31
  `);
@@ -68,14 +69,159 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
68
69
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
69
70
  import { z } from "zod";
70
71
  import { execFileSync, spawn } from "child_process";
71
- import { createHash } from "crypto";
72
72
  import fs from "fs";
73
73
  import path from "path";
74
74
  import { fileURLToPath } from "url";
75
75
  import os from "os";
76
- import WebSocket from "ws";
77
76
  import { OrchestratedBrowserPort } from "./browser-control-port.js";
78
77
  import { getModelSelectionForTurn } from "./model-router.js";
78
+ import {
79
+ initSpeakerDeps,
80
+ // Constants
81
+ DEFAULT_SPEAKERS,
82
+ DEFAULT_CLI_CANDIDATES,
83
+ MAX_AUTO_DISCOVERED_SPEAKERS,
84
+ DEFAULT_BROWSER_APPS,
85
+ DEFAULT_LLM_DOMAINS,
86
+ DEFAULT_WEB_SPEAKERS,
87
+ SPEAKER_SELECTION_FILE,
88
+ SPEAKER_SELECTION_TTL_MS,
89
+ CLI_INVOCATION_HINTS,
90
+ ROLE_KEYWORDS,
91
+ ROLE_HEADING_MARKERS,
92
+ DEGRADATION_TIERS,
93
+ TRANSPORT_TYPES,
94
+ // Utility
95
+ commandExistsInPath,
96
+ shellQuote,
97
+ // Speaker normalization & ordering
98
+ normalizeSpeaker,
99
+ dedupeSpeakers,
100
+ selectNextSpeaker,
101
+ loadRolePrompt,
102
+ inferSuggestedRole,
103
+ parseVotes,
104
+ loadRolePresets,
105
+ applyRolePreset,
106
+ loadExtensionProviderRegistry,
107
+ isExtensionLlmTab,
108
+ // Speaker selection tokens
109
+ createSelectionToken,
110
+ issueSpeakerSelectionToken,
111
+ loadSpeakerSelectionToken,
112
+ clearSpeakerSelectionToken,
113
+ validateSpeakerSelectionSnapshot,
114
+ confirmSpeakerSelectionToken,
115
+ validateSpeakerSelectionRequest,
116
+ // Browser participant helpers
117
+ hasExplicitBrowserParticipantSelection,
118
+ resolveIncludeBrowserSpeakers,
119
+ // CLI discovery
120
+ resolveCliCandidates,
121
+ checkCliLiveness,
122
+ discoverLocalCliSpeakers,
123
+ detectCallerSpeaker,
124
+ // URL / domain helpers
125
+ isLlmUrl,
126
+ dedupeBrowserTabs,
127
+ parseInjectedBrowserTabsFromEnv,
128
+ // CDP helpers
129
+ normalizeCdpEndpoint,
130
+ resolveCdpEndpoints,
131
+ fetchJson,
132
+ inferBrowserFromCdpEndpoint,
133
+ summarizeFailures,
134
+ // Browser LLM tab collection
135
+ collectBrowserLlmTabsViaCdp,
136
+ ensureCdpAvailable,
137
+ collectBrowserLlmTabsViaAppleScript,
138
+ collectBrowserLlmTabs,
139
+ // LLM provider inference
140
+ inferLlmProvider,
141
+ // Speaker candidate collection
142
+ collectSpeakerCandidates,
143
+ formatSpeakerCandidatesReport,
144
+ mapParticipantProfiles,
145
+ // Speaker ordering
146
+ buildSpeakerOrder,
147
+ normalizeSessionActors,
148
+ // Transport routing
149
+ resolveTransportForSpeaker,
150
+ formatTransportGuidance,
151
+ // Degradation
152
+ detectDegradationLevels,
153
+ formatDegradationReport,
154
+ } from "./lib/speaker-discovery.js";
155
+ import {
156
+ initSessionDeps,
157
+ generateSessionId,
158
+ generateTurnId,
159
+ detectContextDirs,
160
+ readContextFromDirs,
161
+ getArchiveDir,
162
+ findSessionRecord,
163
+ ensureDirs,
164
+ loadSession,
165
+ saveSession,
166
+ listActiveSessions,
167
+ resolveSessionId,
168
+ syncMarkdown,
169
+ cleanupSyncMarkdown,
170
+ formatSourceMetadataLine,
171
+ stateToMarkdown,
172
+ archiveState,
173
+ multipleSessionsError,
174
+ truncatePromptText,
175
+ getPromptBudgetForSpeaker,
176
+ formatRecentLogForPrompt,
177
+ buildActiveReportingSection,
178
+ buildClipboardTurnPrompt,
179
+ submitDeliberationTurn,
180
+ } from "./lib/session.js";
181
+ import {
182
+ initTransportDeps,
183
+ // Constants
184
+ TMUX_SESSION,
185
+ MONITOR_SCRIPT,
186
+ MONITOR_SCRIPT_WIN,
187
+ // Terminal management
188
+ tmuxWindowName,
189
+ appleScriptQuote,
190
+ tryExecFile,
191
+ resolveMonitorShell,
192
+ buildMonitorCommand,
193
+ buildMonitorCommandWindows,
194
+ hasTmuxSession,
195
+ hasTmuxWindow,
196
+ tmuxHasAttachedClients,
197
+ isTmuxWindowViewed,
198
+ tmuxWindowCount,
199
+ buildTmuxAttachCommand,
200
+ listPhysicalTerminalWindowIds,
201
+ openPhysicalTerminal,
202
+ spawnMonitorTerminal,
203
+ closePhysicalTerminal,
204
+ closeMonitorTerminal,
205
+ getSessionWindowIds,
206
+ closeAllMonitorTerminals,
207
+ // Browser port singleton
208
+ getBrowserPort,
209
+ // CLI auto-turn helpers
210
+ getCliAutoTurnTimeoutSec,
211
+ getCliExecArgs,
212
+ buildCliAutoTurnFailureText,
213
+ // Auto-turn execution core
214
+ runCliAutoTurnCore,
215
+ runBrowserAutoTurnCore,
216
+ runTeleptyBusAutoTurnCore,
217
+ runUntilBlockedCore,
218
+ generateAutoSynthesis,
219
+ runAutoHandoff,
220
+ // Review helpers
221
+ invokeCliReviewer,
222
+ buildReviewPrompt,
223
+ synthesizeReviews,
224
+ } from "./lib/transport.js";
79
225
  import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
80
226
  import {
81
227
  DECISION_STAGES, STAGE_TRANSITIONS,
@@ -85,6 +231,63 @@ import {
85
231
  loadTemplates, matchTemplate,
86
232
  } from "./decision-engine.js";
87
233
  import { detectLang, t } from "./i18n.js";
234
+ import { checkToolEntitlement } from "./lib/entitlement.js";
235
+ import {
236
+ initTeleptyDeps,
237
+ // Schemas
238
+ StructuredActionableTaskSchema,
239
+ StructuredExperimentOutcomeSchema,
240
+ StructuredSynthesisSchema,
241
+ StructuredExecutionContractSchema,
242
+ TeleptyEnvelopeSchema,
243
+ TeleptyTurnRequestPayloadSchema,
244
+ TeleptyTurnCompletedPayloadSchema,
245
+ TeleptyDeliberationCompletedPayloadSchema,
246
+ TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS,
247
+ // Constants
248
+ TELEPTY_CONFIG_FILE,
249
+ TELEPTY_DEFAULT_HOST,
250
+ TELEPTY_PORT,
251
+ TELEPTY_TRANSPORT_TIMEOUT_MS,
252
+ TELEPTY_SEMANTIC_TIMEOUT_MS,
253
+ TELEPTY_BUS_RECONNECT_MS,
254
+ TELEPTY_SESSION_HEALTH_STALE_MS,
255
+ // State
256
+ teleptyBusState,
257
+ pendingTeleptyTurnRequests,
258
+ // Functions
259
+ hashPromptText,
260
+ sortJsonValue,
261
+ hashStructuredSynthesis,
262
+ buildExecutionContract,
263
+ createEnvelopeId,
264
+ validateTeleptyEnvelope,
265
+ resolveTeleptySourceHost,
266
+ buildTeleptyEnvelope,
267
+ buildTeleptyTurnRequestEnvelope,
268
+ buildTeleptyTurnCompletedEnvelope,
269
+ buildTeleptySynthesisEnvelope,
270
+ resolveTeleptyBusUrl,
271
+ cleanupPendingTeleptyTurn,
272
+ registerPendingTeleptyTurnRequest,
273
+ ackPendingTeleptyTurn,
274
+ completePendingTeleptySemantic,
275
+ updateTeleptySessionHealth,
276
+ getTeleptySessionHealth,
277
+ handleTeleptyBusMessage,
278
+ ensureTeleptyBusSubscriber,
279
+ callBrainIngest,
280
+ notifyTeleptyBus,
281
+ getDefaultOrchestratorSessionId,
282
+ buildTurnCompletionNotificationText,
283
+ notifyTeleptySessionInject,
284
+ dispatchTeleptyTurnRequest,
285
+ loadTeleptyAuthToken,
286
+ formatTeleptyHostLabel,
287
+ collectTeleptySessions,
288
+ scoreTeleptyProcessMatch,
289
+ collectTeleptyProcessLocators,
290
+ } from "./lib/telepty.js";
88
291
 
89
292
  // ── Paths ──────────────────────────────────────────────────────
90
293
 
@@ -97,29 +300,6 @@ const GLOBAL_STATE_DIR = path.join(INSTALL_DIR, "state");
97
300
  const GLOBAL_RUNTIME_LOG = path.join(INSTALL_DIR, "runtime.log");
98
301
  const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
99
302
  const OBSIDIAN_PROJECTS = path.join(OBSIDIAN_VAULT, "10-Projects");
100
- const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
101
- const DEFAULT_CLI_CANDIDATES = [
102
- "claude",
103
- "codex",
104
- "gemini",
105
- "qwen",
106
- "chatgpt",
107
- "aider",
108
- "llm",
109
- "opencode",
110
- "cursor-agent",
111
- "cursor",
112
- "continue",
113
- ];
114
- const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
115
- const TELEPTY_CONFIG_FILE = path.join(HOME, ".telepty", "config.json");
116
- const TELEPTY_DEFAULT_HOST = process.env.TELEPTY_HOST || "127.0.0.1";
117
- const TELEPTY_PORT = Number(process.env.TELEPTY_PORT || 3848);
118
- const TELEPTY_TRANSPORT_TIMEOUT_MS = 5_000;
119
- const TELEPTY_SEMANTIC_TIMEOUT_MS = 60_000;
120
- const TELEPTY_BUS_RECONNECT_MS = 5_000;
121
- const TELEPTY_SESSION_HEALTH_STALE_MS = 25_000;
122
-
123
303
  function loadDeliberationConfig() {
124
304
  const configPath = path.join(INSTALL_DIR, "config.json");
125
305
  try {
@@ -131,283 +311,16 @@ function loadDeliberationConfig() {
131
311
 
132
312
  function saveDeliberationConfig(config) {
133
313
  const configPath = path.join(INSTALL_DIR, "config.json");
314
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
134
315
  config.updated = new Date().toISOString();
135
316
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
136
317
  }
137
318
 
138
- const StructuredActionableTaskSchema = z.object({
139
- id: z.number(),
140
- task: z.string(),
141
- files: z.array(z.string()).optional(),
142
- project: z.string().optional(),
143
- priority: z.enum(["high", "medium", "low"]).optional(),
144
- });
145
-
146
- const StructuredSynthesisSchema = z.object({
147
- summary: z.string(),
148
- decisions: z.array(z.string()),
149
- actionable_tasks: z.array(StructuredActionableTaskSchema),
150
- });
151
-
152
- const TeleptyEnvelopeSchema = z.object({
153
- message_id: z.string().min(1),
154
- session_id: z.string().min(1),
155
- project: z.string().min(1),
156
- kind: z.string().min(1),
157
- source: z.string().min(1),
158
- target: z.string().min(1),
159
- reply_to: z.string().nullable().optional(),
160
- trace: z.array(z.string()),
161
- payload: z.unknown(),
162
- ts: z.string().min(1),
163
- });
164
-
165
- const TeleptyTurnRequestPayloadSchema = z.object({
166
- turn_id: z.string().min(1),
167
- round: z.number().int().positive(),
168
- max_rounds: z.number().int().positive(),
169
- speaker: z.string().min(1),
170
- role: z.string().nullable().optional(),
171
- prompt: z.string().min(1),
172
- prompt_sha1: z.string().length(40),
173
- history_entries: z.number().int().nonnegative().optional(),
174
- transport_timeout_ms: z.number().int().positive(),
175
- semantic_timeout_ms: z.number().int().positive(),
176
- });
177
-
178
- const TeleptyDeliberationCompletedPayloadSchema = z.object({
179
- topic: z.string(),
180
- synthesis: z.string(),
181
- structured_synthesis: StructuredSynthesisSchema.nullable().optional(),
182
- });
183
-
184
- const TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS = {
185
- turn_request: TeleptyTurnRequestPayloadSchema,
186
- deliberation_completed: TeleptyDeliberationCompletedPayloadSchema,
187
- };
188
-
189
- const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
190
- const DEFAULT_LLM_DOMAINS = [
191
- "chatgpt.com",
192
- "openai.com",
193
- "claude.ai",
194
- "anthropic.com",
195
- "gemini.google.com",
196
- "copilot.microsoft.com",
197
- "poe.com",
198
- "perplexity.ai",
199
- "mistral.ai",
200
- "huggingface.co/chat",
201
- "deepseek.com",
202
- "qwen.ai",
203
- "notebooklm.google.com",
204
- ];
205
-
206
- // Well-known web LLMs — always available as speaker candidates regardless of browser detection.
207
- // When a matching browser tab is detected, transport upgrades to browser_auto (CDP) or clipboard.
208
- // When no tab is detected, transport falls back to clipboard (manual paste).
209
- const DEFAULT_WEB_SPEAKERS = [
210
- { speaker: "web-chatgpt", provider: "chatgpt", name: "ChatGPT", url: "https://chatgpt.com" },
211
- { speaker: "web-claude", provider: "claude", name: "Claude", url: "https://claude.ai" },
212
- { speaker: "web-gemini", provider: "gemini", name: "Gemini", url: "https://gemini.google.com" },
213
- { speaker: "web-copilot", provider: "copilot", name: "Copilot", url: "https://copilot.microsoft.com" },
214
- { speaker: "web-perplexity", provider: "perplexity", name: "Perplexity", url: "https://perplexity.ai" },
215
- { speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://chat.deepseek.com" },
216
- { speaker: "web-mistral", provider: "mistral", name: "Mistral", url: "https://mistral.ai" },
217
- { speaker: "web-poe", provider: "poe", name: "Poe", url: "https://poe.com" },
218
- { speaker: "web-grok", provider: "grok", name: "Grok", url: "https://grok.com" },
219
- { speaker: "web-qwen", provider: "qwen", name: "Qwen", url: "https://chat.qwen.ai" },
220
- { speaker: "web-huggingchat", provider: "huggingchat", name: "HuggingChat", url: "https://huggingface.co/chat" },
221
- ];
222
-
223
- let _extensionProviderRegistry = null;
224
- const __dirnameEsm = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
225
- function loadExtensionProviderRegistry() {
226
- if (_extensionProviderRegistry) return _extensionProviderRegistry;
227
- try {
228
- const registryPath = path.join(__dirnameEsm, "selectors", "extension-providers.json");
229
- _extensionProviderRegistry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
230
- return _extensionProviderRegistry;
231
- } catch (err) {
232
- console.error("Failed to load extension-providers.json:", err.message);
233
- _extensionProviderRegistry = { providers: [] };
234
- return _extensionProviderRegistry;
235
- }
236
- }
237
-
238
- function isExtensionLlmTab(url = "", title = "") {
239
- if (!String(url).startsWith("chrome-extension://")) return false;
240
- const registry = loadExtensionProviderRegistry();
241
- const lowerTitle = String(title || "").toLowerCase();
242
- if (!lowerTitle) return false;
243
- return registry.providers.some(p =>
244
- p.titlePatterns.some(pattern => lowerTitle.includes(pattern.toLowerCase()))
245
- );
246
- }
247
-
248
- // ── Sprint 1: Smart Speaker Ordering + Persona Roles ────────────
249
-
250
- function selectNextSpeaker(session) {
251
- const { speakers, current_speaker, log, ordering_strategy } = session;
252
- switch (ordering_strategy || "cyclic") {
253
- case "random":
254
- return speakers[Math.floor(Math.random() * speakers.length)];
255
- case "weighted-random": {
256
- const window = log.slice(-(speakers.length * 2));
257
- const counts = new Map(speakers.map(s => [s, 0]));
258
- for (const entry of window) {
259
- if (counts.has(entry.speaker)) counts.set(entry.speaker, counts.get(entry.speaker) + 1);
260
- }
261
- const maxCount = Math.max(...counts.values(), 1);
262
- const weights = speakers.map(s => maxCount + 1 - counts.get(s));
263
- const total = weights.reduce((a, b) => a + b, 0);
264
- let r = Math.random() * total;
265
- for (let i = 0; i < speakers.length; i++) {
266
- r -= weights[i];
267
- if (r <= 0) return speakers[i];
268
- }
269
- return speakers[speakers.length - 1];
270
- }
271
- case "cyclic":
272
- default: {
273
- const idx = speakers.indexOf(current_speaker);
274
- return speakers[(idx + 1) % speakers.length];
275
- }
276
- }
277
- }
278
-
279
- function loadRolePrompt(role) {
280
- if (!role || role === "free") return "";
281
- try {
282
- const promptPath = path.join(__dirnameEsm, "selectors", "roles", `${role}.md`);
283
- return fs.readFileSync(promptPath, "utf-8").trim();
284
- } catch {
285
- return "";
286
- }
287
- }
288
-
289
- const ROLE_KEYWORDS = {
290
- critic: /문제|위험|실패|약점|리스크|반대|비판|결함|취약/,
291
- implementer: /구현|코드|방법|설계|빌드|개발|함수|모듈|파일/,
292
- mediator: /합의|정리|결론|종합|요약|중재|절충|균형/,
293
- researcher: /사례|데이터|연구|벤치마크|비교|논문|참고/,
294
- };
295
-
296
- const ROLE_HEADING_MARKERS = {
297
- critic: /^##?\s*(Critic|비판|약점|심각도|위험\s*분석|검증|평가|Review)/m,
298
- implementer: /^##?\s*(코드\s*스케치|구현|Implementation|제안\s*코드)/m,
299
- mediator: /^##?\s*(합의|종합|중재|Consensus|Mediation)/m,
300
- researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s*연구|근거|데이터|Data)/m,
301
- };
302
-
303
- function inferSuggestedRole(text) {
304
- const scores = {};
305
- for (const [role, pattern] of Object.entries(ROLE_KEYWORDS)) {
306
- const matches = (text.match(new RegExp(pattern, "g")) || []).length;
307
- if (matches > 0) scores[role] = matches;
308
- }
309
- // Structural heading markers get extra weight (equivalent to 5 keyword matches)
310
- for (const [role, pattern] of Object.entries(ROLE_HEADING_MARKERS)) {
311
- if (pattern.test(text)) {
312
- scores[role] = (scores[role] || 0) + 8;
313
- }
314
- }
315
- if (Object.keys(scores).length === 0) return "free";
316
- return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
317
- }
318
-
319
- function parseVotes(text) {
320
- const votes = [];
321
- for (const line of text.split("\n")) {
322
- const agree = line.match(/\[AGREE\]/i);
323
- const disagree = line.match(/\[DISAGREE\]/i);
324
- const conditional = line.match(/\[CONDITIONAL:\s*(.+?)\]/i);
325
- if (agree) votes.push({ line: line.trim(), vote: "agree" });
326
- else if (disagree) votes.push({ line: line.trim(), vote: "disagree" });
327
- else if (conditional) votes.push({ line: line.trim(), vote: "conditional", condition: conditional[1].trim() });
328
- }
329
- return votes;
330
- }
331
-
332
- let _rolePresetsCache = null;
333
- function loadRolePresets() {
334
- if (_rolePresetsCache) return _rolePresetsCache;
335
- try {
336
- const presetsPath = path.join(__dirnameEsm, "selectors", "role-presets.json");
337
- _rolePresetsCache = JSON.parse(fs.readFileSync(presetsPath, "utf-8"));
338
- return _rolePresetsCache;
339
- } catch {
340
- _rolePresetsCache = { presets: {} };
341
- return _rolePresetsCache;
342
- }
343
- }
344
-
345
- function applyRolePreset(preset, speakers) {
346
- const presets = loadRolePresets();
347
- const presetDef = presets.presets[preset];
348
- if (!presetDef) return {};
349
-
350
- const roles = presetDef.roles;
351
- const result = {};
352
- for (let i = 0; i < speakers.length; i++) {
353
- result[speakers[i]] = roles[i % roles.length];
354
- }
355
- return result;
356
- }
357
-
358
- // ── Graceful Degradation Matrix ──────────────────────────────────
359
-
360
- const DEGRADATION_TIERS = {
361
- monitoring: {
362
- tier1: { name: "tmux", description: "tmux real-time monitoring window", check: () => commandExistsInPath("tmux") },
363
- tier2: { name: "logfile", description: "Log file tail monitoring", check: () => true },
364
- tier3: { name: "silent", description: "No monitoring (log only)", check: () => true },
365
- },
366
- browser: {
367
- tier1: { name: "cdp_auto", description: "CDP auto send/collect", check: async () => { try { const res = await fetch("http://127.0.0.1:9222/json/version", { signal: AbortSignal.timeout(2000) }); return res.ok; } catch { return false; } } },
368
- tier2: { name: "clipboard", description: "Clipboard-based manual transfer", check: () => true },
369
- tier3: { name: "manual", description: "Fully manual copy/paste", check: () => true },
370
- },
371
- terminal: {
372
- tier1: { name: "auto_open", description: "Auto-open terminal app", check: () => process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" },
373
- tier2: { name: "none", description: "Cannot auto-open terminal", check: () => true },
374
- tier3: { name: "none", description: "Cannot auto-open terminal", check: () => true },
375
- },
376
- };
377
-
378
- async function detectDegradationLevels() {
379
- const levels = {};
380
- for (const [feature, tiers] of Object.entries(DEGRADATION_TIERS)) {
381
- for (const tierKey of ["tier1", "tier2", "tier3"]) {
382
- const tier = tiers[tierKey];
383
- const available = await Promise.resolve(tier.check());
384
- if (available) {
385
- levels[feature] = { tier: tierKey, name: tier.name, description: tier.description };
386
- break;
387
- }
388
- }
389
- }
390
- return levels;
391
- }
392
-
393
- function formatDegradationReport(levels) {
394
- const lines = [];
395
- for (const [feature, info] of Object.entries(levels)) {
396
- const tierNum = parseInt(info.tier.replace("tier", ""));
397
- const indicator = tierNum === 1 ? "🟢" : tierNum === 2 ? "🟡" : "🔴";
398
- lines.push(` ${indicator} **${feature}**: ${info.name} — ${info.description}`);
399
- }
400
- return lines.join("\n");
401
- }
402
-
403
319
  const PRODUCT_DISCLAIMER = "ℹ️ This tool does not permanently modify external websites. It reads browser context in read-only mode to route speakers.";
404
320
  const LOCKS_SUBDIR = ".locks";
405
321
  const LOCK_RETRY_MS = 25;
406
322
  const LOCK_TIMEOUT_MS = 8000;
407
323
  const LOCK_STALE_MS = 60000;
408
- const SPEAKER_SELECTION_FILE = "speaker-selection.json";
409
- const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
410
-
411
324
  function getProjectSlug() {
412
325
  return path.basename(process.cwd());
413
326
  }
@@ -452,387 +365,8 @@ function listStateProjects() {
452
365
  }
453
366
  }
454
367
 
455
- function findSessionRecord(sessionRef, { preferProject, activeOnly = false } = {}) {
456
- if (!sessionRef) return null;
457
-
458
- if (typeof sessionRef === "object" && sessionRef !== null && sessionRef.id) {
459
- const project = getSessionProject(sessionRef, preferProject);
460
- const file = getSessionFile(sessionRef.id, project);
461
- const state = readJsonFileSafe(file);
462
- if (!state) return null;
463
- const normalized = normalizeSessionActors(state);
464
- if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
465
- return null;
466
- }
467
- return { file, project, state: normalized };
468
- }
469
-
470
- const sessionId = String(sessionRef);
471
- const preferred = normalizeProjectSlug(preferProject);
472
- const projects = [...new Set([preferred, ...listStateProjects()])];
473
- for (const project of projects) {
474
- const file = getSessionFile(sessionId, project);
475
- const state = readJsonFileSafe(file);
476
- if (!state) continue;
477
- const normalized = normalizeSessionActors(state);
478
- if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
479
- continue;
480
- }
481
- return { file, project: normalized.project || project, state: normalized };
482
- }
483
- return null;
484
- }
485
-
486
- const teleptyBusState = {
487
- ws: null,
488
- status: "idle",
489
- connectPromise: null,
490
- reconnectTimer: null,
491
- lastError: null,
492
- lastConnectedAt: null,
493
- lastMessageAt: null,
494
- healthBySession: new Map(),
495
- };
496
-
497
- const pendingTeleptyTurnRequests = new Map();
498
-
499
- function hashPromptText(value) {
500
- return createHash("sha1").update(String(value || "")).digest("hex");
501
- }
502
-
503
- function createEnvelopeId(prefix = "env") {
504
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
505
- }
506
-
507
- function validateTeleptyEnvelope(envelope) {
508
- const parsed = TeleptyEnvelopeSchema.parse(envelope);
509
- const payloadSchema = TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS[parsed.kind];
510
- if (payloadSchema) {
511
- payloadSchema.parse(parsed.payload);
512
- }
513
- return parsed;
514
- }
515
-
516
- function buildTeleptyEnvelope({ session_id, project, kind, source, target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
517
- return validateTeleptyEnvelope({
518
- message_id,
519
- session_id,
520
- project,
521
- kind,
522
- source,
523
- target,
524
- reply_to,
525
- trace,
526
- payload,
527
- ts,
528
- });
529
- }
530
-
531
- function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, includeHistoryEntries = 0, profile }) {
532
- const role = (state.speaker_roles || {})[speaker] || null;
533
- const target = profile?.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
534
- ? `${profile.telepty_session_id}@${profile.telepty_host}`
535
- : profile?.telepty_session_id || speaker;
536
- return buildTeleptyEnvelope({
537
- session_id: state.id,
538
- project: state.project || getProjectSlug(),
539
- kind: "turn_request",
540
- source: `deliberation:${state.id}`,
541
- target,
542
- reply_to: state.id,
543
- trace: [
544
- `project:${state.project || getProjectSlug()}`,
545
- `speaker:${speaker}`,
546
- `turn:${turnId}`,
547
- ],
548
- payload: {
549
- turn_id: turnId,
550
- round: state.current_round,
551
- max_rounds: state.max_rounds,
552
- speaker,
553
- role,
554
- prompt: turnPrompt,
555
- prompt_sha1: hashPromptText(turnPrompt),
556
- history_entries: includeHistoryEntries,
557
- transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
558
- semantic_timeout_ms: TELEPTY_SEMANTIC_TIMEOUT_MS,
559
- },
560
- });
561
- }
562
-
563
- function buildTeleptySynthesisEnvelope({ state, synthesis, structured }) {
564
- return buildTeleptyEnvelope({
565
- session_id: state.id,
566
- project: state.project || getProjectSlug(),
567
- kind: "deliberation_completed",
568
- source: `deliberation:${state.id}`,
569
- target: "telepty-bus",
570
- reply_to: state.id,
571
- trace: [
572
- `project:${state.project || getProjectSlug()}`,
573
- "stage:synthesis",
574
- ],
575
- payload: {
576
- topic: state.topic,
577
- synthesis,
578
- structured_synthesis: structured || null,
579
- },
580
- });
581
- }
582
-
583
- function resolveTeleptyBusUrl(host = TELEPTY_DEFAULT_HOST) {
584
- const url = new URL(`ws://${host}:${TELEPTY_PORT}/api/bus`);
585
- const token = loadTeleptyAuthToken();
586
- if (token) {
587
- url.searchParams.set("token", token);
588
- }
589
- return url.toString();
590
- }
591
-
592
- function cleanupPendingTeleptyTurn(messageId) {
593
- const entry = pendingTeleptyTurnRequests.get(messageId);
594
- if (!entry) return;
595
- if (entry.transportTimer) clearTimeout(entry.transportTimer);
596
- if (entry.semanticTimer) clearTimeout(entry.semanticTimer);
597
- pendingTeleptyTurnRequests.delete(messageId);
598
- }
599
-
600
- function registerPendingTeleptyTurnRequest({ envelope, profile, speaker }) {
601
- const nowMs = Date.now();
602
- const entry = {
603
- message_id: envelope.message_id,
604
- deliberation_session_id: envelope.session_id,
605
- project: envelope.project,
606
- speaker,
607
- turn_id: envelope.payload.turn_id,
608
- target_session_id: profile?.telepty_session_id || speaker,
609
- target_host: profile?.telepty_host || TELEPTY_DEFAULT_HOST,
610
- prompt_sha1: envelope.payload.prompt_sha1,
611
- published_at: envelope.ts,
612
- transport_status: "pending",
613
- semantic_status: "pending",
614
- transport_deadline_at: new Date(nowMs + TELEPTY_TRANSPORT_TIMEOUT_MS).toISOString(),
615
- semantic_deadline_at: new Date(nowMs + TELEPTY_SEMANTIC_TIMEOUT_MS).toISOString(),
616
- };
617
- entry.transportPromise = new Promise(resolve => {
618
- entry.resolveTransport = resolve;
619
- });
620
- entry.semanticPromise = new Promise(resolve => {
621
- entry.resolveSemantic = resolve;
622
- });
623
- entry.transportTimer = setTimeout(() => {
624
- if (entry.transport_status !== "pending") return;
625
- entry.transport_status = "timeout";
626
- appendRuntimeLog("WARN", `TELEPTY_TRANSPORT_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
627
- entry.resolveTransport?.({ ok: false, code: "transport_timeout" });
628
- }, TELEPTY_TRANSPORT_TIMEOUT_MS);
629
- entry.semanticTimer = setTimeout(() => {
630
- if (entry.semantic_status !== "pending") return;
631
- entry.semantic_status = "timeout";
632
- appendRuntimeLog("WARN", `TELEPTY_SEMANTIC_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
633
- entry.resolveSemantic?.({ ok: false, code: "semantic_timeout" });
634
- setTimeout(() => cleanupPendingTeleptyTurn(entry.message_id), 5_000);
635
- }, TELEPTY_SEMANTIC_TIMEOUT_MS);
636
- pendingTeleptyTurnRequests.set(entry.message_id, entry);
637
- return entry;
638
- }
639
-
640
- function ackPendingTeleptyTurn(event) {
641
- const promptHash = hashPromptText(event?.content || "");
642
- const targetSessionId = String(event?.target_agent || "");
643
- const candidate = [...pendingTeleptyTurnRequests.values()]
644
- .filter(entry =>
645
- entry.transport_status === "pending"
646
- && entry.target_session_id === targetSessionId
647
- && entry.prompt_sha1 === promptHash
648
- )
649
- .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
650
- if (!candidate) return null;
651
-
652
- candidate.transport_status = "ack";
653
- candidate.inject_id = event.inject_id || null;
654
- candidate.transport_acked_at = new Date().toISOString();
655
- if (candidate.transportTimer) clearTimeout(candidate.transportTimer);
656
- candidate.resolveTransport?.({
657
- ok: true,
658
- code: "inject_written",
659
- inject_id: event.inject_id || null,
660
- });
661
- appendRuntimeLog("INFO", `TELEPTY_TRANSPORT_ACK: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id} | inject_id: ${event.inject_id || "n/a"}`);
662
- return candidate;
663
- }
664
-
665
- function completePendingTeleptySemantic({ sessionId, speaker, turnId }) {
666
- const candidate = [...pendingTeleptyTurnRequests.values()]
667
- .filter(entry =>
668
- entry.semantic_status === "pending"
669
- && entry.deliberation_session_id === sessionId
670
- && normalizeSpeaker(entry.speaker) === normalizeSpeaker(speaker)
671
- && (!turnId || !entry.turn_id || entry.turn_id === turnId)
672
- )
673
- .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
674
- if (!candidate) return null;
675
-
676
- candidate.semantic_status = "completed";
677
- candidate.semantic_completed_at = new Date().toISOString();
678
- if (candidate.semanticTimer) clearTimeout(candidate.semanticTimer);
679
- candidate.resolveSemantic?.({ ok: true, code: "responded" });
680
- appendRuntimeLog("INFO", `TELEPTY_SEMANTIC_COMPLETE: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id}`);
681
- setTimeout(() => cleanupPendingTeleptyTurn(candidate.message_id), 5_000);
682
- return candidate;
683
- }
684
-
685
- function updateTeleptySessionHealth(event) {
686
- const sessionId = event?.session_id;
687
- if (!sessionId) return null;
688
- const health = {
689
- session_id: sessionId,
690
- payload: event.payload || {},
691
- timestamp: event.timestamp || new Date().toISOString(),
692
- seen_at: new Date().toISOString(),
693
- };
694
- teleptyBusState.healthBySession.set(sessionId, health);
695
- return health;
696
- }
697
-
698
- function getTeleptySessionHealth(sessionId, nowMs = Date.now()) {
699
- const entry = teleptyBusState.healthBySession.get(sessionId);
700
- if (!entry) return null;
701
- const seenAtMs = Date.parse(entry.seen_at || entry.timestamp || "");
702
- const ageMs = Number.isFinite(seenAtMs) ? nowMs - seenAtMs : null;
703
- return {
704
- ...entry,
705
- age_ms: ageMs,
706
- stale: Number.isFinite(ageMs) ? ageMs > TELEPTY_SESSION_HEALTH_STALE_MS : true,
707
- };
708
- }
709
-
710
- function handleTeleptyBusMessage(raw) {
711
- let parsed = null;
712
- try {
713
- parsed = JSON.parse(String(raw));
714
- } catch {
715
- return null;
716
- }
717
- teleptyBusState.lastMessageAt = new Date().toISOString();
718
- if (!parsed || typeof parsed !== "object") return null;
719
-
720
- if (parsed.type === "inject_written") {
721
- return ackPendingTeleptyTurn(parsed);
722
- }
723
- if (parsed.type === "session_health") {
724
- return updateTeleptySessionHealth(parsed);
725
- }
726
- return parsed;
727
- }
728
-
729
- async function ensureTeleptyBusSubscriber() {
730
- if (teleptyBusState.ws && teleptyBusState.ws.readyState === WebSocket.OPEN) {
731
- return { ok: true, status: "open" };
732
- }
733
- if (teleptyBusState.connectPromise) {
734
- return teleptyBusState.connectPromise;
735
- }
736
-
737
- teleptyBusState.connectPromise = new Promise((resolve) => {
738
- try {
739
- let settled = false;
740
- const finish = (result) => {
741
- if (settled) return;
742
- settled = true;
743
- resolve(result);
744
- };
745
- teleptyBusState.status = "connecting";
746
- const ws = new WebSocket(resolveTeleptyBusUrl());
747
- teleptyBusState.ws = ws;
748
-
749
- ws.once("open", () => {
750
- teleptyBusState.status = "open";
751
- teleptyBusState.lastConnectedAt = new Date().toISOString();
752
- teleptyBusState.lastError = null;
753
- appendRuntimeLog("INFO", "TELEPTY_BUS_CONNECTED");
754
- finish({ ok: true, status: "open" });
755
- });
756
-
757
- ws.on("message", (data) => {
758
- handleTeleptyBusMessage(data.toString());
759
- });
760
-
761
- ws.on("error", (err) => {
762
- teleptyBusState.lastError = String(err?.message || err);
763
- appendRuntimeLog("WARN", `TELEPTY_BUS_ERROR: ${teleptyBusState.lastError}`);
764
- if (ws.readyState !== WebSocket.OPEN) {
765
- teleptyBusState.status = "error";
766
- teleptyBusState.ws = null;
767
- teleptyBusState.connectPromise = null;
768
- finish({ ok: false, status: "error", error: teleptyBusState.lastError });
769
- }
770
- });
771
-
772
- ws.on("close", () => {
773
- teleptyBusState.status = "closed";
774
- teleptyBusState.ws = null;
775
- teleptyBusState.connectPromise = null;
776
- if (!settled) {
777
- finish({ ok: false, status: "closed", error: teleptyBusState.lastError || "socket closed" });
778
- }
779
- if (!teleptyBusState.reconnectTimer) {
780
- teleptyBusState.reconnectTimer = setTimeout(() => {
781
- teleptyBusState.reconnectTimer = null;
782
- ensureTeleptyBusSubscriber().catch(() => {});
783
- }, TELEPTY_BUS_RECONNECT_MS);
784
- }
785
- });
786
- } catch (err) {
787
- teleptyBusState.status = "error";
788
- teleptyBusState.lastError = String(err?.message || err);
789
- teleptyBusState.connectPromise = null;
790
- resolve({ ok: false, status: "error", error: teleptyBusState.lastError });
791
- }
792
- });
793
-
794
- const result = await teleptyBusState.connectPromise;
795
- if (!result.ok) {
796
- teleptyBusState.connectPromise = null;
797
- } else if (teleptyBusState.ws?.readyState === WebSocket.OPEN) {
798
- teleptyBusState.connectPromise = null;
799
- }
800
- return result;
801
- }
802
-
803
- async function notifyTeleptyBus(event) {
804
- const host = process.env.TELEPTY_HOST || "localhost";
805
- const port = process.env.TELEPTY_PORT || "3848";
806
- const token = loadTeleptyAuthToken();
807
- try {
808
- const res = await fetch(`http://${host}:${port}/api/bus/publish`, {
809
- method: "POST",
810
- headers: {
811
- "Content-Type": "application/json",
812
- ...(token ? { "x-telepty-token": token } : {}),
813
- },
814
- body: JSON.stringify(event),
815
- });
816
- const data = await res.json().catch(() => null);
817
- if (res.ok) {
818
- appendRuntimeLog("INFO", `HANDOFF: Telepty bus notified: ${event.kind || event.type || "unknown"}`);
819
- return { ok: true, delivered: data?.delivered ?? null };
820
- }
821
- return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
822
- } catch (err) {
823
- appendRuntimeLog("WARN", `HANDOFF: Telepty bus notification failed: ${err.message}`);
824
- return { ok: false, error: err.message };
825
- }
826
- }
827
-
828
- function getArchiveDir(projectSlug = getProjectSlug()) {
829
- const slug = normalizeProjectSlug(projectSlug);
830
- const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
831
- if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, slug))) {
832
- return obsidianDir;
833
- }
834
- return path.join(getProjectStateDir(slug), "archive");
835
- }
368
+ // findSessionRecord moved to lib/session.js
369
+ // getArchiveDir moved to lib/session.js
836
370
 
837
371
  function getLocksDir(projectSlug = getProjectSlug()) {
838
372
  return path.join(getProjectStateDir(projectSlug), LOCKS_SUBDIR);
@@ -873,6 +407,15 @@ function appendRuntimeLog(level, message) {
873
407
 
874
408
  function safeToolHandler(toolName, handler) {
875
409
  return async (args) => {
410
+ const entitlement = checkToolEntitlement(toolName);
411
+ if (!entitlement.allowed) {
412
+ return {
413
+ content: [{
414
+ type: "text",
415
+ text: `⛔ ${entitlement.reason}. Current tier: ${entitlement.tier}.\nUpgrade: ${entitlement.upgrade_url || 'https://aigentry.dev/upgrade'}`,
416
+ }],
417
+ };
418
+ }
876
419
  try {
877
420
  return await handler(args);
878
421
  } catch (error) {
@@ -934,2447 +477,87 @@ function acquireFileLock(lockPath, {
934
477
  try {
935
478
  const stat = fs.statSync(lockPath);
936
479
  if (Date.now() - stat.mtimeMs > staleMs) {
937
- fs.unlinkSync(lockPath);
938
- continue;
939
- }
940
- } catch {
941
- // lock might have been removed concurrently
942
- }
943
-
944
- if (Date.now() - startedAt > timeoutMs) {
945
- throw new Error(`lock timeout: ${lockPath}`);
946
- }
947
- sleepMs(retryMs);
948
- }
949
- }
950
- }
951
-
952
- function releaseFileLock(lockPath, token) {
953
- try {
954
- const current = fs.readFileSync(lockPath, "utf-8").trim();
955
- if (current === token) {
956
- fs.unlinkSync(lockPath);
957
- }
958
- } catch {
959
- // already released or replaced
960
- }
961
- }
962
-
963
- function withFileLock(lockPath, fn, options) {
964
- const token = acquireFileLock(lockPath, options);
965
- try {
966
- return fn();
967
- } finally {
968
- releaseFileLock(lockPath, token);
969
- }
970
- }
971
-
972
- function withProjectLock(projectSlug, fn, options) {
973
- if (typeof projectSlug === "function") {
974
- return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
975
- }
976
- return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
977
- }
978
-
979
- function withSessionLock(sessionRef, fn, options) {
980
- const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
981
- const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
982
- const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
983
- const projectSlug = explicitProject || record?.project || getProjectSlug();
984
- const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
985
- return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
986
- }
987
-
988
- function normalizeSpeaker(raw) {
989
- if (typeof raw !== "string") return null;
990
- const normalized = raw.trim().toLowerCase();
991
- if (!normalized || normalized === "none") return null;
992
- return normalized;
993
- }
994
-
995
- function dedupeSpeakers(items = []) {
996
- const out = [];
997
- const seen = new Set();
998
- for (const item of items) {
999
- const normalized = normalizeSpeaker(item);
1000
- if (!normalized || seen.has(normalized)) continue;
1001
- seen.add(normalized);
1002
- out.push(normalized);
1003
- }
1004
- return out;
1005
- }
1006
-
1007
- function createSelectionToken() {
1008
- return `sel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1009
- }
1010
-
1011
- function issueSpeakerSelectionToken({ candidates, include_browser }) {
1012
- const selectionState = {
1013
- token: createSelectionToken(),
1014
- phase: "candidates",
1015
- created_at: new Date().toISOString(),
1016
- include_browser: !!include_browser,
1017
- candidate_speakers: dedupeSpeakers((candidates || []).map(c => typeof c === "string" ? c : c?.speaker)),
1018
- };
1019
- writeJsonFileAtomic(getSpeakerSelectionFile(), selectionState);
1020
- return selectionState;
1021
- }
1022
-
1023
- function loadSpeakerSelectionToken() {
1024
- return readJsonFileSafe(getSpeakerSelectionFile());
1025
- }
1026
-
1027
- function clearSpeakerSelectionToken() {
1028
- try {
1029
- fs.unlinkSync(getSpeakerSelectionFile());
1030
- } catch {
1031
- // ignore missing file
1032
- }
1033
- }
1034
-
1035
- function validateSpeakerSelectionSnapshot({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1036
- if (!selection_token) {
1037
- return { ok: false, code: "missing_token" };
1038
- }
1039
- if (!selectionState?.token) {
1040
- return { ok: false, code: "missing_selection_state" };
1041
- }
1042
- if (selectionState.token !== selection_token) {
1043
- return { ok: false, code: "token_mismatch" };
1044
- }
1045
-
1046
- const createdAtMs = Date.parse(selectionState.created_at || "");
1047
- if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
1048
- return { ok: false, code: "expired_token" };
1049
- }
1050
-
1051
- if (!!selectionState.include_browser !== !!includeBrowserSpeakers) {
1052
- return { ok: false, code: "mode_mismatch" };
1053
- }
1054
-
1055
- const availableSpeakers = new Set(dedupeSpeakers(selectionState.candidate_speakers || []));
1056
- const requestedSpeakers = dedupeSpeakers(speakers || []);
1057
- const missingSpeakers = requestedSpeakers.filter(speaker => !availableSpeakers.has(speaker));
1058
- if (missingSpeakers.length > 0) {
1059
- return { ok: false, code: "speaker_mismatch", missing_speakers: missingSpeakers };
1060
- }
1061
-
1062
- return { ok: true };
1063
- }
1064
-
1065
- function confirmSpeakerSelectionToken({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now(), persist = true }) {
1066
- const snapshotValidation = validateSpeakerSelectionSnapshot({
1067
- selectionState,
1068
- selection_token,
1069
- speakers,
1070
- includeBrowserSpeakers,
1071
- nowMs,
1072
- });
1073
- if (!snapshotValidation.ok) {
1074
- return snapshotValidation;
1075
- }
1076
-
1077
- const confirmedSelection = {
1078
- token: createSelectionToken(),
1079
- phase: "confirmed",
1080
- created_at: new Date(nowMs).toISOString(),
1081
- include_browser: !!includeBrowserSpeakers,
1082
- candidate_speakers: dedupeSpeakers(selectionState.candidate_speakers || []),
1083
- selected_speakers: dedupeSpeakers(speakers || []),
1084
- };
1085
- if (persist) {
1086
- writeJsonFileAtomic(getSpeakerSelectionFile(), confirmedSelection);
1087
- }
1088
- return { ok: true, selectionState: confirmedSelection };
1089
- }
1090
-
1091
- function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1092
- const snapshotValidation = validateSpeakerSelectionSnapshot({
1093
- selectionState,
1094
- selection_token,
1095
- speakers,
1096
- includeBrowserSpeakers,
1097
- nowMs,
1098
- });
1099
- if (!snapshotValidation.ok) {
1100
- return snapshotValidation;
1101
- }
1102
-
1103
- if (selectionState.phase !== "confirmed" || !Array.isArray(selectionState.selected_speakers)) {
1104
- return { ok: false, code: "selection_not_confirmed" };
1105
- }
1106
-
1107
- const expectedSpeakers = dedupeSpeakers(selectionState.selected_speakers || []);
1108
- const requestedSpeakers = dedupeSpeakers(speakers || []);
1109
- if (
1110
- expectedSpeakers.length !== requestedSpeakers.length
1111
- || expectedSpeakers.some(speaker => !requestedSpeakers.includes(speaker))
1112
- ) {
1113
- return {
1114
- ok: false,
1115
- code: "selected_speakers_mismatch",
1116
- expected_speakers: expectedSpeakers,
1117
- requested_speakers: requestedSpeakers,
1118
- };
1119
- }
1120
-
1121
- return { ok: true };
1122
- }
1123
-
1124
- function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
1125
- const manualSpeakers = Array.isArray(speakers) ? speakers : [];
1126
- const hasBrowserSpeaker = manualSpeakers.some(speaker => {
1127
- const normalized = normalizeSpeaker(speaker);
1128
- return normalized?.startsWith("web-");
1129
- });
1130
- if (hasBrowserSpeaker) return true;
1131
-
1132
- const overrides = participant_types && typeof participant_types === "object"
1133
- ? Object.entries(participant_types)
1134
- : [];
1135
-
1136
- return overrides.some(([speaker, type]) => {
1137
- const normalized = normalizeSpeaker(speaker);
1138
- return normalized?.startsWith("web-") || type === "browser" || type === "browser_auto";
1139
- });
1140
- }
1141
-
1142
- function resolveIncludeBrowserSpeakers({ include_browser_speakers, config, speakers, participant_types } = {}) {
1143
- if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
1144
- return include_browser_speakers;
1145
- }
1146
- if (config?.include_browser_speakers !== undefined && config?.include_browser_speakers !== null) {
1147
- return config.include_browser_speakers;
1148
- }
1149
- return false;
1150
- }
1151
-
1152
- function resolveCliCandidates() {
1153
- const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
1154
- .split(/[,\s]+/)
1155
- .map(v => v.trim())
1156
- .filter(Boolean);
1157
-
1158
- // If config has enabled_clis, use that as the primary filter
1159
- const config = loadDeliberationConfig();
1160
- if (Array.isArray(config.enabled_clis) && config.enabled_clis.length > 0) {
1161
- return dedupeSpeakers([...fromEnv, ...config.enabled_clis]);
1162
- }
1163
-
1164
- return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
1165
- }
1166
-
1167
- function loadTeleptyAuthToken() {
1168
- try {
1169
- const raw = fs.readFileSync(TELEPTY_CONFIG_FILE, "utf-8");
1170
- const parsed = JSON.parse(raw);
1171
- return typeof parsed?.authToken === "string" && parsed.authToken.trim()
1172
- ? parsed.authToken.trim()
1173
- : null;
1174
- } catch {
1175
- return null;
1176
- }
1177
- }
1178
-
1179
- function formatTeleptyHostLabel(host) {
1180
- return !host || host === "127.0.0.1" || host === "localhost" ? "Local" : host;
1181
- }
1182
-
1183
- async function collectTeleptySessions() {
1184
- const token = loadTeleptyAuthToken();
1185
- if (!token) {
1186
- return { sessions: [], note: "telepty auth token not found." };
1187
- }
1188
-
1189
- const host = TELEPTY_DEFAULT_HOST;
1190
- try {
1191
- const res = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions`, {
1192
- headers: { "x-telepty-token": token },
1193
- signal: AbortSignal.timeout(1500),
1194
- });
1195
- if (!res.ok) {
1196
- return { sessions: [], note: `telepty daemon unavailable (${res.status}).` };
1197
- }
1198
- const sessions = await res.json();
1199
- if (!Array.isArray(sessions)) {
1200
- return { sessions: [], note: "telepty session response format was invalid." };
1201
- }
1202
- ensureTeleptyBusSubscriber().catch(() => {});
1203
- return {
1204
- sessions: sessions.map(session => ({ host, ...session })),
1205
- note: null,
1206
- };
1207
- } catch {
1208
- return { sessions: [], note: null };
1209
- }
1210
- }
1211
-
1212
- function scoreTeleptyProcessMatch(session, baseCommand = "", fullCommand = "") {
1213
- const base = String(baseCommand || "").toLowerCase();
1214
- const full = String(fullCommand || "").toLowerCase();
1215
- const wanted = String(session?.command || "").trim().toLowerCase();
1216
- let score = 0;
1217
-
1218
- if (wanted && (base === wanted || full.startsWith(`${wanted} `) || full.includes(` ${wanted} `))) {
1219
- score += 10;
1220
- }
1221
- if (base === "node" || base === "telepty") {
1222
- score -= 2;
1223
- }
1224
- if (full.includes("mcp-deliberation") || full.includes("oh-my-claudecode") || full.includes("bridge/mcp-server")) {
1225
- score -= 3;
1226
- }
1227
- return score;
1228
- }
1229
-
1230
- function collectTeleptyProcessLocators(sessions = []) {
1231
- const wantedSessions = new Map(
1232
- sessions
1233
- .filter(session => session?.id)
1234
- .map(session => [String(session.id), session])
1235
- );
1236
- if (wantedSessions.size === 0) {
1237
- return new Map();
1238
- }
1239
-
1240
- try {
1241
- const env = {
1242
- HOME: process.env.HOME,
1243
- PATH: process.env.PATH,
1244
- SHELL: process.env.SHELL,
1245
- USER: process.env.USER,
1246
- LOGNAME: process.env.LOGNAME,
1247
- TERM: process.env.TERM,
1248
- };
1249
- const raw = execFileSync("ps", ["eww", "-axo", "pid=,tty=,comm=,command="], {
1250
- encoding: "utf-8",
1251
- windowsHide: true,
1252
- timeout: 2500,
1253
- maxBuffer: 8 * 1024 * 1024,
1254
- env,
1255
- });
1256
-
1257
- const best = new Map();
1258
- for (const line of String(raw).split("\n")) {
1259
- if (!line.includes("TELEPTY_SESSION_ID=")) continue;
1260
- const match = line.match(/^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/);
1261
- if (!match) continue;
1262
- const [, pid, tty, comm, command] = match;
1263
- const sessionIdMatch = command.match(/(?:^|\s)TELEPTY_SESSION_ID=([^\s]+)/);
1264
- const sessionId = sessionIdMatch?.[1];
1265
- if (!sessionId || !wantedSessions.has(sessionId)) continue;
1266
-
1267
- const session = wantedSessions.get(sessionId);
1268
- const score = scoreTeleptyProcessMatch(session, comm, command);
1269
- const current = best.get(sessionId);
1270
- if (!current || score > current.score) {
1271
- best.set(sessionId, { pid: Number(pid), tty, score });
1272
- }
1273
- }
1274
-
1275
- return new Map(
1276
- [...best.entries()].map(([sessionId, value]) => [
1277
- sessionId,
1278
- { pid: Number.isFinite(value.pid) ? value.pid : null, tty: value.tty || null },
1279
- ])
1280
- );
1281
- } catch {
1282
- return new Map();
1283
- }
1284
- }
1285
-
1286
- function commandExistsInPath(command) {
1287
- if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
1288
- return false;
1289
- }
1290
-
1291
- if (process.platform === "win32") {
1292
- try {
1293
- execFileSync("where", [command], { stdio: "ignore" });
1294
- return true;
1295
- } catch {
1296
- // keep PATH scan fallback for shells where "where" is unavailable
1297
- }
1298
- }
1299
-
1300
- const pathVar = process.env.PATH || "";
1301
- const dirs = pathVar.split(path.delimiter).filter(Boolean);
1302
- if (dirs.length === 0) return false;
1303
-
1304
- const extensions = process.platform === "win32"
1305
- ? ["", ".exe", ".cmd", ".bat", ".ps1"]
1306
- : [""];
1307
-
1308
- for (const dir of dirs) {
1309
- for (const ext of extensions) {
1310
- const fullPath = path.join(dir, `${command}${ext}`);
1311
- try {
1312
- fs.accessSync(fullPath, fs.constants.X_OK);
1313
- return true;
1314
- } catch {
1315
- // ignore and continue
1316
- }
1317
- }
1318
- }
1319
- return false;
1320
- }
1321
-
1322
- function shellQuote(value) {
1323
- return `'${String(value).replace(/'/g, "'\\''")}'`;
1324
- }
1325
-
1326
- function checkCliLiveness(command) {
1327
- const hint = CLI_INVOCATION_HINTS[command];
1328
- const env = { ...process.env };
1329
- // Unset CLAUDECODE to avoid nested session errors
1330
- if (hint?.envPrefix?.includes("CLAUDECODE=")) {
1331
- delete env.CLAUDECODE;
1332
- }
1333
- try {
1334
- execFileSync(command, ["--version"], {
1335
- stdio: "ignore",
1336
- windowsHide: true,
1337
- timeout: 5000,
1338
- env,
1339
- });
1340
- return true;
1341
- } catch {
1342
- // --version failed, try --help as fallback
1343
- try {
1344
- execFileSync(command, ["--help"], {
1345
- stdio: "ignore",
1346
- windowsHide: true,
1347
- timeout: 5000,
1348
- env,
1349
- });
1350
- return true;
1351
- } catch {
1352
- return false;
1353
- }
1354
- }
1355
- }
1356
-
1357
- function discoverLocalCliSpeakers() {
1358
- const found = [];
1359
- for (const candidate of resolveCliCandidates()) {
1360
- if (commandExistsInPath(candidate)) {
1361
- found.push(candidate);
1362
- }
1363
- if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
1364
- break;
1365
- }
1366
- }
1367
- return found;
1368
- }
1369
-
1370
- function detectCallerSpeaker() {
1371
- const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
1372
- if (hinted) return hinted;
1373
-
1374
- const pathHint = process.env.PATH || "";
1375
- if (/\bCODEX_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
1376
- return "codex";
1377
- }
1378
- if (pathHint.includes("/.codex/")) {
1379
- return "codex";
1380
- }
1381
-
1382
- if (/\bCLAUDE_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
1383
- return "claude";
1384
- }
1385
- if (pathHint.includes("/.claude/")) {
1386
- return "claude";
1387
- }
1388
-
1389
- return null;
1390
- }
1391
-
1392
- function isLlmUrl(url = "") {
1393
- const value = String(url || "").trim();
1394
- if (!value) return false;
1395
- try {
1396
- const parsed = new URL(value);
1397
- const host = parsed.hostname.toLowerCase();
1398
- return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
1399
- } catch {
1400
- const lowered = value.toLowerCase();
1401
- return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
1402
- }
1403
- }
1404
-
1405
- function dedupeBrowserTabs(tabs = []) {
1406
- const out = [];
1407
- const seen = new Set();
1408
- for (const tab of tabs) {
1409
- const browser = String(tab?.browser || "").trim();
1410
- const title = String(tab?.title || "").trim();
1411
- const url = String(tab?.url || "").trim();
1412
- if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
1413
- // Dedup by title+url (ignore browser name) so that the same tab detected
1414
- // via both AppleScript and CDP is not duplicated. The first occurrence wins,
1415
- // so callers should add preferred sources first (e.g., CDP before AppleScript).
1416
- const key = `${title}\t${url}`;
1417
- if (seen.has(key)) continue;
1418
- seen.add(key);
1419
- out.push({
1420
- browser: browser || "Browser",
1421
- title: title || "(untitled)",
1422
- url,
1423
- });
1424
- }
1425
- return out;
1426
- }
1427
-
1428
- function parseInjectedBrowserTabsFromEnv() {
1429
- const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
1430
- if (!raw) {
1431
- return { tabs: [], note: null };
1432
- }
1433
-
1434
- try {
1435
- const parsed = JSON.parse(raw);
1436
- if (!Array.isArray(parsed)) {
1437
- return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON format error: must be a JSON array." };
1438
- }
1439
-
1440
- const tabs = dedupeBrowserTabs(parsed.map(item => ({
1441
- browser: item?.browser || "External Bridge",
1442
- title: item?.title || "(untitled)",
1443
- url: item?.url || "",
1444
- })));
1445
- return {
1446
- tabs,
1447
- note: tabs.length > 0 ? `Environment variable tab injection: ${tabs.length} tabs` : "No valid LLM URLs found in DELIBERATION_BROWSER_TABS_JSON.",
1448
- };
1449
- } catch (error) {
1450
- const reason = error instanceof Error ? error.message : "unknown error";
1451
- return { tabs: [], note: `Failed to parse DELIBERATION_BROWSER_TABS_JSON: ${reason}` };
1452
- }
1453
- }
1454
-
1455
- function normalizeCdpEndpoint(raw) {
1456
- const value = String(raw || "").trim();
1457
- if (!value) return null;
1458
-
1459
- const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
1460
- try {
1461
- const url = new URL(withProto);
1462
- if (!url.pathname || url.pathname === "/") {
1463
- url.pathname = "/json/list";
1464
- }
1465
- return url.toString();
1466
- } catch {
1467
- return null;
1468
- }
1469
- }
1470
-
1471
- function resolveCdpEndpoints() {
1472
- const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
1473
- .split(/[,\s]+/)
1474
- .map(v => normalizeCdpEndpoint(v))
1475
- .filter(Boolean);
1476
- if (fromEnv.length > 0) {
1477
- return [...new Set(fromEnv)];
1478
- }
1479
-
1480
- const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
1481
- .split(/[,\s]+/)
1482
- .map(v => Number.parseInt(v, 10))
1483
- .filter(v => Number.isInteger(v) && v > 0 && v < 65536);
1484
-
1485
- const endpoints = [];
1486
- for (const port of ports) {
1487
- endpoints.push(`http://127.0.0.1:${port}/json/list`);
1488
- endpoints.push(`http://localhost:${port}/json/list`);
1489
- }
1490
- return [...new Set(endpoints)];
1491
- }
1492
-
1493
- async function fetchJson(url, timeoutMs = 900) {
1494
- if (typeof fetch !== "function") {
1495
- throw new Error("fetch API unavailable in current Node runtime");
1496
- }
1497
-
1498
- const controller = new AbortController();
1499
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1500
- try {
1501
- const response = await fetch(url, {
1502
- method: "GET",
1503
- signal: controller.signal,
1504
- headers: { "accept": "application/json" },
1505
- });
1506
- if (!response.ok) {
1507
- throw new Error(`HTTP ${response.status}`);
1508
- }
1509
- return await response.json();
1510
- } finally {
1511
- clearTimeout(timer);
1512
- }
1513
- }
1514
-
1515
- function inferBrowserFromCdpEndpoint(endpoint) {
1516
- try {
1517
- const parsed = new URL(endpoint);
1518
- const port = Number.parseInt(parsed.port, 10);
1519
- if (port === 9222) return "Google Chrome (CDP)";
1520
- if (port === 9223) return "Microsoft Edge (CDP)";
1521
- if (port === 9333) return "Brave Browser (CDP)";
1522
- return `Browser (CDP:${parsed.host})`;
1523
- } catch {
1524
- return "Browser (CDP)";
1525
- }
1526
- }
1527
-
1528
- function summarizeFailures(items = [], max = 3) {
1529
- if (!Array.isArray(items) || items.length === 0) return null;
1530
- const shown = items.slice(0, max);
1531
- const suffix = items.length > max ? ` and ${items.length - max} more` : "";
1532
- return `${shown.join(", ")}${suffix}`;
1533
- }
1534
-
1535
- async function collectBrowserLlmTabsViaCdp() {
1536
- const endpoints = resolveCdpEndpoints();
1537
- const tabs = [];
1538
- const failures = [];
1539
-
1540
- for (const endpoint of endpoints) {
1541
- try {
1542
- const payload = await fetchJson(endpoint);
1543
- if (!Array.isArray(payload)) {
1544
- throw new Error("unexpected payload");
1545
- }
1546
-
1547
- const browser = inferBrowserFromCdpEndpoint(endpoint);
1548
- for (const item of payload) {
1549
- if (!item || String(item.type) !== "page") continue;
1550
- const url = String(item.url || "").trim();
1551
- const title = String(item.title || "").trim();
1552
- if (!isLlmUrl(url) && !isExtensionLlmTab(url, title)) continue;
1553
- tabs.push({
1554
- browser,
1555
- title: title || "(untitled)",
1556
- url,
1557
- });
1558
- }
1559
- } catch (error) {
1560
- const reason = error instanceof Error ? error.message : "unknown error";
1561
- failures.push(`${endpoint} (${reason})`);
1562
- }
1563
- }
1564
-
1565
- const uniqTabs = dedupeBrowserTabs(tabs);
1566
- if (uniqTabs.length > 0) {
1567
- const failSummary = summarizeFailures(failures);
1568
- return {
1569
- tabs: uniqTabs,
1570
- note: failSummary ? `Some CDP endpoint access failed: ${failSummary}` : null,
1571
- };
1572
- }
1573
-
1574
- const failSummary = summarizeFailures(failures);
1575
- return {
1576
- tabs: [],
1577
- note: `No LLM tabs found via CDP. Run browser with --remote-debugging-port=9222 or inject tab list via DELIBERATION_BROWSER_TABS_JSON.${failSummary ? ` (failed: ${failSummary})` : ""}`,
1578
- };
1579
- }
1580
-
1581
- async function ensureCdpAvailable() {
1582
- const endpoints = resolveCdpEndpoints();
1583
-
1584
- // First attempt: try existing CDP endpoints
1585
- for (const endpoint of endpoints) {
1586
- try {
1587
- const payload = await fetchJson(endpoint, 1500);
1588
- if (Array.isArray(payload)) {
1589
- return { available: true, endpoint };
1590
- }
1591
- } catch { /* not reachable */ }
1592
- }
1593
-
1594
- // Auto-launch Chrome with CDP on macOS, Linux, and Windows
1595
- {
1596
- let chromeBin, chromeUserDataDir;
1597
-
1598
- if (process.platform === "darwin") {
1599
- chromeBin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
1600
- chromeUserDataDir = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome");
1601
- } else if (process.platform === "linux") {
1602
- const chromeCandidates = ["google-chrome", "google-chrome-stable", "google-chrome-beta", "chromium-browser", "chromium"];
1603
- chromeBin = chromeCandidates.find(c => commandExistsInPath(c)) || null;
1604
- if (!chromeBin) {
1605
- return {
1606
- available: false,
1607
- reason: "Chrome/Chromium not found. Install google-chrome or chromium and run with --remote-debugging-port=9222.",
1608
- };
1609
- }
1610
- const googleDir = path.join(os.homedir(), ".config", "google-chrome");
1611
- const chromiumDir = path.join(os.homedir(), ".config", "chromium");
1612
- chromeUserDataDir = fs.existsSync(googleDir) ? googleDir : fs.existsSync(chromiumDir) ? chromiumDir : null;
1613
- } else if (process.platform === "win32") {
1614
- const programFiles = process.env.PROGRAMFILES || "C:\\Program Files";
1615
- const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
1616
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
1617
- const winCandidates = [
1618
- path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
1619
- path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
1620
- path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
1621
- path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
1622
- path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
1623
- ];
1624
- chromeBin = winCandidates.find(p => fs.existsSync(p)) || null;
1625
- if (!chromeBin) {
1626
- return {
1627
- available: false,
1628
- reason: "Chrome/Edge not found. Install Chrome or run with --remote-debugging-port=9222.",
1629
- };
1630
- }
1631
- const chromeDir = path.join(localAppData, "Google", "Chrome", "User Data");
1632
- const edgeDir = path.join(localAppData, "Microsoft", "Edge", "User Data");
1633
- chromeUserDataDir = fs.existsSync(chromeDir) ? chromeDir : fs.existsSync(edgeDir) ? edgeDir : null;
1634
- } else {
1635
- return {
1636
- available: false,
1637
- reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
1638
- };
1639
- }
1640
-
1641
- // Chrome 145+ requires --user-data-dir for CDP to work.
1642
- // The default data dir is rejected, so we copy the profile to ~/.chrome-cdp.
1643
- // Profile can be set via env DELIBERATION_CHROME_PROFILE or config.chrome_profile (e.g., "Profile 1").
1644
- const cdpDataDir = path.join(os.homedir(), ".chrome-cdp");
1645
- const cdpConfig = loadDeliberationConfig();
1646
- const profileDir = process.env.DELIBERATION_CHROME_PROFILE || cdpConfig.chrome_profile || "Default";
1647
-
1648
- try {
1649
- if (chromeUserDataDir) {
1650
- const srcProfile = path.join(chromeUserDataDir, profileDir);
1651
- const dstProfile = path.join(cdpDataDir, profileDir);
1652
- // Track which profile was copied; re-copy if profile changed
1653
- const profileMarker = path.join(cdpDataDir, ".cdp-profile");
1654
- const lastProfile = fs.existsSync(profileMarker) ? fs.readFileSync(profileMarker, "utf8").trim() : null;
1655
- const needsCopy = !fs.existsSync(dstProfile) || (lastProfile && lastProfile !== profileDir);
1656
- if (needsCopy && fs.existsSync(srcProfile)) {
1657
- // Clean old profile if switching
1658
- if (lastProfile && lastProfile !== profileDir) {
1659
- const oldDst = path.join(cdpDataDir, lastProfile);
1660
- if (fs.existsSync(oldDst)) fs.rmSync(oldDst, { recursive: true, force: true });
1661
- }
1662
- fs.mkdirSync(cdpDataDir, { recursive: true });
1663
- execFileSync("cp", ["-R", srcProfile, dstProfile], { timeout: 30000, stdio: "ignore" });
1664
- fs.writeFileSync(profileMarker, profileDir);
1665
- // Create minimal Local State with single profile to avoid profile picker
1666
- const localStateSrc = path.join(chromeUserDataDir, "Local State");
1667
- if (fs.existsSync(localStateSrc)) {
1668
- const state = JSON.parse(fs.readFileSync(localStateSrc, "utf8"));
1669
- state.profile.profiles_created = 1;
1670
- state.profile.last_used = profileDir;
1671
- if (state.profile.info_cache) {
1672
- const kept = {};
1673
- if (state.profile.info_cache[profileDir]) kept[profileDir] = state.profile.info_cache[profileDir];
1674
- state.profile.info_cache = kept;
1675
- }
1676
- fs.writeFileSync(path.join(cdpDataDir, "Local State"), JSON.stringify(state));
1677
- }
1678
- }
1679
- }
1680
- } catch { /* proceed with launch attempt anyway */ }
1681
-
1682
- const launchArgs = [
1683
- "--remote-debugging-port=9222",
1684
- "--remote-allow-origins=http://127.0.0.1:9222",
1685
- `--user-data-dir=${cdpDataDir}`,
1686
- `--profile-directory=${profileDir}`,
1687
- "--no-first-run",
1688
- ];
1689
-
1690
- try {
1691
- const child = spawn(chromeBin, launchArgs, { stdio: "ignore", detached: true });
1692
- child.unref();
1693
- } catch {
1694
- return {
1695
- available: false,
1696
- reason: `Failed to auto-launch Chrome. Manually run Chrome with --remote-debugging-port=9222 --user-data-dir=~/.chrome-cdp.`,
1697
- };
1698
- }
1699
-
1700
- // Wait for Chrome to initialize CDP
1701
- await new Promise(resolve => setTimeout(resolve, 5000));
1702
-
1703
- // Retry CDP connection after launch
1704
- for (const endpoint of endpoints) {
1705
- try {
1706
- const payload = await fetchJson(endpoint, 2000);
1707
- if (Array.isArray(payload)) {
1708
- return { available: true, endpoint, launched: true };
1709
- }
1710
- } catch { /* still not reachable */ }
1711
- }
1712
-
1713
- return {
1714
- available: false,
1715
- reason: "Chrome launched but cannot connect to CDP. Fully close Chrome and try again. (Restart required if Chrome was started without CDP)",
1716
- };
1717
- }
1718
-
1719
- // Unreachable (all platforms handled above), but keep as safety net
1720
- return {
1721
- available: false,
1722
- reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
1723
- };
1724
- }
1725
-
1726
- function collectBrowserLlmTabsViaAppleScript() {
1727
- if (process.platform !== "darwin") {
1728
- return { tabs: [], note: "AppleScript tab scanning is only supported on macOS." };
1729
- }
1730
-
1731
- const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
1732
- const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
1733
- const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
1734
- const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
1735
-
1736
- // NOTE: Use stdin pipe (`osascript -`) instead of multiple `-e` flags
1737
- // because osascript's `-e` mode silently breaks with nested try/on error blocks.
1738
- // Also wrap dynamic `tell application` with `using terms from` so that
1739
- // Chrome-specific properties like `tabs` resolve via the scripting dictionary.
1740
- // Use ASCII character 9 for tab delimiter because `using terms from`
1741
- // shadows the built-in `tab` constant, turning it into the literal string "tab".
1742
- const scriptText = `set llmDomains to ${domainList}
1743
- set browserApps to ${appList}
1744
- set outText to ""
1745
- set tabChar to ASCII character 9
1746
- tell application "System Events"
1747
- set runningApps to name of every application process
1748
- end tell
1749
- repeat with appName in browserApps
1750
- if runningApps contains (appName as string) then
1751
- try
1752
- if (appName as string) is "Safari" then
1753
- using terms from application "Safari"
1754
- tell application (appName as string)
1755
- repeat with w in windows
1756
- try
1757
- repeat with t in tabs of w
1758
- set u to URL of t as string
1759
- set matched to false
1760
- repeat with d in llmDomains
1761
- if u contains (d as string) then set matched to true
1762
- end repeat
1763
- if matched then set outText to outText & (appName as string) & tabChar & (name of t as string) & tabChar & u & linefeed
1764
- end repeat
1765
- end try
1766
- end repeat
1767
- end tell
1768
- end using terms from
1769
- else
1770
- using terms from application "Google Chrome"
1771
- tell application (appName as string)
1772
- repeat with w in windows
1773
- try
1774
- repeat with t in tabs of w
1775
- set u to URL of t as string
1776
- set matched to false
1777
- repeat with d in llmDomains
1778
- if u contains (d as string) then set matched to true
1779
- end repeat
1780
- if matched then set outText to outText & (appName as string) & tabChar & (title of t as string) & tabChar & u & linefeed
1781
- end repeat
1782
- end try
1783
- end repeat
1784
- end tell
1785
- end using terms from
1786
- end if
1787
- on error errMsg
1788
- set outText to outText & (appName as string) & tabChar & "ERROR" & tabChar & errMsg & linefeed
1789
- end try
1790
- end if
1791
- end repeat
1792
- return outText`;
1793
-
1794
- try {
1795
- const raw = execFileSync("osascript", ["-"], {
1796
- input: scriptText,
1797
- encoding: "utf-8",
1798
- timeout: 8000,
1799
- maxBuffer: 2 * 1024 * 1024,
1800
- });
1801
- const rows = String(raw)
1802
- .split("\n")
1803
- .map(line => line.trim())
1804
- .filter(Boolean)
1805
- .map(line => {
1806
- const [browser = "", title = "", url = ""] = line.split("\t");
1807
- return { browser, title, url };
1808
- });
1809
- const tabs = rows.filter(r => r.title !== "ERROR");
1810
- const errors = rows.filter(r => r.title === "ERROR");
1811
- return {
1812
- tabs,
1813
- note: errors.length > 0
1814
- ? `Some browser access failed: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
1815
- : null,
1816
- };
1817
- } catch (error) {
1818
- const reason = error instanceof Error ? error.message : "unknown error";
1819
- return {
1820
- tabs: [],
1821
- note: `Browser tab scan failed: ${reason}. Check macOS automation permissions (Terminal -> Browser control).`,
1822
- };
1823
- }
1824
- }
1825
-
1826
- async function collectBrowserLlmTabs() {
1827
- const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
1828
- const tabs = [];
1829
- const notes = [];
1830
-
1831
- const injected = parseInjectedBrowserTabsFromEnv();
1832
- tabs.push(...injected.tabs);
1833
- if (injected.note) notes.push(injected.note);
1834
-
1835
- if (mode === "off") {
1836
- return {
1837
- tabs: dedupeBrowserTabs(tabs),
1838
- note: notes.length > 0 ? notes.join(" | ") : "Browser tab auto-scanning is disabled.",
1839
- };
1840
- }
1841
-
1842
- // CDP first: CDP-detected tabs are preferred over AppleScript-detected ones
1843
- // because they carry CDP metadata (tab ID, WebSocket URL) for browser_auto transport.
1844
- // Since dedupeBrowserTabs keeps the first occurrence, CDP entries win the dedup.
1845
- const shouldUseCdp = mode === "auto" || mode === "cdp";
1846
- if (shouldUseCdp) {
1847
- const cdp = await collectBrowserLlmTabsViaCdp();
1848
- tabs.push(...cdp.tabs);
1849
- if (cdp.note) notes.push(cdp.note);
1850
- }
1851
-
1852
- const shouldUseAppleScript = mode === "auto" || mode === "applescript";
1853
- if (shouldUseAppleScript && process.platform === "darwin") {
1854
- const mac = collectBrowserLlmTabsViaAppleScript();
1855
- tabs.push(...mac.tabs);
1856
- if (mac.note) notes.push(mac.note);
1857
- } else if (mode === "applescript" && process.platform !== "darwin") {
1858
- notes.push("AppleScript scanning is macOS only. Switch to CDP scanning.");
1859
- }
1860
-
1861
- const uniqTabs = dedupeBrowserTabs(tabs);
1862
- return {
1863
- tabs: uniqTabs,
1864
- note: notes.length > 0 ? notes.join(" | ") : null,
1865
- };
1866
- }
1867
-
1868
- function inferLlmProvider(url = "", title = "") {
1869
- const value = String(url).toLowerCase();
1870
- // Extension side panel: infer from title via registry
1871
- if (value.startsWith("chrome-extension://") && title) {
1872
- const registry = loadExtensionProviderRegistry();
1873
- const lowerTitle = String(title).toLowerCase();
1874
- for (const entry of registry.providers) {
1875
- if (entry.titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
1876
- return entry.provider;
1877
- }
1878
- }
1879
- return "extension-llm";
1880
- }
1881
- if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
1882
- if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
1883
- if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
1884
- if (value.includes("copilot.microsoft.com")) return "copilot";
1885
- if (value.includes("perplexity.ai")) return "perplexity";
1886
- if (value.includes("poe.com")) return "poe";
1887
- if (value.includes("mistral.ai")) return "mistral";
1888
- if (value.includes("huggingface.co/chat")) return "huggingchat";
1889
- if (value.includes("deepseek.com")) return "deepseek";
1890
- if (value.includes("qwen.ai")) return "qwen";
1891
- if (value.includes("grok.com")) return "grok";
1892
- return "web-llm";
1893
- }
1894
-
1895
- async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
1896
- const candidates = [];
1897
- const seen = new Set();
1898
- let browserNote = null;
1899
-
1900
- const add = (candidate) => {
1901
- const speaker = normalizeSpeaker(candidate?.speaker);
1902
- if (!speaker || seen.has(speaker)) return;
1903
- seen.add(speaker);
1904
- candidates.push({ ...candidate, speaker });
1905
- };
1906
-
1907
- if (include_cli) {
1908
- for (const cli of discoverLocalCliSpeakers()) {
1909
- const live = checkCliLiveness(cli);
1910
- add({
1911
- speaker: cli,
1912
- type: "cli",
1913
- label: cli,
1914
- command: cli,
1915
- live,
1916
- });
1917
- }
1918
-
1919
- const { sessions: teleptySessions, note: teleptyNote } = await collectTeleptySessions();
1920
- const locators = collectTeleptyProcessLocators(teleptySessions);
1921
- for (const session of teleptySessions) {
1922
- const locator = locators.get(session.id) || {};
1923
- add({
1924
- speaker: session.id,
1925
- type: "telepty",
1926
- label: session.id,
1927
- telepty_session_id: session.id,
1928
- telepty_host: session.host || TELEPTY_DEFAULT_HOST,
1929
- command: session.command || "wrapped",
1930
- cwd: session.cwd || null,
1931
- active_clients: session.active_clients ?? null,
1932
- runtime_pid: locator.pid ?? null,
1933
- runtime_tty: locator.tty ?? null,
1934
- });
1935
- }
1936
- if (teleptyNote) {
1937
- browserNote = browserNote ? `${browserNote} | ${teleptyNote}` : teleptyNote;
1938
- }
1939
- }
1940
-
1941
- if (include_browser) {
1942
- // Ensure CDP is available before probing browser tabs
1943
- const cdpStatus = await ensureCdpAvailable();
1944
- if (cdpStatus.launched) {
1945
- browserNote = "Chrome CDP auto-launched (--remote-debugging-port=9222)";
1946
- }
1947
-
1948
- const { tabs, note } = await collectBrowserLlmTabs();
1949
- browserNote = browserNote ? `${browserNote} | ${note || ""}`.replace(/ \| $/, "") : (note || null);
1950
- const providerCounts = new Map();
1951
- for (const tab of tabs) {
1952
- const provider = inferLlmProvider(tab.url, tab.title);
1953
- const count = (providerCounts.get(provider) || 0) + 1;
1954
- providerCounts.set(provider, count);
1955
- add({
1956
- speaker: `web-${provider}-${count}`,
1957
- type: "browser",
1958
- provider,
1959
- browser: tab.browser || "",
1960
- title: tab.title || "",
1961
- url: tab.url || "",
1962
- });
1963
- }
1964
-
1965
- // CDP auto-detection: probe endpoints for matching tabs
1966
- const cdpEndpoints = resolveCdpEndpoints();
1967
- const cdpTabsMap = new Map(); // dedupe by tab ID (multiple endpoints may return same tabs)
1968
- for (const endpoint of cdpEndpoints) {
1969
- try {
1970
- const tabs = await fetchJson(endpoint, 2000);
1971
- if (Array.isArray(tabs)) {
1972
- for (const t of tabs) {
1973
- if (t.type === "page" && t.url && t.id && !cdpTabsMap.has(t.id)) {
1974
- cdpTabsMap.set(t.id, t);
1975
- }
1976
- }
1977
- }
1978
- } catch { /* endpoint not reachable */ }
1979
- }
1980
- const cdpTabs = [...cdpTabsMap.values()];
1981
-
1982
- // Match CDP tabs with discovered browser candidates
1983
- for (const candidate of candidates) {
1984
- if (candidate.type !== "browser") continue;
1985
- // For extension candidates, match by title instead of hostname
1986
- const candidateUrl = String(candidate.url || "");
1987
- if (candidateUrl.startsWith("chrome-extension://")) {
1988
- const candidateTitle = String(candidate.title || "").toLowerCase();
1989
- if (candidateTitle) {
1990
- const matches = cdpTabs.filter(t =>
1991
- String(t.url || "").startsWith("chrome-extension://") &&
1992
- String(t.title || "").toLowerCase().includes(candidateTitle)
1993
- );
1994
- if (matches.length >= 1) {
1995
- candidate.cdp_available = true;
1996
- candidate.cdp_tab_id = matches[0].id;
1997
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
1998
- }
1999
- }
2000
- continue;
2001
- }
2002
- let candidateHost = "";
2003
- try {
2004
- candidateHost = new URL(candidate.url).hostname.toLowerCase();
2005
- } catch { continue; }
2006
- if (!candidateHost) continue;
2007
- const matches = cdpTabs.filter(t => {
2008
- try {
2009
- return new URL(t.url).hostname.toLowerCase() === candidateHost;
2010
- } catch { return false; }
2011
- });
2012
- if (matches.length >= 1) {
2013
- candidate.cdp_available = true;
2014
- candidate.cdp_tab_id = matches[0].id;
2015
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
2016
- }
2017
- }
2018
-
2019
- // Auto-register well-known web LLMs that weren't already detected via browser scanning.
2020
- // This ensures web speakers are ALWAYS available regardless of browser detection success.
2021
- // If a browser tab for the same provider was already detected, skip auto-registration
2022
- // to avoid duplicates (e.g., detected "web-chatgpt-1" vs auto-registered "web-chatgpt").
2023
- const detectedProviders = new Set(
2024
- candidates.filter(c => c.type === "browser" && !c.auto_registered).map(c => c.provider)
2025
- );
2026
- // CDP is reachable if we got any tabs from the endpoints (attach() handles auto-tab-creation)
2027
- const cdpReachable = cdpTabs.length > 0 || cdpStatus.available;
2028
- for (const ws of DEFAULT_WEB_SPEAKERS) {
2029
- if (detectedProviders.has(ws.provider)) continue;
2030
- add({
2031
- speaker: ws.speaker,
2032
- type: "browser",
2033
- provider: ws.provider,
2034
- browser: "auto-registered",
2035
- title: ws.name,
2036
- url: ws.url,
2037
- auto_registered: true,
2038
- cdp_available: cdpReachable,
2039
- });
2040
- }
2041
-
2042
- // Second pass: match auto-registered speakers to individual CDP tabs
2043
- // (they were added after the first matching pass and only got the global cdpReachable flag)
2044
- if (cdpTabs.length > 0) {
2045
- for (const candidate of candidates) {
2046
- if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
2047
- let candidateHost = "";
2048
- try {
2049
- candidateHost = new URL(candidate.url).hostname.toLowerCase();
2050
- } catch { continue; }
2051
- if (!candidateHost) continue;
2052
- const matches = cdpTabs.filter(t => {
2053
- try {
2054
- const tabHost = new URL(t.url).hostname.toLowerCase();
2055
- // Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
2056
- return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
2057
- } catch { return false; }
2058
- });
2059
- if (matches.length >= 1) {
2060
- candidate.cdp_available = true;
2061
- candidate.cdp_tab_id = matches[0].id;
2062
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
2063
- }
2064
- }
2065
- }
2066
-
2067
- // Third pass: upgrade browser-detected candidates that missed the first hostname match.
2068
- // When CDP is reachable, AppleScript-detected speakers should also get browser_auto
2069
- // transport. The OrchestratedBrowserPort will create/navigate tabs on demand if needed.
2070
- if (cdpReachable) {
2071
- for (const candidate of candidates) {
2072
- if (candidate.type !== "browser" || candidate.auto_registered) continue;
2073
- if (candidate.cdp_available) continue; // already matched
2074
- candidate.cdp_available = true;
2075
- }
2076
- }
2077
- }
2078
-
2079
- return { candidates, browserNote };
2080
- }
2081
-
2082
- function formatSpeakerCandidatesReport({ candidates, browserNote }) {
2083
- const cli = candidates.filter(c => c.type === "cli");
2084
- const telepty = candidates.filter(c => c.type === "telepty");
2085
- const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
2086
- const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
2087
-
2088
- let out = "## Selectable Speakers\n\n";
2089
- out += "### CLI\n";
2090
- if (cli.length === 0) {
2091
- out += "- (No local CLI detected)\n\n";
2092
- } else {
2093
- out += `${cli.map(c => {
2094
- const status = c.live === false ? " ❌ not executable" : c.live === true ? " ✅ executable" : "";
2095
- return `- \`${c.speaker}\` (command: ${c.command})${status}`;
2096
- }).join("\n")}\n\n`;
2097
- }
2098
-
2099
- out += "### Telepty Sessions\n";
2100
- if (telepty.length === 0) {
2101
- out += "- (No active telepty sessions)\n\n";
2102
- } else {
2103
- out += `${telepty.map(c => {
2104
- const parts = [
2105
- `command: ${c.command || "wrapped"}`,
2106
- c.telepty_host ? `host: ${formatTeleptyHostLabel(c.telepty_host)}` : null,
2107
- Number.isFinite(c.runtime_pid) ? `pid: ${c.runtime_pid}` : null,
2108
- c.runtime_tty ? `tty: ${c.runtime_tty}` : null,
2109
- ].filter(Boolean).join(", ");
2110
- const cwdLine = c.cwd ? `\n cwd: ${c.cwd}` : "";
2111
- return `- \`${c.speaker}\` (${parts})${cwdLine}`;
2112
- }).join("\n")}\n\n`;
2113
- }
2114
-
2115
- out += "### Browser LLM (detected)\n";
2116
- if (detected.length === 0) {
2117
- out += "- (No LLM tabs detected in browser)\n";
2118
- } else {
2119
- out += `${detected.map(c => {
2120
- const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
2121
- const extTag = String(c.url || "").startsWith("chrome-extension://") ? " [Extension]" : "";
2122
- return `- \`${c.speaker}\` [${icon}]${extTag} [${c.browser}] ${c.title}\n ${c.url}`;
2123
- }).join("\n")}\n`;
2124
- }
2125
-
2126
- out += "\n### Web LLM (auto-registered)\n";
2127
- out += `${autoReg.map(c => {
2128
- const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
2129
- return `- \`${c.speaker}\` [${icon}] — ${c.title} (${c.url})`;
2130
- }).join("\n")}\n`;
2131
-
2132
- if (browserNote) {
2133
- out += `\n\nℹ️ ${browserNote}`;
2134
- }
2135
- return out;
2136
- }
2137
-
2138
- function mapParticipantProfiles(speakers, candidates, typeOverrides) {
2139
- const bySpeaker = new Map();
2140
- for (const c of candidates || []) {
2141
- const key = normalizeSpeaker(c.speaker);
2142
- if (key) bySpeaker.set(key, c);
2143
- }
2144
-
2145
- const overrides = typeOverrides || {};
2146
-
2147
- const profiles = [];
2148
- for (const raw of speakers || []) {
2149
- const speaker = normalizeSpeaker(raw);
2150
- if (!speaker) continue;
2151
-
2152
- // Check for explicit type override
2153
- const overrideType = overrides[speaker] || overrides[raw];
2154
- if (overrideType) {
2155
- const candidate = bySpeaker.get(speaker);
2156
- profiles.push({
2157
- speaker,
2158
- type: overrideType,
2159
- ...(overrideType === "browser_auto" || overrideType === "browser" ? {
2160
- provider: candidate?.provider || null,
2161
- browser: candidate?.browser || null,
2162
- title: candidate?.title || null,
2163
- url: candidate?.url || null,
2164
- } : {}),
2165
- });
2166
- continue;
2167
- }
2168
-
2169
- const candidate = bySpeaker.get(speaker);
2170
- if (!candidate) {
2171
- // Force CLI type if the speaker is available as a CLI command in PATH
2172
- if (commandExistsInPath(speaker)) {
2173
- profiles.push({
2174
- speaker,
2175
- type: "cli",
2176
- command: speaker,
2177
- });
2178
- } else {
2179
- profiles.push({
2180
- speaker,
2181
- type: "manual",
2182
- });
2183
- }
2184
- continue;
2185
- }
2186
-
2187
- if (candidate.type === "cli") {
2188
- profiles.push({
2189
- speaker,
2190
- type: "cli",
2191
- command: candidate.command || speaker,
2192
- });
2193
- continue;
2194
- }
2195
-
2196
- if (candidate.type === "telepty") {
2197
- profiles.push({
2198
- speaker,
2199
- type: "telepty",
2200
- command: candidate.command || null,
2201
- telepty_session_id: candidate.telepty_session_id || speaker,
2202
- telepty_host: candidate.telepty_host || null,
2203
- runtime_pid: Number.isFinite(candidate.runtime_pid) ? candidate.runtime_pid : null,
2204
- });
2205
- continue;
2206
- }
2207
-
2208
- const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
2209
- profiles.push({
2210
- speaker,
2211
- type: effectiveType,
2212
- provider: candidate.provider || null,
2213
- browser: candidate.browser || null,
2214
- title: candidate.title || null,
2215
- url: candidate.url || null,
2216
- });
2217
- }
2218
- return profiles;
2219
- }
2220
-
2221
- // ── Transport routing ─────────────────────────────────────────
2222
-
2223
- const TRANSPORT_TYPES = {
2224
- cli: "cli_respond",
2225
- telepty: "telepty_bus",
2226
- browser: "clipboard",
2227
- browser_auto: "browser_auto",
2228
- manual: "manual",
2229
- };
2230
-
2231
- // BrowserControlPort singleton — initialized lazily on first use
2232
- let _browserPort = null;
2233
- function getBrowserPort() {
2234
- if (!_browserPort) {
2235
- const cdpEndpoints = resolveCdpEndpoints();
2236
- _browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
2237
- }
2238
- return _browserPort;
2239
- }
2240
-
2241
- function resolveTransportForSpeaker(state, speaker) {
2242
- const normalizedSpeaker = normalizeSpeaker(speaker);
2243
- if (!normalizedSpeaker || !state?.participant_profiles) {
2244
- return { transport: "manual", reason: "no_profile" };
2245
- }
2246
- const profile = state.participant_profiles.find(
2247
- p => normalizeSpeaker(p.speaker) === normalizedSpeaker
2248
- );
2249
- if (!profile) {
2250
- return { transport: "manual", reason: "speaker_not_in_profiles" };
2251
- }
2252
- const transport = TRANSPORT_TYPES[profile.type] || "manual";
2253
- return { transport, profile, reason: null };
2254
- }
2255
-
2256
- // CLI-specific invocation flags for non-interactive execution
2257
- const CLI_INVOCATION_HINTS = {
2258
- claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
2259
- codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '--model', defaultModel: 'default', provider: 'chatgpt' },
2260
- gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '--model', provider: 'gemini' },
2261
- aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
2262
- cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
2263
- };
2264
-
2265
- function formatTransportGuidance(transport, state, speaker) {
2266
- const sid = state.id;
2267
- const profile = (state.participant_profiles || []).find(
2268
- p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
2269
- ) || null;
2270
- switch (transport) {
2271
- case "cli_respond": {
2272
- const hint = CLI_INVOCATION_HINTS[speaker] || null;
2273
- let invocationGuide = "";
2274
- let modelGuide = "";
2275
- if (hint) {
2276
- const prefix = hint.envPrefix || '';
2277
- invocationGuide = `\n\n**CLI invocation:** \`${hint.example}\`\n(flags: \`${prefix}${hint.cmd} ${hint.flags}\`)`;
2278
- if (hint.modelFlag && hint.provider) {
2279
- const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
2280
- if (cliModel.model !== 'default') {
2281
- modelGuide = `\n**Recommended model:** ${cliModel.model} (${cliModel.reason})\n**Model flag:** \`${hint.modelFlag} ${cliModel.model}\``;
2282
- }
2283
- }
2284
- }
2285
- 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.`;
2286
- }
2287
- case "clipboard":
2288
- 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` +
2289
- `📋 **Prompt has been copied to your clipboard.** (If not, copy the [turn_prompt] section below manually).\n` +
2290
- `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
2291
- `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
2292
- case "browser_auto":
2293
- 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.`;
2294
- case "telepty_bus":
2295
- return `Telepty session speaker. This turn will be published on the telepty bus as a structured \`turn_request\` envelope for the target session to consume.\n\n` +
2296
- `📡 **Bus delivery**: deliberation publishes a typed envelope instead of relying on raw PTY inject.\n` +
2297
- `⏱️ **Timeouts**: transport ack waits ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s, semantic self-submit waits ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s.\n` +
2298
- `⛔ **No proxy response**: the remote telepty session must answer for itself via \`deliberation_respond(...)\`.`;
2299
- case "manual":
2300
- default:
2301
- if (profile?.type === "telepty" && profile.telepty_session_id) {
2302
- const hostSuffix = profile.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
2303
- ? `@${profile.telepty_host}`
2304
- : "";
2305
- const pidNote = Number.isFinite(profile.runtime_pid) ? ` (pid ${profile.runtime_pid})` : "";
2306
- return `Telepty-managed session speaker${pidNote}. Send the [turn_prompt] below to \`telepty inject ${profile.telepty_session_id}${hostSuffix} "<prompt>"\`, then have that remote session self-submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
2307
- `📋 **Recommended path**: inject the prompt into the telepty session and let the remote session answer for itself.\n` +
2308
- `⛔ **No proxy response**: Do not answer on behalf of this speaker from the orchestrator.`;
2309
- }
2310
- 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` +
2311
- `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
2312
- `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
2313
- `⛔ **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.`;
2314
- }
2315
- }
2316
-
2317
- function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
2318
- const ordered = [];
2319
- const seen = new Set();
2320
-
2321
- const add = (candidate) => {
2322
- const speaker = normalizeSpeaker(candidate);
2323
- if (!speaker || seen.has(speaker)) return;
2324
- seen.add(speaker);
2325
- ordered.push(speaker);
2326
- };
2327
-
2328
- if (fallbackPlacement === "front") {
2329
- add(fallbackSpeaker);
2330
- }
2331
-
2332
- if (Array.isArray(speakers)) {
2333
- for (const speaker of speakers) {
2334
- add(speaker);
2335
- }
2336
- }
2337
-
2338
- if (fallbackPlacement !== "front") {
2339
- add(fallbackSpeaker);
2340
- }
2341
-
2342
- if (ordered.length === 0) {
2343
- for (const speaker of DEFAULT_SPEAKERS) {
2344
- add(speaker);
2345
- }
2346
- }
2347
-
2348
- return ordered;
2349
- }
2350
-
2351
- function normalizeSessionActors(state) {
2352
- if (!state || typeof state !== "object") return state;
2353
-
2354
- const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
2355
- || normalizeSpeaker(state.log?.[0]?.speaker)
2356
- || DEFAULT_SPEAKERS[0];
2357
- const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
2358
- state.speakers = speakers;
2359
-
2360
- const normalizedCurrent = normalizeSpeaker(state.current_speaker);
2361
- if (state.status === "active") {
2362
- state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
2363
- ? normalizedCurrent
2364
- : speakers[0];
2365
- } else if (normalizedCurrent) {
2366
- state.current_speaker = normalizedCurrent;
2367
- }
2368
-
2369
- return state;
2370
- }
2371
-
2372
- // ── Session ID generation ─────────────────────────────────────
2373
-
2374
- function generateSessionId(topic) {
2375
- const slug = topic
2376
- .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
2377
- .replace(/\s+/g, "-")
2378
- .toLowerCase()
2379
- .slice(0, 20);
2380
- const ts = Date.now().toString(36);
2381
- const rand = Math.random().toString(36).slice(2, 6);
2382
- return `${slug}-${ts}${rand}`;
2383
- }
2384
-
2385
- function generateTurnId() {
2386
- return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
2387
- }
2388
-
2389
- // ── Context detection ──────────────────────────────────────────
2390
-
2391
- function detectContextDirs() {
2392
- const dirs = [];
2393
- const slug = getProjectSlug();
2394
-
2395
- if (process.env.DELIBERATION_CONTEXT_DIR) {
2396
- dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
2397
- }
2398
- dirs.push(process.cwd());
2399
-
2400
- const obsidianProject = path.join(OBSIDIAN_PROJECTS, slug);
2401
- if (fs.existsSync(obsidianProject)) {
2402
- dirs.push(obsidianProject);
2403
- }
2404
-
2405
- return [...new Set(dirs)];
2406
- }
2407
-
2408
- function readContextFromDirs(dirs, maxChars = 15000) {
2409
- let context = "";
2410
- const seen = new Set();
2411
-
2412
- for (const dir of dirs) {
2413
- if (!fs.existsSync(dir)) continue;
2414
-
2415
- const files = fs.readdirSync(dir)
2416
- .filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
2417
- .sort();
2418
-
2419
- for (const file of files) {
2420
- if (seen.has(file)) continue;
2421
- seen.add(file);
2422
-
2423
- const fullPath = path.join(dir, file);
2424
- let raw;
2425
- try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
2426
-
2427
- let body = raw;
2428
- if (body.startsWith("---")) {
2429
- const end = body.indexOf("---", 3);
2430
- if (end !== -1) body = body.slice(end + 3).trim();
2431
- }
2432
-
2433
- const truncated = body.length > 1200
2434
- ? body.slice(0, 1200) + "\n(...)"
2435
- : body;
2436
-
2437
- context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
2438
-
2439
- if (context.length > maxChars) {
2440
- context = context.slice(0, maxChars) + "\n\n(...context truncated)";
2441
- return context;
2442
- }
2443
- }
2444
- }
2445
- return context || "(No context files found)";
2446
- }
2447
-
2448
- // ── State helpers ──────────────────────────────────────────────
2449
-
2450
- function ensureDirs(projectSlug = getProjectSlug()) {
2451
- fs.mkdirSync(getSessionsDir(projectSlug), { recursive: true });
2452
- fs.mkdirSync(getArchiveDir(projectSlug), { recursive: true });
2453
- fs.mkdirSync(getLocksDir(projectSlug), { recursive: true });
2454
- }
2455
-
2456
- function loadSession(sessionRef) {
2457
- const record = findSessionRecord(sessionRef);
2458
- return record?.state || null;
2459
- }
2460
-
2461
- function saveSession(state) {
2462
- ensureDirs(state.project);
2463
- state.updated = new Date().toISOString();
2464
- writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
2465
- syncMarkdown(state);
2466
- }
2467
-
2468
- function listActiveSessions(projectSlug) {
2469
- const projects = projectSlug
2470
- ? [normalizeProjectSlug(projectSlug)]
2471
- : [...new Set([getProjectSlug(), ...listStateProjects()])];
2472
-
2473
- return projects.flatMap(project => {
2474
- const dir = getSessionsDir(project);
2475
- if (!fs.existsSync(dir)) return [];
2476
-
2477
- return fs.readdirSync(dir)
2478
- .filter(f => f.endsWith(".json"))
2479
- .map(f => {
2480
- try {
2481
- const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
2482
- return normalizeSessionActors(data);
2483
- } catch {
2484
- return null;
2485
- }
2486
- })
2487
- .filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
2488
- });
2489
- }
2490
-
2491
- function resolveSessionId(sessionId) {
2492
- // Use session_id directly if provided
2493
- if (sessionId) return sessionId;
2494
-
2495
- // Auto-select when only one active session
2496
- const active = listActiveSessions();
2497
- if (active.length === 0) return null;
2498
- if (active.length === 1) return active[0].id;
2499
-
2500
- // null if multiple (need to show list)
2501
- return "MULTIPLE";
2502
- }
2503
-
2504
- function syncMarkdown(state) {
2505
- const filename = `deliberation-${state.id}.md`;
2506
- const mdPath = path.join(getProjectStateDir(state.project), filename);
2507
- try {
2508
- writeTextAtomic(mdPath, stateToMarkdown(state));
2509
- } catch { /* ignore sync failures */ }
2510
- }
2511
-
2512
- function cleanupSyncMarkdown(state) {
2513
- const filename = `deliberation-${state.id}.md`;
2514
- const statePath = path.join(getProjectStateDir(state.project), filename);
2515
- try { fs.unlinkSync(statePath); } catch { /* ignore */ }
2516
- // Also clean up legacy files in CWD (from older versions)
2517
- const cwdPath = path.join(process.cwd(), filename);
2518
- try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
2519
- }
2520
-
2521
- function stateToMarkdown(s) {
2522
- const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
2523
- let md = `---
2524
- title: "Deliberation - ${s.topic}"
2525
- session_id: "${s.id}"
2526
- created: ${s.created}
2527
- updated: ${s.updated || new Date().toISOString()}
2528
- type: deliberation
2529
- status: ${s.status}
2530
- project: "${s.project}"
2531
- participants: ${JSON.stringify(speakerOrder)}
2532
- rounds: ${s.max_rounds}
2533
- current_round: ${s.current_round}
2534
- current_speaker: "${s.current_speaker}"
2535
- tags: [deliberation]
2536
- ---
2537
-
2538
- # Deliberation: ${s.topic}
2539
-
2540
- **Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
2541
-
2542
- ---
2543
-
2544
- `;
2545
-
2546
- if (s.synthesis) {
2547
- md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
2548
- }
2549
-
2550
- md += `## Debate Log\n\n`;
2551
- for (const entry of s.log) {
2552
- md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
2553
- if (entry.channel_used || entry.fallback_reason) {
2554
- const parts = [];
2555
- if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
2556
- if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
2557
- md += `> _${parts.join(" | ")}_\n\n`;
2558
- }
2559
- md += `${entry.content}\n\n`;
2560
- if (entry.attachments && entry.attachments.length > 0) {
2561
- for (const att of entry.attachments) {
2562
- if (att.type === "image") {
2563
- md += `![Attachment](${att.path})\n\n`;
2564
- }
2565
- }
2566
- }
2567
- md += `---\n\n`;
2568
- }
2569
- return md;
2570
- }
2571
-
2572
- function archiveState(state) {
2573
- ensureDirs(state.project);
2574
- const slug = state.topic
2575
- .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
2576
- .replace(/\s+/g, "-")
2577
- .slice(0, 30);
2578
- const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
2579
- const filename = `deliberation-${ts}-${slug}.md`;
2580
- const dest = path.join(getArchiveDir(state.project), filename);
2581
- writeTextAtomic(dest, stateToMarkdown(state));
2582
- return dest;
2583
- }
2584
-
2585
- // ── Terminal management ────────────────────────────────────────
2586
-
2587
- const TMUX_SESSION = "deliberation";
2588
- const MONITOR_SCRIPT = path.join(INSTALL_DIR, "session-monitor.sh");
2589
- const MONITOR_SCRIPT_WIN = path.join(INSTALL_DIR, "session-monitor-win.js");
2590
-
2591
- function tmuxWindowName(sessionId) {
2592
- // Keep tmux window name short (remove last part, 20 chars)
2593
- return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
2594
- }
2595
-
2596
- function appleScriptQuote(value) {
2597
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2598
- }
2599
-
2600
- function tryExecFile(command, args = []) {
2601
- try {
2602
- execFileSync(command, args, { stdio: "ignore", windowsHide: true });
2603
- return true;
2604
- } catch {
2605
- return false;
2606
- }
2607
- }
2608
-
2609
- function resolveMonitorShell() {
2610
- if (commandExistsInPath("bash")) return "bash";
2611
- if (commandExistsInPath("sh")) return "sh";
2612
- return null;
2613
- }
2614
-
2615
- function buildMonitorCommand(sessionId, project) {
2616
- const shell = resolveMonitorShell();
2617
- if (!shell) return null;
2618
- return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
2619
- }
2620
-
2621
- function buildMonitorCommandWindows(sessionId, project) {
2622
- return `node "${MONITOR_SCRIPT_WIN}" "${sessionId}" "${project}"`;
2623
- }
2624
-
2625
- function hasTmuxSession(name) {
2626
- try {
2627
- execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
2628
- return true;
2629
- } catch {
2630
- return false;
2631
- }
2632
- }
2633
-
2634
- function hasTmuxWindow(sessionName, windowName) {
2635
- try {
2636
- const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
2637
- encoding: "utf-8",
2638
- stdio: ["ignore", "pipe", "ignore"],
2639
- windowsHide: true,
2640
- });
2641
- return String(output).split("\n").map(s => s.trim()).includes(windowName);
2642
- } catch {
2643
- return false;
2644
- }
2645
- }
2646
-
2647
- function tmuxHasAttachedClients(sessionName) {
2648
- try {
2649
- const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
2650
- encoding: "utf-8",
2651
- stdio: ["ignore", "pipe", "ignore"],
2652
- windowsHide: true,
2653
- });
2654
- return String(output).trim().split("\n").filter(Boolean).length > 0;
2655
- } catch {
2656
- return false;
2657
- }
2658
- }
2659
-
2660
- function isTmuxWindowViewed(sessionName, windowName) {
2661
- try {
2662
- // List all clients and check for matching window name.
2663
- // Grouped sessions (created via 'new-session -t') share the same windows,
2664
- // so checking for the window name anywhere in the client list is sufficient.
2665
- const output = execFileSync("tmux", ["list-clients", "-F", "#{window_name}"], {
2666
- encoding: "utf-8",
2667
- stdio: ["ignore", "pipe", "ignore"],
2668
- windowsHide: true,
2669
- });
2670
- return String(output).split("\n").map(s => s.trim()).filter(Boolean).includes(windowName);
2671
- } catch {
2672
- return false;
2673
- }
2674
- }
2675
-
2676
- function tmuxWindowCount(name) {
2677
- try {
2678
- const output = execFileSync("tmux", ["list-windows", "-t", name], {
2679
- encoding: "utf-8",
2680
- stdio: ["ignore", "pipe", "ignore"],
2681
- windowsHide: true,
2682
- });
2683
- return String(output)
2684
- .split("\n")
2685
- .map(line => line.trim())
2686
- .filter(Boolean)
2687
- .length;
2688
- } catch {
2689
- return 0;
2690
- }
2691
- }
2692
-
2693
- function buildTmuxAttachCommand(sessionId) {
2694
- const winName = tmuxWindowName(sessionId);
2695
- // Use grouped session (new-session -t) so each terminal has independent active window.
2696
- // This prevents window-switching conflicts when multiple deliberations run concurrently.
2697
- return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
2698
- }
2699
-
2700
- function listPhysicalTerminalWindowIds() {
2701
- if (process.platform !== "darwin") {
2702
- return [];
2703
- }
2704
- try {
2705
- const output = execFileSync(
2706
- "osascript",
2707
- [
2708
- "-e",
2709
- 'tell application "Terminal"',
2710
- "-e",
2711
- "if not running then return \"\"",
2712
- "-e",
2713
- "set outText to \"\"",
2714
- "-e",
2715
- "repeat with w in windows",
2716
- "-e",
2717
- "set outText to outText & (id of w as string) & linefeed",
2718
- "-e",
2719
- "end repeat",
2720
- "-e",
2721
- "return outText",
2722
- "-e",
2723
- "end tell",
2724
- ],
2725
- { encoding: "utf-8" }
2726
- );
2727
- return String(output)
2728
- .split("\n")
2729
- .map(s => Number.parseInt(s.trim(), 10))
2730
- .filter(n => Number.isInteger(n) && n > 0);
2731
- } catch {
2732
- return [];
2733
- }
2734
- }
2735
-
2736
- function openPhysicalTerminal(sessionId) {
2737
- const winName = tmuxWindowName(sessionId);
2738
- // Use grouped session (new-session -t) for independent active window per client
2739
- const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
2740
-
2741
- // Prevent duplicate windows for the SAME session:
2742
- // If a client is already viewing this specific window, just activate Terminal.app
2743
- if (isTmuxWindowViewed(TMUX_SESSION, winName)) {
2744
- appendRuntimeLog("INFO", `TMUX_WINDOW_ALREADY_VIEWED: ${winName}. Activating existing Terminal.`);
2745
- if (process.platform === "darwin") {
2746
- try {
2747
- execFileSync("osascript", ["-e", 'tell application "Terminal" to activate'], { stdio: "ignore" });
2748
- } catch { /* ignore */ }
2749
- }
2750
- return { opened: true, windowIds: [] };
2751
- }
2752
-
2753
- // If a terminal is already attached to OTHER windows, open a NEW grouped session
2754
- // instead of select-window (which would hijack all attached clients' views).
2755
- if (tmuxHasAttachedClients(TMUX_SESSION)) {
2756
- if (process.platform === "darwin") {
2757
- const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
2758
- try {
2759
- execFileSync(
2760
- "osascript",
2761
- [
2762
- "-e", 'tell application "Terminal"',
2763
- "-e", "activate",
2764
- "-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
2765
- "-e", "end tell",
2766
- ],
2767
- { encoding: "utf-8" }
2768
- );
2769
- return { opened: true, windowIds: [] };
2770
- } catch { /* fall through to default behavior */ }
2771
- }
2772
- // Non-macOS or fallback: don't force select-window, just report success
2773
- // The monitor window already exists in tmux; user can switch manually
2774
- return { opened: true, windowIds: [] };
2775
- }
2776
-
2777
- if (process.platform === "darwin") {
2778
- const before = new Set(listPhysicalTerminalWindowIds());
2779
- try {
2780
- const output = execFileSync(
2781
- "osascript",
2782
- [
2783
- "-e",
2784
- 'tell application "Terminal"',
2785
- "-e",
2786
- "activate",
2787
- "-e",
2788
- `do script ${appleScriptQuote(attachCmd)}`,
2789
- "-e",
2790
- "delay 0.15",
2791
- "-e",
2792
- "return id of front window",
2793
- "-e",
2794
- "end tell",
2795
- ],
2796
- { encoding: "utf-8" }
2797
- );
2798
- const frontId = Number.parseInt(String(output).trim(), 10);
2799
- const after = listPhysicalTerminalWindowIds();
2800
- const opened = after.filter(id => !before.has(id));
2801
- if (opened.length > 0) {
2802
- return { opened: true, windowIds: [...new Set(opened)] };
2803
- }
2804
- if (Number.isInteger(frontId) && frontId > 0) {
2805
- return { opened: true, windowIds: [frontId] };
2806
- }
2807
- return { opened: false, windowIds: [] };
2808
- } catch {
2809
- return { opened: false, windowIds: [] };
2810
- }
2811
- }
2812
-
2813
- if (process.platform === "linux") {
2814
- const shell = resolveMonitorShell() || "sh";
2815
- const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
2816
- const attempts = [
2817
- ["gnome-terminal", ["--", shell, "-lc", launchCmd]],
2818
- ["kgx", ["--", shell, "-lc", launchCmd]],
2819
- ["konsole", ["-e", shell, "-lc", launchCmd]],
2820
- ["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
2821
- ["xterm", ["-e", shell, "-lc", launchCmd]],
2822
- ["alacritty", ["-e", shell, "-lc", launchCmd]],
2823
- ["kitty", [shell, "-lc", launchCmd]],
2824
- ["wezterm", ["start", "--", shell, "-lc", launchCmd]],
2825
- ];
2826
-
2827
- for (const [command, args] of attempts) {
2828
- if (!commandExistsInPath(command)) continue;
2829
- if (tryExecFile(command, args)) {
2830
- return { opened: true, windowIds: [] };
2831
- }
2832
- }
2833
- return { opened: false, windowIds: [] };
2834
- }
2835
-
2836
- if (process.platform === "win32") {
2837
- // Windows: monitor is launched directly by spawnMonitorTerminal (no tmux)
2838
- // Physical terminal opening is handled there, so just return success
2839
- return { opened: true, windowIds: [] };
2840
- }
2841
-
2842
- return { opened: false, windowIds: [] };
2843
- }
2844
-
2845
- function spawnMonitorTerminal(sessionId) {
2846
- // Windows: use Windows Terminal or PowerShell directly (no tmux needed)
2847
- if (process.platform === "win32") {
2848
- const project = getProjectSlug();
2849
- const monitorCmd = buildMonitorCommandWindows(sessionId, project);
2850
-
2851
- // Try Windows Terminal (wt.exe)
2852
- if (commandExistsInPath("wt") || commandExistsInPath("wt.exe")) {
2853
- if (tryExecFile("wt", ["new-tab", "--title", "Deliberation Monitor", "cmd", "/c", monitorCmd])) {
2854
- return true;
2855
- }
2856
- }
2857
-
2858
- // Fallback: new PowerShell window
2859
- const shell = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"].find(c => commandExistsInPath(c));
2860
- if (shell) {
2861
- const escaped = monitorCmd.replace(/'/g, "''");
2862
- if (tryExecFile(shell, ["-NoProfile", "-Command", `Start-Process cmd -ArgumentList '/c','${escaped}'`])) {
2863
- return true;
2864
- }
2865
- }
2866
-
2867
- return false;
2868
- }
2869
-
2870
- // macOS/Linux: use tmux (existing logic)
2871
- if (!commandExistsInPath("tmux")) {
2872
- return false;
2873
- }
2874
-
2875
- const project = getProjectSlug();
2876
- const winName = tmuxWindowName(sessionId);
2877
- const cmd = buildMonitorCommand(sessionId, project);
2878
- if (!cmd) {
2879
- return false;
2880
- }
2881
-
2882
- try {
2883
- if (hasTmuxSession(TMUX_SESSION)) {
2884
- // Skip if a window with the same name already exists (prevents duplicates)
2885
- if (hasTmuxWindow(TMUX_SESSION, winName)) {
2886
- appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
2887
- return true;
2888
- }
2889
- execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
2890
- stdio: "ignore",
2891
- windowsHide: true,
2892
- });
2893
- appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
2894
- } else {
2895
- execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
2896
- stdio: "ignore",
2897
- windowsHide: true,
2898
- });
2899
- appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
2900
- }
2901
- return true;
2902
- } catch {
2903
- return false;
2904
- }
2905
- }
2906
-
2907
- function closePhysicalTerminal(windowId) {
2908
- if (process.platform !== "darwin") {
2909
- return false;
2910
- }
2911
- if (!Number.isInteger(windowId) || windowId <= 0) {
2912
- return false;
2913
- }
2914
-
2915
- const windowExists = () => {
2916
- try {
2917
- const out = execFileSync(
2918
- "osascript",
2919
- [
2920
- "-e",
2921
- 'tell application "Terminal"',
2922
- "-e",
2923
- `if exists window id ${windowId} then return "1"`,
2924
- "-e",
2925
- 'return "0"',
2926
- "-e",
2927
- "end tell",
2928
- ],
2929
- { encoding: "utf-8" }
2930
- ).trim();
2931
- return out === "1";
2932
- } catch {
2933
- return false;
2934
- }
2935
- };
2936
-
2937
- const dismissCloseDialogs = () => {
2938
- try {
2939
- execFileSync(
2940
- "osascript",
2941
- [
2942
- "-e",
2943
- 'tell application "System Events"',
2944
- "-e",
2945
- 'if exists process "Terminal" then',
2946
- "-e",
2947
- 'tell process "Terminal"',
2948
- "-e",
2949
- "repeat with w in windows",
2950
- "-e",
2951
- "try",
2952
- "-e",
2953
- "if exists (sheet 1 of w) then",
2954
- "-e",
2955
- "if exists button \"종료\" of sheet 1 of w then",
2956
- "-e",
2957
- 'click button "종료" of sheet 1 of w',
2958
- "-e",
2959
- "else if exists button \"Terminate\" of sheet 1 of w then",
2960
- "-e",
2961
- 'click button "Terminate" of sheet 1 of w',
2962
- "-e",
2963
- "else if exists button \"확인\" of sheet 1 of w then",
2964
- "-e",
2965
- 'click button "확인" of sheet 1 of w',
2966
- "-e",
2967
- "else",
2968
- "-e",
2969
- "click button 1 of sheet 1 of w",
2970
- "-e",
2971
- "end if",
2972
- "-e",
2973
- "end if",
2974
- "-e",
2975
- "end try",
2976
- "-e",
2977
- "end repeat",
2978
- "-e",
2979
- "end tell",
2980
- "-e",
2981
- "end if",
2982
- "-e",
2983
- "end tell",
2984
- ],
2985
- { stdio: "ignore" }
2986
- );
2987
- } catch {
2988
- // ignore
2989
- }
2990
- };
2991
-
2992
- for (let i = 0; i < 5; i += 1) {
2993
- try {
2994
- execFileSync(
2995
- "osascript",
2996
- [
2997
- "-e",
2998
- 'tell application "Terminal"',
2999
- "-e",
3000
- "activate",
3001
- "-e",
3002
- `if exists window id ${windowId} then`,
3003
- "-e",
3004
- "try",
3005
- "-e",
3006
- `do script "exit" in window id ${windowId}`,
3007
- "-e",
3008
- "end try",
3009
- "-e",
3010
- "delay 0.12",
3011
- "-e",
3012
- "try",
3013
- "-e",
3014
- `close (window id ${windowId})`,
3015
- "-e",
3016
- "end try",
3017
- "-e",
3018
- "end if",
3019
- "-e",
3020
- "end tell",
3021
- ],
3022
- { stdio: "ignore" }
3023
- );
3024
- } catch {
3025
- // ignore
3026
- }
3027
-
3028
- dismissCloseDialogs();
3029
-
3030
- if (!windowExists()) {
3031
- return true;
3032
- }
3033
- }
3034
-
3035
- return !windowExists();
3036
- }
3037
-
3038
- function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
3039
- if (process.platform !== "win32") {
3040
- const winName = tmuxWindowName(sessionId);
3041
- try {
3042
- execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
3043
- stdio: "ignore",
3044
- windowsHide: true,
3045
- });
3046
- } catch { /* ignore */ }
3047
-
3048
- try {
3049
- if (tmuxWindowCount(TMUX_SESSION) === 0) {
3050
- execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
3051
- stdio: "ignore",
3052
- windowsHide: true,
3053
- });
3054
- }
3055
- } catch { /* ignore */ }
3056
- }
3057
-
3058
- for (const windowId of terminalWindowIds) {
3059
- closePhysicalTerminal(windowId);
3060
- }
3061
- }
3062
-
3063
- function getSessionWindowIds(state) {
3064
- if (!state || typeof state !== "object") {
3065
- return [];
3066
- }
3067
- const ids = [];
3068
- if (Array.isArray(state.monitor_terminal_window_ids)) {
3069
- for (const id of state.monitor_terminal_window_ids) {
3070
- if (Number.isInteger(id) && id > 0) {
3071
- ids.push(id);
3072
- }
3073
- }
3074
- }
3075
- if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
3076
- ids.push(state.monitor_terminal_window_id);
3077
- }
3078
- return [...new Set(ids)];
3079
- }
3080
-
3081
- function closeAllMonitorTerminals() {
3082
- try {
3083
- execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
3084
- } catch { /* ignore */ }
3085
- }
3086
-
3087
- function multipleSessionsError() {
3088
- const active = listActiveSessions();
3089
- const list = active.map(s => `- **${s.id}** [${s.project || "unknown"}]: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
3090
- return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
3091
- }
3092
-
3093
- function truncatePromptText(text, maxChars) {
3094
- const value = String(text || "").trim();
3095
- if (!value || !Number.isFinite(maxChars) || maxChars <= 0 || value.length <= maxChars) {
3096
- return value;
3097
- }
3098
- const remaining = value.length - maxChars;
3099
- return `${value.slice(0, maxChars).trimEnd()}\n...(truncated ${remaining} chars)`;
3100
- }
3101
-
3102
- function getPromptBudgetForSpeaker(speaker, includeHistoryEntries = 4) {
3103
- const defaultBudget = {
3104
- maxEntries: Math.max(0, includeHistoryEntries),
3105
- maxCharsPerEntry: 1600,
3106
- maxTotalChars: 6400,
3107
- maxTopicChars: 3200,
3108
- };
3109
- switch (speaker) {
3110
- case "codex":
3111
- return {
3112
- maxEntries: Math.min(Math.max(0, includeHistoryEntries), 3),
3113
- maxCharsPerEntry: 1200,
3114
- maxTotalChars: 3600,
3115
- maxTopicChars: 2200,
3116
- };
3117
- case "gemini":
3118
- return {
3119
- maxEntries: Math.min(Math.max(0, includeHistoryEntries), 4),
3120
- maxCharsPerEntry: 1400,
3121
- maxTotalChars: 5600,
3122
- maxTopicChars: 2800,
3123
- };
3124
- default:
3125
- return defaultBudget;
3126
- }
3127
- }
480
+ fs.unlinkSync(lockPath);
481
+ continue;
482
+ }
483
+ } catch {
484
+ // lock might have been removed concurrently
485
+ }
3128
486
 
3129
- function formatRecentLogForPrompt(state, maxEntries = 4, options = {}) {
3130
- const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
3131
- if (entries.length === 0) {
3132
- return "(No previous responses yet)";
3133
- }
3134
- const maxCharsPerEntry = options.maxCharsPerEntry || 1600;
3135
- const maxTotalChars = options.maxTotalChars || maxCharsPerEntry * entries.length;
3136
- const rendered = [];
3137
- let usedChars = 0;
3138
-
3139
- for (const entry of entries) {
3140
- const header = `- ${entry.speaker} (Round ${entry.round})`;
3141
- const remainingChars = Math.max(0, maxTotalChars - usedChars - header.length - 1);
3142
- const entryBudget = Math.max(200, Math.min(maxCharsPerEntry, remainingChars || maxCharsPerEntry));
3143
- const content = truncatePromptText(entry.content, entryBudget);
3144
- const block = `${header}\n${content}`;
3145
- rendered.push(block);
3146
- usedChars += block.length + 2;
3147
- if (usedChars >= maxTotalChars) {
3148
- break;
487
+ if (Date.now() - startedAt > timeoutMs) {
488
+ throw new Error(`lock timeout: ${lockPath}`);
489
+ }
490
+ sleepMs(retryMs);
3149
491
  }
3150
492
  }
3151
-
3152
- return rendered.join("\n\n");
3153
493
  }
3154
494
 
3155
- function getCliAutoTurnTimeoutSec({ speaker, requestedTimeoutSec, promptLength, priorTurns }) {
3156
- const requested = Number.isFinite(requestedTimeoutSec) ? requestedTimeoutSec : 120;
3157
- if (speaker === "codex") {
3158
- let recommended = Math.max(requested, priorTurns === 0 ? 240 : 180);
3159
- if (promptLength > 6000) {
3160
- recommended = Math.max(recommended, 300);
3161
- }
3162
- if (promptLength > 10000 || priorTurns >= 1) {
3163
- recommended = Math.max(recommended, 420);
495
+ function releaseFileLock(lockPath, token) {
496
+ try {
497
+ const current = fs.readFileSync(lockPath, "utf-8").trim();
498
+ if (current === token) {
499
+ fs.unlinkSync(lockPath);
3164
500
  }
3165
- return recommended;
501
+ } catch {
502
+ // already released or replaced
3166
503
  }
3167
- return priorTurns === 0 ? Math.max(requested, 180) : requested;
3168
504
  }
3169
505
 
3170
- function getCliExecArgs(speaker) {
3171
- switch (speaker) {
3172
- case "claude":
3173
- return ["-p", "--output-format", "text"];
3174
- case "codex":
3175
- return [
3176
- "exec",
3177
- "--ephemeral",
3178
- "-c", 'approval_policy="never"',
3179
- "-c", 'sandbox_mode="read-only"',
3180
- "-c", 'model_reasoning_effort="low"',
3181
- "-",
3182
- ];
3183
- case "gemini":
3184
- return null;
3185
- default:
3186
- return null;
506
+ function withFileLock(lockPath, fn, options) {
507
+ const token = acquireFileLock(lockPath, options);
508
+ try {
509
+ return fn();
510
+ } finally {
511
+ releaseFileLock(lockPath, token);
3187
512
  }
3188
513
  }
3189
514
 
3190
- function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeout, promptLength, priorTurns }) {
3191
- const isTimeout = /CLI timeout \(/.test(String(err?.message || ""));
3192
- if (!isTimeout) {
3193
- return `❌ CLI auto-turn failed: ${err.message}\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n\nYou can submit a manual response via deliberation_respond(speaker: "${speaker}", content: "...").`;
515
+ function withProjectLock(projectSlug, fn, options) {
516
+ if (typeof projectSlug === "function") {
517
+ return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
3194
518
  }
3195
-
3196
- const retryTimeout = speaker === "codex"
3197
- ? Math.min(Math.max(effectiveTimeout, 420), 600)
3198
- : Math.min(effectiveTimeout + 60, 300);
3199
-
3200
- return t(
3201
- `⏱️ CLI auto-turn timed out.\n\n` +
3202
- `**Speaker:** ${speaker}\n` +
3203
- `**CLI:** ${hint.cmd}\n` +
3204
- `**Timeout:** ${effectiveTimeout}s\n` +
3205
- `**Prompt size:** ${promptLength} chars\n` +
3206
- `**Prior turns by speaker:** ${priorTurns}\n` +
3207
- `**Session state:** still waiting on ${speaker} for Round ${state.current_round}\n\n` +
3208
- `This usually means the CLI stayed busy longer than the timeout. It does **not** necessarily mean the model is down.\n` +
3209
- `${speaker === "codex" ? `Codex is the slowest CLI in recent deliberation logs, especially when recent_log contains long prior responses.\n` : ""}` +
3210
- `Recommended next step: retry with \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\`.\n` +
3211
- `Manual fallback: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3212
- `⏱️ CLI 자동 턴이 타임아웃되었습니다.\n\n` +
3213
- `**Speaker:** ${speaker}\n` +
3214
- `**CLI:** ${hint.cmd}\n` +
3215
- `**Timeout:** ${effectiveTimeout}s\n` +
3216
- `**Prompt 크기:** ${promptLength} chars\n` +
3217
- `**이 speaker의 이전 발언 수:** ${priorTurns}\n` +
3218
- `**세션 상태:** Round ${state.current_round}에서 아직 ${speaker} 응답을 기다리는 중\n\n` +
3219
- `이건 보통 CLI가 제한 시간 안에 응답을 끝내지 못했다는 뜻입니다. 모델이 완전히 죽었다는 의미는 아닙니다.\n` +
3220
- `${speaker === "codex" ? `최근 딜리버레이션 로그 기준으로 Codex는 이전 응답 전문이 길게 들어가면 가장 느린 편입니다.\n` : ""}` +
3221
- `권장 조치: \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\` 로 재시도하세요.\n` +
3222
- `수동 대안: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3223
- state?.lang
3224
- );
519
+ return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
3225
520
  }
3226
521
 
3227
- function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
3228
- const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
3229
- const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
3230
- const extraPrompt = prompt ? `\n[Additional instructions]\n${prompt}\n` : "";
3231
- const topic = truncatePromptText(state.topic, promptBudget.maxTopicChars);
3232
- const noToolRule = speaker === "codex"
3233
- ? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
3234
- : "";
3235
-
3236
- // Role prompt injection
3237
- const speakerRole = (state.speaker_roles || {})[speaker] || "free";
3238
- const rolePromptText = loadRolePrompt(speakerRole);
3239
- const roleSection = rolePromptText
3240
- ? `\n[role]\nrole: ${speakerRole}\n${rolePromptText}\n[/role]\n`
3241
- : "";
3242
-
3243
- return `[deliberation_turn_request]
3244
- session_id: ${state.id}
3245
- project: ${state.project}
3246
- topic: ${topic}
3247
- round: ${state.current_round}/${state.max_rounds}
3248
- target_speaker: ${speaker}
3249
- required_turn: ${state.current_speaker}${roleSection}
3250
-
3251
- [recent_log]
3252
- ${recent}
3253
- [/recent_log]${extraPrompt}
3254
-
3255
- [response_rule]
3256
- - Write only ${speaker}'s response for this turn reflecting the discussion context above
3257
- - Output markdown body only (no unnecessary headers/footers)${speakerRole !== "free" ? `\n- Analyze and respond from the perspective of assigned role (${speakerRole})` : ""}
3258
- - Keep the response concise and decision-oriented${noToolRule}
3259
- - Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
3260
- [/response_rule]
3261
- [/deliberation_turn_request]
3262
- `;
522
+ function withSessionLock(sessionRef, fn, options) {
523
+ const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
524
+ const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
525
+ const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
526
+ const projectSlug = explicitProject || record?.project || getProjectSlug();
527
+ const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
528
+ return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
3263
529
  }
3264
530
 
3265
- function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments }) {
3266
- const resolved = resolveSessionId(session_id);
3267
- if (!resolved) {
3268
- return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
3269
- }
3270
- if (resolved === "MULTIPLE") {
3271
- return { content: [{ type: "text", text: multipleSessionsError() }] };
3272
- }
3273
-
3274
- return withSessionLock(resolved, () => {
3275
- const state = loadSession(resolved);
3276
- if (!state || state.status !== "active") {
3277
- return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
3278
- }
3279
-
3280
- const normalizedSpeaker = normalizeSpeaker(speaker);
3281
- if (!normalizedSpeaker) {
3282
- return { content: [{ type: "text", text: t("Speaker value is empty. Please specify a speaker name.", "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요.", "en") }] };
3283
- }
3284
-
3285
- state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
3286
- const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
3287
- if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
3288
- state.current_speaker = state.speakers[0];
3289
- } else {
3290
- state.current_speaker = normalizedCurrentSpeaker;
3291
- }
3292
-
3293
- if (state.current_speaker !== normalizedSpeaker) {
3294
- return {
3295
- content: [{
3296
- type: "text",
3297
- text: t(`[${state.id}] It is currently **${state.current_speaker}**'s turn. ${normalizedSpeaker} please wait.`, `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`, state?.lang),
3298
- }],
3299
- };
3300
- }
3301
-
3302
- // turn_id validation (optional — must match if provided)
3303
- if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
3304
- return {
3305
- content: [{
3306
- type: "text",
3307
- text: t(`[${state.id}] turn_id mismatch. Expected: "${state.pending_turn_id}", received: "${turn_id}". May be a stale request or duplicate submission.`, `[${state.id}] turn_id 불일치. 예상: "${state.pending_turn_id}", 수신: "${turn_id}". 오래된 요청이거나 중복 제출일 수 있습니다.`, state?.lang),
3308
- }],
3309
- };
3310
- }
3311
-
3312
- const votes = parseVotes(content);
3313
- if (votes.length === 0) {
3314
- appendRuntimeLog("WARN", `INVALID_TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | reason: no_vote_marker`);
3315
- }
3316
- const suggestedRole = inferSuggestedRole(content);
3317
- const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
3318
- const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
3319
- state.log.push({
3320
- round: state.current_round,
3321
- speaker: normalizedSpeaker,
3322
- content,
3323
- timestamp: new Date().toISOString(),
3324
- turn_id: state.pending_turn_id || null,
3325
- channel_used: channel_used || null,
3326
- fallback_reason: fallback_reason || null,
3327
- votes: votes.length > 0 ? votes : undefined,
3328
- suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
3329
- role_drift: roleDrift || undefined,
3330
- attachments: attachments || undefined,
3331
- });
3332
- completePendingTeleptySemantic({
3333
- sessionId: state.id,
3334
- speaker: normalizedSpeaker,
3335
- turnId: state.pending_turn_id || turn_id || null,
3336
- });
3337
- 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}`);
531
+ // Speaker/Candidate Discovery functions moved to lib/speaker-discovery.js
3338
532
 
3339
- state.current_speaker = selectNextSpeaker(state);
533
+ // BrowserControlPort singleton — moved to lib/transport.js
534
+ // Session ID generation, context detection, state helpers, markdown sync,
535
+ // archival — all moved to lib/session.js
536
+ // Terminal management — moved to lib/transport.js
3340
537
 
3341
- // Round transition: check if all speakers have spoken this round
3342
- const roundEntries = state.log.filter(e => e.round === state.current_round);
3343
- const spokeSpeakers = new Set(roundEntries.map(e => e.speaker));
3344
- const allSpoke = state.speakers.every(s => spokeSpeakers.has(s));
538
+ // multipleSessionsError, truncatePromptText, getPromptBudgetForSpeaker,
539
+ // formatRecentLogForPrompt moved to lib/session.js
540
+ // getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText moved to lib/transport.js
541
+ // buildActiveReportingSection, buildClipboardTurnPrompt,
542
+ // submitDeliberationTurn — moved to lib/session.js
3345
543
 
3346
- if (allSpoke) {
3347
- if (state.current_round >= state.max_rounds) {
3348
- state.status = "awaiting_synthesis";
3349
- state.current_speaker = "none";
3350
- saveSession(state);
3351
- return {
3352
- content: [{
3353
- type: "text",
3354
- text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n🏁 **All rounds complete!**\nCreate a synthesis report with deliberation_synthesize(session_id: "${state.id}").`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n🏁 **모든 라운드 종료!**\ndeliberation_synthesize(session_id: "${state.id}")로 합성 보고서를 작성하세요.`, state?.lang),
3355
- }],
3356
- };
3357
- }
3358
- state.current_round += 1;
3359
- }
544
+ // ── MCP Server ─────────────────────────────────────────────────
3360
545
 
3361
- if (state.status === "active") {
3362
- state.pending_turn_id = generateTurnId();
546
+ // Gracefully handle EPIPE on stdout/stderr (MCP client disconnect)
547
+ for (const stream of [process.stdout, process.stderr]) {
548
+ stream.on("error", (err) => {
549
+ if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED") {
550
+ process.exit(0);
3363
551
  }
3364
-
3365
- saveSession(state);
3366
- return {
3367
- content: [{
3368
- type: "text",
3369
- text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n**Next:** ${state.current_speaker} (Round ${state.current_round})`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n**다음:** ${state.current_speaker} (Round ${state.current_round})`, state?.lang),
3370
- }],
3371
- };
3372
552
  });
3373
553
  }
3374
554
 
3375
- // ── MCP Server ─────────────────────────────────────────────────
3376
-
3377
555
  process.on("uncaughtException", (error) => {
556
+ // EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
557
+ if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") {
558
+ try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
559
+ process.exit(0);
560
+ }
3378
561
  const message = formatRuntimeError(error);
3379
562
  appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
3380
563
  try {
@@ -3385,6 +568,11 @@ process.on("uncaughtException", (error) => {
3385
568
  });
3386
569
 
3387
570
  process.on("unhandledRejection", (reason) => {
571
+ // EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
572
+ if (reason?.code === "EPIPE" || reason?.code === "ERR_STREAM_DESTROYED") {
573
+ try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
574
+ process.exit(0);
575
+ }
3388
576
  const message = formatRuntimeError(reason);
3389
577
  appendRuntimeLog("UNHANDLED_REJECTION", message);
3390
578
  try {
@@ -3398,6 +586,55 @@ process.on("unhandledRejection", (reason) => {
3398
586
  const __pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json");
3399
587
  const __pkgVersion = JSON.parse(fs.readFileSync(__pkgPath, "utf-8")).version;
3400
588
 
589
+ // ── Initialize speaker-discovery module dependencies ──
590
+ initSpeakerDeps({
591
+ appendRuntimeLog,
592
+ loadDeliberationConfig,
593
+ getProjectSlug,
594
+ readJsonFileSafe,
595
+ writeJsonFileAtomic,
596
+ getSpeakerSelectionFile,
597
+ });
598
+
599
+ // ── Initialize telepty module dependencies ──
600
+ initTeleptyDeps({
601
+ appendRuntimeLog,
602
+ normalizeSpeaker,
603
+ getProjectSlug,
604
+ resolveTransportForSpeaker,
605
+ generateTurnId,
606
+ buildClipboardTurnPrompt,
607
+ });
608
+
609
+ // ── Initialize session module dependencies ──
610
+ initSessionDeps({
611
+ appendRuntimeLog,
612
+ writeTextAtomic,
613
+ readJsonFileSafe,
614
+ writeJsonFileAtomic,
615
+ withSessionLock,
616
+ getProjectSlug,
617
+ normalizeProjectSlug,
618
+ getProjectStateDir,
619
+ getSessionsDir,
620
+ getSessionFile,
621
+ getSessionProject,
622
+ listStateProjects,
623
+ getLocksDir,
624
+ GLOBAL_STATE_DIR,
625
+ OBSIDIAN_PROJECTS,
626
+ });
627
+
628
+ // ── Initialize transport module dependencies ──
629
+ initTransportDeps({
630
+ appendRuntimeLog,
631
+ getProjectSlug,
632
+ getSessionFile,
633
+ withSessionLock,
634
+ loadDeliberationConfig,
635
+ resolveCdpEndpoints,
636
+ });
637
+
3401
638
  const server = new McpServer({
3402
639
  name: "mcp-deliberation",
3403
640
  version: __pkgVersion,
@@ -3453,8 +690,11 @@ server.tool(
3453
690
  (v) => (typeof v === "string" ? v === "true" : v),
3454
691
  z.boolean().optional()
3455
692
  ).describe("If true, automatically create a handoff task in the inbox when synthesis completes. Enables the Autonomous Deliberation Handoff pattern."),
3456
- },
3457
- safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute }) => {
693
+ mode: z.enum(["standard", "lite"]).default("standard").describe("Deliberation mode. 'lite' caps speakers to 3 and rounds to 2 for quick decisions."),
694
+ orchestrator_session_id: z.string().trim().min(1).max(128).optional()
695
+ .describe("Optional telepty session ID to notify on turn completion. Defaults to TELEPTY_SESSION_ID when available."),
696
+ },
697
+ safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute, mode, orchestrator_session_id }) => {
3458
698
  // ── First-time onboarding guard ──
3459
699
  const config = loadDeliberationConfig();
3460
700
  if (!config.setup_complete) {
@@ -3577,12 +817,22 @@ server.tool(
3577
817
  || normalizeSpeaker(hasManualSpeakers ? selectedSpeakers?.[0] : callerSpeaker)
3578
818
  || normalizeSpeaker(selectedSpeakers?.[0])
3579
819
  || DEFAULT_SPEAKERS[0];
3580
- const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
820
+ let speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
3581
821
 
3582
822
  if (effectiveRequireManual) {
3583
823
  clearSpeakerSelectionToken();
3584
824
  }
3585
825
 
826
+ // Lite mode: cap speakers and rounds for quick decisions
827
+ if (mode === "lite") {
828
+ if (speakerOrder.length > 3) {
829
+ speakerOrder.splice(3);
830
+ }
831
+ if (rounds > 2) {
832
+ rounds = 2;
833
+ }
834
+ }
835
+
3586
836
  // Warn if only 1 speaker — deliberation requires 2+
3587
837
  if (speakerOrder.length < 2) {
3588
838
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
@@ -3634,6 +884,8 @@ server.tool(
3634
884
  speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
3635
885
  degradation: degradationLevels,
3636
886
  auto_execute: auto_execute || false,
887
+ mode: mode || "standard",
888
+ orchestrator_session_id: orchestrator_session_id || getDefaultOrchestratorSessionId() || null,
3637
889
  created: new Date().toISOString(),
3638
890
  updated: new Date().toISOString(),
3639
891
  };
@@ -3922,28 +1174,22 @@ server.tool(
3922
1174
  let manualFallbackPrompt = false;
3923
1175
 
3924
1176
  if (transport === "telepty_bus") {
3925
- turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
3926
- const busReady = await ensureTeleptyBusSubscriber();
3927
- const envelope = buildTeleptyTurnRequestEnvelope({
1177
+ const dispatchResult = await dispatchTeleptyTurnRequest({
3928
1178
  state,
3929
1179
  speaker,
3930
- turnId: turnId || generateTurnId(),
3931
- turnPrompt,
1180
+ prompt,
3932
1181
  includeHistoryEntries: include_history_entries,
3933
- profile,
1182
+ awaitSemantic: false,
3934
1183
  });
3935
- const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
3936
- const publishResult = await notifyTeleptyBus(envelope);
3937
- const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
1184
+ turnPrompt = dispatchResult.turnPrompt;
1185
+ const { envelope, publishResult, transportResult, busReady, health } = dispatchResult;
3938
1186
 
3939
- if (!publishResult.ok) {
3940
- cleanupPendingTeleptyTurn(envelope.message_id);
1187
+ if (!dispatchResult.ok && dispatchResult.stage === "publish") {
3941
1188
  manualFallbackPrompt = true;
3942
1189
  extra += `\n\n❌ Telepty bus publish failed: ${publishResult.error || publishResult.status || "unknown error"}\n` +
3943
1190
  `Fallback: use manual telepty inject for this turn.`;
3944
1191
  guidance = formatTransportGuidance("manual", state, speaker);
3945
1192
  } else {
3946
- const transportResult = await pending.transportPromise;
3947
1193
  const healthLine = health
3948
1194
  ? `\n**Session health:** alive=${health.payload?.alive === true ? "yes" : "no"}, pid=${health.payload?.pid || "n/a"}, age=${Math.max(0, Math.round((health.age_ms || 0) / 1000))}s${health.stale ? " (stale)" : ""}`
3949
1195
  : "";
@@ -4182,326 +1428,7 @@ server.tool(
4182
1428
  })
4183
1429
  );
4184
1430
 
4185
- // ────────────────────────────────────────────────────────────────────────────
4186
- // Auto-handoff orchestrator helpers
4187
- // ────────────────────────────────────────────────────────────────────────────
4188
-
4189
- /**
4190
- * Run a single CLI auto-turn for the given session and speaker.
4191
- * Returns { ok: true, response, elapsedMs } or { ok: false, error }.
4192
- */
4193
- async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
4194
- const state = loadSession(sessionId);
4195
- if (!state || state.status !== "active") {
4196
- return { ok: false, error: "Session not active" };
4197
- }
4198
-
4199
- const { transport } = resolveTransportForSpeaker(state, speaker);
4200
- if (transport !== "cli_respond") {
4201
- return { ok: false, error: `Speaker "${speaker}" is not CLI type` };
4202
- }
4203
-
4204
- const hint = CLI_INVOCATION_HINTS[speaker];
4205
- if (!hint) return { ok: false, error: `No CLI hints for "${speaker}"` };
4206
- if (!checkCliLiveness(hint.cmd)) return { ok: false, error: `CLI "${hint.cmd}" not available` };
4207
-
4208
- const turnId = state.pending_turn_id || generateTurnId();
4209
- const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4210
- const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
4211
- const effectiveTimeout = getCliAutoTurnTimeoutSec({
4212
- speaker,
4213
- requestedTimeoutSec: timeoutSec,
4214
- promptLength: turnPrompt.length,
4215
- priorTurns: speakerPriorTurns,
4216
- });
4217
-
4218
- const startTime = Date.now();
4219
- try {
4220
- const response = await new Promise((resolve, reject) => {
4221
- const env = { ...process.env };
4222
- if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4223
-
4224
- let child;
4225
- let stdout = "";
4226
- let stderr = "";
4227
- let settled = false;
4228
- let forceKillTimer = null;
4229
-
4230
- const resolveOnce = (v) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); resolve(v); } };
4231
- const rejectOnce = (e) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); reject(e); } };
4232
-
4233
- switch (speaker) {
4234
- case "claude":
4235
- child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4236
- child.stdin.write(turnPrompt);
4237
- child.stdin.end();
4238
- break;
4239
- case "codex":
4240
- child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4241
- child.stdin.write(turnPrompt);
4242
- child.stdin.end();
4243
- break;
4244
- case "gemini":
4245
- child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
4246
- break;
4247
- default: {
4248
- const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4249
- child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
4250
- break;
4251
- }
4252
- }
4253
-
4254
- const timer = setTimeout(() => {
4255
- try { child.kill("SIGTERM"); } catch {}
4256
- forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5000);
4257
- if (typeof forceKillTimer?.unref === "function") forceKillTimer.unref();
4258
- rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
4259
- }, effectiveTimeout * 1000);
4260
-
4261
- child.stdout.on("data", (d) => { stdout += d.toString(); });
4262
- child.stderr.on("data", (d) => { stderr += d.toString(); });
4263
-
4264
- child.on("close", (code) => {
4265
- clearTimeout(timer);
4266
- if (code !== 0 && !stdout.trim()) {
4267
- rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
4268
- } else {
4269
- resolveOnce(stdout.trim());
4270
- }
4271
- });
4272
-
4273
- child.on("error", (err) => rejectOnce(err));
4274
- });
4275
-
4276
- // Submit the turn
4277
- submitDeliberationTurn({
4278
- session_id: sessionId,
4279
- speaker,
4280
- content: response,
4281
- turn_id: turnId,
4282
- channel_used: "cli_auto",
4283
- });
4284
-
4285
- return { ok: true, response, elapsedMs: Date.now() - startTime };
4286
- } catch (err) {
4287
- return { ok: false, error: err.message };
4288
- }
4289
- }
4290
-
4291
- /**
4292
- * Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
4293
- */
4294
- async function generateAutoSynthesis(sessionId) {
4295
- const state = loadSession(sessionId);
4296
- if (!state) return null;
4297
-
4298
- const historyText = state.log.map(e => `[${e.speaker}] ${e.content}`).join("\n\n---\n\n");
4299
-
4300
- const synthesisPrompt = `You are a deliberation synthesizer. Analyze this discussion and produce ONLY a JSON response (no markdown, no explanation).
4301
-
4302
- Topic: ${state.topic}
4303
- Project: ${state.project}
4304
- Rounds: ${state.max_rounds}
4305
-
4306
- Discussion:
4307
- ${historyText}
4308
-
4309
- Respond with EXACTLY this JSON structure:
4310
- {
4311
- "summary": "Brief summary of the outcome",
4312
- "decisions": ["Decision 1", "Decision 2"],
4313
- "actionable_tasks": [
4314
- {"id": 1, "task": "What to do", "files": ["path/to/file.ts"], "project": "${state.project}", "priority": "high|medium|low"}
4315
- ],
4316
- "markdown_synthesis": "# Full synthesis in markdown\\n\\n..."
4317
- }`;
4318
-
4319
- // Use the first available CLI speaker to generate synthesis
4320
- const speaker = state.speakers.find(s => {
4321
- const hint = CLI_INVOCATION_HINTS[s];
4322
- return hint && checkCliLiveness(hint.cmd);
4323
- });
4324
-
4325
- if (!speaker) return null;
4326
-
4327
- const hint = CLI_INVOCATION_HINTS[speaker];
4328
-
4329
- try {
4330
- const response = await new Promise((resolve, reject) => {
4331
- const env = { ...process.env };
4332
- if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4333
-
4334
- let child;
4335
- let stdout = "";
4336
-
4337
- switch (speaker) {
4338
- case "claude":
4339
- child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4340
- child.stdin.write(synthesisPrompt);
4341
- child.stdin.end();
4342
- break;
4343
- case "codex":
4344
- child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4345
- child.stdin.write(synthesisPrompt);
4346
- child.stdin.end();
4347
- break;
4348
- case "gemini":
4349
- child = spawn("gemini", ["-p", synthesisPrompt], { env, windowsHide: true });
4350
- break;
4351
- default: {
4352
- const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4353
- child = spawn(hint.cmd, [...flags, synthesisPrompt], { env, windowsHide: true });
4354
- break;
4355
- }
4356
- }
4357
-
4358
- const timer = setTimeout(() => {
4359
- try { child.kill("SIGTERM"); } catch {}
4360
- reject(new Error("Synthesis generation timeout"));
4361
- }, 180000); // 3 min timeout for synthesis
4362
-
4363
- child.stdout.on("data", (d) => { stdout += d.toString(); });
4364
- child.on("close", (code) => {
4365
- clearTimeout(timer);
4366
- resolve(stdout.trim());
4367
- });
4368
- child.on("error", reject);
4369
- });
4370
-
4371
- // Extract JSON from response (may have markdown wrapping)
4372
- const jsonMatch = response.match(/\{[\s\S]*\}/);
4373
- if (!jsonMatch) return { markdown_synthesis: response };
4374
-
4375
- try {
4376
- return JSON.parse(jsonMatch[0]);
4377
- } catch {
4378
- return { markdown_synthesis: response };
4379
- }
4380
- } catch (err) {
4381
- appendRuntimeLog("ERROR", `AUTO_SYNTHESIS_FAILED: ${sessionId} | ${err.message}`);
4382
- return null;
4383
- }
4384
- }
4385
-
4386
- /**
4387
- * Orchestrate full auto-handoff: run all turns -> synthesize -> inbox -> telepty.
4388
- * Called as fire-and-forget from deliberation_start when auto_execute is true.
4389
- */
4390
- async function runAutoHandoff(sessionId) {
4391
- appendRuntimeLog("INFO", `AUTO_HANDOFF_START: ${sessionId}`);
4392
-
4393
- try {
4394
- // Phase 1: Run all deliberation turns
4395
- let maxIterations = 100; // safety limit
4396
- while (maxIterations-- > 0) {
4397
- const state = loadSession(sessionId);
4398
- if (!state) {
4399
- appendRuntimeLog("ERROR", `AUTO_HANDOFF: Session ${sessionId} disappeared`);
4400
- return;
4401
- }
4402
- if (state.status !== "active") {
4403
- appendRuntimeLog("INFO", `AUTO_HANDOFF: Session ${sessionId} status=${state.status}, turns done`);
4404
- break;
4405
- }
4406
-
4407
- const speaker = state.current_speaker;
4408
- if (speaker === "none") break;
4409
-
4410
- appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
4411
-
4412
- const result = await runCliAutoTurnCore(sessionId, speaker);
4413
- if (!result.ok) {
4414
- appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_FAIL: ${sessionId} | speaker: ${speaker} | ${result.error}`);
4415
- // Skip this speaker, continue with next
4416
- const freshState = loadSession(sessionId);
4417
- if (freshState) {
4418
- // Advance to next speaker manually
4419
- const idx = freshState.speakers.indexOf(speaker);
4420
- const nextIdx = (idx + 1) % freshState.speakers.length;
4421
- freshState.current_speaker = freshState.speakers[nextIdx];
4422
- if (nextIdx === 0) freshState.current_round++;
4423
- if (freshState.current_round > freshState.max_rounds) {
4424
- freshState.status = "awaiting_synthesis";
4425
- freshState.current_speaker = "none";
4426
- }
4427
- saveSession(freshState);
4428
- }
4429
- continue;
4430
- }
4431
-
4432
- appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${result.elapsedMs}ms`);
4433
- }
4434
-
4435
- // Phase 2: Generate structured synthesis
4436
- appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZE: ${sessionId}`);
4437
- let synthResult = await generateAutoSynthesis(sessionId);
4438
-
4439
- // Phase 3: Call synthesize (reuse existing logic)
4440
- const state = loadSession(sessionId);
4441
- if (!state) return;
4442
-
4443
- // Fallback: if synthesis generation failed, build a basic structure from the discussion
4444
- if (!synthResult || (!synthResult.summary && !synthResult.actionable_tasks)) {
4445
- appendRuntimeLog("WARN", `AUTO_HANDOFF_SYNTH_FALLBACK: ${sessionId} | Building fallback from discussion log`);
4446
- const turns = state.log || [];
4447
- const fallbackSummary = turns.length > 0
4448
- ? `Deliberation on "${state.topic}" completed with ${turns.length} turns from ${[...new Set(turns.map(t => t.speaker))].join(", ")}.`
4449
- : `Deliberation on "${state.topic}" completed.`;
4450
- synthResult = {
4451
- summary: fallbackSummary,
4452
- decisions: [`Discussed: ${state.topic}`],
4453
- actionable_tasks: [],
4454
- markdown_synthesis: `# Auto-generated synthesis (fallback)\n\n${fallbackSummary}\n\n## Discussion\n${turns.map(t => `**${t.speaker}**: ${typeof t.content === 'string' ? t.content.substring(0, 200) : '(no content)'}${t.content && t.content.length > 200 ? '...' : ''}`).join("\n\n")}`,
4455
- };
4456
- }
4457
-
4458
- const markdownSynthesis = synthResult?.markdown_synthesis ||
4459
- `# Auto-generated synthesis\n\n${synthResult?.summary || "Deliberation completed."}\n\n## Decisions\n${(synthResult?.decisions || []).map(d => `- ${d}`).join("\n")}\n\n## Tasks\n${(synthResult?.actionable_tasks || []).map(t => `- [${t.priority}] ${t.task}`).join("\n")}`;
4460
-
4461
- const structured = {
4462
- summary: synthResult.summary || "",
4463
- decisions: synthResult.decisions || [],
4464
- actionable_tasks: synthResult.actionable_tasks || [],
4465
- };
4466
-
4467
- // Apply synthesis to session
4468
- withSessionLock(sessionId, () => {
4469
- const loaded = loadSession(sessionId);
4470
- if (!loaded) return;
4471
- loaded.synthesis = markdownSynthesis;
4472
- loaded.structured_synthesis = structured;
4473
- loaded.status = "completed";
4474
- loaded.current_speaker = "none";
4475
- saveSession(loaded);
4476
- archiveState(loaded);
4477
- cleanupSyncMarkdown(loaded);
4478
-
4479
- const sessionFile = getSessionFile(loaded);
4480
- try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch {}
4481
- });
4482
-
4483
- closeMonitorTerminal(sessionId, getSessionWindowIds(state));
4484
-
4485
- appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZED: ${sessionId}`);
4486
-
4487
- // Phase 4: Notify telepty bus with full structured data for dustcraw to consume
4488
- if (state.auto_execute) {
4489
- const envelope = buildTeleptySynthesisEnvelope({
4490
- state,
4491
- synthesis: markdownSynthesis,
4492
- structured,
4493
- });
4494
- await notifyTeleptyBus(envelope).catch(() => {});
4495
- appendRuntimeLog("INFO", `AUTO_HANDOFF_NOTIFIED: ${sessionId} | telepty event sent`);
4496
- }
4497
-
4498
- appendRuntimeLog("INFO", `AUTO_HANDOFF_COMPLETE: ${sessionId}`);
4499
- } catch (err) {
4500
- appendRuntimeLog("ERROR", `AUTO_HANDOFF_ERROR: ${sessionId} | ${err.message}`);
4501
- }
4502
- }
4503
-
4504
- // ────────────────────────────────────────────────────────────────────────────
1431
+ // Auto-handoff orchestrator helpers — moved to lib/transport.js
4505
1432
 
4506
1433
  server.tool(
4507
1434
  "deliberation_cli_auto_turn",
@@ -4724,6 +1651,56 @@ server.tool(
4724
1651
  })
4725
1652
  );
4726
1653
 
1654
+ server.tool(
1655
+ "deliberation_run_until_blocked",
1656
+ "Auto-run a deliberation across mixed transports until it completes or reaches a manual/blocking turn.",
1657
+ {
1658
+ session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
1659
+ max_turns: z.number().int().min(1).max(50).default(12).describe("Maximum number of turns to auto-run before stopping"),
1660
+ cli_timeout_sec: z.number().int().min(30).max(900).default(120).describe("CLI auto-turn timeout (seconds)"),
1661
+ browser_timeout_sec: z.number().int().min(15).max(300).default(45).describe("Browser auto-turn timeout (seconds)"),
1662
+ include_history_entries: z.number().int().min(0).max(12).default(4).describe("Recent log entries to include for telepty turns"),
1663
+ },
1664
+ safeToolHandler("deliberation_run_until_blocked", async ({ session_id, max_turns, cli_timeout_sec, browser_timeout_sec, include_history_entries }) => {
1665
+ const resolved = resolveSessionId(session_id);
1666
+ if (!resolved) {
1667
+ return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
1668
+ }
1669
+ if (resolved === "MULTIPLE") {
1670
+ return { content: [{ type: "text", text: multipleSessionsError() }] };
1671
+ }
1672
+
1673
+ const initialState = loadSession(resolved);
1674
+ if (!initialState || initialState.status !== "active") {
1675
+ return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
1676
+ }
1677
+
1678
+ const result = await runUntilBlockedCore(resolved, {
1679
+ maxTurns: max_turns,
1680
+ cliTimeoutSec: cli_timeout_sec,
1681
+ browserTimeoutSec: browser_timeout_sec,
1682
+ includeHistoryEntries: include_history_entries,
1683
+ });
1684
+ const finalState = loadSession(resolved);
1685
+ const stepsText = (result.steps || []).length > 0
1686
+ ? result.steps.map((step, index) => `- ${index + 1}. ${step.speaker} [${step.transport}] → ${step.ok ? "ok" : (step.blocked ? `blocked (${step.error || "blocked"})` : `error (${step.error || "unknown"})`)}${step.elapsedMs ? ` (${step.elapsedMs}ms)` : ""}`).join("\n")
1687
+ : "- none";
1688
+
1689
+ let summary = `## Run Until Blocked — ${resolved}\n\n`;
1690
+ summary += `**Result:** ${result.status}\n`;
1691
+ summary += `**Current state:** ${finalState?.status || initialState.status}\n`;
1692
+ summary += `**Current speaker:** ${finalState?.current_speaker || initialState.current_speaker}\n`;
1693
+ if (result.block_reason) summary += `**Block reason:** ${result.block_reason}\n`;
1694
+ if (result.error) summary += `**Error:** ${result.error}\n`;
1695
+ if (result.turn_prompt) {
1696
+ summary += `\n### [turn_prompt]\n\`\`\`markdown\n${result.turn_prompt}\n\`\`\`\n`;
1697
+ }
1698
+ summary += `\n### Steps\n${stepsText}\n`;
1699
+
1700
+ return { content: [{ type: "text", text: summary }] };
1701
+ })
1702
+ );
1703
+
4727
1704
  server.tool(
4728
1705
  "deliberation_respond",
4729
1706
  "Submit a response for the current turn.",
@@ -4807,6 +1784,51 @@ server.tool(
4807
1784
  })
4808
1785
  );
4809
1786
 
1787
+ server.tool(
1788
+ "deliberation_ingest_remote_reply",
1789
+ "Canonical semantic ingress for replies produced on another machine/session. Use this instead of reconstructing deliberation state from transport events.",
1790
+ {
1791
+ session_id: z.string().describe("Deliberation session ID"),
1792
+ speaker: z.string().describe("Speaker name"),
1793
+ turn_id: z.string().min(1).describe("Turn ID associated with the issued turn_request"),
1794
+ content: z.string().min(1).describe("Remote reply content"),
1795
+ source_machine_id: z.string().min(1).describe("Source machine or peer identifier"),
1796
+ source_session_id: z.string().min(1).describe("Source remote session identifier"),
1797
+ transport_scope: z.string().min(1).describe("Transport scope used to carry the remote reply"),
1798
+ artifact_refs: z.array(z.string().min(1)).optional().describe("Optional artifact references the reply depends on"),
1799
+ reply_origin: z.string().optional().describe("Optional origin hint, e.g. remote_mcp, telepty_thread"),
1800
+ timestamp: z.string().optional().describe("Optional source timestamp"),
1801
+ },
1802
+ safeToolHandler("deliberation_ingest_remote_reply", async ({
1803
+ session_id,
1804
+ speaker,
1805
+ turn_id,
1806
+ content,
1807
+ source_machine_id,
1808
+ source_session_id,
1809
+ transport_scope,
1810
+ artifact_refs,
1811
+ reply_origin,
1812
+ timestamp,
1813
+ }) => {
1814
+ return submitDeliberationTurn({
1815
+ session_id,
1816
+ speaker,
1817
+ content,
1818
+ turn_id,
1819
+ channel_used: `remote_ingress:${transport_scope}`,
1820
+ source_metadata: {
1821
+ source_machine_id,
1822
+ source_session_id,
1823
+ transport_scope,
1824
+ artifact_refs: artifact_refs || [],
1825
+ reply_origin: reply_origin || null,
1826
+ timestamp: timestamp || new Date().toISOString(),
1827
+ },
1828
+ });
1829
+ })
1830
+ );
1831
+
4810
1832
  server.tool(
4811
1833
  "deliberation_list_remote_sessions",
4812
1834
  "List all active deliberation sessions on a remote machine (via Tailscale/IP) to find the correct session_id for context injection.",
@@ -5012,6 +2034,7 @@ server.tool(
5012
2034
 
5013
2035
  loaded.synthesis = synthesis;
5014
2036
  loaded.structured_synthesis = structured || null;
2037
+ loaded.execution_contract = buildExecutionContract({ state: loaded, structured: structured || null });
5015
2038
  loaded.status = "completed";
5016
2039
  loaded.current_speaker = "none";
5017
2040
  saveSession(loaded);
@@ -5033,6 +2056,7 @@ server.tool(
5033
2056
  state,
5034
2057
  synthesis,
5035
2058
  structured,
2059
+ executionContract: state.execution_contract || null,
5036
2060
  });
5037
2061
 
5038
2062
  // Immediately force-close monitor terminal (including physical Terminal) on deliberation end
@@ -5043,6 +2067,24 @@ server.tool(
5043
2067
  notifyTeleptyBus(synthesisEnvelope).catch(() => {}); // fire-and-forget
5044
2068
  }
5045
2069
 
2070
+ // Notify brain ingest if endpoint configured
2071
+ callBrainIngest(state.execution_contract).catch(() => {}); // fire-and-forget
2072
+
2073
+ // Emit lesson_learned events for orchestrator lessons.json auto-population
2074
+ if (state.execution_contract?.decisions?.length > 0) {
2075
+ const lessonEvent = {
2076
+ type: "lesson_learned",
2077
+ session_id: state.id,
2078
+ timestamp: new Date().toISOString(),
2079
+ project: state.project || getProjectSlug(),
2080
+ category: "decision",
2081
+ lesson: state.execution_contract.summary || state.synthesis?.slice(0, 200) || "",
2082
+ decisions: state.execution_contract.decisions,
2083
+ };
2084
+ notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
2085
+ appendRuntimeLog("INFO", `LESSON_LEARNED: ${state.id} | decisions: ${state.execution_contract.decisions.length} | project: ${lessonEvent.project}`);
2086
+ }
2087
+
5046
2088
  return {
5047
2089
  content: [{
5048
2090
  type: "text",
@@ -5267,92 +2309,7 @@ server.tool(
5267
2309
  })
5268
2310
  );
5269
2311
 
5270
- // ── Request Review (auto-review) ───────────────────────────────
5271
-
5272
- function invokeCliReviewer(command, prompt, timeoutMs) {
5273
- const hint = CLI_INVOCATION_HINTS[command];
5274
- let args;
5275
- let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
5276
- const env = { ...process.env };
5277
-
5278
- switch (command) {
5279
- case "claude":
5280
- if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
5281
- args = ["-p", "--output-format", "text", "--no-input"];
5282
- opts.input = prompt;
5283
- break;
5284
- case "codex":
5285
- args = ["exec", "-"];
5286
- opts.input = prompt;
5287
- break;
5288
- case "gemini":
5289
- args = ["-p", prompt];
5290
- opts.stdio = ["ignore", "pipe", "pipe"];
5291
- break;
5292
- default: {
5293
- const flags = hint?.flags ? hint.flags.split(/\s+/).filter(Boolean) : ["-p"];
5294
- args = [...flags, prompt];
5295
- opts.stdio = ["ignore", "pipe", "pipe"];
5296
- break;
5297
- }
5298
- }
5299
-
5300
- try {
5301
- const result = execFileSync(command, args, { ...opts, env });
5302
- let cleaned = result;
5303
- if (command === "codex") {
5304
- const lines = result.split("\n");
5305
- const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
5306
- if (codexLineIdx !== -1) {
5307
- cleaned = lines.slice(codexLineIdx + 1)
5308
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
5309
- .join("\n");
5310
- }
5311
- }
5312
- return { ok: true, response: cleaned.trim() };
5313
- } catch (error) {
5314
- if (error && error.killed) {
5315
- return { ok: false, error: "timeout" };
5316
- }
5317
- const msg = error instanceof Error ? error.message : String(error);
5318
- return { ok: false, error: msg };
5319
- }
5320
- }
5321
-
5322
- function buildReviewPrompt(context, question, priorReviews) {
5323
- let prompt = `You are a code reviewer. Provide a concise, structured review.\n\n`;
5324
- prompt += `## Context\n${context}\n\n`;
5325
- prompt += `## Review Question\n${question}\n\n`;
5326
- if (priorReviews.length > 0) {
5327
- prompt += `## Prior Reviews\n`;
5328
- for (const r of priorReviews) {
5329
- prompt += `### ${r.reviewer}\n${r.response}\n\n`;
5330
- }
5331
- }
5332
- prompt += `Respond with your review. Be specific about issues, risks, and suggestions.`;
5333
- return prompt;
5334
- }
5335
-
5336
- function synthesizeReviews(context, question, reviews) {
5337
- if (reviews.length === 0) return "(No reviews completed)";
5338
-
5339
- let synthesis = `## Review Synthesis\n\n`;
5340
- synthesis += `**Question:** ${question}\n`;
5341
- synthesis += `**Reviews:** ${reviews.length}\n\n`;
5342
-
5343
- synthesis += `### Individual Reviews\n\n`;
5344
- for (const r of reviews) {
5345
- synthesis += `#### ${r.reviewer}\n${r.response}\n\n`;
5346
- }
5347
-
5348
- if (reviews.length > 1) {
5349
- synthesis += `### Summary\n`;
5350
- synthesis += `${reviews.length} reviewer(s) provided feedback on: ${question}\n`;
5351
- synthesis += `Reviewers: ${reviews.map(r => r.reviewer).join(", ")}\n`;
5352
- }
5353
-
5354
- return synthesis;
5355
- }
2312
+ // invokeCliReviewer, buildReviewPrompt, synthesizeReviews moved to lib/transport.js
5356
2313
 
5357
2314
  server.tool(
5358
2315
  "deliberation_request_review",
@@ -5871,6 +2828,21 @@ server.tool(
5871
2828
  actionPlan = state.actionPlan;
5872
2829
 
5873
2830
  appendRuntimeLog("INFO", `DECISION_COMPLETE: ${resolved} | conflicts_resolved: ${responses.length} | decision: ${(actionPlan?.decision || "").slice(0, 60)}`);
2831
+
2832
+ // Emit lesson_learned for decision outcomes
2833
+ if (actionPlan?.decision) {
2834
+ const lessonEvent = {
2835
+ type: "lesson_learned",
2836
+ session_id: resolved,
2837
+ timestamp: new Date().toISOString(),
2838
+ project: state.project || getProjectSlug(),
2839
+ category: "decision",
2840
+ lesson: actionPlan.decision,
2841
+ decisions: actionPlan.decision ? [actionPlan.decision] : [],
2842
+ };
2843
+ notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
2844
+ appendRuntimeLog("INFO", `LESSON_LEARNED: ${resolved} | decision: ${actionPlan.decision.slice(0, 60)}`);
2845
+ }
5874
2846
  });
5875
2847
 
5876
2848
  if (synthesisText.startsWith("❌")) {
@@ -6027,4 +2999,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
6027
2999
  }
6028
3000
 
6029
3001
  // ── Test exports (used by vitest) ──
6030
- export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
3002
+ export { checkToolEntitlement, selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };