@dmsdc-ai/aigentry-telepty 0.4.3 → 0.4.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.
package/daemon.js CHANGED
@@ -6,7 +6,7 @@ const crypto = require('crypto');
6
6
  const { WebSocketServer } = require('ws');
7
7
  const { getConfig } = require('./auth');
8
8
  const pkg = require('./package.json');
9
- const { claimDaemonState, clearDaemonState } = require('./daemon-control');
9
+ const { claimDaemonState, clearDaemonState, isProcessRunning } = require('./daemon-control');
10
10
  const { checkEntitlement } = require('./entitlement');
11
11
  const terminalBackend = require('./terminal-backend');
12
12
  const { FileMailbox } = require('./src/mailbox/index');
@@ -16,6 +16,8 @@ const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = requir
16
16
  const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
17
17
  const submitGate = require('./src/submit-gate');
18
18
  const readyRegistry = require('./src/prompt-symbol-registry');
19
+ const lifecycle = require('./src/lifecycle');
20
+ const { loadTeleptyConfig } = require('./src/config-file');
19
21
 
20
22
  const config = getConfig();
21
23
  const EXPECTED_TOKEN = config.authToken;
@@ -27,6 +29,7 @@ const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STA
27
29
  const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.env.TELEPTY_SESSION_CLEANUP_SECONDS || 300));
28
30
  const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
29
31
  const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
32
+ const IDLE_REAPER_POLL_MS = Math.max(100, Number(process.env.TELEPTY_IDLE_REAPER_POLL_MS || 60000));
30
33
  const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOTSTRAP_READY_TIMEOUT_MS || 30000));
31
34
  const WRAPPED_SUBMIT_DELAY_MS = 500;
32
35
 
@@ -139,7 +142,11 @@ function persistSessions() {
139
142
  lastConnectedAt: s.lastConnectedAt || null,
140
143
  lastDisconnectedAt: s.lastDisconnectedAt || null,
141
144
  lastStateReportAt: s.lastStateReportAt || null,
142
- stateReport: s.stateReport || null
145
+ stateReport: s.stateReport || null,
146
+ idleTtl: s.idleTtl || null,
147
+ idleTtlMs: s.idleTtlMs == null ? null : s.idleTtlMs,
148
+ ownerPid: s.ownerPid || null,
149
+ ptyPid: s.ptyPid || null
143
150
  };
144
151
  }
145
152
  fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
@@ -266,6 +273,13 @@ const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SEC
266
273
  const sessions = {};
267
274
  const handoffs = {};
268
275
  const threads = {};
276
+ let teleptyConfig;
277
+ try {
278
+ teleptyConfig = loadTeleptyConfig();
279
+ } catch (err) {
280
+ console.error(`[CONFIG] Failed to load telepty config: ${err.message}`);
281
+ process.exit(1);
282
+ }
269
283
 
