@dmsdc-ai/aigentry-deliberation 0.0.39 → 0.0.41

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,161 @@ 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
+ DEFAULT_SESSION_TTL_MS,
158
+ isSessionExpired,
159
+ generateSessionId,
160
+ generateTurnId,
161
+ detectContextDirs,
162
+ readContextFromDirs,
163
+ getArchiveDir,
164
+ findSessionRecord,
165
+ ensureDirs,
166
+ loadSession,
167
+ saveSession,
168
+ listActiveSessions,
169
+ resolveSessionId,
170
+ syncMarkdown,
171
+ cleanupSyncMarkdown,
172
+ formatSourceMetadataLine,
173
+ stateToMarkdown,
174
+ archiveState,
175
+ multipleSessionsError,
176
+ truncatePromptText,
177
+ getPromptBudgetForSpeaker,
178
+ formatRecentLogForPrompt,
179
+ buildActiveReportingSection,
180
+ buildClipboardTurnPrompt,
181
+ submitDeliberationTurn,
182
+ } from "./lib/session.js";
183
+ import {
184
+ initTransportDeps,
185
+ // Constants
186
+ TMUX_SESSION,
187
+ MONITOR_SCRIPT,
188
+ MONITOR_SCRIPT_WIN,
189
+ // Terminal management
190
+ tmuxWindowName,
191
+ appleScriptQuote,
192
+ tryExecFile,
193
+ resolveMonitorShell,
194
+ buildMonitorCommand,
195
+ buildMonitorCommandWindows,
196
+ hasTmuxSession,
197
+ hasTmuxWindow,
198
+ tmuxHasAttachedClients,
199
+ isTmuxWindowViewed,
200
+ tmuxWindowCount,
201
+ buildTmuxAttachCommand,
202
+ listPhysicalTerminalWindowIds,
203
+ openPhysicalTerminal,
204
+ spawnMonitorTerminal,
205
+ closePhysicalTerminal,
206
+ closeMonitorTerminal,
207
+ getSessionWindowIds,
208
+ closeAllMonitorTerminals,
209
+ // Browser port singleton
210
+ getBrowserPort,
211
+ // CLI auto-turn helpers
212
+ getCliAutoTurnTimeoutSec,
213
+ getCliExecArgs,
214
+ buildCliAutoTurnFailureText,
215
+ // Auto-turn execution core
216
+ runCliAutoTurnCore,
217
+ runBrowserAutoTurnCore,
218
+ runTeleptyBusAutoTurnCore,
219
+ runUntilBlockedCore,
220
+ generateAutoSynthesis,
221
+ runAutoHandoff,
222
+ // Review helpers
223
+ invokeCliReviewer,
224
+ buildReviewPrompt,
225
+ synthesizeReviews,
226
+ } from "./lib/transport.js";
80
227
  import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
