@dmsdc-ai/aigentry-deliberation 0.0.34 → 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;
355
484
  }
356
485
 
357
- function getSessionsDir() {
358
- return path.join(getProjectStateDir(), "sessions");
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");
359
501
  }
360
502
 
361
- function getSessionFile(sessionId) {
362
- return path.join(getSessionsDir(), `${sessionId}.json`);
503
+ function createEnvelopeId(prefix = "env") {
504
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
363
505
  }
364
506
 
365
- function getArchiveDir() {
366
- const obsidianDir = path.join(OBSIDIAN_PROJECTS, getProjectSlug(), "deliberations");
367
- if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, getProjectSlug()))) {
507
+ function validateTeleptyEnvelope(envelope) {
508
+ const parsed = TeleptyEnvelopeSchema.parse(envelope);
509
+ const payloadSchema = TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS[parsed.kind];
510
+ if (payloadSchema) {
511
+ payloadSchema.parse(parsed.payload);
512
+ }
513
+ return parsed;
514
+ }
515
+
516
+ function buildTeleptyEnvelope({ session_id, project, kind, source, target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
517
+ return validateTeleptyEnvelope({
518
+ message_id,
519
+ session_id,
520
+ project,
521
+ kind,
522
+ source,
523
+ target,
524
+ reply_to,
525
+ trace,
526
+ payload,
527
+ ts,
528
+ });
529
+ }
530
+
531
+ function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, includeHistoryEntries = 0, profile }) {
532
+ const role = (state.speaker_roles || {})[speaker] || null;
533
+ const target = profile?.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
534
+ ? `${profile.telepty_session_id}@${profile.telepty_host}`
535
+ : profile?.telepty_session_id || speaker;
536
+ return buildTeleptyEnvelope({
537
+ session_id: state.id,
538
+ project: state.project || getProjectSlug(),
539
+ kind: "turn_request",
540
+ source: `deliberation:${state.id}`,
541
+ target,
542
+ reply_to: state.id,
543
+ trace: [
544
+ `project:${state.project || getProjectSlug()}`,
545
+ `speaker:${speaker}`,
546
+ `turn:${turnId}`,
547
+ ],
548
+ payload: {
549
+ turn_id: turnId,
550
+ round: state.current_round,
551
+ max_rounds: state.max_rounds,
552
+ speaker,
553
+ role,
554
+ prompt: turnPrompt,
555
+ prompt_sha1: hashPromptText(turnPrompt),
556
+ history_entries: includeHistoryEntries,
557
+ transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
558
+ semantic_timeout_ms: TELEPTY_SEMANTIC_TIMEOUT_MS,
559
+ },
560
+ });
561
+ }
562
+
563
+ function buildTeleptySynthesisEnvelope({ state, synthesis, structured }) {
564
+ return buildTeleptyEnvelope({
565
+ session_id: state.id,
566
+ project: state.project || getProjectSlug(),
567
+ kind: "deliberation_completed",
568
+ source: `deliberation:${state.id}`,
569
+ target: "telepty-bus",
570
+ reply_to: state.id,
571
+ trace: [
572
+ `project:${state.project || getProjectSlug()}`,
573
+ "stage:synthesis",
574
+ ],
575
+ payload: {
576
+ topic: state.topic,
577
+ synthesis,
578
+ structured_synthesis: structured || null,
579
+ },
580
+ });
581
+ }
582
+
583
+ function resolveTeleptyBusUrl(host = TELEPTY_DEFAULT_HOST) {
584
+ const url = new URL(`ws://${host}:${TELEPTY_PORT}/api/bus`);
585
+ const token = loadTeleptyAuthToken();
586
+ if (token) {
587
+ url.searchParams.set("token", token);
588
+ }
589
+ return url.toString();
590
+ }
591
+
592
+ function cleanupPendingTeleptyTurn(messageId) {
593
+ const entry = pendingTeleptyTurnRequests.get(messageId);
594
+ if (!entry) return;
595
+ if (entry.transportTimer) clearTimeout(entry.transportTimer);
596
+ if (entry.semanticTimer) clearTimeout(entry.semanticTimer);
597
+ pendingTeleptyTurnRequests.delete(messageId);
598
+ }
599
+
600
+ function registerPendingTeleptyTurnRequest({ envelope, profile, speaker }) {
601
+ const nowMs = Date.now();
602
+ const entry = {
603
+ message_id: envelope.message_id,
604
+ deliberation_session_id: envelope.session_id,
605
+ project: envelope.project,
606
+ speaker,
607
+ turn_id: envelope.payload.turn_id,
608
+ target_session_id: profile?.telepty_session_id || speaker,
609
+ target_host: profile?.telepty_host || TELEPTY_DEFAULT_HOST,
610
+ prompt_sha1: envelope.payload.prompt_sha1,
611
+ published_at: envelope.ts,
612
+ transport_status: "pending",
613
+ semantic_status: "pending",
614
+ transport_deadline_at: new Date(nowMs + TELEPTY_TRANSPORT_TIMEOUT_MS).toISOString(),
615
+ semantic_deadline_at: new Date(nowMs + TELEPTY_SEMANTIC_TIMEOUT_MS).toISOString(),
616
+ };
617
+ entry.transportPromise = new Promise(resolve => {
618
+ entry.resolveTransport = resolve;
619
+ });
620
+ entry.semanticPromise = new Promise(resolve => {
621
+ entry.resolveSemantic = resolve;
622
+ });
623
+ entry.transportTimer = setTimeout(() => {
624
+ if (entry.transport_status !== "pending") return;
625
+ entry.transport_status = "timeout";
626
+ appendRuntimeLog("WARN", `TELEPTY_TRANSPORT_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
627
+ entry.resolveTransport?.({ ok: false, code: "transport_timeout" });
628
+ }, TELEPTY_TRANSPORT_TIMEOUT_MS);
629
+ entry.semanticTimer = setTimeout(() => {
630
+ if (entry.semantic_status !== "pending") return;
631
+ entry.semantic_status = "timeout";
632
+ appendRuntimeLog("WARN", `TELEPTY_SEMANTIC_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
633
+ entry.resolveSemantic?.({ ok: false, code: "semantic_timeout" });
634
+ setTimeout(() => cleanupPendingTeleptyTurn(entry.message_id), 5_000);
635
+ }, TELEPTY_SEMANTIC_TIMEOUT_MS);
636
+ pendingTeleptyTurnRequests.set(entry.message_id, entry);
637
+ return entry;
638
+ }
639
+
640
+ function ackPendingTeleptyTurn(event) {
641
+ const promptHash = hashPromptText(event?.content || "");
642
+ const targetSessionId = String(event?.target_agent || "");
643
+ const candidate = [...pendingTeleptyTurnRequests.values()]
644
+ .filter(entry =>
645
+ entry.transport_status === "pending"
646
+ && entry.target_session_id === targetSessionId
647
+ && entry.prompt_sha1 === promptHash
648
+ )
649
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
650
+ if (!candidate) return null;
651
+
652
+ candidate.transport_status = "ack";
653
+ candidate.inject_id = event.inject_id || null;
654
+ candidate.transport_acked_at = new Date().toISOString();
655
+ if (candidate.transportTimer) clearTimeout(candidate.transportTimer);
656
+ candidate.resolveTransport?.({
657
+ ok: true,
658
+ code: "inject_written",
659
+ inject_id: event.inject_id || null,
660
+ });
661
+ appendRuntimeLog("INFO", `TELEPTY_TRANSPORT_ACK: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id} | inject_id: ${event.inject_id || "n/a"}`);
662
+ return candidate;
663
+ }
664
+
665
+ function completePendingTeleptySemantic({ sessionId, speaker, turnId }) {
666
+ const candidate = [...pendingTeleptyTurnRequests.values()]
667
+ .filter(entry =>
668
+ entry.semantic_status === "pending"
669
+ && entry.deliberation_session_id === sessionId
670
+ && normalizeSpeaker(entry.speaker) === normalizeSpeaker(speaker)
671
+ && (!turnId || !entry.turn_id || entry.turn_id === turnId)
672
+ )
673
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
674
+ if (!candidate) return null;
675
+
676
+ candidate.semantic_status = "completed";
677
+ candidate.semantic_completed_at = new Date().toISOString();
678
+ if (candidate.semanticTimer) clearTimeout(candidate.semanticTimer);
679
+ candidate.resolveSemantic?.({ ok: true, code: "responded" });
680
+ appendRuntimeLog("INFO", `TELEPTY_SEMANTIC_COMPLETE: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id}`);
681
+ setTimeout(() => cleanupPendingTeleptyTurn(candidate.message_id), 5_000);
682
+ return candidate;
683
+ }
684
+
685
+ function updateTeleptySessionHealth(event) {
686
+ const sessionId = event?.session_id;
687
+ if (!sessionId) return null;
688
+ const health = {
689
+ session_id: sessionId,
690
+ payload: event.payload || {},
691
+ timestamp: event.timestamp || new Date().toISOString(),
692
+ seen_at: new Date().toISOString(),
693
+ };
694
+ teleptyBusState.healthBySession.set(sessionId, health);
695
+ return health;
696
+ }
697
+
698
+ function getTeleptySessionHealth(sessionId, nowMs = Date.now()) {
699
+ const entry = teleptyBusState.healthBySession.get(sessionId);
700
+ if (!entry) return null;
701
+ const seenAtMs = Date.parse(entry.seen_at || entry.timestamp || "");
702
+ const ageMs = Number.isFinite(seenAtMs) ? nowMs - seenAtMs : null;
703
+ return {
704
+ ...entry,
705
+ age_ms: ageMs,
706
+ stale: Number.isFinite(ageMs) ? ageMs > TELEPTY_SESSION_HEALTH_STALE_MS : true,
707
+ };
708
+ }
709
+
710
+ function handleTeleptyBusMessage(raw) {
711
+ let parsed = null;
712
+ try {
713
+ parsed = JSON.parse(String(raw));
714
+ } catch {
715
+ return null;
716
+ }
717
+ teleptyBusState.lastMessageAt = new Date().toISOString();
718
+ if (!parsed || typeof parsed !== "object") return null;
719
+
720
+ if (parsed.type === "inject_written") {
721
+ return ackPendingTeleptyTurn(parsed);
722
+ }
723
+ if (parsed.type === "session_health") {
724
+ return updateTeleptySessionHealth(parsed);
725
+ }
726
+ return parsed;
727
+ }
728
+
729
+ async function ensureTeleptyBusSubscriber() {
730
+ if (teleptyBusState.ws && teleptyBusState.ws.readyState === WebSocket.OPEN) {
731
+ return { ok: true, status: "open" };
732
+ }
733
+ if (teleptyBusState.connectPromise) {
734
+ return teleptyBusState.connectPromise;
735
+ }
736
+
737
+ teleptyBusState.connectPromise = new Promise((resolve) => {
738
+ try {
739
+ let settled = false;
740
+ const finish = (result) => {
741
+ if (settled) return;
742
+ settled = true;
743
+ resolve(result);
744
+ };
745
+ teleptyBusState.status = "connecting";
746
+ const ws = new WebSocket(resolveTeleptyBusUrl());
747
+ teleptyBusState.ws = ws;
748
+
749
+ ws.once("open", () => {
750
+ teleptyBusState.status = "open";
751
+ teleptyBusState.lastConnectedAt = new Date().toISOString();
752
+ teleptyBusState.lastError = null;
753
+ appendRuntimeLog("INFO", "TELEPTY_BUS_CONNECTED");
754
+ finish({ ok: true, status: "open" });
755
+ });
756
+
757
+ ws.on("message", (data) => {
758
+ handleTeleptyBusMessage(data.toString());
759
+ });
760
+
761
+ ws.on("error", (err) => {
762
+ teleptyBusState.lastError = String(err?.message || err);
763
+ appendRuntimeLog("WARN", `TELEPTY_BUS_ERROR: ${teleptyBusState.lastError}`);
764
+ if (ws.readyState !== WebSocket.OPEN) {
765
+ teleptyBusState.status = "error";
766
+ teleptyBusState.ws = null;
767
+ teleptyBusState.connectPromise = null;
768
+ finish({ ok: false, status: "error", error: teleptyBusState.lastError });
769
+ }
770
+ });
771
+
772
+ ws.on("close", () => {
773
+ teleptyBusState.status = "closed";
774
+ teleptyBusState.ws = null;
775
+ teleptyBusState.connectPromise = null;
776
+ if (!settled) {
777
+ finish({ ok: false, status: "closed", error: teleptyBusState.lastError || "socket closed" });
778
+ }
779
+ if (!teleptyBusState.reconnectTimer) {
780
+ teleptyBusState.reconnectTimer = setTimeout(() => {
781
+ teleptyBusState.reconnectTimer = null;
782
+ ensureTeleptyBusSubscriber().catch(() => {});
783
+ }, TELEPTY_BUS_RECONNECT_MS);
784
+ }
785
+ });
786
+ } catch (err) {
787
+ teleptyBusState.status = "error";
788
+ teleptyBusState.lastError = String(err?.message || err);
789
+ teleptyBusState.connectPromise = null;
790
+ resolve({ ok: false, status: "error", error: teleptyBusState.lastError });
791
+ }
792
+ });
793
+
794
+ const result = await teleptyBusState.connectPromise;
795
+ if (!result.ok) {
796
+ teleptyBusState.connectPromise = null;
797
+ } else if (teleptyBusState.ws?.readyState === WebSocket.OPEN) {
798
+ teleptyBusState.connectPromise = null;
799
+ }
800
+ return result;
801
+ }
802
+
803
+ async function notifyTeleptyBus(event) {
804
+ const host = process.env.TELEPTY_HOST || "localhost";
805
+ const port = process.env.TELEPTY_PORT || "3848";
806
+ const token = loadTeleptyAuthToken();
807
+ try {
808
+ const res = await fetch(`http://${host}:${port}/api/bus/publish`, {
809
+ method: "POST",
810
+ headers: {
811
+ "Content-Type": "application/json",
812
+ ...(token ? { "x-telepty-token": token } : {}),
813
+ },
814
+ body: JSON.stringify(event),
815
+ });
816
+ const data = await res.json().catch(() => null);
817
+ if (res.ok) {
818
+ appendRuntimeLog("INFO", `HANDOFF: Telepty bus notified: ${event.kind || event.type || "unknown"}`);
819
+ return { ok: true, delivered: data?.delivered ?? null };
820
+ }
821
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
822
+ } catch (err) {
823
+ appendRuntimeLog("WARN", `HANDOFF: Telepty bus notification failed: ${err.message}`);
824
+ return { ok: false, error: err.message };
825
+ }
826
+ }
827
+
828
+ function getArchiveDir(projectSlug = getProjectSlug()) {
829
+ const slug = normalizeProjectSlug(projectSlug);
830
+ const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
831
+ if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, slug))) {
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,123 @@ 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
+
519
1124
  function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
520
1125
  const manualSpeakers = Array.isArray(speakers) ? speakers : [];
521
1126
  const hasBrowserSpeaker = manualSpeakers.some(speaker => {
@@ -559,6 +1164,125 @@ function resolveCliCandidates() {
559
1164
  return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
560
1165
  }
561
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
+
562
1286
  function commandExistsInPath(command) {
563
1287
  if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
564
1288
  return false;
@@ -1171,6 +1895,7 @@ function inferLlmProvider(url = "", title = "") {
1171
1895
  async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
1172
1896
  const candidates = [];
1173
1897
  const seen = new Set();
1898
+ let browserNote = null;
1174
1899
 
1175
1900
  const add = (candidate) => {
1176
1901
  const speaker = normalizeSpeaker(candidate?.speaker);
@@ -1190,9 +1915,29 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1190
1915
  live,
1191
1916
  });
1192
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
+ }
1193
1939
  }
1194
1940
 
1195
- let browserNote = null;
1196
1941
  if (include_browser) {
1197
1942
  // Ensure CDP is available before probing browser tabs
1198
1943
  const cdpStatus = await ensureCdpAvailable();
@@ -1336,6 +2081,7 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1336
2081
 
1337
2082
  function formatSpeakerCandidatesReport({ candidates, browserNote }) {
1338
2083
  const cli = candidates.filter(c => c.type === "cli");
2084
+ const telepty = candidates.filter(c => c.type === "telepty");
1339
2085
  const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
1340
2086
  const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
1341
2087
 
@@ -1350,6 +2096,22 @@ function formatSpeakerCandidatesReport({ candidates, browserNote }) {
1350
2096
  }).join("\n")}\n\n`;
1351
2097
  }
1352
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
+
1353
2115
  out += "### Browser LLM (detected)\n";
1354
2116
  if (detected.length === 0) {
1355
2117
  out += "- (No LLM tabs detected in browser)\n";
@@ -1431,6 +2193,18 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1431
2193
  continue;
1432
2194
  }
1433
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
+
1434
2208
  const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
1435
2209
  profiles.push({
1436
2210
  speaker,
@@ -1448,6 +2222,7 @@ function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1448
2222
 
1449
2223
  const TRANSPORT_TYPES = {
1450
2224
  cli: "cli_respond",
2225
+ telepty: "telepty_bus",
1451
2226
  browser: "clipboard",
1452
2227
  browser_auto: "browser_auto",
1453
2228
  manual: "manual",
@@ -1489,6 +2264,9 @@ const CLI_INVOCATION_HINTS = {
1489
2264
 
1490
2265
  function formatTransportGuidance(transport, state, speaker) {
1491
2266
  const sid = state.id;
2267
+ const profile = (state.participant_profiles || []).find(
2268
+ p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
2269
+ ) || null;
1492
2270
  switch (transport) {
1493
2271
  case "cli_respond": {
1494
2272
  const hint = CLI_INVOCATION_HINTS[speaker] || null;
@@ -1513,8 +2291,22 @@ function formatTransportGuidance(transport, state, speaker) {
1513
2291
  `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
1514
2292
  case "browser_auto":
1515
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(...)\`.`;
1516
2299
  case "manual":
1517
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
+ }
1518
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` +
1519
2311
  `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
1520
2312
  `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
@@ -1655,38 +2447,45 @@ function readContextFromDirs(dirs, maxChars = 15000) {
1655
2447
 
1656
2448
  // ── State helpers ──────────────────────────────────────────────
1657
2449
 
1658
- function ensureDirs() {
1659
- fs.mkdirSync(getSessionsDir(), { recursive: true });
1660
- fs.mkdirSync(getArchiveDir(), { recursive: true });
1661
- 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 });
1662
2454
  }
1663
2455
 
1664
- function loadSession(sessionId) {
1665
- const file = getSessionFile(sessionId);
1666
- if (!fs.existsSync(file)) return null;
1667
- return normalizeSessionActors(JSON.parse(fs.readFileSync(file, "utf-8")));
2456
+ function loadSession(sessionRef) {
2457
+ const record = findSessionRecord(sessionRef);
2458
+ return record?.state || null;
1668
2459
  }
1669
2460
 
1670
2461
  function saveSession(state) {
1671
- ensureDirs();
2462
+ ensureDirs(state.project);
1672
2463
  state.updated = new Date().toISOString();
1673
- writeTextAtomic(getSessionFile(state.id), JSON.stringify(state, null, 2));
2464
+ writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
1674
2465
  syncMarkdown(state);
1675
2466
  }
1676
2467
 
1677
- function listActiveSessions() {
1678
- const dir = getSessionsDir();
1679
- if (!fs.existsSync(dir)) return [];
2468
+ function listActiveSessions(projectSlug) {
2469
+ const projects = projectSlug
2470
+ ? [normalizeProjectSlug(projectSlug)]
2471
+ : [...new Set([getProjectSlug(), ...listStateProjects()])];
1680
2472
 
1681
- return fs.readdirSync(dir)
1682
- .filter(f => f.endsWith(".json"))
1683
- .map(f => {
1684
- try {
1685
- const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
1686
- return data;
1687
- } catch { return null; }
1688
- })
1689
- .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
+ });
1690
2489
  }
1691
2490
 
1692
2491
  function resolveSessionId(sessionId) {
@@ -1704,8 +2503,7 @@ function resolveSessionId(sessionId) {
1704
2503
 
1705
2504
  function syncMarkdown(state) {
1706
2505
  const filename = `deliberation-${state.id}.md`;
1707
- // Write to state dir instead of CWD to avoid polluting project root
1708
- const mdPath = path.join(getProjectStateDir(), filename);
2506
+ const mdPath = path.join(getProjectStateDir(state.project), filename);
1709
2507
  try {
1710
2508
  writeTextAtomic(mdPath, stateToMarkdown(state));
1711
2509
  } catch { /* ignore sync failures */ }
@@ -1713,8 +2511,7 @@ function syncMarkdown(state) {
1713
2511
 
1714
2512
  function cleanupSyncMarkdown(state) {
1715
2513
  const filename = `deliberation-${state.id}.md`;
1716
- // Remove from state dir
1717
- const statePath = path.join(getProjectStateDir(), filename);
2514
+ const statePath = path.join(getProjectStateDir(state.project), filename);
1718
2515
  try { fs.unlinkSync(statePath); } catch { /* ignore */ }
1719
2516
  // Also clean up legacy files in CWD (from older versions)
1720
2517
  const cwdPath = path.join(process.cwd(), filename);
@@ -1773,14 +2570,14 @@ tags: [deliberation]
1773
2570
  }
1774
2571
 
1775
2572
  function archiveState(state) {
1776
- ensureDirs();
2573
+ ensureDirs(state.project);
1777
2574
  const slug = state.topic
1778
2575
  .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
1779
2576
  .replace(/\s+/g, "-")
1780
2577
  .slice(0, 30);
1781
2578
  const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
1782
2579
  const filename = `deliberation-${ts}-${slug}.md`;
1783
- const dest = path.join(getArchiveDir(), filename);
2580
+ const dest = path.join(getArchiveDir(state.project), filename);
1784
2581
  writeTextAtomic(dest, stateToMarkdown(state));
1785
2582
  return dest;
1786
2583
  }
@@ -2289,24 +3086,152 @@ function closeAllMonitorTerminals() {
2289
3086
 
2290
3087
  function multipleSessionsError() {
2291
3088
  const active = listActiveSessions();
2292
- 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");
2293
3090
  return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
2294
3091
  }
2295
3092
 
2296
- 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 = {}) {
2297
3130
  const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
2298
3131
  if (entries.length === 0) {
2299
3132
  return "(No previous responses yet)";
2300
3133
  }
2301
- return entries.map(e => {
2302
- const content = String(e.content || "").trim();
2303
- return `- ${e.speaker} (Round ${e.round})\n${content}`;
2304
- }).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
+ );
2305
3225
  }
2306
3226
 
2307
3227
  function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
2308
- const recent = formatRecentLogForPrompt(state, includeHistoryEntries);
3228
+ const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
3229
+ const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
2309
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
+ : "";
2310
3235
 
2311
3236
  // Role prompt injection
2312
3237
  const speakerRole = (state.speaker_roles || {})[speaker] || "free";
@@ -2318,7 +3243,7 @@ function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries
2318
3243
  return `[deliberation_turn_request]
2319
3244
  session_id: ${state.id}
2320
3245
  project: ${state.project}
2321
- topic: ${state.topic}
3246
+ topic: ${topic}
2322
3247
  round: ${state.current_round}/${state.max_rounds}
2323
3248
  target_speaker: ${speaker}
2324
3249
  required_turn: ${state.current_speaker}${roleSection}
@@ -2330,6 +3255,7 @@ ${recent}
2330
3255
  [response_rule]
2331
3256
  - Write only ${speaker}'s response for this turn reflecting the discussion context above
2332
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}
2333
3259
  - Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
