@dmsdc-ai/aigentry-deliberation 0.0.33 → 0.0.35

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
@@ -68,10 +68,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
68
68
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
69
69
  import { z } from "zod";
70
70
  import { execFileSync, spawn } from "child_process";
71
+ import { createHash } from "crypto";
71
72
  import fs from "fs";
72
73
  import path from "path";
73
74
  import { fileURLToPath } from "url";
74
75
  import os from "os";
76
+ import WebSocket from "ws";
75
77
  import { OrchestratedBrowserPort } from "./browser-control-port.js";
76
78
  import { getModelSelectionForTurn } from "./model-router.js";
77
79
  import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
@@ -110,6 +112,13 @@ const DEFAULT_CLI_CANDIDATES = [
110
112
  "continue",
111
113
  ];
112
114
  const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
115
+ const TELEPTY_CONFIG_FILE = path.join(HOME, ".telepty", "config.json");
116
+ const TELEPTY_DEFAULT_HOST = process.env.TELEPTY_HOST || "127.0.0.1";
117
+ const TELEPTY_PORT = Number(process.env.TELEPTY_PORT || 3848);
118
+ const TELEPTY_TRANSPORT_TIMEOUT_MS = 5_000;
119
+ const TELEPTY_SEMANTIC_TIMEOUT_MS = 60_000;
120
+ const TELEPTY_BUS_RECONNECT_MS = 5_000;
121
+ const TELEPTY_SESSION_HEALTH_STALE_MS = 25_000;
113
122
 
114
123
  function loadDeliberationConfig() {
115
124
  const configPath = path.join(INSTALL_DIR, "config.json");
@@ -126,6 +135,57 @@ function saveDeliberationConfig(config) {
126
135
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
127
136
  }
128
137
 
138
+ const StructuredActionableTaskSchema = z.object({
139
+ id: z.number(),
140
+ task: z.string(),
141
+ files: z.array(z.string()).optional(),
142
+ project: z.string().optional(),
143
+ priority: z.enum(["high", "medium", "low"]).optional(),
144
+ });
145
+
146
+ const StructuredSynthesisSchema = z.object({
147
+ summary: z.string(),
148
+ decisions: z.array(z.string()),
149
+ actionable_tasks: z.array(StructuredActionableTaskSchema),
150
+ });
151
+
152
+ const TeleptyEnvelopeSchema = z.object({
153
+ message_id: z.string().min(1),
154
+ session_id: z.string().min(1),
155
+ project: z.string().min(1),
156
+ kind: z.string().min(1),
157
+ source: z.string().min(1),
158
+ target: z.string().min(1),
159
+ reply_to: z.string().nullable().optional(),
160
+ trace: z.array(z.string()),
161
+ payload: z.unknown(),
162
+ ts: z.string().min(1),
163
+ });
164
+
165
+ const TeleptyTurnRequestPayloadSchema = z.object({
166
+ turn_id: z.string().min(1),
167
+ round: z.number().int().positive(),
168
+ max_rounds: z.number().int().positive(),
169
+ speaker: z.string().min(1),
170
+ role: z.string().nullable().optional(),
171
+ prompt: z.string().min(1),
172
+ prompt_sha1: z.string().length(40),
173
+ history_entries: z.number().int().nonnegative().optional(),
174
+ transport_timeout_ms: z.number().int().positive(),
175
+ semantic_timeout_ms: z.number().int().positive(),
176
+ });
177
+
178
+ const TeleptyDeliberationCompletedPayloadSchema = z.object({
179
+ topic: z.string(),
180
+ synthesis: z.string(),
181
+ structured_synthesis: StructuredSynthesisSchema.nullable().optional(),
182
+ });
183
+
184
+ const TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS = {
185
+ turn_request: TeleptyTurnRequestPayloadSchema,
186
+ deliberation_completed: TeleptyDeliberationCompletedPayloadSchema,
187
+ };
188
+
129
189
  const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
130
190
  const DEFAULT_LLM_DOMAINS = [
131
191
  "chatgpt.com",
@@ -345,33 +405,441 @@ const LOCKS_SUBDIR = ".locks";
345
405
  const LOCK_RETRY_MS = 25;
346
406
  const LOCK_TIMEOUT_MS = 8000;
347
407
  const LOCK_STALE_MS = 60000;
408
+ const SPEAKER_SELECTION_FILE = "speaker-selection.json";
409
+ const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
348
410
 
349
411
  function getProjectSlug() {
350
412
  return path.basename(process.cwd());
351
413
  }
352
414
 
353
- function getProjectStateDir() {
354
- return path.join(GLOBAL_STATE_DIR, getProjectSlug());
415
+ function normalizeProjectSlug(projectSlug) {
416
+ if (typeof projectSlug === "string" && projectSlug.trim()) {
417
+ return projectSlug.trim();
418
+ }
419
+ return getProjectSlug();
420
+ }
421
+
422
+ function getProjectStateDir(projectSlug = getProjectSlug()) {
423
+ return path.join(GLOBAL_STATE_DIR, normalizeProjectSlug(projectSlug));
424
+ }
425
+
426
+ function getSessionsDir(projectSlug = getProjectSlug()) {
427
+ return path.join(getProjectStateDir(projectSlug), "sessions");
428
+ }
429
+
430
+ function getSessionProject(sessionRef, fallbackProject = getProjectSlug()) {
431
+ if (sessionRef && typeof sessionRef === "object" && typeof sessionRef.project === "string" && sessionRef.project.trim()) {
432
+ return sessionRef.project.trim();
433
+ }
434
+ return normalizeProjectSlug(fallbackProject);
435
+ }
436
+
437
+ function getSessionFile(sessionRef, projectSlug) {
438
+ const sessionId = typeof sessionRef === "object" && sessionRef !== null
439
+ ? sessionRef.id
440
+ : sessionRef;
441
+ return path.join(getSessionsDir(getSessionProject(sessionRef, projectSlug)), `${sessionId}.json`);
442
+ }
443
+
444
+ function listStateProjects() {
445
+ if (!fs.existsSync(GLOBAL_STATE_DIR)) return [];
446
+ try {
447
+ return fs.readdirSync(GLOBAL_STATE_DIR, { withFileTypes: true })
448
+ .filter(entry => entry.isDirectory())
449
+ .map(entry => entry.name);
450
+ } catch {
451
+ return [];
452
+ }
453
+ }
454
+
455
+ function findSessionRecord(sessionRef, { preferProject, activeOnly = false } = {}) {
456
+ if (!sessionRef) return null;
457
+
458
+ if (typeof sessionRef === "object" && sessionRef !== null && sessionRef.id) {
459
+ const project = getSessionProject(sessionRef, preferProject);
460
+ const file = getSessionFile(sessionRef.id, project);
461
+ const state = readJsonFileSafe(file);
462
+ if (!state) return null;
463
+ const normalized = normalizeSessionActors(state);
464
+ if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
465
+ return null;
466
+ }
467
+ return { file, project, state: normalized };
468
+ }
469
+
470
+ const sessionId = String(sessionRef);
471
+ const preferred = normalizeProjectSlug(preferProject);
472
+ const projects = [...new Set([preferred, ...listStateProjects()])];
473
+ for (const project of projects) {
474
+ const file = getSessionFile(sessionId, project);
475
+ const state = readJsonFileSafe(file);
476
+ if (!state) continue;
477
+ const normalized = normalizeSessionActors(state);
478
+ if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
479
+ continue;
480
+ }
481
+ return { file, project: normalized.project || project, state: normalized };
482
+ }
483
+ return null;
484
+ }
485
+
486
+ const teleptyBusState = {
487
+ ws: null,
488
+ status: "idle",
489
+ connectPromise: null,
490
+ reconnectTimer: null,
491
+ lastError: null,
492
+ lastConnectedAt: null,
493
+ lastMessageAt: null,
494
+ healthBySession: new Map(),
495
+ };
496
+
497
+ const pendingTeleptyTurnRequests = new Map();
498
+
499
+ function hashPromptText(value) {
500
+ return createHash("sha1").update(String(value || "")).digest("hex");
501
+ }
502
+
503
+ function createEnvelopeId(prefix = "env") {
504
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
505
+ }
506
+
507
+ function validateTeleptyEnvelope(envelope) {
508
+ const parsed = TeleptyEnvelopeSchema.parse(envelope);
509
+ const payloadSchema = TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS[parsed.kind];
510
+ if (payloadSchema) {
511
+ payloadSchema.parse(parsed.payload);
512
+ }
513
+ return parsed;
514
+ }
515
+
516
+ function buildTeleptyEnvelope({ session_id, project, kind, source, target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
517
+ return validateTeleptyEnvelope({
518
+ message_id,
519
+ session_id,
520
+ project,
521
+ kind,
522
+ source,
523
+ target,
524
+ reply_to,
525
+ trace,
526
+ payload,
527
+ ts,
528
+ });
529
+ }
530
+
531
+ function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, includeHistoryEntries = 0, profile }) {
532
+ const role = (state.speaker_roles || {})[speaker] || null;
533
+ const target = profile?.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
534
+ ? `${profile.telepty_session_id}@${profile.telepty_host}`
535
+ : profile?.telepty_session_id || speaker;
536
+ return buildTeleptyEnvelope({
537
+ session_id: state.id,
538
+ project: state.project || getProjectSlug(),
539
+ kind: "turn_request",
540
+ source: `deliberation:${state.id}`,
541
+ target,
542
+ reply_to: state.id,
543
+ trace: [
544
+ `project:${state.project || getProjectSlug()}`,
545
+ `speaker:${speaker}`,
546
+ `turn:${turnId}`,
547
+ ],
548
+ payload: {
549
+ turn_id: turnId,
550
+ round: state.current_round,
551
+ max_rounds: state.max_rounds,
552
+ speaker,
553
+ role,
554
+ prompt: turnPrompt,
555
+ prompt_sha1: hashPromptText(turnPrompt),
556
+ history_entries: includeHistoryEntries,
557
+ transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
558
+ semantic_timeout_ms: TELEPTY_SEMANTIC_TIMEOUT_MS,
559
+ },
560
+ });
561
+ }
562
+
563
+ function buildTeleptySynthesisEnvelope({ state, synthesis, structured }) {
564
+ return buildTeleptyEnvelope({
565
+ session_id: state.id,
566
+ project: state.project || getProjectSlug(),
567
+ kind: "deliberation_completed",
568
+ source: `deliberation:${state.id}`,
569
+ target: "telepty-bus",
570
+ reply_to: state.id,
571
+ trace: [
572
+ `project:${state.project || getProjectSlug()}`,
573
+ "stage:synthesis",
574
+ ],
575
+ payload: {
576
+ topic: state.topic,
577
+ synthesis,
578
+ structured_synthesis: structured || null,
579
+ },
580
+ });
581
+ }
582
+
583
+ function resolveTeleptyBusUrl(host = TELEPTY_DEFAULT_HOST) {
584
+ const url = new URL(`ws://${host}:${TELEPTY_PORT}/api/bus`);
585
+ const token = loadTeleptyAuthToken();
586
+ if (token) {
587
+ url.searchParams.set("token", token);
588
+ }
589
+ return url.toString();
590
+ }
591
+
592
+ function cleanupPendingTeleptyTurn(messageId) {
593
+ const entry = pendingTeleptyTurnRequests.get(messageId);
594
+ if (!entry) return;
595
+ if (entry.transportTimer) clearTimeout(entry.transportTimer);
596
+ if (entry.semanticTimer) clearTimeout(entry.semanticTimer);
597
+ pendingTeleptyTurnRequests.delete(messageId);
598
+ }
599
+
600
+ function registerPendingTeleptyTurnRequest({ envelope, profile, speaker }) {
601
+ const nowMs = Date.now();
602
+ const entry = {
603
+ message_id: envelope.message_id,
604
+ deliberation_session_id: envelope.session_id,
605
+ project: envelope.project,
606
+ speaker,
607
+ turn_id: envelope.payload.turn_id,
608
+ target_session_id: profile?.telepty_session_id || speaker,
609
+ target_host: profile?.telepty_host || TELEPTY_DEFAULT_HOST,
610
+ prompt_sha1: envelope.payload.prompt_sha1,
611
+ published_at: envelope.ts,
612
+ transport_status: "pending",
613
+ semantic_status: "pending",
614
+ transport_deadline_at: new Date(nowMs + TELEPTY_TRANSPORT_TIMEOUT_MS).toISOString(),
615
+ semantic_deadline_at: new Date(nowMs + TELEPTY_SEMANTIC_TIMEOUT_MS).toISOString(),
616
+ };
617
+ entry.transportPromise = new Promise(resolve => {
618
+ entry.resolveTransport = resolve;
619
+ });
620
+ entry.semanticPromise = new Promise(resolve => {
621
+ entry.resolveSemantic = resolve;
622
+ });
623
+ entry.transportTimer = setTimeout(() => {
624
+ if (entry.transport_status !== "pending") return;
625
+ entry.transport_status = "timeout";
626
+ appendRuntimeLog("WARN", `TELEPTY_TRANSPORT_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
627
+ entry.resolveTransport?.({ ok: false, code: "transport_timeout" });
628
+ }, TELEPTY_TRANSPORT_TIMEOUT_MS);
629
+ entry.semanticTimer = setTimeout(() => {
630
+ if (entry.semantic_status !== "pending") return;
631
+ entry.semantic_status = "timeout";
632
+ appendRuntimeLog("WARN", `TELEPTY_SEMANTIC_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
633
+ entry.resolveSemantic?.({ ok: false, code: "semantic_timeout" });
634
+ setTimeout(() => cleanupPendingTeleptyTurn(entry.message_id), 5_000);
635
+ }, TELEPTY_SEMANTIC_TIMEOUT_MS);
636
+ pendingTeleptyTurnRequests.set(entry.message_id, entry);
637
+ return entry;
638
+ }
639
+
640
+ function ackPendingTeleptyTurn(event) {
641
+ const promptHash = hashPromptText(event?.content || "");
642
+ const targetSessionId = String(event?.target_agent || "");
643
+ const candidate = [...pendingTeleptyTurnRequests.values()]
644
+ .filter(entry =>
645
+ entry.transport_status === "pending"
646
+ && entry.target_session_id === targetSessionId
647
+ && entry.prompt_sha1 === promptHash
648
+ )
649
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
650
+ if (!candidate) return null;
651
+
652
+ candidate.transport_status = "ack";
653
+ candidate.inject_id = event.inject_id || null;
654
+ candidate.transport_acked_at = new Date().toISOString();
655
+ if (candidate.transportTimer) clearTimeout(candidate.transportTimer);
656
+ candidate.resolveTransport?.({
657
+ ok: true,
658
+ code: "inject_written",
659
+ inject_id: event.inject_id || null,
660
+ });
661
+ appendRuntimeLog("INFO", `TELEPTY_TRANSPORT_ACK: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id} | inject_id: ${event.inject_id || "n/a"}`);
662
+ return candidate;
355
663
  }
356
664
 
357
- function getSessionsDir() {
358
- return path.join(getProjectStateDir(), "sessions");
665
+ function completePendingTeleptySemantic({ sessionId, speaker, turnId }) {
666
+ const candidate = [...pendingTeleptyTurnRequests.values()]
667
+ .filter(entry =>
668
+ entry.semantic_status === "pending"
669
+ && entry.deliberation_session_id === sessionId
670
+ && normalizeSpeaker(entry.speaker) === normalizeSpeaker(speaker)
671
+ && (!turnId || !entry.turn_id || entry.turn_id === turnId)
672
+ )
673
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
674
+ if (!candidate) return null;
675
+
676
+ candidate.semantic_status = "completed";
677
+ candidate.semantic_completed_at = new Date().toISOString();
678
+ if (candidate.semanticTimer) clearTimeout(candidate.semanticTimer);
679
+ candidate.resolveSemantic?.({ ok: true, code: "responded" });
680
+ appendRuntimeLog("INFO", `TELEPTY_SEMANTIC_COMPLETE: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id}`);
681
+ setTimeout(() => cleanupPendingTeleptyTurn(candidate.message_id), 5_000);
682
+ return candidate;
359
683
  }
360
684
 
361
- function getSessionFile(sessionId) {
362
- return path.join(getSessionsDir(), `${sessionId}.json`);
685
+ function updateTeleptySessionHealth(event) {
686
+ const sessionId = event?.session_id;
687
+ if (!sessionId) return null;
688
+ const health = {
689
+ session_id: sessionId,
690
+ payload: event.payload || {},
691
+ timestamp: event.timestamp || new Date().toISOString(),
692
+ seen_at: new Date().toISOString(),
693
+ };
694
+ teleptyBusState.healthBySession.set(sessionId, health);
695
+ return health;
696
+ }
697
+
698
+ function getTeleptySessionHealth(sessionId, nowMs = Date.now()) {
699
+ const entry = teleptyBusState.healthBySession.get(sessionId);
700
+ if (!entry) return null;
701
+ const seenAtMs = Date.parse(entry.seen_at || entry.timestamp || "");
702
+ const ageMs = Number.isFinite(seenAtMs) ? nowMs - seenAtMs : null;
703
+ return {
704
+ ...entry,
705
+ age_ms: ageMs,
706
+ stale: Number.isFinite(ageMs) ? ageMs > TELEPTY_SESSION_HEALTH_STALE_MS : true,
707
+ };
708
+ }
709
+
710
+ function handleTeleptyBusMessage(raw) {
711
+ let parsed = null;
712
+ try {
713
+ parsed = JSON.parse(String(raw));
714
+ } catch {
715
+ return null;
716
+ }
717
+ teleptyBusState.lastMessageAt = new Date().toISOString();
718
+ if (!parsed || typeof parsed !== "object") return null;
719
+
720
+ if (parsed.type === "inject_written") {
721
+ return ackPendingTeleptyTurn(parsed);
722
+ }
723
+ if (parsed.type === "session_health") {
724
+ return updateTeleptySessionHealth(parsed);
725
+ }
726
+ return parsed;
727
+ }
728
+
729
+ async function ensureTeleptyBusSubscriber() {
730
+ if (teleptyBusState.ws && teleptyBusState.ws.readyState === WebSocket.OPEN) {
731
+ return { ok: true, status: "open" };
732
+ }
733
+ if (teleptyBusState.connectPromise) {
734
+ return teleptyBusState.connectPromise;
735
+ }
736
+
737
+ teleptyBusState.connectPromise = new Promise((resolve) => {
738
+ try {
739
+ let settled = false;
740
+ const finish = (result) => {
741
+ if (settled) return;
742
+ settled = true;
743
+ resolve(result);
744
+ };
745
+ teleptyBusState.status = "connecting";
746
+ const ws = new WebSocket(resolveTeleptyBusUrl());
747
+ teleptyBusState.ws = ws;
748
+
749
+ ws.once("open", () => {
750
+ teleptyBusState.status = "open";
751
+ teleptyBusState.lastConnectedAt = new Date().toISOString();
752
+ teleptyBusState.lastError = null;
753
+ appendRuntimeLog("INFO", "TELEPTY_BUS_CONNECTED");
754
+ finish({ ok: true, status: "open" });
755
+ });
756
+
757
+ ws.on("message", (data) => {
758
+ handleTeleptyBusMessage(data.toString());
759
+ });
760
+
761
+ ws.on("error", (err) => {
762
+ teleptyBusState.lastError = String(err?.message || err);
763
+ appendRuntimeLog("WARN", `TELEPTY_BUS_ERROR: ${teleptyBusState.lastError}`);
764
+ if (ws.readyState !== WebSocket.OPEN) {
765
+ teleptyBusState.status = "error";
766
+ teleptyBusState.ws = null;
767
+ teleptyBusState.connectPromise = null;
768
+ finish({ ok: false, status: "error", error: teleptyBusState.lastError });
769
+ }
770
+ });
771
+
772
+ ws.on("close", () => {
773
+ teleptyBusState.status = "closed";
774
+ teleptyBusState.ws = null;
775
+ teleptyBusState.connectPromise = null;
776
+ if (!settled) {
777
+ finish({ ok: false, status: "closed", error: teleptyBusState.lastError || "socket closed" });
778
+ }
779
+ if (!teleptyBusState.reconnectTimer) {
780
+ teleptyBusState.reconnectTimer = setTimeout(() => {
781
+ teleptyBusState.reconnectTimer = null;
782
+ ensureTeleptyBusSubscriber().catch(() => {});
783
+ }, TELEPTY_BUS_RECONNECT_MS);
784
+ }
785
+ });
786
+ } catch (err) {
787
+ teleptyBusState.status = "error";
788
+ teleptyBusState.lastError = String(err?.message || err);
789
+ teleptyBusState.connectPromise = null;
790
+ resolve({ ok: false, status: "error", error: teleptyBusState.lastError });
791
+ }
792
+ });
793
+
794
+ const result = await teleptyBusState.connectPromise;
795
+ if (!result.ok) {
796
+ teleptyBusState.connectPromise = null;
797
+ } else if (teleptyBusState.ws?.readyState === WebSocket.OPEN) {
798
+ teleptyBusState.connectPromise = null;
799
+ }
800
+ return result;
801
+ }
802
+
803
+ async function notifyTeleptyBus(event) {
804
+ const host = process.env.TELEPTY_HOST || "localhost";
805
+ const port = process.env.TELEPTY_PORT || "3848";
806
+ const token = loadTeleptyAuthToken();
807
+ try {
808
+ const res = await fetch(`http://${host}:${port}/api/bus/publish`, {
809
+ method: "POST",
810
+ headers: {
811
+ "Content-Type": "application/json",
812
+ ...(token ? { "x-telepty-token": token } : {}),
813
+ },
814
+ body: JSON.stringify(event),
815
+ });
816
+ const data = await res.json().catch(() => null);
817
+ if (res.ok) {
818
+ appendRuntimeLog("INFO", `HANDOFF: Telepty bus notified: ${event.kind || event.type || "unknown"}`);
819
+ return { ok: true, delivered: data?.delivered ?? null };
820
+ }
821
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
822
+ } catch (err) {
823
+ appendRuntimeLog("WARN", `HANDOFF: Telepty bus notification failed: ${err.message}`);
824
+ return { ok: false, error: err.message };
825
+ }
363
826
  }
364
827
 
365
- function getArchiveDir() {
366
- const obsidianDir = path.join(OBSIDIAN_PROJECTS, getProjectSlug(), "deliberations");
367
- if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, getProjectSlug()))) {
828
+ function getArchiveDir(projectSlug = getProjectSlug()) {
829
+ const slug = normalizeProjectSlug(projectSlug);
830
+ const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
831
+ if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, slug))) {
368
832
  return obsidianDir;
369
833
  }
