@dmsdc-ai/aigentry-deliberation 0.0.39 → 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
@@ -69,14 +69,159 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
69
69
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
70
70
  import { z } from "zod";
71
71
  import { execFileSync, spawn } from "child_process";
72
- import { createHash } from "crypto";
73
72
  import fs from "fs";
74
73
  import path from "path";
75
74
  import { fileURLToPath } from "url";
76
75
  import os from "os";
77
- import WebSocket from "ws";
78
76
  import { OrchestratedBrowserPort } from "./browser-control-port.js";
79
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";
80
225
  import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
81
226
  import {
82
227
  DECISION_STAGES, STAGE_TRANSITIONS,
@@ -86,6 +231,63 @@ import {
86
231
  loadTemplates, matchTemplate,
87
232
  } from "./decision-engine.js";
88
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";
89
291
 
90
292
  // ── Paths ──────────────────────────────────────────────────────
91
293
 
@@ -98,29 +300,6 @@ const GLOBAL_STATE_DIR = path.join(INSTALL_DIR, "state");
98
300
  const GLOBAL_RUNTIME_LOG = path.join(INSTALL_DIR, "runtime.log");
99
301
  const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
100
302
  const OBSIDIAN_PROJECTS = path.join(OBSIDIAN_VAULT, "10-Projects");
101
- const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
102
- const DEFAULT_CLI_CANDIDATES = [
103
- "claude",
104
- "codex",
105
- "gemini",
106
- "qwen",
107
- "chatgpt",
108
- "aider",
109
- "llm",
110
- "opencode",
111
- "cursor-agent",
112
- "cursor",
113
- "continue",
114
- ];
115
- const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
116
- const TELEPTY_CONFIG_FILE = path.join(HOME, ".telepty", "config.json");
117
- const TELEPTY_DEFAULT_HOST = process.env.TELEPTY_HOST || "127.0.0.1";
118
- const TELEPTY_PORT = Number(process.env.TELEPTY_PORT || 3848);
119
- const TELEPTY_TRANSPORT_TIMEOUT_MS = 5_000;
120
- const TELEPTY_SEMANTIC_TIMEOUT_MS = 60_000;
121
- const TELEPTY_BUS_RECONNECT_MS = 5_000;
122
- const TELEPTY_SESSION_HEALTH_STALE_MS = 25_000;
123
-
124
303
  function loadDeliberationConfig() {
125
304
  const configPath = path.join(INSTALL_DIR, "config.json");
126
305
  try {
@@ -132,326 +311,16 @@ function loadDeliberationConfig() {
132
311
 
133
312
  function saveDeliberationConfig(config) {
134
313
  const configPath = path.join(INSTALL_DIR, "config.json");
314
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
135
315
  config.updated = new Date().toISOString();
136
316
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
137
317
  }
138
318
 
139
- const StructuredActionableTaskSchema = z.object({
140
- id: z.number(),
141
- task: z.string(),
142
- files: z.array(z.string()).optional(),
143
- project: z.string().optional(),
144
- priority: z.enum(["high", "medium", "low"]).optional(),
145
- });
146
-
147
- const StructuredExperimentOutcomeSchema = z.object({
148
- verdict: z.enum(["keep", "discard", "modify"]),
149
- confidence: z.number().min(0).max(1).optional(),
150
- measurement_window_hours: z.number().nonnegative().optional(),
151
- patches: z.array(z.unknown()).optional(),
152
- suggested_action: z.enum(["advance", "revert", "iterate"]).optional(),
153
- });
154
-
155
- const StructuredSynthesisSchema = z.object({
156
- summary: z.string(),
157
- decisions: z.array(z.string()),
158
- actionable_tasks: z.array(StructuredActionableTaskSchema),
159
- experiment_outcome: StructuredExperimentOutcomeSchema.optional(),
160
- });
161
-
162
- const StructuredExecutionContractSchema = z.object({
163
- schema_version: z.number().int().positive(),
164
- source_session_id: z.string().min(1),
165
- deliberation_id: z.string().min(1),
166
- summary: z.string(),
167
- decisions: z.array(z.string()),
168
- tasks: z.array(StructuredActionableTaskSchema),
169
- experiment_outcome: StructuredExperimentOutcomeSchema.nullable().optional(),
170
- unresolved_questions: z.array(z.string()),
171
- artifact_refs: z.array(z.string()),
172
- generated_from: z.object({
173
- structured_synthesis_hash: z.string().length(40),
174
- }),
175
- });
176
-
177
- const TeleptyEnvelopeSchema = z.object({
178
- version: z.number().int().positive().optional(),
179
- message_id: z.string().min(1),
180
- session_id: z.string().min(1),
181
- project: z.string().min(1),
182
- kind: z.string().min(1),
183
- source: z.string().min(1),
184
- source_host: z.string().min(1).optional(),
185
- target: z.string().min(1),
186
- reply_to: z.string().nullable().optional(),
187
- trace: z.array(z.string()),
188
- payload: z.unknown(),
189
- ts: z.string().min(1),
190
- });
191
-
192
- const TeleptyTurnRequestPayloadSchema = z.object({
193
- turn_id: z.string().min(1),
194
- round: z.number().int().positive(),
195
- max_rounds: z.number().int().positive(),
196
- speaker: z.string().min(1),
197
- role: z.string().nullable().optional(),
198
- prompt: z.string().min(1),
199
- content: z.string().min(1).describe("PTY-compatible alias for prompt field"),
200
- prompt_sha1: z.string().length(40),
201
- history_entries: z.number().int().nonnegative().optional(),
202
- transport_timeout_ms: z.number().int().positive(),
203
- semantic_timeout_ms: z.number().int().positive(),
204
- });
205
-
206
- const TeleptyTurnCompletedPayloadSchema = z.object({
207
- turn_id: z.string().nullable().optional(),
208
- speaker: z.string().min(1),
209
- round: z.number().int().positive(),
210
- max_rounds: z.number().int().positive(),
211
- next_speaker: z.string().min(1),
212
- next_round: z.number().int().positive(),
213
- status: z.string().min(1),
214
- total_responses: z.number().int().nonnegative(),
215
- channel_used: z.string().nullable().optional(),
216
- fallback_reason: z.string().nullable().optional(),
217
- orchestrator_session_id: z.string().nullable().optional(),
218
- });
219
-
220
- const TeleptyDeliberationCompletedPayloadSchema = z.object({
221
- topic: z.string(),
222
- synthesis: z.string(),
223
- structured_synthesis: StructuredSynthesisSchema.nullable().optional(),
224
- execution_contract: StructuredExecutionContractSchema.nullable().optional(),
225
- });
226
-
227
- const TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS = {
228
- turn_request: TeleptyTurnRequestPayloadSchema,
229
- turn_completed: TeleptyTurnCompletedPayloadSchema,
230
- deliberation_completed: TeleptyDeliberationCompletedPayloadSchema,
231
- };
232
-
233
- const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
234
- const DEFAULT_LLM_DOMAINS = [
235
- "chatgpt.com",
236
- "openai.com",
237
- "claude.ai",
238
- "anthropic.com",
239
- "gemini.google.com",
240
- "copilot.microsoft.com",
241
- "poe.com",
242
- "perplexity.ai",
243
- "mistral.ai",
244
- "huggingface.co/chat",
245
- "deepseek.com",
246
- "qwen.ai",
247
- "notebooklm.google.com",
248
- ];
249
-
250
- // Well-known web LLMs — always available as speaker candidates regardless of browser detection.
251
- // When a matching browser tab is detected, transport upgrades to browser_auto (CDP) or clipboard.
252
- // When no tab is detected, transport falls back to clipboard (manual paste).
253
- const DEFAULT_WEB_SPEAKERS = [
254
- { speaker: "web-chatgpt", provider: "chatgpt", name: "ChatGPT", url: "https://chatgpt.com" },
255
- { speaker: "web-claude", provider: "claude", name: "Claude", url: "https://claude.ai" },
256
- { speaker: "web-gemini", provider: "gemini", name: "Gemini", url: "https://gemini.google.com" },
257
- { speaker: "web-copilot", provider: "copilot", name: "Copilot", url: "https://copilot.microsoft.com" },
258
- { speaker: "web-perplexity", provider: "perplexity", name: "Perplexity", url: "https://perplexity.ai" },
259
- { speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://chat.deepseek.com" },
260
- { speaker: "web-mistral", provider: "mistral", name: "Mistral", url: "https://mistral.ai" },
261
- { speaker: "web-poe", provider: "poe", name: "Poe", url: "https://poe.com" },
262
- { speaker: "web-grok", provider: "grok", name: "Grok", url: "https://grok.com" },
263
- { speaker: "web-qwen", provider: "qwen", name: "Qwen", url: "https://chat.qwen.ai" },
264
- { speaker: "web-huggingchat", provider: "huggingchat", name: "HuggingChat", url: "https://huggingface.co/chat" },
265
- ];
266
-
267
- let _extensionProviderRegistry = null;
268
- const __dirnameEsm = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
269
- function loadExtensionProviderRegistry() {
270
- if (_extensionProviderRegistry) return _extensionProviderRegistry;
271
- try {
272
- const registryPath = path.join(__dirnameEsm, "selectors", "extension-providers.json");
273
- _extensionProviderRegistry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
274
- return _extensionProviderRegistry;
275
- } catch (err) {
276
- console.error("Failed to load extension-providers.json:", err.message);
277
- _extensionProviderRegistry = { providers: [] };
278
- return _extensionProviderRegistry;
279
- }
280
- }
281
-
282
- function isExtensionLlmTab(url = "", title = "") {
283
- if (!String(url).startsWith("chrome-extension://")) return false;
284
- const registry = loadExtensionProviderRegistry();
285
- const lowerTitle = String(title || "").toLowerCase();
286
- if (!lowerTitle) return false;
287
- return registry.providers.some(p =>
288
- p.titlePatterns.some(pattern => lowerTitle.includes(pattern.toLowerCase()))
289
- );
290
- }
291
-
292
- // ── Sprint 1: Smart Speaker Ordering + Persona Roles ────────────
293
-
294
- function selectNextSpeaker(session) {
295
- const { speakers, current_speaker, log, ordering_strategy } = session;
296
- switch (ordering_strategy || "cyclic") {
297
- case "random":
298
- return speakers[Math.floor(Math.random() * speakers.length)];
299
- case "weighted-random": {
300
- const window = log.slice(-(speakers.length * 2));
301
- const counts = new Map(speakers.map(s => [s, 0]));
302
- for (const entry of window) {
303
- if (counts.has(entry.speaker)) counts.set(entry.speaker, counts.get(entry.speaker) + 1);
304
- }
305
- const maxCount = Math.max(...counts.values(), 1);
306
- const weights = speakers.map(s => maxCount + 1 - counts.get(s));
307
- const total = weights.reduce((a, b) => a + b, 0);
308
- let r = Math.random() * total;
309
- for (let i = 0; i < speakers.length; i++) {
310
- r -= weights[i];
311
- if (r <= 0) return speakers[i];
312
- }
313
- return speakers[speakers.length - 1];
314
- }
315
- case "cyclic":
316
- default: {
317
- const idx = speakers.indexOf(current_speaker);
318
- return speakers[(idx + 1) % speakers.length];
319
- }
320
- }
321
- }
322
-
323
- function loadRolePrompt(role) {
324
- if (!role || role === "free") return "";
325
- try {
326
- const promptPath = path.join(__dirnameEsm, "selectors", "roles", `${role}.md`);
327
- return fs.readFileSync(promptPath, "utf-8").trim();
328
- } catch {
329
- return "";
330
- }
331
- }
332
-
333
- const ROLE_KEYWORDS = {
334
- critic: /문제|위험|실패|약점|리스크|반대|비판|결함|취약/,
335
- implementer: /구현|코드|방법|설계|빌드|개발|함수|모듈|파일/,
336
- mediator: /합의|정리|결론|종합|요약|중재|절충|균형/,
337
- researcher: /사례|데이터|연구|벤치마크|비교|논문|참고/,
338
- };
339
-
340
- const ROLE_HEADING_MARKERS = {
341
- critic: /^##?\s*(Critic|비판|약점|심각도|위험\s*분석|검증|평가|Review)/m,
342
- implementer: /^##?\s*(코드\s*스케치|구현|Implementation|제안\s*코드)/m,
343
- mediator: /^##?\s*(합의|종합|중재|Consensus|Mediation)/m,
344
- researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s*연구|근거|데이터|Data)/m,
345
- };
346
-
347
- function inferSuggestedRole(text) {
348
- const scores = {};
349
- for (const [role, pattern] of Object.entries(ROLE_KEYWORDS)) {
350
- const matches = (text.match(new RegExp(pattern, "g")) || []).length;
351
- if (matches > 0) scores[role] = matches;
352
- }
353
- // Structural heading markers get extra weight (equivalent to 5 keyword matches)
354
- for (const [role, pattern] of Object.entries(ROLE_HEADING_MARKERS)) {
355
- if (pattern.test(text)) {
356
- scores[role] = (scores[role] || 0) + 8;
357
- }
358
- }
359
- if (Object.keys(scores).length === 0) return "free";
360
- return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
361
- }
362
-
363
- function parseVotes(text) {
364
- const votes = [];
365
- for (const line of text.split("\n")) {
366
- const agree = line.match(/\[AGREE\]/i);
367
- const disagree = line.match(/\[DISAGREE\]/i);
368
- const conditional = line.match(/\[CONDITIONAL:\s*(.+?)\]/i);
369
- if (agree) votes.push({ line: line.trim(), vote: "agree" });
370
- else if (disagree) votes.push({ line: line.trim(), vote: "disagree" });
371
- else if (conditional) votes.push({ line: line.trim(), vote: "conditional", condition: conditional[1].trim() });
372
- }
373
- return votes;
374
- }
375
-
376
- let _rolePresetsCache = null;
377
- function loadRolePresets() {
378
- if (_rolePresetsCache) return _rolePresetsCache;
379
- try {
380
- const presetsPath = path.join(__dirnameEsm, "selectors", "role-presets.json");
381
- _rolePresetsCache = JSON.parse(fs.readFileSync(presetsPath, "utf-8"));
382
- return _rolePresetsCache;
383
- } catch {
384
- _rolePresetsCache = { presets: {} };
385
- return _rolePresetsCache;
386
- }
387
- }
388
-
389
- function applyRolePreset(preset, speakers) {
390
- const presets = loadRolePresets();
391
- const presetDef = presets.presets[preset];
392
- if (!presetDef) return {};
393
-
394
- const roles = presetDef.roles;
395
- const result = {};
396
- for (let i = 0; i < speakers.length; i++) {
397
- result[speakers[i]] = roles[i % roles.length];
398
- }
399
- return result;
400
- }
401
-
402
- // ── Graceful Degradation Matrix ──────────────────────────────────
403
-
404
- const DEGRADATION_TIERS = {
405
- monitoring: {
406
- tier1: { name: "tmux", description: "tmux real-time monitoring window", check: () => commandExistsInPath("tmux") },
407
- tier2: { name: "logfile", description: "Log file tail monitoring", check: () => true },
408
- tier3: { name: "silent", description: "No monitoring (log only)", check: () => true },
409
- },
410
- browser: {
411
- 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; } } },
412
- tier2: { name: "clipboard", description: "Clipboard-based manual transfer", check: () => true },
413
- tier3: { name: "manual", description: "Fully manual copy/paste", check: () => true },
414
- },
415
- terminal: {
416
- tier1: { name: "auto_open", description: "Auto-open terminal app", check: () => process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" },
417
- tier2: { name: "none", description: "Cannot auto-open terminal", check: () => true },
418
- tier3: { name: "none", description: "Cannot auto-open terminal", check: () => true },
419
- },
420
- };
421
-
422
- async function detectDegradationLevels() {
423
- const levels = {};
424
- for (const [feature, tiers] of Object.entries(DEGRADATION_TIERS)) {
425
- for (const tierKey of ["tier1", "tier2", "tier3"]) {
426
- const tier = tiers[tierKey];
427
- const available = await Promise.resolve(tier.check());
428
- if (available) {
429
- levels[feature] = { tier: tierKey, name: tier.name, description: tier.description };
430
- break;
431
- }
432
- }
433
- }
434
- return levels;
435
- }
436
-
437
- function formatDegradationReport(levels) {
438
- const lines = [];
439
- for (const [feature, info] of Object.entries(levels)) {
440
- const tierNum = parseInt(info.tier.replace("tier", ""));
441
- const indicator = tierNum === 1 ? "🟢" : tierNum === 2 ? "🟡" : "🔴";
442
- lines.push(` ${indicator} **${feature}**: ${info.name} — ${info.description}`);
443
- }
444
- return lines.join("\n");
445
- }
446
-
447
319
  const PRODUCT_DISCLAIMER = "ℹ️ This tool does not permanently modify external websites. It reads browser context in read-only mode to route speakers.";
448
320
  const LOCKS_SUBDIR = ".locks";
449
321
  const LOCK_RETRY_MS = 25;
450
322
  const LOCK_TIMEOUT_MS = 8000;
451
323
  const LOCK_STALE_MS = 60000;
452
- const SPEAKER_SELECTION_FILE = "speaker-selection.json";
453
- const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
454
-
455
324
  function getProjectSlug() {
456
325
  return path.basename(process.cwd());
457
326
  }
@@ -496,624 +365,8 @@ function listStateProjects() {
496
365
  }
497
366
  }
498
367
 
499
- function findSessionRecord(sessionRef, { preferProject, activeOnly = false } = {}) {
500
- if (!sessionRef) return null;
501
-
502
- if (typeof sessionRef === "object" && sessionRef !== null && sessionRef.id) {
503
- const project = getSessionProject(sessionRef, preferProject);
504
- const file = getSessionFile(sessionRef.id, project);
505
- const state = readJsonFileSafe(file);
506
- if (!state) return null;
507
- const normalized = normalizeSessionActors(state);
508
- if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
509
- return null;
510
- }
511
- return { file, project, state: normalized };
512
- }
513
-
514
- const sessionId = String(sessionRef);
515
- const preferred = normalizeProjectSlug(preferProject);
516
- const projects = [...new Set([preferred, ...listStateProjects()])];
517
- for (const project of projects) {
518
- const file = getSessionFile(sessionId, project);
519
- const state = readJsonFileSafe(file);
520
- if (!state) continue;
521
- const normalized = normalizeSessionActors(state);
522
- if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
523
- continue;
524
- }
525
- return { file, project: normalized.project || project, state: normalized };
526
- }
527
- return null;
528
- }
529
-
530
- const teleptyBusState = {
531
- ws: null,
532
- status: "idle",
533
- connectPromise: null,
534
- reconnectTimer: null,
535
- lastError: null,
536
- lastConnectedAt: null,
537
- lastMessageAt: null,
538
- healthBySession: new Map(),
539
- };
540
-
541
- const pendingTeleptyTurnRequests = new Map();
542
-
543
- function hashPromptText(value) {
544
- return createHash("sha1").update(String(value || "")).digest("hex");
545
- }
546
-
547
- function sortJsonValue(value) {
548
- if (Array.isArray(value)) {
549
- return value.map(sortJsonValue);
550
- }
551
- if (value && typeof value === "object") {
552
- return Object.keys(value)
553
- .sort()
554
- .reduce((acc, key) => {
555
- acc[key] = sortJsonValue(value[key]);
556
- return acc;
557
- }, {});
558
- }
559
- return value;
560
- }
561
-
562
- function hashStructuredSynthesis(structured) {
563
- return hashPromptText(JSON.stringify(sortJsonValue(structured || null)));
564
- }
565
-
566
- /**
567
- * Build a deterministic execution contract from structured synthesis.
568
- *
569
- * Data model canonical roles:
570
- * - structured_synthesis: human + reasoning canonical — rich context for
571
- * human review (decisions rationale, experiment outcomes, full task descriptions).
572
- * - execution_contract: automation canonical — minimal, deterministic task list
573
- * derived from structured_synthesis via SHA-1 hash for provenance tracking.
574
- * Consumers (inbox-watcher, devkit, registry, orchestrator) MUST prefer
575
- * execution_contract when available; fall back to structured_synthesis only
576
- * when execution_contract is absent.
577
- */
578
- function buildExecutionContract({ state, structured }) {
579
- if (!structured) return null;
580
- return {
581
- schema_version: 2,
582
- source_session_id: state.id,
583
- deliberation_id: state.id,
584
- summary: structured.summary || "",
585
- decisions: structured.decisions || [],
586
- tasks: structured.actionable_tasks || [],
587
- experiment_outcome: structured.experiment_outcome || null,
588
- unresolved_questions: [],
589
- artifact_refs: [],
590
- generated_from: {
591
- structured_synthesis_hash: hashStructuredSynthesis(structured),
592
- },
593
- };
594
- }
595
-
596
- function createEnvelopeId(prefix = "env") {
597
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
598
- }
599
-
600
- function validateTeleptyEnvelope(envelope) {
601
- const parsed = TeleptyEnvelopeSchema.parse(envelope);
602
- const payloadSchema = TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS[parsed.kind];
603
- if (payloadSchema) {
604
- payloadSchema.parse(parsed.payload);
605
- }
606
- return parsed;
607
- }
608
-
609
- function resolveTeleptySourceHost() {
610
- const explicit = process.env.TELEPTY_SOURCE_HOST;
611
- if (typeof explicit === "string" && explicit.trim()) {
612
- return explicit.trim();
613
- }
614
- const hostname = os.hostname();
615
- return typeof hostname === "string" && hostname.trim() ? hostname.trim() : undefined;
616
- }
617
-
618
- function buildTeleptyEnvelope({ session_id, project, kind, source, source_host = resolveTeleptySourceHost(), target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
619
- return validateTeleptyEnvelope({
620
- version: 1,
621
- message_id,
622
- session_id,
623
- project,
624
- kind,
625
- source,
626
- source_host,
627
- target,
628
- reply_to,
629
- trace,
630
- payload,
631
- ts,
632
- });
633
- }
634
-
635
- function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, includeHistoryEntries = 0, profile }) {
636
- const role = (state.speaker_roles || {})[speaker] || null;
637
- const target = profile?.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
638
- ? `${profile.telepty_session_id}@${profile.telepty_host}`
639
- : profile?.telepty_session_id || speaker;
640
- return buildTeleptyEnvelope({
641
- session_id: state.id,
642
- project: state.project || getProjectSlug(),
643
- kind: "turn_request",
644
- source: `deliberation:${state.id}`,
645
- target,
646
- reply_to: state.id,
647
- trace: [
648
- `project:${state.project || getProjectSlug()}`,
649
- `speaker:${speaker}`,
650
- `turn:${turnId}`,
651
- ],
652
- payload: {
653
- turn_id: turnId,
654
- round: state.current_round,
655
- max_rounds: state.max_rounds,
656
- speaker,
657
- role,
658
- prompt: turnPrompt,
659
- content: turnPrompt,
660
- prompt_sha1: hashPromptText(turnPrompt),
661
- history_entries: includeHistoryEntries,
662
- transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
663
- semantic_timeout_ms: TELEPTY_SEMANTIC_TIMEOUT_MS,
664
- },
665
- });
666
- }
667
-
668
- function buildTeleptyTurnCompletedEnvelope({ state, entry }) {
669
- return buildTeleptyEnvelope({
670
- session_id: state.id,
671
- project: state.project || getProjectSlug(),
672
- kind: "turn_completed",
673
- source: `deliberation:${state.id}`,
674
- target: "telepty-bus",
675
- reply_to: state.orchestrator_session_id || state.id,
676
- trace: [
677
- `project:${state.project || getProjectSlug()}`,
678
- `speaker:${entry.speaker}`,
679
- `turn:${entry.turn_id || "none"}`,
680
- ],
681
- payload: {
682
- turn_id: entry.turn_id || null,
683
- speaker: entry.speaker,
684
- round: entry.round,
685
- max_rounds: state.max_rounds,
686
- next_speaker: state.current_speaker || "none",
687
- next_round: state.current_round,
688
- status: state.status,
689
- total_responses: Array.isArray(state.log) ? state.log.length : 0,
690
- channel_used: entry.channel_used || null,
691
- fallback_reason: entry.fallback_reason || null,
692
- orchestrator_session_id: state.orchestrator_session_id || null,
693
- },
694
- });
695
- }
696
-
697
- function buildTeleptySynthesisEnvelope({ state, synthesis, structured, executionContract }) {
698
- const derivedExecutionContract =
699
- executionContract !== undefined
700
- ? executionContract
701
- : (structured ? buildExecutionContract({ state, structured }) : (state.execution_contract || null));
702
- return buildTeleptyEnvelope({
703
- session_id: state.id,
704
- project: state.project || getProjectSlug(),
705
- kind: "deliberation_completed",
706
- source: `deliberation:${state.id}`,
707
- target: "telepty-bus",
708
- reply_to: state.id,
709
- trace: [
710
- `project:${state.project || getProjectSlug()}`,
711
- "stage:synthesis",
712
- ],
713
- payload: {
714
- topic: state.topic,
715
- synthesis,
716
- structured_synthesis: structured || null,
717
- execution_contract: derivedExecutionContract || null,
718
- },
719
- });
720
- }
721
-
722
- function resolveTeleptyBusUrl(host = TELEPTY_DEFAULT_HOST) {
723
- const url = new URL(`ws://${host}:${TELEPTY_PORT}/api/bus`);
724
- const token = loadTeleptyAuthToken();
725
- if (token) {
726
- url.searchParams.set("token", token);
727
- }
728
- return url.toString();
729
- }
730
-
731
- function cleanupPendingTeleptyTurn(messageId) {
732
- const entry = pendingTeleptyTurnRequests.get(messageId);
733
- if (!entry) return;
734
- if (entry.transportTimer) clearTimeout(entry.transportTimer);
735
- if (entry.semanticTimer) clearTimeout(entry.semanticTimer);
736
- pendingTeleptyTurnRequests.delete(messageId);
737
- }
738
-
739
- function registerPendingTeleptyTurnRequest({ envelope, profile, speaker }) {
740
- const nowMs = Date.now();
741
- const entry = {
742
- message_id: envelope.message_id,
743
- deliberation_session_id: envelope.session_id,
744
- project: envelope.project,
745
- speaker,
746
- turn_id: envelope.payload.turn_id,
747
- target_session_id: profile?.telepty_session_id || speaker,
748
- target_host: profile?.telepty_host || TELEPTY_DEFAULT_HOST,
749
- prompt_sha1: envelope.payload.prompt_sha1,
750
- published_at: envelope.ts,
751
- transport_status: "pending",
752
- semantic_status: "pending",
753
- transport_deadline_at: new Date(nowMs + TELEPTY_TRANSPORT_TIMEOUT_MS).toISOString(),
754
- semantic_deadline_at: new Date(nowMs + TELEPTY_SEMANTIC_TIMEOUT_MS).toISOString(),
755
- };
756
- entry.transportPromise = new Promise(resolve => {
757
- entry.resolveTransport = resolve;
758
- });
759
- entry.semanticPromise = new Promise(resolve => {
760
- entry.resolveSemantic = resolve;
761
- });
762
- entry.transportTimer = setTimeout(() => {
763
- if (entry.transport_status !== "pending") return;
764
- entry.transport_status = "timeout";
765
- appendRuntimeLog("WARN", `TELEPTY_TRANSPORT_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
766
- entry.resolveTransport?.({ ok: false, code: "transport_timeout" });
767
- }, TELEPTY_TRANSPORT_TIMEOUT_MS);
768
- entry.semanticTimer = setTimeout(() => {
769
- if (entry.semantic_status !== "pending") return;
770
- entry.semantic_status = "timeout";
771
- appendRuntimeLog("WARN", `TELEPTY_SEMANTIC_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
772
- entry.resolveSemantic?.({ ok: false, code: "semantic_timeout" });
773
- setTimeout(() => cleanupPendingTeleptyTurn(entry.message_id), 5_000);
774
- }, TELEPTY_SEMANTIC_TIMEOUT_MS);
775
- pendingTeleptyTurnRequests.set(entry.message_id, entry);
776
- return entry;
777
- }
778
-
779
- function ackPendingTeleptyTurn(event) {
780
- const promptHash = hashPromptText(event?.content || "");
781
- const targetSessionId = String(event?.target_agent || "");
782
- const candidate = [...pendingTeleptyTurnRequests.values()]
783
- .filter(entry =>
784
- entry.transport_status === "pending"
785
- && entry.target_session_id === targetSessionId
786
- && entry.prompt_sha1 === promptHash
787
- )
788
- .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
789
- if (!candidate) return null;
790
-
791
- candidate.transport_status = "ack";
792
- candidate.inject_id = event.inject_id || null;
793
- candidate.transport_acked_at = new Date().toISOString();
794
- if (candidate.transportTimer) clearTimeout(candidate.transportTimer);
795
- candidate.resolveTransport?.({
796
- ok: true,
797
- code: "inject_written",
798
- inject_id: event.inject_id || null,
799
- });
800
- appendRuntimeLog("INFO", `TELEPTY_TRANSPORT_ACK: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id} | inject_id: ${event.inject_id || "n/a"}`);
801
- return candidate;
802
- }
803
-
804
- function completePendingTeleptySemantic({ sessionId, speaker, turnId }) {
805
- const candidate = [...pendingTeleptyTurnRequests.values()]
806
- .filter(entry =>
807
- entry.semantic_status === "pending"
808
- && entry.deliberation_session_id === sessionId
809
- && normalizeSpeaker(entry.speaker) === normalizeSpeaker(speaker)
810
- && (!turnId || !entry.turn_id || entry.turn_id === turnId)
811
- )
812
- .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
813
- if (!candidate) return null;
814
-
815
- candidate.semantic_status = "completed";
816
- candidate.semantic_completed_at = new Date().toISOString();
817
- if (candidate.semanticTimer) clearTimeout(candidate.semanticTimer);
818
- candidate.resolveSemantic?.({ ok: true, code: "responded" });
819
- appendRuntimeLog("INFO", `TELEPTY_SEMANTIC_COMPLETE: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id}`);
820
- setTimeout(() => cleanupPendingTeleptyTurn(candidate.message_id), 5_000);
821
- return candidate;
822
- }
823
-
824
- function updateTeleptySessionHealth(event) {
825
- const sessionId = event?.session_id;
826
- if (!sessionId) return null;
827
- const health = {
828
- session_id: sessionId,
829
- payload: event.payload || {},
830
- timestamp: event.timestamp || new Date().toISOString(),
831
- seen_at: new Date().toISOString(),
832
- };
833
- teleptyBusState.healthBySession.set(sessionId, health);
834
- return health;
835
- }
836
-
837
- function getTeleptySessionHealth(sessionId, nowMs = Date.now()) {
838
- const entry = teleptyBusState.healthBySession.get(sessionId);
839
- if (!entry) return null;
840
- const seenAtMs = Date.parse(entry.seen_at || entry.timestamp || "");
841
- const ageMs = Number.isFinite(seenAtMs) ? nowMs - seenAtMs : null;
842
- return {
843
- ...entry,
844
- age_ms: ageMs,
845
- stale: Number.isFinite(ageMs) ? ageMs > TELEPTY_SESSION_HEALTH_STALE_MS : true,
846
- };
847
- }
848
-
849
- function handleTeleptyBusMessage(raw) {
850
- let parsed = null;
851
- try {
852
- parsed = JSON.parse(String(raw));
853
- } catch {
854
- return null;
855
- }
856
- teleptyBusState.lastMessageAt = new Date().toISOString();
857
- if (!parsed || typeof parsed !== "object") return null;
858
-
859
- if (parsed.type === "inject_written") {
860
- return ackPendingTeleptyTurn(parsed);
861
- }
862
- if (parsed.type === "session_health") {
863
- return updateTeleptySessionHealth(parsed);
864
- }
865
- return parsed;
866
- }
867
-
868
- async function ensureTeleptyBusSubscriber() {
869
- if (teleptyBusState.ws && teleptyBusState.ws.readyState === WebSocket.OPEN) {
870
- return { ok: true, status: "open" };
871
- }
872
- if (teleptyBusState.connectPromise) {
873
- return teleptyBusState.connectPromise;
874
- }
875
-
876
- teleptyBusState.connectPromise = new Promise((resolve) => {
877
- try {
878
- let settled = false;
879
- const finish = (result) => {
880
- if (settled) return;
881
- settled = true;
882
- resolve(result);
883
- };
884
- teleptyBusState.status = "connecting";
885
- const ws = new WebSocket(resolveTeleptyBusUrl());
886
- teleptyBusState.ws = ws;
887
-
888
- ws.once("open", () => {
889
- teleptyBusState.status = "open";
890
- teleptyBusState.lastConnectedAt = new Date().toISOString();
891
- teleptyBusState.lastError = null;
892
- appendRuntimeLog("INFO", "TELEPTY_BUS_CONNECTED");
893
- finish({ ok: true, status: "open" });
894
- });
895
-
896
- ws.on("message", (data) => {
897
- handleTeleptyBusMessage(data.toString());
898
- });
899
-
900
- ws.on("error", (err) => {
901
- teleptyBusState.lastError = String(err?.message || err);
902
- appendRuntimeLog("WARN", `TELEPTY_BUS_ERROR: ${teleptyBusState.lastError}`);
903
- if (ws.readyState !== WebSocket.OPEN) {
904
- teleptyBusState.status = "error";
905
- teleptyBusState.ws = null;
906
- teleptyBusState.connectPromise = null;
907
- finish({ ok: false, status: "error", error: teleptyBusState.lastError });
908
- }
909
- });
910
-
911
- ws.on("close", () => {
912
- teleptyBusState.status = "closed";
913
- teleptyBusState.ws = null;
914
- teleptyBusState.connectPromise = null;
915
- if (!settled) {
916
- finish({ ok: false, status: "closed", error: teleptyBusState.lastError || "socket closed" });
917
- }
918
- if (!teleptyBusState.reconnectTimer) {
919
- teleptyBusState.reconnectTimer = setTimeout(() => {
920
- teleptyBusState.reconnectTimer = null;
921
- ensureTeleptyBusSubscriber().catch(() => {});
922
- }, TELEPTY_BUS_RECONNECT_MS);
923
- }
924
- });
925
- } catch (err) {
926
- teleptyBusState.status = "error";
927
- teleptyBusState.lastError = String(err?.message || err);
928
- teleptyBusState.connectPromise = null;
929
- resolve({ ok: false, status: "error", error: teleptyBusState.lastError });
930
- }
931
- });
932
-
933
- const result = await teleptyBusState.connectPromise;
934
- if (!result.ok) {
935
- teleptyBusState.connectPromise = null;
936
- } else if (teleptyBusState.ws?.readyState === WebSocket.OPEN) {
937
- teleptyBusState.connectPromise = null;
938
- }
939
- return result;
940
- }
941
-
942
- async function callBrainIngest(executionContract) {
943
- if (!executionContract) return { ok: false, reason: "no_contract" };
944
- try {
945
- const inboxDir = path.join(os.homedir(), ".aigentry", "inbox");
946
- if (!fs.existsSync(inboxDir)) {
947
- fs.mkdirSync(inboxDir, { recursive: true });
948
- }
949
- const fileName = `handoff-${executionContract.deliberation_id}.json`;
950
- const filePath = path.join(inboxDir, fileName);
951
- fs.writeFileSync(filePath, JSON.stringify(executionContract, null, 2), "utf8");
952
- appendRuntimeLog("INFO", `BRAIN_INGEST: wrote handoff file ${filePath}`);
953
- return { ok: true, path: filePath };
954
- } catch (err) {
955
- appendRuntimeLog("WARN", `BRAIN_INGEST: failed to write handoff file: ${err.message}`);
956
- return { ok: false, error: err.message };
957
- }
958
- }
959
-
960
- async function notifyTeleptyBus(event) {
961
- const host = process.env.TELEPTY_HOST || "localhost";
962
- const port = process.env.TELEPTY_PORT || "3848";
963
- const token = loadTeleptyAuthToken();
964
- try {
965
- const res = await fetch(`http://${host}:${port}/api/bus/publish`, {
966
- method: "POST",
967
- headers: {
968
- "Content-Type": "application/json",
969
- ...(token ? { "x-telepty-token": token } : {}),
970
- },
971
- body: JSON.stringify(event),
972
- });
973
- const data = await res.json().catch(() => null);
974
- if (res.ok) {
975
- appendRuntimeLog("INFO", `HANDOFF: Telepty bus notified: ${event.kind || event.type || "unknown"}`);
976
- return { ok: true, delivered: data?.delivered ?? null };
977
- }
978
- return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
979
- } catch (err) {
980
- appendRuntimeLog("WARN", `HANDOFF: Telepty bus notification failed: ${err.message}`);
981
- return { ok: false, error: err.message };
982
- }
983
- }
984
-
985
- function getDefaultOrchestratorSessionId() {
986
- // Check multiple env vars that may indicate an orchestrator context
987
- const candidates = [
988
- process.env.TELEPTY_SESSION_ID,
989
- process.env.DELIBERATION_ORCHESTRATOR_ID,
990
- process.env.ORCHESTRATOR_SESSION_ID,
991
- ];
992
- for (const value of candidates) {
993
- if (typeof value === "string" && value.trim()) return value.trim();
994
- }
995
- return null;
996
- }
997
-
998
- function buildTurnCompletionNotificationText(state, entry) {
999
- const nextSpeaker = state.current_speaker || "none";
1000
- const turnId = entry.turn_id || "(none)";
1001
- if (state.status === "awaiting_synthesis") {
1002
- return [
1003
- `[deliberation turn complete]`,
1004
- `session_id: ${state.id}`,
1005
- `speaker: ${entry.speaker}`,
1006
- `turn_id: ${turnId}`,
1007
- `round: ${entry.round}/${state.max_rounds}`,
1008
- `status: awaiting_synthesis`,
1009
- `responses: ${state.log.length}`,
1010
- `all rounds complete; run deliberation_synthesize(session_id: "${state.id}")`,
1011
- `no further reply needed.`,
1012
- ].join("\n");
1013
- }
1014
-
1015
- return [
1016
- `[deliberation turn complete]`,
1017
- `session_id: ${state.id}`,
1018
- `speaker: ${entry.speaker}`,
1019
- `turn_id: ${turnId}`,
1020
- `round: ${entry.round}/${state.max_rounds}`,
1021
- `status: ${state.status}`,
1022
- `next_speaker: ${nextSpeaker}`,
1023
- `next_round: ${state.current_round}/${state.max_rounds}`,
1024
- `responses: ${state.log.length}`,
1025
- `informational notification only.`,
1026
- `no further reply needed.`,
1027
- ].join("\n");
1028
- }
1029
-
1030
- async function notifyTeleptySessionInject({ targetSessionId, prompt, fromSessionId, replyToSessionId = null, host = TELEPTY_DEFAULT_HOST }) {
1031
- if (!targetSessionId || !prompt) return { ok: false, error: "missing target or prompt" };
1032
- const token = loadTeleptyAuthToken();
1033
- if (!token) return { ok: false, error: "telepty auth token unavailable" };
1034
-
1035
- try {
1036
- const response = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions/${encodeURIComponent(targetSessionId)}/inject`, {
1037
- method: "POST",
1038
- headers: {
1039
- "Content-Type": "application/json",
1040
- "x-telepty-token": token,
1041
- },
1042
- body: JSON.stringify({
1043
- prompt,
1044
- from: fromSessionId || null,
1045
- reply_to: replyToSessionId || null,
1046
- deliberation_session_id: null,
1047
- thread_id: null,
1048
- }),
1049
- });
1050
- const data = await response.json().catch(() => null);
1051
- if (!response.ok) {
1052
- return { ok: false, error: data?.error || `HTTP ${response.status}` };
1053
- }
1054
- return { ok: true, inject_id: data?.inject_id || null };
1055
- } catch (err) {
1056
- return { ok: false, error: String(err?.message || err) };
1057
- }
1058
- }
1059
-
1060
- async function dispatchTeleptyTurnRequest({ state, speaker, prompt = null, includeHistoryEntries = 4, awaitSemantic = false }) {
1061
- const { profile } = resolveTransportForSpeaker(state, speaker);
1062
- const turnId = state.pending_turn_id || generateTurnId();
1063
- const turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries);
1064
- const busReady = await ensureTeleptyBusSubscriber();
1065
- const envelope = buildTeleptyTurnRequestEnvelope({
1066
- state,
1067
- speaker,
1068
- turnId,
1069
- turnPrompt,
1070
- includeHistoryEntries,
1071
- profile,
1072
- });
1073
- const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
1074
- const publishResult = await notifyTeleptyBus(envelope);
1075
- const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
1076
-
1077
- if (!publishResult.ok) {
1078
- cleanupPendingTeleptyTurn(envelope.message_id);
1079
- return {
1080
- ok: false,
1081
- stage: "publish",
1082
- envelope,
1083
- turnPrompt,
1084
- publishResult,
1085
- busReady,
1086
- health,
1087
- };
1088
- }
1089
-
1090
- const transportResult = await pending.transportPromise;
1091
- let semanticResult = null;
1092
- if (awaitSemantic && transportResult.ok) {
1093
- semanticResult = await pending.semanticPromise;
1094
- }
1095
-
1096
- return {
1097
- ok: !awaitSemantic ? transportResult.ok : Boolean(semanticResult?.ok),
1098
- stage: awaitSemantic ? (semanticResult?.ok ? "semantic" : (semanticResult?.code || "semantic_timeout")) : (transportResult.ok ? "transport" : (transportResult?.code || "transport_timeout")),
1099
- envelope,
1100
- turnPrompt,
1101
- publishResult,
1102
- transportResult,
1103
- semanticResult,
1104
- busReady,
1105
- health,
1106
- };
1107
- }
1108
-
1109
- function getArchiveDir(projectSlug = getProjectSlug()) {
1110
- const slug = normalizeProjectSlug(projectSlug);
1111
- const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
1112
- if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, slug))) {
1113
- return obsidianDir;
1114
- }
1115
- return path.join(getProjectStateDir(slug), "archive");
1116
- }
368
+ // findSessionRecord moved to lib/session.js
369
+ // getArchiveDir moved to lib/session.js
1117
370
 
1118
371
  function getLocksDir(projectSlug = getProjectSlug()) {
1119
372
  return path.join(getProjectStateDir(projectSlug), LOCKS_SUBDIR);
@@ -1154,6 +407,15 @@ function appendRuntimeLog(level, message) {
1154
407
 
1155
408
  function safeToolHandler(toolName, handler) {
1156
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
+ }
1157
419
  try {
1158
420
  return await handler(args);
1159
421
  } catch (error) {
@@ -1251,2505 +513,51 @@ function withFileLock(lockPath, fn, options) {
1251
513
  }
1252
514
 
1253
515
  function withProjectLock(projectSlug, fn, options) {
1254
- if (typeof projectSlug === "function") {
1255
- return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
1256
- }
1257
- return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
1258
- }
1259
-
1260
- function withSessionLock(sessionRef, fn, options) {
1261
- const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
1262
- const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
1263
- const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
1264
- const projectSlug = explicitProject || record?.project || getProjectSlug();
1265
- const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
1266
- return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
1267
- }
1268
-
1269
- function normalizeSpeaker(raw) {
1270
- if (typeof raw !== "string") return null;
1271
- const normalized = raw.trim().toLowerCase();
1272
- if (!normalized || normalized === "none") return null;
1273
- return normalized;
1274
- }
1275
-
1276
- function dedupeSpeakers(items = []) {
1277
- const out = [];
1278
- const seen = new Set();
1279
- for (const item of items) {
1280
- const normalized = normalizeSpeaker(item);
1281
- if (!normalized || seen.has(normalized)) continue;
1282
- seen.add(normalized);
1283
- out.push(normalized);
1284
- }
1285
- return out;
1286
- }
1287
-
1288
- function createSelectionToken() {
1289
- return `sel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1290
- }
1291
-
1292
- function issueSpeakerSelectionToken({ candidates, include_browser }) {
1293
- const selectionState = {
1294
- token: createSelectionToken(),
1295
- phase: "candidates",
1296
- created_at: new Date().toISOString(),
1297
- include_browser: !!include_browser,
1298
- candidate_speakers: dedupeSpeakers((candidates || []).map(c => typeof c === "string" ? c : c?.speaker)),
1299
- };
1300
- writeJsonFileAtomic(getSpeakerSelectionFile(), selectionState);
1301
- return selectionState;
1302
- }
1303
-
1304
- function loadSpeakerSelectionToken() {
1305
- return readJsonFileSafe(getSpeakerSelectionFile());
1306
- }
1307
-
1308
- function clearSpeakerSelectionToken() {
1309
- try {
1310
- fs.unlinkSync(getSpeakerSelectionFile());
1311
- } catch {
1312
- // ignore missing file
1313
- }
1314
- }
1315
-
1316
- function validateSpeakerSelectionSnapshot({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1317
- if (!selection_token) {
1318
- return { ok: false, code: "missing_token" };
1319
- }
1320
- if (!selectionState?.token) {
1321
- return { ok: false, code: "missing_selection_state" };
1322
- }
1323
- if (selectionState.token !== selection_token) {
1324
- return { ok: false, code: "token_mismatch" };
1325
- }
1326
-
1327
- const createdAtMs = Date.parse(selectionState.created_at || "");
1328
- if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
1329
- return { ok: false, code: "expired_token" };
1330
- }
1331
-
1332
- if (!!selectionState.include_browser !== !!includeBrowserSpeakers) {
1333
- return { ok: false, code: "mode_mismatch" };
1334
- }
1335
-
1336
- const availableSpeakers = new Set(dedupeSpeakers(selectionState.candidate_speakers || []));
1337
- const requestedSpeakers = dedupeSpeakers(speakers || []);
1338
- const missingSpeakers = requestedSpeakers.filter(speaker => !availableSpeakers.has(speaker));
1339
- if (missingSpeakers.length > 0) {
1340
- return { ok: false, code: "speaker_mismatch", missing_speakers: missingSpeakers };
1341
- }
1342
-
1343
- return { ok: true };
1344
- }
1345
-
1346
- function confirmSpeakerSelectionToken({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now(), persist = true }) {
1347
- const snapshotValidation = validateSpeakerSelectionSnapshot({
1348
- selectionState,
1349
- selection_token,
1350
- speakers,
1351
- includeBrowserSpeakers,
1352
- nowMs,
1353
- });
1354
- if (!snapshotValidation.ok) {
1355
- return snapshotValidation;
1356
- }
1357
-
1358
- const confirmedSelection = {
1359
- token: createSelectionToken(),
1360
- phase: "confirmed",
1361
- created_at: new Date(nowMs).toISOString(),
1362
- include_browser: !!includeBrowserSpeakers,
1363
- candidate_speakers: dedupeSpeakers(selectionState.candidate_speakers || []),
1364
- selected_speakers: dedupeSpeakers(speakers || []),
1365
- };
1366
- if (persist) {
1367
- writeJsonFileAtomic(getSpeakerSelectionFile(), confirmedSelection);
1368
- }
1369
- return { ok: true, selectionState: confirmedSelection };
1370
- }
1371
-
1372
- function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1373
- const snapshotValidation = validateSpeakerSelectionSnapshot({
1374
- selectionState,
1375
- selection_token,
1376
- speakers,
1377
- includeBrowserSpeakers,
1378
- nowMs,
1379
- });
1380
- if (!snapshotValidation.ok) {
1381
- return snapshotValidation;
1382
- }
1383
-
1384
- if (selectionState.phase !== "confirmed" || !Array.isArray(selectionState.selected_speakers)) {
1385
- return { ok: false, code: "selection_not_confirmed" };
1386
- }
1387
-
1388
- const expectedSpeakers = dedupeSpeakers(selectionState.selected_speakers || []);
1389
- const requestedSpeakers = dedupeSpeakers(speakers || []);
1390
- if (
1391
- expectedSpeakers.length !== requestedSpeakers.length
1392
- || expectedSpeakers.some(speaker => !requestedSpeakers.includes(speaker))
1393
- ) {
1394
- return {
1395
- ok: false,
1396
- code: "selected_speakers_mismatch",
1397
- expected_speakers: expectedSpeakers,
1398
- requested_speakers: requestedSpeakers,
1399
- };
1400
- }
1401
-
1402
- return { ok: true };
1403
- }
1404
-
1405
- function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
1406
- const manualSpeakers = Array.isArray(speakers) ? speakers : [];
1407
- const hasBrowserSpeaker = manualSpeakers.some(speaker => {
1408
- const normalized = normalizeSpeaker(speaker);
1409
- return normalized?.startsWith("web-");
1410
- });
1411
- if (hasBrowserSpeaker) return true;
1412
-
1413
- const overrides = participant_types && typeof participant_types === "object"
1414
- ? Object.entries(participant_types)
1415
- : [];
1416
-
1417
- return overrides.some(([speaker, type]) => {
1418
- const normalized = normalizeSpeaker(speaker);
1419
- return normalized?.startsWith("web-") || type === "browser" || type === "browser_auto";
1420
- });
1421
- }
1422
-
1423
- function resolveIncludeBrowserSpeakers({ include_browser_speakers, config, speakers, participant_types } = {}) {
1424
- if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
1425
- return include_browser_speakers;
1426
- }
1427
- if (config?.include_browser_speakers !== undefined && config?.include_browser_speakers !== null) {
1428
- return config.include_browser_speakers;
1429
- }
1430
- return false;
1431
- }
1432
-
1433
- function resolveCliCandidates() {
1434
- const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
1435
- .split(/[,\s]+/)
1436
- .map(v => v.trim())
1437
- .filter(Boolean);
1438
-
1439
- // If config has enabled_clis, use that as the primary filter
1440
- const config = loadDeliberationConfig();
1441
- if (Array.isArray(config.enabled_clis) && config.enabled_clis.length > 0) {
1442
- return dedupeSpeakers([...fromEnv, ...config.enabled_clis]);
1443
- }
1444
-
1445
- return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
1446
- }
1447
-
1448
- function loadTeleptyAuthToken() {
1449
- try {
1450
- const raw = fs.readFileSync(TELEPTY_CONFIG_FILE, "utf-8");
1451
- const parsed = JSON.parse(raw);
1452
- return typeof parsed?.authToken === "string" && parsed.authToken.trim()
1453
- ? parsed.authToken.trim()
1454
- : null;
1455
- } catch {
1456
- return null;
1457
- }
1458
- }
1459
-
1460
- function formatTeleptyHostLabel(host) {
1461
- return !host || host === "127.0.0.1" || host === "localhost" ? "Local" : host;
1462
- }
1463
-
1464
- async function collectTeleptySessions() {
1465
- const token = loadTeleptyAuthToken();
1466
- if (!token) {
1467
- return { sessions: [], note: "telepty auth token not found." };
1468
- }
1469
-
1470
- const host = TELEPTY_DEFAULT_HOST;
1471
- try {
1472
- const res = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions`, {
1473
- headers: { "x-telepty-token": token },
1474
- signal: AbortSignal.timeout(1500),
1475
- });
1476
- if (!res.ok) {
1477
- return { sessions: [], note: `telepty daemon unavailable (${res.status}).` };
1478
- }
1479
- const sessions = await res.json();
1480
- if (!Array.isArray(sessions)) {
1481
- return { sessions: [], note: "telepty session response format was invalid." };
1482
- }
1483
- ensureTeleptyBusSubscriber().catch(() => {});
1484
- return {
1485
- sessions: sessions.map(session => ({ host, ...session })),
1486
- note: null,
1487
- };
1488
- } catch {
1489
- return { sessions: [], note: null };
1490
- }
1491
- }
1492
-
1493
- function scoreTeleptyProcessMatch(session, baseCommand = "", fullCommand = "") {
1494
- const base = String(baseCommand || "").toLowerCase();
1495
- const full = String(fullCommand || "").toLowerCase();
1496
- const wanted = String(session?.command || "").trim().toLowerCase();
1497
- let score = 0;
1498
-
1499
- if (wanted && (base === wanted || full.startsWith(`${wanted} `) || full.includes(` ${wanted} `))) {
1500
- score += 10;
1501
- }
1502
- if (base === "node" || base === "telepty") {
1503
- score -= 2;
1504
- }
1505
- if (full.includes("mcp-deliberation") || full.includes("oh-my-claudecode") || full.includes("bridge/mcp-server")) {
1506
- score -= 3;
1507
- }
1508
- return score;
1509
- }
1510
-
1511
- function collectTeleptyProcessLocators(sessions = []) {
1512
- const wantedSessions = new Map(
1513
- sessions
1514
- .filter(session => session?.id)
1515
- .map(session => [String(session.id), session])
1516
- );
1517
- if (wantedSessions.size === 0) {
1518
- return new Map();
1519
- }
1520
-
1521
- try {
1522
- const env = {
1523
- HOME: process.env.HOME,
1524
- PATH: process.env.PATH,
1525
- SHELL: process.env.SHELL,
1526
- USER: process.env.USER,
1527
- LOGNAME: process.env.LOGNAME,
1528
- TERM: process.env.TERM,
1529
- };
1530
- const raw = execFileSync("ps", ["eww", "-axo", "pid=,tty=,comm=,command="], {
1531
- encoding: "utf-8",
1532
- windowsHide: true,
1533
- timeout: 2500,
1534
- maxBuffer: 8 * 1024 * 1024,
1535
- env,
1536
- });
1537
-
1538
- const best = new Map();
1539
- for (const line of String(raw).split("\n")) {
1540
- if (!line.includes("TELEPTY_SESSION_ID=")) continue;
1541
- const match = line.match(/^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/);
1542
- if (!match) continue;
1543
- const [, pid, tty, comm, command] = match;
1544
- const sessionIdMatch = command.match(/(?:^|\s)TELEPTY_SESSION_ID=([^\s]+)/);
1545
- const sessionId = sessionIdMatch?.[1];
1546
- if (!sessionId || !wantedSessions.has(sessionId)) continue;
1547
-
1548
- const session = wantedSessions.get(sessionId);
1549
- const score = scoreTeleptyProcessMatch(session, comm, command);
1550
- const current = best.get(sessionId);
1551
- if (!current || score > current.score) {
1552
- best.set(sessionId, { pid: Number(pid), tty, score });
1553
- }
1554
- }
1555
-
1556
- return new Map(
1557
- [...best.entries()].map(([sessionId, value]) => [
1558
- sessionId,
1559
- { pid: Number.isFinite(value.pid) ? value.pid : null, tty: value.tty || null },
1560
- ])
1561
- );
1562
- } catch {
1563
- return new Map();
1564
- }
1565
- }
1566
-
1567
- function commandExistsInPath(command) {
1568
- if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
1569
- return false;
1570
- }
1571
-
1572
- if (process.platform === "win32") {
1573
- try {
1574
- execFileSync("where", [command], { stdio: "ignore" });
1575
- return true;
1576
- } catch {
1577
- // keep PATH scan fallback for shells where "where" is unavailable
1578
- }
1579
- }
1580
-
1581
- const pathVar = process.env.PATH || "";
1582
- const dirs = pathVar.split(path.delimiter).filter(Boolean);
1583
- if (dirs.length === 0) return false;
1584
-
1585
- const extensions = process.platform === "win32"
1586
- ? ["", ".exe", ".cmd", ".bat", ".ps1"]
1587
- : [""];
1588
-
1589
- for (const dir of dirs) {
1590
- for (const ext of extensions) {
1591
- const fullPath = path.join(dir, `${command}${ext}`);
1592
- try {
1593
- fs.accessSync(fullPath, fs.constants.X_OK);
1594
- return true;
1595
- } catch {
1596
- // ignore and continue
1597
- }
1598
- }
1599
- }
1600
- return false;
1601
- }
1602
-
1603
- function shellQuote(value) {
1604
- return `'${String(value).replace(/'/g, "'\\''")}'`;
1605
- }
1606
-
1607
- function checkCliLiveness(command) {
1608
- const hint = CLI_INVOCATION_HINTS[command];
1609
- const env = { ...process.env };
1610
- // Unset CLAUDECODE to avoid nested session errors
1611
- if (hint?.envPrefix?.includes("CLAUDECODE=")) {
1612
- delete env.CLAUDECODE;
1613
- }
1614
- try {
1615
- execFileSync(command, ["--version"], {
1616
- stdio: "ignore",
1617
- windowsHide: true,
1618
- timeout: 5000,
1619
- env,
1620
- });
1621
- return true;
1622
- } catch {
1623
- // --version failed, try --help as fallback
1624
- try {
1625
- execFileSync(command, ["--help"], {
1626
- stdio: "ignore",
1627
- windowsHide: true,
1628
- timeout: 5000,
1629
- env,
1630
- });
1631
- return true;
1632
- } catch {
1633
- return false;
1634
- }
1635
- }
1636
- }
1637
-
1638
- function discoverLocalCliSpeakers() {
1639
- const found = [];
1640
- for (const candidate of resolveCliCandidates()) {
1641
- if (commandExistsInPath(candidate)) {
1642
- found.push(candidate);
1643
- }
1644
- if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
1645
- break;
1646
- }
1647
- }
1648
- return found;
1649
- }
1650
-
1651
- function detectCallerSpeaker() {
1652
- const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
1653
- if (hinted) return hinted;
1654
-
1655
- const envKeys = Object.keys(process.env).join(" ");
1656
- const pathHint = process.env.PATH || "";
1657
-
1658
- // Codex detection
1659
- if (/\bCODEX_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.codex/")) {
1660
- return "codex";
1661
- }
1662
-
1663
- // Claude detection
1664
- if (/\bCLAUDE_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.claude/")) {
1665
- return "claude";
1666
- }
1667
-
1668
- // Gemini detection
1669
- if (/\bGOOGLE_GENAI_[A-Z0-9_]+\b/.test(envKeys) || /\bGEMINI_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.gemini/")) {
1670
- return "gemini";
1671
- }
1672
-
1673
- // Aider detection
1674
- if (/\bAIDER_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/aider/")) {
1675
- return "aider";
1676
- }
1677
-
1678
- return null;
1679
- }
1680
-
1681
- function isLlmUrl(url = "") {
1682
- const value = String(url || "").trim();
1683
- if (!value) return false;
1684
- try {
1685
- const parsed = new URL(value);
1686
- const host = parsed.hostname.toLowerCase();
1687
- return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
1688
- } catch {
1689
- const lowered = value.toLowerCase();
1690
- return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
1691
- }
1692
- }
1693
-
1694
- function dedupeBrowserTabs(tabs = []) {
1695
- const out = [];
1696
- const seen = new Set();
1697
- for (const tab of tabs) {
1698
- const browser = String(tab?.browser || "").trim();
1699
- const title = String(tab?.title || "").trim();
1700
- const url = String(tab?.url || "").trim();
1701
- if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
1702
- // Dedup by title+url (ignore browser name) so that the same tab detected
1703
- // via both AppleScript and CDP is not duplicated. The first occurrence wins,
1704
- // so callers should add preferred sources first (e.g., CDP before AppleScript).
1705
- const key = `${title}\t${url}`;
1706
- if (seen.has(key)) continue;
1707
- seen.add(key);
1708
- out.push({
1709
- browser: browser || "Browser",
1710
- title: title || "(untitled)",
1711
- url,
1712
- });
1713
- }
1714
- return out;
1715
- }
1716
-
1717
- function parseInjectedBrowserTabsFromEnv() {
1718
- const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
1719
- if (!raw) {
1720
- return { tabs: [], note: null };
1721
- }
1722
-
1723
- try {
1724
- const parsed = JSON.parse(raw);
1725
- if (!Array.isArray(parsed)) {
1726
- return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON format error: must be a JSON array." };
1727
- }
1728
-
1729
- const tabs = dedupeBrowserTabs(parsed.map(item => ({
1730
- browser: item?.browser || "External Bridge",
1731
- title: item?.title || "(untitled)",
1732
- url: item?.url || "",
1733
- })));
1734
- return {
1735
- tabs,
1736
- note: tabs.length > 0 ? `Environment variable tab injection: ${tabs.length} tabs` : "No valid LLM URLs found in DELIBERATION_BROWSER_TABS_JSON.",
1737
- };
1738
- } catch (error) {
1739
- const reason = error instanceof Error ? error.message : "unknown error";
1740
- return { tabs: [], note: `Failed to parse DELIBERATION_BROWSER_TABS_JSON: ${reason}` };
1741
- }
1742
- }
1743
-
1744
- function normalizeCdpEndpoint(raw) {
1745
- const value = String(raw || "").trim();
1746
- if (!value) return null;
1747
-
1748
- const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
1749
- try {
1750
- const url = new URL(withProto);
1751
- if (!url.pathname || url.pathname === "/") {
1752
- url.pathname = "/json/list";
1753
- }
1754
- return url.toString();
1755
- } catch {
1756
- return null;
1757
- }
1758
- }
1759
-
1760
- function resolveCdpEndpoints() {
1761
- const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
1762
- .split(/[,\s]+/)
1763
- .map(v => normalizeCdpEndpoint(v))
1764
- .filter(Boolean);
1765
- if (fromEnv.length > 0) {
1766
- return [...new Set(fromEnv)];
1767
- }
1768
-
1769
- const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
1770
- .split(/[,\s]+/)
1771
- .map(v => Number.parseInt(v, 10))
1772
- .filter(v => Number.isInteger(v) && v > 0 && v < 65536);
1773
-
1774
- const endpoints = [];
1775
- for (const port of ports) {
1776
- endpoints.push(`http://127.0.0.1:${port}/json/list`);
1777
- endpoints.push(`http://localhost:${port}/json/list`);
1778
- }
1779
- return [...new Set(endpoints)];
1780
- }
1781
-
1782
- async function fetchJson(url, timeoutMs = 900) {
1783
- if (typeof fetch !== "function") {
1784
- throw new Error("fetch API unavailable in current Node runtime");
1785
- }
1786
-
1787
- const controller = new AbortController();
1788
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1789
- try {
1790
- const response = await fetch(url, {
1791
- method: "GET",
1792
- signal: controller.signal,
1793
- headers: { "accept": "application/json" },
1794
- });
1795
- if (!response.ok) {
1796
- throw new Error(`HTTP ${response.status}`);
1797
- }
1798
- return await response.json();
1799
- } finally {
1800
- clearTimeout(timer);
1801
- }
1802
- }
1803
-
1804
- function inferBrowserFromCdpEndpoint(endpoint) {
1805
- try {
1806
- const parsed = new URL(endpoint);
1807
- const port = Number.parseInt(parsed.port, 10);
1808
- if (port === 9222) return "Google Chrome (CDP)";
1809
- if (port === 9223) return "Microsoft Edge (CDP)";
1810
- if (port === 9333) return "Brave Browser (CDP)";
1811
- return `Browser (CDP:${parsed.host})`;
1812
- } catch {
1813
- return "Browser (CDP)";
1814
- }
1815
- }
1816
-
1817
- function summarizeFailures(items = [], max = 3) {
1818
- if (!Array.isArray(items) || items.length === 0) return null;
1819
- const shown = items.slice(0, max);
1820
- const suffix = items.length > max ? ` and ${items.length - max} more` : "";
1821
- return `${shown.join(", ")}${suffix}`;
1822
- }
1823
-
1824
- async function collectBrowserLlmTabsViaCdp() {
1825
- const endpoints = resolveCdpEndpoints();
1826
- const tabs = [];
1827
- const failures = [];
1828
-
1829
- for (const endpoint of endpoints) {
1830
- try {
1831
- const payload = await fetchJson(endpoint);
1832
- if (!Array.isArray(payload)) {
1833
- throw new Error("unexpected payload");
1834
- }
1835
-
1836
- const browser = inferBrowserFromCdpEndpoint(endpoint);
1837
- for (const item of payload) {
1838
- if (!item || String(item.type) !== "page") continue;
1839
- const url = String(item.url || "").trim();
1840
- const title = String(item.title || "").trim();
1841
- if (!isLlmUrl(url) && !isExtensionLlmTab(url, title)) continue;
1842
- tabs.push({
1843
- browser,
1844
- title: title || "(untitled)",
1845
- url,
1846
- });
1847
- }
1848
- } catch (error) {
1849
- const reason = error instanceof Error ? error.message : "unknown error";
1850
- failures.push(`${endpoint} (${reason})`);
1851
- }
1852
- }
1853
-
1854
- const uniqTabs = dedupeBrowserTabs(tabs);
1855
- if (uniqTabs.length > 0) {
1856
- const failSummary = summarizeFailures(failures);
1857
- return {
1858
- tabs: uniqTabs,
1859
- note: failSummary ? `Some CDP endpoint access failed: ${failSummary}` : null,
1860
- };
1861
- }
1862
-
1863
- const failSummary = summarizeFailures(failures);
1864
- return {
1865
- tabs: [],
1866
- 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})` : ""}`,
1867
- };
1868
- }
1869
-
1870
- async function ensureCdpAvailable() {
1871
- const endpoints = resolveCdpEndpoints();
1872
-
1873
- // First attempt: try existing CDP endpoints
1874
- for (const endpoint of endpoints) {
1875
- try {
1876
- const payload = await fetchJson(endpoint, 1500);
1877
- if (Array.isArray(payload)) {
1878
- return { available: true, endpoint };
1879
- }
1880
- } catch { /* not reachable */ }
1881
- }
1882
-
1883
- // Auto-launch Chrome with CDP on macOS, Linux, and Windows
1884
- {
1885
- let chromeBin, chromeUserDataDir;
1886
-
1887
- if (process.platform === "darwin") {
1888
- chromeBin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
1889
- chromeUserDataDir = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome");
1890
- } else if (process.platform === "linux") {
1891
- const chromeCandidates = ["google-chrome", "google-chrome-stable", "google-chrome-beta", "chromium-browser", "chromium"];
1892
- chromeBin = chromeCandidates.find(c => commandExistsInPath(c)) || null;
1893
- if (!chromeBin) {
1894
- return {
1895
- available: false,
1896
- reason: "Chrome/Chromium not found. Install google-chrome or chromium and run with --remote-debugging-port=9222.",
1897
- };
1898
- }
1899
- const googleDir = path.join(os.homedir(), ".config", "google-chrome");
1900
- const chromiumDir = path.join(os.homedir(), ".config", "chromium");
1901
- chromeUserDataDir = fs.existsSync(googleDir) ? googleDir : fs.existsSync(chromiumDir) ? chromiumDir : null;
1902
- } else if (process.platform === "win32") {
1903
- const programFiles = process.env.PROGRAMFILES || "C:\\Program Files";
1904
- const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
1905
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
1906
- const winCandidates = [
1907
- path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
1908
- path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
1909
- path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
1910
- path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
1911
- path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
1912
- ];
1913
- chromeBin = winCandidates.find(p => fs.existsSync(p)) || null;
1914
- if (!chromeBin) {
1915
- return {
1916
- available: false,
1917
- reason: "Chrome/Edge not found. Install Chrome or run with --remote-debugging-port=9222.",
1918
- };
1919
- }
1920
- const chromeDir = path.join(localAppData, "Google", "Chrome", "User Data");
1921
- const edgeDir = path.join(localAppData, "Microsoft", "Edge", "User Data");
1922
- chromeUserDataDir = fs.existsSync(chromeDir) ? chromeDir : fs.existsSync(edgeDir) ? edgeDir : null;
1923
- } else {
1924
- return {
1925
- available: false,
1926
- reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
1927
- };
1928
- }
1929
-
1930
- // Chrome 145+ requires --user-data-dir for CDP to work.
1931
- // The default data dir is rejected, so we copy the profile to ~/.chrome-cdp.
1932
- // Profile can be set via env DELIBERATION_CHROME_PROFILE or config.chrome_profile (e.g., "Profile 1").
1933
- const cdpDataDir = path.join(os.homedir(), ".chrome-cdp");
1934
- const cdpConfig = loadDeliberationConfig();
1935
- const profileDir = process.env.DELIBERATION_CHROME_PROFILE || cdpConfig.chrome_profile || "Default";
1936
-
1937
- try {
1938
- if (chromeUserDataDir) {
1939
- const srcProfile = path.join(chromeUserDataDir, profileDir);
1940
- const dstProfile = path.join(cdpDataDir, profileDir);
1941
- // Track which profile was copied; re-copy if profile changed
1942
- const profileMarker = path.join(cdpDataDir, ".cdp-profile");
1943
- const lastProfile = fs.existsSync(profileMarker) ? fs.readFileSync(profileMarker, "utf8").trim() : null;
1944
- const needsCopy = !fs.existsSync(dstProfile) || (lastProfile && lastProfile !== profileDir);
1945
- if (needsCopy && fs.existsSync(srcProfile)) {
1946
- // Clean old profile if switching
1947
- if (lastProfile && lastProfile !== profileDir) {
1948
- const oldDst = path.join(cdpDataDir, lastProfile);
1949
- if (fs.existsSync(oldDst)) fs.rmSync(oldDst, { recursive: true, force: true });
1950
- }
1951
- fs.mkdirSync(cdpDataDir, { recursive: true });
1952
- execFileSync("cp", ["-R", srcProfile, dstProfile], { timeout: 30000, stdio: "ignore" });
1953
- fs.writeFileSync(profileMarker, profileDir);
1954
- // Create minimal Local State with single profile to avoid profile picker
1955
- const localStateSrc = path.join(chromeUserDataDir, "Local State");
1956
- if (fs.existsSync(localStateSrc)) {
1957
- const state = JSON.parse(fs.readFileSync(localStateSrc, "utf8"));
1958
- state.profile.profiles_created = 1;
1959
- state.profile.last_used = profileDir;
1960
- if (state.profile.info_cache) {
1961
- const kept = {};
1962
- if (state.profile.info_cache[profileDir]) kept[profileDir] = state.profile.info_cache[profileDir];
1963
- state.profile.info_cache = kept;
1964
- }
1965
- fs.writeFileSync(path.join(cdpDataDir, "Local State"), JSON.stringify(state));
1966
- }
1967
- }
1968
- }
1969
- } catch { /* proceed with launch attempt anyway */ }
1970
-
1971
- const launchArgs = [
1972
- "--remote-debugging-port=9222",
1973
- "--remote-allow-origins=http://127.0.0.1:9222",
1974
- `--user-data-dir=${cdpDataDir}`,
1975
- `--profile-directory=${profileDir}`,
1976
- "--no-first-run",
1977
- ];
1978
-
1979
- try {
1980
- const child = spawn(chromeBin, launchArgs, { stdio: "ignore", detached: true });
1981
- child.unref();
1982
- } catch {
1983
- return {
1984
- available: false,
1985
- reason: `Failed to auto-launch Chrome. Manually run Chrome with --remote-debugging-port=9222 --user-data-dir=~/.chrome-cdp.`,
1986
- };
1987
- }
1988
-
1989
- // Wait for Chrome to initialize CDP
1990
- await new Promise(resolve => setTimeout(resolve, 5000));
1991
-
1992
- // Retry CDP connection after launch
1993
- for (const endpoint of endpoints) {
1994
- try {
1995
- const payload = await fetchJson(endpoint, 2000);
1996
- if (Array.isArray(payload)) {
1997
- return { available: true, endpoint, launched: true };
1998
- }
1999
- } catch { /* still not reachable */ }
2000
- }
2001
-
2002
- return {
2003
- available: false,
2004
- reason: "Chrome launched but cannot connect to CDP. Fully close Chrome and try again. (Restart required if Chrome was started without CDP)",
2005
- };
2006
- }
2007
-
2008
- // Unreachable (all platforms handled above), but keep as safety net
2009
- return {
2010
- available: false,
2011
- reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
2012
- };
2013
- }
2014
-
2015
- function collectBrowserLlmTabsViaAppleScript() {
2016
- if (process.platform !== "darwin") {
2017
- return { tabs: [], note: "AppleScript tab scanning is only supported on macOS." };
2018
- }
2019
-
2020
- const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
2021
- const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
2022
- const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
2023
- const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
2024
-
2025
- // NOTE: Use stdin pipe (`osascript -`) instead of multiple `-e` flags
2026
- // because osascript's `-e` mode silently breaks with nested try/on error blocks.
2027
- // Also wrap dynamic `tell application` with `using terms from` so that
2028
- // Chrome-specific properties like `tabs` resolve via the scripting dictionary.
2029
- // Use ASCII character 9 for tab delimiter because `using terms from`
2030
- // shadows the built-in `tab` constant, turning it into the literal string "tab".
2031
- const scriptText = `set llmDomains to ${domainList}
2032
- set browserApps to ${appList}
2033
- set outText to ""
2034
- set tabChar to ASCII character 9
2035
- tell application "System Events"
2036
- set runningApps to name of every application process
2037
- end tell
2038
- repeat with appName in browserApps
2039
- if runningApps contains (appName as string) then
2040
- try
2041
- if (appName as string) is "Safari" then
2042
- using terms from application "Safari"
2043
- tell application (appName as string)
2044
- repeat with w in windows
2045
- try
2046
- repeat with t in tabs of w
2047
- set u to URL of t as string
2048
- set matched to false
2049
- repeat with d in llmDomains
2050
- if u contains (d as string) then set matched to true
2051
- end repeat
2052
- if matched then set outText to outText & (appName as string) & tabChar & (name of t as string) & tabChar & u & linefeed
2053
- end repeat
2054
- end try
2055
- end repeat
2056
- end tell
2057
- end using terms from
2058
- else
2059
- using terms from application "Google Chrome"
2060
- tell application (appName as string)
2061
- repeat with w in windows
2062
- try
2063
- repeat with t in tabs of w
2064
- set u to URL of t as string
2065
- set matched to false
2066
- repeat with d in llmDomains
2067
- if u contains (d as string) then set matched to true
2068
- end repeat
2069
- if matched then set outText to outText & (appName as string) & tabChar & (title of t as string) & tabChar & u & linefeed
2070
- end repeat
2071
- end try
2072
- end repeat
2073
- end tell
2074
- end using terms from
2075
- end if
2076
- on error errMsg
2077
- set outText to outText & (appName as string) & tabChar & "ERROR" & tabChar & errMsg & linefeed
2078
- end try
2079
- end if
2080
- end repeat
2081
- return outText`;
2082
-
2083
- try {
2084
- const raw = execFileSync("osascript", ["-"], {
2085
- input: scriptText,
2086
- encoding: "utf-8",
2087
- timeout: 8000,
2088
- maxBuffer: 2 * 1024 * 1024,
2089
- });
2090
- const rows = String(raw)
2091
- .split("\n")
2092
- .map(line => line.trim())
2093
- .filter(Boolean)
2094
- .map(line => {
2095
- const [browser = "", title = "", url = ""] = line.split("\t");
2096
- return { browser, title, url };
2097
- });
2098
- const tabs = rows.filter(r => r.title !== "ERROR");
2099
- const errors = rows.filter(r => r.title === "ERROR");
2100
- return {
2101
- tabs,
2102
- note: errors.length > 0
2103
- ? `Some browser access failed: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
2104
- : null,
2105
- };
2106
- } catch (error) {
2107
- const reason = error instanceof Error ? error.message : "unknown error";
2108
- return {
2109
- tabs: [],
2110
- note: `Browser tab scan failed: ${reason}. Check macOS automation permissions (Terminal -> Browser control).`,
2111
- };
2112
- }
2113
- }
2114
-
2115
- async function collectBrowserLlmTabs() {
2116
- const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
2117
- const tabs = [];
2118
- const notes = [];
2119
-
2120
- const injected = parseInjectedBrowserTabsFromEnv();
2121
- tabs.push(...injected.tabs);
2122
- if (injected.note) notes.push(injected.note);
2123
-
2124
- if (mode === "off") {
2125
- return {
2126
- tabs: dedupeBrowserTabs(tabs),
2127
- note: notes.length > 0 ? notes.join(" | ") : "Browser tab auto-scanning is disabled.",
2128
- };
2129
- }
2130
-
2131
- // CDP first: CDP-detected tabs are preferred over AppleScript-detected ones
2132
- // because they carry CDP metadata (tab ID, WebSocket URL) for browser_auto transport.
2133
- // Since dedupeBrowserTabs keeps the first occurrence, CDP entries win the dedup.
2134
- const shouldUseCdp = mode === "auto" || mode === "cdp";
2135
- if (shouldUseCdp) {
2136
- const cdp = await collectBrowserLlmTabsViaCdp();
2137
- tabs.push(...cdp.tabs);
2138
- if (cdp.note) notes.push(cdp.note);
2139
- }
2140
-
2141
- const shouldUseAppleScript = mode === "auto" || mode === "applescript";
2142
- if (shouldUseAppleScript && process.platform === "darwin") {
2143
- const mac = collectBrowserLlmTabsViaAppleScript();
2144
- tabs.push(...mac.tabs);
2145
- if (mac.note) notes.push(mac.note);
2146
- } else if (mode === "applescript" && process.platform !== "darwin") {
2147
- notes.push("AppleScript scanning is macOS only. Switch to CDP scanning.");
2148
- }
2149
-
2150
- const uniqTabs = dedupeBrowserTabs(tabs);
2151
- return {
2152
- tabs: uniqTabs,
2153
- note: notes.length > 0 ? notes.join(" | ") : null,
2154
- };
2155
- }
2156
-
2157
- function inferLlmProvider(url = "", title = "") {
2158
- const value = String(url).toLowerCase();
2159
- // Extension side panel: infer from title via registry
2160
- if (value.startsWith("chrome-extension://") && title) {
2161
- const registry = loadExtensionProviderRegistry();
2162
- const lowerTitle = String(title).toLowerCase();
2163
- for (const entry of registry.providers) {
2164
- if (entry.titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
2165
- return entry.provider;
2166
- }
2167
- }
2168
- return "extension-llm";
2169
- }
2170
- if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
2171
- if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
2172
- if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
2173
- if (value.includes("copilot.microsoft.com")) return "copilot";
2174
- if (value.includes("perplexity.ai")) return "perplexity";
2175
- if (value.includes("poe.com")) return "poe";
2176
- if (value.includes("mistral.ai")) return "mistral";
2177
- if (value.includes("huggingface.co/chat")) return "huggingchat";
2178
- if (value.includes("deepseek.com")) return "deepseek";
2179
- if (value.includes("qwen.ai")) return "qwen";
2180
- if (value.includes("grok.com")) return "grok";
2181
- return "web-llm";
2182
- }
2183
-
2184
- async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
2185
- const candidates = [];
2186
- const seen = new Set();
2187
- let browserNote = null;
2188
-
2189
- const add = (candidate) => {
2190
- const speaker = normalizeSpeaker(candidate?.speaker);
2191
- if (!speaker || seen.has(speaker)) return;
2192
- seen.add(speaker);
2193
- candidates.push({ ...candidate, speaker });
2194
- };
2195
-
2196
- if (include_cli) {
2197
- for (const cli of discoverLocalCliSpeakers()) {
2198
- const live = checkCliLiveness(cli);
2199
- add({
2200
- speaker: cli,
2201
- type: "cli",
2202
- label: cli,
2203
- command: cli,
2204
- live,
2205
- });
2206
- }
2207
-
2208
- const { sessions: teleptySessions, note: teleptyNote } = await collectTeleptySessions();
2209
- const locators = collectTeleptyProcessLocators(teleptySessions);
2210
- for (const session of teleptySessions) {
2211
- const locator = locators.get(session.id) || {};
2212
- add({
2213
- speaker: session.id,
2214
- type: "telepty",
2215
- label: session.id,
2216
- telepty_session_id: session.id,
2217
- telepty_host: session.host || TELEPTY_DEFAULT_HOST,
2218
- command: session.command || "wrapped",
2219
- cwd: session.cwd || null,
2220
- active_clients: session.active_clients ?? null,
2221
- runtime_pid: locator.pid ?? null,
2222
- runtime_tty: locator.tty ?? null,
2223
- });
2224
- }
2225
- if (teleptyNote) {
2226
- browserNote = browserNote ? `${browserNote} | ${teleptyNote}` : teleptyNote;
2227
- }
2228
- }
2229
-
2230
- if (include_browser) {
2231
- // Ensure CDP is available before probing browser tabs
2232
- const cdpStatus = await ensureCdpAvailable();
2233
- if (cdpStatus.launched) {
2234
- browserNote = "Chrome CDP auto-launched (--remote-debugging-port=9222)";
2235
- }
2236
-
2237
- const { tabs, note } = await collectBrowserLlmTabs();
2238
- browserNote = browserNote ? `${browserNote} | ${note || ""}`.replace(/ \| $/, "") : (note || null);
2239
- const providerCounts = new Map();
2240
- for (const tab of tabs) {
2241
- const provider = inferLlmProvider(tab.url, tab.title);
2242
- const count = (providerCounts.get(provider) || 0) + 1;
2243
- providerCounts.set(provider, count);
2244
- add({
2245
- speaker: `web-${provider}-${count}`,
2246
- type: "browser",
2247
- provider,
2248
- browser: tab.browser || "",
2249
- title: tab.title || "",
2250
- url: tab.url || "",
2251
- });
2252
- }
2253
-
2254
- // CDP auto-detection: probe endpoints for matching tabs
2255
- const cdpEndpoints = resolveCdpEndpoints();
2256
- const cdpTabsMap = new Map(); // dedupe by tab ID (multiple endpoints may return same tabs)
2257
- for (const endpoint of cdpEndpoints) {
2258
- try {
2259
- const tabs = await fetchJson(endpoint, 2000);
2260
- if (Array.isArray(tabs)) {
2261
- for (const t of tabs) {
2262
- if (t.type === "page" && t.url && t.id && !cdpTabsMap.has(t.id)) {
2263
- cdpTabsMap.set(t.id, t);
2264
- }
2265
- }
2266
- }
2267
- } catch { /* endpoint not reachable */ }
2268
- }
2269
- const cdpTabs = [...cdpTabsMap.values()];
2270
-
2271
- // Match CDP tabs with discovered browser candidates
2272
- for (const candidate of candidates) {
2273
- if (candidate.type !== "browser") continue;
2274
- // For extension candidates, match by title instead of hostname
2275
- const candidateUrl = String(candidate.url || "");
2276
- if (candidateUrl.startsWith("chrome-extension://")) {
2277
- const candidateTitle = String(candidate.title || "").toLowerCase();
2278
- if (candidateTitle) {
2279
- const matches = cdpTabs.filter(t =>
2280
- String(t.url || "").startsWith("chrome-extension://") &&
2281
- String(t.title || "").toLowerCase().includes(candidateTitle)
2282
- );
2283
- if (matches.length >= 1) {
2284
- candidate.cdp_available = true;
2285
- candidate.cdp_tab_id = matches[0].id;
2286
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
2287
- }
2288
- }
2289
- continue;
2290
- }
2291
- let candidateHost = "";
2292
- try {
2293
- candidateHost = new URL(candidate.url).hostname.toLowerCase();
2294
- } catch { continue; }
2295
- if (!candidateHost) continue;
2296
- const matches = cdpTabs.filter(t => {
2297
- try {
2298
- return new URL(t.url).hostname.toLowerCase() === candidateHost;
2299
- } catch { return false; }
2300
- });
2301
- if (matches.length >= 1) {
2302
- candidate.cdp_available = true;
2303
- candidate.cdp_tab_id = matches[0].id;
2304
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
2305
- }
2306
- }
2307
-
2308
- // Auto-register well-known web LLMs that weren't already detected via browser scanning.
2309
- // This ensures web speakers are ALWAYS available regardless of browser detection success.
2310
- // If a browser tab for the same provider was already detected, skip auto-registration
2311
- // to avoid duplicates (e.g., detected "web-chatgpt-1" vs auto-registered "web-chatgpt").
2312
- const detectedProviders = new Set(
2313
- candidates.filter(c => c.type === "browser" && !c.auto_registered).map(c => c.provider)
2314
- );
2315
- // CDP is reachable if we got any tabs from the endpoints (attach() handles auto-tab-creation)
2316
- const cdpReachable = cdpTabs.length > 0 || cdpStatus.available;
2317
- for (const ws of DEFAULT_WEB_SPEAKERS) {
2318
- if (detectedProviders.has(ws.provider)) continue;
2319
- add({
2320
- speaker: ws.speaker,
2321
- type: "browser",
2322
- provider: ws.provider,
2323
- browser: "auto-registered",
2324
- title: ws.name,
2325
- url: ws.url,
2326
- auto_registered: true,
2327
- cdp_available: cdpReachable,
2328
- });
2329
- }
2330
-
2331
- // Second pass: match auto-registered speakers to individual CDP tabs
2332
- // (they were added after the first matching pass and only got the global cdpReachable flag)
2333
- if (cdpTabs.length > 0) {
2334
- for (const candidate of candidates) {
2335
- if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
2336
- let candidateHost = "";
2337
- try {
2338
- candidateHost = new URL(candidate.url).hostname.toLowerCase();
2339
- } catch { continue; }
2340
- if (!candidateHost) continue;
2341
- const matches = cdpTabs.filter(t => {
2342
- try {
2343
- const tabHost = new URL(t.url).hostname.toLowerCase();
2344
- // Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
2345
- return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
2346
- } catch { return false; }
2347
- });
2348
- if (matches.length >= 1) {
2349
- candidate.cdp_available = true;
2350
- candidate.cdp_tab_id = matches[0].id;
2351
- candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
2352
- }
2353
- }
2354
- }
2355
-
2356
- // Third pass: upgrade browser-detected candidates that missed the first hostname match.
2357
- // When CDP is reachable, AppleScript-detected speakers should also get browser_auto
2358
- // transport. The OrchestratedBrowserPort will create/navigate tabs on demand if needed.
2359
- if (cdpReachable) {
2360
- for (const candidate of candidates) {
2361
- if (candidate.type !== "browser" || candidate.auto_registered) continue;
2362
- if (candidate.cdp_available) continue; // already matched
2363
- candidate.cdp_available = true;
2364
- }
2365
- }
2366
- }
2367
-
2368
- return { candidates, browserNote };
2369
- }
2370
-
2371
- function formatSpeakerCandidatesReport({ candidates, browserNote }) {
2372
- const cli = candidates.filter(c => c.type === "cli");
2373
- const telepty = candidates.filter(c => c.type === "telepty");
2374
- const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
2375
- const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
2376
-
2377
- let out = "## Selectable Speakers\n\n";
2378
- out += "### CLI\n";
2379
- if (cli.length === 0) {
2380
- out += "- (No local CLI detected)\n\n";
2381
- } else {
2382
- out += `${cli.map(c => {
2383
- const status = c.live === false ? " ❌ not executable" : c.live === true ? " ✅ executable" : "";
2384
- return `- \`${c.speaker}\` (command: ${c.command})${status}`;
2385
- }).join("\n")}\n\n`;
2386
- }
2387
-
2388
- out += "### Telepty Sessions\n";
2389
- if (telepty.length === 0) {
2390
- out += "- (No active telepty sessions)\n\n";
2391
- } else {
2392
- out += `${telepty.map(c => {
2393
- const parts = [
2394
- `command: ${c.command || "wrapped"}`,
2395
- c.telepty_host ? `host: ${formatTeleptyHostLabel(c.telepty_host)}` : null,
2396
- Number.isFinite(c.runtime_pid) ? `pid: ${c.runtime_pid}` : null,
2397
- c.runtime_tty ? `tty: ${c.runtime_tty}` : null,
2398
- ].filter(Boolean).join(", ");
2399
- const cwdLine = c.cwd ? `\n cwd: ${c.cwd}` : "";
2400
- return `- \`${c.speaker}\` (${parts})${cwdLine}`;
2401
- }).join("\n")}\n\n`;
2402
- }
2403
-
2404
- out += "### Browser LLM (detected)\n";
2405
- if (detected.length === 0) {
2406
- out += "- (No LLM tabs detected in browser)\n";
2407
- } else {
2408
- out += `${detected.map(c => {
2409
- const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
2410
- const extTag = String(c.url || "").startsWith("chrome-extension://") ? " [Extension]" : "";
2411
- return `- \`${c.speaker}\` [${icon}]${extTag} [${c.browser}] ${c.title}\n ${c.url}`;
2412
- }).join("\n")}\n`;
2413
- }
2414
-
2415
- out += "\n### Web LLM (auto-registered)\n";
2416
- out += `${autoReg.map(c => {
2417
- const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
2418
- return `- \`${c.speaker}\` [${icon}] — ${c.title} (${c.url})`;
2419
- }).join("\n")}\n`;
2420
-
2421
- if (browserNote) {
2422
- out += `\n\nℹ️ ${browserNote}`;
2423
- }
2424
- return out;
2425
- }
2426
-
2427
- function mapParticipantProfiles(speakers, candidates, typeOverrides) {
2428
- const bySpeaker = new Map();
2429
- for (const c of candidates || []) {
2430
- const key = normalizeSpeaker(c.speaker);
2431
- if (key) bySpeaker.set(key, c);
2432
- }
2433
-
2434
- const overrides = typeOverrides || {};
2435
-
2436
- const profiles = [];
2437
- for (const raw of speakers || []) {
2438
- const speaker = normalizeSpeaker(raw);
2439
- if (!speaker) continue;
2440
-
2441
- // Check for explicit type override
2442
- const overrideType = overrides[speaker] || overrides[raw];
2443
- if (overrideType) {
2444
- const candidate = bySpeaker.get(speaker);
2445
- profiles.push({
2446
- speaker,
2447
- type: overrideType,
2448
- ...(overrideType === "browser_auto" || overrideType === "browser" ? {
2449
- provider: candidate?.provider || null,
2450
- browser: candidate?.browser || null,
2451
- title: candidate?.title || null,
2452
- url: candidate?.url || null,
2453
- } : {}),
2454
- });
2455
- continue;
2456
- }
2457
-
2458
- const candidate = bySpeaker.get(speaker);
2459
- if (!candidate) {
2460
- // Force CLI type if the speaker is available as a CLI command in PATH
2461
- if (commandExistsInPath(speaker)) {
2462
- profiles.push({
2463
- speaker,
2464
- type: "cli",
2465
- command: speaker,
2466
- });
2467
- } else {
2468
- profiles.push({
2469
- speaker,
2470
- type: "manual",
2471
- });
2472
- }
2473
- continue;
2474
- }
2475
-
2476
- if (candidate.type === "cli") {
2477
- profiles.push({
2478
- speaker,
2479
- type: "cli",
2480
- command: candidate.command || speaker,
2481
- });
2482
- continue;
2483
- }
2484
-
2485
- if (candidate.type === "telepty") {
2486
- profiles.push({
2487
- speaker,
2488
- type: "telepty",
2489
- command: candidate.command || null,
2490
- telepty_session_id: candidate.telepty_session_id || speaker,
2491
- telepty_host: candidate.telepty_host || null,
2492
- runtime_pid: Number.isFinite(candidate.runtime_pid) ? candidate.runtime_pid : null,
2493
- });
2494
- continue;
2495
- }
2496
-
2497
- const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
2498
- profiles.push({
2499
- speaker,
2500
- type: effectiveType,
2501
- provider: candidate.provider || null,
2502
- browser: candidate.browser || null,
2503
- title: candidate.title || null,
2504
- url: candidate.url || null,
2505
- });
2506
- }
2507
- return profiles;
2508
- }
2509
-
2510
- // ── Transport routing ─────────────────────────────────────────
2511
-
2512
- const TRANSPORT_TYPES = {
2513
- cli: "cli_respond",
2514
- telepty: "telepty_bus",
2515
- browser: "clipboard",
2516
- browser_auto: "browser_auto",
2517
- manual: "manual",
2518
- };
2519
-
2520
- // BrowserControlPort singleton — initialized lazily on first use
2521
- let _browserPort = null;
2522
- function getBrowserPort() {
2523
- if (!_browserPort) {
2524
- const cdpEndpoints = resolveCdpEndpoints();
2525
- _browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
2526
- }
2527
- return _browserPort;
2528
- }
2529
-
2530
- function resolveTransportForSpeaker(state, speaker) {
2531
- const normalizedSpeaker = normalizeSpeaker(speaker);
2532
- if (!normalizedSpeaker || !state?.participant_profiles) {
2533
- return { transport: "manual", reason: "no_profile" };
2534
- }
2535
- const profile = state.participant_profiles.find(
2536
- p => normalizeSpeaker(p.speaker) === normalizedSpeaker
2537
- );
2538
- if (!profile) {
2539
- return { transport: "manual", reason: "speaker_not_in_profiles" };
2540
- }
2541
- const transport = TRANSPORT_TYPES[profile.type] || "manual";
2542
- return { transport, profile, reason: null };
2543
- }
2544
-
2545
- // CLI-specific invocation flags for non-interactive execution
2546
- const CLI_INVOCATION_HINTS = {
2547
- claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
2548
- codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '--model', defaultModel: 'default', provider: 'chatgpt' },
2549
- gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '--model', provider: 'gemini' },
2550
- aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
2551
- cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
2552
- };
2553
-
2554
- function formatTransportGuidance(transport, state, speaker) {
2555
- const sid = state.id;
2556
- const profile = (state.participant_profiles || []).find(
2557
- p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
2558
- ) || null;
2559
- switch (transport) {
2560
- case "cli_respond": {
2561
- const hint = CLI_INVOCATION_HINTS[speaker] || null;
2562
- let invocationGuide = "";
2563
- let modelGuide = "";
2564
- if (hint) {
2565
- const prefix = hint.envPrefix || '';
2566
- invocationGuide = `\n\n**CLI invocation:** \`${hint.example}\`\n(flags: \`${prefix}${hint.cmd} ${hint.flags}\`)`;
2567
- if (hint.modelFlag && hint.provider) {
2568
- const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
2569
- if (cliModel.model !== 'default') {
2570
- modelGuide = `\n**Recommended model:** ${cliModel.model} (${cliModel.reason})\n**Model flag:** \`${hint.modelFlag} ${cliModel.model}\``;
2571
- }
2572
- }
2573
- }
2574
- 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.`;
2575
- }
2576
- case "clipboard":
2577
- 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` +
2578
- `📋 **Prompt has been copied to your clipboard.** (If not, copy the [turn_prompt] section below manually).\n` +
2579
- `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
2580
- `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
2581
- case "browser_auto":
2582
- 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.`;
2583
- case "telepty_bus":
2584
- 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` +
2585
- `📡 **Bus delivery**: deliberation publishes a typed envelope instead of relying on raw PTY inject.\n` +
2586
- `⏱️ **Timeouts**: transport ack waits ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s, semantic self-submit waits ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s.\n` +
2587
- `⛔ **No proxy response**: the remote telepty session must answer for itself via \`deliberation_respond(...)\`.`;
2588
- case "manual":
2589
- default:
2590
- if (profile?.type === "telepty" && profile.telepty_session_id) {
2591
- const hostSuffix = profile.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
2592
- ? `@${profile.telepty_host}`
2593
- : "";
2594
- const pidNote = Number.isFinite(profile.runtime_pid) ? ` (pid ${profile.runtime_pid})` : "";
2595
- 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` +
2596
- `📋 **Recommended path**: inject the prompt into the telepty session and let the remote session answer for itself.\n` +
2597
- `⛔ **No proxy response**: Do not answer on behalf of this speaker from the orchestrator.`;
2598
- }
2599
- 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` +
2600
- `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
2601
- `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
2602
- `⛔ **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.`;
2603
- }
2604
- }
2605
-
2606
- function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
2607
- const ordered = [];
2608
- const seen = new Set();
2609
-
2610
- const add = (candidate) => {
2611
- const speaker = normalizeSpeaker(candidate);
2612
- if (!speaker || seen.has(speaker)) return;
2613
- seen.add(speaker);
2614
- ordered.push(speaker);
2615
- };
2616
-
2617
- if (fallbackPlacement === "front") {
2618
- add(fallbackSpeaker);
2619
- }
2620
-
2621
- if (Array.isArray(speakers)) {
2622
- for (const speaker of speakers) {
2623
- add(speaker);
2624
- }
2625
- }
2626
-
2627
- if (fallbackPlacement !== "front") {
2628
- add(fallbackSpeaker);
2629
- }
2630
-
2631
- if (ordered.length === 0) {
2632
- for (const speaker of DEFAULT_SPEAKERS) {
2633
- add(speaker);
2634
- }
2635
- }
2636
-
2637
- return ordered;
2638
- }
2639
-
2640
- function normalizeSessionActors(state) {
2641
- if (!state || typeof state !== "object") return state;
2642
-
2643
- const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
2644
- || normalizeSpeaker(state.log?.[0]?.speaker)
2645
- || DEFAULT_SPEAKERS[0];
2646
- const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
2647
- state.speakers = speakers;
2648
-
2649
- const normalizedCurrent = normalizeSpeaker(state.current_speaker);
2650
- if (state.status === "active") {
2651
- state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
2652
- ? normalizedCurrent
2653
- : speakers[0];
2654
- } else if (normalizedCurrent) {
2655
- state.current_speaker = normalizedCurrent;
2656
- }
2657
-
2658
- return state;
2659
- }
2660
-
2661
- // ── Session ID generation ─────────────────────────────────────
2662
-
2663
- function generateSessionId(topic) {
2664
- const slug = topic
2665
- .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
2666
- .replace(/\s+/g, "-")
2667
- .toLowerCase()
2668
- .slice(0, 20);
2669
- const ts = Date.now().toString(36);
2670
- const rand = Math.random().toString(36).slice(2, 6);
2671
- return `${slug}-${ts}${rand}`;
2672
- }
2673
-
2674
- function generateTurnId() {
2675
- return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
2676
- }
2677
-
2678
- // ── Context detection ──────────────────────────────────────────
2679
-
2680
- function detectContextDirs() {
2681
- const dirs = [];
2682
- const slug = getProjectSlug();
2683
-
2684
- if (process.env.DELIBERATION_CONTEXT_DIR) {
2685
- dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
2686
- }
2687
- dirs.push(process.cwd());
2688
-
2689
- const obsidianProject = path.join(OBSIDIAN_PROJECTS, slug);
2690
- if (fs.existsSync(obsidianProject)) {
2691
- dirs.push(obsidianProject);
2692
- }
2693
-
2694
- return [...new Set(dirs)];
2695
- }
2696
-
2697
- function readContextFromDirs(dirs, maxChars = 15000) {
2698
- let context = "";
2699
- const seen = new Set();
2700
-
2701
- for (const dir of dirs) {
2702
- if (!fs.existsSync(dir)) continue;
2703
-
2704
- const files = fs.readdirSync(dir)
2705
- .filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
2706
- .sort();
2707
-
2708
- for (const file of files) {
2709
- if (seen.has(file)) continue;
2710
- seen.add(file);
2711
-
2712
- const fullPath = path.join(dir, file);
2713
- let raw;
2714
- try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
2715
-
2716
- let body = raw;
2717
- if (body.startsWith("---")) {
2718
- const end = body.indexOf("---", 3);
2719
- if (end !== -1) body = body.slice(end + 3).trim();
2720
- }
2721
-
2722
- const truncated = body.length > 1200
2723
- ? body.slice(0, 1200) + "\n(...)"
2724
- : body;
2725
-
2726
- context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
2727
-
2728
- if (context.length > maxChars) {
2729
- context = context.slice(0, maxChars) + "\n\n(...context truncated)";
2730
- return context;
2731
- }
2732
- }
2733
- }
2734
- return context || "(No context files found)";
2735
- }
2736
-
2737
- // ── State helpers ──────────────────────────────────────────────
2738
-
2739
- function ensureDirs(projectSlug = getProjectSlug()) {
2740
- fs.mkdirSync(getSessionsDir(projectSlug), { recursive: true });
2741
- fs.mkdirSync(getArchiveDir(projectSlug), { recursive: true });
2742
- fs.mkdirSync(getLocksDir(projectSlug), { recursive: true });
2743
- }
2744
-
2745
- function loadSession(sessionRef) {
2746
- const record = findSessionRecord(sessionRef);
2747
- return record?.state || null;
2748
- }
2749
-
2750
- function saveSession(state) {
2751
- ensureDirs(state.project);
2752
- state.updated = new Date().toISOString();
2753
- writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
2754
- syncMarkdown(state);
2755
- }
2756
-
2757
- function listActiveSessions(projectSlug) {
2758
- const projects = projectSlug
2759
- ? [normalizeProjectSlug(projectSlug)]
2760
- : [...new Set([getProjectSlug(), ...listStateProjects()])];
2761
-
2762
- return projects.flatMap(project => {
2763
- const dir = getSessionsDir(project);
2764
- if (!fs.existsSync(dir)) return [];
2765
-
2766
- return fs.readdirSync(dir)
2767
- .filter(f => f.endsWith(".json"))
2768
- .map(f => {
2769
- try {
2770
- const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
2771
- return normalizeSessionActors(data);
2772
- } catch {
2773
- return null;
2774
- }
2775
- })
2776
- .filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
2777
- });
2778
- }
2779
-
2780
- function resolveSessionId(sessionId) {
2781
- // Use session_id directly if provided
2782
- if (sessionId) return sessionId;
2783
-
2784
- // Auto-select when only one active session
2785
- const active = listActiveSessions();
2786
- if (active.length === 0) return null;
2787
- if (active.length === 1) return active[0].id;
2788
-
2789
- // null if multiple (need to show list)
2790
- return "MULTIPLE";
2791
- }
2792
-
2793
- function syncMarkdown(state) {
2794
- const filename = `deliberation-${state.id}.md`;
2795
- const mdPath = path.join(getProjectStateDir(state.project), filename);
2796
- try {
2797
- writeTextAtomic(mdPath, stateToMarkdown(state));
2798
- } catch { /* ignore sync failures */ }
2799
- }
2800
-
2801
- function cleanupSyncMarkdown(state) {
2802
- const filename = `deliberation-${state.id}.md`;
2803
- const statePath = path.join(getProjectStateDir(state.project), filename);
2804
- try { fs.unlinkSync(statePath); } catch { /* ignore */ }
2805
- // Also clean up legacy files in CWD (from older versions)
2806
- const cwdPath = path.join(process.cwd(), filename);
2807
- try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
2808
- }
2809
-
2810
- function formatSourceMetadataLine(meta) {
2811
- if (!meta || typeof meta !== "object") return "";
2812
- const parts = [];
2813
- if (meta.source_machine_id) parts.push(`machine: ${meta.source_machine_id}`);
2814
- if (meta.source_session_id) parts.push(`session: ${meta.source_session_id}`);
2815
- if (meta.transport_scope) parts.push(`transport: ${meta.transport_scope}`);
2816
- if (meta.reply_origin) parts.push(`origin: ${meta.reply_origin}`);
2817
- if (meta.timestamp) parts.push(`timestamp: ${meta.timestamp}`);
2818
- if (Array.isArray(meta.artifact_refs) && meta.artifact_refs.length > 0) {
2819
- parts.push(`artifacts: ${meta.artifact_refs.join(", ")}`);
2820
- }
2821
- return parts.length > 0 ? `> _source: ${parts.join(" | ")}_\n\n` : "";
2822
- }
2823
-
2824
- function stateToMarkdown(s) {
2825
- const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
2826
- let md = `---
2827
- title: "Deliberation - ${s.topic}"
2828
- session_id: "${s.id}"
2829
- created: ${s.created}
2830
- updated: ${s.updated || new Date().toISOString()}
2831
- type: deliberation
2832
- status: ${s.status}
2833
- project: "${s.project}"
2834
- participants: ${JSON.stringify(speakerOrder)}
2835
- rounds: ${s.max_rounds}
2836
- current_round: ${s.current_round}
2837
- current_speaker: "${s.current_speaker}"
2838
- tags: [deliberation]
2839
- ---
2840
-
2841
- # Deliberation: ${s.topic}
2842
-
2843
- **Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
2844
-
2845
- ---
2846
-
2847
- `;
2848
-
2849
- if (s.synthesis) {
2850
- md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
2851
- }
2852
-
2853
- if (s.structured_synthesis) {
2854
- md += `## Structured Synthesis\n\n\`\`\`json\n${JSON.stringify(s.structured_synthesis, null, 2)}\n\`\`\`\n\n---\n\n`;
2855
- }
2856
-
2857
- if (s.execution_contract) {
2858
- md += `## Execution Contract\n\n\`\`\`json\n${JSON.stringify(s.execution_contract, null, 2)}\n\`\`\`\n\n---\n\n`;
2859
- }
2860
-
2861
- md += `## Debate Log\n\n`;
2862
- for (const entry of s.log) {
2863
- md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
2864
- if (entry.channel_used || entry.fallback_reason) {
2865
- const parts = [];
2866
- if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
2867
- if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
2868
- md += `> _${parts.join(" | ")}_\n\n`;
2869
- }
2870
- md += formatSourceMetadataLine(entry.source_metadata);
2871
- md += `${entry.content}\n\n`;
2872
- if (entry.attachments && entry.attachments.length > 0) {
2873
- for (const att of entry.attachments) {
2874
- if (att.type === "image") {
2875
- md += `![Attachment](${att.path})\n\n`;
2876
- }
2877
- }
2878
- }
2879
- md += `---\n\n`;
2880
- }
2881
- return md;
2882
- }
2883
-
2884
- function archiveState(state) {
2885
- ensureDirs(state.project);
2886
- const slug = state.topic
2887
- .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
2888
- .replace(/\s+/g, "-")
2889
- .slice(0, 30);
2890
- const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
2891
- const filename = `deliberation-${ts}-${slug}.md`;
2892
- const dest = path.join(getArchiveDir(state.project), filename);
2893
- writeTextAtomic(dest, stateToMarkdown(state));
2894
-
2895
- // Write machine-readable execution_contract sidecar for automation consumers
2896
- if (state.execution_contract) {
2897
- const contractDest = dest.replace(/\.md$/, ".contract.json");
2898
- writeTextAtomic(contractDest, JSON.stringify({
2899
- ...state.execution_contract,
2900
- _meta: {
2901
- archived_from: state.id,
2902
- project: state.project,
2903
- topic: state.topic,
2904
- archived_at: new Date().toISOString(),
2905
- },
2906
- }, null, 2));
2907
- }
2908
-
2909
- return dest;
2910
- }
2911
-
2912
- // ── Terminal management ────────────────────────────────────────
2913
-
2914
- const TMUX_SESSION = "deliberation";
2915
- const MONITOR_SCRIPT = path.join(INSTALL_DIR, "session-monitor.sh");
2916
- const MONITOR_SCRIPT_WIN = path.join(INSTALL_DIR, "session-monitor-win.js");
2917
-
2918
- function tmuxWindowName(sessionId) {
2919
- // Keep tmux window name short (remove last part, 20 chars)
2920
- return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
2921
- }
2922
-
2923
- function appleScriptQuote(value) {
2924
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2925
- }
2926
-
2927
- function tryExecFile(command, args = []) {
2928
- try {
2929
- execFileSync(command, args, { stdio: "ignore", windowsHide: true });
2930
- return true;
2931
- } catch {
2932
- return false;
2933
- }
2934
- }
2935
-
2936
- function resolveMonitorShell() {
2937
- if (commandExistsInPath("bash")) return "bash";
2938
- if (commandExistsInPath("sh")) return "sh";
2939
- return null;
2940
- }
2941
-
2942
- function buildMonitorCommand(sessionId, project) {
2943
- const shell = resolveMonitorShell();
2944
- if (!shell) return null;
2945
- return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
2946
- }
2947
-
2948
- function buildMonitorCommandWindows(sessionId, project) {
2949
- return `node "${MONITOR_SCRIPT_WIN}" "${sessionId}" "${project}"`;
2950
- }
2951
-
2952
- function hasTmuxSession(name) {
2953
- try {
2954
- execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
2955
- return true;
2956
- } catch {
2957
- return false;
2958
- }
2959
- }
2960
-
2961
- function hasTmuxWindow(sessionName, windowName) {
2962
- try {
2963
- const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
2964
- encoding: "utf-8",
2965
- stdio: ["ignore", "pipe", "ignore"],
2966
- windowsHide: true,
2967
- });
2968
- return String(output).split("\n").map(s => s.trim()).includes(windowName);
2969
- } catch {
2970
- return false;
2971
- }
2972
- }
2973
-
2974
- function tmuxHasAttachedClients(sessionName) {
2975
- try {
2976
- const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
2977
- encoding: "utf-8",
2978
- stdio: ["ignore", "pipe", "ignore"],
2979
- windowsHide: true,
2980
- });
2981
- return String(output).trim().split("\n").filter(Boolean).length > 0;
2982
- } catch {
2983
- return false;
2984
- }
2985
- }
2986
-
2987
- function isTmuxWindowViewed(sessionName, windowName) {
2988
- try {
2989
- // List all clients and check for matching window name.
2990
- // Grouped sessions (created via 'new-session -t') share the same windows,
2991
- // so checking for the window name anywhere in the client list is sufficient.
2992
- const output = execFileSync("tmux", ["list-clients", "-F", "#{window_name}"], {
2993
- encoding: "utf-8",
2994
- stdio: ["ignore", "pipe", "ignore"],
2995
- windowsHide: true,
2996
- });
2997
- return String(output).split("\n").map(s => s.trim()).filter(Boolean).includes(windowName);
2998
- } catch {
2999
- return false;
3000
- }
3001
- }
3002
-
3003
- function tmuxWindowCount(name) {
3004
- try {
3005
- const output = execFileSync("tmux", ["list-windows", "-t", name], {
3006
- encoding: "utf-8",
3007
- stdio: ["ignore", "pipe", "ignore"],
3008
- windowsHide: true,
3009
- });
3010
- return String(output)
3011
- .split("\n")
3012
- .map(line => line.trim())
3013
- .filter(Boolean)
3014
- .length;
3015
- } catch {
3016
- return 0;
3017
- }
3018
- }
3019
-
3020
- function buildTmuxAttachCommand(sessionId) {
3021
- const winName = tmuxWindowName(sessionId);
3022
- // Use grouped session (new-session -t) so each terminal has independent active window.
3023
- // This prevents window-switching conflicts when multiple deliberations run concurrently.
3024
- return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
3025
- }
3026
-
3027
- function listPhysicalTerminalWindowIds() {
3028
- if (process.platform !== "darwin") {
3029
- return [];
3030
- }
3031
- try {
3032
- const output = execFileSync(
3033
- "osascript",
3034
- [
3035
- "-e",
3036
- 'tell application "Terminal"',
3037
- "-e",
3038
- "if not running then return \"\"",
3039
- "-e",
3040
- "set outText to \"\"",
3041
- "-e",
3042
- "repeat with w in windows",
3043
- "-e",
3044
- "set outText to outText & (id of w as string) & linefeed",
3045
- "-e",
3046
- "end repeat",
3047
- "-e",
3048
- "return outText",
3049
- "-e",
3050
- "end tell",
3051
- ],
3052
- { encoding: "utf-8" }
3053
- );
3054
- return String(output)
3055
- .split("\n")
3056
- .map(s => Number.parseInt(s.trim(), 10))
3057
- .filter(n => Number.isInteger(n) && n > 0);
3058
- } catch {
3059
- return [];
3060
- }
3061
- }
3062
-
3063
- function openPhysicalTerminal(sessionId) {
3064
- const winName = tmuxWindowName(sessionId);
3065
- // Use grouped session (new-session -t) for independent active window per client
3066
- const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
3067
-
3068
- // Prevent duplicate windows for the SAME session:
3069
- // If a client is already viewing this specific window, just activate Terminal.app
3070
- if (isTmuxWindowViewed(TMUX_SESSION, winName)) {
3071
- appendRuntimeLog("INFO", `TMUX_WINDOW_ALREADY_VIEWED: ${winName}. Activating existing Terminal.`);
3072
- if (process.platform === "darwin") {
3073
- try {
3074
- execFileSync("osascript", ["-e", 'tell application "Terminal" to activate'], { stdio: "ignore" });
3075
- } catch { /* ignore */ }
3076
- }
3077
- return { opened: true, windowIds: [] };
3078
- }
3079
-
3080
- // If a terminal is already attached to OTHER windows, open a NEW grouped session
3081
- // instead of select-window (which would hijack all attached clients' views).
3082
- if (tmuxHasAttachedClients(TMUX_SESSION)) {
3083
- if (process.platform === "darwin") {
3084
- const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
3085
- try {
3086
- execFileSync(
3087
- "osascript",
3088
- [
3089
- "-e", 'tell application "Terminal"',
3090
- "-e", "activate",
3091
- "-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
3092
- "-e", "end tell",
3093
- ],
3094
- { encoding: "utf-8" }
3095
- );
3096
- return { opened: true, windowIds: [] };
3097
- } catch { /* fall through to default behavior */ }
3098
- }
3099
- // Non-macOS or fallback: don't force select-window, just report success
3100
- // The monitor window already exists in tmux; user can switch manually
3101
- return { opened: true, windowIds: [] };
3102
- }
3103
-
3104
- if (process.platform === "darwin") {
3105
- const before = new Set(listPhysicalTerminalWindowIds());
3106
- try {
3107
- const output = execFileSync(
3108
- "osascript",
3109
- [
3110
- "-e",
3111
- 'tell application "Terminal"',
3112
- "-e",
3113
- "activate",
3114
- "-e",
3115
- `do script ${appleScriptQuote(attachCmd)}`,
3116
- "-e",
3117
- "delay 0.15",
3118
- "-e",
3119
- "return id of front window",
3120
- "-e",
3121
- "end tell",
3122
- ],
3123
- { encoding: "utf-8" }
3124
- );
3125
- const frontId = Number.parseInt(String(output).trim(), 10);
3126
- const after = listPhysicalTerminalWindowIds();
3127
- const opened = after.filter(id => !before.has(id));
3128
- if (opened.length > 0) {
3129
- return { opened: true, windowIds: [...new Set(opened)] };
3130
- }
3131
- if (Number.isInteger(frontId) && frontId > 0) {
3132
- return { opened: true, windowIds: [frontId] };
3133
- }
3134
- return { opened: false, windowIds: [] };
3135
- } catch {
3136
- return { opened: false, windowIds: [] };
3137
- }
3138
- }
3139
-
3140
- if (process.platform === "linux") {
3141
- const shell = resolveMonitorShell() || "sh";
3142
- const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
3143
- const attempts = [
3144
- ["gnome-terminal", ["--", shell, "-lc", launchCmd]],
3145
- ["kgx", ["--", shell, "-lc", launchCmd]],
3146
- ["konsole", ["-e", shell, "-lc", launchCmd]],
3147
- ["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
3148
- ["xterm", ["-e", shell, "-lc", launchCmd]],
3149
- ["alacritty", ["-e", shell, "-lc", launchCmd]],
3150
- ["kitty", [shell, "-lc", launchCmd]],
3151
- ["wezterm", ["start", "--", shell, "-lc", launchCmd]],
3152
- ];
3153
-
3154
- for (const [command, args] of attempts) {
3155
- if (!commandExistsInPath(command)) continue;
3156
- if (tryExecFile(command, args)) {
3157
- return { opened: true, windowIds: [] };
3158
- }
3159
- }
3160
- return { opened: false, windowIds: [] };
3161
- }
3162
-
3163
- if (process.platform === "win32") {
3164
- // Windows: monitor is launched directly by spawnMonitorTerminal (no tmux)
3165
- // Physical terminal opening is handled there, so just return success
3166
- return { opened: true, windowIds: [] };
3167
- }
3168
-
3169
- return { opened: false, windowIds: [] };
3170
- }
3171
-
3172
- function spawnMonitorTerminal(sessionId) {
3173
- // Windows: use Windows Terminal or PowerShell directly (no tmux needed)
3174
- if (process.platform === "win32") {
3175
- const project = getProjectSlug();
3176
- const monitorCmd = buildMonitorCommandWindows(sessionId, project);
3177
-
3178
- // Try Windows Terminal (wt.exe)
3179
- if (commandExistsInPath("wt") || commandExistsInPath("wt.exe")) {
3180
- if (tryExecFile("wt", ["new-tab", "--title", "Deliberation Monitor", "cmd", "/c", monitorCmd])) {
3181
- return true;
3182
- }
3183
- }
3184
-
3185
- // Fallback: new PowerShell window
3186
- const shell = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"].find(c => commandExistsInPath(c));
3187
- if (shell) {
3188
- const escaped = monitorCmd.replace(/'/g, "''");
3189
- if (tryExecFile(shell, ["-NoProfile", "-Command", `Start-Process cmd -ArgumentList '/c','${escaped}'`])) {
3190
- return true;
3191
- }
3192
- }
3193
-
3194
- return false;
3195
- }
3196
-
3197
- // macOS/Linux: use tmux (existing logic)
3198
- if (!commandExistsInPath("tmux")) {
3199
- return false;
3200
- }
3201
-
3202
- const project = getProjectSlug();
3203
- const winName = tmuxWindowName(sessionId);
3204
- const cmd = buildMonitorCommand(sessionId, project);
3205
- if (!cmd) {
3206
- return false;
3207
- }
3208
-
3209
- try {
3210
- if (hasTmuxSession(TMUX_SESSION)) {
3211
- // Skip if a window with the same name already exists (prevents duplicates)
3212
- if (hasTmuxWindow(TMUX_SESSION, winName)) {
3213
- appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
3214
- return true;
3215
- }
3216
- execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
3217
- stdio: "ignore",
3218
- windowsHide: true,
3219
- });
3220
- appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
3221
- } else {
3222
- execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
3223
- stdio: "ignore",
3224
- windowsHide: true,
3225
- });
3226
- appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
3227
- }
3228
- return true;
3229
- } catch {
3230
- return false;
3231
- }
3232
- }
3233
-
3234
- function closePhysicalTerminal(windowId) {
3235
- if (process.platform !== "darwin") {
3236
- return false;
3237
- }
3238
- if (!Number.isInteger(windowId) || windowId <= 0) {
3239
- return false;
3240
- }
3241
-
3242
- const windowExists = () => {
3243
- try {
3244
- const out = execFileSync(
3245
- "osascript",
3246
- [
3247
- "-e",
3248
- 'tell application "Terminal"',
3249
- "-e",
3250
- `if exists window id ${windowId} then return "1"`,
3251
- "-e",
3252
- 'return "0"',
3253
- "-e",
3254
- "end tell",
3255
- ],
3256
- { encoding: "utf-8" }
3257
- ).trim();
3258
- return out === "1";
3259
- } catch {
3260
- return false;
3261
- }
3262
- };
3263
-
3264
- const dismissCloseDialogs = () => {
3265
- try {
3266
- execFileSync(
3267
- "osascript",
3268
- [
3269
- "-e",
3270
- 'tell application "System Events"',
3271
- "-e",
3272
- 'if exists process "Terminal" then',
3273
- "-e",
3274
- 'tell process "Terminal"',
3275
- "-e",
3276
- "repeat with w in windows",
3277
- "-e",
3278
- "try",
3279
- "-e",
3280
- "if exists (sheet 1 of w) then",
3281
- "-e",
3282
- "if exists button \"종료\" of sheet 1 of w then",
3283
- "-e",
3284
- 'click button "종료" of sheet 1 of w',
3285
- "-e",
3286
- "else if exists button \"Terminate\" of sheet 1 of w then",
3287
- "-e",
3288
- 'click button "Terminate" of sheet 1 of w',
3289
- "-e",
3290
- "else if exists button \"확인\" of sheet 1 of w then",
3291
- "-e",
3292
- 'click button "확인" of sheet 1 of w',
3293
- "-e",
3294
- "else",
3295
- "-e",
3296
- "click button 1 of sheet 1 of w",
3297
- "-e",
3298
- "end if",
3299
- "-e",
3300
- "end if",
3301
- "-e",
3302
- "end try",
3303
- "-e",
3304
- "end repeat",
3305
- "-e",
3306
- "end tell",
3307
- "-e",
3308
- "end if",
3309
- "-e",
3310
- "end tell",
3311
- ],
3312
- { stdio: "ignore" }
3313
- );
3314
- } catch {
3315
- // ignore
3316
- }
3317
- };
3318
-
3319
- for (let i = 0; i < 5; i += 1) {
3320
- try {
3321
- execFileSync(
3322
- "osascript",
3323
- [
3324
- "-e",
3325
- 'tell application "Terminal"',
3326
- "-e",
3327
- "activate",
3328
- "-e",
3329
- `if exists window id ${windowId} then`,
3330
- "-e",
3331
- "try",
3332
- "-e",
3333
- `do script "exit" in window id ${windowId}`,
3334
- "-e",
3335
- "end try",
3336
- "-e",
3337
- "delay 0.12",
3338
- "-e",
3339
- "try",
3340
- "-e",
3341
- `close (window id ${windowId})`,
3342
- "-e",
3343
- "end try",
3344
- "-e",
3345
- "end if",
3346
- "-e",
3347
- "end tell",
3348
- ],
3349
- { stdio: "ignore" }
3350
- );
3351
- } catch {
3352
- // ignore
3353
- }
3354
-
3355
- dismissCloseDialogs();
3356
-
3357
- if (!windowExists()) {
3358
- return true;
3359
- }
3360
- }
3361
-
3362
- return !windowExists();
3363
- }
3364
-
3365
- function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
3366
- if (process.platform !== "win32") {
3367
- const winName = tmuxWindowName(sessionId);
3368
- try {
3369
- execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
3370
- stdio: "ignore",
3371
- windowsHide: true,
3372
- });
3373
- } catch { /* ignore */ }
3374
-
3375
- try {
3376
- if (tmuxWindowCount(TMUX_SESSION) === 0) {
3377
- execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
3378
- stdio: "ignore",
3379
- windowsHide: true,
3380
- });
3381
- }
3382
- } catch { /* ignore */ }
3383
- }
3384
-
3385
- for (const windowId of terminalWindowIds) {
3386
- closePhysicalTerminal(windowId);
3387
- }
3388
- }
3389
-
3390
- function getSessionWindowIds(state) {
3391
- if (!state || typeof state !== "object") {
3392
- return [];
3393
- }
3394
- const ids = [];
3395
- if (Array.isArray(state.monitor_terminal_window_ids)) {
3396
- for (const id of state.monitor_terminal_window_ids) {
3397
- if (Number.isInteger(id) && id > 0) {
3398
- ids.push(id);
3399
- }
3400
- }
3401
- }
3402
- if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
3403
- ids.push(state.monitor_terminal_window_id);
3404
- }
3405
- return [...new Set(ids)];
3406
- }
3407
-
3408
- function closeAllMonitorTerminals() {
3409
- try {
3410
- execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
3411
- } catch { /* ignore */ }
3412
- }
3413
-
3414
- function multipleSessionsError() {
3415
- const active = listActiveSessions();
3416
- 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");
3417
- return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
3418
- }
3419
-
3420
- function truncatePromptText(text, maxChars) {
3421
- const value = String(text || "").trim();
3422
- if (!value || !Number.isFinite(maxChars) || maxChars <= 0 || value.length <= maxChars) {
3423
- return value;
3424
- }
3425
- const remaining = value.length - maxChars;
3426
- return `${value.slice(0, maxChars).trimEnd()}\n...(truncated ${remaining} chars)`;
3427
- }
3428
-
3429
- function getPromptBudgetForSpeaker(speaker, includeHistoryEntries = 4) {
3430
- const defaultBudget = {
3431
- maxEntries: Math.max(0, includeHistoryEntries),
3432
- maxCharsPerEntry: 1600,
3433
- maxTotalChars: 6400,
3434
- maxTopicChars: 3200,
3435
- };
3436
- switch (speaker) {
3437
- case "codex":
3438
- return {
3439
- maxEntries: Math.min(Math.max(0, includeHistoryEntries), 3),
3440
- maxCharsPerEntry: 1200,
3441
- maxTotalChars: 3600,
3442
- maxTopicChars: 2200,
3443
- };
3444
- case "gemini":
3445
- return {
3446
- maxEntries: Math.min(Math.max(0, includeHistoryEntries), 4),
3447
- maxCharsPerEntry: 1400,
3448
- maxTotalChars: 5600,
3449
- maxTopicChars: 2800,
3450
- };
3451
- default:
3452
- return defaultBudget;
3453
- }
3454
- }
3455
-
3456
- function formatRecentLogForPrompt(state, maxEntries = 4, options = {}) {
3457
- const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
3458
- if (entries.length === 0) {
3459
- return "(No previous responses yet)";
3460
- }
3461
- const maxCharsPerEntry = options.maxCharsPerEntry || 1600;
3462
- const maxTotalChars = options.maxTotalChars || maxCharsPerEntry * entries.length;
3463
- const rendered = [];
3464
- let usedChars = 0;
3465
-
3466
- for (const entry of entries) {
3467
- const header = `- ${entry.speaker} (Round ${entry.round})`;
3468
- const remainingChars = Math.max(0, maxTotalChars - usedChars - header.length - 1);
3469
- const entryBudget = Math.max(200, Math.min(maxCharsPerEntry, remainingChars || maxCharsPerEntry));
3470
- const content = truncatePromptText(entry.content, entryBudget);
3471
- const block = `${header}\n${content}`;
3472
- rendered.push(block);
3473
- usedChars += block.length + 2;
3474
- if (usedChars >= maxTotalChars) {
3475
- break;
3476
- }
3477
- }
3478
-
3479
- return rendered.join("\n\n");
3480
- }
3481
-
3482
- function getCliAutoTurnTimeoutSec({ speaker, requestedTimeoutSec, promptLength, priorTurns }) {
3483
- const requested = Number.isFinite(requestedTimeoutSec) ? requestedTimeoutSec : 120;
3484
- if (speaker === "codex") {
3485
- let recommended = Math.max(requested, priorTurns === 0 ? 240 : 180);
3486
- if (promptLength > 6000) {
3487
- recommended = Math.max(recommended, 300);
3488
- }
3489
- if (promptLength > 10000 || priorTurns >= 1) {
3490
- recommended = Math.max(recommended, 420);
3491
- }
3492
- return recommended;
3493
- }
3494
- return priorTurns === 0 ? Math.max(requested, 180) : requested;
3495
- }
3496
-
3497
- function getCliExecArgs(speaker) {
3498
- switch (speaker) {
3499
- case "claude":
3500
- return ["-p", "--output-format", "text"];
3501
- case "codex":
3502
- return [
3503
- "exec",
3504
- "--ephemeral",
3505
- "-c", 'approval_policy="never"',
3506
- "-c", 'sandbox_mode="read-only"',
3507
- "-c", 'model_reasoning_effort="low"',
3508
- "-",
3509
- ];
3510
- case "gemini":
3511
- return null;
3512
- default:
3513
- return null;
3514
- }
3515
- }
3516
-
3517
- function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeout, promptLength, priorTurns }) {
3518
- const isTimeout = /CLI timeout \(/.test(String(err?.message || ""));
3519
- if (!isTimeout) {
3520
- 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: "...").`;
3521
- }
3522
-
3523
- const retryTimeout = speaker === "codex"
3524
- ? Math.min(Math.max(effectiveTimeout, 420), 600)
3525
- : Math.min(effectiveTimeout + 60, 300);
3526
-
3527
- return t(
3528
- `⏱️ CLI auto-turn timed out.\n\n` +
3529
- `**Speaker:** ${speaker}\n` +
3530
- `**CLI:** ${hint.cmd}\n` +
3531
- `**Timeout:** ${effectiveTimeout}s\n` +
3532
- `**Prompt size:** ${promptLength} chars\n` +
3533
- `**Prior turns by speaker:** ${priorTurns}\n` +
3534
- `**Session state:** still waiting on ${speaker} for Round ${state.current_round}\n\n` +
3535
- `This usually means the CLI stayed busy longer than the timeout. It does **not** necessarily mean the model is down.\n` +
3536
- `${speaker === "codex" ? `Codex is the slowest CLI in recent deliberation logs, especially when recent_log contains long prior responses.\n` : ""}` +
3537
- `Recommended next step: retry with \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\`.\n` +
3538
- `Manual fallback: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3539
- `⏱️ CLI 자동 턴이 타임아웃되었습니다.\n\n` +
3540
- `**Speaker:** ${speaker}\n` +
3541
- `**CLI:** ${hint.cmd}\n` +
3542
- `**Timeout:** ${effectiveTimeout}s\n` +
3543
- `**Prompt 크기:** ${promptLength} chars\n` +
3544
- `**이 speaker의 이전 발언 수:** ${priorTurns}\n` +
3545
- `**세션 상태:** Round ${state.current_round}에서 아직 ${speaker} 응답을 기다리는 중\n\n` +
3546
- `이건 보통 CLI가 제한 시간 안에 응답을 끝내지 못했다는 뜻입니다. 모델이 완전히 죽었다는 의미는 아닙니다.\n` +
3547
- `${speaker === "codex" ? `최근 딜리버레이션 로그 기준으로 Codex는 이전 응답 전문이 길게 들어가면 가장 느린 편입니다.\n` : ""}` +
3548
- `권장 조치: \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\` 로 재시도하세요.\n` +
3549
- `수동 대안: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3550
- state?.lang
3551
- );
3552
- }
3553
-
3554
- function buildActiveReportingSection(state, speaker) {
3555
- const orchestratorSessionId = state?.orchestrator_session_id;
3556
- if (!orchestratorSessionId) return "";
3557
-
3558
- return `\n[active_reporting_rule]
3559
- - After you successfully submit this turn with \`deliberation_respond(...)\`, send a brief status update to the orchestrator session.
3560
- - Primary method (telepty available):
3561
- \`telepty inject --from \"$TELEPTY_SESSION_ID\" --reply-to ${orchestratorSessionId} ${orchestratorSessionId} \"[deliberation status] session_id: ${state.id} speaker: ${speaker} round: ${state.current_round}/${state.max_rounds} submitted. no further reply needed.\"\`
3562
- - Fallback (telepty unavailable): Include the status in your next tool call or response context.
3563
- - Keep it to one short line. Do not wait for a reply.
3564
- - This rule applies regardless of transport type (CLI, browser, telepty_bus).
3565
- [/active_reporting_rule]
3566
- `;
3567
- }
3568
-
3569
- function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
3570
- const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
3571
- const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
3572
- const extraPrompt = prompt ? `\n[Additional instructions]\n${prompt}\n` : "";
3573
- const topic = truncatePromptText(state.topic, promptBudget.maxTopicChars);
3574
- const noToolRule = speaker === "codex"
3575
- ? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
3576
- : "";
3577
- const activeReportingSection = buildActiveReportingSection(state, speaker);
3578
-
3579
- // Role prompt injection
3580
- const speakerRole = (state.speaker_roles || {})[speaker] || "free";
3581
- const rolePromptText = loadRolePrompt(speakerRole);
3582
- const roleSection = rolePromptText
3583
- ? `\n[role]\nrole: ${speakerRole}\n${rolePromptText}\n[/role]\n`
3584
- : "";
3585
-
3586
- return `[deliberation_turn_request]
3587
- session_id: ${state.id}
3588
- project: ${state.project}
3589
- topic: ${topic}
3590
- round: ${state.current_round}/${state.max_rounds}
3591
- target_speaker: ${speaker}
3592
- required_turn: ${state.current_speaker}${roleSection}${activeReportingSection}
3593
-
3594
- [recent_log]
3595
- ${recent}
3596
- [/recent_log]${extraPrompt}
3597
-
3598
- [response_rule]
3599
- - Write only ${speaker}'s response for this turn reflecting the discussion context above
3600
- - Output markdown body only (no unnecessary headers/footers)${speakerRole !== "free" ? `\n- Analyze and respond from the perspective of assigned role (${speakerRole})` : ""}
3601
- - Keep the response concise and decision-oriented${noToolRule}
3602
- - Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
3603
- [/response_rule]
3604
- [/deliberation_turn_request]
3605
- `;
3606
- }
3607
-
3608
- function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments, source_metadata }) {
3609
- const resolved = resolveSessionId(session_id);
3610
- if (!resolved) {
3611
- return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
3612
- }
3613
- if (resolved === "MULTIPLE") {
3614
- return { content: [{ type: "text", text: multipleSessionsError() }] };
3615
- }
3616
-
3617
- let completionState = null;
3618
- let completionEntry = null;
3619
- const result = withSessionLock(resolved, () => {
3620
- const state = loadSession(resolved);
3621
- if (!state || state.status !== "active") {
3622
- return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
3623
- }
3624
-
3625
- const normalizedSpeaker = normalizeSpeaker(speaker);
3626
- if (!normalizedSpeaker) {
3627
- return { content: [{ type: "text", text: t("Speaker value is empty. Please specify a speaker name.", "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요.", "en") }] };
3628
- }
3629
-
3630
- state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
3631
- const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
3632
- if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
3633
- state.current_speaker = state.speakers[0];
3634
- } else {
3635
- state.current_speaker = normalizedCurrentSpeaker;
3636
- }
3637
-
3638
- if (state.current_speaker !== normalizedSpeaker) {
3639
- return {
3640
- content: [{
3641
- type: "text",
3642
- text: t(`[${state.id}] It is currently **${state.current_speaker}**'s turn. ${normalizedSpeaker} please wait.`, `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`, state?.lang),
3643
- }],
3644
- };
3645
- }
3646
-
3647
- // turn_id validation (optional — must match if provided)
3648
- if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
3649
- return {
3650
- content: [{
3651
- type: "text",
3652
- 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),
3653
- }],
3654
- };
3655
- }
516
+ if (typeof projectSlug === "function") {
517
+ return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
518
+ }
519
+ return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
520
+ }
3656
521
 
