@dmsdc-ai/aigentry-telepty 0.3.3 → 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 (34) hide show
  1. package/AGENTS.md +23 -0
  2. package/CHANGELOG.md +110 -0
  3. package/README.md +67 -1
  4. package/cli.js +125 -39
  5. package/cross-machine.js +132 -0
  6. package/docs/reports/2026-05-05-issue-8-claude-review.md +194 -0
  7. package/docs/specs/2026-05-05-issue-8-telepty-init.md +477 -0
  8. package/host-spec.js +60 -0
  9. package/mcp-server/index.mjs +24 -3
  10. package/package.json +6 -5
  11. package/scripts/regen-snippet-fixtures.js +42 -0
  12. package/skill-installer.js +42 -6
  13. package/skills/telepty/SKILL.md +1 -1
  14. package/skills/telepty-allow/SKILL.md +1 -1
  15. package/skills/telepty-attach/SKILL.md +1 -1
  16. package/skills/telepty-broadcast/SKILL.md +1 -1
  17. package/skills/telepty-daemon/SKILL.md +1 -1
  18. package/skills/telepty-inject/SKILL.md +76 -4
  19. package/skills/telepty-list/SKILL.md +1 -1
  20. package/skills/telepty-listen/SKILL.md +1 -1
  21. package/skills/telepty-rename/SKILL.md +1 -1
  22. package/skills/telepty-session/SKILL.md +1 -1
  23. package/src/init/print-snippet.js +114 -0
  24. package/src/init/snippets/agents.md +15 -0
  25. package/src/init/snippets/claude.md +15 -0
  26. package/src/init/snippets/gemini.md +15 -0
  27. package/tests/snippet-protocol/v1/golden-agents.json +1 -0
  28. package/tests/snippet-protocol/v1/golden-agents.md +17 -0
  29. package/tests/snippet-protocol/v1/golden-all.json +3 -0
  30. package/tests/snippet-protocol/v1/golden-all.md +53 -0
  31. package/tests/snippet-protocol/v1/golden-claude.json +1 -0
  32. package/tests/snippet-protocol/v1/golden-claude.md +17 -0
  33. package/tests/snippet-protocol/v1/golden-gemini.json +1 -0
  34. package/tests/snippet-protocol/v1/golden-gemini.md +17 -0
package/AGENTS.md CHANGED
@@ -72,3 +72,26 @@ telepty inject --ref --from aigentry-telepty-{cli} aigentry-orchestrator-claude
72
72
  - **Evidence-Based**: 추측 금지. 데이터/로그/테스트 결과 기반 판단.
73
73
  - **Fail Fast**: 에러 즉시 보고. 숨기지 않음.
74
74
  - **Constitution**: ~/projects/aigentry/docs/CONSTITUTION.md 준수.
