@adhdev/daemon-core 0.9.82-rc.552 → 0.9.82-rc.554
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/dist/cli-adapter-types.d.ts +29 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +80 -1
- package/dist/cli-adapters/provider-cli-shared.d.ts +55 -0
- package/dist/cli-adapters/terminal-screen.d.ts +5 -0
- package/dist/commands/stream-commands.d.ts +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +789 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +785 -12
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-refine-gates.d.ts +86 -0
- package/dist/providers/cli-provider-instance.d.ts +97 -0
- package/dist/providers/provider-instance.d.ts +9 -0
- package/dist/providers/spec/adapter.d.ts +7 -0
- package/dist/providers/spec/cli-adapter.d.ts +64 -0
- package/dist/providers/spec/fsm-driver.d.ts +11 -0
- package/dist/repo-mesh-types.d.ts +23 -0
- package/package.json +3 -3
- package/src/cli-adapter-types.ts +29 -0
- package/src/cli-adapters/provider-cli-adapter.ts +135 -0
- package/src/cli-adapters/provider-cli-shared.ts +172 -0
- package/src/cli-adapters/terminal-screen.ts +5 -0
- package/src/commands/handler.ts +4 -0
- package/src/commands/router-refine.ts +40 -1
- package/src/commands/router.ts +15 -0
- package/src/commands/stream-commands.ts +125 -0
- package/src/index.ts +1 -0
- package/src/mesh/coordinator-prompt.ts +2 -0
- package/src/mesh/mesh-events-utils.ts +15 -0
- package/src/mesh/mesh-ledger.ts +6 -0
- package/src/mesh/mesh-refine-gates.ts +256 -0
- package/src/providers/cli-provider-instance.ts +277 -6
- package/src/providers/provider-instance-manager.ts +11 -0
- package/src/providers/provider-instance.ts +10 -0
- package/src/providers/spec/adapter.ts +7 -0
- package/src/providers/spec/cli-adapter.ts +122 -0
- package/src/providers/spec/fsm-driver.ts +5 -0
- package/src/repo-mesh-types.ts +35 -0
|
@@ -482,6 +482,178 @@ export function sanitizeTerminalText(str: string): string {
|
|
|
482
482
|
return stripTerminalNoise(stripAnsi(accumulator.append(str)));
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
// ─── MESH-SEND-KEYS (feature 3: key injection) ─────────────────────────────
|
|
486
|
+
//
|
|
487
|
+
// The coordinator injects a STRUCTURED key sequence into a worker PTY — never
|
|
488
|
+
// raw/base64 bytes (auditability + safety). Each item is either literal UTF-8
|
|
489
|
+
// `text` or a named `key` from this closed enum; the encoder maps each key to its
|
|
490
|
+
// exact terminal byte sequence. Keeping raw bytes out of the input surface means
|
|
491
|
+
// the ledger can record the key ENUM (not the body text) and the destructive-key
|
|
492
|
+
// gate can reason about a small, known set.
|
|
493
|
+
|
|
494
|
+
/** Named PTY keys the send-keys tool accepts (closed enum). */
|
|
495
|
+
export type MeshSendKeyName =
|
|
496
|
+
| 'ENTER' | 'ESC' | 'CTRL_C'
|
|
497
|
+
| 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'
|
|
498
|
+
| 'TAB' | 'BACKSPACE';
|
|
499
|
+
|
|
500
|
+
/** Exact terminal byte sequence for each named key. */
|
|
501
|
+
export const MESH_SEND_KEY_ENCODING: Record<MeshSendKeyName, string> = {
|
|
502
|
+
ENTER: '\r',
|
|
503
|
+
ESC: '\x1b',
|
|
504
|
+
CTRL_C: '\x03',
|
|
505
|
+
UP: '\x1b[A',
|
|
506
|
+
DOWN: '\x1b[B',
|
|
507
|
+
RIGHT: '\x1b[C',
|
|
508
|
+
LEFT: '\x1b[D',
|
|
509
|
+
TAB: '\t',
|
|
510
|
+
BACKSPACE: '\x7f',
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Destructive keys: they can kill or derail the worker process. CTRL_C sends
|
|
514
|
+
// SIGINT (kills the running command / the agent's turn); ESC dismisses / cancels
|
|
515
|
+
// modals and pickers. delegatedWorkerAutoApprove is a TOOL-CONSENT policy, NOT a
|
|
516
|
+
// PTY-input authorization — so these MUST NOT ride the auto-approve pathway; they
|
|
517
|
+
// require an explicit confirm + mesh-policy opt-in. Text / ENTER / arrows / TAB /
|
|
518
|
+
// BACKSPACE are non-destructive and need no confirm.
|
|
519
|
+
export const MESH_DESTRUCTIVE_KEYS: ReadonlySet<MeshSendKeyName> = new Set<MeshSendKeyName>(['CTRL_C', 'ESC']);
|
|
520
|
+
|
|
521
|
+
export type MeshSendKeyItem = { text: string } | { key: MeshSendKeyName };
|
|
522
|
+
|
|
523
|
+
export interface MeshSendKeysEncodeResult {
|
|
524
|
+
/** The concatenated byte sequence to write to the PTY (single atomic write). */
|
|
525
|
+
sequence: string;
|
|
526
|
+
/** Named keys present, in order (for audit — text bodies are NOT recorded). */
|
|
527
|
+
keys: MeshSendKeyName[];
|
|
528
|
+
/** True if any item is a destructive key (CTRL_C / ESC). */
|
|
529
|
+
hasDestructive: boolean;
|
|
530
|
+
/** True if the sequence submits (ends with an ENTER/CR) — informational. */
|
|
531
|
+
submits: boolean;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Per-call limits (auditability + safety — no unbounded blast). */
|
|
535
|
+
export const MESH_SEND_KEYS_MAX_ITEMS = 64;
|
|
536
|
+
export const MESH_SEND_KEYS_MAX_TEXT_BYTES = 4096;
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Validate + encode a structured key sequence into the exact bytes to write.
|
|
540
|
+
* Throws on an invalid key name, an over-limit item count, or an over-limit
|
|
541
|
+
* total text byte length. text+ENTER (or any text followed by ENTER) is encoded
|
|
542
|
+
* as ONE contiguous string so the caller can submit it in a single atomic write
|
|
543
|
+
* — no interleaving between the text and its submit key.
|
|
544
|
+
*/
|
|
545
|
+
export function encodeMeshSendKeys(items: MeshSendKeyItem[]): MeshSendKeysEncodeResult {
|
|
546
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
547
|
+
throw new Error('send_keys: sequence must be a non-empty array');
|
|
548
|
+
}
|
|
549
|
+
if (items.length > MESH_SEND_KEYS_MAX_ITEMS) {
|
|
550
|
+
throw new Error(`send_keys: sequence exceeds ${MESH_SEND_KEYS_MAX_ITEMS} items`);
|
|
551
|
+
}
|
|
552
|
+
const parts: string[] = [];
|
|
553
|
+
const keys: MeshSendKeyName[] = [];
|
|
554
|
+
let hasDestructive = false;
|
|
555
|
+
let submits = false;
|
|
556
|
+
let textBytes = 0;
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
if (item && typeof (item as { text?: unknown }).text === 'string') {
|
|
559
|
+
const text = (item as { text: string }).text;
|
|
560
|
+
textBytes += Buffer.byteLength(text, 'utf8');
|
|
561
|
+
if (textBytes > MESH_SEND_KEYS_MAX_TEXT_BYTES) {
|
|
562
|
+
throw new Error(`send_keys: total literal text exceeds ${MESH_SEND_KEYS_MAX_TEXT_BYTES} bytes`);
|
|
563
|
+
}
|
|
564
|
+
parts.push(text);
|
|
565
|
+
submits = false; // literal text after a submit re-opens the line
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const keyName = item && typeof (item as { key?: unknown }).key === 'string'
|
|
569
|
+
? (item as { key: string }).key
|
|
570
|
+
: '';
|
|
571
|
+
if (!(keyName in MESH_SEND_KEY_ENCODING)) {
|
|
572
|
+
throw new Error(`send_keys: unknown key '${keyName}'`);
|
|
573
|
+
}
|
|
574
|
+
const key = keyName as MeshSendKeyName;
|
|
575
|
+
parts.push(MESH_SEND_KEY_ENCODING[key]);
|
|
576
|
+
keys.push(key);
|
|
577
|
+
if (MESH_DESTRUCTIVE_KEYS.has(key)) hasDestructive = true;
|
|
578
|
+
submits = key === 'ENTER';
|
|
579
|
+
}
|
|
580
|
+
return { sequence: parts.join(''), keys, hasDestructive, submits };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* MESH-READ-TERMINAL (feature 2): result of a byte-bounded, bottom-tail terminal
|
|
585
|
+
* screen truncation. The bound is in BYTES (UTF-8), not characters, because a
|
|
586
|
+
* screen full of multi-byte glyphs can blow past an MCP payload cap even when the
|
|
587
|
+
* character count looks safe. The bottom (tail) of the screen is preserved — the
|
|
588
|
+
* prompt, an active modal and the most recent output all live at the bottom — so
|
|
589
|
+
* a truncated read still shows the coordinator the actionable frame.
|
|
590
|
+
*/
|
|
591
|
+
export interface ByteTailTruncation {
|
|
592
|
+
text: string;
|
|
593
|
+
truncated: boolean;
|
|
594
|
+
/** UTF-8 byte length of the full input before truncation. */
|
|
595
|
+
originalBytes: number;
|
|
596
|
+
/** UTF-8 byte length of the returned (possibly truncated) text. */
|
|
597
|
+
returnedBytes: number;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Truncate `text` to at most `maxBytes` UTF-8 bytes, preserving whole lines from
|
|
602
|
+
* the BOTTOM up (the prompt/modal/recent output). Never splits a line and never
|
|
603
|
+
* splits a UTF-8 code point (whole-line granularity guarantees valid UTF-8). If
|
|
604
|
+
* even the single last line exceeds the bound, that line is hard-clipped on a
|
|
605
|
+
* safe UTF-8 boundary from its END so the tail is still returned intact-ish.
|
|
606
|
+
*/
|
|
607
|
+
export function truncateToByteTailByLine(text: string, maxBytes: number): ByteTailTruncation {
|
|
608
|
+
const input = String(text ?? '');
|
|
609
|
+
const originalBytes = Buffer.byteLength(input, 'utf8');
|
|
610
|
+
if (originalBytes <= maxBytes) {
|
|
611
|
+
return { text: input, truncated: false, originalBytes, returnedBytes: originalBytes };
|
|
612
|
+
}
|
|
613
|
+
// Split on newlines, keeping the newline characters so reassembly is exact.
|
|
614
|
+
const lines = input.split('\n');
|
|
615
|
+
const kept: string[] = [];
|
|
616
|
+
let bytes = 0;
|
|
617
|
+
// Walk from the last line upward, adding lines while they fit. Account for the
|
|
618
|
+
// '\n' rejoin cost (1 byte) between kept lines.
|
|
619
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
620
|
+
const line = lines[i];
|
|
621
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
622
|
+
const joinCost = kept.length > 0 ? 1 : 0;
|
|
623
|
+
if (bytes + lineBytes + joinCost > maxBytes) break;
|
|
624
|
+
bytes += lineBytes + joinCost;
|
|
625
|
+
kept.unshift(line);
|
|
626
|
+
}
|
|
627
|
+
if (kept.length > 0) {
|
|
628
|
+
const out = kept.join('\n');
|
|
629
|
+
return {
|
|
630
|
+
text: out,
|
|
631
|
+
truncated: true,
|
|
632
|
+
originalBytes,
|
|
633
|
+
returnedBytes: Buffer.byteLength(out, 'utf8'),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
// Degenerate case: the single last line alone exceeds maxBytes. Hard-clip its
|
|
637
|
+
// TAIL on a UTF-8 code-point boundary (Buffer slice can split a multi-byte
|
|
638
|
+
// sequence, so decode-and-recut with the replacement-char guard).
|
|
639
|
+
const lastLine = lines[lines.length - 1] ?? '';
|
|
640
|
+
const buf = Buffer.from(lastLine, 'utf8');
|
|
641
|
+
let slice = buf.subarray(Math.max(0, buf.length - maxBytes));
|
|
642
|
+
// Trim leading bytes until the decode has no leading replacement char (i.e. we
|
|
643
|
+
// landed on a valid code-point boundary).
|
|
644
|
+
let decoded = slice.toString('utf8');
|
|
645
|
+
while (decoded.length > 0 && decoded.charCodeAt(0) === 0xfffd && slice.length > 0) {
|
|
646
|
+
slice = slice.subarray(1);
|
|
647
|
+
decoded = slice.toString('utf8');
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
text: decoded,
|
|
651
|
+
truncated: true,
|
|
652
|
+
originalBytes,
|
|
653
|
+
returnedBytes: Buffer.byteLength(decoded, 'utf8'),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
485
657
|
export function listCliScriptNames(scripts: CliScripts | undefined): string[] {
|
|
486
658
|
if (!scripts) return [];
|
|
487
659
|
return Object.entries(scripts)
|
|
@@ -56,6 +56,11 @@ export class TerminalScreen {
|
|
|
56
56
|
return this.terminal.getCursorPosition();
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Current viewport dimensions (cols × rows). */
|
|
60
|
+
getSize(): { cols: number; rows: number } {
|
|
61
|
+
return { cols: this.cols, rows: this.rows };
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
dispose(): void {
|
|
60
65
|
this.terminal.dispose();
|
|
61
66
|
}
|
package/src/commands/handler.ts
CHANGED
|
@@ -564,6 +564,10 @@ export class DaemonCommandHandler implements CommandHelpers {
|
|
|
564
564
|
// ─── PTY Raw I/O (stream-commands.ts) ─────────
|
|
565
565
|
case 'pty_input': return Stream.handlePtyInput(this, args);
|
|
566
566
|
case 'pty_resize': return Stream.handlePtyResize(this, args);
|
|
567
|
+
// ─── MESH-READ-TERMINAL (feature 2): raw viewport read ──────────
|
|
568
|
+
case 'read_terminal': return Stream.handleReadTerminal(this, args);
|
|
569
|
+
// ─── MESH-SEND-KEYS (feature 3): structured key injection ────────
|
|
570
|
+
case 'send_keys': return Stream.handleSendKeys(this, args);
|
|
567
571
|
|
|
568
572
|
// ─── Provider Settings (stream-commands.ts) ──────────
|
|
569
573
|
case 'get_provider_settings': return Stream.handleGetProviderSettings(this, args);
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
RefineContext,
|
|
36
36
|
RefineExecFileAsync,
|
|
37
37
|
RefineStageOutcome,
|
|
38
|
+
classifyPatchEquivalenceFailure,
|
|
38
39
|
recordMeshRefineStage,
|
|
39
40
|
resolveRefineryAutoPublishSubmoduleMainCommits,
|
|
40
41
|
runMeshRefineEffectiveDiffGate,
|
|
@@ -616,9 +617,20 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
|
|
|
616
617
|
error: submoduleHintPatchEquivalence.error,
|
|
617
618
|
actionableHint: submoduleHintPatchEquivalence.actionableHint,
|
|
618
619
|
});
|
|
620
|
+
const classification = await classifyPatchEquivalenceFailure(
|
|
621
|
+
repoRoot, baseHead, ctx.branchHead, submoduleHintPatchEquivalence,
|
|
622
|
+
{
|
|
623
|
+
targetBaseRef: baseHead,
|
|
624
|
+
autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled,
|
|
625
|
+
},
|
|
626
|
+
);
|
|
619
627
|
return { kind: 'terminal', result: {
|
|
620
628
|
success: false,
|
|
621
629
|
code: 'patch_equivalence_failed',
|
|
630
|
+
detailedReason: classification.detailedReason,
|
|
631
|
+
detailedReasonDescription: classification.detailedReasonDescription,
|
|
632
|
+
recommendedAction: classification.recommendedAction,
|
|
633
|
+
evidence: classification.evidence,
|
|
622
634
|
convergenceStatus: 'blocked_review',
|
|
623
635
|
error: 'Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.',
|
|
624
636
|
branch,
|
|
@@ -801,7 +813,7 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
|
|
|
801
813
|
export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
|
|
802
814
|
// DS2: node/execFileAsync are no longer needed here — the rebase moved to
|
|
803
815
|
// sync_base — and branchHead/patchEquivalence are no longer mutated in-stage.
|
|
804
|
-
const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
|
|
816
|
+
const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
|
|
805
817
|
const branchHead = ctx.branchHead;
|
|
806
818
|
const patchEquivalenceStarted = Date.now();
|
|
807
819
|
const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
|
|
@@ -826,9 +838,24 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
|
|
|
826
838
|
// branch itself has no changes (degenerate), which is NOT already-merged.
|
|
827
839
|
const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
|
|
828
840
|
if (!alreadyMergedViaOtherPath) {
|
|
841
|
+
const classification = await classifyPatchEquivalenceFailure(
|
|
842
|
+
repoRoot, baseHead, branchHead, patchEquivalence,
|
|
843
|
+
{
|
|
844
|
+
targetBaseRef: baseHead,
|
|
845
|
+
autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled,
|
|
846
|
+
},
|
|
847
|
+
);
|
|
848
|
+
recordMeshRefineStage(refineStages, 'patch_equivalence_classification', 'failed', patchEquivalenceStarted, {
|
|
849
|
+
detailedReason: classification.detailedReason,
|
|
850
|
+
recommendedAction: classification.recommendedAction,
|
|
851
|
+
});
|
|
829
852
|
return { kind: 'terminal', result: {
|
|
830
853
|
success: false,
|
|
831
854
|
code: 'patch_equivalence_failed',
|
|
855
|
+
detailedReason: classification.detailedReason,
|
|
856
|
+
detailedReasonDescription: classification.detailedReasonDescription,
|
|
857
|
+
recommendedAction: classification.recommendedAction,
|
|
858
|
+
evidence: classification.evidence,
|
|
832
859
|
convergenceStatus: 'blocked_review',
|
|
833
860
|
error: 'Refinery patch-equivalence preflight failed; merge/refine was not attempted.',
|
|
834
861
|
branch,
|
|
@@ -2278,6 +2305,15 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
2278
2305
|
};
|
|
2279
2306
|
if (typeof result.error === 'string') ctx.error = result.error;
|
|
2280
2307
|
if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
|
|
2308
|
+
// Detailed patch-equivalence sub-cause classification (base_divergence,
|
|
2309
|
+
// submodule_unreachable, actual_patch_diff, trivial_ff_misjudgment,
|
|
2310
|
+
// already_converged, unclassified) + recommended action + evidence.
|
|
2311
|
+
// Promoted onto blockerContext so coordinators reading task_failed ledger
|
|
2312
|
+
// entries see the cause without parsing the free-form error string.
|
|
2313
|
+
if (typeof result.detailedReason === 'string') ctx.detailedReason = result.detailedReason;
|
|
2314
|
+
if (typeof result.detailedReasonDescription === 'string') ctx.detailedReasonDescription = result.detailedReasonDescription;
|
|
2315
|
+
if (typeof result.recommendedAction === 'string') ctx.recommendedAction = result.recommendedAction;
|
|
2316
|
+
if (result.evidence && typeof result.evidence === 'object') ctx.evidence = result.evidence;
|
|
2281
2317
|
// Patch equivalence details
|
|
2282
2318
|
if (stage === 'patch_equivalence' && result.patchEquivalence) {
|
|
2283
2319
|
const pe = result.patchEquivalence as Record<string, unknown>;
|
|
@@ -2287,6 +2323,9 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
|
|
|
2287
2323
|
status: pe.status,
|
|
2288
2324
|
actionableHint: pe.actionableHint,
|
|
2289
2325
|
error: pe.error,
|
|
2326
|
+
...(typeof result.detailedReason === 'string' ? { detailedReason: result.detailedReason } : {}),
|
|
2327
|
+
...(typeof result.recommendedAction === 'string' ? { recommendedAction: result.recommendedAction } : {}),
|
|
2328
|
+
...(result.evidence && typeof result.evidence === 'object' ? { evidence: result.evidence } : {}),
|
|
2290
2329
|
};
|
|
2291
2330
|
}
|
|
2292
2331
|
// Submodule reachability details
|
package/src/commands/router.ts
CHANGED
|
@@ -212,6 +212,21 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
|
|
|
212
212
|
// daemon's own CLI session (TASKECHO coordinator self-echo). Forwarding to the owning daemon
|
|
213
213
|
// delivers it to the real worker instead. (findAdapter is also fail-closed as the backstop.)
|
|
214
214
|
'agent_command',
|
|
215
|
+
// read_terminal (MESH-READ-TERMINAL feature 2): mesh_read_terminal reads the CURRENT
|
|
216
|
+
// rendered PTY viewport of a specific worker session. The live viewport lives ONLY on the
|
|
217
|
+
// OWNING session's adapter, so when the target is a REMOTE worker the coordinator has no
|
|
218
|
+
// local instance and the handler would return 'Session not found' — the exact
|
|
219
|
+
// remote-worker forwarding gap of mission 6938892f. Forward it to the owning worker daemon
|
|
220
|
+
// so it reads its own live screen. (It is read-only; unlike the mutations above it makes no
|
|
221
|
+
// state change, but it is session-scoped identically and must reach the owning daemon.)
|
|
222
|
+
'read_terminal',
|
|
223
|
+
// send_keys (MESH-SEND-KEYS feature 3): mesh_send_keys injects a structured key sequence
|
|
224
|
+
// into a specific worker session's PTY. The live PTY lives ONLY on the OWNING session's
|
|
225
|
+
// adapter, so a remote-worker target must be forwarded to the owning daemon or the handler
|
|
226
|
+
// returns 'Session not found' (same class as mission 6938892f). Unlike read_terminal this
|
|
227
|
+
// MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
|
|
228
|
+
// doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
|
|
229
|
+
'send_keys',
|
|
215
230
|
]);
|
|
216
231
|
|
|
217
232
|
function normalizeCommandSource(source: string): CommandLogEntry['source'] {
|
|
@@ -134,6 +134,131 @@ export function handlePtyResize(_h: CommandHelpers, args: any): CommandResult {
|
|
|
134
134
|
return { success: false, error: 'PTY resize temporarily disabled', code: 'PTY_RESIZE_DISABLED' };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
interface TerminalSnapshotInstance extends ProviderInstance {
|
|
138
|
+
getTerminalScreenSnapshot?(maxBytes?: number): {
|
|
139
|
+
text: string;
|
|
140
|
+
cursor: { col: number; row: number };
|
|
141
|
+
cols: number;
|
|
142
|
+
rows: number;
|
|
143
|
+
truncated: boolean;
|
|
144
|
+
originalBytes: number;
|
|
145
|
+
returnedBytes: number;
|
|
146
|
+
hash: string;
|
|
147
|
+
} | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* MESH-READ-TERMINAL (feature 2: RAW terminal read). Reads the CURRENT rendered
|
|
152
|
+
* PTY viewport of a specific mesh worker session for the mesh_read_terminal tool.
|
|
153
|
+
*
|
|
154
|
+
* This is the daemon-side `read_terminal` verb. It runs BOTH on the coordinator
|
|
155
|
+
* (for a locally-hosted worker) and, after router forwarding
|
|
156
|
+
* (MESH_FORWARDABLE_SESSION_COMMANDS + _meshDirectDispatch), on the OWNING remote
|
|
157
|
+
* worker daemon — where the live viewport actually exists. The instance's
|
|
158
|
+
* getTerminalScreenSnapshot() is gated on isMeshWorkerSession() (returns null for
|
|
159
|
+
* a non-mesh session), which the MCP layer complements with a mesh/session/node
|
|
160
|
+
* ownership cross-check. SECURITY: the raw viewport can contain tokens / args /
|
|
161
|
+
* env / user data — never logged here (only its byte size / truncation flag are).
|
|
162
|
+
*/
|
|
163
|
+
export function handleReadTerminal(h: CommandHelpers, args: any): CommandResult {
|
|
164
|
+
const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
|
|
165
|
+
const sessionId = targetSessionId || h.currentSession?.sessionId || '';
|
|
166
|
+
if (!sessionId) return { success: false, error: 'targetSessionId required' };
|
|
167
|
+
|
|
168
|
+
const session = h.ctx.sessionRegistry?.get(sessionId);
|
|
169
|
+
const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
|
|
170
|
+
const instance = h.ctx.instanceManager?.getInstance(instanceKey) as TerminalSnapshotInstance | undefined;
|
|
171
|
+
if (!instance) return { success: false, error: `Session not found: ${sessionId.split('_')[0]}` };
|
|
172
|
+
if (instance.category !== 'cli' || typeof instance.getTerminalScreenSnapshot !== 'function') {
|
|
173
|
+
return { success: false, error: 'read_terminal is only supported for CLI (PTY) sessions' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const requestedMaxBytes = typeof args?.maxBytes === 'number' && Number.isFinite(args.maxBytes)
|
|
177
|
+
? args.maxBytes
|
|
178
|
+
: undefined;
|
|
179
|
+
const snapshot = instance.getTerminalScreenSnapshot(requestedMaxBytes);
|
|
180
|
+
if (!snapshot) {
|
|
181
|
+
// getTerminalScreenSnapshot returns null when the session is NOT a mesh
|
|
182
|
+
// worker — the raw-viewport read is scoped to coordinator-delegated workers.
|
|
183
|
+
return { success: false, error: 'read_terminal is only available for coordinator-spawned mesh worker sessions' };
|
|
184
|
+
}
|
|
185
|
+
// Log size/truncation ONLY — never the screen text (may carry secrets).
|
|
186
|
+
LOG.info('Command', `[readTerminal] session=${sessionId.split('_')[0]} bytes=${snapshot.returnedBytes}/${snapshot.originalBytes} truncated=${snapshot.truncated} cols=${snapshot.cols} rows=${snapshot.rows}`);
|
|
187
|
+
return { success: true, ...snapshot };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface KeyInjectableInstance extends ProviderInstance {
|
|
191
|
+
injectKeys?(
|
|
192
|
+
items: Array<{ text: string } | { key: string }>,
|
|
193
|
+
opts?: { allowModalOverride?: boolean },
|
|
194
|
+
): Promise<
|
|
195
|
+
| { ok: true; keys: string[]; hasDestructive: boolean; submits: boolean; bytes: number }
|
|
196
|
+
| { ok: false; refused: string; keys: string[]; hasDestructive: boolean }
|
|
197
|
+
>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* MESH-SEND-KEYS (feature 3: key injection). Inject a structured key sequence into
|
|
202
|
+
* a specific mesh worker session's PTY for the mesh_send_keys tool.
|
|
203
|
+
*
|
|
204
|
+
* Daemon-side `send_keys` verb. Like read_terminal it runs on the coordinator for
|
|
205
|
+
* a local worker and, after router forwarding (MESH_FORWARDABLE_SESSION_COMMANDS +
|
|
206
|
+
* _meshDirectDispatch), on the OWNING remote worker daemon. The instance's
|
|
207
|
+
* injectKeys() is gated on isMeshWorkerSession(); the MCP layer complements it with
|
|
208
|
+
* mesh/session/node ownership + the destructive-key double gate (confirm_destructive
|
|
209
|
+
* + mesh policy) + audit ledger.
|
|
210
|
+
*
|
|
211
|
+
* Defense-in-depth here: even though the MCP layer gates destructive keys, the
|
|
212
|
+
* daemon re-enforces confirm_destructive so a direct/forwarded send_keys that
|
|
213
|
+
* contains CTRL_C/ESC without confirm is refused at the boundary too.
|
|
214
|
+
* SECURITY: never logs the literal text body (only key enums / byte counts).
|
|
215
|
+
*/
|
|
216
|
+
export async function handleSendKeys(h: CommandHelpers, args: any): Promise<CommandResult> {
|
|
217
|
+
const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
|
|
218
|
+
const sessionId = targetSessionId || h.currentSession?.sessionId || '';
|
|
219
|
+
if (!sessionId) return { success: false, error: 'targetSessionId required' };
|
|
220
|
+
|
|
221
|
+
const items = Array.isArray(args?.sequence) ? args.sequence : null;
|
|
222
|
+
if (!items || items.length === 0) {
|
|
223
|
+
return { success: false, error: 'sequence (non-empty array of {text}|{key}) required' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const session = h.ctx.sessionRegistry?.get(sessionId);
|
|
227
|
+
const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
|
|
228
|
+
const instance = h.ctx.instanceManager?.getInstance(instanceKey) as KeyInjectableInstance | undefined;
|
|
229
|
+
if (!instance) return { success: false, error: `Session not found: ${sessionId.split('_')[0]}` };
|
|
230
|
+
if (instance.category !== 'cli' || typeof instance.injectKeys !== 'function') {
|
|
231
|
+
return { success: false, error: 'send_keys is only supported for CLI (PTY) sessions' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Defense-in-depth destructive gate: refuse CTRL_C/ESC without confirm_destructive
|
|
235
|
+
// even at the daemon boundary (the MCP layer is the primary gate + policy check).
|
|
236
|
+
const DESTRUCTIVE = new Set(['CTRL_C', 'ESC']);
|
|
237
|
+
const hasDestructiveRequested = items.some((it: any) => it && typeof it.key === 'string' && DESTRUCTIVE.has(it.key));
|
|
238
|
+
if (hasDestructiveRequested && args?.confirm_destructive !== true) {
|
|
239
|
+
return {
|
|
240
|
+
success: false,
|
|
241
|
+
error: 'destructive key (CTRL_C/ESC) requires confirm_destructive=true',
|
|
242
|
+
refused: 'destructive_unconfirmed',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const result = await instance.injectKeys(items, {
|
|
248
|
+
allowModalOverride: args?.allow_modal_override === true,
|
|
249
|
+
});
|
|
250
|
+
if (!result.ok) {
|
|
251
|
+
LOG.info('Command', `[sendKeys] session=${sessionId.split('_')[0]} refused=${result.refused} keys=${result.keys.join(',')} destructive=${result.hasDestructive}`);
|
|
252
|
+
return { success: false, error: `send_keys refused: ${result.refused}`, refused: result.refused, keys: result.keys, hasDestructive: result.hasDestructive };
|
|
253
|
+
}
|
|
254
|
+
LOG.info('Command', `[sendKeys] session=${sessionId.split('_')[0]} injected keys=${result.keys.join(',') || '(text-only)'} bytes=${result.bytes} destructive=${result.hasDestructive} submits=${result.submits}`);
|
|
255
|
+
return { success: true, keys: result.keys, hasDestructive: result.hasDestructive, submits: result.submits, bytes: result.bytes };
|
|
256
|
+
} catch (e: any) {
|
|
257
|
+
// Encode/validation errors (unknown key, over-limit) surface as a clean failure.
|
|
258
|
+
return { success: false, error: `send_keys: ${e?.message || String(e)}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
137
262
|
// ─── Provider Settings ────────────────────────
|
|
138
263
|
|
|
139
264
|
export function handleGetProviderSettings(h: CommandHelpers, args: any): CommandResult {
|
package/src/index.ts
CHANGED
|
@@ -743,6 +743,8 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
743
743
|
| \`mesh_launch_session\` | Start a new agent session on a node |
|
|
744
744
|
| \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
|
|
745
745
|
| \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
|
|
746
|
+
| \`mesh_read_terminal\` | Read a worker session's CURRENT raw terminal screen (the live rendered PTY viewport — prompt/modal/spinner/unparsed output), not the parsed chat. Byte-bounded (32KiB default, 64KiB max; bottom of screen kept). Use to see exactly what a worker is showing when mesh_read_chat is not enough (e.g. after a stall alert). Screen text may contain secrets — treat as sensitive |
|
|
747
|
+
| \`mesh_send_keys\` | Inject a STRUCTURED key sequence into a worker's live PTY (text + named keys ENTER/ESC/CTRL_C/UP/DOWN/LEFT/RIGHT/TAB/BACKSPACE). For interactions mesh_send_task can't express — answer a non-approval prompt, navigate a picker, submit a typed line, or interrupt (CTRL_C). Use mesh_approve for approval modals (send_keys is refused on one). Destructive keys (CTRL_C/ESC) need confirm_destructive=true AND mesh policy allowSendKeysDestructive. Refused on a pending submit/echo race. Audited (key enums only) |
|
|
746
748
|
| \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
|
|
747
749
|
| \`mesh_ledger_query\` | Read-only ledger query along the kind/time/node axes (complement to task-axis mesh_task_history): filter by kind, since, node, tail — answer "what happened on node X / what failed since T" without scanning transcripts |
|
|
748
750
|
| \`mesh_reconcile_ledger\` | Reconcile daemon-local ledgers over P2P — import missing entries from remote nodes into the coordinator local ledger |
|
|
@@ -356,6 +356,21 @@ export function buildMeshSystemMessage(args: {
|
|
|
356
356
|
return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
|
|
357
357
|
}
|
|
358
358
|
if (args.event === 'monitor:no_progress') {
|
|
359
|
+
// MESH-STALL-WATCH (feature 1: STALL detection): the status-agnostic stall
|
|
360
|
+
// watchdog fires this event regardless of the reported status — a worker's
|
|
361
|
+
// raw PTY output was byte-for-byte unchanged past the stall bound. Surface
|
|
362
|
+
// the generalized "output unchanged" wording (with the observed status,
|
|
363
|
+
// stalled duration and taskId as context) and make explicit this is
|
|
364
|
+
// INFORMATIONAL — a quiet/idle worker can trip it; it is NOT a failure or
|
|
365
|
+
// auto-restart. The generating-only StatusMonitor copy keeps its original
|
|
366
|
+
// phrasing.
|
|
367
|
+
if (args.metadataEvent.meshWorkerStall === true) {
|
|
368
|
+
const observedStatus = readNonEmptyString(args.metadataEvent.observedStatus);
|
|
369
|
+
const stalledMs = typeof args.metadataEvent.stalledMs === 'number' ? args.metadataEvent.stalledMs : undefined;
|
|
370
|
+
const stalledSuffix = stalledMs !== undefined ? ` for ${Math.round(stalledMs / 1000)}s` : '';
|
|
371
|
+
const statusSuffix = observedStatus ? ` (observed status: ${observedStatus})` : '';
|
|
372
|
+
return `[System] ${args.nodeLabel}: PTY output unchanged${stalledSuffix}${statusSuffix}${metadata}. This is an informational stall — the worker's screen has been static regardless of its reported status; it may be genuinely idle, waiting, or wedged, so this is NOT a failure or auto-restart. Judge whether to inspect it: wait for pendingCoordinatorEvents/a completion event, or make one bounded mesh_read_chat check if you need to see its current screen, then wait again.`;
|
|
373
|
+
}
|
|
359
374
|
return `[System] ${args.nodeLabel} is still reported as generating after a long interval${metadata}. Wait for pendingCoordinatorEvents or a completion/status event; if the user explicitly asks for status, make one bounded status check and then wait again.`;
|
|
360
375
|
}
|
|
361
376
|
if (args.event === 'worktree_bootstrap_complete') {
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -82,6 +82,12 @@ export type MeshLedgerKind =
|
|
|
82
82
|
// magi_synthesis payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, synthesis }
|
|
83
83
|
| 'magi_dispatched'
|
|
84
84
|
| 'magi_synthesis'
|
|
85
|
+
// MESH-SEND-KEYS (feature 3): audit trail for coordinator PTY key injections
|
|
86
|
+
// via mesh_send_keys. Records the key ENUMS, destructive flag and result —
|
|
87
|
+
// NEVER the literal text body (may carry tokens / user data).
|
|
88
|
+
// payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
|
|
89
|
+
// refused?: string, submits?: boolean, confirmDestructive?: boolean }
|
|
90
|
+
| 'key_injection'
|
|
85
91
|
;
|
|
86
92
|
|
|
87
93
|
export interface MeshLedgerEntry {
|