@dmsdc-ai/aigentry-deliberation 0.0.43 → 0.0.47
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/doctor.js +76 -0
- package/index.js +524 -60
- package/install.js +1 -0
- package/lib/session.js +15 -0
- package/lib/speaker-discovery.js +23 -0
- package/lib/transport.js +107 -8
- package/logger-emit.js +119 -0
- package/package.json +3 -1
package/doctor.js
CHANGED
|
@@ -62,6 +62,54 @@ const LOG_LOCATIONS = [
|
|
|
62
62
|
path.join(HOME, ".local", "lib", "mcp-deliberation", "runtime.log"),
|
|
63
63
|
];
|
|
64
64
|
|
|
65
|
+
// Runtime log size-check thresholds (env-configurable).
|
|
66
|
+
// Doctor DIAGNOSES only — it never mutates log files.
|
|
67
|
+
const LOG_SIZE_WARN_MB = Number(process.env.DELIBERATION_LOG_SIZE_WARN_MB) > 0
|
|
68
|
+
? Number(process.env.DELIBERATION_LOG_SIZE_WARN_MB)
|
|
69
|
+
: 50;
|
|
70
|
+
const LOG_SIZE_ERROR_MB = Number(process.env.DELIBERATION_LOG_SIZE_ERROR_MB) > 0
|
|
71
|
+
? Number(process.env.DELIBERATION_LOG_SIZE_ERROR_MB)
|
|
72
|
+
: 500;
|
|
73
|
+
|
|
74
|
+
function formatBytes(bytes) {
|
|
75
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "? B";
|
|
76
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
77
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
79
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inspect ~/.local/lib/mcp-deliberation/ for runtime.log* files and report on
|
|
84
|
+
* total footprint. Returns { level, totalBytes, topFiles } without mutating
|
|
85
|
+
* anything (doctor is diagnostic-only).
|
|
86
|
+
*/
|
|
87
|
+
function checkRuntimeLogFootprint(installDir) {
|
|
88
|
+
const result = { level: "ok", totalBytes: 0, topFiles: [], dir: installDir };
|
|
89
|
+
try {
|
|
90
|
+
if (!fs.existsSync(installDir)) return result;
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const name of fs.readdirSync(installDir)) {
|
|
93
|
+
if (!/^runtime\.log(\.|$)/.test(name)) continue;
|
|
94
|
+
const p = path.join(installDir, name);
|
|
95
|
+
try {
|
|
96
|
+
const st = fs.statSync(p);
|
|
97
|
+
if (!st.isFile()) continue;
|
|
98
|
+
entries.push({ path: p, size: st.size });
|
|
99
|
+
result.totalBytes += st.size;
|
|
100
|
+
} catch { /* skip */ }
|
|
101
|
+
}
|
|
102
|
+
entries.sort((a, b) => b.size - a.size);
|
|
103
|
+
result.topFiles = entries.slice(0, 3);
|
|
104
|
+
if (result.totalBytes >= LOG_SIZE_ERROR_MB * 1024 * 1024) {
|
|
105
|
+
result.level = "error";
|
|
106
|
+
} else if (result.totalBytes >= LOG_SIZE_WARN_MB * 1024 * 1024) {
|
|
107
|
+
result.level = "warn";
|
|
108
|
+
}
|
|
109
|
+
} catch { /* ignore */ }
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
// ── TOML parser (minimal, mcp_servers only) ────────────────────
|
|
66
114
|
|
|
67
115
|
function parseMcpServersFromToml(content) {
|
|
@@ -381,6 +429,34 @@ function runDiagnostics() {
|
|
|
381
429
|
)
|
|
382
430
|
: path.join(HOME, ".local", "lib", "mcp-deliberation");
|
|
383
431
|
|
|
432
|
+
// Runtime log footprint check — diagnose only, never mutate.
|
|
433
|
+
const logCheck = checkRuntimeLogFootprint(installDir);
|
|
434
|
+
if (logCheck.totalBytes > 0) {
|
|
435
|
+
const sizeStr = formatBytes(logCheck.totalBytes);
|
|
436
|
+
if (logCheck.level === "error") {
|
|
437
|
+
totalIssues++;
|
|
438
|
+
console.log(` ❌ runtime.log footprint: ${sizeStr} (>= ${LOG_SIZE_ERROR_MB} MB ERROR threshold)`);
|
|
439
|
+
} else if (logCheck.level === "warn") {
|
|
440
|
+
totalIssues++;
|
|
441
|
+
console.log(` ⚠️ runtime.log footprint: ${sizeStr} (>= ${LOG_SIZE_WARN_MB} MB WARN threshold)`);
|
|
442
|
+
} else {
|
|
443
|
+
console.log(` ✅ runtime.log footprint: ${sizeStr}`);
|
|
444
|
+
}
|
|
445
|
+
if (logCheck.level !== "ok" && logCheck.topFiles.length > 0) {
|
|
446
|
+
console.log(` top offenders:`);
|
|
447
|
+
for (const f of logCheck.topFiles) {
|
|
448
|
+
console.log(` - ${formatBytes(f.size).padStart(10)} ${f.path}`);
|
|
449
|
+
}
|
|
450
|
+
console.log(` fix: upgrade to v0.0.45+ and let normal rotation / budget enforcement reclaim space. Immediate: rm ${path.join(installDir, 'runtime.log.old')} && : > ${path.join(installDir, 'runtime.log')}`);
|
|
451
|
+
allIssues.push({
|
|
452
|
+
config: "logs",
|
|
453
|
+
server: "runtime.log",
|
|
454
|
+
issue: logCheck.level === "error" ? "log dir >= 500 MB" : "log dir >= 50 MB",
|
|
455
|
+
fix: "upgrade to v0.0.45+ or manual cleanup",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
384
460
|
const selfPath = path.join(installDir, "index.js");
|
|
385
461
|
if (checkPathExists(selfPath)) {
|
|
386
462
|
console.log(` ✅ Server file: ${selfPath}`);
|
package/index.js
CHANGED
|
@@ -69,6 +69,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
69
69
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
70
70
|
import { z } from "zod";
|
|
71
71
|
import { execFileSync, spawn } from "child_process";
|
|
72
|
+
import crypto from "crypto";
|
|
72
73
|
import fs from "fs";
|
|
73
74
|
import path from "path";
|
|
74
75
|
import { fileURLToPath } from "url";
|
|
@@ -112,6 +113,7 @@ import {
|
|
|
112
113
|
clearSpeakerSelectionToken,
|
|
113
114
|
validateSpeakerSelectionSnapshot,
|
|
114
115
|
confirmSpeakerSelectionToken,
|
|
116
|
+
markSelectionTokenConsumed,
|
|
115
117
|
validateSpeakerSelectionRequest,
|
|
116
118
|
// Browser participant helpers
|
|
117
119
|
hasExplicitBrowserParticipantSelection,
|
|
@@ -234,6 +236,9 @@ import {
|
|
|
234
236
|
} from "./decision-engine.js";
|
|
235
237
|
import { detectLang, t } from "./i18n.js";
|
|
236
238
|
import { checkToolEntitlement } from "./lib/entitlement.js";
|
|
239
|
+
// δ2 (#440) — telemetry emit wrapper. emit-skip-with-warning when role
|
|
240
|
+
// is unset; failures swallowed; never blocks the synthesis path.
|
|
241
|
+
import { emitSynthesisEvent, emitHandoffEvent } from "./logger-emit.js";
|
|
237
242
|
import {
|
|
238
243
|
initTeleptyDeps,
|
|
239
244
|
// Schemas
|
|
@@ -406,6 +411,179 @@ function getSpeakerSelectionFile(projectSlug = getProjectSlug()) {
|
|
|
406
411
|
return path.join(getProjectStateDir(projectSlug), SPEAKER_SELECTION_FILE);
|
|
407
412
|
}
|
|
408
413
|
|
|
414
|
+
// ── ADR-264 helpers ─────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
// §2.2 Proxy Response Submission: verify external_output via SHA-256 digest
|
|
417
|
+
// so an orchestrator that pre-spawned a CLI/browser can submit responses
|
|
418
|
+
// without the server re-running the CLI. `verify: "none"` + source
|
|
419
|
+
// "trusted_orchestrator" is an explicit opt-out used only at a trust boundary.
|
|
420
|
+
export function verifyExternalOutputProof({
|
|
421
|
+
external_output,
|
|
422
|
+
external_output_proof,
|
|
423
|
+
verify = "hash",
|
|
424
|
+
} = {}) {
|
|
425
|
+
if (typeof external_output !== "string" || external_output.length === 0) {
|
|
426
|
+
return { ok: false, code: "E_EXTERNAL_OUTPUT_MISSING" };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (verify === "none") {
|
|
430
|
+
if (external_output_proof?.source !== "trusted_orchestrator") {
|
|
431
|
+
return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_SOURCE_REQUIRED" };
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
audit: {
|
|
436
|
+
source: "trusted_orchestrator",
|
|
437
|
+
verify: "none",
|
|
438
|
+
length: Buffer.byteLength(external_output, "utf-8"),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!external_output_proof || typeof external_output_proof !== "object") {
|
|
444
|
+
return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_MISSING" };
|
|
445
|
+
}
|
|
446
|
+
if (external_output_proof.algo !== "sha256") {
|
|
447
|
+
return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_ALGO_UNSUPPORTED", algo: external_output_proof.algo };
|
|
448
|
+
}
|
|
449
|
+
const expected = String(external_output_proof.digest || "").toLowerCase();
|
|
450
|
+
const actual = crypto.createHash("sha256").update(external_output, "utf-8").digest("hex");
|
|
451
|
+
if (expected !== actual) {
|
|
452
|
+
return { ok: false, code: "E_EXTERNAL_OUTPUT_PROOF_MISMATCH", expected, actual };
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
audit: {
|
|
457
|
+
source: external_output_proof.source || "unspecified",
|
|
458
|
+
verify: "hash",
|
|
459
|
+
digest: actual,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// §2.3 step 5 — reuse the same safeId regex as withSessionLock (lib/speaker-discovery
|
|
465
|
+
// invariant: only a-z A-Z 0-9 가-힣 . _ - survive). Prevents `../`, `/`, and
|
|
466
|
+
// null-byte path traversal when a caller supplies session_id.
|
|
467
|
+
export function sanitizeSessionDirName(sessionId) {
|
|
468
|
+
return String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// §2.3 steps 1-4, 7, 8 — resolve and audit an optional spawn_cwd against a
|
|
472
|
+
// prefix allowlist. Symlinks are allowed only if the realpath still satisfies
|
|
473
|
+
// the prefix check. Caller passes `allowedPrefixes` derived from
|
|
474
|
+
// ~/.config/mcp-deliberation/allowed-cwd-prefixes.json when present; otherwise
|
|
475
|
+
// defaults to $HOME + $TMPDIR so bench harnesses in /tmp work out of the box.
|
|
476
|
+
export function validateSpawnCwd(input, { allowedPrefixes } = {}) {
|
|
477
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
478
|
+
return { ok: false, code: "E_CWD_NOT_ABSOLUTE", input };
|
|
479
|
+
}
|
|
480
|
+
if (!path.isAbsolute(input)) {
|
|
481
|
+
return { ok: false, code: "E_CWD_NOT_ABSOLUTE", input };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let resolved;
|
|
485
|
+
try {
|
|
486
|
+
resolved = fs.realpathSync(input);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
const code = err && err.code === "ENOENT" ? "E_CWD_NOT_FOUND" : "E_CWD_NOT_FOUND";
|
|
489
|
+
return { ok: false, code, input, error: err?.message };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const prefixesUnresolved = Array.isArray(allowedPrefixes) && allowedPrefixes.length > 0
|
|
493
|
+
? allowedPrefixes
|
|
494
|
+
: [os.homedir(), os.tmpdir()];
|
|
495
|
+
|
|
496
|
+
// Resolve the prefixes themselves so callers can pass "/tmp" and still match
|
|
497
|
+
// realpath'd children like "/private/tmp/foo" on macOS.
|
|
498
|
+
const prefixesResolved = prefixesUnresolved.map((p) => {
|
|
499
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
500
|
+
});
|
|
501
|
+
const prefixesAll = Array.from(new Set([...prefixesUnresolved, ...prefixesResolved]));
|
|
502
|
+
|
|
503
|
+
const matches = (candidate, prefixList) =>
|
|
504
|
+
prefixList.some((prefix) =>
|
|
505
|
+
candidate === prefix || candidate.startsWith(prefix + path.sep),
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const inputLooksAllowed = matches(input, prefixesAll);
|
|
509
|
+
const resolvedLooksAllowed = matches(resolved, prefixesResolved);
|
|
510
|
+
const symlinkCrossed = resolved !== input;
|
|
511
|
+
|
|
512
|
+
// §2.3 step 3 — input path itself must be under an allowed prefix, so
|
|
513
|
+
// passing "/etc/passwd" when tmpRoot is allowed immediately fails.
|
|
514
|
+
if (!inputLooksAllowed) {
|
|
515
|
+
return { ok: false, code: "E_CWD_NOT_ALLOWED", input, resolved, allowed_prefixes: prefixesResolved };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// §2.3 step 4 — input looked allowed, but realpath escapes → symlink attack.
|
|
519
|
+
if (!resolvedLooksAllowed) {
|
|
520
|
+
return { ok: false, code: "E_CWD_SYMLINK_ESCAPE", input, resolved, allowed_prefixes: prefixesResolved };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { ok: true, input, resolved, allowed_prefixes: prefixesResolved, symlink_crossed: symlinkCrossed };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// §2.3 step 6 — create a per-session subdir under the resolved cwd so two
|
|
527
|
+
// concurrent cli_auto_turn calls do not share working state. Idempotent when
|
|
528
|
+
// the caller has already pointed at a matching session folder.
|
|
529
|
+
export function ensureSessionSubdir(resolvedCwd, sessionId) {
|
|
530
|
+
let safe = sanitizeSessionDirName(sessionId);
|
|
531
|
+
// `.` and `..` survive the ADR-specified regex but would climb out of
|
|
532
|
+
// resolvedCwd when joined. Rewrite to a literal segment so the subdir is
|
|
533
|
+
// always strictly nested.
|
|
534
|
+
if (safe === "." || safe === "..") {
|
|
535
|
+
safe = `_${safe.length === 2 ? "dotdot" : "dot"}`;
|
|
536
|
+
}
|
|
537
|
+
if (path.basename(resolvedCwd) === safe) {
|
|
538
|
+
return resolvedCwd;
|
|
539
|
+
}
|
|
540
|
+
const target = path.join(resolvedCwd, safe);
|
|
541
|
+
fs.mkdirSync(target, { recursive: true });
|
|
542
|
+
return target;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// §2.4 — normalize observability data into the {value, status} envelope that
|
|
546
|
+
// downstream aggregators skip (status !== "ok") before arithmetic. Fields not
|
|
547
|
+
// provided default to `adapter_missing`.
|
|
548
|
+
const OBS_FIELDS = [
|
|
549
|
+
"tokens_in",
|
|
550
|
+
"tokens_out",
|
|
551
|
+
"estimated_cost_usd",
|
|
552
|
+
"model_reported_by_cli",
|
|
553
|
+
"actual_model_id",
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
export function buildObservabilityEnvelope(adapter) {
|
|
557
|
+
const src = adapter && typeof adapter === "object" ? adapter : {};
|
|
558
|
+
const out = {};
|
|
559
|
+
for (const field of OBS_FIELDS) {
|
|
560
|
+
const entry = src[field];
|
|
561
|
+
if (entry && typeof entry === "object" && "status" in entry) {
|
|
562
|
+
out[field] = { value: entry.value ?? null, status: String(entry.status) };
|
|
563
|
+
} else {
|
|
564
|
+
out[field] = { value: null, status: "adapter_missing" };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function loadAllowedCwdPrefixes() {
|
|
571
|
+
const overridePath = path.join(os.homedir(), ".config", "mcp-deliberation", "allowed-cwd-prefixes.json");
|
|
572
|
+
try {
|
|
573
|
+
const raw = fs.readFileSync(overridePath, "utf-8");
|
|
574
|
+
const parsed = JSON.parse(raw);
|
|
575
|
+
if (parsed && Array.isArray(parsed.prefixes) && parsed.prefixes.every((p) => typeof p === "string")) {
|
|
576
|
+
return parsed.prefixes;
|
|
577
|
+
}
|
|
578
|
+
appendRuntimeLog("WARN", `ALLOWED_CWD_PREFIXES_SCHEMA: ${overridePath} lacks { prefixes: string[] }, falling back to defaults`);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
if (err && err.code !== "ENOENT") {
|
|
581
|
+
appendRuntimeLog("WARN", `ALLOWED_CWD_PREFIXES_LOAD: ${overridePath} parse failed (${err.message}), falling back to defaults`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
|
|
409
587
|
function formatRuntimeError(error) {
|
|
410
588
|
if (error instanceof Error) {
|
|
411
589
|
return error.stack || error.message;
|
|
@@ -413,22 +591,154 @@ function formatRuntimeError(error) {
|
|
|
413
591
|
return String(error);
|
|
414
592
|
}
|
|
415
593
|
|
|
416
|
-
|
|
594
|
+
// ── Runtime log configuration (env-overridable) ────────────────
|
|
595
|
+
const LOG_MAX_SIZE_MB = Number(process.env.DELIBERATION_LOG_MAX_SIZE_MB) > 0
|
|
596
|
+
? Number(process.env.DELIBERATION_LOG_MAX_SIZE_MB)
|
|
597
|
+
: 1;
|
|
598
|
+
const LOG_TOTAL_BUDGET_MB = Number(process.env.DELIBERATION_LOG_TOTAL_BUDGET_MB) > 0
|
|
599
|
+
? Number(process.env.DELIBERATION_LOG_TOTAL_BUDGET_MB)
|
|
600
|
+
: 10;
|
|
601
|
+
const LOG_DEDUP_MS = Number.isFinite(Number(process.env.DELIBERATION_LOG_DEDUP_MS)) && Number(process.env.DELIBERATION_LOG_DEDUP_MS) >= 0
|
|
602
|
+
? Number(process.env.DELIBERATION_LOG_DEDUP_MS)
|
|
603
|
+
: 1000;
|
|
604
|
+
const LOG_HARD_CAP_BYTES = LOG_MAX_SIZE_MB * 2 * 1024 * 1024; // race fallback: 2× per-file threshold
|
|
605
|
+
const LOG_TAIL_BYTES = 500 * 1024; // truncate to last 500 KB on hard-cap overflow
|
|
606
|
+
const LOG_PRE_UPGRADE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
607
|
+
|
|
608
|
+
// Module-level dedup state
|
|
609
|
+
let _dedupLastKey = null;
|
|
610
|
+
let _dedupLastWriteMs = 0;
|
|
611
|
+
let _dedupLastMessage = null;
|
|
612
|
+
let _dedupPendingCount = 0;
|
|
613
|
+
|
|
614
|
+
// Module-level upgrade-safety guard (once per process)
|
|
615
|
+
let _logUpgradeSafetyRan = false;
|
|
616
|
+
|
|
617
|
+
function _flushDedupToFile() {
|
|
618
|
+
if (_dedupPendingCount <= 0) return;
|
|
417
619
|
try {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
620
|
+
const elapsed = Date.now() - _dedupLastWriteMs;
|
|
621
|
+
const summary = `${new Date().toISOString()} [DEDUP] [${_dedupPendingCount}x in ${elapsed}ms] ${_dedupLastMessage || ""}\n`;
|
|
622
|
+
fs.appendFileSync(GLOBAL_RUNTIME_LOG, summary, "utf-8");
|
|
623
|
+
} catch { /* ignore */ }
|
|
624
|
+
_dedupPendingCount = 0;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function _runLogUpgradeSafetyOnce() {
|
|
628
|
+
if (_logUpgradeSafetyRan) return;
|
|
629
|
+
_logUpgradeSafetyRan = true;
|
|
630
|
+
try {
|
|
631
|
+
const dir = path.dirname(GLOBAL_RUNTIME_LOG);
|
|
632
|
+
if (!fs.existsSync(dir)) return;
|
|
633
|
+
const markerPath = path.join(dir, ".log-upgrade-v0.0.45");
|
|
634
|
+
if (!fs.existsSync(markerPath)) {
|
|
635
|
+
let totalSize = 0;
|
|
636
|
+
const candidates = [];
|
|
637
|
+
for (const name of fs.readdirSync(dir)) {
|
|
638
|
+
if (!/^runtime\.log(\.|$)/.test(name)) continue;
|
|
639
|
+
if (name.startsWith("runtime.log.pre-")) continue;
|
|
640
|
+
const p = path.join(dir, name);
|
|
641
|
+
try {
|
|
642
|
+
const s = fs.statSync(p).size;
|
|
643
|
+
totalSize += s;
|
|
644
|
+
candidates.push(p);
|
|
645
|
+
} catch { /* skip */ }
|
|
646
|
+
}
|
|
647
|
+
if (totalSize > 1024 * 1024) {
|
|
648
|
+
const preBackup = GLOBAL_RUNTIME_LOG + ".pre-0.0.45";
|
|
649
|
+
try {
|
|
650
|
+
if (fs.existsSync(GLOBAL_RUNTIME_LOG) && !fs.existsSync(preBackup)) {
|
|
651
|
+
fs.renameSync(GLOBAL_RUNTIME_LOG, preBackup);
|
|
652
|
+
}
|
|
653
|
+
// Any other rotated files (runtime.log.old etc.) — removed so normal
|
|
654
|
+
// rotation can start fresh. The .pre-0.0.45 backup retains the latest.
|
|
655
|
+
for (const p of candidates) {
|
|
656
|
+
if (p === preBackup) continue;
|
|
657
|
+
if (!fs.existsSync(p)) continue;
|
|
658
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
659
|
+
}
|
|
660
|
+
} catch { /* ignore */ }
|
|
661
|
+
}
|
|
662
|
+
try { fs.writeFileSync(markerPath, new Date().toISOString()); } catch { /* ignore */ }
|
|
663
|
+
}
|
|
664
|
+
// Expire .pre-0.0.45 after 7 days OR on budget overflow
|
|
421
665
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
666
|
+
const preBackup = GLOBAL_RUNTIME_LOG + ".pre-0.0.45";
|
|
667
|
+
if (fs.existsSync(preBackup)) {
|
|
668
|
+
const preStat = fs.statSync(preBackup);
|
|
669
|
+
let totalDir = preStat.size;
|
|
670
|
+
try {
|
|
671
|
+
for (const name of fs.readdirSync(dir)) {
|
|
672
|
+
if (!/^runtime\.log(\.|$)/.test(name)) continue;
|
|
673
|
+
if (name === "runtime.log.pre-0.0.45") continue;
|
|
674
|
+
try { totalDir += fs.statSync(path.join(dir, name)).size; } catch { /* skip */ }
|
|
675
|
+
}
|
|
676
|
+
} catch { /* skip */ }
|
|
677
|
+
const age = Date.now() - preStat.mtimeMs;
|
|
678
|
+
if (age > LOG_PRE_UPGRADE_EXPIRY_MS || totalDir > LOG_TOTAL_BUDGET_MB * 1024 * 1024) {
|
|
679
|
+
try { fs.unlinkSync(preBackup); } catch { /* ignore */ }
|
|
427
680
|
}
|
|
428
681
|
}
|
|
429
|
-
} catch { /* ignore
|
|
682
|
+
} catch { /* ignore */ }
|
|
683
|
+
} catch { /* ignore */ }
|
|
684
|
+
}
|
|
430
685
|
|
|
431
|
-
|
|
686
|
+
function _rotateOrTruncate() {
|
|
687
|
+
try {
|
|
688
|
+
if (!fs.existsSync(GLOBAL_RUNTIME_LOG)) return;
|
|
689
|
+
const stats = fs.statSync(GLOBAL_RUNTIME_LOG);
|
|
690
|
+
// Hard cap: if file exceeds 2× per-file threshold (race under concurrent writers),
|
|
691
|
+
// truncate in place to the last LOG_TAIL_BYTES to prevent runaway growth.
|
|
692
|
+
if (stats.size > LOG_HARD_CAP_BYTES) {
|
|
693
|
+
try {
|
|
694
|
+
const fd = fs.openSync(GLOBAL_RUNTIME_LOG, "r");
|
|
695
|
+
try {
|
|
696
|
+
const tailStart = Math.max(0, stats.size - LOG_TAIL_BYTES);
|
|
697
|
+
const buf = Buffer.alloc(stats.size - tailStart);
|
|
698
|
+
fs.readSync(fd, buf, 0, buf.length, tailStart);
|
|
699
|
+
fs.writeFileSync(GLOBAL_RUNTIME_LOG, buf, "utf-8");
|
|
700
|
+
} finally {
|
|
701
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
702
|
+
}
|
|
703
|
+
} catch { /* fall through */ }
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (stats.size > LOG_MAX_SIZE_MB * 1024 * 1024) {
|
|
707
|
+
const oldLog = GLOBAL_RUNTIME_LOG + ".old";
|
|
708
|
+
// Explicit cleanup: delete previous .old before rename (robustness over
|
|
709
|
+
// atomic-rename-overwrite assumption, especially under concurrent writers).
|
|
710
|
+
try {
|
|
711
|
+
if (fs.existsSync(oldLog)) fs.unlinkSync(oldLog);
|
|
712
|
+
} catch { /* ignore */ }
|
|
713
|
+
try { fs.renameSync(GLOBAL_RUNTIME_LOG, oldLog); } catch { /* ignore */ }
|
|
714
|
+
}
|
|
715
|
+
} catch { /* ignore rotation failures */ }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function appendRuntimeLog(level, message) {
|
|
719
|
+
try {
|
|
720
|
+
fs.mkdirSync(path.dirname(GLOBAL_RUNTIME_LOG), { recursive: true });
|
|
721
|
+
_runLogUpgradeSafetyOnce();
|
|
722
|
+
|
|
723
|
+
const safeMessage = String(message ?? "");
|
|
724
|
+
const key = `${level}:${safeMessage.slice(0, 200)}`;
|
|
725
|
+
const now = Date.now();
|
|
726
|
+
|
|
727
|
+
// Dedup: suppress repeated identical messages within window
|
|
728
|
+
if (_dedupLastKey === key && (now - _dedupLastWriteMs) < LOG_DEDUP_MS) {
|
|
729
|
+
_dedupPendingCount += 1;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// New key or window expired — flush prior suppression summary first
|
|
734
|
+
if (_dedupPendingCount > 0) _flushDedupToFile();
|
|
735
|
+
_dedupLastKey = key;
|
|
736
|
+
_dedupLastWriteMs = now;
|
|
737
|
+
_dedupLastMessage = `[${level}] ${safeMessage}`;
|
|
738
|
+
|
|
739
|
+
_rotateOrTruncate();
|
|
740
|
+
|
|
741
|
+
const line = `${new Date(now).toISOString()} [${level}] ${safeMessage}\n`;
|
|
432
742
|
fs.appendFileSync(GLOBAL_RUNTIME_LOG, line, "utf-8");
|
|
433
743
|
} catch {
|
|
434
744
|
// ignore logging failures
|
|
@@ -582,34 +892,48 @@ for (const stream of [process.stdout, process.stderr]) {
|
|
|
582
892
|
});
|
|
583
893
|
}
|
|
584
894
|
|
|
895
|
+
// Module-level reentrance guard. Once a fatal handler has fired, subsequent
|
|
896
|
+
// invocations become no-ops. This breaks the EPIPE self-amplifying loop where
|
|
897
|
+
// writing the previous error's log line itself triggered another EPIPE.
|
|
898
|
+
let _hasHandledFatalError = false;
|
|
899
|
+
|
|
900
|
+
function _isBrokenStdioError(err) {
|
|
901
|
+
if (!err) return false;
|
|
902
|
+
const code = err.code;
|
|
903
|
+
if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED" || code === "ERR_STREAM_WRITE_AFTER_END") return true;
|
|
904
|
+
const message = String(err?.message ?? err ?? "");
|
|
905
|
+
return /EPIPE|write after end/i.test(message);
|
|
906
|
+
}
|
|
907
|
+
|
|
585
908
|
process.on("uncaughtException", (error) => {
|
|
586
|
-
|
|
587
|
-
if (error
|
|
909
|
+
if (_hasHandledFatalError) return;
|
|
910
|
+
if (_isBrokenStdioError(error)) {
|
|
911
|
+
_hasHandledFatalError = true;
|
|
912
|
+
try { _flushDedupToFile(); } catch { /* noop */ }
|
|
588
913
|
try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
|
|
589
|
-
process.exit(0);
|
|
590
|
-
|
|
591
|
-
const message = formatRuntimeError(error);
|
|
592
|
-
appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
|
|
593
|
-
try {
|
|
594
|
-
process.stderr.write(`[mcp-deliberation] uncaughtException: ${message}\n`);
|
|
595
|
-
} catch {
|
|
596
|
-
// ignore stderr write failures
|
|
914
|
+
try { process.exit(0); } catch { /* noop */ }
|
|
915
|
+
return;
|
|
597
916
|
}
|
|
917
|
+
// Non-stdio fatal: log to file only. process.stderr.write was REMOVED here
|
|
918
|
+
// because it was the re-trigger source when stdio was the broken channel.
|
|
919
|
+
try { appendRuntimeLog("UNCAUGHT_EXCEPTION", formatRuntimeError(error)); } catch { /* noop */ }
|
|
598
920
|
});
|
|
599
921
|
|
|
600
922
|
process.on("unhandledRejection", (reason) => {
|
|
601
|
-
|
|
602
|
-
if (reason
|
|
923
|
+
if (_hasHandledFatalError) return;
|
|
924
|
+
if (_isBrokenStdioError(reason)) {
|
|
925
|
+
_hasHandledFatalError = true;
|
|
926
|
+
try { _flushDedupToFile(); } catch { /* noop */ }
|
|
603
927
|
try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
|
|
604
|
-
process.exit(0);
|
|
605
|
-
|
|
606
|
-
const message = formatRuntimeError(reason);
|
|
607
|
-
appendRuntimeLog("UNHANDLED_REJECTION", message);
|
|
608
|
-
try {
|
|
609
|
-
process.stderr.write(`[mcp-deliberation] unhandledRejection: ${message}\n`);
|
|
610
|
-
} catch {
|
|
611
|
-
// ignore stderr write failures
|
|
928
|
+
try { process.exit(0); } catch { /* noop */ }
|
|
929
|
+
return;
|
|
612
930
|
}
|
|
931
|
+
try { appendRuntimeLog("UNHANDLED_REJECTION", formatRuntimeError(reason)); } catch { /* noop */ }
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Flush any pending dedup summary on graceful exit so the tail summary is not lost
|
|
935
|
+
process.on("exit", () => {
|
|
936
|
+
try { _flushDedupToFile(); } catch { /* noop */ }
|
|
613
937
|
});
|
|
614
938
|
|
|
615
939
|
// Read version from package.json (single source of truth)
|
|
@@ -783,11 +1107,21 @@ server.tool(
|
|
|
783
1107
|
const manualSpeakersProvided = Array.isArray(speakers) && speakers.length > 0;
|
|
784
1108
|
let selectionValidation = { ok: true };
|
|
785
1109
|
if (effectiveRequireManual && manualSpeakersProvided) {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1110
|
+
// ADR-264 §2.1 — read-validate-consume wrapped in withProjectLock so two
|
|
1111
|
+
// MCP server processes racing on the same speaker-selection.json cannot
|
|
1112
|
+
// both observe the same token as unconsumed.
|
|
1113
|
+
selectionValidation = withProjectLock(getProjectSlug(), () => {
|
|
1114
|
+
const persistedState = loadSpeakerSelectionToken();
|
|
1115
|
+
const result = validateSpeakerSelectionRequest({
|
|
1116
|
+
selectionState: persistedState,
|
|
1117
|
+
selection_token,
|
|
1118
|
+
speakers,
|
|
1119
|
+
includeBrowserSpeakers,
|
|
1120
|
+
});
|
|
1121
|
+
if (result.ok) {
|
|
1122
|
+
markSelectionTokenConsumed({ selectionState: persistedState });
|
|
1123
|
+
}
|
|
1124
|
+
return result;
|
|
791
1125
|
});
|
|
792
1126
|
}
|
|
793
1127
|
const hasManualSpeakers = manualSpeakersProvided && (!effectiveRequireManual || selectionValidation.ok);
|
|
@@ -855,9 +1189,10 @@ server.tool(
|
|
|
855
1189
|
|| DEFAULT_SPEAKERS[0];
|
|
856
1190
|
let speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
|
|
857
1191
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1192
|
+
// ADR-264 §2.1 — selection token has already been stamped `consumed_at`
|
|
1193
|
+
// inside withProjectLock above. Leaving the file in place with the tombstone
|
|
1194
|
+
// lets any racing caller surface `token_already_consumed` instead of the
|
|
1195
|
+
// more ambiguous `missing_selection_state`. TTL garbage-collects it.
|
|
861
1196
|
|
|
862
1197
|
// Lite mode: cap speakers and rounds for quick decisions
|
|
863
1198
|
if (mode === "lite") {
|
|
@@ -1020,9 +1355,21 @@ server.tool(
|
|
|
1020
1355
|
},
|
|
1021
1356
|
async ({ include_cli, include_browser }) => {
|
|
1022
1357
|
const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1358
|
+
// ADR-264 §2.1 — issue inside withProjectLock and warn when overwriting a
|
|
1359
|
+
// confirmed-but-not-consumed state so racing consumers cannot silently
|
|
1360
|
+
// invalidate a token already promised to a start call in flight.
|
|
1361
|
+
const selection = withProjectLock(getProjectSlug(), () => {
|
|
1362
|
+
const existing = loadSpeakerSelectionToken();
|
|
1363
|
+
if (existing?.phase === "confirmed" && !existing.consumed_at) {
|
|
1364
|
+
appendRuntimeLog(
|
|
1365
|
+
"WARN",
|
|
1366
|
+
`SELECTION_OVERWRITE_CONFIRMED: project=${getProjectSlug()} | token=${existing.token} | selected=${(existing.selected_speakers || []).join(",")}`,
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
return issueSpeakerSelectionToken({
|
|
1370
|
+
candidates: snapshot.candidates,
|
|
1371
|
+
include_browser,
|
|
1372
|
+
});
|
|
1026
1373
|
});
|
|
1027
1374
|
const text = formatSpeakerCandidatesReport(snapshot);
|
|
1028
1375
|
return {
|
|
@@ -1493,8 +1840,14 @@ server.tool(
|
|
|
1493
1840
|
{
|
|
1494
1841
|
session_id: z.string().optional().describe("Session ID (required if multiple sessions are active)"),
|
|
1495
1842
|
timeout_sec: z.number().optional().default(120).describe("CLI response wait timeout (seconds)"),
|
|
1843
|
+
// ADR-264 §2.3 — optional working directory for the spawned CLI. Validated
|
|
1844
|
+
// against an allowlist (default: $HOME + $TMPDIR; override via
|
|
1845
|
+
// ~/.config/mcp-deliberation/allowed-cwd-prefixes.json). The server creates
|
|
1846
|
+
// a session-unique subdirectory under the resolved cwd so concurrent
|
|
1847
|
+
// cli_auto_turn calls do not share working files.
|
|
1848
|
+
spawn_cwd: z.string().optional().describe("Absolute path under an allowed prefix to use as CLI working directory. Used to isolate project CLAUDE.md/GEMINI.md context from the server's cwd."),
|
|
1496
1849
|
},
|
|
1497
|
-
safeToolHandler("deliberation_cli_auto_turn", async ({ session_id, timeout_sec }) => {
|
|
1850
|
+
safeToolHandler("deliberation_cli_auto_turn", async ({ session_id, timeout_sec, spawn_cwd }) => {
|
|
1498
1851
|
const resolved = resolveSessionId(session_id);
|
|
1499
1852
|
if (!resolved) {
|
|
1500
1853
|
return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
|
|
@@ -1555,6 +1908,34 @@ server.tool(
|
|
|
1555
1908
|
priorTurns: speakerPriorTurns,
|
|
1556
1909
|
});
|
|
1557
1910
|
|
|
1911
|
+
// ADR-264 §2.3 — validate optional spawn_cwd and allocate a session-unique
|
|
1912
|
+
// subdirectory before we spawn the child. Structured error codes (E_CWD_*)
|
|
1913
|
+
// surface via the return envelope so orchestrators can diagnose misuse.
|
|
1914
|
+
let resolvedSpawnCwd;
|
|
1915
|
+
if (typeof spawn_cwd === "string" && spawn_cwd.length > 0) {
|
|
1916
|
+
const override = loadAllowedCwdPrefixes();
|
|
1917
|
+
const cwdCheck = validateSpawnCwd(spawn_cwd, override ? { allowedPrefixes: override } : undefined);
|
|
1918
|
+
if (!cwdCheck.ok) {
|
|
1919
|
+
appendRuntimeLog(
|
|
1920
|
+
"WARN",
|
|
1921
|
+
`SPAWN_CWD_REJECTED: ${resolved} | speaker=${speaker} | code=${cwdCheck.code} | input=${cwdCheck.input} | resolved=${cwdCheck.resolved || "n/a"}`,
|
|
1922
|
+
);
|
|
1923
|
+
return {
|
|
1924
|
+
content: [{
|
|
1925
|
+
type: "text",
|
|
1926
|
+
text: `❌ **spawn_cwd rejected**: \`${cwdCheck.code}\`\n\nInput: \`${cwdCheck.input}\`\nResolved: \`${cwdCheck.resolved || "n/a"}\`\nAllowed prefixes: ${(cwdCheck.allowed_prefixes || []).map((p) => `\`${p}\``).join(", ") || "(none)"}\n\nProvide an absolute path under one of the allowed prefixes, or configure \`~/.config/mcp-deliberation/allowed-cwd-prefixes.json\`.`,
|
|
1927
|
+
}],
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
if (cwdCheck.symlink_crossed) {
|
|
1931
|
+
appendRuntimeLog(
|
|
1932
|
+
"WARN",
|
|
1933
|
+
`SPAWN_CWD_SYMLINK: ${resolved} | speaker=${speaker} | input=${cwdCheck.input} | resolved=${cwdCheck.resolved}`,
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
resolvedSpawnCwd = ensureSessionSubdir(cwdCheck.resolved, resolved);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1558
1939
|
// Spawn CLI process
|
|
1559
1940
|
const startTime = Date.now();
|
|
1560
1941
|
try {
|
|
@@ -1564,6 +1945,8 @@ server.tool(
|
|
|
1564
1945
|
if (hint.envPrefix?.includes("CLAUDECODE=")) {
|
|
1565
1946
|
delete env.CLAUDECODE;
|
|
1566
1947
|
}
|
|
1948
|
+
const spawnOpts = { env, windowsHide: true };
|
|
1949
|
+
if (resolvedSpawnCwd) spawnOpts.cwd = resolvedSpawnCwd;
|
|
1567
1950
|
|
|
1568
1951
|
let child;
|
|
1569
1952
|
let stdout = "";
|
|
@@ -1587,22 +1970,22 @@ server.tool(
|
|
|
1587
1970
|
// Different invocation patterns per CLI
|
|
1588
1971
|
switch (speaker) {
|
|
1589
1972
|
case "claude":
|
|
1590
|
-
child = spawn("claude", getCliExecArgs("claude"),
|
|
1973
|
+
child = spawn("claude", getCliExecArgs("claude"), spawnOpts);
|
|
1591
1974
|
child.stdin.write(turnPrompt);
|
|
1592
1975
|
child.stdin.end();
|
|
1593
1976
|
break;
|
|
1594
1977
|
case "codex":
|
|
1595
|
-
child = spawn("codex", getCliExecArgs("codex"),
|
|
1978
|
+
child = spawn("codex", getCliExecArgs("codex"), spawnOpts);
|
|
1596
1979
|
child.stdin.write(turnPrompt);
|
|
1597
1980
|
child.stdin.end();
|
|
1598
1981
|
break;
|
|
1599
1982
|
case "gemini":
|
|
1600
|
-
child = spawn("gemini", ["-p", turnPrompt],
|
|
1983
|
+
child = spawn("gemini", ["-p", turnPrompt], spawnOpts);
|
|
1601
1984
|
break;
|
|
1602
1985
|
default: {
|
|
1603
1986
|
// Generic: try command with prompt as argument
|
|
1604
1987
|
const flags = hint.flags ? hint.flags.split(/\s+/) : [];
|
|
1605
|
-
child = spawn(hint.cmd, [...flags, turnPrompt],
|
|
1988
|
+
child = spawn(hint.cmd, [...flags, turnPrompt], spawnOpts);
|
|
1606
1989
|
break;
|
|
1607
1990
|
}
|
|
1608
1991
|
}
|
|
@@ -1682,11 +2065,26 @@ server.tool(
|
|
|
1682
2065
|
fallback_reason: null,
|
|
1683
2066
|
});
|
|
1684
2067
|
|
|
2068
|
+
// ADR-264 §2.4 — Phase 0 observability envelope. Per-CLI adapters
|
|
2069
|
+
// (claude/codex/gemini) that parse usage from stdout are a follow-up;
|
|
2070
|
+
// until then every field reports `adapter_missing` so downstream
|
|
2071
|
+
// aggregators can skip non-`ok` entries without NaN propagation.
|
|
2072
|
+
const observability = buildObservabilityEnvelope(null);
|
|
2073
|
+
|
|
1685
2074
|
return {
|
|
1686
2075
|
content: [{
|
|
1687
2076
|
type: "text",
|
|
1688
|
-
text: `✅ CLI auto-turn complete!\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n**Turn ID:** ${turnId}\n**Response length:** ${response.length} chars\n**Elapsed:** ${elapsedMs}ms\n\n${result.content[0].text}`,
|
|
2077
|
+
text: `✅ CLI auto-turn complete!\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n**Turn ID:** ${turnId}\n**Response length:** ${response.length} chars\n**Elapsed:** ${elapsedMs}ms${resolvedSpawnCwd ? `\n**spawn_cwd:** \`${resolvedSpawnCwd}\`` : ""}\n\n**Observability (ADR-264 §2.4, Phase 0):**\n- tokens_in: ${observability.tokens_in.status}\n- tokens_out: ${observability.tokens_out.status}\n- estimated_cost_usd: ${observability.estimated_cost_usd.status}\n- model_reported_by_cli: ${observability.model_reported_by_cli.status}\n- actual_model_id: ${observability.actual_model_id.status}\n\n${result.content[0].text}`,
|
|
1689
2078
|
}],
|
|
2079
|
+
structuredContent: {
|
|
2080
|
+
speaker,
|
|
2081
|
+
cli: hint.cmd,
|
|
2082
|
+
turn_id: turnId,
|
|
2083
|
+
response_length: response.length,
|
|
2084
|
+
elapsed_ms: elapsedMs,
|
|
2085
|
+
spawn_cwd: resolvedSpawnCwd ?? null,
|
|
2086
|
+
...observability,
|
|
2087
|
+
},
|
|
1690
2088
|
};
|
|
1691
2089
|
|
|
1692
2090
|
} catch (err) {
|
|
@@ -1769,10 +2167,22 @@ server.tool(
|
|
|
1769
2167
|
use_clipboard: z.boolean().optional().describe("Read content from system clipboard (alternative to content/content_file)"),
|
|
1770
2168
|
include_clipboard_image: z.boolean().optional().describe("Capture and include image from system clipboard"),
|
|
1771
2169
|
turn_id: z.string().optional().describe("Turn verification ID (value received from deliberation_route_turn)"),
|
|
2170
|
+
// ADR-264 §2.2 — optional proxy submission. An orchestrator that pre-spawned
|
|
2171
|
+
// the CLI/browser itself can submit the collected response here instead of
|
|
2172
|
+
// asking the server to re-run the CLI. Default behavior (fields absent) is
|
|
2173
|
+
// unchanged: proxy is blocked.
|
|
2174
|
+
external_output: z.string().optional().describe("Pre-collected response body that the orchestrator obtained from a CLI/browser it spawned itself. Requires external_output_proof."),
|
|
2175
|
+
external_output_proof: z.object({
|
|
2176
|
+
algo: z.literal("sha256"),
|
|
2177
|
+
digest: z.string().describe("Lowercase hex sha256 digest of external_output bytes."),
|
|
2178
|
+
source: z.enum(["cli_stdout", "browser_dom", "trusted_orchestrator"]),
|
|
2179
|
+
}).optional().describe("Proof payload for external_output. sha256 of the raw UTF-8 bytes."),
|
|
2180
|
+
verify: z.enum(["hash", "none"]).optional().default("hash").describe("Proof verification mode. 'none' requires source=trusted_orchestrator and logs an explicit audit entry."),
|
|
1772
2181
|
},
|
|
1773
|
-
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, use_clipboard, include_clipboard_image, turn_id }) => {
|
|
2182
|
+
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, use_clipboard, include_clipboard_image, turn_id, external_output, external_output_proof, verify }) => {
|
|
1774
2183
|
// Guard: prevent orchestrator from fabricating responses for CLI/browser speakers
|
|
1775
2184
|
const resolved = resolveSessionId(session_id);
|
|
2185
|
+
let externalAcceptedContent = null;
|
|
1776
2186
|
if (resolved && resolved !== "MULTIPLE") {
|
|
1777
2187
|
const state = loadSession(resolved);
|
|
1778
2188
|
if (state) {
|
|
@@ -1782,22 +2192,55 @@ server.tool(
|
|
|
1782
2192
|
const callerSpeaker = detectCallerSpeaker();
|
|
1783
2193
|
const callerIsSpeaker = callerSpeaker && (speaker === callerSpeaker);
|
|
1784
2194
|
if (!callerIsSpeaker) {
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2195
|
+
// ADR-264 §2.2 — accept pre-collected external_output when a proof
|
|
2196
|
+
// is supplied. Failed verification surfaces a structured error code
|
|
2197
|
+
// instead of falling back to the opaque "proxy blocked" message.
|
|
2198
|
+
if (typeof external_output === "string" && external_output.length > 0) {
|
|
2199
|
+
const proof = verifyExternalOutputProof({ external_output, external_output_proof, verify });
|
|
2200
|
+
if (proof.ok) {
|
|
2201
|
+
externalAcceptedContent = external_output;
|
|
2202
|
+
appendRuntimeLog(
|
|
2203
|
+
"INFO",
|
|
2204
|
+
`EXTERNAL_OUTPUT_ACCEPTED: ${resolved} | speaker=${speaker} | transport=${transport} | source=${proof.audit?.source} | verify=${proof.audit?.verify} | len=${external_output.length}`,
|
|
2205
|
+
);
|
|
2206
|
+
} else {
|
|
2207
|
+
const digestDetail = proof.expected
|
|
2208
|
+
? `\n\nExpected digest: ${proof.expected}\nActual digest: ${proof.actual}`
|
|
2209
|
+
: "";
|
|
2210
|
+
appendRuntimeLog(
|
|
2211
|
+
"WARN",
|
|
2212
|
+
`EXTERNAL_OUTPUT_REJECTED: ${resolved} | speaker=${speaker} | code=${proof.code}${proof.expected ? ` | expected=${proof.expected} | actual=${proof.actual}` : ""}`,
|
|
2213
|
+
);
|
|
2214
|
+
return {
|
|
2215
|
+
content: [{
|
|
2216
|
+
type: "text",
|
|
2217
|
+
text: t(
|
|
2218
|
+
`⚠️ **Proxy response rejected**: \`${proof.code}\`\n\nThe supplied external_output did not pass verification (verify=\"${verify || "hash"}\").${digestDetail}\n\nSupply a correct sha256 proof, or call \`deliberation_cli_auto_turn\` / \`deliberation_browser_auto_turn\` to let the server run the transport.`,
|
|
2219
|
+
`⚠️ **대리 응답 거부**: \`${proof.code}\`\n\n제공된 external_output 검증 실패 (verify=\"${verify || "hash"}\").${digestDetail}\n\n올바른 sha256 proof를 제공하거나, \`deliberation_cli_auto_turn\` / \`deliberation_browser_auto_turn\` 으로 서버가 직접 transport를 실행하게 하세요.`,
|
|
2220
|
+
state?.lang),
|
|
2221
|
+
}],
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
} else {
|
|
2225
|
+
return {
|
|
2226
|
+
content: [{
|
|
2227
|
+
type: "text",
|
|
2228
|
+
text: t(
|
|
2229
|
+
`⚠️ **Proxy response blocked**: Speaker "${speaker}" has ${transport} transport.\n\nThe orchestrator is not allowed to write responses on behalf of other speakers.\nUse the following tools instead:\n- CLI speaker → \`deliberation_route_turn\` or \`deliberation_cli_auto_turn\`\n- Browser speaker → \`deliberation_route_turn\` or \`deliberation_browser_auto_turn\`\n\nThese tools run the actual CLI/browser to collect genuine responses.\n\nAlternatively, the orchestrator can submit a response it spawned itself via \`external_output\` + \`external_output_proof\` (sha256 hex of the UTF-8 bytes).`,
|
|
2230
|
+
`⚠️ **대리 응답 차단**: speaker "${speaker}"는 ${transport} transport입니다.\n\n오케스트레이터가 다른 speaker를 대신하여 응답을 작성하는 것은 허용되지 않습니다.\n대신 다음 도구를 사용하세요:\n- CLI speaker → \`deliberation_route_turn\` 또는 \`deliberation_cli_auto_turn\`\n- 브라우저 speaker → \`deliberation_route_turn\` 또는 \`deliberation_browser_auto_turn\`\n\n이 도구들이 실제 CLI/브라우저를 실행하여 진짜 응답을 수집합니다.\n\n또는 오케스트레이터가 직접 spawn한 결과는 \`external_output\` + \`external_output_proof\` (sha256 hex) 로 제출할 수 있습니다.`,
|
|
2231
|
+
state?.lang),
|
|
2232
|
+
}],
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
1794
2235
|
}
|
|
1795
2236
|
}
|
|
1796
2237
|
}
|
|
1797
2238
|
}
|
|
1798
2239
|
|
|
1799
2240
|
// Support reading content from file or clipboard to avoid JSON escaping issues
|
|
1800
|
-
|
|
2241
|
+
// ADR-264 §2.2 — external_output (when verified) takes precedence so proxy
|
|
2242
|
+
// submissions don't conflict with stale content/content_file values.
|
|
2243
|
+
let finalContent = externalAcceptedContent ?? content;
|
|
1801
2244
|
if (use_clipboard && !content) {
|
|
1802
2245
|
try {
|
|
1803
2246
|
finalContent = readClipboardText();
|
|
@@ -2117,6 +2560,27 @@ server.tool(
|
|
|
2117
2560
|
}
|
|
2118
2561
|
|
|
2119
2562
|
appendRuntimeLog("INFO", `SYNTHESIZED: ${resolved} | turns: ${state.log.length} | rounds: ${state.max_rounds}`);
|
|
2563
|
+
emitSynthesisEvent(
|
|
2564
|
+
{
|
|
2565
|
+
session: resolved,
|
|
2566
|
+
turns: state.log.length,
|
|
2567
|
+
max_rounds: state.max_rounds,
|
|
2568
|
+
speakers: state.speakers || [],
|
|
2569
|
+
auto_execute: !!state.auto_execute,
|
|
2570
|
+
has_structured: !!structured,
|
|
2571
|
+
},
|
|
2572
|
+
resolved,
|
|
2573
|
+
);
|
|
2574
|
+
if (state.execution_contract) {
|
|
2575
|
+
emitHandoffEvent(
|
|
2576
|
+
{
|
|
2577
|
+
session: resolved,
|
|
2578
|
+
tasks_total: state.execution_contract?.tasks?.length ?? 0,
|
|
2579
|
+
auto_execute: !!state.auto_execute,
|
|
2580
|
+
},
|
|
2581
|
+
resolved,
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2120
2584
|
const synthesisEnvelope = buildTeleptySynthesisEnvelope({
|
|
2121
2585
|
state,
|
|
2122
2586
|
synthesis,
|
|
@@ -3109,4 +3573,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
|
|
|
3109
3573
|
}
|
|
3110
3574
|
|
|
3111
3575
|
// ── Test exports (used by vitest) ──
|
|
3112
|
-
export { checkToolEntitlement, selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
|
|
3576
|
+
export { appendRuntimeLog, _flushDedupToFile, _isBrokenStdioError, checkToolEntitlement, selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, markSelectionTokenConsumed, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
|
package/install.js
CHANGED
package/lib/session.js
CHANGED
|
@@ -30,6 +30,9 @@ import {
|
|
|
30
30
|
normalizeSessionActors,
|
|
31
31
|
} from "./speaker-discovery.js";
|
|
32
32
|
import { t } from "../i18n.js";
|
|
33
|
+
// δ2 (#440) — telemetry emit wrapper. emit-skip-with-warning when role
|
|
34
|
+
// is unset; failures swallowed; never blocks the turn-submission path.
|
|
35
|
+
import { emitTurnEvent } from "../logger-emit.js";
|
|
33
36
|
|
|
34
37
|
// ── Dependency injection ────────────────────────────────────────
|
|
35
38
|
// Functions that live in index.js but are needed here. Injected once
|
|
@@ -633,6 +636,18 @@ export function submitDeliberationTurn({ session_id, speaker, content, turn_id,
|
|
|
633
636
|
});
|
|
634
637
|
|
|
635
638
|
if (completionState && completionEntry) {
|
|
639
|
+
emitTurnEvent(
|
|
640
|
+
"turn_complete",
|
|
641
|
+
{
|
|
642
|
+
session: completionState.id,
|
|
643
|
+
speaker: completionEntry.speaker,
|
|
644
|
+
round: completionEntry.round,
|
|
645
|
+
max_rounds: completionState.max_rounds,
|
|
646
|
+
next_speaker: completionState.current_speaker,
|
|
647
|
+
votes_count: Array.isArray(completionEntry.votes) ? completionEntry.votes.length : 0,
|
|
648
|
+
},
|
|
649
|
+
completionEntry.turn_id || undefined,
|
|
650
|
+
);
|
|
636
651
|
const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
|
|
637
652
|
notifyTeleptyBus(envelope).catch(() => {});
|
|
638
653
|
|
package/lib/speaker-discovery.js
CHANGED
|
@@ -106,6 +106,8 @@ export const CLI_INVOCATION_HINTS = {
|
|
|
106
106
|
claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', defaultModel: null, provider: 'claude' },
|
|
107
107
|
codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '-m', defaultModel: 'gpt-5.4', provider: 'chatgpt' },
|
|
108
108
|
gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '-m', defaultModel: null, provider: 'gemini' },
|
|
109
|
+
grok: { cmd: "grok", flags: '-p', example: 'grok -p "prompt"', modelFlag: '-m', defaultModel: null, provider: 'grok' },
|
|
110
|
+
agy: { cmd: "agy", flags: '-p', example: 'agy -p "prompt"', modelFlag: '--model', defaultModel: null, provider: 'gemini' },
|
|
109
111
|
aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
|
|
110
112
|
cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
|
|
111
113
|
};
|
|
@@ -379,6 +381,12 @@ export function validateSpeakerSelectionSnapshot({ selectionState, selection_tok
|
|
|
379
381
|
return { ok: false, code: "token_mismatch" };
|
|
380
382
|
}
|
|
381
383
|
|
|
384
|
+
// ADR-264 §2.1 — token_already_consumed precedes TTL/mode checks so the
|
|
385
|
+
// caller can distinguish "was already used" from "expired" or "wrong mode".
|
|
386
|
+
if (selectionState.consumed_at) {
|
|
387
|
+
return { ok: false, code: "token_already_consumed", consumed_at: selectionState.consumed_at };
|
|
388
|
+
}
|
|
389
|
+
|
|
382
390
|
const createdAtMs = Date.parse(selectionState.created_at || "");
|
|
383
391
|
if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
|
|
384
392
|
return { ok: false, code: "expired_token" };
|
|
@@ -424,6 +432,21 @@ export function confirmSpeakerSelectionToken({ selectionState, selection_token,
|
|
|
424
432
|
return { ok: true, selectionState: confirmedSelection };
|
|
425
433
|
}
|
|
426
434
|
|
|
435
|
+
// ADR-264 §2.1 — atomic consume: stamp `consumed_at` onto the confirmed state
|
|
436
|
+
// file so cross-process races surface as `token_already_consumed` instead of
|
|
437
|
+
// allowing silent double-start. Callers MUST wrap the read+validate+mark
|
|
438
|
+
// sequence in `withProjectLock` (index.js) to be cross-process safe.
|
|
439
|
+
export function markSelectionTokenConsumed({ selectionState, nowMs = Date.now(), persist = true }) {
|
|
440
|
+
if (!selectionState || typeof selectionState !== "object") {
|
|
441
|
+
return selectionState;
|
|
442
|
+
}
|
|
443
|
+
const consumed = { ...selectionState, consumed_at: new Date(nowMs).toISOString() };
|
|
444
|
+
if (persist) {
|
|
445
|
+
_deps.writeJsonFileAtomic(_deps.getSpeakerSelectionFile(), consumed);
|
|
446
|
+
}
|
|
447
|
+
return consumed;
|
|
448
|
+
}
|
|
449
|
+
|
|
427
450
|
export function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
|
|
428
451
|
const snapshotValidation = validateSpeakerSelectionSnapshot({
|
|
429
452
|
selectionState,
|
package/lib/transport.js
CHANGED
|
@@ -89,6 +89,59 @@ export function tmuxWindowName(sessionId) {
|
|
|
89
89
|
return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// #610 — Resolve the tmux window TARGET for select-window by INDEX rather than by
|
|
93
|
+
// name. Window names may contain Hangul (가-힣, see tmuxWindowName), and when the
|
|
94
|
+
// select-window target string is injected through the
|
|
95
|
+
// osascript -> Terminal.app -> zsh -> tmux path the non-ASCII bytes get corrupted,
|
|
96
|
+
// so `select-window -t "deliberation:<corrupted>"` never matches. The window INDEX
|
|
97
|
+
// is pure ASCII digits and survives that path intact. node reads window names back
|
|
98
|
+
// correctly (no osascript layer), so we map name -> index here.
|
|
99
|
+
|
|
100
|
+
// Pure: pick the index whose window name matches `windowName` from the raw output
|
|
101
|
+
// of `tmux list-windows -F '#{window_index}\t#{window_name}'`. Returns null when
|
|
102
|
+
// the window is not present.
|
|
103
|
+
export function parseTmuxWindowIndex(listWindowsOutput, windowName) {
|
|
104
|
+
for (const line of String(listWindowsOutput).split("\n")) {
|
|
105
|
+
const tabIdx = line.indexOf("\t");
|
|
106
|
+
if (tabIdx === -1) continue;
|
|
107
|
+
const index = line.slice(0, tabIdx).trim();
|
|
108
|
+
const name = line.slice(tabIdx + 1).trim();
|
|
109
|
+
if (name === windowName && /^\d+$/.test(index)) {
|
|
110
|
+
return Number.parseInt(index, 10);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Pure: build the select-window target ("deliberation:<index>") from a session id
|
|
117
|
+
// and the raw list-windows output. Falls back to the window name when the window
|
|
118
|
+
// is not yet listed (no regression vs. prior name-based behavior).
|
|
119
|
+
export function buildTmuxAttachTarget(sessionId, listWindowsOutput) {
|
|
120
|
+
const winName = tmuxWindowName(sessionId);
|
|
121
|
+
const index = parseTmuxWindowIndex(listWindowsOutput, winName);
|
|
122
|
+
const suffix = index !== null ? String(index) : winName;
|
|
123
|
+
return `${TMUX_SESSION}:${suffix}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Impure wrapper: read the current tmux window listing. Empty string on failure
|
|
127
|
+
// so buildTmuxAttachTarget falls back to the window name.
|
|
128
|
+
export function listTmuxWindowsRaw(sessionName) {
|
|
129
|
+
try {
|
|
130
|
+
return execFileSync(
|
|
131
|
+
"tmux",
|
|
132
|
+
["list-windows", "-t", sessionName, "-F", "#{window_index}\t#{window_name}"],
|
|
133
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolve the live select-window target for a session's monitor window.
|
|
141
|
+
export function resolveTmuxWindowTarget(sessionId) {
|
|
142
|
+
return buildTmuxAttachTarget(sessionId, listTmuxWindowsRaw(TMUX_SESSION));
|
|
143
|
+
}
|
|
144
|
+
|
|
92
145
|
export function appleScriptQuote(value) {
|
|
93
146
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
94
147
|
}
|
|
@@ -187,10 +240,11 @@ export function tmuxWindowCount(name) {
|
|
|
187
240
|
}
|
|
188
241
|
|
|
189
242
|
export function buildTmuxAttachCommand(sessionId) {
|
|
190
|
-
|
|
243
|
+
// #610 — target the window by INDEX (ASCII-safe), not by the possibly-Hangul name.
|
|
244
|
+
const winTarget = resolveTmuxWindowTarget(sessionId);
|
|
191
245
|
// Use grouped session (new-session -t) so each terminal has independent active window.
|
|
192
246
|
// This prevents window-switching conflicts when multiple deliberations run concurrently.
|
|
193
|
-
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(
|
|
247
|
+
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(winTarget)}`;
|
|
194
248
|
}
|
|
195
249
|
|
|
196
250
|
export function listPhysicalTerminalWindowIds() {
|
|
@@ -231,8 +285,12 @@ export function listPhysicalTerminalWindowIds() {
|
|
|
231
285
|
|
|
232
286
|
export function openPhysicalTerminal(sessionId) {
|
|
233
287
|
const winName = tmuxWindowName(sessionId);
|
|
288
|
+
// #610 — target the window by INDEX (ASCII-safe across the osascript path), not
|
|
289
|
+
// by the possibly-Hangul name. winName is still used below for node-side window
|
|
290
|
+
// matching (isTmuxWindowViewed / logging), which reads names correctly.
|
|
291
|
+
const winTarget = resolveTmuxWindowTarget(sessionId);
|
|
234
292
|
// Use grouped session (new-session -t) for independent active window per client
|
|
235
|
-
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${
|
|
293
|
+
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${winTarget}"`;
|
|
236
294
|
|
|
237
295
|
// Prevent duplicate windows for the SAME session:
|
|
238
296
|
// If a client is already viewing this specific window, just activate Terminal.app
|
|
@@ -250,7 +308,7 @@ export function openPhysicalTerminal(sessionId) {
|
|
|
250
308
|
// instead of select-window (which would hijack all attached clients' views).
|
|
251
309
|
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
252
310
|
if (process.platform === "darwin") {
|
|
253
|
-
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${
|
|
311
|
+
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${winTarget}"`;
|
|
254
312
|
try {
|
|
255
313
|
execFileSync(
|
|
256
314
|
"osascript",
|
|
@@ -780,7 +838,23 @@ export async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
|
|
|
780
838
|
channel_used: "cli_auto",
|
|
781
839
|
});
|
|
782
840
|
|
|
783
|
-
|
|
841
|
+
// ADR-264 §2.4 — Phase 0 observability: return envelope-ready fields so
|
|
842
|
+
// callers that aggregate results across transports see a uniform shape.
|
|
843
|
+
// CLI-specific adapters (claude/codex/gemini) are a follow-up; for now
|
|
844
|
+
// every field reports `adapter_missing` and downstream aggregators MUST
|
|
845
|
+
// skip entries where status !== "ok" before arithmetic.
|
|
846
|
+
return {
|
|
847
|
+
ok: true,
|
|
848
|
+
response,
|
|
849
|
+
elapsedMs: Date.now() - startTime,
|
|
850
|
+
observability: {
|
|
851
|
+
tokens_in: { value: null, status: "adapter_missing" },
|
|
852
|
+
tokens_out: { value: null, status: "adapter_missing" },
|
|
853
|
+
estimated_cost_usd: { value: null, status: "adapter_missing" },
|
|
854
|
+
model_reported_by_cli: { value: null, status: "adapter_missing" },
|
|
855
|
+
actual_model_id: { value: null, status: "adapter_missing" },
|
|
856
|
+
},
|
|
857
|
+
};
|
|
784
858
|
} catch (err) {
|
|
785
859
|
return { ok: false, error: err.message };
|
|
786
860
|
}
|
|
@@ -1128,6 +1202,22 @@ export async function runAutoHandoff(sessionId) {
|
|
|
1128
1202
|
const retryConfig = { maxRetries: 2, retryDelayMs: 10000 };
|
|
1129
1203
|
|
|
1130
1204
|
try {
|
|
1205
|
+
// Pre-flight: if every speaker matches the orchestrator's CLI identity, there is
|
|
1206
|
+
// no one to auto-dispatch. Halt Phase 1 and skip Phase 2 synthesis so the session
|
|
1207
|
+
// remains `active` and the orchestrator can provide turns manually.
|
|
1208
|
+
{
|
|
1209
|
+
const initialState = loadSession(sessionId);
|
|
1210
|
+
const callerId = detectCallerSpeaker();
|
|
1211
|
+
if (initialState && callerId && Array.isArray(initialState.speakers) && initialState.speakers.length > 0) {
|
|
1212
|
+
const normalizedCaller = normalizeSpeaker(callerId);
|
|
1213
|
+
const allSelf = initialState.speakers.every(s => normalizeSpeaker(s) === normalizedCaller);
|
|
1214
|
+
if (allSelf) {
|
|
1215
|
+
_deps.appendRuntimeLog("WARN", `AUTO_HANDOFF_ALL_SELF_TURN: ${sessionId} | all speakers match caller identity "${normalizedCaller}" | halting auto-dispatch; orchestrator must proceed manually`);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1131
1221
|
// Phase 1: Run all deliberation turns
|
|
1132
1222
|
let maxIterations = 100; // safety limit
|
|
1133
1223
|
while (maxIterations-- > 0) {
|
|
@@ -1157,10 +1247,19 @@ export async function runAutoHandoff(sessionId) {
|
|
|
1157
1247
|
break;
|
|
1158
1248
|
}
|
|
1159
1249
|
|
|
1160
|
-
// self_turn
|
|
1250
|
+
// self_turn: orchestrator is the speaker. The defensive guard at runUntilBlockedCore
|
|
1251
|
+
// prevented a recursive CLI spawn; here we submit a visible placeholder so the session
|
|
1252
|
+
// can advance to the next speaker rather than aborting Phase 1 entirely.
|
|
1161
1253
|
if (runResult.block_reason === "self_turn") {
|
|
1162
|
-
_deps.appendRuntimeLog("WARN", `
|
|
1163
|
-
|
|
1254
|
+
_deps.appendRuntimeLog("WARN", `AUTO_HANDOFF_SELF_TURN_SKIP: ${sessionId} | speaker: ${speaker} | submitting placeholder and advancing`);
|
|
1255
|
+
submitDeliberationTurn({
|
|
1256
|
+
session_id: sessionId,
|
|
1257
|
+
speaker,
|
|
1258
|
+
content: `[SELF_TURN_SKIP] Speaker ${speaker} matches the orchestrator identity; auto-dispatch skipped to avoid recursive self-spawn. The orchestrator may contribute this speaker's input manually via deliberation_respond before synthesis.`,
|
|
1259
|
+
channel_used: "self_turn_skip",
|
|
1260
|
+
fallback_reason: "caller_identity_match",
|
|
1261
|
+
});
|
|
1262
|
+
turnSucceeded = true; // placeholder submitted, advance to next speaker
|
|
1164
1263
|
break;
|
|
1165
1264
|
}
|
|
1166
1265
|
|
package/logger-emit.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// δ2 Phase 3 (#440) — deliberation emitter wrapper.
|
|
2
|
+
//
|
|
3
|
+
// Library-context emit. The deliberation MCP server is consumed by many
|
|
4
|
+
// session roles (orchestrator/coder/architect/...), so we cannot
|
|
5
|
+
// fabricate a Role when AIGENTRY_ROLE is unset: per the Phase 2 ACK
|
|
6
|
+
// decision (option B), we emit-skip-with-warning so telemetry stays
|
|
7
|
+
// clean of synthetic attribution. Warning fires once per process.
|
|
8
|
+
//
|
|
9
|
+
// Schema mapping per orchestrator A1 decision:
|
|
10
|
+
// spec event → ssot kind + payload.subtype
|
|
11
|
+
// turn_event → state-change + turn_start | turn_complete
|
|
12
|
+
// synthesis_event → report + synthesis
|
|
13
|
+
// handoff_event → report + handoff_v2
|
|
14
|
+
//
|
|
15
|
+
// ESM module (package.json "type": "module"). @dmsdc-ai/aigentry-logger is also
|
|
16
|
+
// ESM — direct `import` works.
|
|
17
|
+
//
|
|
18
|
+
// Failure mode (§9 독립): transport errors swallowed via try/catch.
|
|
19
|
+
|
|
20
|
+
import { emit as loggerEmit } from "@dmsdc-ai/aigentry-logger";
|
|
21
|
+
|
|
22
|
+
const VALID_ROLES = new Set([
|
|
23
|
+
"orchestrator",
|
|
24
|
+
"architect",
|
|
25
|
+
"coder",
|
|
26
|
+
"tester",
|
|
27
|
+
"builder",
|
|
28
|
+
"analyst",
|
|
29
|
+
"researcher",
|
|
30
|
+
"reviewer",
|
|
31
|
+
"logger",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
let _warnedRoleUnset = false;
|
|
35
|
+
function warnOnceRoleUnset() {
|
|
36
|
+
if (_warnedRoleUnset) return;
|
|
37
|
+
_warnedRoleUnset = true;
|
|
38
|
+
try {
|
|
39
|
+
console.warn(
|
|
40
|
+
"[logger] emit skipped: AIGENTRY_ROLE unset (this is normal for library context)",
|
|
41
|
+
);
|
|
42
|
+
} catch (_e) {
|
|
43
|
+
/* noop */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function __resetRoleWarningForTests() {
|
|
48
|
+
_warnedRoleUnset = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveContext(env = process.env) {
|
|
52
|
+
const session_id = env.AIGENTRY_SESSION_ID || `pid-${process.pid}`;
|
|
53
|
+
const rawRole = env.AIGENTRY_ROLE;
|
|
54
|
+
if (rawRole && VALID_ROLES.has(rawRole)) {
|
|
55
|
+
return { session_id, role: rawRole };
|
|
56
|
+
}
|
|
57
|
+
return { session_id, role: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function emitTelemetry(input, opts = {}) {
|
|
61
|
+
const env = opts.env || process.env;
|
|
62
|
+
if (env.AIGENTRY_LOGGER_DISABLED === "1") return;
|
|
63
|
+
const ctx = resolveContext(env);
|
|
64
|
+
if (ctx.role === null) {
|
|
65
|
+
warnOnceRoleUnset();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const now = opts.now || (() => new Date());
|
|
69
|
+
const event = {
|
|
70
|
+
schema_version: "1",
|
|
71
|
+
kind: input.kind,
|
|
72
|
+
session_id: ctx.session_id,
|
|
73
|
+
role: ctx.role,
|
|
74
|
+
emitted_at: now().toISOString(),
|
|
75
|
+
payload: input.payload || {},
|
|
76
|
+
};
|
|
77
|
+
if (input.correlation_id) event.correlation_id = input.correlation_id;
|
|
78
|
+
const sink = opts.__emit || loggerEmit;
|
|
79
|
+
try {
|
|
80
|
+
sink(event);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
83
|
+
try {
|
|
84
|
+
console.error(`[deliberation:logger-emit] telemetry emit failed: ${msg}`);
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
/* noop */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function emitTurnEvent(subtype, payload, correlationId) {
|
|
92
|
+
emitTelemetry(
|
|
93
|
+
{
|
|
94
|
+
kind: "state-change",
|
|
95
|
+
payload: { subtype, ...(payload || {}) },
|
|
96
|
+
...(correlationId ? { correlation_id: correlationId } : {}),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function emitSynthesisEvent(payload, correlationId) {
|
|
102
|
+
emitTelemetry(
|
|
103
|
+
{
|
|
104
|
+
kind: "report",
|
|
105
|
+
payload: { subtype: "synthesis", ...(payload || {}) },
|
|
106
|
+
...(correlationId ? { correlation_id: correlationId } : {}),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function emitHandoffEvent(payload, correlationId) {
|
|
112
|
+
emitTelemetry(
|
|
113
|
+
{
|
|
114
|
+
kind: "report",
|
|
115
|
+
payload: { subtype: "handoff_v2", ...(payload || {}) },
|
|
116
|
+
...(correlationId ? { correlation_id: correlationId } : {}),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-deliberation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"description": "MCP server for structured multi-AI discussions — deliberate across Claude, GPT, Gemini and more before committing to decisions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"observer.js",
|
|
39
39
|
"browser-control-port.js",
|
|
40
40
|
"degradation-state-machine.js",
|
|
41
|
+
"logger-emit.js",
|
|
41
42
|
"session-monitor.sh",
|
|
42
43
|
"session-monitor-win.js",
|
|
43
44
|
"selectors/**",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"release:major": "npm version major && git push && git push --tags"
|
|
60
61
|
},
|
|
61
62
|
"dependencies": {
|
|
63
|
+
"@dmsdc-ai/aigentry-logger": "^0.2.0",
|
|
62
64
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
63
65
|
"ws": "^8.18.0"
|
|
64
66
|
},
|