370
- return path.join(getProjectStateDir(), "archive");
834
+ return path.join(getProjectStateDir(slug), "archive");
371
835
  }
372
836
 
373
- function getLocksDir() {
374
- return path.join(getProjectStateDir(), LOCKS_SUBDIR);
837
+ function getLocksDir(projectSlug = getProjectSlug()) {
838
+ return path.join(getProjectStateDir(projectSlug), LOCKS_SUBDIR);
839
+ }
840
+
841
+ function getSpeakerSelectionFile(projectSlug = getProjectSlug()) {
842
+ return path.join(getProjectStateDir(projectSlug), SPEAKER_SELECTION_FILE);
375
843
  }
376
844
 
377
845
  function formatRuntimeError(error) {
@@ -429,6 +897,19 @@ function writeTextAtomic(filePath, text) {
429
897
  fs.renameSync(tmp, filePath);
430
898
  }
431
899
 
900
+ function readJsonFileSafe(filePath) {
901
+ try {
902
+ if (!fs.existsSync(filePath)) return null;
903
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
904
+ } catch {
905
+ return null;
906
+ }
907
+ }
908
+
909
+ function writeJsonFileAtomic(filePath, value) {
910
+ writeTextAtomic(filePath, JSON.stringify(value, null, 2));
911
+ }
912
+
432
913
  function acquireFileLock(lockPath, {
433
914
  timeoutMs = LOCK_TIMEOUT_MS,
434
915
  retryMs = LOCK_RETRY_MS,
@@ -488,13 +969,20 @@ function withFileLock(lockPath, fn, options) {
488
969
  }
489
970
  }
490
971
 
491
- function withProjectLock(fn, options) {
492
- return withFileLock(path.join(getLocksDir(), "_project.lock"), fn, options);
972
+ function withProjectLock(projectSlug, fn, options) {
973
+ if (typeof projectSlug === "function") {
974
+ return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
975
+ }
976
+ return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
493
977
  }
494
978
 
495
- function withSessionLock(sessionId, fn, options) {
979
+ function withSessionLock(sessionRef, fn, options) {
980
+ const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
981
+ const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
982
+ const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
983
+ const projectSlug = explicitProject || record?.project || getProjectSlug();
496
984
  const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
497
- return withFileLock(path.join(getLocksDir(), `${safeId}.lock`), fn, options);
985
+ return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
498
986
  }
499
987
 
500
988
  function normalizeSpeaker(raw) {
@@ -516,6 +1004,151 @@ function dedupeSpeakers(items = []) {
516
1004
  return out;
517
1005
  }
518
1006
 
1007
+ function createSelectionToken() {
1008
+ return `sel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1009
+ }
1010
+
1011
+ function issueSpeakerSelectionToken({ candidates, include_browser }) {
1012
+ const selectionState = {
1013
+ token: createSelectionToken(),
1014
+ phase: "candidates",
1015
+ created_at: new Date().toISOString(),
1016
+ include_browser: !!include_browser,
1017
+ candidate_speakers: dedupeSpeakers((candidates || []).map(c => typeof c === "string" ? c : c?.speaker)),
1018
+ };
1019
+ writeJsonFileAtomic(getSpeakerSelectionFile(), selectionState);
1020
+ return selectionState;
1021
+ }
1022
+
1023
+ function loadSpeakerSelectionToken() {
1024
+ return readJsonFileSafe(getSpeakerSelectionFile());
1025
+ }
1026
+
1027
+ function clearSpeakerSelectionToken() {
1028
+ try {
1029
+ fs.unlinkSync(getSpeakerSelectionFile());
1030
+ } catch {
1031
+ // ignore missing file
1032
+ }
1033
+ }
1034
+
1035
+ function validateSpeakerSelectionSnapshot({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1036
+ if (!selection_token) {
1037
+ return { ok: false, code: "missing_token" };
1038
+ }
1039
+ if (!selectionState?.token) {
1040
+ return { ok: false, code: "missing_selection_state" };
1041
+ }
1042
+ if (selectionState.token !== selection_token) {
1043
+ return { ok: false, code: "token_mismatch" };
1044
+ }
1045
+
1046
+ const createdAtMs = Date.parse(selectionState.created_at || "");
1047
+ if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
1048
+ return { ok: false, code: "expired_token" };
1049
+ }
1050
+
1051
+ if (!!selectionState.include_browser !== !!includeBrowserSpeakers) {
1052
+ return { ok: false, code: "mode_mismatch" };
1053
+ }
1054
+
1055
+ const availableSpeakers = new Set(dedupeSpeakers(selectionState.candidate_speakers || []));
1056
+ const requestedSpeakers = dedupeSpeakers(speakers || []);
1057
+ const missingSpeakers = requestedSpeakers.filter(speaker => !availableSpeakers.has(speaker));
1058
+ if (missingSpeakers.length > 0) {
1059
+ return { ok: false, code: "speaker_mismatch", missing_speakers: missingSpeakers };
1060
+ }
1061
+
1062
+ return { ok: true };
1063
+ }
1064
+
1065
+ function confirmSpeakerSelectionToken({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now(), persist = true }) {
1066
+ const snapshotValidation = validateSpeakerSelectionSnapshot({
1067
+ selectionState,
1068
+ selection_token,
1069
+ speakers,
1070
+ includeBrowserSpeakers,
1071
+ nowMs,
1072
+ });
1073
+ if (!snapshotValidation.ok) {
1074
+ return snapshotValidation;
1075
+ }
1076
+
1077
+ const confirmedSelection = {
1078
+ token: createSelectionToken(),
1079
+ phase: "confirmed",
1080
+ created_at: new Date(nowMs).toISOString(),
1081
+ include_browser: !!includeBrowserSpeakers,
1082
+ candidate_speakers: dedupeSpeakers(selectionState.candidate_speakers || []),
1083
+ selected_speakers: dedupeSpeakers(speakers || []),
1084
+ };
1085
+ if (persist) {
1086
+ writeJsonFileAtomic(getSpeakerSelectionFile(), confirmedSelection);
1087
+ }
1088
+ return { ok: true, selectionState: confirmedSelection };
1089
+ }
1090
+
1091
+ function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
1092
+ const snapshotValidation = validateSpeakerSelectionSnapshot({
1093
+ selectionState,
1094
+ selection_token,
1095
+ speakers,
1096
+ includeBrowserSpeakers,
1097
+ nowMs,
1098
+ });
1099
+ if (!snapshotValidation.ok) {
1100
+ return snapshotValidation;
1101
+ }
1102
+
1103
+ if (selectionState.phase !== "confirmed" || !Array.isArray(selectionState.selected_speakers)) {
1104
+ return { ok: false, code: "selection_not_confirmed" };
1105
+ }
1106
+
1107
+ const expectedSpeakers = dedupeSpeakers(selectionState.selected_speakers || []);
1108
+ const requestedSpeakers = dedupeSpeakers(speakers || []);
1109
+ if (
1110
+ expectedSpeakers.length !== requestedSpeakers.length
1111
+ || expectedSpeakers.some(speaker => !requestedSpeakers.includes(speaker))
1112
+ ) {
1113
+ return {
1114
+ ok: false,
1115
+ code: "selected_speakers_mismatch",
1116
+ expected_speakers: expectedSpeakers,
1117
+ requested_speakers: requestedSpeakers,
1118
+ };
1119
+ }
1120
+
1121
+ return { ok: true };
1122
+ }
1123
+
1124
+ function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
1125
+ const manualSpeakers = Array.isArray(speakers) ? speakers : [];
1126
+ const hasBrowserSpeaker = manualSpeakers.some(speaker => {
1127
+ const normalized = normalizeSpeaker(speaker);
1128
+ return normalized?.startsWith("web-");
1129
+ });
1130
+ if (hasBrowserSpeaker) return true;
1131
+
1132
+ const overrides = participant_types && typeof participant_types === "object"
1133
+ ? Object.entries(participant_types)
1134
+ : [];
1135
+
1136
+ return overrides.some(([speaker, type]) => {
1137
+ const normalized = normalizeSpeaker(speaker);
1138
+ return normalized?.startsWith("web-") || type === "browser" || type === "browser_auto";
1139
+ });
1140
+ }
1141
+
1142
+ function resolveIncludeBrowserSpeakers({ include_browser_speakers, config, speakers, participant_types } = {}) {
1143
+ if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
1144
+ return include_browser_speakers;
1145
+ }
1146
+ if (config?.include_browser_speakers !== undefined && config?.include_browser_speakers !== null) {
1147
+ return config.include_browser_speakers;
1148
+ }
1149
+ return false;
1150
+ }
1151
+
519
1152
  function resolveCliCandidates() {
520
1153
  const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
521
1154
  .split(/[,\s]+/)
@@ -531,6 +1164,125 @@ function resolveCliCandidates() {
531
1164
  return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
532
1165
  }
533
1166
 
1167
+ function loadTeleptyAuthToken() {
1168
+ try {
1169
+ const raw = fs.readFileSync(TELEPTY_CONFIG_FILE, "utf-8");
1170
+ const parsed = JSON.parse(raw);
1171
+ return typeof parsed?.authToken === "string" && parsed.authToken.trim()
1172
+ ? parsed.authToken.trim()
1173
+ : null;
1174
+ } catch {
1175
+ return null;
1176
+ }
1177
+ }
1178
+
1179
+ function formatTeleptyHostLabel(host) {
1180
+ return !host || host === "127.0.0.1" || host === "localhost" ? "Local" : host;
1181
+ }
1182
+
1183
+ async function collectTeleptySessions() {
1184
+ const token = loadTeleptyAuthToken();
1185
+ if (!token) {
1186
+ return { sessions: [], note: "telepty auth token not found." };
1187
+ }
1188
+
1189
+ const host = TELEPTY_DEFAULT_HOST;
1190
+ try {
1191
+ const res = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions`, {
1192
+ headers: { "x-telepty-token": token },
1193
+ signal: AbortSignal.timeout(1500),
1194
+ });
1195
+ if (!res.ok) {
1196
+ return { sessions: [], note: `telepty daemon unavailable (${res.status}).` };
1197
+ }
1198
+ const sessions = await res.json();
1199
+ if (!Array.isArray(sessions)) {
1200
+ return { sessions: [], note: "telepty session response format was invalid." };
1201
+ }
1202
+ ensureTeleptyBusSubscriber().catch(() => {});
1203
+ return {
1204
+ sessions: sessions.map(session => ({ host, ...session })),
1205
+ note: null,
1206
+ };
1207
+ } catch {
1208
+ return { sessions: [], note: null };
1209
+ }
1210
+ }
1211
+
1212
+ function scoreTeleptyProcessMatch(session, baseCommand = "", fullCommand = "") {
1213
+ const base = String(baseCommand || "").toLowerCase();
1214
+ const full = String(fullCommand || "").toLowerCase();
1215
+ const wanted = String(session?.command || "").trim().toLowerCase();
1216
+ let score = 0;
1217
+
1218
+ if (wanted && (base === wanted || full.startsWith(`${wanted} `) || full.includes(` ${wanted} `))) {
1219
+ score += 10;
1220
+ }
1221
+ if (base === "node" || base === "telepty") {
1222
+ score -= 2;
1223
+ }
1224
+ if (full.includes("mcp-deliberation") || full.includes("oh-my-claudecode") || full.includes("bridge/mcp-server")) {
1225
+ score -= 3;
1226
+ }
1227
+ return score;
1228
+ }
1229
+
1230
+ function collectTeleptyProcessLocators(sessions = []) {
1231
+ const wantedSessions = new Map(
1232
+ sessions
1233
+ .filter(session => session?.id)
1234
+ .map(session => [String(session.id), session])
1235
+ );
1236
+ if (wantedSessions.size === 0) {
1237
+ return new Map();
1238
+ }
1239
+
1240
+ try {
1241
+ const env = {
1242
+ HOME: process.env.HOME,
1243
+ PATH: process.env.PATH,
1244
+ SHELL: process.env.SHELL,
1245
+ USER: process.env.USER,
1246
+ LOGNAME: process.env.LOGNAME,
1247
+ TERM: process.env.TERM,
1248
+ };
1249
+ const raw = execFileSync("ps", ["eww", "-axo", "pid=,tty=,comm=,command="], {
1250
+ encoding: "utf-8",
1251
+ windowsHide: true,
1252
+ timeout: 2500,
1253
+ maxBuffer: 8 * 1024 * 1024,
1254
+ env,
1255
+ });
1256
+
1257
+ const best = new Map();
1258
+ for (const line of String(raw).split("\n")) {
1259
+ if (!line.includes("TELEPTY_SESSION_ID=")) continue;
1260
+ const match = line.match(/^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/);
1261
+ if (!match) continue;
1262
+ const [, pid, tty, comm, command] = match;
1263
+ const sessionIdMatch = command.match(/(?:^|\s)TELEPTY_SESSION_ID=([^\s]+)/);
1264
+ const sessionId = sessionIdMatch?.[1];
1265
+ if (!sessionId || !wantedSessions.has(sessionId)) continue;
1266
+
1267
+ const session = wantedSessions.get(sessionId);
1268
+ const score = scoreTeleptyProcessMatch(session, comm, command);
1269
+ const current = best.get(sessionId);
1270
+ if (!current || score > current.score) {
1271
+ best.set(sessionId, { pid: Number(pid), tty, score });
1272
+ }
1273
+ }
1274
+
1275
+ return new Map(
1276
+ [...best.entries()].map(([sessionId, value]) => [
1277
+ sessionId,
1278
+ { pid: Number.isFinite(value.pid) ? value.pid : null, tty: value.tty || null },
1279
+ ])
1280
+ );
1281
+ } catch {
1282
+ return new Map();
1283
+ }
1284
+ }
1285
+
534
1286
  function commandExistsInPath(command) {
535
1287
  if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
536
1288
  return false;
@@ -1143,6 +1895,7 @@ function inferLlmProvider(url = "", title = "") {
1143
1895
  async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
1144
1896
  const candidates = [];
1145
1897
  const seen = new Set();
1898
+ let browserNote = null;
1146
1899
 
1147
1900
  const add = (candidate) => {
1148
1901
  const speaker = normalizeSpeaker(candidate?.speaker);
@@ -1162,9 +1915,29 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1162
1915
  live,
1163
1916
  });
1164
1917
  }
1918
+
1919
+ const { sessions: teleptySessions, note: teleptyNote } = await collectTeleptySessions();
1920
+ const locators = collectTeleptyProcessLocators(teleptySessions);
1921
+ for (const session of teleptySessions) {
1922
+ const locator = locators.get(session.id) || {};
1923
+ add({
1924
+ speaker: session.id,
1925
+ type: "telepty",
1926
+ label: session.id,
1927
+ telepty_session_id: session.id,
1928
+ telepty_host: session.host || TELEPTY_DEFAULT_HOST,
1929
+ command: session.command || "wrapped",
1930
+ cwd: session.cwd || null,
1931
+ active_clients: session.active_clients ?? null,
1932
+ runtime_pid: locator.pid ?? null,
1933
+ runtime_tty: locator.tty ?? null,
1934
+ });
1935
+ }
1936
+ if (teleptyNote) {
1937
+ browserNote = browserNote ? `${browserNote} | ${teleptyNote}` : teleptyNote;
1938
+ }
1165
1939
  }
1166
1940
 
1167
- let browserNote = null;
1168
1941
  if (include_browser) {
1169
1942
  // Ensure CDP is available before probing browser tabs
1170
1943
  const cdpStatus = await ensureCdpAvailable();
@@ -1308,6 +2081,7 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1308
2081
 
1309
2082
  function formatSpeakerCandidatesReport({ candidates, browserNote }) {
1310
2083
  const cli = candidates.filter(c => c.type === "cli");
2084
+ const telepty = candidates.filter(c => c.type === "telepty");
1311
2085
  const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
1312
2086
  const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
1313
2087
 
@@ -1322,6 +2096,22 @@ function formatSpeakerCandidatesReport({ candidates, browserNote }) {
1322
2096
  }).join("\n")}\n\n`;
1323
2097
  }
1324
2098
 
2099
+ out += "### Telepty Sessions\n";
2100
+ if (telepty.length === 0) {
2101
+ out += "- (No active telepty sessions)\n\n";
2102
+ } else {
2103
+ out += `${telepty.map(c => {
2104
+ const parts = [
2105
+ `command: ${c.command || "wrapped"}`,
2106
+ c.telepty_host ? `host: ${formatTeleptyHostLabel(c.telepty_host)}` : null,
2107
+ Number.isFinite(c.runtime_pid) ? `pid: ${c.runtime_pid}` : null,
2108
+ c.runtime_tty ? `tty: ${c.runtime_tty}` : null,
2109
+ ].filter(Boolean).join(", ");
2110
+ const cwdLine = c.cwd ? `\n cwd: ${c.cwd}` : "";
2111
+ return `- \`${c.speaker}\` (${parts})${cwdLine}`;
2112
+ }).join("\n")}\n\n`;
2113
+ }
2114
+
1325
2115
  out += "### Browser LLM (detected)\n";
1326
2116
  if (detected.length === 0) {
1327
2117
  out += "- (No LLM tabs detected in browser)\n";
@@ -1403,6 +2193,18 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1403
2193
  continue;
1404
2194
  }
1405
2195
 
2196
+ if (candidate.type === "telepty") {
2197
+ profiles.push({
2198
+ speaker,
2199
+ type: "telepty",
2200
+ command: candidate.command || null,
2201
+ telepty_session_id: candidate.telepty_session_id || speaker,
2202
+ telepty_host: candidate.telepty_host || null,
2203
+ runtime_pid: Number.isFinite(candidate.runtime_pid) ? candidate.runtime_pid : null,
2204
+ });
2205
+ continue;
2206
+ }
2207
+
1406
2208
  const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
1407
2209
  profiles.push({
1408
2210
  speaker,
@@ -1420,6 +2222,7 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1420
2222
 
1421
2223
  const TRANSPORT_TYPES = {
1422
2224
  cli: "cli_respond",
2225
+ telepty: "telepty_bus",
1423
2226
  browser: "clipboard",
1424
2227
  browser_auto: "browser_auto",
1425
2228
  manual: "manual",
@@ -1461,6 +2264,9 @@ const CLI_INVOCATION_HINTS = {
1461
2264
 
1462
2265
  function formatTransportGuidance(transport, state, speaker) {
1463
2266
  const sid = state.id;
2267
+ const profile = (state.participant_profiles || []).find(
2268
+ p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
2269
+ ) || null;
1464
2270
  switch (transport) {
1465
2271
  case "cli_respond": {
1466
2272
  const hint = CLI_INVOCATION_HINTS[speaker] || null;
@@ -1485,8 +2291,22 @@ function formatTransportGuidance(transport, state, speaker) {
1485
2291
  `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
1486
2292
  case "browser_auto":
1487
2293
  return `Auto browser speaker. Proceed automatically with \`deliberation_browser_auto_turn(session_id: "${sid}")\`. Inputs directly to browser LLM via CDP and reads responses.\n\n⛔ **No API calls**: Proceeds only via CDP automation. No REST API or HTTP requests.`;
2294
+ case "telepty_bus":
2295
+ return `Telepty session speaker. This turn will be published on the telepty bus as a structured \`turn_request\` envelope for the target session to consume.\n\n` +
2296
+ `📡 **Bus delivery**: deliberation publishes a typed envelope instead of relying on raw PTY inject.\n` +
2297
+ `⏱️ **Timeouts**: transport ack waits ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s, semantic self-submit waits ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s.\n` +
2298
+ `⛔ **No proxy response**: the remote telepty session must answer for itself via \`deliberation_respond(...)\`.`;
1488
2299
  case "manual":
1489
2300
  default:
2301
+ if (profile?.type === "telepty" && profile.telepty_session_id) {
2302
+ const hostSuffix = profile.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
2303
+ ? `@${profile.telepty_host}`
2304
+ : "";
2305
+ const pidNote = Number.isFinite(profile.runtime_pid) ? ` (pid ${profile.runtime_pid})` : "";
2306
+ return `Telepty-managed session speaker${pidNote}. Send the [turn_prompt] below to \`telepty inject ${profile.telepty_session_id}${hostSuffix} "<prompt>"\`, then have that remote session self-submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
2307
+ `📋 **Recommended path**: inject the prompt into the telepty session and let the remote session answer for itself.\n` +
2308
+ `⛔ **No proxy response**: Do not answer on behalf of this speaker from the orchestrator.`;
2309
+ }
1490
2310
  return `Manual speaker. Get a response from the LLM's **web UI or CLI tool** and submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
1491
2311
  `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
1492
2312
  `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
@@ -1627,38 +2447,45 @@ function readContextFromDirs(dirs, maxChars = 15000) {
1627
2447
 
1628
2448
  // ── State helpers ──────────────────────────────────────────────
1629
2449
 
1630
- function ensureDirs() {
1631
- fs.mkdirSync(getSessionsDir(), { recursive: true });
1632
- fs.mkdirSync(getArchiveDir(), { recursive: true });
1633
- fs.mkdirSync(getLocksDir(), { recursive: true });
2450
+ function ensureDirs(projectSlug = getProjectSlug()) {
2451
+ fs.mkdirSync(getSessionsDir(projectSlug), { recursive: true });
2452
+ fs.mkdirSync(getArchiveDir(projectSlug), { recursive: true });
2453
+ fs.mkdirSync(getLocksDir(projectSlug), { recursive: true });
1634
2454
  }
1635
2455
 
1636
- function loadSession(sessionId) {
1637
- const file = getSessionFile(sessionId);
1638
- if (!fs.existsSync(file)) return null;
1639
- return normalizeSessionActors(JSON.parse(fs.readFileSync(file, "utf-8")));
2456
+ function loadSession(sessionRef) {
2457
+ const record = findSessionRecord(sessionRef);
2458
+ return record?.state || null;
1640
2459
  }
1641
2460
 
1642
2461
  function saveSession(state) {
1643
- ensureDirs();
2462
+ ensureDirs(state.project);
1644
2463
  state.updated = new Date().toISOString();
1645
- writeTextAtomic(getSessionFile(state.id), JSON.stringify(state, null, 2));
2464
+ writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
1646
2465
  syncMarkdown(state);
1647
2466
  }
1648
2467
 
1649
- function listActiveSessions() {
1650
- const dir = getSessionsDir();
1651
- if (!fs.existsSync(dir)) return [];
2468
+ function listActiveSessions(projectSlug) {
2469
+ const projects = projectSlug
2470
+ ? [normalizeProjectSlug(projectSlug)]
2471
+ : [...new Set([getProjectSlug(), ...listStateProjects()])];
1652
2472
 
1653
- return fs.readdirSync(dir)
1654
- .filter(f => f.endsWith(".json"))
1655
- .map(f => {
1656
- try {
1657
- const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
1658
- return data;
1659
- } catch { return null; }
1660
- })
1661
- .filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
2473
+ return projects.flatMap(project => {
2474
+ const dir = getSessionsDir(project);
2475
+ if (!fs.existsSync(dir)) return [];
2476
+
2477
+ return fs.readdirSync(dir)
2478
+ .filter(f => f.endsWith(".json"))
2479
+ .map(f => {
2480
+ try {
2481
+ const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
2482
+ return normalizeSessionActors(data);
2483
+ } catch {
2484
+ return null;
2485
+ }
2486
+ })
2487
+ .filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
2488
+ });
1662
2489
  }
1663
2490
 
1664
2491
  function resolveSessionId(sessionId) {
@@ -1676,8 +2503,7 @@ function resolveSessionId(sessionId) {
1676
2503
 
1677
2504
  function syncMarkdown(state) {
1678
2505
  const filename = `deliberation-${state.id}.md`;
1679
- // Write to state dir instead of CWD to avoid polluting project root
1680
- const mdPath = path.join(getProjectStateDir(), filename);
2506
+ const mdPath = path.join(getProjectStateDir(state.project), filename);
1681
2507
  try {
1682
2508
  writeTextAtomic(mdPath, stateToMarkdown(state));
1683
2509
  } catch { /* ignore sync failures */ }
@@ -1685,8 +2511,7 @@ function syncMarkdown(state) {
1685
2511
 
1686
2512
  function cleanupSyncMarkdown(state) {
1687
2513
  const filename = `deliberation-${state.id}.md`;
1688
- // Remove from state dir
1689
- const statePath = path.join(getProjectStateDir(), filename);
2514
+ const statePath = path.join(getProjectStateDir(state.project), filename);
1690
2515
  try { fs.unlinkSync(statePath); } catch { /* ignore */ }
1691
2516
  // Also clean up legacy files in CWD (from older versions)
1692
2517
  const cwdPath = path.join(process.cwd(), filename);
@@ -1745,14 +2570,14 @@ tags: [deliberation]
1745
2570
  }
1746
2571
 
1747
2572
  function archiveState(state) {
1748
- ensureDirs();
2573
+ ensureDirs(state.project);
1749
2574
  const slug = state.topic
1750
2575
  .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
1751
2576
  .replace(/\s+/g, "-")
1752
2577
  .slice(0, 30);
1753
2578
  const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
1754
2579
  const filename = `deliberation-${ts}-${slug}.md`;
1755
- const dest = path.join(getArchiveDir(), filename);
2580
+ const dest = path.join(getArchiveDir(state.project), filename);
1756
2581
  writeTextAtomic(dest, stateToMarkdown(state));
1757
2582
  return dest;
1758
2583
  }
@@ -2261,24 +3086,152 @@ function closeAllMonitorTerminals() {
2261
3086
 
2262
3087
  function multipleSessionsError() {
2263
3088
  const active = listActiveSessions();
2264
- const list = active.map(s => `- **${s.id}**: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
3089
+ const list = active.map(s => `- **${s.id}** [${s.project || "unknown"}]: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
2265
3090
  return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
2266
3091
  }
2267
3092
 
2268
- function formatRecentLogForPrompt(state, maxEntries = 4) {
3093
+ function truncatePromptText(text, maxChars) {
3094
+ const value = String(text || "").trim();
3095
+ if (!value || !Number.isFinite(maxChars) || maxChars <= 0 || value.length <= maxChars) {
3096
+ return value;
3097
+ }
3098
+ const remaining = value.length - maxChars;
3099
+ return `${value.slice(0, maxChars).trimEnd()}\n...(truncated ${remaining} chars)`;
3100
+ }
3101
+
3102
+ function getPromptBudgetForSpeaker(speaker, includeHistoryEntries = 4) {
3103
+ const defaultBudget = {
3104
+ maxEntries: Math.max(0, includeHistoryEntries),
3105
+ maxCharsPerEntry: 1600,
3106
+ maxTotalChars: 6400,
3107
+ maxTopicChars: 3200,
3108
+ };
3109
+ switch (speaker) {
3110
+ case "codex":
3111
+ return {
3112
+ maxEntries: Math.min(Math.max(0, includeHistoryEntries), 3),
3113
+ maxCharsPerEntry: 1200,
3114
+ maxTotalChars: 3600,
3115
+ maxTopicChars: 2200,
3116
+ };
3117
+ case "gemini":
3118
+ return {
3119
+ maxEntries: Math.min(Math.max(0, includeHistoryEntries), 4),
3120
+ maxCharsPerEntry: 1400,
3121
+ maxTotalChars: 5600,
3122
+ maxTopicChars: 2800,
3123
+ };
3124
+ default:
3125
+ return defaultBudget;
3126
+ }
3127
+ }
3128
+
3129
+ function formatRecentLogForPrompt(state, maxEntries = 4, options = {}) {
2269
3130
  const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
2270
3131
  if (entries.length === 0) {
2271
3132
  return "(No previous responses yet)";
2272
3133
  }
2273
- return entries.map(e => {
2274
- const content = String(e.content || "").trim();
2275
- return `- ${e.speaker} (Round ${e.round})\n${content}`;
2276
- }).join("\n\n");
3134
+ const maxCharsPerEntry = options.maxCharsPerEntry || 1600;
3135
+ const maxTotalChars = options.maxTotalChars || maxCharsPerEntry * entries.length;
3136
+ const rendered = [];
3137
+ let usedChars = 0;
3138
+
3139
+ for (const entry of entries) {
3140
+ const header = `- ${entry.speaker} (Round ${entry.round})`;
3141
+ const remainingChars = Math.max(0, maxTotalChars - usedChars - header.length - 1);
3142
+ const entryBudget = Math.max(200, Math.min(maxCharsPerEntry, remainingChars || maxCharsPerEntry));
3143
+ const content = truncatePromptText(entry.content, entryBudget);
3144
+ const block = `${header}\n${content}`;
3145
+ rendered.push(block);
3146
+ usedChars += block.length + 2;
3147
+ if (usedChars >= maxTotalChars) {
3148
+ break;
3149
+ }
3150
+ }
3151
+
3152
+ return rendered.join("\n\n");
3153
+ }
3154
+
3155
+ function getCliAutoTurnTimeoutSec({ speaker, requestedTimeoutSec, promptLength, priorTurns }) {
3156
+ const requested = Number.isFinite(requestedTimeoutSec) ? requestedTimeoutSec : 120;
3157
+ if (speaker === "codex") {
3158
+ let recommended = Math.max(requested, priorTurns === 0 ? 240 : 180);
3159
+ if (promptLength > 6000) {
3160
+ recommended = Math.max(recommended, 300);
3161
+ }
3162
+ if (promptLength > 10000 || priorTurns >= 1) {
3163
+ recommended = Math.max(recommended, 420);
3164
+ }
3165
+ return recommended;
3166
+ }
3167
+ return priorTurns === 0 ? Math.max(requested, 180) : requested;
3168
+ }
3169
+
3170
+ function getCliExecArgs(speaker) {
3171
+ switch (speaker) {
3172
+ case "claude":
3173
+ return ["-p", "--output-format", "text"];
3174
+ case "codex":
3175
+ return [
3176
+ "exec",
3177
+ "--ephemeral",
3178
+ "-c", 'approval_policy="never"',
3179
+ "-c", 'sandbox_mode="read-only"',
3180
+ "-c", 'model_reasoning_effort="low"',
3181
+ "-",
3182
+ ];
3183
+ case "gemini":
3184
+ return null;
3185
+ default:
3186
+ return null;
3187
+ }
3188
+ }
3189
+
3190
+ function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeout, promptLength, priorTurns }) {
3191
+ const isTimeout = /CLI timeout \(/.test(String(err?.message || ""));
3192
+ if (!isTimeout) {
3193
+ return `❌ CLI auto-turn failed: ${err.message}\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n\nYou can submit a manual response via deliberation_respond(speaker: "${speaker}", content: "...").`;
3194
+ }
3195
+
3196
+ const retryTimeout = speaker === "codex"
3197
+ ? Math.min(Math.max(effectiveTimeout, 420), 600)
3198
+ : Math.min(effectiveTimeout + 60, 300);
3199
+
3200
+ return t(
3201
+ `⏱️ CLI auto-turn timed out.\n\n` +
3202
+ `**Speaker:** ${speaker}\n` +
3203
+ `**CLI:** ${hint.cmd}\n` +
3204
+ `**Timeout:** ${effectiveTimeout}s\n` +
3205
+ `**Prompt size:** ${promptLength} chars\n` +
3206
+ `**Prior turns by speaker:** ${priorTurns}\n` +
3207
+ `**Session state:** still waiting on ${speaker} for Round ${state.current_round}\n\n` +
3208
+ `This usually means the CLI stayed busy longer than the timeout. It does **not** necessarily mean the model is down.\n` +
3209
+ `${speaker === "codex" ? `Codex is the slowest CLI in recent deliberation logs, especially when recent_log contains long prior responses.\n` : ""}` +
3210
+ `Recommended next step: retry with \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\`.\n` +
3211
+ `Manual fallback: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3212
+ `⏱️ CLI 자동 턴이 타임아웃되었습니다.\n\n` +
3213
+ `**Speaker:** ${speaker}\n` +
3214
+ `**CLI:** ${hint.cmd}\n` +
3215
+ `**Timeout:** ${effectiveTimeout}s\n` +
3216
+ `**Prompt 크기:** ${promptLength} chars\n` +
3217
+ `**이 speaker의 이전 발언 수:** ${priorTurns}\n` +
3218
+ `**세션 상태:** Round ${state.current_round}에서 아직 ${speaker} 응답을 기다리는 중\n\n` +
3219
+ `이건 보통 CLI가 제한 시간 안에 응답을 끝내지 못했다는 뜻입니다. 모델이 완전히 죽었다는 의미는 아닙니다.\n` +
3220
+ `${speaker === "codex" ? `최근 딜리버레이션 로그 기준으로 Codex는 이전 응답 전문이 길게 들어가면 가장 느린 편입니다.\n` : ""}` +
3221
+ `권장 조치: \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\` 로 재시도하세요.\n` +
3222
+ `수동 대안: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
3223
+ state?.lang
3224
+ );
2277
3225
  }
2278
3226
 
2279
3227
  function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
2280
- const recent = formatRecentLogForPrompt(state, includeHistoryEntries);
3228
+ const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
3229
+ const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
2281
3230
  const extraPrompt = prompt ? `\n[Additional instructions]\n${prompt}\n` : "";
3231
+ const topic = truncatePromptText(state.topic, promptBudget.maxTopicChars);
3232
+ const noToolRule = speaker === "codex"
3233
+ ? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
3234
+ : "";
2282
3235
 
2283
3236
  // Role prompt injection
2284
3237
  const speakerRole = (state.speaker_roles || {})[speaker] || "free";
@@ -2290,7 +3243,7 @@ function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries
2290
3243
  return `[deliberation_turn_request]
2291
3244
  session_id: ${state.id}
2292
3245
  project: ${state.project}
2293
- topic: ${state.topic}
3246
+ topic: ${topic}
2294
3247
  round: ${state.current_round}/${state.max_rounds}
2295
3248
  target_speaker: ${speaker}
2296
3249
  required_turn: ${state.current_speaker}${roleSection}
@@ -2302,6 +3255,7 @@ ${recent}
2302
3255
  [response_rule]
2303
3256
  - Write only ${speaker}'s response for this turn reflecting the discussion context above
2304
3257
  - Output markdown body only (no unnecessary headers/footers)${speakerRole !== "free" ? `\n- Analyze and respond from the perspective of assigned role (${speakerRole})` : ""}
3258
+ - Keep the response concise and decision-oriented${noToolRule}
2305
3259
  - Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
2306
3260
  [/response_rule]
2307
3261
  [/deliberation_turn_request]
@@ -2375,6 +3329,11 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
2375
3329
  role_drift: roleDrift || undefined,
2376
3330
  attachments: attachments || undefined,
2377
3331
  });
3332
+ completePendingTeleptySemantic({
3333
+ sessionId: state.id,
3334
+ speaker: normalizedSpeaker,
3335
+ turnId: state.pending_turn_id || turn_id || null,
3336
+ });
2378
3337
  appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | attachments: ${attachments ? attachments.length : 0}`);
2379
3338
 
2380
3339
  state.current_speaker = selectNextSpeaker(state);
@@ -2452,6 +3411,7 @@ server.tool(
2452
3411
  session_id: z.string().trim().min(1).max(64).optional().describe("Explicit session ID to use. If omitted, one is generated from topic."),
2453
3412
  rounds: z.coerce.number().optional().describe("Number of rounds (defaults to config setting, default 3)"),
2454
3413
  first_speaker: z.string().trim().min(1).max(64).optional().describe("First speaker name (defaults to first item in speakers)"),
3414
+ selection_token: z.string().trim().min(1).max(128).optional().describe("Single-use token returned by deliberation_speaker_candidates. Required for fresh manual speaker selection."),
2455
3415
  speakers: z.preprocess(
2456
3416
  (v) => {
2457
3417
  const parsed = typeof v === "string" ? JSON.parse(v) : v;
@@ -2468,14 +3428,18 @@ server.tool(
2468
3428
  require_manual_speakers: z.preprocess(
2469
3429
  (v) => (typeof v === "string" ? v === "true" : v),
2470
3430
  z.boolean().optional()
2471
- ).describe("If true, speakers must be explicitly specified to start (defaults to config setting)"),
3431
+ ).describe("Deprecated toggle. Speakers are now always selected manually before start."),
2472
3432
  auto_discover_speakers: z.preprocess(
2473
3433
  (v) => (typeof v === "string" ? v === "true" : v),
2474
3434
  z.boolean().optional()
2475
- ).describe("Whether to auto-discover speakers when omitted (defaults to config setting)"),
3435
+ ).describe("Deprecated toggle. Auto-discovery no longer auto-joins participants; use deliberation_speaker_candidates instead."),
3436
+ include_browser_speakers: z.preprocess(
3437
+ (v) => (typeof v === "string" ? v === "true" : v),
3438
+ z.boolean().optional()
3439
+ ).describe("Whether browser speakers are allowed to participate. Defaults to false unless explicitly enabled."),
2476
3440
  participant_types: z.preprocess(
2477
3441
  (v) => (typeof v === "string" ? JSON.parse(v) : v),
2478
- z.record(z.string(), z.enum(["cli", "browser", "browser_auto", "manual"])).optional()
3442
+ z.record(z.string(), z.enum(["cli", "telepty", "browser", "browser_auto", "manual"])).optional()
2479
3443
  ).describe("Per-speaker type override (e.g., {\"chatgpt\": \"browser_auto\"})"),
2480
3444
  ordering_strategy: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
2481
3445
  .describe("Ordering strategy: auto (automatic based on speaker count), cyclic (sequential), random (random each turn), weighted-random (less spoken speakers first)"),
@@ -2485,8 +3449,12 @@ server.tool(
2485
3449
  ).describe("Per-speaker role assignment (e.g., {\"claude\": \"critic\", \"codex\": \"implementer\"})"),
2486
3450
  role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
2487
3451
  .describe("Role preset (balanced/debate/research/brainstorm/review/consensus). Ignored if speaker_roles is specified"),
3452
+ auto_execute: z.preprocess(
3453
+ (v) => (typeof v === "string" ? v === "true" : v),
3454
+ z.boolean().optional()
3455
+ ).describe("If true, automatically create a handoff task in the inbox when synthesis completes. Enables the Autonomous Deliberation Handoff pattern."),
2488
3456
  },
2489
- safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
3457
+ safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, selection_token, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_speakers, participant_types, ordering_strategy, speaker_roles, role_preset, auto_execute }) => {
2490
3458
  // ── First-time onboarding guard ──
2491
3459
  const config = loadDeliberationConfig();
2492
3460
  if (!config.setup_complete) {
@@ -2495,7 +3463,7 @@ server.tool(
2495
3463
  return {
2496
3464
  content: [{
2497
3465
  type: "text",
2498
- text: `🎉 **Welcome to Deliberation!**\n\nPlease configure basic settings before starting.\n\n**Currently detected speakers:**\n${candidateText}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nYou can set all options at once:\n\n\`\`\`\ndeliberation_cli_config(\n require_speaker_selection: true/false,\n default_rounds: 3,\n default_ordering: "auto"\n)\n\`\`\`\n\n**1. Speaker participation mode** (\`require_speaker_selection\`)\n - \`true\` — Select participating speakers each time\n - \`false\` — All detected CLI + browser LLMs auto-join\n\n**2. Default rounds** (\`default_rounds\`)\n - \`1\` — Quick consensus\n - \`3\` — Default (recommended)\n - \`5\` — Deep discussion\n\n**3. Ordering strategy** (\`default_ordering\`)\n - \`"auto"\` — cyclic for 2 speakers, weighted-random for 3+ (recommended)\n - \`"cyclic"\` — Fixed order\n - \`"random"\` — Random each turn\n - \`"weighted-random"\` — Less spoken speakers first`,
3466
+ text: `🎉 **Welcome to Deliberation!**\n\nPlease configure basic settings before starting.\n\n**Currently detected speakers:**\n${candidateText}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nYou can set the remaining defaults with:\n\n\`\`\`\ndeliberation_cli_config(\n include_browser_speakers: false,\n default_rounds: 3,\n default_ordering: "auto"\n)\n\`\`\`\n\n**1. Speaker participation mode**\n - Always manual — participants are selected fresh at every start from the current candidate snapshot\n\n**2. Browser speakers** (\`include_browser_speakers\`)\n - \`false\` — CLI + telepty sessions only (recommended)\n - \`true\` — Include browser LLM speakers too\n\n**3. Default rounds** (\`default_rounds\`)\n - \`1\` — Quick consensus\n - \`3\` — Default (recommended)\n - \`5\` — Deep discussion\n\n**4. Ordering strategy** (\`default_ordering\`)\n - \`"auto"\` — cyclic for 2 speakers, weighted-random for 3+ (recommended)\n - \`"cyclic"\` — Fixed order\n - \`"random"\` — Random each turn\n - \`"weighted-random"\` — Less spoken speakers first`,
2499
3467
  }],
2500
3468
  };
2501
3469
  }
@@ -2507,36 +3475,75 @@ server.tool(
2507
3475
  return { content: [{ type: "text", text: `❌ Session "${session_id}" is already active. Please use a different ID or reset it first.` }] };
2508
3476
  }
2509
3477
  }
2510
- const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
3478
+ const explicitBrowserSelection = hasExplicitBrowserParticipantSelection({ speakers, participant_types });
3479
+ const includeBrowserSpeakers = resolveIncludeBrowserSpeakers({
3480
+ include_browser_speakers,
3481
+ config,
3482
+ speakers,
3483
+ participant_types,
3484
+ });
3485
+ if (explicitBrowserSelection && !includeBrowserSpeakers) {
3486
+ return {
3487
+ content: [{
3488
+ type: "text",
3489
+ text: `❌ Browser speakers are currently disabled.\n\nThis deliberation server now defaults to CLI-only participation to avoid browser timeouts blocking the session.\n\nTo include browser speakers, opt in explicitly:\n\`\`\`\ndeliberation_start(\n topic: "${topic.replace(/"/g, '\\"')}",\n speakers: ${JSON.stringify(speakers || ["claude", "codex"])},\n include_browser_speakers: true,\n require_manual_speakers: true\n)\n\`\`\`\n\nOr save it in config:\n\`deliberation_cli_config(include_browser_speakers: true)\``,
3490
+ }],
3491
+ };
3492
+ }
3493
+
3494
+ const candidateSnapshot = await collectSpeakerCandidates({
3495
+ include_cli: true,
3496
+ include_browser: includeBrowserSpeakers,
3497
+ });
2511
3498
 
2512
3499
  // Resolve effective settings from config
2513
- const effectiveRequireManual = require_manual_speakers ?? config.require_speaker_selection ?? true;
2514
- const effectiveAutoDiscover = auto_discover_speakers ?? !effectiveRequireManual;
3500
+ const effectiveRequireManual = true;
3501
+ const effectiveAutoDiscover = false;
2515
3502
  rounds = rounds ?? config.default_rounds ?? 3;
2516
3503
  const rawOrdering = ordering_strategy ?? config.default_ordering ?? "auto";
2517
3504
  // Resolve "auto": 2 speakers → cyclic, 3+ → weighted-random
2518
3505
  ordering_strategy = rawOrdering === "auto" ? undefined : rawOrdering; // resolved after speakers are known
2519
3506
 
2520
- // When require_speaker_selection is explicitly true in config,
2521
- // ignore LLM-provided speakers UNLESS require_manual_speakers: true is explicitly passed
2522
- // (which signals the user has confirmed the speaker selection)
2523
- const configRequiresSelection = config.require_speaker_selection === true;
2524
- const llmExplicitlyConfirmed = require_manual_speakers === true;
2525
- const hasManualSpeakers = Array.isArray(speakers) && speakers.length > 0
2526
- && (!configRequiresSelection || llmExplicitlyConfirmed);
3507
+ const manualSpeakersProvided = Array.isArray(speakers) && speakers.length > 0;
3508
+ let selectionValidation = { ok: true };
3509
+ if (effectiveRequireManual && manualSpeakersProvided) {
3510
+ selectionValidation = validateSpeakerSelectionRequest({
3511
+ selectionState: loadSpeakerSelectionToken(),
3512
+ selection_token,
3513
+ speakers,
3514
+ includeBrowserSpeakers,
3515
+ });
3516
+ }
3517
+ const hasManualSpeakers = manualSpeakersProvided && (!effectiveRequireManual || selectionValidation.ok);
3518
+
3519
+ if (manualSpeakersProvided && effectiveRequireManual && !selectionValidation.ok) {
3520
+ const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
3521
+ const mismatchNote = selectionValidation.code === "speaker_mismatch"
3522
+ ? `\n\nRequested speakers not in the latest candidate snapshot: ${(selectionValidation.missing_speakers || []).join(", ")}`
3523
+ : selectionValidation.code === "selected_speakers_mismatch"
3524
+ ? `\n\nThis token is bound to a different speaker set.\nExpected: ${(selectionValidation.expected_speakers || []).join(", ")}\nRequested: ${(selectionValidation.requested_speakers || []).join(", ")}`
3525
+ : "";
3526
+ const confirmationNote = selectionValidation.code === "selection_not_confirmed"
3527
+ ? "\n\nThe token you passed is only a candidate snapshot token. You must confirm the exact user-picked speakers before start."
3528
+ : "";
3529
+ return {
3530
+ content: [{
3531
+ type: "text",
3532
+ text: `Fresh participant selection is required before each deliberation start.${confirmationNote}${mismatchNote}\n\n1. Call \`deliberation_speaker_candidates(include_cli: true, include_browser: ${includeBrowserSpeakers ? "true" : "false"})\`\n2. Show the speaker list in the TUI and let the user choose participants\n3. Call \`deliberation_confirm_speakers(selection_token: "<candidate-token>", speakers: [...])\`\n4. Pass the returned confirmed \`selection_token\` into \`deliberation_start(..., selection_token: "...", speakers: [...])\`\n\n${candidateText}`,
3533
+ }],
3534
+ };
3535
+ }
2527
3536
 
2528
3537
  if (!hasManualSpeakers && effectiveRequireManual) {
2529
3538
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
2530
3539
  const llmSuggested = Array.isArray(speakers) && speakers.length > 0
2531
- ? `\n\n💡 **LLM suggested speakers:** ${speakers.join(", ")}\nTo use this suggestion, pass speakers again with \`require_manual_speakers: true\`.`
2532
- : "";
2533
- const configNote = configRequiresSelection
2534
- ? "\n\n⚙️ `require_speaker_selection: true` setting requires you to manually select speakers."
3540
+ ? `\n\n💡 **LLM suggested speakers:** ${speakers.join(", ")}\nShow the candidate list in the TUI, let the user confirm, then call \`deliberation_confirm_speakers\` with the final speaker list.`
2535
3541
  : "";
3542
+ const configNote = "\n\n⚙️ Manual speaker selection is enabled and requires a fresh confirmed `selection_token`.";
2536
3543
  return {
2537
3544
  content: [{
2538
3545
  type: "text",
2539
- text: `Speakers must be manually selected to start a deliberation.${configNote}${llmSuggested}\n\n${candidateText}\n\nExample:\n\ndeliberation_start(\n topic: "${topic.replace(/"/g, '\\"')}",\n rounds: ${rounds},\n speakers: ["codex", "web-claude-1", "web-chatgpt-1"],\n require_manual_speakers: true,\n first_speaker: "codex"\n)\n\nFirst call deliberation_speaker_candidates to check currently available speakers.`,
3546
+ text: `Speakers must be manually selected to start a deliberation.${configNote}${llmSuggested}\n\n${candidateText}\n\nExample:\n\n1. \`deliberation_speaker_candidates(...)\`\n2. User picks speakers in the TUI\n3. \`deliberation_confirm_speakers(selection_token: "<candidate-token>", speakers: ["claude", "codex", "gemini"])\`\n4. \`deliberation_start(\n topic: "${topic.replace(/"/g, '\\"')}",\n selection_token: "<confirmed-token>",\n rounds: ${rounds},\n speakers: ["claude", "codex", "gemini"],\n require_manual_speakers: true,\n first_speaker: "codex"\n)\`\n\nFirst call deliberation_speaker_candidates to check currently available speakers.`,
2540
3547
  }],
2541
3548
  };
2542
3549
  }
@@ -2544,7 +3551,6 @@ server.tool(
2544
3551
  let autoDiscoveredSpeakers = [];
2545
3552
  let autoParticipantTypes = {};
2546
3553
  if (!hasManualSpeakers && effectiveAutoDiscover) {
2547
- // Include ALL candidates: CLI + browser
2548
3554
  for (const c of candidateSnapshot.candidates) {
2549
3555
  autoDiscoveredSpeakers.push(c.speaker);
2550
3556
  if (c.type === "browser" && c.cdp_available) {
@@ -2573,14 +3579,17 @@ server.tool(
2573
3579
  || DEFAULT_SPEAKERS[0];
2574
3580
  const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
2575
3581
 
3582
+ if (effectiveRequireManual) {
3583
+ clearSpeakerSelectionToken();
3584
+ }
3585
+
2576
3586
  // Warn if only 1 speaker — deliberation requires 2+
2577
3587
  if (speakerOrder.length < 2) {
2578
- const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
2579
3588
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
2580
3589
  return {
2581
3590
  content: [{
2582
3591
  type: "text",
2583
- text: `⚠️ Deliberation requires at least 2 speakers. Currently only ${speakerOrder.length} specified: ${speakerOrder.join(", ")}\n\nAvailable speaker candidates:\n${candidateText}\n\nExample:\ndeliberation_start(topic: "${topic.slice(0, 50)}...", speakers: ["claude", "codex", "web-gemini-1"])`,
3592
+ text: `⚠️ Deliberation requires at least 2 speakers. Currently only ${speakerOrder.length} specified: ${speakerOrder.join(", ")}\n\nAvailable speaker candidates:\n${candidateText}\n\nExample:\ndeliberation_start(topic: "${topic.slice(0, 50)}...", speakers: ["claude", "codex", "gemini"])`,
2584
3593
  }],
2585
3594
  };
2586
3595
  }
@@ -2601,7 +3610,7 @@ server.tool(
2601
3610
  }
2602
3611
 
2603
3612
  const participantMode = hasManualSpeakers
2604
- ? "manually specified"
3613
+ ? "user-selected"
2605
3614
  : (autoDiscoveredSpeakers.length > 0 ? "auto-discovered (PATH)" : "default");
2606
3615
 
2607
3616
  const degradationLevels = await detectDegradationLevels();
@@ -2624,6 +3633,7 @@ server.tool(
2624
3633
  ordering_strategy: ordering_strategy || (speakerOrder.length <= 2 ? "cyclic" : "weighted-random"),
2625
3634
  speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
2626
3635
  degradation: degradationLevels,
3636
+ auto_execute: auto_execute || false,
2627
3637
  created: new Date().toISOString(),
2628
3638
  updated: new Date().toISOString(),
2629
3639
  };
@@ -2691,6 +3701,15 @@ server.tool(
2691
3701
  }).join("\n");
2692
3702
 
2693
3703
  appendRuntimeLog("INFO", `SESSION_CREATED: ${sessionId} | topic: ${topic.slice(0, 60)} | speakers: ${speakerOrder.join(",")} | rounds: ${rounds}`);
3704
+
3705
+ // Auto-handoff: kick off background orchestration
3706
+ if (auto_execute) {
3707
+ // Fire-and-forget — runs in background
3708
+ runAutoHandoff(sessionId).catch(err => {
3709
+ appendRuntimeLog("ERROR", `AUTO_HANDOFF_SPAWN_ERROR: ${sessionId} | ${err.message}`);
3710
+ });
3711
+ }
3712
+
2694
3713
  return {
2695
3714
  content: [{
2696
3715
  type: "text",
@@ -2702,15 +3721,66 @@ server.tool(
2702
3721
 
2703
3722
  server.tool(
2704
3723
  "deliberation_speaker_candidates",
2705
- "Query available speaker candidates (local CLI + browser LLM tabs).",
3724
+ "Query available speaker candidates (local CLI + telepty active sessions + browser LLM tabs).",
2706
3725
  {
2707
3726
  include_cli: z.boolean().default(true).describe("Include local CLI candidates"),
2708
3727
  include_browser: z.boolean().default(true).describe("Include browser LLM tab candidates"),
2709
3728
  },
2710
3729
  async ({ include_cli, include_browser }) => {
2711
3730
  const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
3731
+ const selection = issueSpeakerSelectionToken({
3732
+ candidates: snapshot.candidates,
3733
+ include_browser,
3734
+ });
2712
3735
  const text = formatSpeakerCandidatesReport(snapshot);
2713
- return { content: [{ type: "text", text: `${text}\n\n${PRODUCT_DISCLAIMER}` }] };
3736
+ return {
3737
+ content: [{
3738
+ type: "text",
3739
+ text: `${text}\n\n**Candidate token:** \`${selection.token}\`\nAfter the user picks participants in the TUI, call \`deliberation_confirm_speakers(selection_token: "${selection.token}", speakers: [...])\` to mint a confirmed start token. Raw candidate tokens cannot start a deliberation.\n\n${PRODUCT_DISCLAIMER}`,
3740
+ }],
3741
+ };
3742
+ }
3743
+ );
3744
+
3745
+ server.tool(
3746
+ "deliberation_confirm_speakers",
3747
+ "Bind a fresh candidate token to the exact CLI/telepty/browser speakers the user chose in the TUI.",
3748
+ {
3749
+ selection_token: z.string().trim().min(1).max(128).describe("Candidate token returned by deliberation_speaker_candidates."),
3750
+ speakers: z.array(z.string().trim().min(1)).min(1).describe("Exact speakers the user selected in the TUI."),
3751
+ },
3752
+ async ({ selection_token, speakers }) => {
3753
+ const selectionState = loadSpeakerSelectionToken();
3754
+ const includeBrowserSpeakers = !!selectionState?.include_browser;
3755
+ const confirmation = confirmSpeakerSelectionToken({
3756
+ selectionState,
3757
+ selection_token,
3758
+ speakers,
3759
+ includeBrowserSpeakers,
3760
+ });
3761
+
3762
+ if (!confirmation.ok) {
3763
+ const candidateText = formatSpeakerCandidatesReport(await collectSpeakerCandidates({
3764
+ include_cli: true,
3765
+ include_browser: includeBrowserSpeakers,
3766
+ }));
3767
+ const mismatchNote = confirmation.code === "speaker_mismatch"
3768
+ ? `\n\nRequested speakers not in the latest candidate snapshot: ${(confirmation.missing_speakers || []).join(", ")}`
3769
+ : "";
3770
+ return {
3771
+ content: [{
3772
+ type: "text",
3773
+ text: `Speaker confirmation failed.${mismatchNote}\n\n1. Call \`deliberation_speaker_candidates\` for a fresh snapshot\n2. Let the user choose speakers in the TUI\n3. Call \`deliberation_confirm_speakers\` with that exact selection\n\n${candidateText}`,
3774
+ }],
3775
+ };
3776
+ }
3777
+
3778
+ return {
3779
+ content: [{
3780
+ type: "text",
3781
+ text: `✅ Speaker selection confirmed.\n\n**Selected speakers:** ${confirmation.selectionState.selected_speakers.join(", ")}\n**Confirmed selection token:** \`${confirmation.selectionState.token}\`\n\nUse this exact token with the same speaker list in \`deliberation_start(..., selection_token: "...", speakers: [...])\`.\nIf the user changes the selection, call \`deliberation_speaker_candidates\` again for a fresh snapshot.`,
3782
+ }],
3783
+ };
2714
3784
  }
2715
3785
  );
2716
3786
 
@@ -2849,6 +3919,55 @@ server.tool(
2849
3919
 
2850
3920
  let extra = "";
2851
3921
  let turnPrompt = "";
3922
+ let manualFallbackPrompt = false;
3923
+
3924
+ if (transport === "telepty_bus") {
3925
+ turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
3926
+ const busReady = await ensureTeleptyBusSubscriber();
3927
+ const envelope = buildTeleptyTurnRequestEnvelope({
3928
+ state,
3929
+ speaker,
3930
+ turnId: turnId || generateTurnId(),
3931
+ turnPrompt,
3932
+ includeHistoryEntries: include_history_entries,
3933
+ profile,
3934
+ });
3935
+ const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
3936
+ const publishResult = await notifyTeleptyBus(envelope);
3937
+ const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
3938
+
3939
+ if (!publishResult.ok) {
3940
+ cleanupPendingTeleptyTurn(envelope.message_id);
3941
+ manualFallbackPrompt = true;
3942
+ extra += `\n\n❌ Telepty bus publish failed: ${publishResult.error || publishResult.status || "unknown error"}\n` +
3943
+ `Fallback: use manual telepty inject for this turn.`;
3944
+ guidance = formatTransportGuidance("manual", state, speaker);
3945
+ } else {
3946
+ const transportResult = await pending.transportPromise;
3947
+ const healthLine = health
3948
+ ? `\n**Session health:** alive=${health.payload?.alive === true ? "yes" : "no"}, pid=${health.payload?.pid || "n/a"}, age=${Math.max(0, Math.round((health.age_ms || 0) / 1000))}s${health.stale ? " (stale)" : ""}`
3949
+ : "";
3950
+ const transportLine = transportResult.ok
3951
+ ? `✅ Transport ack received via \`inject_written\` within ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s.`
3952
+ : `⚠️ Transport ack not observed within ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s. The request was published, but delivery is still best-effort.`;
3953
+ const subscriberLine = busReady.ok
3954
+ ? "- Bus subscriber: connected"
3955
+ : `- Bus subscriber: unavailable (${busReady.error || busReady.status || "unknown"})`;
3956
+ if (!transportResult.ok) {
3957
+ manualFallbackPrompt = true;
3958
+ }
3959
+ extra += `\n\n### Telepty Bus Dispatch\n` +
3960
+ `- Envelope: \`${envelope.message_id}\`\n` +
3961
+ `- Kind: \`${envelope.kind}\`\n` +
3962
+ `- Target: \`${envelope.target}\`\n` +
3963
+ `- Delivered subscribers: ${publishResult.delivered ?? "unknown"}\n` +
3964
+ `${subscriberLine}\n` +
3965
+ `- Transport timeout: ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s\n` +
3966
+ `- Semantic timeout: ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s${healthLine}\n\n` +
3967
+ `${transportLine}\n\n` +
3968
+ `The remote telepty session must still self-submit its response with \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", ...)\` before the semantic timeout.`;
3969
+ }
3970
+ }
2852
3971
 
2853
3972
  if (transport === "browser_auto") {
2854
3973
  // Auto-execute browser_auto_turn
@@ -2925,9 +4044,13 @@ server.tool(
2925
4044
  extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
2926
4045
  }
2927
4046
 
4047
+ if (transport === "telepty_bus" && manualFallbackPrompt) {
4048
+ extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
4049
+ }
4050
+
2928
4051
  const profileInfo = profile
2929
4052
  ? `\n**Profile:** ${profile.type}${profile.url ? ` | ${profile.url}` : ""}${profile.command ? ` | command: ${profile.command}` : ""}`
2930
- : "";
4053
+ : "";
2931
4054
 
2932
4055
  return {
2933
4056
  content: [{
@@ -3059,6 +4182,327 @@ server.tool(
3059
4182
  })
3060
4183
  );
3061
4184
 
4185
+ // ────────────────────────────────────────────────────────────────────────────
4186
+ // Auto-handoff orchestrator helpers
4187
+ // ────────────────────────────────────────────────────────────────────────────
4188
+
4189
+ /**
4190
+ * Run a single CLI auto-turn for the given session and speaker.
4191
+ * Returns { ok: true, response, elapsedMs } or { ok: false, error }.
4192
+ */
4193
+ async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
4194
+ const state = loadSession(sessionId);
4195
+ if (!state || state.status !== "active") {
4196
+ return { ok: false, error: "Session not active" };
4197
+ }
4198
+
4199
+ const { transport } = resolveTransportForSpeaker(state, speaker);
4200
+ if (transport !== "cli_respond") {
4201
+ return { ok: false, error: `Speaker "${speaker}" is not CLI type` };
4202
+ }
4203
+
4204
+ const hint = CLI_INVOCATION_HINTS[speaker];
4205
+ if (!hint) return { ok: false, error: `No CLI hints for "${speaker}"` };
4206
+ if (!checkCliLiveness(hint.cmd)) return { ok: false, error: `CLI "${hint.cmd}" not available` };
4207
+
4208
+ const turnId = state.pending_turn_id || generateTurnId();
4209
+ const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4210
+ const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
4211
+ const effectiveTimeout = getCliAutoTurnTimeoutSec({
4212
+ speaker,
4213
+ requestedTimeoutSec: timeoutSec,
4214
+ promptLength: turnPrompt.length,
4215
+ priorTurns: speakerPriorTurns,
4216
+ });
4217
+
4218
+ const startTime = Date.now();
4219
+ try {
4220
+ const response = await new Promise((resolve, reject) => {
4221
+ const env = { ...process.env };
4222
+ if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4223
+
4224
+ let child;
4225
+ let stdout = "";
4226
+ let stderr = "";
4227
+ let settled = false;
4228
+ let forceKillTimer = null;
4229
+
4230
+ const resolveOnce = (v) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); resolve(v); } };
4231
+ const rejectOnce = (e) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); reject(e); } };
4232
+
4233
+ switch (speaker) {
4234
+ case "claude":
4235
+ child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4236
+ child.stdin.write(turnPrompt);
4237
+ child.stdin.end();
4238
+ break;
4239
+ case "codex":
4240
+ child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4241
+ child.stdin.write(turnPrompt);
4242
+ child.stdin.end();
4243
+ break;
4244
+ case "gemini":
4245
+ child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
4246
+ break;
4247
+ default: {
4248
+ const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4249
+ child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
4250
+ break;
4251
+ }
4252
+ }
4253
+
4254
+ const timer = setTimeout(() => {
4255
+ try { child.kill("SIGTERM"); } catch {}
4256
+ forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5000);
4257
+ if (typeof forceKillTimer?.unref === "function") forceKillTimer.unref();
4258
+ rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
4259
+ }, effectiveTimeout * 1000);
4260
+
4261
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
4262
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
4263
+
4264
+ child.on("close", (code) => {
4265
+ clearTimeout(timer);
4266
+ if (code !== 0 && !stdout.trim()) {
4267
+ rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
4268
+ } else {
4269
+ resolveOnce(stdout.trim());
4270
+ }
4271
+ });
4272
+
4273
+ child.on("error", (err) => rejectOnce(err));
4274
+ });
4275
+
4276
+ // Submit the turn
4277
+ submitDeliberationTurn({
4278
+ session_id: sessionId,
4279
+ speaker,
4280
+ content: response,
4281
+ turn_id: turnId,
4282
+ channel_used: "cli_auto",
4283
+ });
4284
+
4285
+ return { ok: true, response, elapsedMs: Date.now() - startTime };
4286
+ } catch (err) {
4287
+ return { ok: false, error: err.message };
4288
+ }
4289
+ }
4290
+
4291
+ /**
4292
+ * Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
4293
+ */
4294
+ async function generateAutoSynthesis(sessionId) {
4295
+ const state = loadSession(sessionId);
4296
+ if (!state) return null;
4297
+
4298
+ const historyText = state.log.map(e => `[${e.speaker}] ${e.content}`).join("\n\n---\n\n");
4299
+
4300
+ const synthesisPrompt = `You are a deliberation synthesizer. Analyze this discussion and produce ONLY a JSON response (no markdown, no explanation).
4301
+
4302
+ Topic: ${state.topic}
4303
+ Project: ${state.project}
4304
+ Rounds: ${state.max_rounds}
4305
+
4306
+ Discussion:
4307
+ ${historyText}
4308
+
4309
+ Respond with EXACTLY this JSON structure:
4310
+ {
4311
+ "summary": "Brief summary of the outcome",
4312
+ "decisions": ["Decision 1", "Decision 2"],
4313
+ "actionable_tasks": [
4314
+ {"id": 1, "task": "What to do", "files": ["path/to/file.ts"], "project": "${state.project}", "priority": "high|medium|low"}
4315
+ ],
4316
+ "markdown_synthesis": "# Full synthesis in markdown\\n\\n..."
4317
+ }`;
4318
+
4319
+ // Use the first available CLI speaker to generate synthesis
4320
+ const speaker = state.speakers.find(s => {
4321
+ const hint = CLI_INVOCATION_HINTS[s];
4322
+ return hint && checkCliLiveness(hint.cmd);
4323
+ });
4324
+
4325
+ if (!speaker) return null;
4326
+
4327
+ const hint = CLI_INVOCATION_HINTS[speaker];
4328
+
4329
+ try {
4330
+ const response = await new Promise((resolve, reject) => {
4331
+ const env = { ...process.env };
4332
+ if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
4333
+
4334
+ let child;
4335
+ let stdout = "";
4336
+
4337
+ switch (speaker) {
4338
+ case "claude":
4339
+ child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
4340
+ child.stdin.write(synthesisPrompt);
4341
+ child.stdin.end();
4342
+ break;
4343
+ case "codex":
4344
+ child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4345
+ child.stdin.write(synthesisPrompt);
4346
+ child.stdin.end();
4347
+ break;
4348
+ case "gemini":
4349
+ child = spawn("gemini", ["-p", synthesisPrompt], { env, windowsHide: true });
4350
+ break;
4351
+ default: {
4352
+ const flags = hint.flags ? hint.flags.split(/\s+/) : [];
4353
+ child = spawn(hint.cmd, [...flags, synthesisPrompt], { env, windowsHide: true });
4354
+ break;
4355
+ }
4356
+ }
4357
+
4358
+ const timer = setTimeout(() => {
4359
+ try { child.kill("SIGTERM"); } catch {}
4360
+ reject(new Error("Synthesis generation timeout"));
4361
+ }, 180000); // 3 min timeout for synthesis
4362
+
4363
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
4364
+ child.on("close", (code) => {
4365
+ clearTimeout(timer);
4366
+ resolve(stdout.trim());
4367
+ });
4368
+ child.on("error", reject);
4369
+ });
4370
+
4371
+ // Extract JSON from response (may have markdown wrapping)
4372
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
4373
+ if (!jsonMatch) return { markdown_synthesis: response };
4374
+
4375
+ try {
4376
+ return JSON.parse(jsonMatch[0]);
4377
+ } catch {
4378
+ return { markdown_synthesis: response };
4379
+ }
4380
+ } catch (err) {
4381
+ appendRuntimeLog("ERROR", `AUTO_SYNTHESIS_FAILED: ${sessionId} | ${err.message}`);
4382
+ return null;
4383
+ }
4384
+ }
4385
+
4386
+ /**
4387
+ * Orchestrate full auto-handoff: run all turns -> synthesize -> inbox -> telepty.
4388
+ * Called as fire-and-forget from deliberation_start when auto_execute is true.
4389
+ */
4390
+ async function runAutoHandoff(sessionId) {
4391
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_START: ${sessionId}`);
4392
+
4393
+ try {
4394
+ // Phase 1: Run all deliberation turns
4395
+ let maxIterations = 100; // safety limit
4396
+ while (maxIterations-- > 0) {
4397
+ const state = loadSession(sessionId);
4398
+ if (!state) {
4399
+ appendRuntimeLog("ERROR", `AUTO_HANDOFF: Session ${sessionId} disappeared`);
4400
+ return;
4401
+ }
4402
+ if (state.status !== "active") {
4403
+ appendRuntimeLog("INFO", `AUTO_HANDOFF: Session ${sessionId} status=${state.status}, turns done`);
4404
+ break;
4405
+ }
4406
+
4407
+ const speaker = state.current_speaker;
4408
+ if (speaker === "none") break;
4409
+
4410
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
4411
+
4412
+ const result = await runCliAutoTurnCore(sessionId, speaker);
4413
+ if (!result.ok) {
4414
+ appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_FAIL: ${sessionId} | speaker: ${speaker} | ${result.error}`);
4415
+ // Skip this speaker, continue with next
4416
+ const freshState = loadSession(sessionId);
4417
+ if (freshState) {
4418
+ // Advance to next speaker manually
4419
+ const idx = freshState.speakers.indexOf(speaker);
4420
+ const nextIdx = (idx + 1) % freshState.speakers.length;
4421
+ freshState.current_speaker = freshState.speakers[nextIdx];
4422
+ if (nextIdx === 0) freshState.current_round++;
4423
+ if (freshState.current_round > freshState.max_rounds) {
4424
+ freshState.status = "awaiting_synthesis";
4425
+ freshState.current_speaker = "none";
4426
+ }
4427
+ saveSession(freshState);
4428
+ }
4429
+ continue;
4430
+ }
4431
+
4432
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${result.elapsedMs}ms`);
4433
+ }
4434
+
4435
+ // Phase 2: Generate structured synthesis
4436
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZE: ${sessionId}`);
4437
+ let synthResult = await generateAutoSynthesis(sessionId);
4438
+
4439
+ // Phase 3: Call synthesize (reuse existing logic)
4440
+ const state = loadSession(sessionId);
4441
+ if (!state) return;
4442
+
4443
+ // Fallback: if synthesis generation failed, build a basic structure from the discussion
4444
+ if (!synthResult || (!synthResult.summary && !synthResult.actionable_tasks)) {
4445
+ appendRuntimeLog("WARN", `AUTO_HANDOFF_SYNTH_FALLBACK: ${sessionId} | Building fallback from discussion log`);
4446
+ const turns = state.log || [];
4447
+ const fallbackSummary = turns.length > 0
4448
+ ? `Deliberation on "${state.topic}" completed with ${turns.length} turns from ${[...new Set(turns.map(t => t.speaker))].join(", ")}.`
4449
+ : `Deliberation on "${state.topic}" completed.`;
4450
+ synthResult = {
4451
+ summary: fallbackSummary,
4452
+ decisions: [`Discussed: ${state.topic}`],
4453
+ actionable_tasks: [],
4454
+ markdown_synthesis: `# Auto-generated synthesis (fallback)\n\n${fallbackSummary}\n\n## Discussion\n${turns.map(t => `**${t.speaker}**: ${typeof t.content === 'string' ? t.content.substring(0, 200) : '(no content)'}${t.content && t.content.length > 200 ? '...' : ''}`).join("\n\n")}`,
4455
+ };
4456
+ }
4457
+
4458
+ const markdownSynthesis = synthResult?.markdown_synthesis ||
4459
+ `# Auto-generated synthesis\n\n${synthResult?.summary || "Deliberation completed."}\n\n## Decisions\n${(synthResult?.decisions || []).map(d => `- ${d}`).join("\n")}\n\n## Tasks\n${(synthResult?.actionable_tasks || []).map(t => `- [${t.priority}] ${t.task}`).join("\n")}`;
4460
+
4461
+ const structured = {
4462
+ summary: synthResult.summary || "",
4463
+ decisions: synthResult.decisions || [],
4464
+ actionable_tasks: synthResult.actionable_tasks || [],
4465
+ };
4466
+
4467
+ // Apply synthesis to session
4468
+ withSessionLock(sessionId, () => {
4469
+ const loaded = loadSession(sessionId);
4470
+ if (!loaded) return;
4471
+ loaded.synthesis = markdownSynthesis;
4472
+ loaded.structured_synthesis = structured;
4473
+ loaded.status = "completed";
4474
+ loaded.current_speaker = "none";
4475
+ saveSession(loaded);
4476
+ archiveState(loaded);
4477
+ cleanupSyncMarkdown(loaded);
4478
+
4479
+ const sessionFile = getSessionFile(loaded);
4480
+ try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch {}
4481
+ });
4482
+
4483
+ closeMonitorTerminal(sessionId, getSessionWindowIds(state));
4484
+
4485
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZED: ${sessionId}`);
4486
+
4487
+ // Phase 4: Notify telepty bus with full structured data for dustcraw to consume
4488
+ if (state.auto_execute) {
4489
+ const envelope = buildTeleptySynthesisEnvelope({
4490
+ state,
4491
+ synthesis: markdownSynthesis,
4492
+ structured,
4493
+ });
4494
+ await notifyTeleptyBus(envelope).catch(() => {});
4495
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_NOTIFIED: ${sessionId} | telepty event sent`);
4496
+ }
4497
+
4498
+ appendRuntimeLog("INFO", `AUTO_HANDOFF_COMPLETE: ${sessionId}`);
4499
+ } catch (err) {
4500
+ appendRuntimeLog("ERROR", `AUTO_HANDOFF_ERROR: ${sessionId} | ${err.message}`);
4501
+ }
4502
+ }
4503
+
4504
+ // ────────────────────────────────────────────────────────────────────────────
4505
+
3062
4506
  server.tool(
3063
4507
  "deliberation_cli_auto_turn",
3064
4508
  "Automatically send a turn to a CLI speaker and collect the response.",
@@ -3107,10 +4551,7 @@ server.tool(
3107
4551
  }] };
3108
4552
  }
3109
4553
 
3110
- // Dynamic timeout: first turn gets extra time for cold-start
3111
4554
  const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
3112
- const effectiveTimeout = speakerPriorTurns === 0 ? Math.max(timeout_sec, 180) : timeout_sec;
3113
-
3114
4555
  const hint = CLI_INVOCATION_HINTS[speaker];
3115
4556
  if (!hint) {
3116
4557
  return { content: [{ type: "text", text: t(`No CLI invocation info for speaker "${speaker}". This speaker is not registered in CLI_INVOCATION_HINTS.`, `speaker "${speaker}"에 대한 CLI 호출 정보가 없습니다. CLI_INVOCATION_HINTS에 등록되지 않은 speaker입니다.`, state?.lang) }] };
@@ -3123,6 +4564,12 @@ server.tool(
3123
4564
 
3124
4565
  const turnId = state.pending_turn_id || generateTurnId();
3125
4566
  const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
4567
+ const effectiveTimeout = getCliAutoTurnTimeoutSec({
4568
+ speaker,
4569
+ requestedTimeoutSec: timeout_sec,
4570
+ promptLength: turnPrompt.length,
4571
+ priorTurns: speakerPriorTurns,
4572
+ });
3126
4573
 
3127
4574
  // Spawn CLI process
3128
4575
  const startTime = Date.now();
@@ -3137,16 +4584,31 @@ server.tool(
3137
4584
  let child;
3138
4585
  let stdout = "";
3139
4586
  let stderr = "";
4587
+ let settled = false;
4588
+ let forceKillTimer = null;
4589
+
4590
+ const resolveOnce = (value) => {
4591
+ if (settled) return;
4592
+ settled = true;
4593
+ if (forceKillTimer) clearTimeout(forceKillTimer);
4594
+ resolve(value);
4595
+ };
4596
+ const rejectOnce = (error) => {
4597
+ if (settled) return;
4598
+ settled = true;
4599
+ if (forceKillTimer) clearTimeout(forceKillTimer);
4600
+ reject(error);
4601
+ };
3140
4602
 
3141
4603
  // Different invocation patterns per CLI
3142
4604
  switch (speaker) {
3143
4605
  case "claude":
3144
- child = spawn("claude", ["-p", "--output-format", "text"], { env, windowsHide: true });
4606
+ child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
3145
4607
  child.stdin.write(turnPrompt);
3146
4608
  child.stdin.end();
3147
4609
  break;
3148
4610
  case "codex":
3149
- child = spawn("codex", ["exec", "-"], { env, windowsHide: true });
4611
+ child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
3150
4612
  child.stdin.write(turnPrompt);
3151
4613
  child.stdin.end();
3152
4614
  break;
@@ -3162,8 +4624,19 @@ server.tool(
3162
4624
  }
3163
4625
 
3164
4626
  const timer = setTimeout(() => {
3165
- child.kill("SIGTERM");
3166
- reject(new Error(`CLI timeout (${effectiveTimeout}s)`));
4627
+ appendRuntimeLog("WARN", `CLI_TURN_TIMEOUT: ${resolved} | speaker: ${speaker} | cli: ${hint.cmd} | timeout: ${effectiveTimeout}s | prompt_len: ${turnPrompt.length} | prior_turns: ${speakerPriorTurns}`);
4628
+ try {
4629
+ child.kill("SIGTERM");
4630
+ } catch { /* ignore */ }
4631
+ forceKillTimer = setTimeout(() => {
4632
+ try {
4633
+ child.kill("SIGKILL");
4634
+ } catch { /* ignore */ }
4635
+ }, 5000);
4636
+ if (typeof forceKillTimer.unref === "function") {
4637
+ forceKillTimer.unref();
4638
+ }
4639
+ rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
3167
4640
  }, effectiveTimeout * 1000);
3168
4641
 
3169
4642
  child.stdout.on("data", (data) => { stdout += data.toString(); });
@@ -3172,7 +4645,8 @@ server.tool(
3172
4645
  child.on("close", (code) => {
3173
4646
  clearTimeout(timer);
3174
4647
  if (code !== 0 && !stdout.trim()) {
3175
- reject(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
4648
+ appendRuntimeLog("ERROR", `CLI_TURN_EXIT: ${resolved} | speaker: ${speaker} | cli: ${hint.cmd} | code: ${code} | stderr: ${stderr.slice(0, 200).replace(/\s+/g, " ")}`);
4649
+ rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
3176
4650
  } else {
3177
4651
  // Clean up output noise
3178
4652
  let cleaned = stdout;
@@ -3183,12 +4657,12 @@ server.tool(
3183
4657
  const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
3184
4658
  if (codexLineIdx !== -1) {
3185
4659
  cleaned = lines.slice(codexLineIdx + 1)
3186
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
4660
+ .filter(line => !/^(tokens used$|^[0-9,]*$|^mcp:.*)/.test(line))
3187
4661
  .join("\n");
3188
4662
  } else {
3189
4663
  // Fallback regex cleaning
3190
4664
  cleaned = stdout.split("\n")
3191
- .filter(line => !/^(OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|mcp:|thinking$|tokens used$|^[0-9,]*$)/.test(line))
4665
+ .filter(line => !/^(OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|mcp:.*|thinking$|tokens used$|^[0-9,]*$)/.test(line))
3192
4666
  .join("\n");
3193
4667
  }
3194
4668
  } else if (speaker === "gemini") {
@@ -3196,13 +4670,14 @@ server.tool(
3196
4670
  .filter(line => !/^(Loaded cached|Error during discovery|\[MCP error\]| {4}at| {2}errno:| {2}code:| {2}syscall:| {2}path:| {2}spawnargs:|MCP issues detected|Server .* supports tool updates)/.test(line))
3197
4671
  .join("\n");
3198
4672
  }
3199
- resolve(cleaned.trim());
4673
+ resolveOnce(cleaned.trim());
3200
4674
  }
3201
4675
  });
3202
4676
 
3203
4677
  child.on("error", (err) => {
3204
4678
  clearTimeout(timer);
3205
- reject(err);
4679
+ appendRuntimeLog("ERROR", `CLI_TURN_ERROR: ${resolved} | speaker: ${speaker} | cli: ${hint.cmd} | error: ${String(err.message || err).replace(/\s+/g, " ")}`);
4680
+ rejectOnce(err);
3206
4681
  });
3207
4682
  });
3208
4683
 
@@ -3234,7 +4709,15 @@ server.tool(
3234
4709
  return {
3235
4710
  content: [{
3236
4711
  type: "text",
3237
- text: `❌ 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: "...").`,
4712
+ text: buildCliAutoTurnFailureText({
4713
+ state,
4714
+ speaker,
4715
+ hint,
4716
+ err,
4717
+ effectiveTimeout,
4718
+ promptLength: turnPrompt.length,
4719
+ priorTurns: speakerPriorTurns,
4720
+ }),
3238
4721
  }],
3239
4722
  };
3240
4723
  }
@@ -3495,12 +4978,22 @@ server.tool(
3495
4978
 
3496
4979
  server.tool(
3497
4980
  "deliberation_synthesize",
3498
- "End the deliberation and submit a synthesis report.",
4981
+ "End the deliberation and submit a synthesis report. Optionally include structured actionable tasks for automated handoff.",
3499
4982
  {
3500
4983
  session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
3501
4984
  synthesis: z.string().describe("Synthesis report (markdown)"),
4985
+ structured: z.preprocess(
4986
+ (v) => {
4987
+ if (typeof v === "string") {
4988
+ try { return JSON.parse(v); }
4989
+ catch { return v; }
4990
+ }
4991
+ return v;
4992
+ },
4993
+ StructuredSynthesisSchema.optional()
4994
+ ).describe("Structured synthesis data for automated handoff. If omitted, only markdown synthesis is stored."),
3502
4995
  },
3503
- safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis }) => {
4996
+ safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis, structured }) => {
3504
4997
  const resolved = resolveSessionId(session_id);
3505
4998
  if (!resolved) {
3506
4999
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -3518,13 +5011,15 @@ server.tool(
3518
5011
  }
3519
5012
 
3520
5013
  loaded.synthesis = synthesis;
5014
+ loaded.structured_synthesis = structured || null;
3521
5015
  loaded.status = "completed";
3522
5016
  loaded.current_speaker = "none";
3523
5017
  saveSession(loaded);
3524
5018
  archivePath = archiveState(loaded);
3525
5019
  cleanupSyncMarkdown(loaded);
5020
+
3526
5021
  // Clean up the active session JSON file upon completion
3527
- const sessionFile = getSessionFile(loaded.id);
5022
+ const sessionFile = getSessionFile(loaded);
3528
5023
  try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch { /* ignore */ }
3529
5024
  state = loaded;
3530
5025
  return null;
@@ -3534,10 +5029,20 @@ server.tool(
3534
5029
  }
3535
5030
 
3536
5031
  appendRuntimeLog("INFO", `SYNTHESIZED: ${resolved} | turns: ${state.log.length} | rounds: ${state.max_rounds}`);
5032
+ const synthesisEnvelope = buildTeleptySynthesisEnvelope({
5033
+ state,
5034
+ synthesis,
5035
+ structured,
5036
+ });
3537
5037
 
3538
5038
  // Immediately force-close monitor terminal (including physical Terminal) on deliberation end
3539
5039
  closeMonitorTerminal(state.id, getSessionWindowIds(state));
3540
5040
 
5041
+ // Notify telepty bus with full structured data for dustcraw to consume
5042
+ if (state.auto_execute) {
5043
+ notifyTeleptyBus(synthesisEnvelope).catch(() => {}); // fire-and-forget
5044
+ }
5045
+
3541
5046
  return {
3542
5047
  content: [{
3543
5048
  type: "text",
@@ -3585,11 +5090,11 @@ server.tool(
3585
5090
  // Reset specific session only
3586
5091
  let toCloseIds = [];
3587
5092
  const result = withSessionLock(session_id, () => {
3588
- const file = getSessionFile(session_id);
3589
- if (!fs.existsSync(file)) {
5093
+ const state = loadSession(session_id);
5094
+ if (!state) {
3590
5095
  return { content: [{ type: "text", text: t(`Session "${session_id}" not found.`, `세션 "${session_id}"을 찾을 수 없습니다.`, "en") }] };
3591
5096
  }
3592
- const state = loadSession(session_id);
5097
+ const file = getSessionFile(state);
3593
5098
  if (state && state.log.length > 0) {
3594
5099
  archiveState(state);
3595
5100
  }
@@ -3665,7 +5170,11 @@ server.tool(
3665
5170
  require_speaker_selection: z.preprocess(
3666
5171
  (v) => (typeof v === "string" ? v === "true" : v),
3667
5172
  z.boolean().optional()
3668
- ).describe("true: user selects speakers before each start, false: all detected speakers auto-join"),
5173
+ ).describe("Deprecated toggle. Speaker selection is now always manual; any provided value is normalized to true."),
5174
+ include_browser_speakers: z.preprocess(
5175
+ (v) => (typeof v === "string" ? v === "true" : v),
5176
+ z.boolean().optional()
5177
+ ).describe("true: browser LLM speakers may join when requested, false: CLI + telepty candidate mode"),
3669
5178
  default_rounds: z.coerce.number().int().min(1).max(10).optional()
3670
5179
  .describe("Default number of rounds (1-10, default 3)"),
3671
5180
  default_ordering: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
@@ -3673,13 +5182,17 @@ server.tool(
3673
5182
  chrome_profile: z.string().optional()
3674
5183
  .describe("Chrome profile directory name for CDP (e.g., \"Default\", \"Profile 1\"). Stored for auto-launch."),
3675
5184
  },
3676
- safeToolHandler("deliberation_cli_config", async ({ enabled_clis, require_speaker_selection, default_rounds, default_ordering, chrome_profile }) => {
5185
+ safeToolHandler("deliberation_cli_config", async ({ enabled_clis, require_speaker_selection, include_browser_speakers, default_rounds, default_ordering, chrome_profile }) => {
3677
5186
  const config = loadDeliberationConfig();
3678
5187
 
3679
5188
  // Handle setup config updates
3680
5189
  let configChanged = false;
3681
5190
  if (require_speaker_selection !== undefined && require_speaker_selection !== null) {
3682
- config.require_speaker_selection = require_speaker_selection;
5191
+ config.require_speaker_selection = true;
5192
+ configChanged = true;
5193
+ }
5194
+ if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
5195
+ config.include_browser_speakers = include_browser_speakers;
3683
5196
  configChanged = true;
3684
5197
  }
3685
5198
  if (default_rounds !== undefined && default_rounds !== null) {
@@ -3708,7 +5221,7 @@ server.tool(
3708
5221
  return {
3709
5222
  content: [{
3710
5223
  type: "text",
3711
- text: `## Deliberation CLI Settings\n\n**Mode:** ${mode}\n**Speaker selection:** ${config.require_speaker_selection === false ? "auto (all detected speakers join)" : "manual (user selects)"}\n**Default rounds:** ${config.default_rounds || 3}\n**Ordering:** ${config.default_ordering || "auto"}\n**Chrome profile:** ${config.chrome_profile || "Default"} (env: DELIBERATION_CHROME_PROFILE)\n**Configured CLIs:** ${configured.length > 0 ? configured.join(", ") : "(none — full auto-detection)"}\n**Currently detected CLIs:** ${detected.join(", ") || "(none)"}\n**All supported CLIs:** ${DEFAULT_CLI_CANDIDATES.join(", ")}\n\nTo change:\n\`deliberation_cli_config(require_speaker_selection: false, default_rounds: 3, default_ordering: "auto")\`\n\nTo set Chrome profile for CDP:\n\`deliberation_cli_config(chrome_profile: "Profile 1")\`\n\nTo revert to full auto-detection:\n\`deliberation_cli_config(enabled_clis: [])\``,
5224
+ text: `## Deliberation CLI Settings\n\n**Mode:** ${mode}\n**Speaker selection:** manual only (fresh user selection required every start)\n**Browser speakers:** ${config.include_browser_speakers === true ? "enabled" : "disabled (CLI + telepty default)"}\n**Default rounds:** ${config.default_rounds || 3}\n**Ordering:** ${config.default_ordering || "auto"}\n**Chrome profile:** ${config.chrome_profile || "Default"} (env: DELIBERATION_CHROME_PROFILE)\n**Configured CLIs:** ${configured.length > 0 ? configured.join(", ") : "(none — full auto-detection)"}\n**Currently detected CLIs:** ${detected.join(", ") || "(none)"}\n**All supported CLIs:** ${DEFAULT_CLI_CANDIDATES.join(", ")}\n\nℹ️ Every start now requires two steps: \`deliberation_speaker_candidates\` for a fresh snapshot, then \`deliberation_confirm_speakers\` for the exact user-picked set. Telepty active sessions are included in the candidate list automatically.\n\nTo change defaults:\n\`deliberation_cli_config(include_browser_speakers: false, default_rounds: 3, default_ordering: "auto")\`\n\nTo enable browser speakers:\n\`deliberation_cli_config(include_browser_speakers: true)\`\n\nTo set Chrome profile for CDP:\n\`deliberation_cli_config(chrome_profile: "Profile 1")\`\n\nTo revert CLI filters to full auto-detection:\n\`deliberation_cli_config(enabled_clis: [])\``,
3712
5225
  }],
3713
5226
  };
3714
5227
  }
@@ -4140,11 +5653,12 @@ server.tool(
4140
5653
  const env = { ...process.env, NO_COLOR: "1" };
4141
5654
 
4142
5655
  if (speaker === "claude") {
4143
- proc = spawn("claude", ["-p", "--output-format", "text", "--no-input"], { env, windowsHide: true });
5656
+ const args = getCliExecArgs("claude");
5657
+ proc = spawn("claude", args.includes("--no-input") ? args : [...args, "--no-input"], { env, windowsHide: true });
4144
5658
  proc.stdin.write(opinionPrompt);
4145
5659
  proc.stdin.end();
4146
5660
  } else if (speaker === "codex") {
4147
- proc = spawn("codex", ["exec", "-"], { env, windowsHide: true });
5661
+ proc = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4148
5662
  proc.stdin.write(opinionPrompt);
4149
5663
  proc.stdin.end();
4150
5664
  } else if (speaker === "gemini") {
@@ -4170,7 +5684,7 @@ server.tool(
4170
5684
  const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
4171
5685
  if (codexLineIdx !== -1) {
4172
5686
  cleaned = lines.slice(codexLineIdx + 1)
4173
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
5687
+ .filter(line => !/^(tokens used$|^[0-9,]*$|^mcp:.*)/.test(line))
4174
5688
  .join("\n").trim();
4175
5689
  }
4176
5690
  } else if (speaker === "gemini") {
@@ -4513,4 +6027,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
4513
6027
  }
4514
6028
 
4515
6029
  // ── Test exports (used by vitest) ──
4516
- 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 };
6030
+ export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };