@dmsdc-ai/aigentry-telepty 0.4.3 → 0.4.4

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/cli.js CHANGED
@@ -23,6 +23,7 @@ const crossMachine = require('./cross-machine');
23
23
  const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
24
24
  const { FileMailbox } = require('./src/mailbox/index');
25
25
  const readyRegistry = require('./src/prompt-symbol-registry');
26
+ const lifecycle = require('./src/lifecycle');
26
27
  const args = process.argv.slice(2);
27
28
  let pendingTerminalInputError = null;
28
29
  let simulatedPromptErrorInjected = false;
@@ -156,6 +157,11 @@ const fetchWithAuth = (url, options = {}) => {
156
157
  return fetch(url, { ...options, headers });
157
158
  };
158
159
 
160
+ function isSubmitForceDefaultEnabled(env = process.env) {
161
+ const value = (env.TELEPTY_SUBMIT_FORCE_DEFAULT || '').trim().toLowerCase();
162
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
163
+ }
164
+
159
165
  async function getDaemonMeta(host = REMOTE_HOST) {
160
166
  try {
161
167
  const res = await fetchWithAuth(`${daemonUrl(host)}/api/meta`, {
@@ -206,6 +212,26 @@ function formatSessionHealth(session) {
206
212
  return status;
207
213
  }
208
214
 
215
+ function enrichSessionIdle(session, nowMs = Date.now()) {
216
+ const idleSeconds = typeof session.idleSeconds === 'number'
217
+ ? session.idleSeconds
218
+ : lifecycle.computeIdleSeconds(session.lastActivityAt, nowMs);
219
+ return {
220
+ ...session,
221
+ idleSeconds,
222
+ idle_seconds: idleSeconds
223
+ };
224
+ }
225
+
226
+ function formatSessionStatusWithIdle(session) {
227
+ const base = formatSessionHealth(session);
228
+ const idleSeconds = typeof session.idleSeconds === 'number' ? session.idleSeconds : null;
229
+ if (idleSeconds !== null && idleSeconds > 60) {
230
+ return `${base} 💤 idle (${lifecycle.formatIdleDuration(idleSeconds)})`;
231
+ }
232
+ return base;
233
+ }
234
+
209
235
  function formatApiError(data, fallback = 'Request failed.') {
210
236
  if (!data) {
211
237
  return fallback;
@@ -914,7 +940,25 @@ async function main() {
914
940
 
915
941
  if (cmd === 'list') {
916
942
  try {
917
- const sessions = await discoverSessions({ silent: true });
943
+ let sessions = await discoverSessions({ silent: true });
944
+ // Bridge merge: surface supervisor-managed sessions discovered via
945
+ // filesystem manifest scan. De-dup with daemon entries by session id.
946
+ // Daemon path remains source-of-truth when both surfaces report the
947
+ // same session; bridge fills the gap when daemon is down (P2 #430).
948
+ try {
949
+ const bridgeSessions = require('./src/bridge/j3-shim').list();
950
+ const seenIds = new Set(sessions.map((s) => s.id));
951
+ for (const bs of bridgeSessions) {
952
+ if (!seenIds.has(bs.id)) {
953
+ sessions.push(bs);
954
+ seenIds.add(bs.id);
955
+ }
956
+ }
957
+ } catch {
958
+ // Best-effort: daemon list still surfaced above.
959
+ }
960
+ const nowMs = Date.now();
961
+ sessions = sessions.map((session) => enrichSessionIdle(session, nowMs));
918
962
  if (args.includes('--json')) {
919
963
  console.log(JSON.stringify(sessions, null, 2));
920
964
  return;
@@ -927,7 +971,7 @@ async function main() {
927
971
  console.log(` Command: ${s.command}`);
928
972
  const autoEmoji = s.autoState ? s.autoState.emoji : '';
929
973
  const autoLabel = s.autoState ? s.autoState.state : '';
930
- console.log(` Status: ${formatSessionHealth(s)}${autoLabel ? ` ${autoEmoji} ${autoLabel}` : ''}`);
974
+ console.log(` Status: ${formatSessionStatusWithIdle(s)}${autoLabel ? ` ${autoEmoji} ${autoLabel}` : ''}`);
931
975
  console.log(` Terminal: ${formatSessionTerminal(s)}`);
932
976
  console.log(` CWD: ${s.cwd}`);
933
977
  console.log(` Clients: ${s.active_clients}`);
@@ -977,6 +1021,24 @@ async function main() {
977
1021
  allowArgs.splice(idIndex, 2);
978
1022
  }
979
1023
 
1024
+ // Extract per-session idle TTL override
1025
+ let idleTtl = null;
1026
+ const idleTtlIndex = allowArgs.indexOf('--idle-ttl');
1027
+ if (idleTtlIndex !== -1) {
1028
+ if (!allowArgs[idleTtlIndex + 1]) {
1029
+ console.error('❌ Usage: telepty allow [--id <session_id>] [--idle-ttl <duration|off>] <command> [args...]');
1030
+ process.exit(1);
1031
+ }
1032
+ idleTtl = allowArgs[idleTtlIndex + 1];
1033
+ try {
1034
+ lifecycle.parseDuration(idleTtl, { fieldName: 'idle_ttl' });
1035
+ } catch (err) {
1036
+ console.error(`❌ ${err.message}`);
1037
+ process.exit(1);
1038
+ }
1039
+ allowArgs.splice(idleTtlIndex, 2);
1040
+ }
1041
+
980
1042
  // Extract --auto-restart flag
981
1043
  const autoRestartIndex = allowArgs.indexOf('--auto-restart');
982
1044
  const autoRestart = autoRestartIndex !== -1;
@@ -1037,7 +1099,9 @@ async function main() {
1037
1099
  cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null,
1038
1100
  cmux_surface_id: process.env.CMUX_SURFACE_ID || null,
1039
1101
  term_program: terminalProgram,
1040
- term: terminalType
1102
+ term: terminalType,
1103
+ owner_pid: process.pid,
1104
+ ...(idleTtl !== null ? { idle_ttl: idleTtl } : {})
1041
1105
  })
1042
1106
  });
1043
1107
  const data = await res.json();
@@ -1060,6 +1124,27 @@ async function main() {
1060
1124
  const MAX_CRASHES = 3;
1061
1125
  const DEATH_LOG_PATH = path.join(os.homedir(), '.telepty', 'logs', 'session-deaths.log');
1062
1126
 
1127
+ function updateDaemonProcessMetadata() {
1128
+ const body = {
1129
+ session_id: sessionId,
1130
+ command,
1131
+ cwd: process.cwd(),
1132
+ backend: detectedBackend,
1133
+ cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null,
1134
+ cmux_surface_id: process.env.CMUX_SURFACE_ID || null,
1135
+ term_program: terminalProgram,
1136
+ term: terminalType,
1137
+ owner_pid: process.pid,
1138
+ ...(child && child.pid ? { pty_pid: child.pid } : {}),
1139
+ ...(idleTtl !== null ? { idle_ttl: idleTtl } : {})
1140
+ };
1141
+ fetchWithAuth(`${DAEMON_URL}/api/sessions/register`, {
1142
+ method: 'POST',
1143
+ headers: { 'Content-Type': 'application/json' },
1144
+ body: JSON.stringify(body)
1145
+ }).catch(() => {});
1146
+ }
1147
+
1063
1148
  function logSessionDeath(exitCode, signal, duration) {
1064
1149
  try {
1065
1150
  fs.mkdirSync(path.dirname(DEATH_LOG_PATH), { recursive: true });
@@ -1105,6 +1190,7 @@ async function main() {
1105
1190
  env: sessionEnv
1106
1191
  });
1107
1192
  sessionStartTime = Date.now();
1193
+ updateDaemonProcessMetadata();
1108
1194
  return child;
1109
1195
  }
1110
1196
 
@@ -1239,7 +1325,10 @@ async function main() {
1239
1325
  cmux_workspace_id: process.env.CMUX_WORKSPACE_ID || null,
1240
1326
  cmux_surface_id: process.env.CMUX_SURFACE_ID || null,
1241
1327
  term_program: terminalProgram,
1242
- term: terminalType
1328
+ term: terminalType,
1329
+ owner_pid: process.pid,
1330
+ ...(child && child.pid ? { pty_pid: child.pid } : {}),
1331
+ ...(idleTtl !== null ? { idle_ttl: idleTtl } : {})
1243
1332
  })
1244
1333
  });
1245
1334
  } catch (e) {
@@ -1644,8 +1733,14 @@ async function main() {
1644
1733
  // caller is confident the target REPL is ready (e.g., orchestrator is
1645
1734
  // visibly idle). See specs/2026-05-02-submit-force-and-retry.md
1646
1735
  const submitForceIndex = args.indexOf('--submit-force');
1647
- const submitForce = submitForceIndex !== -1;
1648
- if (submitForce) args.splice(submitForceIndex, 1);
1736
+ const noSubmitForceIndex = args.indexOf('--no-submit-force');
1737
+ const explicitSubmitForce = submitForceIndex !== -1;
1738
+ const explicitNoSubmitForce = noSubmitForceIndex !== -1;
1739
+ for (const index of [submitForceIndex, noSubmitForceIndex].filter((i) => i !== -1).sort((a, b) => b - a)) {
1740
+ args.splice(index, 1);
1741
+ }
1742
+ const submitForceFromEnv = !explicitSubmitForce && !explicitNoSubmitForce && isSubmitForceDefaultEnabled();
1743
+ const submitForce = explicitSubmitForce || submitForceFromEnv;
1649
1744
 
1650
1745
  // Extract --submit-retry N flag (default 1, clamp [0, 3]). On a 504
1651
1746
  // gated-failure with a retry-safe reason (gate timed out and body is
@@ -1737,6 +1832,23 @@ async function main() {
1737
1832
  referencePath = reference.referencePath;
1738
1833
  }
1739
1834
 
1835
+ // Bridge-first attempt for local supervisor-managed sessions (P2 #430).
1836
+ // Gated submit semantics (render-gate, retry, submit-force) stay on
1837
+ // daemon.js — P2 wire does not carry those yet — so we only bridge the
1838
+ // basic inject path. Bridge failure (no manifest, supervisor crashed
1839
+ // mid-call, etc.) falls through to the daemon HTTP path below.
1840
+ if (!useSubmit) {
1841
+ const bridgeShim = require('./src/bridge/j3-shim');
1842
+ if (bridgeShim.findSupervisorManifest(target.id)) {
1843
+ const bridgeRes = await bridgeShim.inject(target.id, `${injectPrompt}\r`, {});
1844
+ if (bridgeRes.success) {
1845
+ const refSuffix = referencePath ? ` (ref: ${referencePath})` : '';
1846
+ console.log(`✅ Context injected successfully into '\x1b[36m${target.id}\x1b[0m' (bridge).${refSuffix}`);
1847
+ return;
1848
+ }
1849
+ }
1850
+ }
1851
+
1740
1852
  const body = buildInjectRequestBody(injectPrompt, {
1741
1853
  fromId,
1742
1854
  replyTo,
@@ -1764,6 +1876,9 @@ async function main() {
1764
1876
  // an Enter that genuinely never landed cannot double-submit.
1765
1877
  // See docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md
1766
1878
  if (useSubmit) {
1879
+ if (submitForceFromEnv) {
1880
+ console.error('[telepty inject] submit-force=env-default (TELEPTY_SUBMIT_FORCE_DEFAULT=1)');
1881
+ }
1767
1882
  const submitBody = {
1768
1883
  injected_body: injectPrompt || '',
1769
1884
  retries: 1,
@@ -2208,10 +2323,111 @@ async function main() {
2208
2323
  return;
2209
2324
  }
2210
2325
 
2326
+ if (cmd === 'kill') {
2327
+ const killArgs = args.slice(1);
2328
+ const force = killArgs.includes('--force');
2329
+ const timeoutIndex = killArgs.indexOf('--timeout');
2330
+ let timeout = 5;
2331
+ if (timeoutIndex !== -1) {
2332
+ if (!killArgs[timeoutIndex + 1]) {
2333
+ console.error('❌ Usage: telepty kill <session-id> [--force] [--timeout <sec>]');
2334
+ process.exit(1);
2335
+ }
2336
+ timeout = Number(killArgs[timeoutIndex + 1]);
2337
+ if (!Number.isFinite(timeout) || timeout < 0) {
2338
+ console.error('❌ --timeout must be a non-negative number of seconds.');
2339
+ process.exit(1);
2340
+ }
2341
+ killArgs.splice(timeoutIndex, 2);
2342
+ }
2343
+ const filtered = killArgs.filter((item) => item !== '--force');
2344
+ const sessionRef = filtered[0];
2345
+ if (!sessionRef) { console.error('❌ Usage: telepty kill <session-id> [--force] [--timeout <sec>]'); process.exit(1); }
2346
+
2347
+ try {
2348
+ const target = await resolveSessionTarget(sessionRef);
2349
+ if (!target) { console.error(`❌ Session '${sessionRef}' not found.`); process.exit(1); }
2350
+ const res = await fetchWithAuth(`${daemonUrl(target.host)}/api/sessions/${encodeURIComponent(target.id)}/kill`, {
2351
+ method: 'POST',
2352
+ headers: { 'Content-Type': 'application/json' },
2353
+ body: JSON.stringify({ force, timeout, source: 'cli' })
2354
+ });
2355
+ const data = await res.json();
2356
+ if (!res.ok) {
2357
+ console.error(`❌ Error: ${data.error || 'Failed to kill session.'}`);
2358
+ process.exit(1);
2359
+ }
2360
+ console.log(`✅ Session '\x1b[36m${target.id}\x1b[0m' killed${data.kill && data.kill.escalated ? ' (escalated)' : ''}.`);
2361
+ } catch (e) {
2362
+ console.error(`❌ ${e.message || 'Failed to kill session.'}`);
2363
+ process.exit(1);
2364
+ }
2365
+ return;
2366
+ }
2367
+
2211
2368
  if (cmd === 'clean') {
2212
2369
  try {
2370
+ const cleanArgs = args.slice(1);
2371
+ const dryRun = cleanArgs.includes('--dry-run');
2372
+ const idle = cleanArgs.includes('--idle');
2373
+ const olderThanIndex = cleanArgs.indexOf('--older-than');
2374
+ let olderThanMs = null;
2375
+ if (olderThanIndex !== -1) {
2376
+ if (!cleanArgs[olderThanIndex + 1]) {
2377
+ console.error('❌ Usage: telepty clean [--older-than <duration>] [--idle] [--dry-run]');
2378
+ process.exit(1);
2379
+ }
2380
+ try {
2381
+ olderThanMs = lifecycle.parseDuration(cleanArgs[olderThanIndex + 1], { fieldName: '--older-than' });
2382
+ } catch (err) {
2383
+ console.error(`❌ ${err.message}`);
2384
+ process.exit(1);
2385
+ }
2386
+ if (olderThanMs == null) {
2387
+ console.error('❌ --older-than must be a duration like 30m, 1h, or 2d.');
2388
+ process.exit(1);
2389
+ }
2390
+ }
2391
+
2213
2392
  const sessions = await discoverSessions({ silent: true });
2214
2393
  if (sessions.length === 0) { console.log('No sessions found.'); return; }
2394
+ if (olderThanMs !== null) {
2395
+ const targets = lifecycle.selectCleanOlderThanTargets(sessions, {
2396
+ olderThanMs,
2397
+ idle,
2398
+ nowMs: Date.now()
2399
+ });
2400
+ if (targets.length === 0) {
2401
+ console.log(`✅ No ${idle ? 'idle ' : ''}sessions older than ${cleanArgs[olderThanIndex + 1]} found.`);
2402
+ return;
2403
+ }
2404
+ if (dryRun) {
2405
+ targets.forEach((target) => {
2406
+ console.log(` Would remove: \x1b[36m${target.id}\x1b[0m (${target.reference}, ${Math.floor(target.ageSeconds / 60)}m old)`);
2407
+ });
2408
+ console.log(`✅ Dry run: ${targets.length} session(s) would be removed.`);
2409
+ return;
2410
+ }
2411
+
2412
+ let cleaned = 0;
2413
+ for (const target of targets) {
2414
+ try {
2415
+ const host = target.session.host || '127.0.0.1';
2416
+ const res = await fetchWithAuth(`${daemonUrl(host)}/api/sessions/${encodeURIComponent(target.id)}/kill`, {
2417
+ method: 'POST',
2418
+ headers: { 'Content-Type': 'application/json' },
2419
+ body: JSON.stringify({ force: false, timeout: 5, source: 'clean', reason: idle ? 'CLEAN_IDLE_OLDER_THAN' : 'CLEAN_OLDER_THAN' })
2420
+ });
2421
+ if (res.ok) {
2422
+ console.log(` 🗑 Removed session: \x1b[36m${target.id}\x1b[0m (${target.reference})`);
2423
+ cleaned++;
2424
+ }
2425
+ } catch (_) {}
2426
+ }
2427
+ console.log(cleaned > 0 ? `✅ Cleaned ${cleaned} session(s).` : '✅ No sessions cleaned.');
2428
+ return;
2429
+ }
2430
+
2215
2431
  let cleaned = 0;
2216
2432
  for (const s of sessions) {
2217
2433
  if (s.healthStatus === 'STALE' || s.healthStatus === 'DISCONNECTED') {
@@ -3171,10 +3387,12 @@ Discuss the following topic from your project's perspective. Engage with other s
3171
3387
  \x1b[1mSession Management:\x1b[0m
3172
3388
  telepty daemon Start the background daemon (port 3848)
3173
3389
  telepty spawn --id <id> <command> [args...] Spawn a new background session
3174
- telepty allow [--id <id>] [--auto-restart] <command> [args...] Wrap a CLI for remote control
3390
+ telepty allow [--id <id>] [--idle-ttl 1h|off] [--auto-restart] <command> [args...] Wrap a CLI for remote control
3175
3391
  telepty list [--json] List sessions (local + Tailnet)
3176
3392
  telepty attach [id[@host]] Attach interactively (picker if no ID)
3177
3393
  telepty rename <old_id[@host]> <new_id> Rename a session
3394
+ telepty kill <id[@host]> [--force] [--timeout N] Gracefully terminate a session
3395
+ telepty clean [--older-than 7d] [--idle] [--dry-run] Clean ghost or old sessions
3178
3396
  telepty session info <id[@host]> [--json] Show session metadata
3179
3397
 
3180
3398
  \x1b[1mInject & Communicate:\x1b[0m
package/daemon.js CHANGED
@@ -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 => {
@@ -2364,6 +2494,35 @@ app.patch('/api/sessions/:id', (req, res) => {
2364
2494
  res.json({ success: true, old_id: id, new_id });
2365
2495
  });
2366
2496
 
2497
+ app.post('/api/sessions/:id/kill', async (req, res) => {
2498
+ const requestedId = req.params.id;
2499
+ const resolvedId = resolveSessionAlias(requestedId);
2500
+ if (!resolvedId) return res.status(404).json({ error: 'Session not found', requested: requestedId });
2501
+
2502
+ try {
2503
+ const timeoutSeconds = req.body && req.body.timeout != null
2504
+ ? Number(req.body.timeout)
2505
+ : (req.body && req.body.timeout_sec != null ? Number(req.body.timeout_sec) : 5);
2506
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
2507
+ return res.status(400).json({ error: 'timeout must be a non-negative number of seconds', code: 'INVALID_TIMEOUT' });
2508
+ }
2509
+
2510
+ const result = await teardownSessionById(resolvedId, {
2511
+ force: req.body && req.body.force === true,
2512
+ timeoutMs: Math.floor(timeoutSeconds * 1000),
2513
+ reason: req.body && req.body.reason ? String(req.body.reason) : 'manual',
2514
+ source: req.body && req.body.source ? String(req.body.source) : 'api'
2515
+ });
2516
+ if (!result.success) {
2517
+ return res.status(result.httpStatus || 500).json({ error: result.error || 'Failed to kill session' });
2518
+ }
2519
+ console.log(`[KILL] Session ${resolvedId} closed (reason=${result.reason}, force=${result.force}, pid=${result.kill.pid || 'none'})`);
2520
+ res.json(result);
2521
+ } catch (err) {
2522
+ res.status(500).json({ error: err.message || 'Failed to kill session' });
2523
+ }
2524
+ });
2525
+
2367
2526
  app.delete('/api/sessions/:id', (req, res) => {
2368
2527
  const requestedId = req.params.id;
2369
2528
  const resolvedId = resolveSessionAlias(requestedId);
@@ -2381,6 +2540,7 @@ app.delete('/api/sessions/:id', (req, res) => {
2381
2540
  delete sessions[id];
2382
2541
  sessionStateManager.unregister(id);
2383
2542
  try { mailbox.purge(id); } catch {}
2543
+ lifecycle.cleanupSessionArtifacts(id);
2384
2544
  console.log(`[KILL] Session ${id} removed`);
2385
2545
  persistSessions();
2386
2546
  res.json({ success: true, status: 'closing' });
@@ -2389,6 +2549,7 @@ app.delete('/api/sessions/:id', (req, res) => {
2389
2549
  delete sessions[id];
2390
2550
  sessionStateManager.unregister(id);
2391
2551
  try { mailbox.purge(id); } catch {}
2552
+ lifecycle.cleanupSessionArtifacts(id);
2392
2553
  persistSessions();
2393
2554
  console.log(`[KILL] Session ${id} force-removed (process cleanup error: ${err.message})`);
2394
2555
  res.json({ success: true, status: 'force-removed' });
@@ -2769,6 +2930,43 @@ if (staleBroken > 0) {
2769
2930
  mailboxDelivery.start();
2770
2931
 
2771
2932
  const IDLE_THRESHOLD_SECONDS = 60;
2933
+ async function runIdleTtlSweep(nowMs = Date.now()) {
2934
+ const victims = lifecycle.selectIdleTtlVictims(sessions, teleptyConfig, { nowMs });
2935
+ for (const victim of victims) {
2936
+ const session = sessions[victim.id];
2937
+ if (!session || session._idleTtlKilling) continue;
2938
+ session._idleTtlKilling = true;
2939
+ broadcastSessionEvent('tracing', victim.id, session, {
2940
+ nowMs,
2941
+ extra: {
2942
+ action: 'idle_ttl_auto_kill',
2943
+ reason: 'IDLE_TTL',
2944
+ idle_duration: victim.idleSeconds,
2945
+ idle_duration_seconds: victim.idleSeconds,
2946
+ idle_ttl_ms: victim.ttlMs
2947
+ }
2948
+ });
2949
+ try {
2950
+ await teardownSessionById(victim.id, {
2951
+ force: false,
2952
+ timeoutMs: 5000,
2953
+ reason: 'IDLE_TTL',
2954
+ source: 'idle_reaper'
2955
+ });
2956
+ console.log(`[REAPER] Auto-killed ${victim.id} after ${victim.idleSeconds}s idle (ttl=${victim.ttlMs}ms)`);
2957
+ } catch (err) {
2958
+ session._idleTtlKilling = false;
2959
+ console.error(`[REAPER] Failed to auto-kill ${victim.id}: ${err.message}`);
2960
+ }
2961
+ }
2962
+ }
2963
+
2964
+ setInterval(() => {
2965
+ runIdleTtlSweep().catch((err) => {
2966
+ console.error(`[REAPER] Idle TTL sweep failed: ${err.message}`);
2967
+ });
2968
+ }, IDLE_REAPER_POLL_MS);
2969
+
2772
2970
  setInterval(() => {
2773
2971
  const now = Date.now();
2774
2972
  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.4",
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 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/",
36
+ "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 && 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/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",
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/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 && git diff --exit-code tests/snippet-protocol/v1/",
39
39
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
40
40
  },
41
41
  "keywords": [