@adhdev/daemon-core 0.9.82-rc.552 → 0.9.82-rc.553
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-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 +453 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +449 -6
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/providers/cli-provider-instance.d.ts +82 -0
- package/dist/providers/provider-instance.d.ts +9 -0
- package/dist/repo-mesh-types.d.ts +23 -0
- package/package.json +3 -3
- 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.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/providers/cli-provider-instance.ts +196 -0
- package/src/providers/provider-instance-manager.ts +11 -0
- package/src/providers/provider-instance.ts +10 -0
- package/src/repo-mesh-types.ts +35 -0
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
16
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
17
|
import { type MeshLedgerOriginatingCoordinatorV2 } from './contracts.js';
|
|
18
|
-
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
|
|
18
|
+
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection';
|
|
19
19
|
export interface MeshLedgerEntry {
|
|
20
20
|
id: string;
|
|
21
21
|
meshId: string;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { type ProviderModule, type InputEnvelope } from './contracts.js';
|
|
8
8
|
import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionState, SessionModalState } from './provider-instance.js';
|
|
9
9
|
import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
|
|
10
|
+
import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
|
|
10
11
|
import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
11
12
|
import type { ChatMessage } from '../types.js';
|
|
12
13
|
export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
@@ -169,12 +170,15 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
169
170
|
* with no approval never carries recency and is never held (no regression).
|
|
170
171
|
*/
|
|
171
172
|
private static readonly APPROVAL_RESUME_GRACE_MS;
|
|
173
|
+
private static readonly MESH_WORKER_STALL_THRESHOLD_MS;
|
|
172
174
|
private adapter;
|
|
173
175
|
private context;
|
|
174
176
|
private events;
|
|
175
177
|
private lastStatus;
|
|
176
178
|
private agentReadyEmitted;
|
|
177
179
|
private generatingStartedAt;
|
|
180
|
+
private meshStallAnchorAt;
|
|
181
|
+
private meshStallEmittedForAnchor;
|
|
178
182
|
private busyEpoch;
|
|
179
183
|
private fastCollapseSynthesizedTaskId;
|
|
180
184
|
private startupGraceCollapseAt;
|
|
@@ -429,6 +433,84 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
429
433
|
private approvalResolutionFinalizationBlock;
|
|
430
434
|
private scheduleCompletedDebounceFlush;
|
|
431
435
|
private isMeshWorkerSession;
|
|
436
|
+
/**
|
|
437
|
+
* MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
|
|
438
|
+
* CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
|
|
439
|
+
* the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
|
|
440
|
+
* only; no debug buffers / parser state / history; byte-bounded, bottom-tail
|
|
441
|
+
* preserved).
|
|
442
|
+
*
|
|
443
|
+
* Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
|
|
444
|
+
* command args / env / user data, so only a coordinator-spawned worker session
|
|
445
|
+
* is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
|
|
446
|
+
* (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
|
|
447
|
+
* "delegated" gate, so the two together block cross-mesh access. Returns null
|
|
448
|
+
* for a non-mesh session so the daemon command surfaces a clean refusal.
|
|
449
|
+
*/
|
|
450
|
+
getTerminalScreenSnapshot(maxBytes?: number): {
|
|
451
|
+
text: string;
|
|
452
|
+
cursor: {
|
|
453
|
+
col: number;
|
|
454
|
+
row: number;
|
|
455
|
+
};
|
|
456
|
+
cols: number;
|
|
457
|
+
rows: number;
|
|
458
|
+
truncated: boolean;
|
|
459
|
+
originalBytes: number;
|
|
460
|
+
returnedBytes: number;
|
|
461
|
+
hash: string;
|
|
462
|
+
} | null;
|
|
463
|
+
/**
|
|
464
|
+
* MESH-SEND-KEYS (feature 3: key injection). Public entry for the
|
|
465
|
+
* mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
|
|
466
|
+
* key encoding + atomic write + submit-race recheck + modal fail-closed).
|
|
467
|
+
*
|
|
468
|
+
* Gated on isMeshWorkerSession(): PTY input into a worker is a
|
|
469
|
+
* coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
|
|
470
|
+
* node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
|
|
471
|
+
* double gate (confirm_destructive + policy) and the audit ledger. Returns a
|
|
472
|
+
* refusal object for a non-mesh session so the daemon command surfaces a clean
|
|
473
|
+
* error (never silently writes to a non-worker PTY).
|
|
474
|
+
*/
|
|
475
|
+
injectKeys(items: MeshSendKeyItem[], opts?: {
|
|
476
|
+
allowModalOverride?: boolean;
|
|
477
|
+
}): Promise<{
|
|
478
|
+
ok: true;
|
|
479
|
+
keys: MeshSendKeyName[];
|
|
480
|
+
hasDestructive: boolean;
|
|
481
|
+
submits: boolean;
|
|
482
|
+
bytes: number;
|
|
483
|
+
} | {
|
|
484
|
+
ok: false;
|
|
485
|
+
refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker';
|
|
486
|
+
keys: MeshSendKeyName[];
|
|
487
|
+
hasDestructive: boolean;
|
|
488
|
+
}>;
|
|
489
|
+
/**
|
|
490
|
+
* MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
|
|
491
|
+
* watchdog for coordinator-spawned mesh worker sessions. Driven by the
|
|
492
|
+
* ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
|
|
493
|
+
* ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
|
|
494
|
+
* clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
|
|
495
|
+
* live worker's screen has been byte-for-byte unchanged for
|
|
496
|
+
* MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
|
|
497
|
+
* monitor:no_progress event down the existing task_stalled ledger +
|
|
498
|
+
* pendingCoordinatorEvent path.
|
|
499
|
+
*
|
|
500
|
+
* Deliberately status-agnostic: it does NOT read getStatus()'s reported status
|
|
501
|
+
* (which would couple it to the generating-only StatusMonitor and the
|
|
502
|
+
* idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
|
|
503
|
+
* minutes; that is accepted and surfaced as an informational stall (NOT a
|
|
504
|
+
* failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
|
|
505
|
+
* called here, so status heartbeats never move the stall anchor.
|
|
506
|
+
*
|
|
507
|
+
* Anchoring: the episode arms against the current lastOutputAt; a worker that
|
|
508
|
+
* has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
|
|
509
|
+
* time) so a silent spawn is still caught. Any new output re-arms the anchor
|
|
510
|
+
* and clears the emitted flag, so one continuous stall emits at most once and a
|
|
511
|
+
* later stall re-arms cleanly.
|
|
512
|
+
*/
|
|
513
|
+
checkMeshWorkerStall(now?: number): void;
|
|
432
514
|
/**
|
|
433
515
|
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
434
516
|
* persist before the in-progress settle gate is torn down. For a delegated
|
|
@@ -164,6 +164,15 @@ export interface ProviderInstance {
|
|
|
164
164
|
init(context: InstanceContext): Promise<void>;
|
|
165
165
|
/** Tick — periodic status refresh (IDE: readChat, Extension: stream collection) */
|
|
166
166
|
onTick(): Promise<void>;
|
|
167
|
+
/**
|
|
168
|
+
* MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
|
|
169
|
+
* watchdog for coordinator-spawned mesh worker sessions, invoked from the
|
|
170
|
+
* ProviderInstanceManager's existing tick loop (no separate timer). Fires ONE
|
|
171
|
+
* informational monitor:no_progress event when a live worker's raw PTY output
|
|
172
|
+
* has been unchanged past the stall threshold. Optional — only CLI instances
|
|
173
|
+
* (which own a PTY / lastOutputAt clock) implement it; a no-op elsewhere.
|
|
174
|
+
*/
|
|
175
|
+
checkMeshWorkerStall?(now?: number): void;
|
|
167
176
|
/** Return current status */
|
|
168
177
|
getState(): ProviderState;
|
|
169
178
|
/**
|
|
@@ -255,6 +255,17 @@ export interface RepoMeshPolicy {
|
|
|
255
255
|
* A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
|
|
256
256
|
*/
|
|
257
257
|
delegatedWorkerAutoApprove?: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* MESH-SEND-KEYS (feature 3): opt-in to allow the coordinator to inject
|
|
260
|
+
* DESTRUCTIVE keys (CTRL_C / ESC) into a worker PTY via mesh_send_keys. These
|
|
261
|
+
* can kill or derail the worker process, and delegatedWorkerAutoApprove is a
|
|
262
|
+
* TOOL-CONSENT policy, not a PTY-input authorization — so a destructive key
|
|
263
|
+
* injection additionally requires this explicit mesh-owner opt-in AND a
|
|
264
|
+
* per-call confirm_destructive=true. Defaults to false (destructive keys
|
|
265
|
+
* refused). Non-destructive keys (text/ENTER/arrows/TAB/BACKSPACE) are
|
|
266
|
+
* unaffected. A node policy may override per-node.
|
|
267
|
+
*/
|
|
268
|
+
allowSendKeysDestructive?: boolean;
|
|
258
269
|
/**
|
|
259
270
|
* What to do with delegated session-host records for a node when it is removed.
|
|
260
271
|
* Defaults to 'preserve' so completed work can be reviewed later and live
|
|
@@ -374,6 +385,11 @@ export interface RepoMeshNodePolicy {
|
|
|
374
385
|
* precedence over the mesh-level policy for worker sessions launched onto this node.
|
|
375
386
|
*/
|
|
376
387
|
delegatedWorkerAutoApprove?: boolean;
|
|
388
|
+
/**
|
|
389
|
+
* MESH-SEND-KEYS (feature 3): per-node override for
|
|
390
|
+
* RepoMeshPolicy.allowSendKeysDestructive.
|
|
391
|
+
*/
|
|
392
|
+
allowSendKeysDestructive?: boolean;
|
|
377
393
|
/**
|
|
378
394
|
* Optional associated/external repos that must be checked alongside this node.
|
|
379
395
|
* These are explicit policy/config entries only; Repo Mesh does not auto-discover
|
|
@@ -479,6 +495,13 @@ export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined
|
|
|
479
495
|
* launch path merges the envelope as a settingsOverride on top of the provider defaults.
|
|
480
496
|
*/
|
|
481
497
|
export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
|
|
498
|
+
/**
|
|
499
|
+
* MESH-SEND-KEYS (feature 3): resolve whether DESTRUCTIVE key injection
|
|
500
|
+
* (CTRL_C/ESC via mesh_send_keys) is permitted for a node. Node policy overrides
|
|
501
|
+
* mesh policy; DEFAULTS TO FALSE (fail-closed) — a destructive key still requires
|
|
502
|
+
* a per-call confirm_destructive=true on top of this opt-in.
|
|
503
|
+
*/
|
|
504
|
+
export declare function resolveAllowSendKeysDestructive(meshPolicy?: Pick<RepoMeshPolicy, 'allowSendKeysDestructive'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'allowSendKeysDestructive'> | null): boolean;
|
|
482
505
|
/**
|
|
483
506
|
* Resolve the enforced per-(node, provider) maxParallel cap from a node's resolved
|
|
484
507
|
* capability slots, or undefined when no matching slot declares a finite cap. Used
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.553",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.553",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.553",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as os from 'os';
|
|
18
|
+
import { createHash } from 'crypto';
|
|
18
19
|
import type { CliAdapter, CliLaunchInfo } from '../cli-adapter-types.js';
|
|
19
20
|
import type { InteractivePromptResponse } from '../providers/types/interactive-prompt.js';
|
|
20
21
|
import { LOG } from '../logging/logger.js';
|
|
@@ -43,7 +44,11 @@ import {
|
|
|
43
44
|
normalizeScreenSnapshot,
|
|
44
45
|
promptLikelyVisible,
|
|
45
46
|
sanitizeTerminalText,
|
|
47
|
+
truncateToByteTailByLine,
|
|
48
|
+
encodeMeshSendKeys,
|
|
46
49
|
TerminalTranscriptAccumulator,
|
|
50
|
+
type MeshSendKeyItem,
|
|
51
|
+
type MeshSendKeyName,
|
|
47
52
|
type CliChatMessage,
|
|
48
53
|
type CliProviderModule,
|
|
49
54
|
type CliScriptInput,
|
|
@@ -308,6 +313,11 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
308
313
|
result: any;
|
|
309
314
|
} | null = null;
|
|
310
315
|
private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
|
|
316
|
+
// MESH-READ-TERMINAL (feature 2): byte caps for getTerminalScreenSnapshot.
|
|
317
|
+
// Byte, not char — a multi-byte-glyph screen can exceed an MCP payload cap
|
|
318
|
+
// while the char count still looks safe. 32KiB default, 64KiB absolute hard cap.
|
|
319
|
+
private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
|
|
320
|
+
private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
|
|
311
321
|
// (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
|
|
312
322
|
// session must show before the poll-static-idle confirm fires. 2 = one extra
|
|
313
323
|
// status tick of hysteresis: enough to reject a single momentary-silence
|
|
@@ -2297,6 +2307,131 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
2297
2307
|
};
|
|
2298
2308
|
}
|
|
2299
2309
|
isAlive(): boolean { return this.ptyProcess !== null; }
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* MESH-READ-TERMINAL (feature 2: RAW terminal read). Narrow, least-privilege
|
|
2313
|
+
* read of the CURRENT rendered viewport for mesh_read_terminal. Deliberately
|
|
2314
|
+
* NARROWER than getSnapshot()/getDebugSnapshot():
|
|
2315
|
+
* - It returns ONLY the terminal's current rendered viewport (what a human
|
|
2316
|
+
* would see on screen right now), the cursor position and the viewport
|
|
2317
|
+
* size. NO debug buffers, NO parser/FSM state, NO scrollback/history.
|
|
2318
|
+
* - It does NOT call getParseScreenText() (which may graft an older snapshot
|
|
2319
|
+
* onto the current frame for parse accuracy) — the caller asked for the
|
|
2320
|
+
* live viewport, not a parse-optimized composite.
|
|
2321
|
+
* - The payload is bounded in BYTES (UTF-8) with bottom-tail preservation so
|
|
2322
|
+
* a screen of multi-byte glyphs can never exceed the MCP payload cap. See
|
|
2323
|
+
* truncateToByteTailByLine.
|
|
2324
|
+
*
|
|
2325
|
+
* SECURITY NOTE: the raw viewport can contain tokens / command args / env
|
|
2326
|
+
* values / user data. Callers MUST gate this on mesh ownership and MUST NOT
|
|
2327
|
+
* log the returned text. Opt-in redaction is intentionally out of scope for
|
|
2328
|
+
* this feature and left as a future enhancement.
|
|
2329
|
+
*
|
|
2330
|
+
* `maxBytes` is clamped to [1KiB, ABSOLUTE_MAX] (default 32KiB, hard cap 64KiB).
|
|
2331
|
+
*/
|
|
2332
|
+
getTerminalScreenSnapshot(maxBytes = ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES): {
|
|
2333
|
+
text: string;
|
|
2334
|
+
cursor: { col: number; row: number };
|
|
2335
|
+
cols: number;
|
|
2336
|
+
rows: number;
|
|
2337
|
+
truncated: boolean;
|
|
2338
|
+
originalBytes: number;
|
|
2339
|
+
returnedBytes: number;
|
|
2340
|
+
hash: string;
|
|
2341
|
+
} {
|
|
2342
|
+
const cap = Math.min(
|
|
2343
|
+
ProviderCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
|
|
2344
|
+
Math.max(1024, Math.floor(maxBytes) || ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES),
|
|
2345
|
+
);
|
|
2346
|
+
// Current rendered viewport only — no scrollback, no parse composite.
|
|
2347
|
+
const rawViewport = this.terminalScreen.getText() || '';
|
|
2348
|
+
const size = this.terminalScreen.getSize();
|
|
2349
|
+
const cursor = this.terminalScreen.getCursorPosition();
|
|
2350
|
+
const truncation = truncateToByteTailByLine(rawViewport, cap);
|
|
2351
|
+
const hash = createHash('sha256').update(rawViewport, 'utf8').digest('hex').slice(0, 16);
|
|
2352
|
+
return {
|
|
2353
|
+
text: truncation.text,
|
|
2354
|
+
cursor,
|
|
2355
|
+
cols: size.cols,
|
|
2356
|
+
rows: size.rows,
|
|
2357
|
+
truncated: truncation.truncated,
|
|
2358
|
+
originalBytes: truncation.originalBytes,
|
|
2359
|
+
returnedBytes: truncation.returnedBytes,
|
|
2360
|
+
hash,
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key sequence
|
|
2366
|
+
* into the PTY for the mesh_send_keys tool. Reuses the same serialized write
|
|
2367
|
+
* path as sends (writeToPty via writeRaw semantics) so the injection FIFO-
|
|
2368
|
+
* orders behind any in-flight write.
|
|
2369
|
+
*
|
|
2370
|
+
* Two guards run INSIDE this method, immediately before the write, so they see
|
|
2371
|
+
* a consistent snapshot of the adapter's submit/echo/queue state (no async gap
|
|
2372
|
+
* between check and write — the encode is synchronous and the write is chained
|
|
2373
|
+
* atomically after it):
|
|
2374
|
+
*
|
|
2375
|
+
* 1. submit-race recheck (echo-gate): even though writeToPty is FIFO, an
|
|
2376
|
+
* already-SCHEDULED echo-gated Enter (engine.submitPendingUntil in the
|
|
2377
|
+
* future), an armed stuck-submit retry (submitRetryTimer), or an in-flight
|
|
2378
|
+
* pending-outbound flush can submit at a DIFFERENT tick than our write —
|
|
2379
|
+
* so an injected literal could be submitted by a pending Enter, or our
|
|
2380
|
+
* ENTER could submit a half-typed pending body. When any of those is live
|
|
2381
|
+
* we REFUSE the injection rather than interleave.
|
|
2382
|
+
*
|
|
2383
|
+
* 2. modal fail-closed: if the session is parked on an actionable approval
|
|
2384
|
+
* modal, refuse a NON-destructive injection (ENTER/text/arrows) and direct
|
|
2385
|
+
* the caller to mesh_approve — so a modal choice can't be confirmed via
|
|
2386
|
+
* send_keys to bypass the approval policy. (A destructive ESC/CTRL_C, which
|
|
2387
|
+
* dismisses rather than confirms, is allowed to proceed past this gate; it
|
|
2388
|
+
* is separately gated by confirm_destructive + policy at the tool layer.)
|
|
2389
|
+
*
|
|
2390
|
+
* The caller (tool layer) owns the destructive-key double gate and the audit
|
|
2391
|
+
* ledger. This method NEVER logs the literal text (only key enums / byte len).
|
|
2392
|
+
*/
|
|
2393
|
+
async injectKeys(
|
|
2394
|
+
items: MeshSendKeyItem[],
|
|
2395
|
+
opts: { allowModalOverride?: boolean } = {},
|
|
2396
|
+
): Promise<
|
|
2397
|
+
| { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
|
|
2398
|
+
| { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
|
|
2399
|
+
> {
|
|
2400
|
+
if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
|
|
2401
|
+
const encoded = encodeMeshSendKeys(items);
|
|
2402
|
+
|
|
2403
|
+
// (1) submit-race recheck — atomic w.r.t. the write below (no await between).
|
|
2404
|
+
const now = Date.now();
|
|
2405
|
+
const submitPending = this.engine.submitPendingUntil > now;
|
|
2406
|
+
const submitRetryArmed = this.submitRetryTimer !== null;
|
|
2407
|
+
const outboundBusy = this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length > 0;
|
|
2408
|
+
if (submitPending || submitRetryArmed || outboundBusy) {
|
|
2409
|
+
LOG.warn('CLI', `[${this.cliType}] send_keys refused (submit_race): submitPending=${submitPending} submitRetry=${submitRetryArmed} outboundBusy=${outboundBusy} keys=${encoded.keys.join(',')}`);
|
|
2410
|
+
return { ok: false, refused: 'submit_race', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// (2) modal fail-closed — a NON-destructive injection into an actionable
|
|
2414
|
+
// modal is refused unless explicitly overridden.
|
|
2415
|
+
const modalActive = this.engine.hasActionableApproval();
|
|
2416
|
+
if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
|
|
2417
|
+
LOG.warn('CLI', `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(',')} — use mesh_approve`);
|
|
2418
|
+
return { ok: false, refused: 'actionable_modal', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// Atomic write: the full encoded sequence goes out in ONE writeToPty (which
|
|
2422
|
+
// chains onto the write FIFO). text+ENTER is already one contiguous string,
|
|
2423
|
+
// so the submit key can never be separated from the text it submits.
|
|
2424
|
+
await this.writeToPty(encoded.sequence);
|
|
2425
|
+
LOG.info('CLI', `[${this.cliType}] send_keys injected keys=${encoded.keys.join(',') || '(text-only)'} bytes=${Buffer.byteLength(encoded.sequence, 'utf8')} destructive=${encoded.hasDestructive}`);
|
|
2426
|
+
return {
|
|
2427
|
+
ok: true,
|
|
2428
|
+
keys: encoded.keys,
|
|
2429
|
+
hasDestructive: encoded.hasDestructive,
|
|
2430
|
+
submits: encoded.submits,
|
|
2431
|
+
bytes: Buffer.byteLength(encoded.sequence, 'utf8'),
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2300
2435
|
flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
|
|
2301
2436
|
|
|
2302
2437
|
async writeRaw(data: string | Buffer): Promise<void> {
|
|
@@ -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);
|
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'] {
|