81
228
  import {
82
229
  DECISION_STAGES, STAGE_TRANSITIONS,
@@ -86,6 +233,63 @@ import {
86
233
  loadTemplates, matchTemplate,
87
234
  } from "./decision-engine.js";
88
235
  import { detectLang, t } from "./i18n.js";
236
+ import { checkToolEntitlement } from "./lib/entitlement.js";
237
+ import {
238
+ initTeleptyDeps,
239
+ // Schemas
240
+ StructuredActionableTaskSchema,
241
+ StructuredExperimentOutcomeSchema,
242
+ StructuredSynthesisSchema,
243
+ StructuredExecutionContractSchema,
244
+ TeleptyEnvelopeSchema,
245
+ TeleptyTurnRequestPayloadSchema,
246
+ TeleptyTurnCompletedPayloadSchema,
247
+ TeleptyDeliberationCompletedPayloadSchema,
248
+ TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS,
249
+ // Constants
250
+ TELEPTY_CONFIG_FILE,
251
+ TELEPTY_DEFAULT_HOST,
252
+ TELEPTY_PORT,
253
+ TELEPTY_TRANSPORT_TIMEOUT_MS,
254
+ TELEPTY_SEMANTIC_TIMEOUT_MS,
255
+ TELEPTY_BUS_RECONNECT_MS,
256
+ TELEPTY_SESSION_HEALTH_STALE_MS,
257
+ // State
258
+ teleptyBusState,
259
+ pendingTeleptyTurnRequests,
260
+ // Functions
261
+ hashPromptText,
262
+ sortJsonValue,
263
+ hashStructuredSynthesis,
264
+ buildExecutionContract,
265
+ createEnvelopeId,
266
+ validateTeleptyEnvelope,
267
+ resolveTeleptySourceHost,
268
+ buildTeleptyEnvelope,
269
+ buildTeleptyTurnRequestEnvelope,
270
+ buildTeleptyTurnCompletedEnvelope,
271
+ buildTeleptySynthesisEnvelope,
272
+ resolveTeleptyBusUrl,
273
+ cleanupPendingTeleptyTurn,
274
+ registerPendingTeleptyTurnRequest,
275
+ ackPendingTeleptyTurn,
276
+ completePendingTeleptySemantic,
277
+ updateTeleptySessionHealth,
278
+ getTeleptySessionHealth,
279
+ handleTeleptyBusMessage,
280
+ ensureTeleptyBusSubscriber,
281
+ callBrainIngest,
282
+ notifyTeleptyBus,
283
+ getDefaultOrchestratorSessionId,
284
+ buildTurnCompletionNotificationText,
285
+ notifyTeleptySessionInject,
286
+ dispatchTeleptyTurnRequest,
287
+ loadTeleptyAuthToken,
288
+ formatTeleptyHostLabel,
289
+ collectTeleptySessions,
290
+ scoreTeleptyProcessMatch,
291
+ collectTeleptyProcessLocators,
292
+ } from "./lib/telepty.js";
89
293
 
90
294
  // ── Paths ──────────────────────────────────────────────────────
91
295
 
@@ -98,29 +302,6 @@ const GLOBAL_STATE_DIR = path.join(INSTALL_DIR, "state");
98
302
  const GLOBAL_RUNTIME_LOG = path.join(INSTALL_DIR, "runtime.log");
99
303
  const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
100
304
  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
305
  function loadDeliberationConfig() {
125
306
  const configPath = path.join(INSTALL_DIR, "config.json");
126
307
  try {
@@ -132,326 +313,16 @@ function loadDeliberationConfig() {
132
313
 
133
314
  function saveDeliberationConfig(config) {
134
315
  const configPath = path.join(INSTALL_DIR, "config.json");
316
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
135
317
  config.updated = new Date().toISOString();
136
318
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
137
319
  }
138
320
 
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
321
  const PRODUCT_DISCLAIMER = "ℹ️ This tool does not permanently modify external websites. It reads browser context in read-only mode to route speakers.";
448
322
  const LOCKS_SUBDIR = ".locks";
449
323
  const LOCK_RETRY_MS = 25;
450
324
  const LOCK_TIMEOUT_MS = 8000;
451
325
  const LOCK_STALE_MS = 60000;
452
- const SPEAKER_SELECTION_FILE = "speaker-selection.json";
453
- const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
454
-
455
326
  function getProjectSlug() {
456
327
  return path.basename(process.cwd());
457
328
  }
@@ -485,635 +356,47 @@ function getSessionFile(sessionRef, projectSlug) {
485
356
  return path.join(getSessionsDir(getSessionProject(sessionRef, projectSlug)), `${sessionId}.json`);
486
357
  }
487
358
 
488
- function listStateProjects() {
489
- if (!fs.existsSync(GLOBAL_STATE_DIR)) return [];
490
- try {
491
- return fs.readdirSync(GLOBAL_STATE_DIR, { withFileTypes: true })
492
- .filter(entry => entry.isDirectory())
493
- .map(entry => entry.name);
494
- } catch {
495
- return [];
496
- }
497
- }
498
-
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;
359
+ function getExecutionStatusFile(sessionId, projectSlug) {
360
+ return path.join(getProjectStateDir(projectSlug || getProjectSlug()), `exec-status-${sessionId}.json`);
866
361
  }
867
362
 
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) => {
363
+ function loadExecutionStatus(sessionId, projectSlug) {
364
+ // Search across all projects if projectSlug not given
365
+ const projects = projectSlug
366
+ ? [normalizeProjectSlug(projectSlug)]
367
+ : [getProjectSlug(), ...listStateProjects()];
368
+ for (const p of [...new Set(projects)]) {
369
+ const file = getExecutionStatusFile(sessionId, p);
877
370
  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();
371
+ const data = JSON.parse(fs.readFileSync(file, "utf-8"));
372
+ if (data && data.session_id === sessionId) return data;
373
+ } catch { /* not found */ }
994
374
  }
995
375
  return null;
996
376
  }
997
377
 
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");
378
+ function saveExecutionStatus(sessionId, projectSlug, patch) {
379
+ const file = getExecutionStatusFile(sessionId, normalizeProjectSlug(projectSlug || getProjectSlug()));
380
+ fs.mkdirSync(path.dirname(file), { recursive: true });
381
+ const existing = (() => { try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch { return {}; } })();
382
+ const updated = { ...existing, ...patch, session_id: sessionId, updated_at: new Date().toISOString() };
383
+ fs.writeFileSync(file, JSON.stringify(updated, null, 2));
384
+ return updated;
1028
385
  }
1029
386
 
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
-
387
+ function listStateProjects() {
388
+ if (!fs.existsSync(GLOBAL_STATE_DIR)) return [];
1035
389
  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;
390
+ return fs.readdirSync(GLOBAL_STATE_DIR, { withFileTypes: true })
391
+ .filter(entry => entry.isDirectory())
392
+ .map(entry => entry.name);
393
+ } catch {
394
+ return [];
1094
395
  }
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
396
  }
1108
397
 
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
- }
398
+ // findSessionRecord moved to lib/session.js
399
+ // getArchiveDir moved to lib/session.js
1117
400
 
1118
401
  function getLocksDir(projectSlug = getProjectSlug()) {
1119
402
  return path.join(getProjectStateDir(projectSlug), LOCKS_SUBDIR);
@@ -1154,6 +437,15 @@ function appendRuntimeLog(level, message) {
1154
437
 
1155
438
  function safeToolHandler(toolName, handler) {
1156
439
  return async (args) => {
440
+ const entitlement = checkToolEntitlement(toolName);
441
+ if (!entitlement.allowed) {
442
+ return {
443
+ content: [{
444
+ type: "text",
445
+ text: `⛔ ${entitlement.reason}. Current tier: ${entitlement.tier}.\nUpgrade: ${entitlement.upgrade_url || 'https://aigentry.dev/upgrade'}`,
446
+ }],
447
+ };
448
+ }
1157
449
  try {
1158
450
  return await handler(args);
1159
451
  } catch (error) {
@@ -1264,2492 +556,38 @@ function withSessionLock(sessionRef, fn, options) {
1264
556
  const projectSlug = explicitProject || record?.project || getProjectSlug();
1265
557
  const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
1266
558
  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
- }
3656
-
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}` : ""}`);
3685
-
3686
- state.current_speaker = selectNextSpeaker(state);
3687
-
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));
3692
-
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
- }
3707
-
3708
- if (state.status === "active") {
3709
- state.pending_turn_id = generateTurnId();
3710
- }
3711
-
3712
- if (!state.orchestrator_session_id) {
3713
- state.orchestrator_session_id = getDefaultOrchestratorSessionId() || null;
3714
- }
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
- });
559
+ }
3731
560
 
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
- }
561
+ // Speaker/Candidate Discovery functions moved to lib/speaker-discovery.js
3746
562
 