75
+
76
+ ## Legacy exception — `skill-installer.js`
77
+
78
+ `skill-installer.js` is a **named legacy exception** grandfathered by
79
+ **ADR 2026-05-05-telepty-devkit-boundary §6.2.1**. It is the single
80
+ exception to the telepty/devkit boundary rule and is NOT precedent for
81
+ new placements.
82
+
83
+ - **Scope**: bugfixes, security patches, dependency upgrades only.
84
+ - **No new feature expansion**: net-new functionality (new CLI detection,
85
+ new skill types, new install paths, new flags) MUST land in
86
+ `@dmsdc-ai/aigentry-devkit`. At migration time devkit will introduce
87
+ `aigentry scaffold install-skills`.
88
+ - **Reviewer enforcement**: PR reviewers cite ADR 2026-05-05 §6.2.1 when
89
+ rejecting feature-expansion PRs against `skill-installer.js`.
90
+ - **Migration triggers** (per ADR §6.2.1): ≥2 PRs in 60 days attempting
91
+ net-new features; devkit feature requires functionality only in
92
+ `skill-installer.js`; breaking change to its interface.
93
+
94
+ Per-CLI hook installation, `CLAUDE.md`/`AGENTS.md`/`GEMINI.md` scaffolding,
95
+ project-file generation, and cross-cutting installable skills are
96
+ **devkit-owned** per ADR 2026-05-05 §3.3 — not telepty's responsibility.
97
+ See `@dmsdc-ai/aigentry-devkit` (`aigentry scaffold …`).
package/CHANGELOG.md CHANGED
@@ -2,6 +2,116 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.3.5] — 2026-05-05
6
+
7
+ ### Added — `telepty init --print-snippet` (Issue #8)
8
+
9
+ New subcommand that emits the canonical telepty-baseline snippet to stdout for
10
+ graceful integration into per-CLI agent files. **Mechanism only** — telepty
11
+ emits the versioned snippet text; downstream tooling (`aigentry-devkit
12
+ scaffold --integrate-telepty`) owns idempotent insertion into
13
+ `~/CLAUDE.md` / `~/AGENTS.md` / `~/GEMINI.md`. Boundary contract per ADR
14
+ `2026-05-05-telepty-devkit-boundary` (commit `e4b072b`).
15
+
16
+ ```
17
+ telepty init --print-snippet [--target {claude|agents|gemini|all}] [--format {markdown|json}]
18
+ ```
19
+
20
+ - **argv-only**: never consumes stdin (safe in scripted pipelines).
21
+ - **zero file I/O**: pure stdout emission; nothing read from or written to disk.
22
+ - **deterministic**: byte-identical output for a given (target, format) pair —
23
+ fixtures can be hashed for verification.
24
+ - **LF-only bodies**: no CRLF leakage on cross-platform consumers.
25
+ - **stderr clean**: success path emits no warnings.
26
+
27
+ Spec: `docs/specs/2026-05-05-issue-8-telepty-init.md` (commit `8d2dc94`).
28
+ Implementation: `f5c6bad`. Protocol SSOT: `aigentry-ssot/contracts/telepty-snippet-v1.md`
29
+ (commit `f4ff0cd`). 15 conformance fixtures shipped at `tests/snippet-protocol/v1/`
30
+ covering markdown envelopes (claude, agents, gemini, all), JSON records,
31
+ shell-hazard guards, deterministic LF output, default targeting, unsupported-target
32
+ rejection, internal-failure exit codes, stdin-pipe ignore, devkit-free invocation,
33
+ and the snippet golden fixtures themselves.
34
+
35
+ ### Docs — G7/G8/G9 M0 audit gate closure (commit `d7b8b21`)
36
+
37
+ Per ADR `2026-05-05-telepty-devkit-boundary` §3.1.2 (devkit owns content
38
+ placement; telepty owns mechanism), three gates closed:
39
+
40
+ - **G7 — `README.md`**: removed reference to the rejected `telepty install
41
+ hooks` subcommand. Per ADR §3.1.2, that responsibility lives in devkit.
42
+ - **G8 — `AGENTS.md`**: added Legacy exception subsection documenting the
43
+ remaining devkit-shaped legacy surface.
44
+ - **G9 — `skill-installer.js`**: top-of-file LEGACY header per ADR §6.2.1
45
+ marking the module as legacy-track (devkit migration pending).
46
+
47
+ ### Internal
48
+
49
+ - Cross-LLM review pattern applied: Codex implemented the `init` subcommand
50
+ + fixtures; Claude reviewed and ACCEPTed (commit `d06e1e9`).
51
+ - `test/enforce-report.test.js` version assertion bumped to track release
52
+ (commit `d0f4495`).
53
+
54
+ ### Tests
55
+
56
+ - `test/init.test.js` — full coverage of the new subcommand (snippet
57
+ emission, target/format permutations, stdin-ignore, error exits, devkit-free
58
+ invocation).
59
+ - `tests/snippet-protocol/v1/` — golden fixtures for protocol conformance;
60
+ `npm test` runs `git diff --exit-code` against them so any drift fails CI.
61
+
62
+ ### Invariants preserved
63
+
64
+ - Daemon code unchanged. No new dependencies. No `bin` field changes.
65
+ - Existing CLI subcommands (`allow`, `inject`, `list`, `tui`, `daemon`, …)
66
+ unchanged.
67
+ - Cross-host inject path (0.3.4) unchanged.
68
+
69
+ ## [0.3.4] — 2026-05-05
70
+
71
+ ### Added — Cross-host inject (`<id>@<host>` syntax)
72
+
73
+ Enables `telepty inject <id>@<host> "msg"` to deliver to a remote daemon
74
+ without SSH wrapping, by resolving `<host>` against the peer registry and
75
+ issuing direct HTTP `POST /api/sessions/<id>/inject`. Closes the gap that
76
+ forced operators to either pre-shell into the host or pipe through SSH.
77
+
78
+ - **`connect-http` peer mode** (commit `a92cacc`) — new HTTP-only peer
79
+ registration path that does not require a reverse PTY tunnel; suitable
80
+ for daemons reachable via Tailscale / private DNS.
81
+ - **`TELEPTY_HOST` env parser fix** (commit `a92cacc`) — `<id>@<host>` now
82
+ parses correctly when the host segment contains a port or non-default
83
+ scheme; prior parser dropped the host portion silently.
84
+ - **Peer registry HTTP-only mode** — registry entries can be marked
85
+ HTTP-only so the daemon does not attempt PTY fan-out for them.
86
+
87
+ ### Added — Skill installer auto-detect (`486bc1e`)
88
+
89
+ `telepty install` now auto-detects which AI CLIs are present
90
+ (`claude`, `codex`, `gemini`) and only installs the corresponding skill
91
+ files. Reduces noisy "skipped" log lines and prevents stub installs
92
+ on machines that don't have the target CLI yet.
93
+
94
+ ### Fixed — Node 18 ESM regression (`fc7ff9a`)
95
+
96
+ Pinned `uuid@9` (was floating to v10, which is ESM-only and caused
97
+ `ERR_REQUIRE_ESM` under Node 18 CommonJS consumers).
98
+
99
+ ### Docs
100
+
101
+ - Cross-host inject `<id>@<host>` syntax documented (commit `c8b9bbb`).
102
+ - `[context-ref]` inject protocol standardized across docs (commit `8986a96`).
103
+ - REPORT pattern + orchestrator-id runtime resolution documented in skills
104
+ (commit `658f712`).
105
+ - Korean trigger keywords added to skill `SKILL.md` descriptions for
106
+ cross-locale activation (commit `57f46e1`).
107
+
108
+ ### Note — never published to npm
109
+
110
+ `0.3.4` was version-bumped locally but never reached the registry; this
111
+ entry is added retrospectively alongside the `0.3.5` publish so the
112
+ changelog history matches the git log. Registry consumers go directly
113
+ from `0.3.3` → `0.3.5`.
114
+
5
115
  ## [0.3.3] — 2026-05-02
6
116
 
7
117
  ### Added — `inject --submit-force` + idempotent client retry (spec: `docs/superpowers/specs/2026-05-02-submit-force-and-retry.md`)
package/README.md CHANGED
@@ -72,13 +72,31 @@ telepty broadcast "status report"
72
72
 
73
73
  telepty auto-discovers sessions across your Tailnet. All commands (`list`, `attach`, `inject`, `rename`, `multicast`, `broadcast`) work seamlessly across machines.
74
74
 
75
- When the same session ID exists on multiple hosts, disambiguate with `session_id@host`:
75
+ ### `<id>@<host>` syntax
76
+
77
+ To target a specific host (when the same session ID exists on multiple hosts,
78
+ or when there is no Tailnet auto-discovery), append `@<host>` to the session
79
+ ID. `<host>` can be a hostname, LAN IP, or Tailnet name.
76
80
 
77
81
  ```bash
82
+ # Hostname / Tailnet name
78
83
  telepty inject my-session@macbook "hello"
79
84
  telepty attach worker@server-01
85
+
86
+ # LAN IP — useful when no Tailnet is configured
87
+ telepty inject orchestrator-claude@172.28.4.165 "ping"
88
+ telepty read-screen build-runner@10.0.0.42 --lines 50
80
89
  ```
81
90
 
