@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +67 -1
  3. package/cli.js +161 -54
  4. package/cross-machine.js +132 -0
  5. package/daemon.js +355 -5
  6. package/host-spec.js +60 -0
  7. package/mcp-server/index.mjs +24 -3
  8. package/package.json +30 -5
  9. package/session-state.js +23 -0
  10. package/skill-installer.js +42 -6
  11. package/skills/telepty/SKILL.md +1 -1
  12. package/skills/telepty-allow/SKILL.md +1 -1
  13. package/skills/telepty-attach/SKILL.md +1 -1
  14. package/skills/telepty-broadcast/SKILL.md +1 -1
  15. package/skills/telepty-daemon/SKILL.md +1 -1
  16. package/skills/telepty-inject/SKILL.md +76 -4
  17. package/skills/telepty-list/SKILL.md +1 -1
  18. package/skills/telepty-listen/SKILL.md +1 -1
  19. package/skills/telepty-rename/SKILL.md +1 -1
  20. package/skills/telepty-session/SKILL.md +1 -1
  21. package/src/init/print-snippet.js +114 -0
  22. package/src/init/snippets/agents.md +15 -0
  23. package/src/init/snippets/claude.md +15 -0
  24. package/src/init/snippets/gemini.md +15 -0
  25. package/src/prompt-symbol-registry.js +43 -1
  26. package/.claude/commands/telepty-allow.md +0 -58
  27. package/.claude/commands/telepty-attach.md +0 -22
  28. package/.claude/commands/telepty-inject.md +0 -72
  29. package/.claude/commands/telepty-list.md +0 -22
  30. package/.claude/commands/telepty-manual-test.md +0 -73
  31. package/.claude/commands/telepty-start.md +0 -25
  32. package/.claude/commands/telepty-test.md +0 -25
  33. package/.claude/commands/telepty.md +0 -82
  34. package/AGENTS.md +0 -74
  35. package/BOUNDARY.md +0 -31
  36. package/BUS_EVENT_SCHEMA.md +0 -206
  37. package/CLAUDE.md +0 -100
  38. package/GEMINI.md +0 -10
  39. package/URGENT_ISSUES.resolved.md +0 -1
  40. package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +0 -447
  41. package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +0 -571
  42. package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +0 -608
  43. package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +0 -139
  44. package/protocol/mailbox.md +0 -244
  45. package/specs/codex-inject-spec.md +0 -201
  46. package/specs/enforce-report-spec.md +0 -237
  47. 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
- let REMOTE_HOST = process.env.TELEPTY_HOST || '127.0.0.1';
123
- const PORT = Number(process.env.TELEPTY_PORT || 3848);
124
- let DAEMON_URL = `http://${REMOTE_HOST}:${PORT}`;
125
- let WS_URL = `ws://${REMOTE_HOST}:${PORT}`;
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
- const config = getConfig();
128
- const TOKEN = config.authToken;
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': 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(`http://${host}:${PORT}/api/meta`, {
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(`http://127.0.0.1:${PORT}/api/sessions`, {
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 = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
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(`http://${targetHost}:${PORT}/api/sessions`);
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(`http://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
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
- const PROMPT_PATTERNS = {
1047
- claude: /[❯>]\s*$/,
1048
- gemini: /[❯>]\s*$/,
1049
- codex: /[❯>]\s*$/,
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: flush after 5s regardless (prevent stuck queue when prompt not detected)
1130
- if (!queueFlushTimer) {
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 = `ws://${REMOTE_HOST}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}&owner=1`;
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 flush immediately.
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 (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1206
- flushBridgeMailbox();
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 (promptPattern.test(data)) {
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 (promptPattern.test(data)) {
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 = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
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(`http://${targetHost}:${PORT}/api/sessions`);
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(`http://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`);
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`, {
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(`http://${host}:${PORT}/api/sessions/multicast/inject`, {
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(`http://${host}:${PORT}/api/sessions/broadcast/inject`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`, { method: 'DELETE' });
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(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`, {
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`);
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(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`);
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(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(session.id)}/inject`, {
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(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(session.id)}/submit`, { method: 'POST' });
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 disconnected = knownNames.filter(n => !active.find(a => a.name === n));
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 = `ws://${host}:${PORT}/api/bus?token=${encodeURIComponent(TOKEN)}`;
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] SSH tunnel to remote host
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
  };