@dmsdc-ai/aigentry-telepty 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,114 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.4.3] - 2026-05-23
6
+
7
+ ### Fixed
8
+
9
+ - **telepty#15** — Daemon version mismatch auto-restart + port-owner
10
+ fallback + banner-to-stderr (root-cause fix for task #400).
11
+ - All five daemon-related banners in `cli.js` (lines 429, 585, 592,
12
+ 594, 600) now emit to `process.stderr` instead of `process.stdout`.
13
+ Closes task #400 (banner contaminated `telepty list --json | jq`
14
+ stdin → `Invalid numeric literal`). A new lint-style regression
15
+ test (`test/banner-stderr-jq-safety.test.js`) statically scans
16
+ `cli.js` and fails CI if any "⚙️/⚠️ Daemon…" banner regresses
17
+ back to `process.stdout.write`.
18
+ - New pure-functional `src/version-handshake.js` exposes
19
+ `decideVersionAction({ daemonVersion, cliVersion })` returning a
20
+ stable action enum (`START` / `RESTART` / `NOOP`) plus reason.
21
+ Six-cell decision matrix: daemon unreachable, CLI-version missing,
22
+ versions equal, daemon older (newer-wins → restart), daemon newer
23
+ (preserve newer daemon → noop), non-semver (string compare).
24
+ Wired into `cli.js` `ensureDaemonRunning` so the previously-inline
25
+ `meta.version !== pkg.version` check now delegates to the module.
26
+ - New port-owner fallback in `daemon-control.js`:
27
+ `findPortOwnerPid(port)` uses `lsof -nP -iTCP:<port> -sTCP:LISTEN -t`
28
+ on POSIX and `Get-NetTCPConnection -State Listen -LocalPort <port>`
29
+ on Windows. `cleanupDaemonProcesses` now treats the listener as a
30
+ third kill candidate (`source: 'port-owner'`) — but only after
31
+ confirming the PID is actually a telepty daemon via
32
+ `pidMatchesTeleptyCmdline`. Unconfirmed port-owners are never
33
+ killed (zero-arbitrary-kill safety). `probeTeleptyOnPort` (HTTP
34
+ `/api/health`) is exported for future async-aware callers.
35
+ - SIGTERM → SIGKILL grace period bumped from 1500 ms to 5000 ms
36
+ (POSIX). Configurable via `TELEPTY_DAEMON_KILL_GRACE_MS` env.
37
+ - New `src/win-kill-process.js` (parallel to existing
38
+ `src/win-resolve-executable.js`) provides `buildTaskkillArgs(pid)`
39
+ and `killWindowsProcess(pid, opts)`. Unit-testable taskkill args
40
+ generator with injectable `execFileSync`. `daemon-control.js`
41
+ Windows branch now delegates to this module.
42
+ - `cleanupDaemonProcesses(opts)` accepts injectors
43
+ (`readDaemonState`, `listDaemonProcesses`, `findPortOwnerPid`,
44
+ `pidMatchesTeleptyCmdline`, `stopDaemonProcess`, `includePortOwner`,
45
+ `port`) for unit-testable source attribution.
46
+ - **Tests**: 343 / 343 pass (301 baseline preserved + 42 new across
47
+ four files: `test/version-handshake.test.js` (16),
48
+ `test/win-kill-process.test.js` (10),
49
+ `test/daemon-control-port-owner.test.js` (10),
50
+ `test/banner-stderr-jq-safety.test.js` (6)).
51
+
52
+ ### Notes
53
+
54
+ - No new npm dependencies (Constitution §17 무의존).
55
+ - No drive-by refactors (Rule 29 surgical); changes limited to
56
+ `cli.js`, `daemon-control.js`, `src/version-handshake.js` (NEW),
57
+ `src/win-kill-process.js` (NEW), and four new test files.
58
+ - **Snyk SAST scan on changed files** — `daemon-control.js` +
59
+ `src/version-handshake.js` + `src/win-kill-process.js` +
60
+ `test/version-handshake.test.js` +
61
+ `test/win-kill-process.test.js` +
62
+ `test/daemon-control-port-owner.test.js` +
63
+ `test/banner-stderr-jq-safety.test.js` = **0 findings**
64
+ (At-Inception clean). `cli.js` shows the same **5 pre-existing
65
+ findings** carried from v0.4.2 (2 Medium Command Injection at
66
+ `execSync` (was L469 → now L471) and `pty.spawn` (was L1096 → now
67
+ L1100); 3 Low Path Traversal at `fs.readFileSync`/`fs.readdirSync`
68
+ (was L2308/L2310/L2619 → now L2312/L2314/L2623)) with **identical
69
+ fingerprints** vs HEAD~1 (5/5 verified by direct rescan of HEAD~1
70
+ `cli.js`: fingerprint leading hashes `6eb481d6`, `24799351`,
71
+ `11a45176`, `11a45176`, `e0fda459` all match). Line numbers
72
+ downstream of `cli.js:21` shifted +2/+4 due to the new
73
+ `version-handshake` require + the expanded
74
+ `restartDaemonGraceful` banner/comment paths; logical
75
+ source→sink unchanged, no new sink call sites added. Out of
76
+ telepty#15 surgical scope. Tracked in
77
+ **dmsdc-ai/aigentry-telepty#26** (task #408) for follow-up PR.
78
+
79
+ ## [0.4.2] - 2026-05-17
80
+
81
+ ### Fixed
82
+
83
+ - **#28** — SSH-peer routing for `telepty inject` / `list` / `enter`
84
+ cross-machine: file-backed `peers.json` fallback resolves the prior
85
+ `fetch failed` against SSH peers in fresh CLI subprocesses. Previously
86
+ `cross-machine.js` consulted only the in-memory `activePeers` Map, which
87
+ is process-local and empty for every CLI subprocess spawned after
88
+ `telepty connect`. New: `listSshPeers` + `getSshPeerHandle` helpers
89
+ (`cross-machine.js`) make SSH-peer discovery/inject symmetric with the
90
+ existing HTTP-peer path; `pickSessionTarget` (`session-routing.js`)
91
+ matches `<id>@<peerName>` against the peer alias; `resolveSessionTarget`
92
+ (`cli.js`) enriches synthetic targets with `peerName` when the host
93
+ matches a known SSH peer. 7 new unit tests
94
+ (`test/cross-machine-ssh-routing.test.js`) + 1 new peer-alias test
95
+ (`test/session-routing.test.js`). Scope: `inject` / `list` / `enter`;
96
+ `attach` / `read-screen` / `rename` / `destroy` / `state` /
97
+ `session info` share the same gap but are deferred to v0.4.3+.
98
+
99
+ ### Notes
100
+
101
+ - **Snyk SAST scan on changed files** — `cross-machine.js` +
102
+ `session-routing.js` + `test/cross-machine-ssh-routing.test.js` +
103
+ `test/session-routing.test.js` = **0 findings** (At-Inception clean).
104
+ `cli.js` shows **5 pre-existing findings** (2 Medium Command Injection
105
+ at `execSync` L469 + `pty.spawn` L1096, 3 Low Path Traversal at
106
+ L2308/L2310/L2619) with **identical fingerprints** vs HEAD~1 (5/5
107
+ verified). Line numbers for sinks below L543 shifted +21 from the
108
+ `resolveSessionTarget` enrichment block (cli.js L543–L566) — logical
109
+ source→sink unchanged; no new sink call sites added. Out of #28
110
+ surgical scope. Tracked in **dmsdc-ai/aigentry-telepty#26** for
111
+ follow-up PR.
112
+
5
113
  ## [0.4.1] - 2026-05-17