91
+ **Requirements**:
92
+ - The remote daemon must be reachable on port **3848** from the calling host
93
+ (LAN routing, firewall rules, or Tailscale).
94
+ - No SSH or `sshd` is required on either side — the call hits the remote
95
+ daemon's HTTP API directly. This is the recommended path for laptop
96
+ daemons that don't run sshd.
97
+ - The `@<host>` qualifier works for `inject`, `attach`, `read-screen`,
98
+ `enter`, `multicast`, and `rename`.
99
+
82
100
  ## How It Works
83
101
 
84
102
  ```
@@ -92,6 +110,54 @@ CLI (telepty) ──> HTTP/WS ──> Daemon (:3848)
92
110
  - **`inject`** delivers text via the fastest available path: kitty terminal API, WebSocket, or UDS (Unix Domain Socket for embedded integrations)
93
111
  - **`submit`** is handled separately from text injection for reliability across all AI CLIs
94
112
 
113
+ ## `[context-ref]` Protocol — long payloads via shared file
114
+
115
+ When a sender uses `telepty inject --ref <file> <target> "<message>"`, telepty
116
+ stores the payload in a shared file under `~/.telepty/shared/<sha256>.md` and
117
+ injects only a short pointer prompt of the form:
118
+
119
+ ```
120
+ [context-ref] Read ~/.telepty/shared/<sha256>.md and use it as the source of truth for this task.
121
+ <inline message>
122
+ ```
123
+
124
+ This avoids prompt rot in the receiving session (and in the orchestrator's
125
+ window when the reply is small).
126
+
127
+ ### Receiver contract
128
+
129
+ The receiving AI session is expected to:
130
+ 1. Detect the `[context-ref]` prefix on the first line.
131
+ 2. Read the file at the absolute path.
132
+ 3. Treat the file contents as the **authoritative payload** for the task — the
133
+ inline message is supplementary (topic / hint), not the source of truth.
134
+
135
+ ### Storage location
136
+
137
+ - File path: `~/.telepty/shared/<sha256>.md` (sha256 of payload body)
138
+ - Created with mode `0600`; readable only by the local user
139
+ - Persists across sessions; not garbage-collected automatically (run
140
+ `telepty clean --shared` to prune)
141
+
142
+ ### When to use `--ref`
143
+
144
+ - Payload exceeds ~1KB or contains structured content (code, logs, tables).
145
+ - You want the receiver to load the payload deterministically rather than
146
+ paraphrase it from the inject prompt.
147
+ - You're orchestrating a multi-hop conversation where the orchestrator should
148
+ not see the full payload in its own context window.
149
+
150
+ ### Integration scope
151
+
152
+ Per-agent receiver integrations (auto-loading the file via Claude Code
153
+ `UserPromptSubmit` hooks, Codex `AGENTS.md` directives, etc.) are **out of
154
+ scope for telepty core** — they live in the agent's own configuration.
155
+ Per-CLI hook installation lives in devkit: run `aigentry scaffold
156
+ install-hooks {claude|codex|gemini}` after installing
157
+ `@dmsdc-ai/aigentry-devkit`. (Older drafts proposed a receiver-side
158
+ `telepty install` subcommand for this; that direction is rejected per ADR
159
+ 2026-05-05-telepty-devkit-boundary §3.1.2 / §3.4 row 2.)
160
+
95
161
  ## Inject Delivery Paths
96
162
 
97
163
  | Priority | Method | When |
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
 
@@ -1656,7 +1694,7 @@ async function main() {
1656
1694
  noEnter: useSubmit
1657
1695
  });
1658
1696
 
1659
- 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`, {
1660
1698
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
1661
1699
  });
1662
1700
  const data = await res.json();
@@ -1699,7 +1737,7 @@ async function main() {
1699
1737
  }
1700
1738
  attemptsMade = attempt + 1;