2334
3260
  [/response_rule]
2335
3261
  [/deliberation_turn_request]
@@ -2403,6 +3329,11 @@ function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel
2403
3329
  role_drift: roleDrift || undefined,
2404
3330
  attachments: attachments || undefined,
2405
3331
  });
3332
+ completePendingTeleptySemantic({
3333
+ sessionId: state.id,
3334
+ speaker: normalizedSpeaker,
3335
+ turnId: state.pending_turn_id || turn_id || null,
3336
+ });
2406
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}`);
2407
3338
 
2408
3339
  state.current_speaker = selectNextSpeaker(state);
@@ -2480,6 +3411,7 @@ server.tool(
2480
3411
  session_id: z.string().trim().min(1).max(64).optional().describe("Explicit session ID to use. If omitted, one is generated from topic."),
2481
3412
  rounds: z.coerce.number().optional().describe("Number of rounds (defaults to config setting, default 3)"),
2482
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."),
2483
3415
  speakers: z.preprocess(
2484
3416
  (v) => {
2485
3417
  const parsed = typeof v === "string" ? JSON.parse(v) : v;
@@ -2496,18 +3428,18 @@ server.tool(
2496
3428
  require_manual_speakers: z.preprocess(
2497
3429
  (v) => (typeof v === "string" ? v === "true" : v),
2498
3430
  z.boolean().optional()
2499
- ).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."),
2500
3432
  auto_discover_speakers: z.preprocess(
2501
3433
  (v) => (typeof v === "string" ? v === "true" : v),
2502
3434
  z.boolean().optional()
2503
- ).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."),
2504
3436
  include_browser_speakers: z.preprocess(
2505
3437
  (v) => (typeof v === "string" ? v === "true" : v),
2506
3438
  z.boolean().optional()
2507
3439
  ).describe("Whether browser speakers are allowed to participate. Defaults to false unless explicitly enabled."),
2508
3440
  participant_types: z.preprocess(
2509
3441
  (v) => (typeof v === "string" ? JSON.parse(v) : v),
2510
- 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()
2511
3443
  ).describe("Per-speaker type override (e.g., {\"chatgpt\": \"browser_auto\"})"),
2512
3444
  ordering_strategy: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
2513
3445
  .describe("Ordering strategy: auto (automatic based on speaker count), cyclic (sequential), random (random each turn), weighted-random (less spoken speakers first)"),
@@ -2517,8 +3449,12 @@ server.tool(
2517
3449
  ).describe("Per-speaker role assignment (e.g., {\"claude\": \"critic\", \"codex\": \"implementer\"})"),
2518
3450
  role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
2519
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."),
2520
3456
  },
2521
- safeToolHandler("deliberation_start", async ({ topic, session_id, rounds, first_speaker, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, include_browser_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 }) => {
2522
3458
  // ── First-time onboarding guard ──
2523
3459
  const config = loadDeliberationConfig();
2524
3460
  if (!config.setup_complete) {
@@ -2527,7 +3463,7 @@ server.tool(
2527
3463
  return {
2528
3464
  content: [{
2529
3465
  type: "text",
2530
- 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 include_browser_speakers: 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\` Auto-join detected speakers\n\n**2. Browser speakers** (\`include_browser_speakers\`)\n - \`false\` — CLI 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`,
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`,
2531
3467
  }],
2532
3468
  };
2533
3469
  }
@@ -2561,33 +3497,53 @@ server.tool(
2561
3497
  });
2562
3498
 
2563
3499
  // Resolve effective settings from config
2564
- const effectiveRequireManual = require_manual_speakers ?? config.require_speaker_selection ?? true;
2565
- const effectiveAutoDiscover = auto_discover_speakers ?? !effectiveRequireManual;
3500
+ const effectiveRequireManual = true;
3501
+ const effectiveAutoDiscover = false;
2566
3502
  rounds = rounds ?? config.default_rounds ?? 3;
2567
3503
  const rawOrdering = ordering_strategy ?? config.default_ordering ?? "auto";
2568
3504
  // Resolve "auto": 2 speakers → cyclic, 3+ → weighted-random
2569
3505
  ordering_strategy = rawOrdering === "auto" ? undefined : rawOrdering; // resolved after speakers are known
2570
3506
 
2571
- // When require_speaker_selection is explicitly true in config,
2572
- // ignore LLM-provided speakers UNLESS require_manual_speakers: true is explicitly passed
2573
- // (which signals the user has confirmed the speaker selection)
2574
- const configRequiresSelection = config.require_speaker_selection === true;
2575
- const llmExplicitlyConfirmed = require_manual_speakers === true;
2576
- const hasManualSpeakers = Array.isArray(speakers) && speakers.length > 0
2577
- && (!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
+ }
2578
3536
 
2579
3537
  if (!hasManualSpeakers && effectiveRequireManual) {
2580
3538
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
2581
3539
  const llmSuggested = Array.isArray(speakers) && speakers.length > 0
2582
- ? `\n\n💡 **LLM suggested speakers:** ${speakers.join(", ")}\nTo use this suggestion, pass speakers again with \`require_manual_speakers: true\`.`
2583
- : "";
2584
- const configNote = configRequiresSelection
2585
- ? "\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.`
2586
3541
  : "";
