@dmsdc-ai/aigentry-telepty 0.3.3 → 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 +182 -0
- package/README.md +67 -1
- package/cli.js +161 -54
- package/cross-machine.js +132 -0
- package/daemon.js +355 -5
- package/host-spec.js +60 -0
- package/mcp-server/index.mjs +24 -3
- package/package.json +30 -5
- package/session-state.js +23 -0
- package/skill-installer.js +42 -6
- package/skills/telepty/SKILL.md +1 -1
- package/skills/telepty-allow/SKILL.md +1 -1
- package/skills/telepty-attach/SKILL.md +1 -1
- package/skills/telepty-broadcast/SKILL.md +1 -1
- package/skills/telepty-daemon/SKILL.md +1 -1
- package/skills/telepty-inject/SKILL.md +76 -4
- package/skills/telepty-list/SKILL.md +1 -1
- package/skills/telepty-listen/SKILL.md +1 -1
- package/skills/telepty-rename/SKILL.md +1 -1
- package/skills/telepty-session/SKILL.md +1 -1
- package/src/init/print-snippet.js +114 -0
- package/src/init/snippets/agents.md +15 -0
- package/src/init/snippets/claude.md +15 -0
- package/src/init/snippets/gemini.md +15 -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 -74
- 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/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/specs/codex-inject-spec.md +0 -201
- package/specs/enforce-report-spec.md +0 -237
- package/templates/AGENTS.md +0 -71
package/cli.js
CHANGED
|
@@ -18,7 +18,9 @@ const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./s
|
|
|
18
18
|
const { buildSharedContextPrompt, createSharedContextDescriptor, ensureSharedContextFile } = require('./shared-context');
|
|
19
19
|
const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
20
20
|
const crossMachine = require('./cross-machine');
|
|
21
|
+
const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
|
|
21
22
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
23
|
+
const readyRegistry = require('./src/prompt-symbol-registry');
|
|
22
24
|
const args = process.argv.slice(2);
|
|
23
25
|
let pendingTerminalInputError = null;
|
|
24
26
|
let simulatedPromptErrorInjected = false;
|
|
@@ -118,23 +120,43 @@ if (!process.env.NO_UPDATE_NOTIFIER && !process.env.TELEPTY_DISABLE_UPDATE_NOTIF
|
|
|
118
120
|
updateNotifier({pkg}).notify({ isGlobal: true });
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
// Support remote host via environment variable or default to localhost
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// Support remote host via environment variable or default to localhost.
|
|
124
|
+
// TELEPTY_HOST accepts: `host`, `host:port`, or `http://host:port`. Embedded
|
|
125
|
+
// port from TELEPTY_HOST is used unless TELEPTY_PORT is set explicitly.
|
|
126
|
+
const _explicitPort = process.env.TELEPTY_PORT ? Number(process.env.TELEPTY_PORT) : null;
|
|
127
|
+
const _hostSpec = parseHostSpec(process.env.TELEPTY_HOST, _explicitPort || 3848);
|
|
128
|
+
let REMOTE_HOST = _hostSpec.host;
|
|
129
|
+
const PORT = _explicitPort != null ? _explicitPort : _hostSpec.port;
|
|
130
|
+
let DAEMON_URL = buildDaemonUrl(REMOTE_HOST, PORT);
|
|
131
|
+
let WS_URL = buildDaemonWsUrl(REMOTE_HOST, PORT);
|
|
132
|
+
|
|
133
|
+
function daemonUrl(host) {
|
|
134
|
+
if (host == null || host === '') return DAEMON_URL;
|
|
135
|
+
return buildDaemonUrl(host, PORT);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function daemonWsUrl(host) {
|
|
139
|
+
if (host == null || host === '') return WS_URL;
|
|
140
|
+
return buildDaemonWsUrl(host, PORT);
|
|
141
|
+
}
|
|
126
142
|
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
let cachedAuthToken = null;
|
|
144
|
+
|
|
145
|
+
function getAuthToken() {
|
|
146
|
+
if (cachedAuthToken == null) {
|
|
147
|
+
cachedAuthToken = getConfig().authToken;
|
|
148
|
+
}
|
|
149
|
+
return cachedAuthToken;
|
|
150
|
+
}
|
|
129
151
|
|
|
130
152
|
const fetchWithAuth = (url, options = {}) => {
|
|
131
|
-
const headers = { ...options.headers, 'x-telepty-token':
|
|
153
|
+
const headers = { ...options.headers, 'x-telepty-token': getAuthToken() };
|
|
132
154
|
return fetch(url, { ...options, headers });
|
|
133
155
|
};
|
|
134
156
|
|
|
135
157
|
async function getDaemonMeta(host = REMOTE_HOST) {
|
|
136
158
|
try {
|
|
137
|
-
const res = await fetchWithAuth(
|
|
159
|
+
const res = await fetchWithAuth(`${daemonUrl(host)}/api/meta`, {
|
|
138
160
|
signal: AbortSignal.timeout(1500)
|
|
139
161
|
});
|
|
140
162
|
if (!res.ok) {
|
|
@@ -487,7 +509,7 @@ async function discoverSessions(options = {}) {
|
|
|
487
509
|
|
|
488
510
|
// Local daemon sessions
|
|
489
511
|
try {
|
|
490
|
-
const res = await fetchWithAuth(
|
|
512
|
+
const res = await fetchWithAuth(`${daemonUrl('127.0.0.1')}/api/sessions`, {
|
|
491
513
|
signal: AbortSignal.timeout(1500)
|
|
492
514
|
});
|
|
493
515
|
if (res.ok) {
|
|
@@ -502,6 +524,14 @@ async function discoverSessions(options = {}) {
|
|
|
502
524
|
const remoteSessions = crossMachine.discoverAllRemoteSessions();
|
|
503
525
|
allSessions.push(...remoteSessions);
|
|
504
526
|
|
|
527
|
+
// Remote peer sessions via HTTP (no SSH)
|
|
528
|
+
try {
|
|
529
|
+
const httpSessions = await crossMachine.discoverHttpRemoteSessions();
|
|
530
|
+
allSessions.push(...httpSessions);
|
|
531
|
+
} catch {
|
|
532
|
+
// HTTP peer discovery is best-effort.
|
|
533
|
+
}
|
|
534
|
+
|
|
505
535
|
return allSessions;
|
|
506
536
|
}
|
|
507
537
|
|
|
@@ -550,7 +580,7 @@ async function ensureDaemonRunning(options = {}) {
|
|
|
550
580
|
}
|
|
551
581
|
|
|
552
582
|
async function manageInteractiveAttach(sessionId, targetHost) {
|
|
553
|
-
const wsUrl =
|
|
583
|
+
const wsUrl = `${daemonWsUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}`;
|
|
554
584
|
const ws = new WebSocket(wsUrl);
|
|
555
585
|
let cleanupTerminal = null;
|
|
556
586
|
return new Promise((resolve) => {
|
|
@@ -576,7 +606,7 @@ async function manageInteractiveAttach(sessionId, targetHost) {
|
|
|
576
606
|
|
|
577
607
|
// Check if other clients are still attached before destroying
|
|
578
608
|
try {
|
|
579
|
-
const res = await fetchWithAuth(
|
|
609
|
+
const res = await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions`);
|
|
580
610
|
if (res.ok) {
|
|
581
611
|
const sessions = await res.json();
|
|
582
612
|
const session = sessions.find(s => s.id === sessionId);
|
|
@@ -584,7 +614,7 @@ async function manageInteractiveAttach(sessionId, targetHost) {
|
|
|
584
614
|
console.log(`\n\x1b[33mLeft room '${sessionId}'. Other clients still attached — session kept alive.\x1b[0m\n`);
|
|
585
615
|
} else {
|
|
586
616
|
console.log(`\n\x1b[33mLeft room '${sessionId}'. No other clients — destroying session.\x1b[0m\n`);
|
|
587
|
-
await fetchWithAuth(
|
|
617
|
+
await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
|
588
618
|
}
|
|
589
619
|
}
|
|
590
620
|
} catch(e) {
|
|
@@ -787,7 +817,7 @@ async function manageInteractive() {
|
|
|
787
817
|
const { promptText } = injectPromptResponse;
|
|
788
818
|
if (!promptText) continue;
|
|
789
819
|
try {
|
|
790
|
-
const res = await fetchWithAuth(
|
|
820
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
|
|
791
821
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: promptText })
|
|
792
822
|
});
|
|
793
823
|
const data = await res.json();
|
|
@@ -812,6 +842,15 @@ async function main() {
|
|
|
812
842
|
return;
|
|
813
843
|
}
|
|
814
844
|
|
|
845
|
+
if (cmd === 'init') {
|
|
846
|
+
const { main: runInit } = require('./src/init/print-snippet');
|
|
847
|
+
const exitCode = runInit(args.slice(1));
|
|
848
|
+
if (exitCode) {
|
|
849
|
+
process.exitCode = exitCode;
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
815
854
|
if (cmd === 'update') {
|
|
816
855
|
console.log('\x1b[36m🔄 Updating telepty to the latest version...\x1b[0m');
|
|
817
856
|
try {
|
|
@@ -1042,15 +1081,14 @@ async function main() {
|
|
|
1042
1081
|
|
|
1043
1082
|
spawnChild();
|
|
1044
1083
|
|
|
1045
|
-
// Prompt-ready detection for safe inject delivery
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
};
|
|
1051
|
-
const cmdBase = path.basename(command).replace(/\..*$/, '');
|
|
1052
|
-
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*$/;
|
|
1053
1089
|
let promptReady = false; // wait for CLI prompt before accepting inject
|
|
1090
|
+
let firstReadyObserved = false;
|
|
1091
|
+
let outputTail = '';
|
|
1054
1092
|
let lastUserInputTime = 0; // timestamp of last user keystroke
|
|
1055
1093
|
const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
|
|
1056
1094
|
|
|
@@ -1090,6 +1128,14 @@ async function main() {
|
|
|
1090
1128
|
return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
|
|
1091
1129
|
}
|
|
1092
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
|
+
|
|
1093
1139
|
let queueFlushTimer = null;
|
|
1094
1140
|
let idleCheckTimer = null;
|
|
1095
1141
|
|
|
@@ -1126,8 +1172,9 @@ async function main() {
|
|
|
1126
1172
|
flushBridgeMailbox();
|
|
1127
1173
|
}
|
|
1128
1174
|
}, 500);
|
|
1129
|
-
// Safety
|
|
1130
|
-
|
|
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) {
|
|
1131
1178
|
queueFlushTimer = setTimeout(() => {
|
|
1132
1179
|
queueFlushTimer = null;
|
|
1133
1180
|
if (bridgePendingCount > 0) {
|
|
@@ -1140,7 +1187,7 @@ async function main() {
|
|
|
1140
1187
|
// Connect to daemon WebSocket with auto-reconnect
|
|
1141
1188
|
// owner=1 tells daemon this is the allow bridge (owner), not an attach viewer.
|
|
1142
1189
|
// Daemon uses this to reclaim ownership even if a stale ownerWs is still registered.
|
|
1143
|
-
const wsUrl =
|
|
1190
|
+
const wsUrl = `${daemonWsUrl(REMOTE_HOST)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}&owner=1`;
|
|
1144
1191
|
let daemonWs = null;
|
|
1145
1192
|
let wsReady = false;
|
|
1146
1193
|
let reconnectAttempts = 0;
|
|
@@ -1200,10 +1247,16 @@ async function main() {
|
|
|
1200
1247
|
|
|
1201
1248
|
const isCr = chunk === '\r';
|
|
1202
1249
|
if (isCr && bridgePendingCount > 0) {
|
|
1203
|
-
// 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.
|
|
1204
1253
|
enqueueBridgeMessage(chunk);
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1254
|
+
if (isIdle()) {
|
|
1255
|
+
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
1256
|
+
flushBridgeMailbox();
|
|
1257
|
+
} else {
|
|
1258
|
+
scheduleIdleFlush();
|
|
1259
|
+
}
|
|
1207
1260
|
} else if (isCr) {
|
|
1208
1261
|
// CR always written immediately — never idle-gated.
|
|
1209
1262
|
child.write(chunk);
|
|
@@ -1315,8 +1368,9 @@ async function main() {
|
|
|
1315
1368
|
daemonWs.send(JSON.stringify({ type: 'output', data }));
|
|
1316
1369
|
}
|
|
1317
1370
|
// Detect prompt in output to enable inject delivery
|
|
1318
|
-
if (
|
|
1371
|
+
if (observePromptReady(data)) {
|
|
1319
1372
|
promptReady = true;
|
|
1373
|
+
firstReadyObserved = true;
|
|
1320
1374
|
flushBridgeMailbox();
|
|
1321
1375
|
// Notify daemon that CLI is ready for inject
|
|
1322
1376
|
if (!readyNotified && wsReady && daemonWs.readyState === 1) {
|
|
@@ -1345,6 +1399,10 @@ async function main() {
|
|
|
1345
1399
|
setTimeout(() => {
|
|
1346
1400
|
try {
|
|
1347
1401
|
spawnChild();
|
|
1402
|
+
promptReady = false;
|
|
1403
|
+
firstReadyObserved = false;
|
|
1404
|
+
readyNotified = false;
|
|
1405
|
+
outputTail = '';
|
|
1348
1406
|
// Re-attach output relay, prompt detection, and exit handler
|
|
1349
1407
|
child.onData((data) => {
|
|
1350
1408
|
const rewritten = rewriteTitleSequences(data);
|
|
@@ -1352,8 +1410,9 @@ async function main() {
|
|
|
1352
1410
|
if (wsReady && daemonWs.readyState === 1) {
|
|
1353
1411
|
daemonWs.send(JSON.stringify({ type: 'output', data }));
|
|
1354
1412
|
}
|
|
1355
|
-
if (
|
|
1413
|
+
if (observePromptReady(data)) {
|
|
1356
1414
|
promptReady = true;
|
|
1415
|
+
firstReadyObserved = true;
|
|
1357
1416
|
flushBridgeMailbox();
|
|
1358
1417
|
if (wsReady && daemonWs.readyState === 1) {
|
|
1359
1418
|
daemonWs.send(JSON.stringify({ type: 'ready' }));
|
|
@@ -1448,7 +1507,7 @@ async function main() {
|
|
|
1448
1507
|
}
|
|
1449
1508
|
}
|
|
1450
1509
|
|
|
1451
|
-
const wsUrl =
|
|
1510
|
+
const wsUrl = `${daemonWsUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}`;
|
|
1452
1511
|
const ws = new WebSocket(wsUrl);
|
|
1453
1512
|
let cleanupTerminal = null;
|
|
1454
1513
|
|
|
@@ -1486,7 +1545,7 @@ async function main() {
|
|
|
1486
1545
|
|
|
1487
1546
|
// Check if other clients are still attached before destroying
|
|
1488
1547
|
try {
|
|
1489
|
-
const res = await fetchWithAuth(
|
|
1548
|
+
const res = await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions`);
|
|
1490
1549
|
if (res.ok) {
|
|
1491
1550
|
const allSessions = await res.json();
|
|
1492
1551
|
const session = allSessions.find(s => s.id === sessionId);
|
|
@@ -1494,7 +1553,7 @@ async function main() {
|
|
|
1494
1553
|
console.log(`\n\x1b[33mLeft room '${sessionId}'. Other clients still attached — session kept alive.\x1b[0m`);
|
|
1495
1554
|
} else {
|
|
1496
1555
|
console.log(`\n\x1b[33mLeft room '${sessionId}'. No other clients — destroying session.\x1b[0m`);
|
|
1497
|
-
await fetchWithAuth(
|
|
1556
|
+
await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
|
1498
1557
|
}
|
|
1499
1558
|
}
|
|
1500
1559
|
} catch(e) {}
|
|
@@ -1524,7 +1583,7 @@ async function main() {
|
|
|
1524
1583
|
process.exit(1);
|
|
1525
1584
|
}
|
|
1526
1585
|
|
|
1527
|
-
const res = await fetchWithAuth(
|
|
1586
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
|
|
1528
1587
|
const data = await res.json();
|
|
1529
1588
|
if (!res.ok) { console.error(`❌ Error: ${data.error}`); process.exit(1); }
|
|
1530
1589
|
|
|
@@ -1656,7 +1715,7 @@ async function main() {
|
|
|
1656
1715
|
noEnter: useSubmit
|
|
1657
1716
|
});
|
|
1658
1717
|
|
|
1659
|
-
const res = await fetchWithAuth(
|
|
1718
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
|
|
1660
1719
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
1661
1720
|
});
|
|
1662
1721
|
const data = await res.json();
|
|
@@ -1699,7 +1758,7 @@ async function main() {
|
|
|
1699
1758
|
}
|
|
1700
1759
|
attemptsMade = attempt + 1;
|
|
1701
1760
|
try {
|
|
1702
|
-
submitRes = await fetchWithAuth(
|
|
1761
|
+
submitRes = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
|
|
1703
1762
|
method: 'POST',
|
|
1704
1763
|
headers: { 'Content-Type': 'application/json' },
|
|
1705
1764
|
body: JSON.stringify(submitBody),
|
|
@@ -1776,7 +1835,7 @@ async function main() {
|
|
|
1776
1835
|
return;
|
|
1777
1836
|
}
|
|
1778
1837
|
|
|
1779
|
-
const res = await fetchWithAuth(
|
|
1838
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
|
|
1780
1839
|
method: 'POST',
|
|
1781
1840
|
headers: { 'Content-Type': 'application/json' },
|
|
1782
1841
|
body: JSON.stringify(buildInjectRequestBody('', {}))
|
|
@@ -1808,7 +1867,7 @@ async function main() {
|
|
|
1808
1867
|
|
|
1809
1868
|
// send-key is a manual override — bypass the render gate via force=true.
|
|
1810
1869
|
// See: docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md §3.1
|
|
1811
|
-
const res = await fetchWithAuth(
|
|
1870
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
|
|
1812
1871
|
method: 'POST',
|
|
1813
1872
|
headers: { 'Content-Type': 'application/json' },
|
|
1814
1873
|
body: JSON.stringify({ force: true }),
|
|
@@ -1836,7 +1895,7 @@ async function main() {
|
|
|
1836
1895
|
const target = await resolveSessionTarget(replyTo);
|
|
1837
1896
|
if (!target) { console.error(`❌ Session '${replyTo}' was not found on any discovered host.`); process.exit(1); }
|
|
1838
1897
|
const body = { prompt: replyText, from: mySessionId, reply_to: mySessionId };
|
|
1839
|
-
const res = await fetchWithAuth(
|
|
1898
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
|
|
1840
1899
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
1841
1900
|
});
|
|
1842
1901
|
const data = await res.json();
|
|
@@ -1860,7 +1919,7 @@ async function main() {
|
|
|
1860
1919
|
process.exit(1);
|
|
1861
1920
|
}
|
|
1862
1921
|
|
|
1863
|
-
const res = await fetchWithAuth(
|
|
1922
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/state`);
|
|
1864
1923
|
const data = await res.json();
|
|
1865
1924
|
if (!res.ok) {
|
|
1866
1925
|
console.error(`❌ ${formatApiError(data)}`);
|
|
@@ -1977,7 +2036,7 @@ async function main() {
|
|
|
1977
2036
|
process.exit(1);
|
|
1978
2037
|
}
|
|
1979
2038
|
|
|
1980
|
-
const res = await fetchWithAuth(
|
|
2039
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/state`, {
|
|
1981
2040
|
method: 'POST',
|
|
1982
2041
|
headers: { 'Content-Type': 'application/json' },
|
|
1983
2042
|
body: JSON.stringify(buildSessionStateReportBody({
|
|
@@ -2022,7 +2081,7 @@ async function main() {
|
|
|
2022
2081
|
|
|
2023
2082
|
const aggregate = { successful: [], failed: [] };
|
|
2024
2083
|
for (const [host, ids] of groupedTargets.entries()) {
|
|
2025
|
-
const res = await fetchWithAuth(
|
|
2084
|
+
const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/multicast/inject`, {
|
|
2026
2085
|
method: 'POST',
|
|
2027
2086
|
headers: { 'Content-Type': 'application/json' },
|
|
2028
2087
|
body: JSON.stringify({ session_ids: ids, prompt })
|
|
@@ -2065,7 +2124,7 @@ async function main() {
|
|
|
2065
2124
|
}
|
|
2066
2125
|
|
|
2067
2126
|
for (const host of groupSessionsByHost(local).keys()) {
|
|
2068
|
-
const res = await fetchWithAuth(
|
|
2127
|
+
const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/broadcast/inject`, {
|
|
2069
2128
|
method: 'POST',
|
|
2070
2129
|
headers: { 'Content-Type': 'application/json' },
|
|
2071
2130
|
body: JSON.stringify({ prompt: localPrompt })
|
|
@@ -2112,7 +2171,7 @@ async function main() {
|
|
|
2112
2171
|
try {
|
|
2113
2172
|
const target = await resolveSessionTarget(sessionRef);
|
|
2114
2173
|
if (!target) { console.error(`❌ Session '${sessionRef}' not found.`); process.exit(1); }
|
|
2115
|
-
const res = await fetchWithAuth(
|
|
2174
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`, { method: 'DELETE' });
|
|
2116
2175
|
const data = await res.json();
|
|
2117
2176
|
if (!res.ok) { console.error(`❌ Error: ${data.error}`); return; }
|
|
2118
2177
|
console.log(`✅ Session '\x1b[36m${target.id}\x1b[0m' deleted.`);
|
|
@@ -2129,7 +2188,7 @@ async function main() {
|
|
|
2129
2188
|
if (s.healthStatus === 'STALE' || s.healthStatus === 'DISCONNECTED') {
|
|
2130
2189
|
try {
|
|
2131
2190
|
const host = s.host || '127.0.0.1';
|
|
2132
|
-
const res = await fetchWithAuth(
|
|
2191
|
+
const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
|
|
2133
2192
|
if (res.ok) { console.log(` 🗑 Removed ghost: \x1b[36m${s.id}\x1b[0m (${s.healthStatus})`); cleaned++; }
|
|
2134
2193
|
} catch (_) {}
|
|
2135
2194
|
}
|
|
@@ -2149,7 +2208,7 @@ async function main() {
|
|
|
2149
2208
|
process.exit(1);
|
|
2150
2209
|
}
|
|
2151
2210
|
|
|
2152
|
-
const res = await fetchWithAuth(
|
|
2211
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`, {
|
|
2153
2212
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_id: newId })
|
|
2154
2213
|
});
|
|
2155
2214
|
const data = await res.json();
|
|
@@ -2181,7 +2240,7 @@ async function main() {
|
|
|
2181
2240
|
return;
|
|
2182
2241
|
}
|
|
2183
2242
|
|
|
2184
|
-
const res = await fetchWithAuth(
|
|
2243
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`);
|
|
2185
2244
|
const data = await res.json();
|
|
2186
2245
|
if (!res.ok) {
|
|
2187
2246
|
console.error(`❌ Error: ${data.error}`);
|
|
@@ -2196,7 +2255,7 @@ async function main() {
|
|
|
2196
2255
|
return;
|
|
2197
2256
|
}
|
|
2198
2257
|
|
|
2199
|
-
const res = await fetchWithAuth(
|
|
2258
|
+
const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`);
|
|
2200
2259
|
const data = await res.json();
|
|
2201
2260
|
if (!res.ok) {
|
|
2202
2261
|
console.error(`❌ Error: ${data.error}`);
|
|
@@ -2649,7 +2708,7 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2649
2708
|
reply_to: orchestratorId,
|
|
2650
2709
|
thread_id: threadId
|
|
2651
2710
|
};
|
|
2652
|
-
const resp = await fetchWithAuth(
|
|
2711
|
+
const resp = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(session.id)}/inject`, {
|
|
2653
2712
|
method: 'POST',
|
|
2654
2713
|
headers: { 'Content-Type': 'application/json' },
|
|
2655
2714
|
body: JSON.stringify(body)
|
|
@@ -2658,7 +2717,7 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2658
2717
|
// Submit after text injection (300ms delay handled by daemon)
|
|
2659
2718
|
setTimeout(async () => {
|
|
2660
2719
|
try {
|
|
2661
|
-
await fetchWithAuth(
|
|
2720
|
+
await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(session.id)}/submit`, { method: 'POST' });
|
|
2662
2721
|
} catch {}
|
|
2663
2722
|
}, 500);
|
|
2664
2723
|
console.log(` ✅ Injected to ${session.id}`);
|
|
@@ -2908,6 +2967,43 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2908
2967
|
return;
|
|
2909
2968
|
}
|
|
2910
2969
|
|
|
2970
|
+
// telepty connect-http <host>[:port] [--name <name>] [--token <token>]
|
|
2971
|
+
// HTTP-only remote daemon registration (no SSH/sshd required).
|
|
2972
|
+
// Records peer in ~/.telepty/peers.json with transport='http' so subsequent
|
|
2973
|
+
// `telepty list`/`inject`/etc. discover sessions on the remote daemon via
|
|
2974
|
+
// its HTTP API. Designed for laptop daemons where running sshd is not
|
|
2975
|
+
// viable. See GitHub issue #13.
|
|
2976
|
+
if (cmd === 'connect-http') {
|
|
2977
|
+
const target = args[1];
|
|
2978
|
+
if (!target) {
|
|
2979
|
+
console.error('❌ Usage: telepty connect-http <host>[:port] [--name <name>] [--token <token>]');
|
|
2980
|
+
process.exit(1);
|
|
2981
|
+
}
|
|
2982
|
+
const nameFlag = args.indexOf('--name');
|
|
2983
|
+
const tokenFlag = args.indexOf('--token');
|
|
2984
|
+
const options = {};
|
|
2985
|
+
if (nameFlag !== -1 && args[nameFlag + 1]) options.name = args[nameFlag + 1];
|
|
2986
|
+
if (tokenFlag !== -1 && args[tokenFlag + 1]) options.token = args[tokenFlag + 1];
|
|
2987
|
+
|
|
2988
|
+
process.stdout.write(`\x1b[36m🔗 Connecting to ${target} via HTTP...\x1b[0m\n`);
|
|
2989
|
+
try {
|
|
2990
|
+
const result = await crossMachine.connectHttp(target, options);
|
|
2991
|
+
if (result.success) {
|
|
2992
|
+
console.log(`\x1b[32m✅ Connected to ${result.name}\x1b[0m`);
|
|
2993
|
+
console.log(` Host: ${result.host}:${result.port}`);
|
|
2994
|
+
console.log(` Machine ID: ${result.machineId}`);
|
|
2995
|
+
console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
|
|
2996
|
+
} else {
|
|
2997
|
+
console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
|
|
2998
|
+
process.exit(1);
|
|
2999
|
+
}
|
|
3000
|
+
} catch (err) {
|
|
3001
|
+
console.error(`\x1b[31m❌ ${err.message}\x1b[0m`);
|
|
3002
|
+
process.exit(1);
|
|
3003
|
+
}
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2911
3007
|
// telepty disconnect [<name> | --all]
|
|
2912
3008
|
if (cmd === 'disconnect') {
|
|
2913
3009
|
if (args[1] === '--all') {
|
|
@@ -2937,8 +3033,9 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2937
3033
|
|
|
2938
3034
|
const active = crossMachine.listActivePeers();
|
|
2939
3035
|
const known = crossMachine.listKnownPeers();
|
|
3036
|
+
const httpPeers = crossMachine.listHttpPeers();
|
|
2940
3037
|
|
|
2941
|
-
console.log('\x1b[1mConnected Peers:\x1b[0m');
|
|
3038
|
+
console.log('\x1b[1mConnected Peers (SSH ControlMaster):\x1b[0m');
|
|
2942
3039
|
if (active.length === 0) {
|
|
2943
3040
|
console.log(' (none)');
|
|
2944
3041
|
} else {
|
|
@@ -2948,7 +3045,8 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2948
3045
|
}
|
|
2949
3046
|
|
|
2950
3047
|
const knownNames = Object.keys(known);
|
|
2951
|
-
const
|
|
3048
|
+
const httpNames = new Set(httpPeers.map((p) => p.name));
|
|
3049
|
+
const disconnected = knownNames.filter(n => !active.find(a => a.name === n) && !httpNames.has(n));
|
|
2952
3050
|
if (disconnected.length > 0) {
|
|
2953
3051
|
console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
|
|
2954
3052
|
for (const name of disconnected) {
|
|
@@ -2956,6 +3054,14 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2956
3054
|
console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
|
|
2957
3055
|
}
|
|
2958
3056
|
}
|
|
3057
|
+
|
|
3058
|
+
if (httpPeers.length > 0) {
|
|
3059
|
+
console.log('\n\x1b[1mHTTP Peers (no SSH):\x1b[0m');
|
|
3060
|
+
for (const peer of httpPeers) {
|
|
3061
|
+
const tokenNote = peer.hasToken ? ' [token]' : '';
|
|
3062
|
+
console.log(` \x1b[36m◆\x1b[0m ${peer.name} (${peer.host}:${peer.port}) [${peer.machineId}]${tokenNote}`);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
2959
3065
|
return;
|
|
2960
3066
|
}
|
|
2961
3067
|
|
|
@@ -2973,7 +3079,7 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
2973
3079
|
let connectedHosts = 0;
|
|
2974
3080
|
|
|
2975
3081
|
hosts.forEach((host) => {
|
|
2976
|
-
const wsUrl =
|
|
3082
|
+
const wsUrl = `${daemonWsUrl(host)}/api/bus?token=${encodeURIComponent(getAuthToken())}`;
|
|
2977
3083
|
const ws = new WebSocket(wsUrl);
|
|
2978
3084
|
|
|
2979
3085
|
ws.on('open', () => {
|
|
@@ -3051,7 +3157,8 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
3051
3157
|
telepty read-screen <id[@host]> [--lines N] Read session screen buffer
|
|
3052
3158
|
|
|
3053
3159
|
\x1b[1mCross-Machine:\x1b[0m
|
|
3054
|
-
telepty connect <user@host> [--name N] [--port P]
|
|
3160
|
+
telepty connect <user@host> [--name N] [--port P] SSH tunnel to remote host
|
|
3161
|
+
telepty connect-http <host>[:port] [--name N] [--token T] Register remote daemon via HTTP (no SSH)
|
|
3055
3162
|
telepty disconnect <name> | --all Disconnect remote host
|
|
3056
3163
|
telepty peers [--remove <name>] List connected peers
|
|
3057
3164
|
|
package/cross-machine.js
CHANGED
|
@@ -5,10 +5,16 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const { getSharedContextPromptPath } = require('./shared-context');
|
|
8
|
+
const { parseHostSpec } = require('./host-spec');
|
|
8
9
|
|
|
9
10
|
const PEERS_PATH = path.join(os.homedir(), '.telepty', 'peers.json');
|
|
10
11
|
const CONTROL_DIR = path.join(os.homedir(), '.telepty', 'ssh');
|
|
11
12
|
|
|
13
|
+
function getPeerTransport(entry) {
|
|
14
|
+
if (!entry) return null;
|
|
15
|
+
return entry.transport || 'ssh';
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
// SSH ControlMaster socket path pattern
|
|
13
19
|
function controlPath(target) {
|
|
14
20
|
return path.join(CONTROL_DIR, `ctrl-${target.replace(/[^a-zA-Z0-9@.-]/g, '_')}`);
|
|
@@ -307,6 +313,126 @@ function removePeer(name) {
|
|
|
307
313
|
return { success: true };
|
|
308
314
|
}
|
|
309
315
|
|
|
316
|
+
// ── HTTP peer support (no SSH required) ─────────────────────────────────────
|
|
317
|
+
// connect-http records a remote daemon's host:port in peers.json with
|
|
318
|
+
// transport='http'. Subsequent inject/list calls discover sessions via the
|
|
319
|
+
// remote daemon's HTTP API directly. Built for laptop daemons where running
|
|
320
|
+
// sshd is not viable. See GitHub issue #13.
|
|
321
|
+
|
|
322
|
+
async function connectHttp(target, options = {}) {
|
|
323
|
+
const spec = parseHostSpec(target);
|
|
324
|
+
if (!spec.host) {
|
|
325
|
+
return { success: false, error: 'connect-http requires a host (got empty value).' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const name = options.name || spec.host.split('.')[0] || spec.host;
|
|
329
|
+
|
|
330
|
+
const headers = {};
|
|
331
|
+
if (options.token) headers['x-telepty-token'] = options.token;
|
|
332
|
+
|
|
333
|
+
let machineId = name;
|
|
334
|
+
let healthOk = false;
|
|
335
|
+
try {
|
|
336
|
+
const healthUrl = `http://${spec.host}:${spec.port}/api/health`;
|
|
337
|
+
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) });
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
return { success: false, error: `Daemon at ${spec.host}:${spec.port} returned HTTP ${res.status} on /api/health.` };
|
|
340
|
+
}
|
|
341
|
+
healthOk = true;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
return { success: false, error: `Cannot reach daemon at ${spec.host}:${spec.port}: ${err.message}` };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const metaUrl = `http://${spec.host}:${spec.port}/api/meta`;
|
|
348
|
+
const res = await fetch(metaUrl, { signal: AbortSignal.timeout(3000), headers });
|
|
349
|
+
if (res.ok) {
|
|
350
|
+
const meta = await res.json();
|
|
351
|
+
if (meta && typeof meta.machine_id === 'string' && meta.machine_id) {
|
|
352
|
+
machineId = meta.machine_id;
|
|
353
|
+
} else if (meta && typeof meta.host === 'string' && meta.host) {
|
|
354
|
+
machineId = meta.host;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
// /api/meta is auth-gated; failure is not fatal — health passed.
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const peers = loadPeers();
|
|
362
|
+
peers.peers[name] = {
|
|
363
|
+
transport: 'http',
|
|
364
|
+
host: spec.host,
|
|
365
|
+
port: spec.port,
|
|
366
|
+
target: `${spec.host}:${spec.port}`,
|
|
367
|
+
machineId,
|
|
368
|
+
lastConnected: new Date().toISOString()
|
|
369
|
+
};
|
|
370
|
+
if (options.token) {
|
|
371
|
+
peers.peers[name].token = options.token;
|
|
372
|
+
}
|
|
373
|
+
savePeers(peers);
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
name,
|
|
378
|
+
host: spec.host,
|
|
379
|
+
port: spec.port,
|
|
380
|
+
machineId,
|
|
381
|
+
healthOk
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function listHttpPeers() {
|
|
386
|
+
const peers = loadPeers().peers || {};
|
|
387
|
+
return Object.entries(peers)
|
|
388
|
+
.filter(([, entry]) => getPeerTransport(entry) === 'http')
|
|
389
|
+
.map(([name, entry]) => ({
|
|
390
|
+
name,
|
|
391
|
+
host: entry.host,
|
|
392
|
+
port: entry.port,
|
|
393
|
+
machineId: entry.machineId,
|
|
394
|
+
lastConnected: entry.lastConnected,
|
|
395
|
+
hasToken: Boolean(entry.token)
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function listHttpRemoteSessions(name, options = {}) {
|
|
400
|
+
const peers = loadPeers().peers || {};
|
|
401
|
+
const entry = peers[name];
|
|
402
|
+
if (!entry || getPeerTransport(entry) !== 'http') return [];
|
|
403
|
+
|
|
404
|
+
const headers = {};
|
|
405
|
+
const token = options.token || entry.token;
|
|
406
|
+
if (token) headers['x-telepty-token'] = token;
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const url = `http://${entry.host}:${entry.port}/api/sessions`;
|
|
410
|
+
const res = await fetch(url, {
|
|
411
|
+
signal: AbortSignal.timeout(options.timeoutMs || 3000),
|
|
412
|
+
headers
|
|
413
|
+
});
|
|
414
|
+
if (!res.ok) return [];
|
|
415
|
+
const sessions = await res.json();
|
|
416
|
+
if (!Array.isArray(sessions)) return [];
|
|
417
|
+
return sessions.map((s) => ({
|
|
418
|
+
...s,
|
|
419
|
+
host: `${entry.host}:${entry.port}`,
|
|
420
|
+
peerName: name,
|
|
421
|
+
peerPort: entry.port
|
|
422
|
+
}));
|
|
423
|
+
} catch {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function discoverHttpRemoteSessions(options = {}) {
|
|
429
|
+
const peers = listHttpPeers();
|
|
430
|
+
const results = await Promise.all(
|
|
431
|
+
peers.map((peer) => listHttpRemoteSessions(peer.name, options))
|
|
432
|
+
);
|
|
433
|
+
return results.flat();
|
|
434
|
+
}
|
|
435
|
+
|
|
310
436
|
module.exports = {
|
|
311
437
|
connect,
|
|
312
438
|
disconnect,
|
|
@@ -323,5 +449,11 @@ module.exports = {
|
|
|
323
449
|
remoteEnsureSharedContext,
|
|
324
450
|
remoteAttach,
|
|
325
451
|
findSessionPeer,
|
|
452
|
+
// HTTP peer transport (no SSH required)
|
|
453
|
+
connectHttp,
|
|
454
|
+
listHttpPeers,
|
|
455
|
+
listHttpRemoteSessions,
|
|
456
|
+
discoverHttpRemoteSessions,
|
|
457
|
+
getPeerTransport,
|
|
326
458
|
PEERS_PATH
|
|
327
459
|
};
|