@dmsdc-ai/aigentry-telepty 0.1.98 → 0.3.5

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 (44) hide show
  1. package/AGENTS.md +23 -0
  2. package/CHANGELOG.md +436 -0
  3. package/CLAUDE.md +5 -1
  4. package/README.md +70 -1
  5. package/cli.js +232 -53
  6. package/cross-machine.js +132 -0
  7. package/daemon.js +399 -39
  8. package/docs/reports/2026-05-05-issue-8-claude-review.md +194 -0
  9. package/docs/specs/2026-05-05-issue-8-telepty-init.md +477 -0
  10. package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +447 -0
  11. package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +571 -0
  12. package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +608 -0
  13. package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +139 -0
  14. package/host-spec.js +60 -0
  15. package/mcp-server/index.mjs +24 -3
  16. package/package.json +6 -5
  17. package/scripts/regen-snippet-fixtures.js +42 -0
  18. package/skill-installer.js +42 -6
  19. package/skills/telepty/SKILL.md +1 -1
  20. package/skills/telepty-allow/SKILL.md +1 -1
  21. package/skills/telepty-attach/SKILL.md +1 -1
  22. package/skills/telepty-broadcast/SKILL.md +1 -1
  23. package/skills/telepty-daemon/SKILL.md +1 -1
  24. package/skills/telepty-inject/SKILL.md +76 -4
  25. package/skills/telepty-list/SKILL.md +1 -1
  26. package/skills/telepty-listen/SKILL.md +1 -1
  27. package/skills/telepty-rename/SKILL.md +1 -1
  28. package/skills/telepty-session/SKILL.md +1 -1
  29. package/specs/enforce-report-spec.md +237 -0
  30. package/src/init/print-snippet.js +114 -0
  31. package/src/init/snippets/agents.md +15 -0
  32. package/src/init/snippets/claude.md +15 -0
  33. package/src/init/snippets/gemini.md +15 -0
  34. package/src/prompt-symbol-registry.js +97 -0
  35. package/src/report-enforcement.js +86 -0
  36. package/src/submit-gate.js +269 -0
  37. package/tests/snippet-protocol/v1/golden-agents.json +1 -0
  38. package/tests/snippet-protocol/v1/golden-agents.md +17 -0
  39. package/tests/snippet-protocol/v1/golden-all.json +3 -0
  40. package/tests/snippet-protocol/v1/golden-all.md +53 -0
  41. package/tests/snippet-protocol/v1/golden-claude.json +1 -0
  42. package/tests/snippet-protocol/v1/golden-claude.md +17 -0
  43. package/tests/snippet-protocol/v1/golden-gemini.json +1 -0
  44. package/tests/snippet-protocol/v1/golden-gemini.md +17 -0
package/cli.js CHANGED
@@ -18,6 +18,7 @@ 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');
22
23
  const args = process.argv.slice(2);
23
24
  let pendingTerminalInputError = null;
@@ -118,23 +119,43 @@ if (!process.env.NO_UPDATE_NOTIFIER && !process.env.TELEPTY_DISABLE_UPDATE_NOTIF
118
119
  updateNotifier({pkg}).notify({ isGlobal: true });
119
120
  }
120
121
 
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}`;
122
+ // Support remote host via environment variable or default to localhost.
123
+ // TELEPTY_HOST accepts: `host`, `host:port`, or `http://host:port`. Embedded
124
+ // port from TELEPTY_HOST is used unless TELEPTY_PORT is set explicitly.
125
+ const _explicitPort = process.env.TELEPTY_PORT ? Number(process.env.TELEPTY_PORT) : null;
126
+ const _hostSpec = parseHostSpec(process.env.TELEPTY_HOST, _explicitPort || 3848);
127
+ let REMOTE_HOST = _hostSpec.host;
128
+ const PORT = _explicitPort != null ? _explicitPort : _hostSpec.port;
129
+ let DAEMON_URL = buildDaemonUrl(REMOTE_HOST, PORT);
130
+ let WS_URL = buildDaemonWsUrl(REMOTE_HOST, PORT);
131
+
132
+ function daemonUrl(host) {
133
+ if (host == null || host === '') return DAEMON_URL;
134
+ return buildDaemonUrl(host, PORT);
135
+ }
136
+
137
+ function daemonWsUrl(host) {
138
+ if (host == null || host === '') return WS_URL;
139
+ return buildDaemonWsUrl(host, PORT);
140
+ }
126
141
 