6
114
 
7
115
  ### Fixed
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 { resolveWindowsExecutable } = require('./src/win-resolve-executable');
21
+ const { decideVersionAction } = require('./src/version-handshake');
21
22
  const crossMachine = require('./cross-machine');
22
23
  const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
23
24
  const { FileMailbox } = require('./src/mailbox/index');
@@ -426,7 +427,8 @@ async function restartDaemonGraceful(options = {}) {
426
427
  // Retry with backoff
427
428
  if (attempt < maxAttempts) {
428
429
  const backoff = 1000 * attempt;
429
- process.stdout.write(`\x1b[33m⚠️ Daemon restart attempt ${attempt}/${maxAttempts} failed. Retrying in ${backoff / 1000}s...\x1b[0m\n`);
430
+ // stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
431
+ process.stderr.write(`\x1b[33m⚠️ Daemon restart attempt ${attempt}/${maxAttempts} failed. Retrying in ${backoff / 1000}s...\x1b[0m\n`);
430
432
  await new Promise(r => setTimeout(r, backoff));
431
433
  }
432
434
  }
@@ -542,7 +544,28 @@ function isRemoteSession(session) {
542
544
 
543
545
  async function resolveSessionTarget(sessionRef, options = {}) {
544
546
  const sessions = options.sessions || await discoverSessions({ silent: true });
545
- return pickSessionTarget(sessionRef, sessions, REMOTE_HOST);
547
+ const target = pickSessionTarget(sessionRef, sessions, REMOTE_HOST);
548
+ // When <id>@<peerName> uses an SSH peer alias (e.g. `winserver`) and the
549
+ // session is not in `sessions` (discovery missed it, ControlMaster expired,
550
+ // or remote has no such session), pickSessionTarget returns a synthetic
551
+ // target with no peerName/remote flag. Detect SSH peer alias here so the
552
+ // caller routes through cross-machine.remoteInject (SSH path) rather than
553
+ // falling into the HTTP fetch path with `http://winserver:3848/...`. #411
554
+ if (
555
+ target &&
556
+ !target.peerName &&
557
+ target.host &&
558
+ target.host !== '127.0.0.1' &&
559
+ !target.host.includes(':') &&
560
+ !target.host.includes('@')
561
+ ) {
562
+ const sshPeer = crossMachine.getSshPeerHandle(target.host);
563
+ if (sshPeer) {
564
+ target.peerName = target.host;
565
+ target.remote = true;
566
+ }
567
+ }
568
+ return target;
546
569
  }
547
570
 
548
571
  async function ensureDaemonRunning(options = {}) {
@@ -559,24 +582,26 @@ async function ensureDaemonRunning(options = {}) {
559
582
  });
560
583
 
561
584
  if (sessionsRes.ok && hasCapabilities) {
562
- // Version mismatch: running daemon is older than installed CLI
563
- if (meta && meta.version !== pkg.version) {
564
- process.stdout.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
585
+ // Delegate decision to pure-functional handshake so the policy is unit-testable
586
+ // and consistent across CLI invocations.
587
+ const decision = decideVersionAction({ daemonVersion: meta && meta.version, cliVersion: pkg.version });
588
+ if (decision.action === 'restart') {
589
+ // stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
590
+ process.stderr.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
565
591
  await restartDaemonGraceful({ requiredCapabilities });
566
592
  return;
567
- } else {
568
- return;
569
593
  }
594
+ return;
570
595
  } else if (sessionsRes.ok && !meta) {
571
- process.stdout.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
596
+ process.stderr.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
572
597
  } else if (sessionsRes.ok && meta) {
573
- process.stdout.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
598
+ process.stderr.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
574
599
  }
575
600
  } catch (e) {
576
601
  // Continue to auto-start below.
577
602
  }
578
603
 
579
- process.stdout.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
604
+ process.stderr.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
580
605
  await restartDaemonGraceful({ requiredCapabilities });
581
606
  }