3747
- return result;
3748
- }
563
+ // BrowserControlPort singleton — moved to lib/transport.js
564
+ // Session ID generation, context detection, state helpers, markdown sync,
565
+ // archival — all moved to lib/session.js
566
+ // Terminal management — moved to lib/transport.js
567
+
568
+ // multipleSessionsError, truncatePromptText, getPromptBudgetForSpeaker,
569
+ // formatRecentLogForPrompt — moved to lib/session.js
570
+ // getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText — moved to lib/transport.js
571
+ // buildActiveReportingSection, buildClipboardTurnPrompt,
572
+ // submitDeliberationTurn — moved to lib/session.js
3749
573
 
3750
574
  // ── MCP Server ─────────────────────────────────────────────────
3751
575
 
576
+ // Gracefully handle EPIPE on stdout/stderr (MCP client disconnect)
577
+ for (const stream of [process.stdout, process.stderr]) {
578
+ stream.on("error", (err) => {
579
+ if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED") {
580
+ process.exit(0);
581
+ }
582
+ });
583
+ }
584
+
3752
585
  process.on("uncaughtException", (error) => {
586
+ // EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
587
+ if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") {
588
+ try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
589
+ process.exit(0);
590
+ }
3753
591
  const message = formatRuntimeError(error);
3754
592
  appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
3755
593
  try {
@@ -3760,6 +598,11 @@ process.on("uncaughtException", (error) => {
3760
598
  });
3761
599
 
3762
600
  process.on("unhandledRejection", (reason) => {
601
+ // EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
602
+ if (reason?.code === "EPIPE" || reason?.code === "ERR_STREAM_DESTROYED") {
603
+ try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
604
+ process.exit(0);
605
+ }
3763
606
  const message = formatRuntimeError(reason);
3764
607
  appendRuntimeLog("UNHANDLED_REJECTION", message);
3765
608
  try {
@@ -3773,6 +616,55 @@ process.on("unhandledRejection", (reason) => {
3773
616
  const __pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json");
3774
617
  const __pkgVersion = JSON.parse(fs.readFileSync(__pkgPath, "utf-8")).version;
3775
618
 
619
+ // ── Initialize speaker-discovery module dependencies ──
620
+ initSpeakerDeps({
621
+ appendRuntimeLog,
622
+ loadDeliberationConfig,
623
+ getProjectSlug,
624
+ readJsonFileSafe,
625
+ writeJsonFileAtomic,
626
+ getSpeakerSelectionFile,
627
+ });
628
+
629
+ // ── Initialize telepty module dependencies ──
630
+ initTeleptyDeps({
631
+ appendRuntimeLog,
632
+ normalizeSpeaker,
633
+ getProjectSlug,
634
+ resolveTransportForSpeaker,
635
+ generateTurnId,
636
+ buildClipboardTurnPrompt,
637
+ });
638
+
639
+ // ── Initialize session module dependencies ──
640
+ initSessionDeps({
641
+ appendRuntimeLog,
642
+ writeTextAtomic,
643
+ readJsonFileSafe,
644
+ writeJsonFileAtomic,
645
+ withSessionLock,
646
+ getProjectSlug,
647
+ normalizeProjectSlug,
648
+ getProjectStateDir,
649
+ getSessionsDir,
650
+ getSessionFile,
651
+ getSessionProject,
652
+ listStateProjects,
653
+ getLocksDir,
654
+ GLOBAL_STATE_DIR,
655
+ OBSIDIAN_PROJECTS,
656
+ });
657
+
658
+ // ── Initialize transport module dependencies ──
659
+ initTransportDeps({
660
+ appendRuntimeLog,
661
+ getProjectSlug,
662
+ getSessionFile,
663
+ withSessionLock,
664
+ loadDeliberationConfig,
665
+ resolveCdpEndpoints,
666
+ });
667
+
3776
668
  const server = new McpServer({
3777
669
  name: "mcp-deliberation",
3778
670
  version: __pkgVersion,
@@ -3828,11 +720,17 @@ server.tool(
3828
720
  (v) => (typeof v === "string" ? v === "true" : v),
3829
721
  z.boolean().optional()
3830
722
  ).describe("If true, automatically create a handoff task in the inbox when synthesis completes. Enables the Autonomous Deliberation Handoff pattern."),
723
+ auto_synthesize: z.preprocess(
724
+ (v) => (typeof v === "string" ? v === "true" : v),
725
+ z.boolean().optional()
726
+ ).describe("If true, automatically generate synthesis when all rounds complete. Lighter than auto_execute (no handoff)."),
3831
727
  mode: z.enum(["standard", "lite"]).default("standard").describe("Deliberation mode. 'lite' caps speakers to 3 and rounds to 2 for quick decisions."),
728
+ session_ttl_ms: z.number().int().min(60000).max(86400000).optional()
729
+ .describe("Session TTL in milliseconds. Sessions expire after this duration. Default: 7200000 (2 hours). Max: 86400000 (24 hours)."),
3832
730
  orchestrator_session_id: z.string().trim().min(1).max(128).optional()
3833
731
  .describe("Optional telepty session ID to notify on turn completion. Defaults to TELEPTY_SESSION_ID when available."),
3834
732
  },
3835
- safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute, mode, orchestrator_session_id }) => {
733
+ safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute, auto_synthesize, mode, session_ttl_ms, orchestrator_session_id }) => {
3836
734
  // ── First-time onboarding guard ──
3837
735
  const config = loadDeliberationConfig();
3838
736
  if (!config.setup_complete) {
@@ -4022,7 +920,9 @@ server.tool(
4022
920
  speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
4023
921
  degradation: degradationLevels,
4024
922
  auto_execute: auto_execute || false,
923
+ auto_synthesize: auto_synthesize || auto_execute || false,
4025
924
  mode: mode || "standard",
925
+ session_ttl_ms: session_ttl_ms || DEFAULT_SESSION_TTL_MS,
4026
926
  orchestrator_session_id: orchestrator_session_id || getDefaultOrchestratorSessionId() || null,
4027
927
  created: new Date().toISOString(),
4028
928
  updated: new Date().toISOString(),
@@ -4093,7 +993,9 @@ server.tool(
4093
993
  appendRuntimeLog("INFO", `SESSION_CREATED: ${sessionId} | topic: ${topic.slice(0, 60)} | speakers: ${speakerOrder.join(",")} | rounds: ${rounds}`);
4094
994
 
4095
995
  // Auto-handoff: kick off background orchestration
4096
- if (auto_execute) {
996
+ // auto_execute = full handoff (turns + synthesis + bus notification)
997
+ // auto_synthesize = lighter (turns + synthesis only, no bus notification)
998
+ if (auto_execute || auto_synthesize) {
4097
999
  // Fire-and-forget — runs in background
4098
1000
  runAutoHandoff(sessionId).catch(err => {
4099
1001
  appendRuntimeLog("ERROR", `AUTO_HANDOFF_SPAWN_ERROR: ${sessionId} | ${err.message}`);
@@ -4212,10 +1114,27 @@ server.tool(
4212
1114
  return { content: [{ type: "text", text: t(`Session "${resolved}" not found.`, `세션 "${resolved}"을 찾을 수 없습니다.`, "en") }] };
4213
1115
  }
4214
1116
 
1117
+ if (isSessionExpired(state)) {
1118
+ state.status = "expired";
1119
+ state.current_speaker = "none";
1120
+ state.expired_reason = "ttl_exceeded";
1121
+ saveSession(state);
1122
+ return {
1123
+ content: [{
1124
+ type: "text",
1125
+ text: `⏰ Session "${resolved}" has expired (TTL exceeded).\nTopic: ${state.topic}\nCreated: ${state.created}\n\nUse \`deliberation_reset(session_id: "${resolved}")\` to clean up, or start a new deliberation.`,
1126
+ }],
1127
+ };
1128
+ }
1129
+
1130
+ const execStatus = loadExecutionStatus(state.id, state.project);
1131
+ const execLine = execStatus
1132
+ ? `\n**Execution status:** ${execStatus.execution_status}${execStatus.tasks_total > 0 ? ` (${execStatus.tasks_done}/${execStatus.tasks_total} tasks)` : ""}${execStatus.note ? ` — ${execStatus.note}` : ""}`
1133
+ : "";
4215
1134
  return {
4216
1135
  content: [{
4217
1136
  type: "text",
4218
- text: `📋 **Forum Status** — ${state.id}\n\n**Project:** ${state.project}\n**Topic:** ${state.topic}\n**Status:** ${state.status === "active" ? "active" : state.status === "awaiting_synthesis" ? "awaiting synthesis" : state.status === "completed" ? "completed" : state.status} (Round ${state.current_round}/${state.max_rounds})\n**Participants:** ${state.speakers.join(", ")}\n**Current turn:** ${state.current_speaker}\n**Accumulated responses:** ${state.log.length}${state.degradation ? `\n\n**Environment status:**\n${formatDegradationReport(state.degradation)}` : ""}`,
1137
+ text: `📋 **Forum Status** — ${state.id}\n\n**Project:** ${state.project}\n**Topic:** ${state.topic}\n**Status:** ${state.status === "active" ? "active" : state.status === "awaiting_synthesis" ? "awaiting synthesis" : state.status === "completed" ? "completed" : state.status} (Round ${state.current_round}/${state.max_rounds})${execLine}\n**Participants:** ${state.speakers.join(", ")}\n**Current turn:** ${state.current_speaker}\n**Accumulated responses:** ${state.log.length}${state.degradation ? `\n\n**Environment status:**\n${formatDegradationReport(state.degradation)}` : ""}`,
4219
1138
  }],
4220
1139
  };
4221
1140
  }
@@ -4566,534 +1485,7 @@ server.tool(
4566
1485
  })
4567
1486
  );
4568
1487
 
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
- // ────────────────────────────────────────────────────────────────────────────
1488
+ // Auto-handoff orchestrator helpers — moved to lib/transport.js
5097
1489
 
5098
1490
  server.tool(
5099
1491
  "deliberation_cli_auto_turn",
@@ -5705,6 +2097,14 @@ server.tool(
5705
2097
  saveSession(loaded);
5706
2098
  archivePath = archiveState(loaded);
5707
2099
  cleanupSyncMarkdown(loaded);
2100
+ // Write execution_status sidecar (persists after session file is deleted)
2101
+ saveExecutionStatus(loaded.id, loaded.project, {
2102
+ execution_status: loaded.auto_execute ? "executing" : "pending",
2103
+ tasks_total: loaded.execution_contract?.tasks?.length ?? 0,
2104
+ tasks_done: 0,
2105
+ project: loaded.project,
2106
+ topic: loaded.topic,
2107
+ });
5708
2108
 
5709
2109
  // Clean up the active session JSON file upon completion
5710
2110
  const sessionFile = getSessionFile(loaded);
@@ -5735,6 +2135,21 @@ server.tool(
5735
2135
  // Notify brain ingest if endpoint configured
5736
2136
  callBrainIngest(state.execution_contract).catch(() => {}); // fire-and-forget
5737
2137
 
2138
+ // Emit lesson_learned events for orchestrator lessons.json auto-population
2139
+ if (state.execution_contract?.decisions?.length > 0) {
2140
+ const lessonEvent = {
2141
+ type: "lesson_learned",
2142
+ session_id: state.id,
2143
+ timestamp: new Date().toISOString(),
2144
+ project: state.project || getProjectSlug(),
2145
+ category: "decision",
2146
+ lesson: state.execution_contract.summary || state.synthesis?.slice(0, 200) || "",
2147
+ decisions: state.execution_contract.decisions,
2148
+ };
2149
+ notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
2150
+ appendRuntimeLog("INFO", `LESSON_LEARNED: ${state.id} | decisions: ${state.execution_contract.decisions.length} | project: ${lessonEvent.project}`);
2151
+ }
2152
+
5738
2153
  return {
5739
2154
  content: [{
5740
2155
  type: "text",
@@ -5744,6 +2159,51 @@ server.tool(
5744
2159
  })
5745
2160
  );
5746
2161
 
2162
+ server.tool(
2163
+ "deliberation_set_execution_status",
2164
+ "Update the execution status of a completed deliberation's handoff. Used by executor agents to report implementation progress.",
2165
+ {
2166
+ session_id: z.string().min(1).describe("Session ID of the completed deliberation"),
2167
+ status: z.enum(["pending", "executing", "implemented", "failed"]).describe("New execution status"),
2168
+ tasks_done: z.number().int().min(0).optional().describe("Number of tasks completed so far"),
2169
+ tasks_total: z.number().int().min(0).optional().describe("Total number of tasks (if known)"),
2170
+ note: z.string().max(200).optional().describe("Short progress note (max 200 chars)"),
2171
+ project: z.string().optional().describe("Project slug (auto-detected if omitted)"),
2172
+ },
2173
+ safeToolHandler("deliberation_set_execution_status", async ({ session_id, status, tasks_done, tasks_total, note, project }) => {
2174
+ const patch = { execution_status: status };
2175
+ if (tasks_done !== undefined) patch.tasks_done = tasks_done;
2176
+ if (tasks_total !== undefined) patch.tasks_total = tasks_total;
2177
+ if (note !== undefined) patch.note = note;
2178
+
2179
+ const saved = saveExecutionStatus(session_id, project, patch);
2180
+
2181
+ // Notify telepty bus so other MCP processes can react
2182
+ const statusEvent = {
2183
+ kind: "execution_status_update",
2184
+ session_id,
2185
+ project: project || getProjectSlug(),
2186
+ execution_status: status,
2187
+ tasks_done: saved.tasks_done ?? 0,
2188
+ tasks_total: saved.tasks_total ?? 0,
2189
+ note: note || null,
2190
+ timestamp: new Date().toISOString(),
2191
+ };
2192
+ notifyTeleptyBus(statusEvent).catch(() => {});
2193
+ appendRuntimeLog("INFO", `EXECUTION_STATUS: ${session_id} | status: ${status} | tasks: ${saved.tasks_done ?? 0}/${saved.tasks_total ?? 0}`);
2194
+
2195
+ const taskLine = (saved.tasks_total ?? 0) > 0
2196
+ ? ` (${saved.tasks_done ?? 0}/${saved.tasks_total} tasks)`
2197
+ : "";
2198
+ return {
2199
+ content: [{
2200
+ type: "text",
2201
+ text: `✅ Execution status updated\n\n**Session:** ${session_id}\n**Status:** ${status}${taskLine}${note ? `\n**Note:** ${note}` : ""}`,
2202
+ }],
2203
+ };
2204
+ })
2205
+ );
2206
+
5747
2207
  server.tool(
5748
2208
  "deliberation_list",
5749
2209
  "Return the list of past deliberation archives.",
@@ -5959,92 +2419,7 @@ server.tool(
5959
2419
  })
5960
2420
  );
5961
2421
 
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
- }
2422
+ // invokeCliReviewer, buildReviewPrompt, synthesizeReviews moved to lib/transport.js
6048
2423
 
6049
2424
  server.tool(
6050
2425
  "deliberation_request_review",
@@ -6563,6 +2938,21 @@ server.tool(
6563
2938
  actionPlan = state.actionPlan;
6564
2939
 
6565
2940
  appendRuntimeLog("INFO", `DECISION_COMPLETE: ${resolved} | conflicts_resolved: ${responses.length} | decision: ${(actionPlan?.decision || "").slice(0, 60)}`);
2941
+
2942
+ // Emit lesson_learned for decision outcomes
2943
+ if (actionPlan?.decision) {
2944
+ const lessonEvent = {
2945
+ type: "lesson_learned",
2946
+ session_id: resolved,
2947
+ timestamp: new Date().toISOString(),
2948
+ project: state.project || getProjectSlug(),
2949
+ category: "decision",
2950
+ lesson: actionPlan.decision,
2951
+ decisions: actionPlan.decision ? [actionPlan.decision] : [],
2952
+ };
2953
+ notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
2954
+ appendRuntimeLog("INFO", `LESSON_LEARNED: ${resolved} | decision: ${actionPlan.decision.slice(0, 60)}`);
2955
+ }
6566
2956
  });
6567
2957
 
6568
2958
  if (synthesisText.startsWith("❌")) {
@@ -6719,4 +3109,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
6719
3109
  }
6720
3110
 
6721
3111
  // ── 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 };
3112
+ 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 };