1701
1739
  try {
1702
- submitRes = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1740
+ submitRes = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1703
1741
  method: 'POST',
1704
1742
  headers: { 'Content-Type': 'application/json' },
1705
1743
  body: JSON.stringify(submitBody),
@@ -1776,7 +1814,7 @@ async function main() {
1776
1814
  return;
1777
1815
  }
1778
1816
 
1779
- 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`, {
1780
1818
  method: 'POST',
1781
1819
  headers: { 'Content-Type': 'application/json' },
1782
1820
  body: JSON.stringify(buildInjectRequestBody('', {}))
@@ -1808,7 +1846,7 @@ async function main() {
1808
1846
 
1809
1847
  // send-key is a manual override — bypass the render gate via force=true.
1810
1848
  // 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`, {
1849
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
1812
1850
  method: 'POST',
1813
1851
  headers: { 'Content-Type': 'application/json' },
1814
1852
  body: JSON.stringify({ force: true }),
@@ -1836,7 +1874,7 @@ async function main() {
1836
1874
  const target = await resolveSessionTarget(replyTo);
1837
1875
  if (!target) { console.error(`❌ Session '${replyTo}' was not found on any discovered host.`); process.exit(1); }
1838
1876
  const body = { prompt: replyText, from: mySessionId, reply_to: mySessionId };
1839
- 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`, {
1840
1878
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
1841
1879
  });
1842
1880
  const data = await res.json();
@@ -1860,7 +1898,7 @@ async function main() {
1860
1898
  process.exit(1);
1861
1899
  }
1862
1900
 
1863
- 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`);
1864
1902
  const data = await res.json();
1865
1903
  if (!res.ok) {
1866
1904
  console.error(`❌ ${formatApiError(data)}`);
@@ -1977,7 +2015,7 @@ async function main() {
1977
2015
  process.exit(1);
1978
2016
  }
1979
2017
 
1980
- 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`, {
1981
2019
  method: 'POST',
1982
2020
  headers: { 'Content-Type': 'application/json' },
1983
2021
  body: JSON.stringify(buildSessionStateReportBody({
@@ -2022,7 +2060,7 @@ async function main() {
2022
2060
 
2023
2061
  const aggregate = { successful: [], failed: [] };
2024
2062
  for (const [host, ids] of groupedTargets.entries()) {
2025
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/multicast/inject`, {
2063
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/multicast/inject`, {
2026
2064
  method: 'POST',
2027
2065
  headers: { 'Content-Type': 'application/json' },
2028
2066
  body: JSON.stringify({ session_ids: ids, prompt })
@@ -2065,7 +2103,7 @@ async function main() {
2065
2103
  }
2066
2104
 
2067
2105
  for (const host of groupSessionsByHost(local).keys()) {
2068
- const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions/broadcast/inject`, {
2106
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/broadcast/inject`, {
2069
2107
  method: 'POST',
2070
2108
  headers: { 'Content-Type': 'application/json' },
2071
2109
  body: JSON.stringify({ prompt: localPrompt })
@@ -2112,7 +2150,7 @@ async function main() {
2112
2150
  try {
2113
2151
  const target = await resolveSessionTarget(sessionRef);
2114
2152
  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' });
2153
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}`, { method: 'DELETE' });
2116
2154
  const data = await res.json();
2117
2155
  if (!res.ok) { console.error(`❌ Error: ${data.error}`); return; }
2118
2156
  console.log(`✅ Session '\x1b[36m${target.id}\x1b[0m' deleted.`);
@@ -2129,7 +2167,7 @@ async function main() {
2129
2167
  if (s.healthStatus === 'STALE' || s.healthStatus === 'DISCONNECTED') {
2130
2168
  try {
2131
2169
  const host = s.host || '127.0.0.1';
2132
- 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' });
2133
2171
  if (res.ok) { console.log(` 🗑 Removed ghost: \x1b[36m${s.id}\x1b[0m (${s.healthStatus})`); cleaned++; }
2134
2172
  } catch (_) {}
2135
2173
  }
@@ -2149,7 +2187,7 @@ async function main() {
2149
2187
  process.exit(1);
2150
2188
  }
2151
2189
 
2152
- 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)}`, {
2153
2191
  method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_id: newId })
2154
2192
  });
2155
2193
  const data = await res.json();
@@ -2181,7 +2219,7 @@ async function main() {
2181
2219
  return;
2182
2220
  }
2183
2221
 
2184
- 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)}`);
2185
2223
  const data = await res.json();
2186
2224
  if (!res.ok) {
2187
2225
  console.error(`❌ Error: ${data.error}`);
@@ -2196,7 +2234,7 @@ async function main() {
2196
2234
  return;
2197
2235
  }
2198
2236
 
2199
- 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)}`);
2200
2238
  const data = await res.json();
2201
2239
  if (!res.ok) {
2202
2240
  console.error(`❌ Error: ${data.error}`);
@@ -2649,7 +2687,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2649
2687
  reply_to: orchestratorId,
2650
2688
  thread_id: threadId
2651
2689
  };
2652
- 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`, {
2653
2691
  method: 'POST',
2654
2692
  headers: { 'Content-Type': 'application/json' },
2655
2693
  body: JSON.stringify(body)
@@ -2658,7 +2696,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2658
2696
  // Submit after text injection (300ms delay handled by daemon)
2659
2697
  setTimeout(async () => {
2660
2698
  try {
2661
- 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' });
2662
2700
  } catch {}
2663
2701
  }, 500);
2664
2702
  console.log(` ✅ Injected to ${session.id}`);
@@ -2908,6 +2946,43 @@ Discuss the following topic from your project's perspective. Engage with other s
2908
2946
  return;
2909
2947
  }
2910
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
+
2911
2986
  // telepty disconnect [<name> | --all]
2912
2987
  if (cmd === 'disconnect') {
2913
2988
  if (args[1] === '--all') {
@@ -2937,8 +3012,9 @@ Discuss the following topic from your project's perspective. Engage with other s
2937
3012
 
2938
3013
  const active = crossMachine.listActivePeers();
2939
3014
  const known = crossMachine.listKnownPeers();
3015
+ const httpPeers = crossMachine.listHttpPeers();
2940
3016
 
2941
- console.log('\x1b[1mConnected Peers:\x1b[0m');
3017
+ console.log('\x1b[1mConnected Peers (SSH ControlMaster):\x1b[0m');
2942
3018
  if (active.length === 0) {
2943
3019
  console.log(' (none)');
2944
3020
  } else {
@@ -2948,7 +3024,8 @@ Discuss the following topic from your project's perspective. Engage with other s
2948
3024
  }
2949
3025
 
2950
3026
  const knownNames = Object.keys(known);
2951
- 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));
2952
3029
  if (disconnected.length > 0) {
2953
3030
  console.log('\n\x1b[1mKnown Peers (disconnected):\x1b[0m');
2954
3031
  for (const name of disconnected) {
@@ -2956,6 +3033,14 @@ Discuss the following topic from your project's perspective. Engage with other s
2956
3033
  console.log(` \x1b[90m○\x1b[0m ${name} (${p.target}) — last: ${p.lastConnected || 'never'}`);
2957
3034
  }
2958
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
+ }
2959
3044
  return;
2960
3045
  }
2961
3046
 
@@ -2973,7 +3058,7 @@ Discuss the following topic from your project's perspective. Engage with other s
2973
3058
  let connectedHosts = 0;
2974
3059
 
2975
3060
  hosts.forEach((host) => {
2976
- const wsUrl = `ws://${host}:${PORT}/api/bus?token=${encodeURIComponent(TOKEN)}`;
3061
+ const wsUrl = `${daemonWsUrl(host)}/api/bus?token=${encodeURIComponent(getAuthToken())}`;
2977
3062
  const ws = new WebSocket(wsUrl);
2978
3063
 
2979
3064
  ws.on('open', () => {
@@ -3051,7 +3136,8 @@ Discuss the following topic from your project's perspective. Engage with other s
3051
3136
  telepty read-screen <id[@host]> [--lines N] Read session screen buffer
3052
3137
 
3053
3138
  \x1b[1mCross-Machine:\x1b[0m
3054
- 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)
3055
3141
  telepty disconnect <name> | --all Disconnect remote host
3056
3142
  telepty peers [--remove <name>] List connected peers
3057
3143