582
607
 
package/cross-machine.js CHANGED
@@ -37,6 +37,37 @@ function savePeers(data) {
37
37
  // In-memory active peers
38
38
  const activePeers = new Map(); // name -> { target, controlSocket, connectedAt, machineId }
39
39
 
40
+ // File-backed SSH peer enumeration. Required because CLI subprocesses (fresh
41
+ // node procs) start with an empty `activePeers` Map — only the process that
42
+ // called connect() has it populated. peers.json is the cross-process source of
43
+ // truth. controlPath(target) is deterministic, so any process can reuse the
44
+ // ControlMaster socket established by an earlier connect() as long as
45
+ // ControlPersist hasn't expired. See #411.
46
+ function listSshPeers() {
47
+ const peers = loadPeers().peers || {};
48
+ return Object.entries(peers)
49
+ .filter(([, entry]) => getPeerTransport(entry) === 'ssh' && entry && entry.target)
50
+ .map(([name, entry]) => ({
51
+ name,
52
+ target: entry.target,
53
+ machineId: entry.machineId || name,
54
+ lastConnected: entry.lastConnected
55
+ }));
56
+ }
57
+
58
+ function getSshPeerHandle(name) {
59
+ if (activePeers.has(name)) return activePeers.get(name);
60
+ const peers = loadPeers().peers || {};
61
+ const entry = peers[name];
62
+ if (!entry || getPeerTransport(entry) !== 'ssh' || !entry.target) return null;
63
+ return {
64
+ target: entry.target,
65
+ controlSocket: controlPath(entry.target),
66
+ name,
67
+ machineId: entry.machineId || name
68
+ };
69
+ }
70
+
40
71
  function shellQuote(value) {
41
72
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
42
73
  }
@@ -175,14 +206,11 @@ function disconnectAll() {
175
206
  * @returns {Array} sessions with host info
176
207
  */
177
208
  function listRemoteSessions(name) {
178
- const peer = activePeers.get(name);
209
+ const peer = getSshPeerHandle(name);
179
210
  if (!peer) return [];
180
211
 
181
212
  try {
182
- const output = execSync(
183
- `ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty list --json"`,
184
- { timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
185
- );
213
+ const output = runRemoteCommand(peer, 'telepty list --json', { timeout: 10000 });
186
214
  const sessions = JSON.parse(output);
187
215
  return sessions.map(s => ({ ...s, host: peer.target, peerName: name, remote: true }));
188
216
  } catch {
@@ -191,13 +219,21 @@ function listRemoteSessions(name) {
191
219
  }
192
220
 
193
221
  /**
194
- * Discover sessions across all connected peers.
222
+ * Discover sessions across all connected peers, including SSH peers that are
223
+ * persisted in peers.json but not in this process's activePeers Map. Fresh
224
+ * CLI subprocesses depend on the file-backed path — #411.
195
225
  * @returns {Array} all remote sessions
196
226
  */
197
227
  function discoverAllRemoteSessions() {
198
228
  const allSessions = [];
229
+ const seen = new Set();
199
230
  for (const [name] of activePeers) {
200
231
  allSessions.push(...listRemoteSessions(name));
232
+ seen.add(name);
233
+ }
234
+ for (const peer of listSshPeers()) {
235
+ if (seen.has(peer.name)) continue;
236
+ allSessions.push(...listRemoteSessions(peer.name));
201
237
  }
202
238
  return allSessions;
203
239
  }
@@ -206,7 +242,7 @@ function discoverAllRemoteSessions() {
206
242
  * Inject text into a remote session via SSH.
207
243
  */
208
244
  function remoteInject(name, sessionId, prompt, options = {}) {
209
- const peer = activePeers.get(name);
245
+ const peer = getSshPeerHandle(name);
210
246
  if (!peer) return { success: false, error: `Not connected to ${name}` };
211
247
 
212
248
  try {
@@ -227,7 +263,7 @@ function remoteInject(name, sessionId, prompt, options = {}) {
227
263
  }
228
264
 
229
265
  function remoteEnsureSharedContext(name, descriptor) {
230
- const peer = activePeers.get(name);
266
+ const peer = getSshPeerHandle(name);
231
267
  if (!peer) return { success: false, error: `Not connected to ${name}` };
232
268
 
233
269
  try {
@@ -454,6 +490,9 @@ module.exports = {
454
490
  listHttpPeers,
455
491
  listHttpRemoteSessions,
456
492
  discoverHttpRemoteSessions,
493
+ // File-backed SSH peer enumeration (cross-process — #411)
494
+ listSshPeers,
495
+ getSshPeerHandle,
457
496
  getPeerTransport,
458
497
  PEERS_PATH
459
498
  };
package/daemon-control.js CHANGED
@@ -3,10 +3,19 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const http = require('http');
6
7
  const { execFileSync, execSync } = require('child_process');
8
+ const { killWindowsProcess } = require('./src/win-kill-process');
7
9
 
8
10
  const TELEPTY_DIR = path.join(os.homedir(), '.telepty');
9
11
  const DAEMON_STATE_FILE = path.join(TELEPTY_DIR, 'daemon-state.json');
12
+ const DEFAULT_KILL_GRACE_MS = 5000;
13
+
14
+ function killGraceMs() {
15
+ const raw = Number(process.env.TELEPTY_DAEMON_KILL_GRACE_MS);
16
+ if (Number.isFinite(raw) && raw >= 0) return raw;
17
+ return DEFAULT_KILL_GRACE_MS;
18
+ }
10
19
 
11
20
  function ensureTeleptyDir() {
12
21
  fs.mkdirSync(TELEPTY_DIR, { recursive: true, mode: 0o700 });
@@ -159,12 +168,12 @@ function stopDaemonProcess(pid) {
159
168
 
160
169
  try {
161
170
  if (process.platform === 'win32') {
162
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: ['ignore', 'ignore', 'ignore'] });
163
- return true;
171
+ // Windows: delegate to dedicated module so taskkill args are unit-testable.
172
+ return killWindowsProcess(pid);
164
173
  }
165
174
 
166
175
  process.kill(pid, 'SIGTERM');
167
- const deadline = Date.now() + 1500;
176
+ const deadline = Date.now() + killGraceMs();
168
177
  while (Date.now() < deadline) {
169
178
  if (!isProcessRunning(pid)) {
170
179
  return true;
@@ -179,25 +188,134 @@ function stopDaemonProcess(pid) {
179
188
  }
180
189
  }
181
190
 
182
- function cleanupDaemonProcesses() {
191
+ // Port-owner discovery for telepty#15 port-owner fallback.
192
+ // Returns the LISTEN-state pid bound to `port` on the local machine, or null
193
+ // if no listener can be identified (or detection failed).
194
+ function findPortOwnerPid(port, opts) {
195
+ if (!Number.isInteger(port) || port <= 0) return null;
196
+ const o = opts || {};
197
+ const platform = o.platform || process.platform;
198
+ const exec = o.execSync || execSync;
199
+
200
+ try {
201
+ if (platform === 'win32') {
202
+ // PowerShell: Get-NetTCPConnection guarantees LISTEN-state filter.
203
+ const script =
204
+ `Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue ` +
205
+ `| Select-Object -First 1 -ExpandProperty OwningProcess`;
206
+ const output = String(exec(`powershell.exe -NoProfile -Command "${script}"`, {
207
+ encoding: 'utf8',
208
+ stdio: ['ignore', 'pipe', 'ignore']
209
+ })).trim();
210
+ const pid = Number(output);
211
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
212
+ }
213
+
214
+ // POSIX: lsof -t prints only PIDs; -sTCP:LISTEN narrows to listeners.
215
+ const output = String(exec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, {
216
+ encoding: 'utf8',
217
+ stdio: ['ignore', 'pipe', 'ignore']
218
+ })).trim();
219
+ if (!output) return null;
220
+ const pid = Number(output.split(/\s+/)[0]);
221
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ // Confirm via HTTP probe that the listener on `port` is actually a telepty
228
+ // daemon (token-less /api/health endpoint is enough — daemon.js:2891 path).
229
+ function probeTeleptyOnPort(port, opts) {
230
+ if (!Number.isInteger(port) || port <= 0) return Promise.resolve(false);
231
+ const o = opts || {};
232
+ const timeoutMs = Number.isFinite(o.timeoutMs) ? o.timeoutMs : 1500;
233
+ const httpGet = o.httpGet || http.get;
234
+
235
+ return new Promise((resolve) => {
236
+ let settled = false;
237
+ const finish = (value) => {
238
+ if (settled) return;
239
+ settled = true;
240
+ resolve(value);
241
+ };
242
+
243
+ let req;
244
+ try {
245
+ req = httpGet({ hostname: '127.0.0.1', port, path: '/api/health', timeout: timeoutMs }, (res) => {
246
+ let body = '';
247
+ res.on('data', (chunk) => { body += chunk; });
248
+ res.on('end', () => {
249
+ try {
250
+ const data = JSON.parse(body);
251
+ finish(Boolean(data && data.status === 'ok'));
252
+ } catch {
253
+ finish(false);
254
+ }
255
+ });
256
+ });
257
+ } catch {
258
+ finish(false);
259
+ return;
260
+ }
261
+
262
+ req.on('error', () => finish(false));
263
+ req.on('timeout', () => {
264
+ try { req.destroy(); } catch {}
265
+ finish(false);
266
+ });
267
+ });
268
+ }
269
+
270
+ // Confirm via local process scan that `pid`'s command line looks like a
271
+ // telepty daemon (fallback when HTTP probe fails — daemon may be stuck).
272
+ function pidMatchesTeleptyCmdline(pid) {
273
+ if (!Number.isInteger(pid) || pid <= 0) return false;
274
+ try {
275
+ const processes = process.platform === 'win32' ? listWindowsProcesses() : listUnixProcesses();
276
+ const match = processes.find((item) => item.pid === pid);
277
+ return Boolean(match && isLikelyTeleptyDaemon(match.commandLine));
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ function cleanupDaemonProcesses(opts) {
284
+ const o = opts || {};
183
285
  const targets = new Map();
184
- const state = readDaemonState();
286
+ const state = (o.readDaemonState || readDaemonState)();
185
287
 
186
288
  if (state && Number.isInteger(state.pid) && state.pid > 0 && state.pid !== process.pid) {
187
289
  targets.set(state.pid, { pid: state.pid, source: 'state-file' });
188
290
  }
189
291
 
190
- for (const item of listDaemonProcesses()) {
292
+ for (const item of (o.listDaemonProcesses || listDaemonProcesses)()) {
191
293
  if (!targets.has(item.pid)) {
192
294
  targets.set(item.pid, { pid: item.pid, source: 'process-scan', commandLine: item.commandLine });
193
295
  }
194
296
  }
195
297
 
298
+ // 3rd source: port-owner fallback (telepty#15).
299
+ // Only consider it a kill candidate after a confirmation step so we never
300
+ // SIGTERM an arbitrary process that happens to own the port.
301
+ // Confirmation order: HTTP /api/health probe → ps cmdline match.
302
+ // Sync-friendly: HTTP probe is opt-in (port-owner kept disabled in tests).
303
+ if (o.includePortOwner !== false) {
304
+ const portOwnerPid = (o.findPortOwnerPid || findPortOwnerPid)(o.port || 3848);
305
+ if (portOwnerPid && portOwnerPid !== process.pid && !targets.has(portOwnerPid)) {
306
+ const confirmCmdline = (o.pidMatchesTeleptyCmdline || pidMatchesTeleptyCmdline);
307
+ if (confirmCmdline(portOwnerPid)) {
308
+ targets.set(portOwnerPid, { pid: portOwnerPid, source: 'port-owner' });
309
+ }
310
+ }
311
+ }
312
+
196
313
  const stopped = [];
197
314
  const failed = [];
198
315
 
199
316
  for (const item of targets.values()) {
200
- if (stopDaemonProcess(item.pid)) {
317
+ const killer = o.stopDaemonProcess || stopDaemonProcess;
318
+ if (killer(item.pid)) {
201
319
  stopped.push(item);
202
320
  } else {
203
321
  failed.push(item);
@@ -217,7 +335,10 @@ module.exports = {
217
335
  claimDaemonState,
218
336
  cleanupDaemonProcesses,
219
337
  clearDaemonState,
338
+ findPortOwnerPid,
220
339
  isProcessRunning,
221
340
  listDaemonProcesses,
341
+ pidMatchesTeleptyCmdline,
342
+ probeTeleptyOnPort,
222
343
  readDaemonState
223
344
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -33,9 +33,9 @@
33
33
  "CHANGELOG.md"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js test/win-resolve-executable.test.js && git diff --exit-code tests/snippet-protocol/v1/",
37
- "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js test/win-resolve-executable.test.js",
38
- "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js test/win-resolve-executable.test.js && git diff --exit-code tests/snippet-protocol/v1/",
36
+ "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js && git diff --exit-code tests/snippet-protocol/v1/",
37
+ "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js",
38
+ "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
39
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
40
40
  },
41
41
  "keywords": [
@@ -38,7 +38,12 @@ function pickSessionTarget(sessionRef, sessions, defaultHost = '127.0.0.1') {
38
38
  }
39
39
 
40
40
  if (parsed.host) {
41
- const exactMatch = sessions.find((session) => session.id === parsed.id && session.host === parsed.host);
41
+ // Match by host or by peerName alias (e.g. `sid@winserver` where
42
+ // winserver is the SSH peer name and session.host is `user@FQDN`). #411
43
+ const exactMatch = sessions.find((session) =>
44
+ session.id === parsed.id &&
45
+ (session.host === parsed.host || session.peerName === parsed.host)
46
+ );
42
47
  return exactMatch || { id: parsed.id, host: parsed.host };
43
48
  }
44
49
 
@@ -0,0 +1,82 @@
1
+ // src/version-handshake.js — pure-functional daemon/CLI version handshake policy.
2
+ //
3
+ // Fixes telepty#15 (auto-restart on daemon version mismatch) and task #400
4
+ // (mismatch-banner-on-stdout contaminates `telepty list --json | jq`).
5
+ //
6
+ // Decision policy (newer-wins; daemon-side EADDRINUSE health-probe already
7
+ // handles the symmetric case where a newer daemon refuses to clobber). Input
8
+ // is the daemon `/api/meta` response (or null when unreachable) plus the local
9
+ // CLI package version. Output is a stable action enum the caller maps to side
10
+ // effects (restart vs noop vs start). Pure → unit-testable without sockets.
11
+ //
12
+ // Constraints honored:
13
+ // - Constitution §1 lightweight: ≤80 lines core logic, no deps.
14
+ // - Constitution §2 cross-platform: pure JS, no OS calls.
15
+ // - Constitution §17 무의존: no new npm dependencies.
16
+
17
+ 'use strict';
18
+
19
+ const ACTIONS = Object.freeze({
20
+ NOOP: 'noop',
21
+ RESTART: 'restart',
22
+ START: 'start'
23
+ });
24
+
25
+ function parseSemver(value) {
26
+ if (typeof value !== 'string') return null;
27
+ const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
28
+ if (!match) return null;
29
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
30
+ }
31
+
32
+ function compareSemver(a, b) {
33
+ const left = parseSemver(a);
34
+ const right = parseSemver(b);
35
+ if (!left || !right) return null;
36
+ for (let i = 0; i < 3; i += 1) {
37
+ if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ function decideVersionAction(input) {
43
+ const daemonVersion = input && input.daemonVersion;
44
+ const cliVersion = input && input.cliVersion;
45
+
46
+ // No daemon reachable → caller should start one (separate from restart).
47
+ if (!daemonVersion) {
48
+ return { action: ACTIONS.START, reason: 'daemon-unreachable' };
49
+ }
50
+ if (!cliVersion) {
51
+ return { action: ACTIONS.NOOP, reason: 'cli-version-missing' };
52
+ }
53
+
54
+ const cmp = compareSemver(daemonVersion, cliVersion);
55
+
56
+ // Non-semver values: fall back to string compare so dev/test tags still flow.
57
+ if (cmp === null) {
58
+ if (daemonVersion === cliVersion) {
59
+ return { action: ACTIONS.NOOP, reason: 'versions-equal-nonsemver' };
60
+ }
61
+ return { action: ACTIONS.RESTART, reason: 'version-mismatch-nonsemver' };
62
+ }
63
+
64
+ if (cmp === 0) {
65
+ return { action: ACTIONS.NOOP, reason: 'versions-equal' };
66
+ }
67
+ if (cmp < 0) {
68
+ // Daemon older than CLI → newer-wins, restart.
69
+ return { action: ACTIONS.RESTART, reason: 'daemon-older' };
70
+ }
71
+ // Daemon newer than CLI → respect newer daemon; CLI talks via HTTP wire
72
+ // protocol that is forward-compatible. No restart (avoid clobbering newer).
73
+ return { action: ACTIONS.NOOP, reason: 'daemon-newer' };
74
+ }
75
+
76
+ module.exports = {
77
+ ACTIONS,
78
+ decideVersionAction,
79
+ // Exported for tests + reuse by daemon-control.js port-owner probe.
80
+ parseSemver,
81
+ compareSemver
82
+ };
@@ -0,0 +1,51 @@
1
+ // src/win-kill-process.js — Windows process-kill helper.
2
+ //
3
+ // Fixes telepty#15 (port-owner fallback when stale daemon holds port 3848).
4
+ // On Windows `process.kill(pid, 'SIGTERM')` is unreliable for daemons spawned
5
+ // in a different console group; `taskkill /T /F` is the supported path.
6
+ //
7
+ // On POSIX this module is a no-op (callers fall back to native SIGTERM →
8
+ // SIGKILL grace inline in `daemon-control.js`).
9
+ //
10
+ // Exports:
11
+ // buildTaskkillArgs(pid) → ['/PID', '<pid>', '/T', '/F']
12
+ // killWindowsProcess(pid, opts) → boolean — true on success
13
+ //
14
+ // Constraints honored:
15
+ // - Constitution §1 lightweight: ≤80 lines, child_process only.
16
+ // - Constitution §2 cross-platform: POSIX → returns false fast.
17
+ // - Constitution §17 무의존: no new dependencies.
18
+
19
+ 'use strict';
20
+
21
+ const { execFileSync } = require('child_process');
22
+
23
+ function buildTaskkillArgs(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) {
25
+ throw new Error('telepty: buildTaskkillArgs requires a positive integer pid');
26
+ }
27
+ return ['/PID', String(pid), '/T', '/F'];
28
+ }
29
+
30
+ function killWindowsProcess(pid, opts) {
31
+ const o = opts || {};
32
+ const platform = o.platform || process.platform;
33
+ if (platform !== 'win32') return false;
34
+
35
+ if (!Number.isInteger(pid) || pid <= 0) {
36
+ return false;
37
+ }
38
+
39
+ const exec = o.execFileSync || execFileSync;
40
+ try {
41
+ exec('taskkill', buildTaskkillArgs(pid), { stdio: ['ignore', 'ignore', 'ignore'] });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ module.exports = {
49
+ buildTaskkillArgs,
50
+ killWindowsProcess
51
+ };