@dmsdc-ai/aigentry-telepty 0.4.2 → 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,80 @@
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
+
5
79
  ## [0.4.2] - 2026-05-17
6
80
 
7
81
  ### 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
  }
@@ -580,24 +582,26 @@ async function ensureDaemonRunning(options = {}) {
580
582
  });
581
583
 
582
584
  if (sessionsRes.ok && hasCapabilities) {
583
- // Version mismatch: running daemon is older than installed CLI
584
- if (meta && meta.version !== pkg.version) {
585
- 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`);
586
591
  await restartDaemonGraceful({ requiredCapabilities });
587
592
  return;
588
- } else {
589
- return;
590
593
  }
594
+ return;
591
595
  } else if (sessionsRes.ok && !meta) {
592
- 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');
593
597
  } else if (sessionsRes.ok && meta) {
594
- 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');
595
599
  }
596
600
  } catch (e) {
597
601
  // Continue to auto-start below.
598
602
  }
599
603
 
600
- 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');
601
605
  await restartDaemonGraceful({ requiredCapabilities });
602
606
  }
603
607
 
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.2",
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/cross-machine-ssh-routing.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/cross-machine-ssh-routing.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/cross-machine-ssh-routing.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": [
@@ -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
+ };