@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/CHANGELOG.md +385 -0
- package/README.md +17 -0
- package/cli.js +225 -7
- package/daemon.js +240 -3
- package/package.json +6 -4
- package/scripts/postinstall.js +94 -0
- package/src/bridge/j3-shim.js +264 -0
- package/src/bridge/supervisor-ipc.js +330 -0
- package/src/bridge/supervisor-launcher.js +193 -0
- package/src/config-file.js +86 -0
- package/src/lifecycle.js +237 -0
- package/src/prompt-symbol-registry.js +34 -4
- package/src/submit-gate.js +7 -1
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
|
-
|
|
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
|
+
"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
|
-
"
|
|
37
|
-
"test
|
|
38
|
-
"test:
|
|
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
|
+
})();
|