3542
+ const configNote = "\n\n⚙️ Manual speaker selection is enabled and requires a fresh confirmed `selection_token`.";
2587
3543
  return {
2588
3544
  content: [{
2589
3545
  type: "text",
2590
- 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: ["claude", "codex", "gemini"],\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.`,
2591
3547
  }],
2592
3548
  };
2593
3549
  }
@@ -2623,6 +3579,10 @@ server.tool(
2623
3579
  || DEFAULT_SPEAKERS[0];
2624
3580
  const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
2625
3581
 
3582
+ if (effectiveRequireManual) {
3583
+ clearSpeakerSelectionToken();
3584
+ }
3585
+
2626
3586
  // Warn if only 1 speaker — deliberation requires 2+
2627
3587
  if (speakerOrder.length < 2) {
2628
3588
  const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
@@ -2650,7 +3610,7 @@ server.tool(
2650
3610
  }
2651
3611
 
2652
3612
  const participantMode = hasManualSpeakers
2653
- ? "manually specified"
3613
+ ? "user-selected"
2654
3614
  : (autoDiscoveredSpeakers.length > 0 ? "auto-discovered (PATH)" : "default");
2655
3615
 
2656
3616
  const degradationLevels = await detectDegradationLevels();
@@ -2673,6 +3633,7 @@ server.tool(
2673
3633
  ordering_strategy: ordering_strategy || (speakerOrder.length <= 2 ? "cyclic" : "weighted-random"),
2674
3634
  speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
2675
3635
  degradation: degradationLevels,
3636
+ auto_execute: auto_execute || false,
2676
3637
  created: new Date().toISOString(),
2677
3638
  updated: new Date().toISOString(),
2678
3639
  };
@@ -2740,6 +3701,15 @@ server.tool(
2740
3701
  }).join("\n");
2741
3702
 
2742
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
+
2743
3713
  return {
2744
3714
  content: [{
2745
3715
  type: "text",
@@ -2751,15 +3721,66 @@ server.tool(
2751
3721
 
2752
3722
  server.tool(
2753
3723
  "deliberation_speaker_candidates",
2754
- "Query available speaker candidates (local CLI + browser LLM tabs).",
3724
+ "Query available speaker candidates (local CLI + telepty active sessions + browser LLM tabs).",
2755
3725
  {
2756
3726
  include_cli: z.boolean().default(true).describe("Include local CLI candidates"),
2757
3727
  include_browser: z.boolean().default(true).describe("Include browser LLM tab candidates"),
2758
3728
  },
2759
3729
  async ({ include_cli, include_browser }) => {
2760
3730
  const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
3731
+ const selection = issueSpeakerSelectionToken({
3732
+ candidates: snapshot.candidates,
3733
+ include_browser,
3734
+ });
2761
3735
  const text = formatSpeakerCandidatesReport(snapshot);
2762
- 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
+ };
2763
3784
  }
2764
3785
  );
2765
3786
 
@@ -2898,6 +3919,55 @@ server.tool(
2898
3919
 
2899
3920
  let extra = "";
2900
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
+ }
2901
3971
 
2902
3972
  if (transport === "browser_auto") {
2903
3973
  // Auto-execute browser_auto_turn
@@ -2974,9 +4044,13 @@ server.tool(
2974
4044
  extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
2975
4045
  }
2976
4046
 
4047
+ if (transport === "telepty_bus" && manualFallbackPrompt) {
4048
+ extra += `\n\n### [turn_prompt]\n\`\`\`markdown\n${turnPrompt}\n\`\`\``;
4049
+ }
4050
+
2977
4051
  const profileInfo = profile
2978
4052
  ? `\n**Profile:** ${profile.type}${profile.url ? ` | ${profile.url}` : ""}${profile.command ? ` | command: ${profile.command}` : ""}`
2979
- : "";
4053
+ : "";
2980
4054
 
2981
4055
  return {
2982
4056
  content: [{
@@ -3108,6 +4182,327 @@ server.tool(
3108
4182
  })
3109
4183
  );
3110
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
+
3111
4506
  server.tool(
3112
4507
  "deliberation_cli_auto_turn",
3113
4508
  "Automatically send a turn to a CLI speaker and collect the response.",
@@ -3156,10 +4551,7 @@ server.tool(
3156
4551
  }] };
3157
4552
  }
3158
4553
 
3159
- // Dynamic timeout: first turn gets extra time for cold-start
3160
4554
  const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
3161
- const effectiveTimeout = speakerPriorTurns === 0 ? Math.max(timeout_sec, 180) : timeout_sec;
3162
-
3163
4555
  const hint = CLI_INVOCATION_HINTS[speaker];
3164
4556
  if (!hint) {
3165
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) }] };
@@ -3172,6 +4564,12 @@ server.tool(
3172
4564
 
3173
4565
  const turnId = state.pending_turn_id || generateTurnId();
3174
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
+ });
3175
4573
 
