@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/README.md +37 -0
- package/clipboard.js +178 -0
- package/index.js +1560 -103
- package/install.js +18 -1
- package/package.json +2 -1
- package/skills/deliberation/SKILL.md +7 -6
- package/skills/deliberation-gate/SKILL.md +9 -5
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
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
|
362
|
-
return
|
|
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
|
|
366
|
-
const
|
|
367
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1665
|
-
const
|
|
1666
|
-
|
|
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
|
|
2464
|
+
writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
|
|
1674
2465
|
syncMarkdown(state);
|
|
1675
2466
|
}
|
|
1676
2467
|
|
|
1677
|
-
function listActiveSessions() {
|
|
1678
|
-
const
|
|
1679
|
-
|
|
2468
|
+
function listActiveSessions(projectSlug) {
|
|
2469
|
+
const projects = projectSlug
|
|
2470
|
+
? [normalizeProjectSlug(projectSlug)]
|
|
2471
|
+
: [...new Set([getProjectSlug(), ...listStateProjects()])];
|
|
1680
2472
|
|
|
1681
|
-
return
|
|
1682
|
-
|
|
1683
|
-
.
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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
|
|
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: ${
|
|
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("
|
|
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("
|
|
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
|
|
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 =
|
|
2565
|
-
const effectiveAutoDiscover =
|
|
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
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
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(", ")}\
|
|
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\
|
|
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
|
-
? "
|
|
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 {
|
|
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",
|
|
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",
|
|
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
|
-
|
|
3215
|
-
|
|
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
|
-
|
|
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,]
|
|
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
|
|
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
|
-
|
|
4673
|
+
resolveOnce(cleaned.trim());
|
|
3249
4674
|
}
|
|
3250
4675
|
});
|
|
3251
4676
|
|
|
3252
4677
|
child.on("error", (err) => {
|
|
3253
4678
|
clearTimeout(timer);
|
|
3254
|
-
|
|
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:
|
|
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
|
|
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
|
|
3638
|
-
if (!
|
|
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
|
|
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("
|
|
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
|
|
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 =
|
|
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:**
|
|
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
|
-
|
|
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",
|
|
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,]
|
|
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 };
|