3657
- const votes = parseVotes(content);
3658
- if (votes.length === 0) {
3659
- appendRuntimeLog("WARN", `INVALID_TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | reason: no_vote_marker`);
3660
- }
3661
- const suggestedRole = inferSuggestedRole(content);
3662
- const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
3663
- const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
3664
- const logEntry = {
3665
- round: state.current_round,
3666
- speaker: normalizedSpeaker,
3667
- content,
3668
- timestamp: new Date().toISOString(),
3669
- turn_id: state.pending_turn_id || null,
3670
- channel_used: channel_used || null,
3671
- fallback_reason: fallback_reason || null,
3672
- votes: votes.length > 0 ? votes : undefined,
3673
- suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
3674
- role_drift: roleDrift || undefined,
3675
- attachments: attachments || undefined,
3676
- source_metadata: source_metadata || undefined,
3677
- };
3678
- state.log.push(logEntry);
3679
- completePendingTeleptySemantic({
3680
- sessionId: state.id,
3681
- speaker: normalizedSpeaker,
3682
- turnId: state.pending_turn_id || turn_id || null,
3683
- });
3684
- 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}${source_metadata?.source_machine_id ? ` | source_machine: ${source_metadata.source_machine_id}` : ""}`);
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);
529
+ }
3685
530
 
3686
- state.current_speaker = selectNextSpeaker(state);
531
+ // Speaker/Candidate Discovery functions moved to lib/speaker-discovery.js
3687
532
 
3688
- // Round transition: check if all speakers have spoken this round
3689
- const roundEntries = state.log.filter(e => e.round === state.current_round);
3690
- const spokeSpeakers = new Set(roundEntries.map(e => e.speaker));
3691
- const allSpoke = state.speakers.every(s => spokeSpeakers.has(s));
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
3692
537
 
3693
- if (allSpoke) {
3694
- if (state.current_round >= state.max_rounds) {
3695
- state.status = "awaiting_synthesis";
3696
- state.current_speaker = "none";
3697
- saveSession(state);
3698
- return {
3699
- content: [{
3700
- type: "text",
3701
- 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),
3702
- }],
3703
- };
3704
- }
3705
- state.current_round += 1;
3706
- }
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
3707
543
 
3708
- if (state.status === "active") {
3709
- state.pending_turn_id = generateTurnId();
3710
- }
544
+ // ── MCP Server ─────────────────────────────────────────────────
3711
545
 
3712
- if (!state.orchestrator_session_id) {
3713
- state.orchestrator_session_id = getDefaultOrchestratorSessionId() || null;
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);
3714
551
  }
3715
- completionEntry = {
3716
- ...logEntry,
3717
- turn_id: logEntry.turn_id || turn_id || null,
3718
- };
3719
- completionState = {
3720
- ...state,
3721
- log: [...state.log],
3722
- };
3723
- saveSession(state);
3724
- return {
3725
- content: [{
3726
- type: "text",
3727
- 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),
3728
- }],
3729
- };
3730
552
  });
3731
-
3732
- if (completionState && completionEntry) {
3733
- const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
3734
- notifyTeleptyBus(envelope).catch(() => {});
3735
-
3736
- const orchestratorSessionId = completionState.orchestrator_session_id || null;
3737
- if (orchestratorSessionId) {
3738
- const notificationText = buildTurnCompletionNotificationText(completionState, completionEntry);
3739
- notifyTeleptySessionInject({
3740
- targetSessionId: orchestratorSessionId,
3741
- prompt: notificationText,
3742
- fromSessionId: `deliberation:${completionState.id}`,
3743
- }).catch(() => {});
3744
- }
3745
- }
3746
-
3747
- return result;
3748
553
  }
3749
554
 
3750
- // ── MCP Server ─────────────────────────────────────────────────
3751
-
3752
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
+ }
3753
561
  const message = formatRuntimeError(error);
3754
562
  appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
3755
563
  try {
@@ -3760,6 +568,11 @@ process.on("uncaughtException", (error) => {
3760
568
  });
3761
569
 
3762
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
+ }
3763
576
  const message = formatRuntimeError(reason);
3764
577
  appendRuntimeLog("UNHANDLED_REJECTION", message);
3765
578
  try {
@@ -3773,6 +586,55 @@ process.on("unhandledRejection", (reason) => {
3773
586
  const __pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json");
3774
587
  const __pkgVersion = JSON.parse(fs.readFileSync(__pkgPath, "utf-8")).version;
3775
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
+
3776
638
  const server = new McpServer({
3777
639
  name: "mcp-deliberation",
3778
640
  version: __pkgVersion,
@@ -4566,534 +1428,7 @@ server.tool(
4566
1428
  })
4567
1429
  );
4568
1430
 
4569
- // ────────────────────────────────────────────────────────────────────────────
4570
- // Auto-handoff orchestrator helpers
4571
- // ────────────────────────────────────────────────────────────────────────────
4572
-
4573
- /**
4574
- * Run a single CLI auto-turn for the given session and speaker.
4575
- * Returns { ok: true, response, elapsedMs } or { ok: false, error }.
4576
- */
4577
- async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
4578
- const state = loadSession(sessionId);
4579
- if (!state || state.status !== "active") {
4580
- return { ok: false, error: "Session not active" };
4581
- }
4582
-
4583
- const { transport } = resolveTransportForSpeaker(state, speaker);
4584
- if (transport !== "cli_respond") {
4585
- return { ok: false, error: `Speaker "${speaker}" is not CLI type` };
4586
- }
4587
-
4588
- const hint = CLI_INVOCATION_HINTS[speaker];
4589
- if (!hint) return { ok: false, error: `No CLI hints for "${speaker}"` };
4590
- if (!checkCliLiveness(hint.cmd)) return { ok: false, error: `CLI "${hint.cmd}" not available` };
4591
-
4592
- const turnId = state.pending_turn_id || generateTurnId();
4593
- const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4594
- const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
4595
- const effectiveTimeout = getCliAutoTurnTimeoutSec({
4596
- speaker,
4597
- requestedTimeoutSec: timeoutSec,
4598
- promptLength: turnPrompt.length,
4599
- priorTurns: speakerPriorTurns,
4600
- });
4601
-
4602
- const startTime = Date.now();
4603
- try {
4604
- const response = await new Promise((resolve, reject) => {
4605
- const env = { ...process.env };
4606
- if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4607
-
4608
- let child;
4609
- let stdout = "";
4610
- let stderr = "";
4611
- let settled = false;
4612
- let forceKillTimer = null;
4613
-
4614
- const resolveOnce = (v) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); resolve(v); } };
4615
- const rejectOnce = (e) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); reject(e); } };
4616
-
4617
- switch (speaker) {
4618
- case "claude":
4619
- child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4620
- child.stdin.write(turnPrompt);
4621
- child.stdin.end();
4622
- break;
4623
- case "codex":
4624
- child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4625
- child.stdin.write(turnPrompt);
4626
- child.stdin.end();
4627
- break;
4628
- case "gemini":
4629
- child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
4630
- break;
4631
- default: {
4632
- const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4633
- child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
4634
- break;
4635
- }
4636
- }
4637
-
4638
- const timer = setTimeout(() => {
4639
- try { child.kill("SIGTERM"); } catch {}
4640
- forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5000);
4641
- if (typeof forceKillTimer?.unref === "function") forceKillTimer.unref();
4642
- rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
4643
- }, effectiveTimeout * 1000);
4644
-
4645
- child.stdout.on("data", (d) => { stdout += d.toString(); });
4646
- child.stderr.on("data", (d) => { stderr += d.toString(); });
4647
-
4648
- child.on("close", (code) => {
4649
- clearTimeout(timer);
4650
- if (code !== 0 && !stdout.trim()) {
4651
- rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
4652
- } else {
4653
- resolveOnce(stdout.trim());
4654
- }
4655
- });
4656
-
4657
- child.on("error", (err) => rejectOnce(err));
4658
- });
4659
-
4660
- // Submit the turn
4661
- submitDeliberationTurn({
4662
- session_id: sessionId,
4663
- speaker,
4664
- content: response,
4665
- turn_id: turnId,
4666
- channel_used: "cli_auto",
4667
- });
4668
-
4669
- return { ok: true, response, elapsedMs: Date.now() - startTime };
4670
- } catch (err) {
4671
- return { ok: false, error: err.message };
4672
- }
4673
- }
4674
-
4675
- async function runBrowserAutoTurnCore(sessionId, speaker, timeoutSec = 45) {
4676
- const state = loadSession(sessionId);
4677
- if (!state || state.status !== "active") {
4678
- return { ok: false, error: "Session not active" };
4679
- }
4680
-
4681
- const { transport, profile } = resolveTransportForSpeaker(state, speaker);
4682
- if (transport !== "browser_auto") {
4683
- return { ok: false, error: `Speaker "${speaker}" is not browser_auto type` };
4684
- }
4685
-
4686
- const turnId = state.pending_turn_id || generateTurnId();
4687
- const port = getBrowserPort();
4688
- const effectiveProvider = profile?.provider || "chatgpt";
4689
- const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
4690
- const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4691
- const startTime = Date.now();
4692
-
4693
- try {
4694
- const attachResult = await port.attach(sessionId, {
4695
- provider: effectiveProvider,
4696
- url: profile?.url || undefined,
4697
- });
4698
- if (!attachResult.ok) {
4699
- return { ok: false, error: `attach failed: ${attachResult.error?.message || "unknown error"}` };
4700
- }
4701
-
4702
- const loginCheck = await port.checkLogin(sessionId);
4703
- if (loginCheck && !loginCheck.loggedIn) {
4704
- await port.detach(sessionId);
4705
- return { ok: false, error: `login required: ${loginCheck.reason || "not logged in"}` };
4706
- }
4707
-
4708
- if (modelSelection.model !== "default") {
4709
- await port.switchModel(sessionId, modelSelection.model);
4710
- }
4711
-
4712
- const sendResult = await port.sendTurnWithDegradation(sessionId, turnId, turnPrompt);
4713
- if (!sendResult.ok) {
4714
- await port.detach(sessionId);
4715
- return { ok: false, error: `send failed: ${sendResult.error?.message || "unknown error"}` };
4716
- }
4717
-
4718
- const waitResult = await port.waitTurnResult(sessionId, turnId, timeoutSec);
4719
- await port.detach(sessionId);
4720
- if (!waitResult.ok || !waitResult.data?.response) {
4721
- return { ok: false, error: waitResult.error?.message || "no response received" };
4722
- }
4723
-
4724
- submitDeliberationTurn({
4725
- session_id: sessionId,
4726
- speaker,
4727
- content: waitResult.data.response,
4728
- turn_id: turnId,
4729
- channel_used: "browser_auto",
4730
- });
4731
-
4732
- return {
4733
- ok: true,
4734
- response: waitResult.data.response,
4735
- elapsedMs: Date.now() - startTime,
4736
- model: modelSelection.model,
4737
- provider: effectiveProvider,
4738
- };
4739
- } catch (err) {
4740
- try { await port.detach(sessionId); } catch {}
4741
- return { ok: false, error: err?.message || String(err) };
4742
- }
4743
- }
4744
-
4745
- async function runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries = 4) {
4746
- const state = loadSession(sessionId);
4747
- if (!state || state.status !== "active") {
4748
- return { ok: false, error: "Session not active" };
4749
- }
4750
-
4751
- const { transport } = resolveTransportForSpeaker(state, speaker);
4752
- if (transport !== "telepty_bus") {
4753
- return { ok: false, error: `Speaker "${speaker}" is not telepty_bus type` };
4754
- }
4755
-
4756
- const startTime = Date.now();
4757
- const dispatchResult = await dispatchTeleptyTurnRequest({
4758
- state,
4759
- speaker,
4760
- includeHistoryEntries,
4761
- awaitSemantic: true,
4762
- });
4763
- if (!dispatchResult.publishResult?.ok) {
4764
- return {
4765
- ok: false,
4766
- blocked: true,
4767
- error: dispatchResult.publishResult?.error || dispatchResult.publishResult?.status || "telepty bus publish failed",
4768
- envelope: dispatchResult.envelope,
4769
- turnPrompt: dispatchResult.turnPrompt,
4770
- };
4771
- }
4772
- if (!dispatchResult.transportResult?.ok) {
4773
- return {
4774
- ok: false,
4775
- blocked: true,
4776
- error: dispatchResult.transportResult?.code || "transport timeout",
4777
- envelope: dispatchResult.envelope,
4778
- turnPrompt: dispatchResult.turnPrompt,
4779
- };
4780
- }
4781
- if (!dispatchResult.semanticResult?.ok) {
4782
- return {
4783
- ok: false,
4784
- blocked: true,
4785
- error: dispatchResult.semanticResult?.code || "semantic timeout",
4786
- envelope: dispatchResult.envelope,
4787
- turnPrompt: dispatchResult.turnPrompt,
4788
- };
4789
- }
4790
-
4791
- return {
4792
- ok: true,
4793
- elapsedMs: Date.now() - startTime,
4794
- envelope: dispatchResult.envelope,
4795
- publishResult: dispatchResult.publishResult,
4796
- transportResult: dispatchResult.transportResult,
4797
- semanticResult: dispatchResult.semanticResult,
4798
- };
4799
- }
4800
-
4801
- async function runUntilBlockedCore(sessionId, {
4802
- maxTurns = 12,
4803
- cliTimeoutSec = 120,
4804
- browserTimeoutSec = 45,
4805
- includeHistoryEntries = 4,
4806
- } = {}) {
4807
- const steps = [];
4808
-
4809
- for (let iteration = 0; iteration < maxTurns; iteration += 1) {
4810
- const state = loadSession(sessionId);
4811
- if (!state) {
4812
- return { ok: false, status: "missing", error: "Session not found", steps };
4813
- }
4814
- if (state.status !== "active" || state.current_speaker === "none") {
4815
- return { ok: true, status: state.status, steps };
4816
- }
4817
-
4818
- const speaker = state.current_speaker;
4819
- const { transport } = resolveTransportForSpeaker(state, speaker);
4820
- const callerSpeaker = detectCallerSpeaker();
4821
- if (transport === "cli_respond" && callerSpeaker && normalizeSpeaker(callerSpeaker) === normalizeSpeaker(speaker)) {
4822
- return {
4823
- ok: true,
4824
- status: "blocked",
4825
- block_reason: "self_turn",
4826
- speaker,
4827
- transport,
4828
- turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4829
- steps,
4830
- };
4831
- }
4832
-
4833
- if (transport === "manual" || transport === "clipboard") {
4834
- return {
4835
- ok: true,
4836
- status: "blocked",
4837
- block_reason: "manual_transport",
4838
- speaker,
4839
- transport,
4840
- turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4841
- steps,
4842
- };
4843
- }
4844
-
4845
- let result = null;
4846
- if (transport === "cli_respond") {
4847
- result = await runCliAutoTurnCore(sessionId, speaker, cliTimeoutSec);
4848
- } else if (transport === "browser_auto") {
4849
- result = await runBrowserAutoTurnCore(sessionId, speaker, browserTimeoutSec);
4850
- } else if (transport === "telepty_bus") {
4851
- result = await runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries);
4852
- } else {
4853
- return {
4854
- ok: true,
4855
- status: "blocked",
4856
- block_reason: "unsupported_transport",
4857
- speaker,
4858
- transport,
4859
- turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
4860
- steps,
4861
- };
4862
- }
4863
-
4864
- steps.push({
4865
- speaker,
4866
- transport,
4867
- ok: Boolean(result?.ok),
4868
- error: result?.error || null,
4869
- elapsedMs: result?.elapsedMs || null,
4870
- blocked: Boolean(result?.blocked),
4871
- });
4872
-
4873
- if (!result?.ok) {
4874
- return {
4875
- ok: Boolean(result?.blocked),
4876
- status: result?.blocked ? "blocked" : "error",
4877
- block_reason: result?.blocked ? (result.error || "transport_blocked") : null,
4878
- speaker,
4879
- transport,
4880
- error: result?.error || null,
4881
- turn_prompt: result?.turnPrompt || null,
4882
- steps,
4883
- };
4884
- }
4885
- }
4886
-
4887
- const finalState = loadSession(sessionId);
4888
- return {
4889
- ok: true,
4890
- status: finalState?.status === "active" ? "max_turns_reached" : (finalState?.status || "completed"),
4891
- steps,
4892
- };
4893
- }
4894
-
4895
- /**
4896
- * Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
4897
- */
4898
- async function generateAutoSynthesis(sessionId) {
4899
- const state = loadSession(sessionId);
4900
- if (!state) return null;
4901
-
4902
- const historyText = state.log.map(e => `[${e.speaker}] ${e.content}`).join("\n\n---\n\n");
4903
-
4904
- const synthesisPrompt = `You are a deliberation synthesizer. Analyze this discussion and produce ONLY a JSON response (no markdown, no explanation).
4905
-
4906
- Topic: ${state.topic}
4907
- Project: ${state.project}
4908
- Rounds: ${state.max_rounds}
4909
-
4910
- Discussion:
4911
- ${historyText}
4912
-
4913
- Respond with EXACTLY this JSON structure:
4914
- {
4915
- "summary": "Brief summary of the outcome",
4916
- "decisions": ["Decision 1", "Decision 2"],
4917
- "actionable_tasks": [
4918
- {"id": 1, "task": "What to do", "files": ["path/to/file.ts"], "project": "${state.project}", "priority": "high|medium|low"}
4919
- ],
4920
- "markdown_synthesis": "# Full synthesis in markdown\\n\\n..."
4921
- }`;
4922
-
4923
- // Use the first available CLI speaker to generate synthesis
4924
- const speaker = state.speakers.find(s => {
4925
- const hint = CLI_INVOCATION_HINTS[s];
4926
- return hint && checkCliLiveness(hint.cmd);
4927
- });
4928
-
4929
- if (!speaker) return null;
4930
-
4931
- const hint = CLI_INVOCATION_HINTS[speaker];
4932
-
4933
- try {
4934
- const response = await new Promise((resolve, reject) => {
4935
- const env = { ...process.env };
4936
- if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4937
-
4938
- let child;
4939
- let stdout = "";
4940
-
4941
- switch (speaker) {
4942
- case "claude":
4943
- child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4944
- child.stdin.write(synthesisPrompt);
4945
- child.stdin.end();
4946
- break;
4947
- case "codex":
4948
- child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4949
- child.stdin.write(synthesisPrompt);
4950
- child.stdin.end();
4951
- break;
4952
- case "gemini":
4953
- child = spawn("gemini", ["-p", synthesisPrompt], { env, windowsHide: true });
4954
- break;
4955
- default: {
4956
- const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4957
- child = spawn(hint.cmd, [...flags, synthesisPrompt], { env, windowsHide: true });
4958
- break;
4959
- }
4960
- }
4961
-
4962
- const timer = setTimeout(() => {
4963
- try { child.kill("SIGTERM"); } catch {}
4964
- reject(new Error("Synthesis generation timeout"));
4965
- }, 180000); // 3 min timeout for synthesis
4966
-
4967
- child.stdout.on("data", (d) => { stdout += d.toString(); });
4968
- child.on("close", (code) => {
4969
- clearTimeout(timer);
4970
- resolve(stdout.trim());
4971
- });
4972
- child.on("error", reject);
4973
- });
4974
-
4975
- // Extract JSON from response (may have markdown wrapping)
4976
- const jsonMatch = response.match(/\{[\s\S]*\}/);
4977
- if (!jsonMatch) return { markdown_synthesis: response };
4978
-
4979
- try {
4980
- return JSON.parse(jsonMatch[0]);
4981
- } catch {
4982
- return { markdown_synthesis: response };
4983
- }
4984
- } catch (err) {
4985
- appendRuntimeLog("ERROR", `AUTO_SYNTHESIS_FAILED: ${sessionId} | ${err.message}`);
4986
- return null;
4987
- }
4988
- }
4989
-
4990
- /**
4991
- * Orchestrate full auto-handoff: run all turns -> synthesize -> inbox -> telepty.
4992
- * Called as fire-and-forget from deliberation_start when auto_execute is true.
4993
- */
4994
- async function runAutoHandoff(sessionId) {
4995
- appendRuntimeLog("INFO", `AUTO_HANDOFF_START: ${sessionId}`);
4996
-
4997
- try {
4998
- // Phase 1: Run all deliberation turns
4999
- let maxIterations = 100; // safety limit
5000
- while (maxIterations-- > 0) {
5001
- const state = loadSession(sessionId);
5002
- if (!state) {
5003
- appendRuntimeLog("ERROR", `AUTO_HANDOFF: Session ${sessionId} disappeared`);
5004
- return;
5005
- }
5006
- if (state.status !== "active") {
5007
- appendRuntimeLog("INFO", `AUTO_HANDOFF: Session ${sessionId} status=${state.status}, turns done`);
5008
- break;
5009
- }
5010
-
5011
- const speaker = state.current_speaker;
5012
- if (speaker === "none") break;
5013
-
5014
- appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
5015
-
5016
- const runResult = await runUntilBlockedCore(sessionId, { maxTurns: 1, includeHistoryEntries: 3 });
5017
- const step = runResult.steps.at(-1) || null;
5018
- if (!runResult.ok || runResult.status === "blocked") {
5019
- appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_BLOCKED: ${sessionId} | speaker: ${speaker} | ${runResult.block_reason || runResult.error || "unknown"}`);
5020
- break;
5021
- }
5022
-
5023
- appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${step?.elapsedMs || 0}ms`);
5024
- }
5025
-
5026
- // Phase 2: Generate structured synthesis
5027
- appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZE: ${sessionId}`);
5028
- let synthResult = await generateAutoSynthesis(sessionId);
5029
-
5030
- // Phase 3: Call synthesize (reuse existing logic)
5031
- const state = loadSession(sessionId);
5032
- if (!state) return;
5033
-
5034
- // Fallback: if synthesis generation failed, build a basic structure from the discussion
5035
- if (!synthResult || (!synthResult.summary && !synthResult.actionable_tasks)) {
5036
- appendRuntimeLog("WARN", `AUTO_HANDOFF_SYNTH_FALLBACK: ${sessionId} | Building fallback from discussion log`);
5037
- const turns = state.log || [];
5038
- const fallbackSummary = turns.length > 0
5039
- ? `Deliberation on "${state.topic}" completed with ${turns.length} turns from ${[...new Set(turns.map(t => t.speaker))].join(", ")}.`
5040
- : `Deliberation on "${state.topic}" completed.`;
5041
- synthResult = {
5042
- summary: fallbackSummary,
5043
- decisions: [`Discussed: ${state.topic}`],
5044
- actionable_tasks: [],
5045
- 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")}`,
5046
- };
5047
- }
5048
-
5049
- const markdownSynthesis = synthResult?.markdown_synthesis ||
5050
- `# 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")}`;
5051
-
5052
- const structured = {
5053
- summary: synthResult.summary || "",
5054
- decisions: synthResult.decisions || [],
5055
- actionable_tasks: synthResult.actionable_tasks || [],
5056
- };
5057
-
5058
- // Apply synthesis to session
5059
- withSessionLock(sessionId, () => {
5060
- const loaded = loadSession(sessionId);
5061
- if (!loaded) return;
5062
- loaded.synthesis = markdownSynthesis;
5063
- loaded.structured_synthesis = structured;
5064
- loaded.execution_contract = buildExecutionContract({ state: loaded, structured });
5065
- loaded.status = "completed";
5066
- loaded.current_speaker = "none";
5067
- saveSession(loaded);
5068
- archiveState(loaded);
5069
- cleanupSyncMarkdown(loaded);
5070
-
5071
- const sessionFile = getSessionFile(loaded);
5072
- try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch {}
5073
- });
5074
-
5075
- closeMonitorTerminal(sessionId, getSessionWindowIds(state));
5076
-
5077
- appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZED: ${sessionId}`);
5078
-
5079
- // Phase 4: Notify telepty bus with full structured data for dustcraw to consume
5080
- if (state.auto_execute) {
5081
- const envelope = buildTeleptySynthesisEnvelope({
5082
- state,
5083
- synthesis: markdownSynthesis,
5084
- structured,
5085
- });
5086
- await notifyTeleptyBus(envelope).catch(() => {});
5087
- appendRuntimeLog("INFO", `AUTO_HANDOFF_NOTIFIED: ${sessionId} | telepty event sent`);
5088
- }
5089
-
5090
- appendRuntimeLog("INFO", `AUTO_HANDOFF_COMPLETE: ${sessionId}`);
5091
- } catch (err) {
5092
- appendRuntimeLog("ERROR", `AUTO_HANDOFF_ERROR: ${sessionId} | ${err.message}`);
5093
- }
5094
- }
5095
-
5096
- // ────────────────────────────────────────────────────────────────────────────
1431
+ // Auto-handoff orchestrator helpers — moved to lib/transport.js
5097
1432
 
5098
1433
  server.tool(
5099
1434
  "deliberation_cli_auto_turn",
@@ -5735,6 +2070,21 @@ server.tool(
5735
2070
  // Notify brain ingest if endpoint configured
5736
2071
  callBrainIngest(state.execution_contract).catch(() => {}); // fire-and-forget
5737
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
+
5738
2088
  return {
5739
2089
  content: [{
5740
2090
  type: "text",
@@ -5959,92 +2309,7 @@ server.tool(
5959
2309
  })
5960
2310
  );
5961
2311
 
5962
- // ── Request Review (auto-review) ───────────────────────────────
5963
-
5964
- function invokeCliReviewer(command, prompt, timeoutMs) {
5965
- const hint = CLI_INVOCATION_HINTS[command];
5966
- let args;
5967
- let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
5968
- const env = { ...process.env };
5969
-
5970
- switch (command) {
5971
- case "claude":
5972
- if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
5973
- args = ["-p", "--output-format", "text", "--no-input"];
5974
- opts.input = prompt;
5975
- break;
5976
- case "codex":
5977
- args = ["exec", "-"];
5978
- opts.input = prompt;
5979
- break;
5980
- case "gemini":
5981
- args = ["-p", prompt];
5982
- opts.stdio = ["ignore", "pipe", "pipe"];
5983
- break;
5984
- default: {
5985
- const flags = hint?.flags ? hint.flags.split(/\s+/).filter(Boolean) : ["-p"];
5986
- args = [...flags, prompt];
5987
- opts.stdio = ["ignore", "pipe", "pipe"];
5988
- break;
5989
- }
5990
- }
5991
-
5992
- try {
5993
- const result = execFileSync(command, args, { ...opts, env });
5994
- let cleaned = result;
5995
- if (command === "codex") {
5996
- const lines = result.split("\n");
5997
- const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
5998
- if (codexLineIdx !== -1) {
5999
- cleaned = lines.slice(codexLineIdx + 1)
6000
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
6001
- .join("\n");
6002
- }
6003
- }
6004
- return { ok: true, response: cleaned.trim() };
6005
- } catch (error) {
6006
- if (error && error.killed) {
6007
- return { ok: false, error: "timeout" };
6008
- }
6009
- const msg = error instanceof Error ? error.message : String(error);
6010
- return { ok: false, error: msg };
6011
- }
6012
- }
6013
-
6014
- function buildReviewPrompt(context, question, priorReviews) {
6015
- let prompt = `You are a code reviewer. Provide a concise, structured review.\n\n`;
6016
- prompt += `## Context\n${context}\n\n`;
6017
- prompt += `## Review Question\n${question}\n\n`;
6018
- if (priorReviews.length > 0) {
6019
- prompt += `## Prior Reviews\n`;
6020
- for (const r of priorReviews) {
6021
- prompt += `### ${r.reviewer}\n${r.response}\n\n`;
6022
- }
6023
- }
6024
- prompt += `Respond with your review. Be specific about issues, risks, and suggestions.`;
6025
- return prompt;
6026
- }
6027
-
6028
- function synthesizeReviews(context, question, reviews) {
6029
- if (reviews.length === 0) return "(No reviews completed)";
6030
-
6031
- let synthesis = `## Review Synthesis\n\n`;
6032
- synthesis += `**Question:** ${question}\n`;
6033
- synthesis += `**Reviews:** ${reviews.length}\n\n`;
6034
-
6035
- synthesis += `### Individual Reviews\n\n`;
6036
- for (const r of reviews) {
6037
- synthesis += `#### ${r.reviewer}\n${r.response}\n\n`;
6038
- }
6039
-
6040
- if (reviews.length > 1) {
6041
- synthesis += `### Summary\n`;
6042
- synthesis += `${reviews.length} reviewer(s) provided feedback on: ${question}\n`;
6043
- synthesis += `Reviewers: ${reviews.map(r => r.reviewer).join(", ")}\n`;
6044
- }
6045
-
6046
- return synthesis;
6047
- }
2312
+ // invokeCliReviewer, buildReviewPrompt, synthesizeReviews moved to lib/transport.js
6048
2313
 
6049
2314
  server.tool(
6050
2315
  "deliberation_request_review",
@@ -6563,6 +2828,21 @@ server.tool(
6563
2828
  actionPlan = state.actionPlan;
6564
2829
 
6565
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
+ }
6566
2846
  });
6567
2847
 
6568
2848
  if (synthesisText.startsWith("❌")) {
@@ -6719,4 +2999,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
6719
2999
  }
6720
3000
 
6721
3001
  // ── Test exports (used by vitest) ──
6722
- 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, buildTeleptyTurnCompletedEnvelope, 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 };