3176
4574
  // Spawn CLI process
3177
4575
  const startTime = Date.now();
@@ -3186,16 +4584,31 @@ server.tool(
3186
4584
  let child;
3187
4585
  let stdout = "";
3188
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
+ };
3189
4602
 
3190
4603
  // Different invocation patterns per CLI
3191
4604
  switch (speaker) {
3192
4605
  case "claude":
3193
- child = spawn("claude", ["-p", "--output-format", "text"], { env, windowsHide: true });
4606
+ child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
3194
4607
  child.stdin.write(turnPrompt);
3195
4608
  child.stdin.end();
3196
4609
  break;
3197
4610
  case "codex":
3198
- child = spawn("codex", ["exec", "-"], { env, windowsHide: true });
4611
+ child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
3199
4612
  child.stdin.write(turnPrompt);
3200
4613
  child.stdin.end();
3201
4614
  break;
@@ -3211,8 +4624,19 @@ server.tool(
3211
4624
  }
3212
4625
 
3213
4626
  const timer = setTimeout(() => {
3214
- child.kill("SIGTERM");
3215
- 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)`));
3216
4640
  }, effectiveTimeout * 1000);
3217
4641
 
3218
4642
  child.stdout.on("data", (data) => { stdout += data.toString(); });
@@ -3221,7 +4645,8 @@ server.tool(
3221
4645
  child.on("close", (code) => {
3222
4646
  clearTimeout(timer);
3223
4647
  if (code !== 0 && !stdout.trim()) {
3224
- 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)}`));
3225
4650
  } else {
3226
4651
  // Clean up output noise
3227
4652
  let cleaned = stdout;
@@ -3232,12 +4657,12 @@ server.tool(
3232
4657
  const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
3233
4658
  if (codexLineIdx !== -1) {
3234
4659
  cleaned = lines.slice(codexLineIdx + 1)
3235
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
4660
+ .filter(line => !/^(tokens used$|^[0-9,]*$|^mcp:.*)/.test(line))
3236
4661
  .join("\n");
3237
4662
  } else {
3238
4663
  // Fallback regex cleaning
3239
4664
  cleaned = stdout.split("\n")
3240
- .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))
3241
4666
  .join("\n");
3242
4667
  }