127
- const config = getConfig();
128
- const TOKEN = config.authToken;
142
+ let cachedAuthToken = null;
143
+
144
+ function getAuthToken() {
145
+ if (cachedAuthToken == null) {
146
+ cachedAuthToken = getConfig().authToken;
147
+ }
148
+ return cachedAuthToken;
149
+ }
129
150
 
130
151
  const fetchWithAuth = (url, options = {}) => {
131
- const headers = { ...options.headers, 'x-telepty-token': TOKEN };
152
+ const headers = { ...options.headers, 'x-telepty-token': getAuthToken() };
132
153
  return fetch(url, { ...options, headers });
133
154
  };
134
155
 
135
156
  async function getDaemonMeta(host = REMOTE_HOST) {
136
157
  try {
137
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/meta`, {
158
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/meta`, {
138
159
  signal: AbortSignal.timeout(1500)
139
160
  });
140
161
  if (!res.ok) {
@@ -487,7 +508,7 @@ async function discoverSessions(options = {}) {
487
508
 
488
509
  // Local daemon sessions
489
510
  try {
490
- const res = await fetchWithAuth(`http://127.0.0.1:${PORT}/api/sessions`, {
511
+ const res = await fetchWithAuth(`${daemonUrl('127.0.0.1')}/api/sessions`, {
491
512
  signal: AbortSignal.timeout(1500)
492
513
  });
493
514
  if (res.ok) {
@@ -502,6 +523,14 @@ async function discoverSessions(options = {}) {
502
523
  const remoteSessions = crossMachine.discoverAllRemoteSessions();
503
524
  allSessions.push(...remoteSessions);
504
525
 
526
+ // Remote peer sessions via HTTP (no SSH)
527
+ try {
528
+ const httpSessions = await crossMachine.discoverHttpRemoteSessions();
529
+ allSessions.push(...httpSessions);
530
+ } catch {
531
+ // HTTP peer discovery is best-effort.
532
+ }
533
+
505
534
  return allSessions;
506
535
  }
507
536
 
@@ -550,7 +579,7 @@ async function ensureDaemonRunning(options = {}) {
550
579
  }
551
580
 
552
581
  async function manageInteractiveAttach(sessionId, targetHost) {
553
- const wsUrl = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
582
+ const wsUrl = `${daemonWsUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}`;
554
583
  const ws = new WebSocket(wsUrl);
555
584
  let cleanupTerminal = null;
556
585
  return new Promise((resolve) => {
@@ -576,7 +605,7 @@ async function manageInteractiveAttach(sessionId, targetHost) {
576
605
 
577
606
  // Check if other clients are still attached before destroying
578
607
  try {
579
- const res = await fetchWithAuth(`http://${targetHost}:${PORT}/api/sessions`);
608
+ const res = await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions`);
580
609
  if (res.ok) {
581
610
  const sessions = await res.json();
582
611
  const session = sessions.find(s => s.id === sessionId);
@@ -584,7 +613,7 @@ async function manageInteractiveAttach(sessionId, targetHost) {
584
613
  console.log(`\n\x1b[33mLeft room '${sessionId}'. Other clients still attached — session kept alive.\x1b[0m\n`);
585
614
  } else {
586
615
  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' });
616
+ await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
588
617
  }
589
618
  }
590
619
  } catch(e) {
@@ -787,7 +816,7 @@ async function manageInteractive() {
787
816
  const { promptText } = injectPromptResponse;
788
817
  if (!promptText) continue;
789
818
  try {
790
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
819
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
791
820
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: promptText })
792
821
  });
793
822
  const data = await res.json();
@@ -812,6 +841,15 @@ async function main() {
812
841
  return;
813
842
  }
814
843
 
844
+ if (cmd === 'init') {
845
+ const { main: runInit } = require('./src/init/print-snippet');
846
+ const exitCode = runInit(args.slice(1));
847
+ if (exitCode) {
848
+ process.exitCode = exitCode;
849
+ }
850
+ return;
851
+ }
852
+
815
853
  if (cmd === 'update') {
816
854
  console.log('\x1b[36m🔄 Updating telepty to the latest version...\x1b[0m');
817
855
  try {
@@ -1140,7 +1178,7 @@ async function main() {
1140
1178
  // Connect to daemon WebSocket with auto-reconnect
1141
1179
  // owner=1 tells daemon this is the allow bridge (owner), not an attach viewer.
1142
1180
  // 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`;
1181
+ const wsUrl = `${daemonWsUrl(REMOTE_HOST)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}&owner=1`;
1144
1182
  let daemonWs = null;
1145
1183
  let wsReady = false;
1146
1184
  let reconnectAttempts = 0;
@@ -1448,7 +1486,7 @@ async function main() {
1448
1486
  }
1449
1487
  }
1450
1488
 
1451
- const wsUrl = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
1489
+ const wsUrl = `${daemonWsUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}`;
1452
1490
  const ws = new WebSocket(wsUrl);
1453
1491
  let cleanupTerminal = null;
1454
1492
 
@@ -1486,7 +1524,7 @@ async function main() {
1486
1524
 
1487
1525
  // Check if other clients are still attached before destroying
1488
1526
  try {
1489
- const res = await fetchWithAuth(`http://${targetHost}:${PORT}/api/sessions`);
1527
+ const res = await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions`);
1490
1528
  if (res.ok) {
1491
1529
  const allSessions = await res.json();
1492
1530
  const session = allSessions.find(s => s.id === sessionId);
@@ -1494,7 +1532,7 @@ async function main() {
1494
1532
  console.log(`\n\x1b[33mLeft room '${sessionId}'. Other clients still attached — session kept alive.\x1b[0m`);
1495
1533
  } else {
1496
1534
  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' });
1535
+ await fetchWithAuth(`${daemonUrl(targetHost)}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
1498
1536
  }
1499
1537
  }
1500
1538
  } catch(e) {}
@@ -1524,7 +1562,7 @@ async function main() {
1524
1562
  process.exit(1);
1525
1563
  }
1526
1564
 
1527
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
1565
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/screen?lines=${lines}${raw ? '&raw=1' : ''}`);
1528
1566
  const data = await res.json();
1529
1567
  if (!res.ok) { console.error(`❌ Error: ${data.error}`); process.exit(1); }
1530
1568
 
@@ -1550,6 +1588,32 @@ async function main() {
1550
1588
  const useSubmit = submitIndex !== -1;
1551
1589
  if (useSubmit) args.splice(submitIndex, 1);
1552
1590
 
1591
+ // Extract --submit-force flag (gate bypass; opt-in escape hatch).
1592
+ // Mirrors `telepty send-key`'s force semantics: skip both Layer 3 and
1593
+ // Layer 1 gates and dispatch Enter immediately. Safe only when the
1594
+ // caller is confident the target REPL is ready (e.g., orchestrator is
1595
+ // visibly idle). See specs/2026-05-02-submit-force-and-retry.md
1596
+ const submitForceIndex = args.indexOf('--submit-force');
1597
+ const submitForce = submitForceIndex !== -1;
1598
+ if (submitForce) args.splice(submitForceIndex, 1);
1599
+
1600
+ // Extract --submit-retry N flag (default 1, clamp [0, 3]). On a 504
1601
+ // gated-failure with a retry-safe reason (gate timed out and body is
1602
+ // still in the input box → idempotent), wait 300ms and retry. Hard-fail
1603
+ // reasons (session_dead/error/restarting/no_state) do NOT retry —
1604
+ // re-firing won't recover and would be a wasted round-trip.
1605
+ let submitRetries = 1;
1606
+ const submitRetryIndex = args.indexOf('--submit-retry');
1607
+ if (submitRetryIndex !== -1) {
1608
+ const raw = Number(args[submitRetryIndex + 1]);
1609
+ if (Number.isFinite(raw)) {
1610
+ submitRetries = Math.min(Math.max(Math.floor(raw), 0), 3);
1611
+ args.splice(submitRetryIndex, 2);
1612
+ } else {
1613
+ args.splice(submitRetryIndex, 1);
1614
+ }
1615
+ }
1616
+
1553
1617
  // Extract --from flag
1554
1618
  let fromId;
1555
1619
  const fromIndex = args.indexOf('--from');
@@ -1630,7 +1694,7 @@ async function main() {
1630
1694
  noEnter: useSubmit
1631
1695
  });
1632
1696
 
1633
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1697
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1634
1698
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
1635
1699
  });
1636
1700
  const data = await res.json();
@@ -1638,23 +1702,84 @@ async function main() {
1638
1702
  const refSuffix = referencePath ? ` (ref: ${referencePath})` : '';
1639
1703
  console.log(`✅ Context injected successfully into '\x1b[36m${target.id}\x1b[0m'.${refSuffix}`);
1640
1704
 
1641
- // Terminal-level submit: POST /submit after text injection
1705
+ // Terminal-level submit: POST /submit after text injection.
1706
+ // Daemon-side render-gate handles timing (waits for REPL readiness),
1707
+ // so the CLI no longer needs the legacy 500ms blind sleep. Pass the
1708
+ // injected body so the daemon can verify it was consumed by the input
1709
+ // box and bounded-retry once if not.
1710
+ //
1711
+ // 0.3.3: opt-in --submit-force (gate bypass) and idempotent client-side
1712
+ // retry on retry-safe 504s. The retry guard is gate timeout + body
1713
+ // still visible in the input box (verify.consumed=false) — re-firing
1714
+ // an Enter that genuinely never landed cannot double-submit.
1715
+ // See docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md
1642
1716
  if (useSubmit) {
1643
- await new Promise(resolve => setTimeout(resolve, 500));
1644
- try {
1645
- const submitRes = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1646
- method: 'POST',
1647
- headers: { 'Content-Type': 'application/json' },
1648
- body: JSON.stringify({ pre_delay_ms: 600, retries: 2, retry_delay_ms: 500 })
1649
- });
1650
- const submitData = await submitRes.json();
1651
- if (submitRes.ok) {
1652
- console.log(`✅ Submitted via ${submitData.strategy}${submitData.attempts > 1 ? ` (${submitData.attempts} attempts)` : ''}.`);
1653
- } else {
1654
- console.error(`⚠️ Submit failed: ${formatApiError(submitData)}`);
1717
+ const submitBody = {
1718
+ injected_body: injectPrompt || '',
1719
+ retries: 1,
1720
+ retry_delay_ms: 500,
1721
+ ...(submitForce ? { force: true } : {}),
1722
+ };
1723
+ const RETRY_DELAY_MS = 300;
1724
+ const RETRY_SAFE_REASONS = new Set([
1725
+ 'gated_dispatch_unconsumed',
1726
+ 'gate_timeout',
1727
+ 'no_prompt_symbol_seen',
1728
+ ]);
1729
+ const maxAttempts = 1 + submitRetries;
1730
+ let submitRes = null;
1731
+ let submitData = null;
1732
+ let attemptsMade = 0;
1733
+ let lastError = null;
1734
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1735
+ if (attempt > 0) {
1736
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
1655
1737
  }
1656
- } catch (submitErr) {
1657
- console.error(`⚠️ Submit failed: ${submitErr.message}`);
1738
+ attemptsMade = attempt + 1;
1739
+ try {
1740
+ submitRes = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1741
+ method: 'POST',
1742
+ headers: { 'Content-Type': 'application/json' },
1743
+ body: JSON.stringify(submitBody),
1744
+ });
1745
+ submitData = await submitRes.json();
1746
+ } catch (submitErr) {
1747
+ lastError = submitErr;
1748
+ submitRes = null;
1749
+ submitData = null;
1750
+ break;
1751
+ }
1752
+ if (submitRes.ok) break;
1753
+ if (submitRes.status !== 504) break;
1754
+ const retryReason = submitData && typeof submitData.reason === 'string' ? submitData.reason : null;
1755
+ if (!RETRY_SAFE_REASONS.has(retryReason)) break;
1756
+ }
1757
+ if (lastError) {
1758
+ console.error(`⚠️ Submit failed: ${lastError.message}`);
1759
+ } else if (submitRes && submitRes.ok) {
1760
+ const gateNote = submitData.gated && submitData.gate_wait_ms > 0
1761
+ ? ` [gate ${submitData.gate_wait_ms}ms]`
1762
+ : '';
1763
+ const lateNote = submitData.gated_dispatch_after_timeout
1764
+ ? ' (dispatched-after-gate-timeout)'
1765
+ : '';
1766
+ const attemptsNote = submitData.attempts > 1 ? ` (${submitData.attempts} attempts)` : '';
1767
+ const retryNote = attemptsMade > 1 ? ` [retry ${attemptsMade - 1}/${submitRetries}]` : '';
1768
+ const forcedNote = submitData.forced ? ' [forced]' : '';
1769
+ console.log(`✅ Submitted via ${submitData.strategy}${attemptsNote}${gateNote}${lateNote}${retryNote}${forcedNote}.`);
1770
+ } else if (submitRes && submitRes.status === 504) {
1771
+ // Soft failure: REPL never readied. Orchestrator scripts depend on
1772
+ // exit 0 here — surface a clear remediation hint but do not exit
1773
+ // non-zero.
1774
+ const reason = (submitData && submitData.reason) || 'gate_timeout';
1775
+ const lastState = (submitData && submitData.last_state) || 'unknown';
1776
+ const retriesNote = attemptsMade > 1 ? ` after ${attemptsMade} attempts` : '';
1777
+ const hint = submitForce
1778
+ ? ''
1779
+ : ` Try \`telepty inject --submit --submit-force ${target.id} ...\` or manual \`telepty send-key ${target.id} enter\`.`;
1780
+ console.log(`⚠️ Submit gated-timeout (${reason}, last_state=${lastState})${retriesNote}.${hint}`);
1781
+ } else {
1782
+ console.error(`⚠️ Submit failed: ${formatApiError(submitData)}`);
1658
1783
  }
1659
1784
  }
1660
1785
  } catch (e) { console.error(`❌ ${e.message || 'Failed to connect to the target daemon.'}`); }
@@ -1689,7 +1814,7 @@ async function main() {
1689
1814
  return;
1690
1815
  }
1691
1816
 
1692
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1817
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1693
1818
  method: 'POST',
1694
1819
  headers: { 'Content-Type': 'application/json' },
1695
1820
  body: JSON.stringify(buildInjectRequestBody('', {}))
@@ -1719,7 +1844,13 @@ async function main() {
1719
1844
  process.exit(1);
1720
1845
  }
1721
1846
 
1722
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, { method: 'POST' });
1847
+ // send-key is a manual override bypass the render gate via force=true.
1848
+ // See: docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md §3.1
1849
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1850
+ method: 'POST',
1851
+ headers: { 'Content-Type': 'application/json' },
1852
+ body: JSON.stringify({ force: true }),
1853
+ });
1723
1854
  const data = await res.json();
1724
1855
  if (!res.ok) { console.error(`❌ ${formatApiError(data)}`); return; }
1725
1856
  console.log(`✅ Key '${key}' sent to '\x1b[36m${target.id}\x1b[0m'. (strategy: ${data.strategy})`);
@@ -1743,7 +1874,7 @@ async function main() {
1743
1874
  const target = await resolveSessionTarget(replyTo);
1744
1875
  if (!target) { console.error(`❌ Session '${replyTo}' was not found on any discovered host.`); process.exit(1); }
1745
1876
  const body = { prompt: replyText, from: mySessionId, reply_to: mySessionId };
1746
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1877
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
1747
1878
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
1748
1879
  });
1749
1880
  const data = await res.json();
@@ -1767,7 +1898,7 @@ async function main() {
1767
1898
  process.exit(1);
1768
1899
  }
1769
1900
 
1770
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`);
1901
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/state`);
1771
1902
  const data = await res.json();
1772
1903
  if (!res.ok) {
1773
1904
  console.error(`❌ ${formatApiError(data)}`);
@@ -1884,7 +2015,7 @@ async function main() {
1884
2015
  process.exit(1);
1885
2016
  }
1886
2017
 
1887
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`, {
2018
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/state`, {
1888
2019
  method: 'POST',
1889
2020
  headers: { 'Content-Type': 'application/json' },
1890
2021
  body: JSON.stringify(buildSessionStateReportBody({
@@ -1929,7 +2060,7 @@ async function main() {
1929
2060
 
1930
2061
  const aggregate = { successful: [], failed: [] };
1931
2062
  for (const [host, ids] of groupedTargets.entries()) {
1932
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/multicast/inject`, {
2063
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/multicast/inject`, {
1933
2064
  method: 'POST',
1934
2065
  headers: { 'Content-Type': 'application/json' },
1935
2066
  body: JSON.stringify({ session_ids: ids, prompt })
@@ -1972,7 +2103,7 @@ async function main() {
1972
2103
  }
1973
2104
 
1974
2105
  for (const host of groupSessionsByHost(local).keys()) {
1975
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/broadcast/inject`, {
2106
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/broadcast/inject`, {
1976
2107
  method: 'POST',
1977
2108
  headers: { 'Content-Type': 'application/json' },
1978
2109
  body: JSON.stringify({ prompt: localPrompt })
@@ -2019,7 +2150,7 @@ async function main() {
2019
2150
  try {
2020
2151
  const target = await resolveSessionTarget(sessionRef);
2021
2152
  if (!target) { console.error(`❌ Session '${sessionRef}' not found.`); process.exit(1); }
2022
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`, { method: 'DELETE' });
2153
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`, { method: 'DELETE' });
2023
2154
  const data = await res.json();
2024
2155
  if (!res.ok) { console.error(`❌ Error: ${data.error}`); return; }
2025
2156
  console.log(`✅ Session '\x1b[36m${target.id}\x1b[0m' deleted.`);
@@ -2036,7 +2167,7 @@ async function main() {
2036
2167
  if (s.healthStatus === 'STALE' || s.healthStatus === 'DISCONNECTED') {
2037
2168
  try {
2038
2169
  const host = s.host || '127.0.0.1';
2039
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
2170
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
2040
2171
  if (res.ok) { console.log(` 🗑 Removed ghost: \x1b[36m${s.id}\x1b[0m (${s.healthStatus})`); cleaned++; }
2041
2172
  } catch (_) {}
2042
2173
  }
@@ -2056,7 +2187,7 @@ async function main() {
2056
2187
  process.exit(1);
2057
2188
  }
2058
2189
 
2059
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`, {
2190
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`, {
2060
2191
  method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_id: newId })
2061
2192
  });
2062
2193
  const data = await res.json();
@@ -2088,7 +2219,7 @@ async function main() {
2088
2219
  return;
2089
2220
  }
2090
2221
 
2091
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`);
2222
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`);
2092
2223
  const data = await res.json();
2093
2224
  if (!res.ok) {
2094
2225
  console.error(`❌ Error: ${data.error}`);
@@ -2103,7 +2234,7 @@ async function main() {
2103
2234
  return;
2104
2235
  }
2105
2236
 
2106
- const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}`);
2237
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`);
2107
2238
  const data = await res.json();
2108
2239
  if (!res.ok) {
2109
2240
  console.error(`❌ Error: ${data.error}`);
@@ -2556,7 +2687,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2556
2687
  reply_to: orchestratorId,
2557
2688
  thread_id: threadId
2558
2689
  };
2559
- const resp = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(session.id)}/inject`, {
2690
+ const resp = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(session.id)}/inject`, {
2560
2691
  method: 'POST',
2561
2692
  headers: { 'Content-Type': 'application/json' },
2562
2693
  body: JSON.stringify(body)
@@ -2565,7 +2696,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2565
2696
  // Submit after text injection (300ms delay handled by daemon)
2566
2697
  setTimeout(async () => {
2567
2698
  try {
2568
- await fetchWithAuth(`http://${host}:${PORT}/api/sessions/${encodeURIComponent(session.id)}/submit`, { method: 'POST' });
2699
+ await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(session.id)}/submit`, { method: 'POST' });
2569
2700
  } catch {}
2570
2701
  }, 500);
2571
2702
  console.log(` ✅ Injected to ${session.id}`);
@@ -2815,6 +2946,43 @@ Discuss the following topic from your project's perspective. Engage with other s
2815
2946
  return;
2816
2947
  }
2817
2948
 
2949
+ // telepty connect-http <host>[:port] [--name <name>] [--token <token>]
2950
+ // HTTP-only remote daemon registration (no SSH/sshd required).
2951
+ // Records peer in ~/.telepty/peers.json with transport='http' so subsequent
2952
+ // `telepty list`/`inject`/etc. discover sessions on the remote daemon via
2953
+ // its HTTP API. Designed for laptop daemons where running sshd is not
2954
+ // viable. See GitHub issue #13.
2955
+ if (cmd === 'connect-http') {
2956
+ const target = args[1];
2957
+ if (!target) {
2958
+ console.error('❌ Usage: telepty connect-http <host>[:port] [--name <name>] [--token <token>]');
2959
+ process.exit(1);
2960
+ }
2961
+ const nameFlag = args.indexOf('--name');
2962
+ const tokenFlag = args.indexOf('--token');
2963
+ const options = {};
2964
+ if (nameFlag !== -1 && args[nameFlag + 1]) options.name = args[nameFlag + 1];
2965
+ if (tokenFlag !== -1 && args[tokenFlag + 1]) options.token = args[tokenFlag + 1];
2966
+
2967
+ process.stdout.write(`\x1b[36m🔗 Connecting to ${target} via HTTP...\x1b[0m\n`);
2968
+ try {
2969
+ const result = await crossMachine.connectHttp(target, options);
2970
+ if (result.success) {
2971
+ console.log(`\x1b[32m✅ Connected to ${result.name}\x1b[0m`);
2972
+ console.log(` Host: ${result.host}:${result.port}`);
2973
+ console.log(` Machine ID: ${result.machineId}`);
2974
+ console.log(`\nSessions on ${result.name} are now discoverable via \x1b[36mtelepty list\x1b[0m`);
2975
+ } else {
2976
+ console.error(`\x1b[31m❌ ${result.error}\x1b[0m`);
2977
+ process.exit(1);
2978
+ }
2979
+ } catch (err) {
2980
+ console.error(`\x1b[31m❌ ${err.message}\x1b[0m`);
2981
+ process.exit(1);
2982
+ }
2983
+ return;
2984
+ }
2985
+
2818
2986
  // telepty disconnect [<name> | --all]
2819
2987
  if (cmd === 'disconnect') {
2820
2988
  if (args[1] === '--all') {
@@ -2844,8 +3012,9 @@ Discuss the following topic from your project's perspective. Engage with other s
2844
3012
 
2845
3013
  const active = crossMachine.listActivePeers();
2846
3014
  const known = crossMachine.listKnownPeers();
3015
+ const httpPeers = crossMachine.listHttpPeers();
2847
3016
 
2848
- console.log('\x1b[1mConnected Peers:\x1b[0m');
3017
+ console.log('\x1b[1mConnected Peers (SSH ControlMaster):\x1b[0m');
2849
3018
  if (active.length === 0) {
2850
3019
  console.log(' (none)');
2851
3020
  } else {
@@ -2855,7 +3024,8 @@ Discuss the following topic from your project's perspective. Engage with other s
2855
3024
  }
2856
3025
 
2857
3026
  const knownNames = Object.keys(known);
2858
- const disconnected = knownNames.filter(n => !active.find(a => a.name === n));
3027
+ const httpNames = new Set(httpPeers.map((p) => p.name));
3028
+ const disconnected = knownNames.filter(n => !active.find(a => a.name === n) && !httpNames.has(n));
2859
3029
  if (disconnected.length > 0) {
2860
3030
  console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
2861
3031
  for (const name of disconnected) {
@@ -2863,6 +3033,14 @@ Discuss the following topic from your project's perspective. Engage with other s
2863
3033
  console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
2864
3034
  }
2865
3035
  }
3036
+
3037
+ if (httpPeers.length > 0) {
3038
+ console.log('\n\x1b[1mHTTP Peers (no SSH):\x1b[0m');
3039
+ for (const peer of httpPeers) {
3040
+ const tokenNote = peer.hasToken ? ' [token]' : '';
3041
+ console.log(` \x1b[36m◆\x1b[0m ${peer.name} (${peer.host}:${peer.port}) [${peer.machineId}]${tokenNote}`);
3042
+ }
3043
+ }
2866
3044
  return;
2867
3045
  }
2868
3046
 
@@ -2880,7 +3058,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2880
3058
  let connectedHosts = 0;
2881
3059
 
2882
3060
  hosts.forEach((host) => {
2883
- const wsUrl = `ws://${host}:${PORT}/api/bus?token=${encodeURIComponent(TOKEN)}`;
3061
+ const wsUrl = `${daemonWsUrl(host)}/api/bus?token=${encodeURIComponent(getAuthToken())}`;
2884
3062
  const ws = new WebSocket(wsUrl);
2885
3063
 
2886
3064
  ws.on('open', () => {
@@ -2958,7 +3136,8 @@ Discuss the following topic from your project's perspective. Engage with other s
2958
3136
  telepty read-screen <id[@host]> [--lines N] Read session screen buffer
2959
3137
 
2960
3138
  \x1b[1mCross-Machine:\x1b[0m
2961
- telepty connect <user@host> [--name N] [--port P] SSH tunnel to remote host
3139
+ telepty connect <user@host> [--name N] [--port P] SSH tunnel to remote host
3140
+ telepty connect-http <host>[:port] [--name N] [--token T] Register remote daemon via HTTP (no SSH)
2962
3141
  telepty disconnect <name> | --all Disconnect remote host
2963
3142
  telepty peers [--remove <name>] List connected peers
2964
3143