270
284
  function broadcastBusEvent(event) {
271
285
  const serialized = JSON.stringify(event);
@@ -357,6 +371,53 @@ function getSessionHealthReason(session, healthStatus) {
357
371
  return session.ptyProcess && !session.ptyProcess.killed ? 'PTY_RUNNING' : 'PTY_EXITED';
358
372
  }
359
373
 
374
+ function parseOptionalIdleTtl(body) {
375
+ if (!body || !Object.prototype.hasOwnProperty.call(body, 'idle_ttl')) {
376
+ return { present: false };
377
+ }
378
+ try {
379
+ return {
380
+ present: true,
381
+ raw: body.idle_ttl == null ? 'off' : String(body.idle_ttl),
382
+ ms: lifecycle.parseDuration(body.idle_ttl == null ? 'off' : body.idle_ttl, { fieldName: 'idle_ttl' })
383
+ };
384
+ } catch (err) {
385
+ return { present: true, error: err.message };
386
+ }
387
+ }
388
+
389
+ function applyProcessMetadata(session, body) {
390
+ if (!session || !body) return;
391
+ const ownerPid = Number(body.owner_pid);
392
+ const ptyPid = Number(body.pty_pid);
393
+ if (Number.isInteger(ownerPid) && ownerPid > 0) {
394
+ session.ownerPid = ownerPid;
395
+ }
396
+ if (Number.isInteger(ptyPid) && ptyPid > 0) {
397
+ session.ptyPid = ptyPid;
398
+ }
399
+ }
400
+
401
+ function applyIdleTtlMetadata(session, parsedIdleTtl) {
402
+ if (!session || !parsedIdleTtl || !parsedIdleTtl.present || parsedIdleTtl.error) return;
403
+ session.idleTtl = parsedIdleTtl.raw;
404
+ session.idleTtlMs = parsedIdleTtl.ms;
405
+ }
406
+
407
+ function applyTimestampMetadata(session, body) {
408
+ if (!session || !body) return;
409
+ for (const [field, prop] of [
410
+ ['created_at', 'createdAt'],
411
+ ['last_activity_at', 'lastActivityAt']
412
+ ]) {
413
+ if (!Object.prototype.hasOwnProperty.call(body, field)) continue;
414
+ const value = body[field] == null ? null : String(body[field]);
415
+ if (value && Number.isFinite(new Date(value).getTime())) {
416
+ session[prop] = value;
417
+ }
418
+ }
419
+ }
420
+
360
421
  function sleep(ms) {
361
422
  return new Promise((resolve) => setTimeout(resolve, ms));
362
423
  }
@@ -1104,6 +1165,11 @@ function serializeSession(id, session, options = {}) {
1104
1165
  healthReason,
1105
1166
  disconnectedSeconds: disconnectedMs === null ? null : Math.floor(disconnectedMs / 1000),
1106
1167
  lastStateReportAt: session.lastStateReportAt || null,
1168
+ idleTtl: session.idleTtl || null,
1169
+ idleTtlMs: session.idleTtlMs == null ? null : session.idleTtlMs,
1170
+ effectiveIdleTtlMs: lifecycle.effectiveIdleTtlMs(session, teleptyConfig),
1171
+ ownerPid: session.ownerPid || null,
1172
+ ptyPid: session.ptyPid || (session.ptyProcess && session.ptyProcess.pid) || null,
1107
1173
  transport,
1108
1174
  semantic,
1109
1175
  autoState: autoState ? {
@@ -1123,6 +1189,53 @@ function serializeSession(id, session, options = {}) {
1123
1189
  };
1124
1190
  }
1125
1191
 
1192
+ async function teardownSessionById(id, options = {}) {
1193
+ const session = sessions[id];
1194
+ if (!session) {
1195
+ return { success: false, httpStatus: 404, error: 'Session not found' };
1196
+ }
1197
+
1198
+ const timeoutMs = Math.max(0, Number(options.timeoutMs ?? 5000));
1199
+ const force = options.force === true;
1200
+ const reason = options.reason || (force ? 'manual_force' : 'manual');
1201
+ session.isClosing = true;
1202
+
1203
+ const kill = await lifecycle.killSessionProcess(session, { timeoutMs, force });
1204
+ emitSessionLifecycleEvent('session_closed', id, session, {
1205
+ reason,
1206
+ force,
1207
+ pid: kill.pid,
1208
+ signal: kill.signal || null,
1209
+ escalated: kill.escalated === true,
1210
+ source: options.source || 'daemon'
1211
+ });
1212
+
1213
+ if (session.clients) {
1214
+ session.clients.forEach(ws => {
1215
+ try { ws.close(1000, 'Session destroyed'); } catch {}
1216
+ });
1217
+ }
1218
+ if (session.ownerWs) {
1219
+ try { session.ownerWs.close(1000, 'Session destroyed'); } catch {}
1220
+ }
1221
+
1222
+ delete sessions[id];
1223
+ sessionStateManager.unregister(id);
1224
+ try { mailbox.purge(id); } catch {}
1225
+ lifecycle.cleanupSessionArtifacts(id);
1226
+ persistSessions();
1227
+
1228
+ return {
1229
+ success: true,
1230
+ session_id: id,
1231
+ status: 'closed',
1232
+ reason,
1233
+ force,
1234
+ timeout_ms: timeoutMs,
1235
+ kill
1236
+ };
1237
+ }
1238
+
1126
1239
  // Detect terminal environment at daemon startup
1127
1240
  const DETECTED_TERMINAL = terminalBackend.detectTerminal();
1128
1241
  console.log(`[DAEMON] Terminal backend: ${DETECTED_TERMINAL}`);
@@ -1145,6 +1258,10 @@ for (const [id, meta] of Object.entries(_persisted)) {
1145
1258
  lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || new Date().toISOString(),
1146
1259
  lastStateReportAt: meta.lastStateReportAt || null,
1147
1260
  stateReport: meta.stateReport || null,
1261
+ idleTtl: meta.idleTtl || null,
1262
+ idleTtlMs: meta.idleTtlMs == null ? null : meta.idleTtlMs,
1263
+ ownerPid: meta.ownerPid || null,
1264
+ ptyPid: meta.ptyPid || null,
1148
1265
  clients: new Set(), isClosing: false, outputRing: [], ready: true, };
1149
1266
  initializeBootstrapState(sessions[id]);
1150
1267
  console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
@@ -1242,6 +1359,7 @@ app.post('/api/sessions/spawn', (req, res) => {
1242
1359
  id: session_id,
1243
1360
  type: 'spawned',
1244
1361
  ptyProcess,
1362
+ ptyPid: ptyProcess.pid || null,
1245
1363
  command,
1246
1364
  cwd,
1247
1365
  createdAt: new Date().toISOString(),
@@ -1311,6 +1429,10 @@ app.post('/api/sessions/spawn', (req, res) => {
1311
1429
  app.post('/api/sessions/register', (req, res) => {
1312
1430
  const { session_id, command, cwd = process.cwd(), backend, cmux_workspace_id, cmux_surface_id, term_program, term } = req.body;
1313
1431
  if (!session_id) return res.status(400).json({ error: 'session_id is required' });
1432
+ const parsedIdleTtl = parseOptionalIdleTtl(req.body);
1433
+ if (parsedIdleTtl.error) {
1434
+ return res.status(400).json({ error: parsedIdleTtl.error, code: 'INVALID_IDLE_TTL' });
1435
+ }
1314
1436
  // Idempotent: allow re-registration (update command/cwd, keep clients)
1315
1437
  if (sessions[session_id]) {
1316
1438
  const existing = sessions[session_id];
@@ -1333,6 +1455,9 @@ app.post('/api/sessions/register', (req, res) => {
1333
1455
  existing.ready = true;
1334
1456
  markSessionConnected(existing);
1335
1457
  }
1458
+ applyProcessMetadata(existing, req.body);
1459
+ applyIdleTtlMetadata(existing, parsedIdleTtl);
1460
+ applyTimestampMetadata(existing, req.body);
1336
1461
  initializeBootstrapState(existing);
1337
1462
  console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
1338
1463
  return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true });
@@ -1360,12 +1485,17 @@ app.post('/api/sessions/register', (req, res) => {
1360
1485
  lastDisconnectedAt: delivery_type === 'aterm' ? null : new Date().toISOString(),
1361
1486
  lastStateReportAt: null,
1362
1487
  stateReport: null,
1488
+ idleTtl: parsedIdleTtl.present ? parsedIdleTtl.raw : null,
1489
+ idleTtlMs: parsedIdleTtl.present ? parsedIdleTtl.ms : null,
1490
+ ownerPid: Number.isInteger(Number(req.body.owner_pid)) && Number(req.body.owner_pid) > 0 ? Number(req.body.owner_pid) : null,
1491
+ ptyPid: Number.isInteger(Number(req.body.pty_pid)) && Number(req.body.pty_pid) > 0 ? Number(req.body.pty_pid) : null,
1363
1492
  clients: new Set(),
1364
1493
  isClosing: false,
1365
1494
  outputRing: [],
1366
1495
  ready: true, // unknown commands remain injectable once registered (#150)
1367
1496
  };
1368
1497
  initializeBootstrapState(sessionRecord);
1498
+ applyTimestampMetadata(sessionRecord, req.body);
1369
1499
  // Check for existing session with same base alias and emit replaced event
1370
1500
  const baseAlias = session_id.replace(/-\d+$/, '');
1371
1501
  const replaced = Object.keys(sessions).find(id => {
@@ -1836,7 +1966,10 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1836
1966
 
1837
1967
  console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
1838
1968
 
1839
- if (isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
1969
+ // #471 (0.4.5): force=true must bypass the bootstrap gate. Without `!force`
1970
+ // here the per-request escape hatch (cli.js --submit-force) is enqueued and
1971
+ // 504s before the force-bypass block below ever runs.
1972
+ if (!force && isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
1840
1973
  const op = enqueueBootstrapOperation(id, session, {
1841
1974
  type: 'submit',
1842
1975
  body: { ...(req.body || {}) }
@@ -2364,6 +2497,35 @@ app.patch('/api/sessions/:id', (req, res) => {
2364
2497
  res.json({ success: true, old_id: id, new_id });
2365
2498
  });
2366
2499
 
2500
+ app.post('/api/sessions/:id/kill', async (req, res) => {
2501
+ const requestedId = req.params.id;
2502
+ const resolvedId = resolveSessionAlias(requestedId);
2503
+ if (!resolvedId) return res.status(404).json({ error: 'Session not found', requested: requestedId });
2504
+
2505
+ try {
2506
+ const timeoutSeconds = req.body && req.body.timeout != null
2507
+ ? Number(req.body.timeout)
2508
+ : (req.body && req.body.timeout_sec != null ? Number(req.body.timeout_sec) : 5);
2509
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
2510
+ return res.status(400).json({ error: 'timeout must be a non-negative number of seconds', code: 'INVALID_TIMEOUT' });
2511
+ }
2512
+
2513
+ const result = await teardownSessionById(resolvedId, {
2514
+ force: req.body && req.body.force === true,
2515
+ timeoutMs: Math.floor(timeoutSeconds * 1000),
2516
+ reason: req.body && req.body.reason ? String(req.body.reason) : 'manual',
2517
+ source: req.body && req.body.source ? String(req.body.source) : 'api'
2518
+ });
2519
+ if (!result.success) {
2520
+ return res.status(result.httpStatus || 500).json({ error: result.error || 'Failed to kill session' });
2521
+ }
2522
+ console.log(`[KILL] Session ${resolvedId} closed (reason=${result.reason}, force=${result.force}, pid=${result.kill.pid || 'none'})`);
2523
+ res.json(result);
2524
+ } catch (err) {
2525
+ res.status(500).json({ error: err.message || 'Failed to kill session' });
2526
+ }
2527
+ });
2528
+
2367
2529
  app.delete('/api/sessions/:id', (req, res) => {
2368
2530
  const requestedId = req.params.id;
2369
2531
  const resolvedId = resolveSessionAlias(requestedId);
@@ -2381,6 +2543,7 @@ app.delete('/api/sessions/:id', (req, res) => {
2381
2543
  delete sessions[id];
2382
2544
  sessionStateManager.unregister(id);
2383
2545
  try { mailbox.purge(id); } catch {}
2546
+ lifecycle.cleanupSessionArtifacts(id);
2384
2547
  console.log(`[KILL] Session ${id} removed`);
2385
2548
  persistSessions();
2386
2549
  res.json({ success: true, status: 'closing' });
@@ -2389,6 +2552,7 @@ app.delete('/api/sessions/:id', (req, res) => {
2389
2552
  delete sessions[id];
2390
2553
  sessionStateManager.unregister(id);
2391
2554
  try { mailbox.purge(id); } catch {}
2555
+ lifecycle.cleanupSessionArtifacts(id);
2392
2556
  persistSessions();
2393
2557
  console.log(`[KILL] Session ${id} force-removed (process cleanup error: ${err.message})`);
2394
2558
  res.json({ success: true, status: 'force-removed' });
@@ -2721,8 +2885,44 @@ app.patch('/api/threads/:id', (req, res) => {
2721
2885
 
2722
2886
  const server = app.listen(PORT, HOST, () => {
2723
2887
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
2888
+ runStartupBootstrapRestore();
2724
2889
  });
2725
2890
 
2891
+ // #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
2892
+ // persisted sessions are restored at daemon.js:1244 but bootstrapReady stays
2893
+ // false until the owner WS reconnects — leaving every survivor session stuck
2894
+ // at ready:false indefinitely. Re-probe on startup: for cmux sessions whose
2895
+ // owner PID is still alive, run the WS-independent prompt-symbol probe; for
2896
+ // non-cmux survivors, optimistically mark ready (the underlying CLI is alive
2897
+ // and no probe primitive is available).
2898
+ function runStartupBootstrapRestore() {
2899
+ for (const [id, session] of Object.entries(sessions)) {
2900
+ if (!isBootstrapGatedSession(session) || isBootstrapReady(session)) continue;
2901
+ const ownerPid = Number(session.ownerPid);
2902
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !isProcessRunning(ownerPid)) {
2903
+ continue;
2904
+ }
2905
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
2906
+ submitGate.awaitPromptSymbol(session, { timeoutMs: 5000 })
2907
+ .then((result) => {
2908
+ if (result && result.ready) {
2909
+ markBootstrapReady(id, session, 'startup_restore');
2910
+ } else {
2911
+ markBootstrapReady(id, session, 'startup_owner_alive');
2912
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=${result?.reason || 'timeout'})`);
2913
+ }
2914
+ })
2915
+ .catch(() => {
2916
+ markBootstrapReady(id, session, 'startup_owner_alive');
2917
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=error)`);
2918
+ });
2919
+ } else {
2920
+ markBootstrapReady(id, session, 'startup_owner_alive');
2921
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, backend=${session.backend || 'unknown'})`);
2922
+ }
2923
+ }
2924
+ }
2925
+
2726
2926
  // --- Mailbox system initialization ---
2727
2927
  const mailbox = new FileMailbox();
2728
2928
  const mailboxNotifier = new UnixSocketNotifier({ coalesceMs: 25 });
@@ -2769,6 +2969,43 @@ if (staleBroken > 0) {
2769
2969
  mailboxDelivery.start();
2770
2970
 
2771
2971
  const IDLE_THRESHOLD_SECONDS = 60;
2972
+ async function runIdleTtlSweep(nowMs = Date.now()) {
2973
+ const victims = lifecycle.selectIdleTtlVictims(sessions, teleptyConfig, { nowMs });
2974
+ for (const victim of victims) {
2975
+ const session = sessions[victim.id];
2976
+ if (!session || session._idleTtlKilling) continue;
2977
+ session._idleTtlKilling = true;
2978
+ broadcastSessionEvent('tracing', victim.id, session, {
2979
+ nowMs,
2980
+ extra: {
2981
+ action: 'idle_ttl_auto_kill',
2982
+ reason: 'IDLE_TTL',
2983
+ idle_duration: victim.idleSeconds,
2984
+ idle_duration_seconds: victim.idleSeconds,
2985
+ idle_ttl_ms: victim.ttlMs
2986
+ }
2987
+ });
2988
+ try {
2989
+ await teardownSessionById(victim.id, {
2990
+ force: false,
2991
+ timeoutMs: 5000,
2992
+ reason: 'IDLE_TTL',
2993
+ source: 'idle_reaper'
2994
+ });
2995
+ console.log(`[REAPER] Auto-killed ${victim.id} after ${victim.idleSeconds}s idle (ttl=${victim.ttlMs}ms)`);
2996
+ } catch (err) {
2997
+ session._idleTtlKilling = false;
2998
+ console.error(`[REAPER] Failed to auto-kill ${victim.id}: ${err.message}`);
2999
+ }
3000
+ }
3001
+ }
3002
+
3003
+ setInterval(() => {
3004
+ runIdleTtlSweep().catch((err) => {
3005
+ console.error(`[REAPER] Idle TTL sweep failed: ${err.message}`);
3006
+ });
3007
+ }, IDLE_REAPER_POLL_MS);
3008
+
2772
3009
  setInterval(() => {
2773
3010
  const now = Date.now();
2774
3011
  for (const [id, session] of Object.entries(sessions)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -28,14 +28,16 @@
28
28
  "install.sh",
29
29
  "install.ps1",
30
30
  "mcp-server/",
31
+ "scripts/postinstall.js",
31
32
  "src/",
32
33
  "skills/",
33
34
  "CHANGELOG.md"
34
35
  ],
35
36
  "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 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/",
37
+ "postinstall": "node scripts/postinstall.js",
38
+ "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.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/inject-submit-force-env.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 test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
+ "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.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/inject-submit-force-env.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 test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
40
+ "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/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.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/inject-submit-force-env.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 test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
41
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
40
42
  },
41
43
  "keywords": [
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // #469 (0.4.5): npm postinstall hook — restart a stale telepty-daemon after
5
+ // `npm install -g`. Without this, the running daemon keeps executing the
6
+ // previously-loaded code (verified: a daemon ran 22 days through 4 npm
7
+ // upgrades), so user-facing upgrades quietly no-op until they manually kill
8
+ // the daemon. Wires the existing daemon-shutdown primitive into npm's
9
+ // lifecycle; does not add new shutdown logic.
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { spawn, execSync } = require('child_process');
15
+
16
+ const pkg = require('../package.json');
17
+
18
+ function shouldSkip() {
19
+ if (process.env.TELEPTY_SKIP_POSTINSTALL === '1') {
20
+ return 'TELEPTY_SKIP_POSTINSTALL=1';
21
+ }
22
+ // Only act on global installs. Local `npm install` (CI, dev) must not
23
+ // restart a user's daemon.
24
+ if (process.env.npm_config_global !== 'true') {
25
+ return 'non-global install';
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function readDaemonState() {
31
+ const statePath = path.join(os.homedir(), '.telepty', 'daemon-state.json');
32
+ try {
33
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function resolveTeleptyBin() {
40
+ try {
41
+ const cmd = os.platform() === 'win32' ? 'where telepty' : 'which telepty';
42
+ return execSync(cmd, { encoding: 'utf8' }).split('\n')[0].trim() || 'telepty';
43
+ } catch {
44
+ return 'telepty';
45
+ }
46
+ }
47
+
48
+ (function main() {
49
+ const skip = shouldSkip();
50
+ if (skip) {
51
+ console.log(`[telepty postinstall] Skipped (${skip}).`);
52
+ return;
53
+ }
54
+
55
+ const state = readDaemonState();
56
+ if (!state || !Number.isInteger(state.pid) || state.pid <= 0) {
57
+ console.log('[telepty postinstall] No running daemon detected — nothing to restart.');
58
+ return;
59
+ }
60
+
61
+ if (state.version === pkg.version) {
62
+ console.log(`[telepty postinstall] Running daemon already at ${pkg.version} (pid ${state.pid}). No restart needed.`);
63
+ return;
64
+ }
65
+
66
+ console.log(`[telepty postinstall] Detected stale daemon ${state.version || 'unknown'} (pid ${state.pid}); upgrading in-place to ${pkg.version}.`);
67
+
68
+ let stopped = 0;
69
+ try {
70
+ // Lazy require so a malformed install of daemon-control.js doesn't abort
71
+ // postinstall before the skip-check runs.
72
+ const { cleanupDaemonProcesses } = require('../daemon-control');
73
+ const result = cleanupDaemonProcesses();
74
+ stopped = result.stopped.length;
75
+ if (result.failed.length > 0) {
76
+ console.warn(`[telepty postinstall] Could not stop ${result.failed.length} daemon process(es).`);
77
+ }
78
+ } catch (err) {
79
+ console.warn(`[telepty postinstall] cleanupDaemonProcesses failed: ${err.message}`);
80
+ }
81
+
82
+ // launchd/systemd KeepAlive will respawn the daemon automatically on
83
+ // macOS/root-Linux. For other platforms (Windows, non-root Linux) or when
84
+ // the user disabled the service, spawn a fresh detached daemon so upgrades
85
+ // never silently leave the user without one.
86
+ try {
87
+ const bin = resolveTeleptyBin();
88
+ const child = spawn(bin, ['daemon'], { detached: true, stdio: 'ignore' });
89
+ child.unref();
90
+ console.log(`[telepty postinstall] Stopped ${stopped} stale daemon(s); spawned fresh ${pkg.version} daemon.`);
91
+ } catch (err) {
92
+ console.warn(`[telepty postinstall] Daemon respawn failed: ${err.message} (launchd/systemd may restart it automatically).`);
93
+ }
94
+ })();