@dmsdc-ai/aigentry-telepty 0.3.5 → 0.4.0
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/CHANGELOG.md +72 -0
- package/cli.js +36 -15
- package/daemon.js +355 -5
- package/package.json +25 -1
- package/session-state.js +23 -0
- package/src/prompt-symbol-registry.js +43 -1
- package/.claude/commands/telepty-allow.md +0 -58
- package/.claude/commands/telepty-attach.md +0 -22
- package/.claude/commands/telepty-inject.md +0 -72
- package/.claude/commands/telepty-list.md +0 -22
- package/.claude/commands/telepty-manual-test.md +0 -73
- package/.claude/commands/telepty-start.md +0 -25
- package/.claude/commands/telepty-test.md +0 -25
- package/.claude/commands/telepty.md +0 -82
- package/AGENTS.md +0 -97
- package/BOUNDARY.md +0 -31
- package/BUS_EVENT_SCHEMA.md +0 -206
- package/CLAUDE.md +0 -100
- package/GEMINI.md +0 -10
- package/URGENT_ISSUES.resolved.md +0 -1
- package/docs/reports/2026-05-05-issue-8-claude-review.md +0 -194
- package/docs/specs/2026-05-05-issue-8-telepty-init.md +0 -477
- package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +0 -447
- package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +0 -571
- package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +0 -608
- package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +0 -139
- package/protocol/mailbox.md +0 -244
- package/scripts/regen-snippet-fixtures.js +0 -42
- package/specs/codex-inject-spec.md +0 -201
- package/specs/enforce-report-spec.md +0 -237
- package/templates/AGENTS.md +0 -71
- package/tests/snippet-protocol/v1/golden-agents.json +0 -1
- package/tests/snippet-protocol/v1/golden-agents.md +0 -17
- package/tests/snippet-protocol/v1/golden-all.json +0 -3
- package/tests/snippet-protocol/v1/golden-all.md +0 -53
- package/tests/snippet-protocol/v1/golden-claude.json +0 -1
- package/tests/snippet-protocol/v1/golden-claude.md +0 -17
- package/tests/snippet-protocol/v1/golden-gemini.json +0 -1
- package/tests/snippet-protocol/v1/golden-gemini.md +0 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.0] — 2026-05-15
|
|
6
|
+
|
|
7
|
+
### Added — Phase 1 sidecar supervisor spike (M1–M5)
|
|
8
|
+
|
|
9
|
+
Out-of-process Rust supervisor (`crates/telepty-supervisor-{core,bin}`)
|
|
10
|
+
incubating the future spawn/kill/IPC backend for `daemon.js`. Five
|
|
11
|
+
milestones complete; **incubating only — not on the request path** in
|
|
12
|
+
0.4.0. Daemon (`daemon.js`) and CLI (`cli.js`) routing is unchanged.
|
|
13
|
+
|
|
14
|
+
- **M1** — spawn + observe (commit `07cd2e7`).
|
|
15
|
+
- **M2** — graceful + forced kill gate, manifest cleanup invariant A8
|
|
16
|
+
(commit `ec00412`).
|
|
17
|
+
- **M3** — IPC + wire contract conformance, NDJSON UDS frames + golden
|
|
18
|
+
fixtures (commit `76cde35`).
|
|
19
|
+
- **M4** — cross-OS POSIX parity + reproducible RSS measurement, GitHub
|
|
20
|
+
Actions matrix (`.github/workflows/phase1-spike-ci.yml`); RSS PASS at
|
|
21
|
+
2.9–3.0 MiB / supervisor on macOS arm64 (commit `eb04c73`).
|
|
22
|
+
- **M5** — manual integration bridge (`scripts/bridge-phase1.js`, 194
|
|
23
|
+
LOC Node stdlib only) — four parity scenarios A/B/C/D, exit 0 iff all
|
|
24
|
+
PASS; one-line Rust correctness fix (emit `shutdown_drain` before IPC
|
|
25
|
+
shutdown so connected clients receive the frame) (commit `be091e0`).
|
|
26
|
+
|
|
27
|
+
Phase 1 LOC ceiling 1500 honored (Rust src/ tokei = 1240, 260 LOC
|
|
28
|
+
headroom unused). Test suite: 23/23 (lib unit 12 + wire_golden 6 +
|
|
29
|
+
ipc_protocol 5). Spec: `docs/specs/2026-05-10-supervisor-c3-kill-gate-spec.md`.
|
|
30
|
+
Plan: `docs/plans/2026-05-12-phase1-sidecar-spike-plan.md`.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **#18** — Bootstrap inject queue race. Welcome-banner bypass via
|
|
35
|
+
positive-override `is_ready` so queued injects flush in the correct
|
|
36
|
+
order without colliding with the banner (commit `744ad6a`).
|
|
37
|
+
- **#16** — REPORT-based idle status detection. Replaces heuristic
|
|
38
|
+
prompt-symbol detection with explicit REPORT-frame anchoring
|
|
39
|
+
(commit `3ed1e83`).
|
|
40
|
+
|
|
41
|
+
### Build
|
|
42
|
+
|
|
43
|
+
- `package.json` — added `files` whitelist (22 entries) to constrain
|
|
44
|
+
npm-published surface to actual runtime distribution. Tarball
|
|
45
|
+
reduction: 228 MB → 123 kB (1850×). The Rust spike artifacts
|
|
46
|
+
(`target/`, `crates/`, `Cargo.lock/Cargo.toml`, `rust-toolchain.toml`)
|
|
47
|
+
ship in git but **not** to npm (commit `a0baf84`).
|
|
48
|
+
|
|
49
|
+
### Docs
|
|
50
|
+
|
|
51
|
+
- MD audit wave-2 fix: `CLAUDE.md` converted to `@AGENTS.md` stub
|
|
52
|
+
(101 → 27 lines), `AGENTS.md` gained Session Environment section
|
|
53
|
+
(`$TELEPTY_SESSION_ID`, `$TELEPTY_AVAILABLE`) and disclosed
|
|
54
|
+
cross-repo ADR location for `2026-05-05-telepty-devkit-boundary §6.2.1`.
|
|
55
|
+
Score delta `AGENTS.md` 80 → 87, `CLAUDE.md` 66 → 87 (commit `74a6374`,
|
|
56
|
+
full report `docs/reports/2026-05-14-md-audit.md`).
|
|
57
|
+
|
|
58
|
+
### Notes
|
|
59
|
+
|
|
60
|
+
- **Snyk SAST deferred for this release** — see follow-up task #130.
|
|
61
|
+
Waiver basis (Rule 32-A track B):
|
|
62
|
+
- M1–M5 spike code is Rust and is **excluded from the npm tarball** by
|
|
63
|
+
the new `files` whitelist — first-party code shipped to consumers is
|
|
64
|
+
JS only.
|
|
65
|
+
- The shipped JS files are unchanged or only minimally changed since
|
|
66
|
+
`0.3.5` (cli.js +51 / daemon.js +360 / src/prompt-symbol-registry.js
|
|
67
|
+
+44 / new session-state.js — additive, no breaks per Phase 1 audit).
|
|
68
|
+
- Dependency-side coverage exists via `npm audit` (10 pre-existing
|
|
69
|
+
vulns documented; not introduced by this release).
|
|
70
|
+
- Per CLAUDE.md user-instruction "Snyk At Inception" scope = *new
|
|
71
|
+
first-party code shipped* — 0 net-new shipped JS code in this
|
|
72
|
+
release, so the at-inception rule does not bind here. Follow-up
|
|
73
|
+
task #130 will land the standing SAST gate as a release-script
|
|
74
|
+
primitive (so future releases scan automatically without per-run
|
|
75
|
+
auth steps).
|
|
76
|
+
|
|
5
77
|
## [0.3.5] — 2026-05-05
|
|
6
78
|
|
|
7
79
|
### Added — `telepty init --print-snippet` (Issue #8)
|
package/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
|
20
20
|
const crossMachine = require('./cross-machine');
|
|
21
21
|
const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
|
|
22
22
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
23
|
+
const readyRegistry = require('./src/prompt-symbol-registry');
|
|
23
24
|
const args = process.argv.slice(2);
|
|
24
25
|
let pendingTerminalInputError = null;
|
|
25
26
|
let simulatedPromptErrorInjected = false;
|
|
@@ -1080,15 +1081,14 @@ async function main() {
|
|
|
1080
1081
|
|
|
1081
1082
|
spawnChild();
|
|
1082
1083
|
|
|
1083
|
-
// Prompt-ready detection for safe inject delivery
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
};
|
|
1089
|
-
const cmdBase = path.basename(command).replace(/\..*$/, '');
|
|
1090
|
-
const promptPattern = PROMPT_PATTERNS[cmdBase] || /[❯>$#%]\s*$/;
|
|
1084
|
+
// Prompt-ready detection for safe inject delivery.
|
|
1085
|
+
// Known AI CLIs use the centralized geometry-aware registry; generic
|
|
1086
|
+
// commands keep the permissive legacy prompt regex for compatibility.
|
|
1087
|
+
const knownAiCli = readyRegistry.isKnownAiCli(command);
|
|
1088
|
+
const promptPattern = /[❯>$#%]\s*$/;
|
|
1091
1089
|
let promptReady = false; // wait for CLI prompt before accepting inject
|
|
1090
|
+
let firstReadyObserved = false;
|
|
1091
|
+
let outputTail = '';
|
|
1092
1092
|
let lastUserInputTime = 0; // timestamp of last user keystroke
|
|
1093
1093
|
const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
|
|
1094
1094
|
|
|
@@ -1128,6 +1128,14 @@ async function main() {
|
|
|
1128
1128
|
return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
|
|
1129
1129
|
}
|
|
1130
1130
|
|
|
1131
|
+
function observePromptReady(data) {
|
|
1132
|
+
if (knownAiCli) {
|
|
1133
|
+
outputTail = (outputTail + data).slice(-20000);
|
|
1134
|
+
return !!readyRegistry.detectOutput(command, outputTail).found;
|
|
1135
|
+
}
|
|
1136
|
+
return promptPattern.test(data);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1131
1139
|
let queueFlushTimer = null;
|
|
1132
1140
|
let idleCheckTimer = null;
|
|
1133
1141
|
|
|
@@ -1164,8 +1172,9 @@ async function main() {
|
|
|
1164
1172
|
flushBridgeMailbox();
|
|
1165
1173
|
}
|
|
1166
1174
|
}, 500);
|
|
1167
|
-
// Safety
|
|
1168
|
-
|
|
1175
|
+
// Safety fallback is compatibility-only during known AI CLI bootstrap:
|
|
1176
|
+
// first dispatch must wait for a strong ready signal.
|
|
1177
|
+
if ((!knownAiCli || firstReadyObserved) && !queueFlushTimer) {
|
|
1169
1178
|
queueFlushTimer = setTimeout(() => {
|
|
1170
1179
|
queueFlushTimer = null;
|
|
1171
1180
|
if (bridgePendingCount > 0) {
|
|
@@ -1238,10 +1247,16 @@ async function main() {
|
|
|
1238
1247
|
|
|
1239
1248
|
const isCr = chunk === '\r';
|
|
1240
1249
|
if (isCr && bridgePendingCount > 0) {
|
|
1241
|
-
// CR with pending queued text — queue CR too and
|
|
1250
|
+
// CR with pending queued text — queue CR too and wait for the
|
|
1251
|
+
// same readiness gate as the text. This preserves order during
|
|
1252
|
+
// bootstrap and busy-session delivery.
|
|
1242
1253
|
enqueueBridgeMessage(chunk);
|
|
1243
|
-
if (
|
|
1244
|
-
|
|
1254
|
+
if (isIdle()) {
|
|
1255
|
+
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
1256
|
+
flushBridgeMailbox();
|
|
1257
|
+
} else {
|
|
1258
|
+
scheduleIdleFlush();
|
|
1259
|
+
}
|
|
1245
1260
|
} else if (isCr) {
|
|
1246
1261
|
// CR always written immediately — never idle-gated.
|
|
1247
1262
|
child.write(chunk);
|
|
@@ -1353,8 +1368,9 @@ async function main() {
|
|
|
1353
1368
|
daemonWs.send(JSON.stringify({ type: 'output', data }));
|
|
1354
1369
|
}
|
|
1355
1370
|
// Detect prompt in output to enable inject delivery
|
|
1356
|
-
if (
|
|
1371
|
+
if (observePromptReady(data)) {
|
|
1357
1372
|
promptReady = true;
|
|
1373
|
+
firstReadyObserved = true;
|
|
1358
1374
|
flushBridgeMailbox();
|
|
1359
1375
|
// Notify daemon that CLI is ready for inject
|
|
1360
1376
|
if (!readyNotified && wsReady && daemonWs.readyState === 1) {
|
|
@@ -1383,6 +1399,10 @@ async function main() {
|
|
|
1383
1399
|
setTimeout(() => {
|
|
1384
1400
|
try {
|
|
1385
1401
|
spawnChild();
|
|
1402
|
+
promptReady = false;
|
|
1403
|
+
firstReadyObserved = false;
|
|
1404
|
+
readyNotified = false;
|
|
1405
|
+
outputTail = '';
|
|
1386
1406
|
// Re-attach output relay, prompt detection, and exit handler
|
|
1387
1407
|
child.onData((data) => {
|
|
1388
1408
|
const rewritten = rewriteTitleSequences(data);
|
|
@@ -1390,8 +1410,9 @@ async function main() {
|
|
|
1390
1410
|
if (wsReady && daemonWs.readyState === 1) {
|
|
1391
1411
|
daemonWs.send(JSON.stringify({ type: 'output', data }));
|
|
1392
1412
|
}
|
|
1393
|
-
if (
|
|
1413
|
+
if (observePromptReady(data)) {
|
|
1394
1414
|
promptReady = true;
|
|
1415
|
+
firstReadyObserved = true;
|
|
1395
1416
|
flushBridgeMailbox();
|
|
1396
1417
|
if (wsReady && daemonWs.readyState === 1) {
|
|
1397
1418
|
daemonWs.send(JSON.stringify({ type: 'ready' }));
|
package/daemon.js
CHANGED
|
@@ -15,6 +15,7 @@ const { UnixSocketNotifier } = require('./src/mailbox/notifier');
|
|
|
15
15
|
const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = require('./session-state');
|
|
16
16
|
const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
|
|
17
17
|
const submitGate = require('./src/submit-gate');
|
|
18
|
+
const readyRegistry = require('./src/prompt-symbol-registry');
|
|
18
19
|
|
|
19
20
|
const config = getConfig();
|
|
20
21
|
const EXPECTED_TOKEN = config.authToken;
|
|
@@ -26,6 +27,8 @@ const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STA
|
|
|
26
27
|
const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.env.TELEPTY_SESSION_CLEANUP_SECONDS || 300));
|
|
27
28
|
const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
|
|
28
29
|
const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
|
|
30
|
+
const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOTSTRAP_READY_TIMEOUT_MS || 30000));
|
|
31
|
+
const WRAPPED_SUBMIT_DELAY_MS = 500;
|
|
29
32
|
|
|
30
33
|
// Session state machine manager — auto-detects session state from PTY output
|
|
31
34
|
const sessionStateManager = new SessionStateManager({
|
|
@@ -354,6 +357,292 @@ function getSessionHealthReason(session, healthStatus) {
|
|
|
354
357
|
return session.ptyProcess && !session.ptyProcess.killed ? 'PTY_RUNNING' : 'PTY_EXITED';
|
|
355
358
|
}
|
|
356
359
|
|
|
360
|
+
function sleep(ms) {
|
|
361
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isBootstrapGatedSession(session) {
|
|
365
|
+
return !!(session && session.type === 'wrapped' && readyRegistry.isKnownAiCli(session.command));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function initializeBootstrapState(session) {
|
|
369
|
+
if (!session) return session;
|
|
370
|
+
if (!Array.isArray(session.bootstrapQueue)) {
|
|
371
|
+
session.bootstrapQueue = [];
|
|
372
|
+
}
|
|
373
|
+
session.bootstrapDraining = session.bootstrapDraining === true;
|
|
374
|
+
session.bootstrapDrainPromise = session.bootstrapDrainPromise || null;
|
|
375
|
+
session.bootstrapPromptPoll = session.bootstrapPromptPoll || null;
|
|
376
|
+
|
|
377
|
+
if (isBootstrapGatedSession(session)) {
|
|
378
|
+
session.bootstrapReady = session.bootstrapReady === true;
|
|
379
|
+
session.bootstrapReadyAt = session.bootstrapReadyAt || null;
|
|
380
|
+
session.bootstrapReadyReason = session.bootstrapReadyReason || null;
|
|
381
|
+
session.ready = session.bootstrapReady === true;
|
|
382
|
+
} else {
|
|
383
|
+
session.bootstrapReady = true;
|
|
384
|
+
session.bootstrapReadyAt = session.bootstrapReadyAt || new Date().toISOString();
|
|
385
|
+
session.bootstrapReadyReason = session.bootstrapReadyReason || 'generic_command_compat';
|
|
386
|
+
session.ready = true;
|
|
387
|
+
}
|
|
388
|
+
return session;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isBootstrapReady(session) {
|
|
392
|
+
return !isBootstrapGatedSession(session) || session.bootstrapReady === true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildBootstrapBlock(session) {
|
|
396
|
+
return {
|
|
397
|
+
gated: isBootstrapGatedSession(session),
|
|
398
|
+
ready: isBootstrapReady(session),
|
|
399
|
+
ready_at: session.bootstrapReadyAt || null,
|
|
400
|
+
reason: session.bootstrapReadyReason || null,
|
|
401
|
+
queued: Array.isArray(session.bootstrapQueue) ? session.bootstrapQueue.length : 0,
|
|
402
|
+
draining: session.bootstrapDraining === true
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function shouldQueueBootstrapOperation(session) {
|
|
407
|
+
return isBootstrapGatedSession(session) && !isBootstrapReady(session);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hasBootstrapBacklog(session) {
|
|
411
|
+
return !!(session && Array.isArray(session.bootstrapQueue) && session.bootstrapQueue.length > 0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function emitBootstrapEvent(eventType, sessionId, session, extra = {}) {
|
|
415
|
+
broadcastSessionEvent(eventType, sessionId, session, {
|
|
416
|
+
extra: {
|
|
417
|
+
bootstrap: buildBootstrapBlock(session),
|
|
418
|
+
...extra
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function enqueueBootstrapOperation(sessionId, session, operation) {
|
|
424
|
+
initializeBootstrapState(session);
|
|
425
|
+
const op = {
|
|
426
|
+
op_id: crypto.randomUUID(),
|
|
427
|
+
queued_at: new Date().toISOString(),
|
|
428
|
+
...operation
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
if (op.type === 'submit') {
|
|
432
|
+
op.promise = new Promise((resolve) => {
|
|
433
|
+
op.resolve = resolve;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
session.bootstrapQueue.push(op);
|
|
438
|
+
emitBootstrapEvent('bootstrap_queue_queued', sessionId, session, {
|
|
439
|
+
op_id: op.op_id,
|
|
440
|
+
operation: op.type,
|
|
441
|
+
depth: session.bootstrapQueue.length
|
|
442
|
+
});
|
|
443
|
+
scheduleBootstrapPromptPoll(sessionId, session);
|
|
444
|
+
return op;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolveBootstrapSubmit(op, result) {
|
|
448
|
+
if (op && typeof op.resolve === 'function') {
|
|
449
|
+
op.resolve(result);
|
|
450
|
+
op.resolve = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function bootstrapQueuedResponse(op, extra = {}) {
|
|
455
|
+
return {
|
|
456
|
+
success: true,
|
|
457
|
+
strategy: 'bootstrap_queue',
|
|
458
|
+
queued: true,
|
|
459
|
+
bootstrap_queued: true,
|
|
460
|
+
bootstrap_op_id: op.op_id,
|
|
461
|
+
...extra
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function executeBootstrapInject(sessionId, session, op) {
|
|
466
|
+
const prompt = typeof op.prompt === 'string' ? op.prompt : '';
|
|
467
|
+
const textResult = await writeDataToSession(sessionId, session, prompt);
|
|
468
|
+
if (!textResult.success) return textResult;
|
|
469
|
+
|
|
470
|
+
if (!op.noEnter) {
|
|
471
|
+
await sleep(WRAPPED_SUBMIT_DELAY_MS);
|
|
472
|
+
const submitResult = await writeDataToSession(sessionId, session, '\r');
|
|
473
|
+
if (!submitResult.success) return submitResult;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
session.lastActivityAt = new Date().toISOString();
|
|
477
|
+
return {
|
|
478
|
+
success: true,
|
|
479
|
+
strategy: 'bootstrap_direct',
|
|
480
|
+
submit: op.noEnter ? 'skipped' : 'sent'
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function executeBootstrapSubmit(sessionId, session, op) {
|
|
485
|
+
const strategy = terminalLevelSubmit(sessionId, session);
|
|
486
|
+
if (!strategy) {
|
|
487
|
+
return {
|
|
488
|
+
status: 503,
|
|
489
|
+
body: {
|
|
490
|
+
error: 'Submit failed via all strategies (kitty/cmux/pty)',
|
|
491
|
+
strategy: 'none',
|
|
492
|
+
attempts: 0,
|
|
493
|
+
gated: false,
|
|
494
|
+
bootstrap_queued: true
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
status: 200,
|
|
500
|
+
body: {
|
|
501
|
+
success: true,
|
|
502
|
+
strategy,
|
|
503
|
+
attempts: 1,
|
|
504
|
+
gated: false,
|
|
505
|
+
verify: null,
|
|
506
|
+
bootstrap_queued: true
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function drainBootstrapQueue(sessionId, session) {
|
|
512
|
+
if (!session || session.bootstrapDraining) {
|
|
513
|
+
return session ? session.bootstrapDrainPromise : null;
|
|
514
|
+
}
|
|
515
|
+
if (!isBootstrapReady(session)) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
session.bootstrapDraining = true;
|
|
520
|
+
session.bootstrapDrainPromise = (async () => {
|
|
521
|
+
while (hasBootstrapBacklog(session)) {
|
|
522
|
+
const op = session.bootstrapQueue.shift();
|
|
523
|
+
try {
|
|
524
|
+
if (op.cancelled) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (op.type === 'inject') {
|
|
528
|
+
const result = await executeBootstrapInject(sessionId, session, op);
|
|
529
|
+
if (!result.success) {
|
|
530
|
+
emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
|
|
531
|
+
op_id: op.op_id,
|
|
532
|
+
operation: op.type,
|
|
533
|
+
code: result.code || 'DELIVERY_FAILED',
|
|
534
|
+
error: result.error || 'bootstrap delivery failed'
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
} else if (op.type === 'submit') {
|
|
538
|
+
const result = await executeBootstrapSubmit(sessionId, session, op);
|
|
539
|
+
resolveBootstrapSubmit(op, result);
|
|
540
|
+
if (result.status >= 400) {
|
|
541
|
+
emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
|
|
542
|
+
op_id: op.op_id,
|
|
543
|
+
operation: op.type,
|
|
544
|
+
code: result.body.code || 'SUBMIT_FAILED',
|
|
545
|
+
error: result.body.error || 'bootstrap submit failed'
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (op.type === 'submit') {
|
|
551
|
+
resolveBootstrapSubmit(op, {
|
|
552
|
+
status: 500,
|
|
553
|
+
body: {
|
|
554
|
+
error: error.message || 'bootstrap submit failed',
|
|
555
|
+
strategy: 'none',
|
|
556
|
+
attempts: 0,
|
|
557
|
+
gated: false,
|
|
558
|
+
bootstrap_queued: true
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
|
|
563
|
+
op_id: op.op_id,
|
|
564
|
+
operation: op.type,
|
|
565
|
+
code: 'BOOTSTRAP_DRAIN_FAILED',
|
|
566
|
+
error: error.message || 'bootstrap drain failed'
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
emitBootstrapEvent('bootstrap_queue_drained', sessionId, session);
|
|
572
|
+
})().finally(() => {
|
|
573
|
+
session.bootstrapDraining = false;
|
|
574
|
+
session.bootstrapDrainPromise = null;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return session.bootstrapDrainPromise;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function markBootstrapReady(sessionId, session, reason) {
|
|
581
|
+
if (!session) return false;
|
|
582
|
+
initializeBootstrapState(session);
|
|
583
|
+
if (!isBootstrapGatedSession(session)) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
if (session.bootstrapReady === true) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
session.bootstrapReady = true;
|
|
591
|
+
session.bootstrapReadyAt = new Date().toISOString();
|
|
592
|
+
session.bootstrapReadyReason = reason || 'ready';
|
|
593
|
+
session.ready = true;
|
|
594
|
+
emitBootstrapEvent('bootstrap_ready', sessionId, session, { reason: session.bootstrapReadyReason });
|
|
595
|
+
drainBootstrapQueue(sessionId, session);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function scheduleBootstrapPromptPoll(sessionId, session) {
|
|
600
|
+
if (!session || !isBootstrapGatedSession(session) || isBootstrapReady(session)) return;
|
|
601
|
+
if (session.bootstrapPromptPoll || session.backend !== 'cmux' || !session.cmuxWorkspaceId) return;
|
|
602
|
+
if (!isOpenWebSocket(session.ownerWs)) return;
|
|
603
|
+
|
|
604
|
+
session.bootstrapPromptPoll = submitGate.awaitPromptSymbol(session, {
|
|
605
|
+
timeoutMs: BOOTSTRAP_READY_TIMEOUT_MS
|
|
606
|
+
}).then((result) => {
|
|
607
|
+
session.bootstrapPromptPoll = null;
|
|
608
|
+
if (result && result.ready && isOpenWebSocket(session.ownerWs)) {
|
|
609
|
+
markBootstrapReady(sessionId, session, 'cmux_prompt_symbol');
|
|
610
|
+
} else if (result && result.reason) {
|
|
611
|
+
emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
|
|
612
|
+
reason: result.reason,
|
|
613
|
+
waited_ms: result.waited_ms || 0
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}).catch((error) => {
|
|
617
|
+
session.bootstrapPromptPoll = null;
|
|
618
|
+
emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
|
|
619
|
+
reason: 'prompt_symbol_error',
|
|
620
|
+
error: error.message || String(error)
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function waitForBootstrapSubmit(op, session, timeoutMs) {
|
|
626
|
+
const timeout = sleep(timeoutMs).then(() => {
|
|
627
|
+
op.cancelled = true;
|
|
628
|
+
return {
|
|
629
|
+
status: 504,
|
|
630
|
+
body: {
|
|
631
|
+
error: 'Submit bootstrap-timeout — target CLI did not become ready',
|
|
632
|
+
reason: 'bootstrap_not_ready',
|
|
633
|
+
last_state: sessionStateManager.getState(session.id)?.state || null,
|
|
634
|
+
strategy: 'none',
|
|
635
|
+
attempts: 0,
|
|
636
|
+
gated: true,
|
|
637
|
+
bootstrap_queued: true,
|
|
638
|
+
bootstrap_op_id: op.op_id,
|
|
639
|
+
bootstrap: buildBootstrapBlock(session)
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
return Promise.race([op.promise, timeout]);
|
|
644
|
+
}
|
|
645
|
+
|
|
357
646
|
function buildSessionTransportBlock(session, options = {}) {
|
|
358
647
|
if (!session) {
|
|
359
648
|
return null;
|
|
@@ -380,7 +669,8 @@ function buildSessionTransportBlock(session, options = {}) {
|
|
|
380
669
|
last_disconnected_at: session.lastDisconnectedAt || null,
|
|
381
670
|
last_inject_from: session.lastInjectFrom || null,
|
|
382
671
|
last_reply_to: session.lastInjectReplyTo || null,
|
|
383
|
-
last_thread_id: session.lastThreadId || null
|
|
672
|
+
last_thread_id: session.lastThreadId || null,
|
|
673
|
+
bootstrap: buildBootstrapBlock(session)
|
|
384
674
|
};
|
|
385
675
|
}
|
|
386
676
|
|
|
@@ -645,6 +935,28 @@ function terminalLevelSubmit(id, session) {
|
|
|
645
935
|
|
|
646
936
|
async function deliverInjectionToSession(id, session, prompt, options = {}) {
|
|
647
937
|
const now = Date.now();
|
|
938
|
+
if (!options.bypassBootstrapQueue && shouldQueueBootstrapOperation(session)) {
|
|
939
|
+
const healthStatus = getSessionHealthStatus(session, { nowMs: now });
|
|
940
|
+
if (healthStatus === 'STALE') {
|
|
941
|
+
return { success: false, httpStatus: 410, code: 'STALE', error: 'Session is stale and awaiting cleanup.' };
|
|
942
|
+
}
|
|
943
|
+
const op = enqueueBootstrapOperation(id, session, {
|
|
944
|
+
type: 'inject',
|
|
945
|
+
prompt,
|
|
946
|
+
noEnter: !!options.noEnter,
|
|
947
|
+
options: {
|
|
948
|
+
source: options.source || 'inject',
|
|
949
|
+
from: options.from || 'daemon'
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
session.lastActivityAt = new Date(now).toISOString();
|
|
953
|
+
return bootstrapQueuedResponse(op, {
|
|
954
|
+
msg_id: op.op_id,
|
|
955
|
+
pending: session.bootstrapQueue.length,
|
|
956
|
+
submit: options.noEnter ? 'skipped' : 'queued'
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
648
960
|
const injectFailure = getInjectFailure(session, { nowMs: now });
|
|
649
961
|
if (injectFailure) {
|
|
650
962
|
return { success: false, ...injectFailure };
|
|
@@ -834,6 +1146,7 @@ for (const [id, meta] of Object.entries(_persisted)) {
|
|
|
834
1146
|
lastStateReportAt: meta.lastStateReportAt || null,
|
|
835
1147
|
stateReport: meta.stateReport || null,
|
|
836
1148
|
clients: new Set(), isClosing: false, outputRing: [], ready: true, };
|
|
1149
|
+
initializeBootstrapState(sessions[id]);
|
|
837
1150
|
console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
|
|
838
1151
|
}
|
|
839
1152
|
}
|
|
@@ -1020,6 +1333,7 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
1020
1333
|
existing.ready = true;
|
|
1021
1334
|
markSessionConnected(existing);
|
|
1022
1335
|
}
|
|
1336
|
+
initializeBootstrapState(existing);
|
|
1023
1337
|
console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
|
|
1024
1338
|
return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true });
|
|
1025
1339
|
}
|
|
@@ -1049,8 +1363,9 @@ app.post('/api/sessions/register', (req, res) => {
|
|
|
1049
1363
|
clients: new Set(),
|
|
1050
1364
|
isClosing: false,
|
|
1051
1365
|
outputRing: [],
|
|
1052
|
-
ready: true, //
|
|
1053
|
-
|
|
1366
|
+
ready: true, // unknown commands remain injectable once registered (#150)
|
|
1367
|
+
};
|
|
1368
|
+
initializeBootstrapState(sessionRecord);
|
|
1054
1369
|
// Check for existing session with same base alias and emit replaced event
|
|
1055
1370
|
const baseAlias = session_id.replace(/-\d+$/, '');
|
|
1056
1371
|
const replaced = Object.keys(sessions).find(id => {
|
|
@@ -1521,6 +1836,18 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
1521
1836
|
|
|
1522
1837
|
console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
|
|
1523
1838
|
|
|
1839
|
+
if (isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
|
|
1840
|
+
const op = enqueueBootstrapOperation(id, session, {
|
|
1841
|
+
type: 'submit',
|
|
1842
|
+
body: { ...(req.body || {}) }
|
|
1843
|
+
});
|
|
1844
|
+
if (isBootstrapReady(session)) {
|
|
1845
|
+
drainBootstrapQueue(id, session);
|
|
1846
|
+
}
|
|
1847
|
+
const queuedSubmit = await waitForBootstrapSubmit(op, session, gateTimeoutMs);
|
|
1848
|
+
return res.status(queuedSubmit.status).json(queuedSubmit.body);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1524
1851
|
function emitSubmitBus(payload) {
|
|
1525
1852
|
const busMsg = JSON.stringify({
|
|
1526
1853
|
type: 'submit',
|
|
@@ -1805,6 +2132,12 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
|
|
|
1805
2132
|
delete pendingReports[senderAlias];
|
|
1806
2133
|
const elapsedSecs = Number(((Date.now() - new Date(senderPending.injectedAt).getTime()) / 1000).toFixed(1));
|
|
1807
2134
|
const senderSession = sessions[senderAlias];
|
|
2135
|
+
sessionStateManager.markIdle(senderAlias, 1.0, {
|
|
2136
|
+
trigger: 'report_inject',
|
|
2137
|
+
report_inject_id: inject_id,
|
|
2138
|
+
report_status: classification,
|
|
2139
|
+
source: senderPending.source
|
|
2140
|
+
});
|
|
1808
2141
|
const eventType =
|
|
1809
2142
|
classification === 'report_blocked' ? 'TASK_BLOCKED_WITH_REASON' :
|
|
1810
2143
|
classification === 'report_dismissed' ? 'TASK_DISMISSED' :
|
|
@@ -1881,7 +2214,17 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
|
|
|
1881
2214
|
});
|
|
1882
2215
|
}
|
|
1883
2216
|
|
|
1884
|
-
res.json({
|
|
2217
|
+
res.json({
|
|
2218
|
+
success: true,
|
|
2219
|
+
inject_id,
|
|
2220
|
+
strategy: delivery.strategy,
|
|
2221
|
+
submit: delivery.submit,
|
|
2222
|
+
...(delivery.bootstrap_queued ? {
|
|
2223
|
+
bootstrap_queued: true,
|
|
2224
|
+
bootstrap_op_id: delivery.bootstrap_op_id || delivery.msg_id,
|
|
2225
|
+
pending: delivery.pending
|
|
2226
|
+
} : {})
|
|
2227
|
+
});
|
|
1885
2228
|
} catch (err) {
|
|
1886
2229
|
emitInjectFailureEvent(id, 'DELIVERY_FAILED', err.message, { inject_id }, session);
|
|
1887
2230
|
res.status(500).json(buildErrorBody('DELIVERY_FAILED', err.message));
|
|
@@ -2607,6 +2950,7 @@ wss.on('connection', (ws, req) => {
|
|
|
2607
2950
|
outputRing: [],
|
|
2608
2951
|
ready: true,
|
|
2609
2952
|
};
|
|
2953
|
+
initializeBootstrapState(autoSession);
|
|
2610
2954
|
sessions[sessionId] = autoSession;
|
|
2611
2955
|
console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
|
|
2612
2956
|
// Set tab title via kitty (no \x0c redraw — it causes flickering on multi-session reconnect)
|
|
@@ -2639,7 +2983,9 @@ wss.on('connection', (ws, req) => {
|
|
|
2639
2983
|
}
|
|
2640
2984
|
activeSession.ownerWs = ws;
|
|
2641
2985
|
markSessionConnected(activeSession);
|
|
2986
|
+
initializeBootstrapState(activeSession);
|
|
2642
2987
|
console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
2988
|
+
scheduleBootstrapPromptPoll(sessionId, activeSession);
|
|
2643
2989
|
if (hadDisconnectedOwner) {
|
|
2644
2990
|
emitSessionLifecycleEvent('session_reconnect', sessionId, activeSession);
|
|
2645
2991
|
}
|
|
@@ -2665,7 +3011,11 @@ wss.on('connection', (ws, req) => {
|
|
|
2665
3011
|
}
|
|
2666
3012
|
});
|
|
2667
3013
|
} else if (type === 'ready') {
|
|
2668
|
-
activeSession
|
|
3014
|
+
if (isBootstrapGatedSession(activeSession)) {
|
|
3015
|
+
markBootstrapReady(sessionId, activeSession, 'bridge_ready');
|
|
3016
|
+
} else {
|
|
3017
|
+
activeSession.ready = true;
|
|
3018
|
+
}
|
|
2669
3019
|
activeSession.lastActivityAt = new Date().toISOString();
|
|
2670
3020
|
console.log(`[READY] Session ${sessionId} CLI is ready for inject`);
|
|
2671
3021
|
// Broadcast readiness to bus (cmux/kitty paths now enabled for this session)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -8,6 +8,30 @@
|
|
|
8
8
|
"telepty-install": "install.js",
|
|
9
9
|
"telepty-mcp": "mcp-server/index.mjs"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"cli.js",
|
|
13
|
+
"daemon.js",
|
|
14
|
+
"install.js",
|
|
15
|
+
"auth.js",
|
|
16
|
+
"cross-machine.js",
|
|
17
|
+
"daemon-control.js",
|
|
18
|
+
"entitlement.js",
|
|
19
|
+
"host-spec.js",
|
|
20
|
+
"interactive-terminal.js",
|
|
21
|
+
"runtime-info.js",
|
|
22
|
+
"session-routing.js",
|
|
23
|
+
"session-state.js",
|
|
24
|
+
"shared-context.js",
|
|
25
|
+
"skill-installer.js",
|
|
26
|
+
"terminal-backend.js",
|
|
27
|
+
"tui.js",
|
|
28
|
+
"install.sh",
|
|
29
|
+
"install.ps1",
|
|
30
|
+
"mcp-server/",
|
|
31
|
+
"src/",
|
|
32
|
+
"skills/",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
11
35
|
"scripts": {
|
|
12
36
|
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
13
37
|
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js",
|