3243
4668
  } else if (speaker === "gemini") {
@@ -3245,13 +4670,14 @@ server.tool(
3245
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))
3246
4671
  .join("\n");
3247
4672
  }
3248
- resolve(cleaned.trim());
4673
+ resolveOnce(cleaned.trim());
3249
4674
  }
3250
4675
  });
3251
4676
 
3252
4677
  child.on("error", (err) => {
3253
4678
  clearTimeout(timer);
3254
- 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);
3255
4681
  });
3256
4682
  });
3257
4683
 
@@ -3283,7 +4709,15 @@ server.tool(
3283
4709
  return {
3284
4710
  content: [{
3285
4711
  type: "text",
3286
- 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
+ }),
3287
4721
  }],
3288
4722
  };
3289
4723
  }
@@ -3544,12 +4978,22 @@ server.tool(
3544
4978
 
3545
4979
  server.tool(
3546
4980
  "deliberation_synthesize",
3547
- "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.",
3548
4982
  {
3549
4983
  session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
3550
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."),
3551
4995
  },
3552
- safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis }) => {
4996
+ safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis, structured }) => {
3553
4997
  const resolved = resolveSessionId(session_id);
3554
4998
  if (!resolved) {
3555
4999
  return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
@@ -3567,13 +5011,15 @@ server.tool(
3567
5011
  }
3568
5012
 
3569
5013
  loaded.synthesis = synthesis;
5014
+ loaded.structured_synthesis = structured || null;
3570
5015
  loaded.status = "completed";
3571
5016
  loaded.current_speaker = "none";
3572
5017
  saveSession(loaded);
3573
5018
  archivePath = archiveState(loaded);
3574
5019
  cleanupSyncMarkdown(loaded);
5020
+
3575
5021
  // Clean up the active session JSON file upon completion
3576
- const sessionFile = getSessionFile(loaded.id);
5022
+ const sessionFile = getSessionFile(loaded);
3577
5023
  try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch { /* ignore */ }
3578
5024
  state = loaded;
3579
5025
  return null;
@@ -3583,10 +5029,20 @@ server.tool(
3583
5029
  }
3584
5030
 
3585
5031
  appendRuntimeLog("INFO", `SYNTHESIZED: ${resolved} | turns: ${state.log.length} | rounds: ${state.max_rounds}`);
5032
+ const synthesisEnvelope = buildTeleptySynthesisEnvelope({
5033
+ state,
5034
+ synthesis,
5035
+ structured,
5036
+ });
3586
5037
 
3587
5038
  // Immediately force-close monitor terminal (including physical Terminal) on deliberation end
3588
5039
  closeMonitorTerminal(state.id, getSessionWindowIds(state));
3589
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
+
3590
5046
  return {
3591
5047
  content: [{
3592
5048
  type: "text",
@@ -3634,11 +5090,11 @@ server.tool(
3634
5090
  // Reset specific session only
3635
5091
  let toCloseIds = [];
3636
5092
  const result = withSessionLock(session_id, () => {
3637
- const file = getSessionFile(session_id);
3638
- if (!fs.existsSync(file)) {
5093
+ const state = loadSession(session_id);
5094
+ if (!state) {
3639
5095
  return { content: [{ type: "text", text: t(`Session "${session_id}" not found.`, `세션 "${session_id}"을 찾을 수 없습니다.`, "en") }] };
3640
5096
  }
3641
- const state = loadSession(session_id);
5097
+ const file = getSessionFile(state);
3642
5098
  if (state && state.log.length > 0) {
3643
5099
  archiveState(state);
3644
5100
  }
@@ -3714,11 +5170,11 @@ server.tool(
3714
5170
  require_speaker_selection: z.preprocess(
3715
5171
  (v) => (typeof v === "string" ? v === "true" : v),
3716
5172
  z.boolean().optional()
3717
- ).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."),
3718
5174
  include_browser_speakers: z.preprocess(
3719
5175
  (v) => (typeof v === "string" ? v === "true" : v),
3720
5176
  z.boolean().optional()
3721
- ).describe("true: browser LLM speakers may join when requested or auto-discovered, false: CLI-only mode"),
5177
+ ).describe("true: browser LLM speakers may join when requested, false: CLI + telepty candidate mode"),
3722
5178
  default_rounds: z.coerce.number().int().min(1).max(10).optional()
3723
5179
  .describe("Default number of rounds (1-10, default 3)"),
3724
5180
  default_ordering: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
@@ -3732,7 +5188,7 @@ server.tool(
3732
5188
  // Handle setup config updates
3733
5189
  let configChanged = false;
3734
5190
  if (require_speaker_selection !== undefined && require_speaker_selection !== null) {
3735
- config.require_speaker_selection = require_speaker_selection;
5191
+ config.require_speaker_selection = true;
3736
5192
  configChanged = true;
3737
5193
  }
3738
5194
  if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
@@ -3765,7 +5221,7 @@ server.tool(
3765
5221
  return {
3766
5222
  content: [{
3767
5223
  type: "text",
3768
- text: `## Deliberation CLI Settings\n\n**Mode:** ${mode}\n**Speaker selection:** ${config.require_speaker_selection === false ? "auto (detected speakers join)" : "manual (user selects)"}\n**Browser speakers:** ${config.include_browser_speakers === true ? "enabled" : "disabled (CLI-only 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\nTo change:\n\`deliberation_cli_config(require_speaker_selection: false, 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 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: [])\``,
3769
5225
  }],
3770
5226
  };
3771
5227
  }
@@ -4197,11 +5653,12 @@ server.tool(
4197
5653
  const env = { ...process.env, NO_COLOR: "1" };
4198
5654
 
4199
5655
  if (speaker === "claude") {
4200
- 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 });
4201
5658
  proc.stdin.write(opinionPrompt);
4202
5659
  proc.stdin.end();
4203
5660
  } else if (speaker === "codex") {
4204
- proc = spawn("codex", ["exec", "-"], { env, windowsHide: true });
5661
+ proc = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
4205
5662
  proc.stdin.write(opinionPrompt);
4206
5663
  proc.stdin.end();
4207
5664
  } else if (speaker === "gemini") {
@@ -4227,7 +5684,7 @@ server.tool(
4227
5684
  const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
4228
5685
  if (codexLineIdx !== -1) {
4229
5686
  cleaned = lines.slice(codexLineIdx + 1)
4230
- .filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
5687
+ .filter(line => !/^(tokens used$|^[0-9,]*$|^mcp:.*)/.test(line))
4231
5688
  .join("\n").trim();
4232
5689
  }
4233
5690
  } else if (speaker === "gemini") {
@@ -4570,4 +6027,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
4570
6027
  }
4571
6028
 
4572
6029
  // ── Test exports (used by vitest) ──
4573
- 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 };
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 };