instar 0.23.14 → 0.23.16

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.
Files changed (58) hide show
  1. package/dashboard/index.html +353 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +70 -5
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/PostUpdateMigrator.d.ts +11 -0
  6. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  7. package/dist/core/PostUpdateMigrator.js +69 -0
  8. package/dist/core/PostUpdateMigrator.js.map +1 -1
  9. package/dist/core/SessionManager.d.ts +18 -0
  10. package/dist/core/SessionManager.d.ts.map +1 -1
  11. package/dist/core/SessionManager.js +82 -2
  12. package/dist/core/SessionManager.js.map +1 -1
  13. package/dist/core/TopicResumeMap.d.ts +14 -2
  14. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  15. package/dist/core/TopicResumeMap.js +36 -41
  16. package/dist/core/TopicResumeMap.js.map +1 -1
  17. package/dist/core/types.d.ts +2 -0
  18. package/dist/core/types.d.ts.map +1 -1
  19. package/dist/core/types.js.map +1 -1
  20. package/dist/data/http-hook-templates.js +10 -10
  21. package/dist/data/http-hook-templates.js.map +1 -1
  22. package/dist/memory/TopicMemory.js +3 -3
  23. package/dist/memory/TopicMemory.js.map +1 -1
  24. package/dist/monitoring/SessionRecovery.d.ts +110 -0
  25. package/dist/monitoring/SessionRecovery.d.ts.map +1 -0
  26. package/dist/monitoring/SessionRecovery.js +432 -0
  27. package/dist/monitoring/SessionRecovery.js.map +1 -0
  28. package/dist/monitoring/crash-detector.d.ts +50 -0
  29. package/dist/monitoring/crash-detector.d.ts.map +1 -0
  30. package/dist/monitoring/crash-detector.js +224 -0
  31. package/dist/monitoring/crash-detector.js.map +1 -0
  32. package/dist/monitoring/jsonl-truncator.d.ts +44 -0
  33. package/dist/monitoring/jsonl-truncator.d.ts.map +1 -0
  34. package/dist/monitoring/jsonl-truncator.js +224 -0
  35. package/dist/monitoring/jsonl-truncator.js.map +1 -0
  36. package/dist/monitoring/stall-detector.d.ts +34 -0
  37. package/dist/monitoring/stall-detector.d.ts.map +1 -0
  38. package/dist/monitoring/stall-detector.js +151 -0
  39. package/dist/monitoring/stall-detector.js.map +1 -0
  40. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  41. package/dist/scheduler/JobScheduler.js +16 -2
  42. package/dist/scheduler/JobScheduler.js.map +1 -1
  43. package/dist/server/WebSocketManager.d.ts +2 -0
  44. package/dist/server/WebSocketManager.d.ts.map +1 -1
  45. package/dist/server/WebSocketManager.js +24 -3
  46. package/dist/server/WebSocketManager.js.map +1 -1
  47. package/dist/server/routes.d.ts.map +1 -1
  48. package/dist/server/routes.js +118 -5
  49. package/dist/server/routes.js.map +1 -1
  50. package/package.json +2 -1
  51. package/scripts/pre-push-gate.js +149 -0
  52. package/src/data/builtin-manifest.json +63 -63
  53. package/src/data/http-hook-templates.ts +10 -10
  54. package/src/templates/hooks/telegram-topic-context.sh +4 -4
  55. package/upgrades/0.23.10.md +19 -0
  56. package/upgrades/0.23.11.md +21 -0
  57. package/upgrades/0.23.15.md +30 -0
  58. package/upgrades/0.23.16.md +25 -0
@@ -1206,6 +1206,213 @@
1206
1206
  to { opacity: 1; transform: translateY(0); }
1207
1207
  }
1208
1208
 
1209
+ /* ── New Session modal ────────────────────────────── */
1210
+ .modal-backdrop {
1211
+ position: fixed;
1212
+ inset: 0;
1213
+ background: rgba(0,0,0,0.6);
1214
+ z-index: 5000;
1215
+ display: flex;
1216
+ align-items: center;
1217
+ justify-content: center;
1218
+ }
1219
+
1220
+ .modal-dialog {
1221
+ background: var(--bg-panel);
1222
+ border: 1px solid var(--border);
1223
+ border-radius: 12px;
1224
+ padding: 24px;
1225
+ width: 380px;
1226
+ max-width: 90vw;
1227
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
1228
+ }
1229
+
1230
+ .modal-dialog h3 {
1231
+ color: var(--text-bright);
1232
+ font-size: 16px;
1233
+ margin-bottom: 16px;
1234
+ }
1235
+
1236
+ .modal-field {
1237
+ margin-bottom: 14px;
1238
+ }
1239
+
1240
+ .modal-field label {
1241
+ display: block;
1242
+ font-size: 12px;
1243
+ color: var(--text-dim);
1244
+ margin-bottom: 4px;
1245
+ text-transform: uppercase;
1246
+ letter-spacing: 0.04em;
1247
+ }
1248
+
1249
+ .modal-field input[type="text"] {
1250
+ width: 100%;
1251
+ padding: 10px 12px;
1252
+ border-radius: 6px;
1253
+ border: 1px solid var(--border);
1254
+ background: var(--bg);
1255
+ color: var(--text-bright);
1256
+ font-size: 14px;
1257
+ outline: none;
1258
+ box-sizing: border-box;
1259
+ }
1260
+
1261
+ .modal-field input[type="text"]:focus {
1262
+ border-color: var(--accent-dim);
1263
+ }
1264
+
1265
+ .modal-toggle {
1266
+ display: flex;
1267
+ align-items: center;
1268
+ gap: 10px;
1269
+ cursor: pointer;
1270
+ user-select: none;
1271
+ }
1272
+
1273
+ .modal-toggle input[type="checkbox"] {
1274
+ appearance: none;
1275
+ width: 36px;
1276
+ height: 20px;
1277
+ border-radius: 10px;
1278
+ background: #333;
1279
+ position: relative;
1280
+ cursor: pointer;
1281
+ transition: background 0.2s;
1282
+ flex-shrink: 0;
1283
+ }
1284
+
1285
+ .modal-toggle input[type="checkbox"]::after {
1286
+ content: '';
1287
+ position: absolute;
1288
+ top: 2px;
1289
+ left: 2px;
1290
+ width: 16px;
1291
+ height: 16px;
1292
+ border-radius: 50%;
1293
+ background: #888;
1294
+ transition: transform 0.2s, background 0.2s;
1295
+ }
1296
+
1297
+ .modal-toggle input[type="checkbox"]:checked {
1298
+ background: var(--accent-dim);
1299
+ }
1300
+
1301
+ .modal-toggle input[type="checkbox"]:checked::after {
1302
+ transform: translateX(16px);
1303
+ background: #fff;
1304
+ }
1305
+
1306
+ .modal-toggle span {
1307
+ font-size: 13px;
1308
+ color: var(--text);
1309
+ }
1310
+
1311
+ .modal-hint {
1312
+ font-size: 11px;
1313
+ color: var(--text-dim);
1314
+ margin-top: 4px;
1315
+ font-style: italic;
1316
+ }
1317
+
1318
+ .modal-actions {
1319
+ display: flex;
1320
+ gap: 8px;
1321
+ margin-top: 20px;
1322
+ justify-content: flex-end;
1323
+ }
1324
+
1325
+ .modal-actions button {
1326
+ padding: 8px 18px;
1327
+ border-radius: 6px;
1328
+ font-size: 13px;
1329
+ font-weight: 500;
1330
+ cursor: pointer;
1331
+ border: 1px solid var(--border);
1332
+ }
1333
+
1334
+ .modal-btn-cancel {
1335
+ background: var(--bg);
1336
+ color: var(--text);
1337
+ }
1338
+
1339
+ .modal-btn-cancel:hover {
1340
+ background: var(--bg-hover);
1341
+ }
1342
+
1343
+ .modal-btn-create {
1344
+ background: var(--accent-dim);
1345
+ color: #000;
1346
+ border-color: var(--accent-dim);
1347
+ }
1348
+
1349
+ .modal-btn-create:hover {
1350
+ background: var(--accent);
1351
+ }
1352
+
1353
+ .modal-btn-create:disabled {
1354
+ opacity: 0.4;
1355
+ cursor: not-allowed;
1356
+ }
1357
+
1358
+ /* ── New session button ───────────────────────────── */
1359
+ .new-session-btn {
1360
+ width: 28px;
1361
+ height: 28px;
1362
+ border-radius: 6px;
1363
+ border: 1px solid var(--border);
1364
+ background: var(--bg);
1365
+ color: var(--accent);
1366
+ font-size: 18px;
1367
+ cursor: pointer;
1368
+ display: flex;
1369
+ align-items: center;
1370
+ justify-content: center;
1371
+ transition: all 0.15s;
1372
+ line-height: 1;
1373
+ }
1374
+
1375
+ .new-session-btn:hover {
1376
+ background: var(--bg-hover);
1377
+ border-color: var(--accent-dim);
1378
+ }
1379
+
1380
+ /* ── Session close button ─────────────────────────── */
1381
+ .session-close-btn {
1382
+ background: none;
1383
+ border: none;
1384
+ color: var(--text-dim);
1385
+ cursor: pointer;
1386
+ font-size: 14px;
1387
+ padding: 2px 6px;
1388
+ border-radius: 4px;
1389
+ opacity: 0;
1390
+ transition: opacity 0.15s, color 0.15s;
1391
+ flex-shrink: 0;
1392
+ line-height: 1;
1393
+ }
1394
+
1395
+ .session-item:hover .session-close-btn,
1396
+ .session-item.active .session-close-btn {
1397
+ opacity: 1;
1398
+ }
1399
+
1400
+ .session-close-btn:hover {
1401
+ color: var(--red);
1402
+ background: rgba(239, 68, 68, 0.1);
1403
+ }
1404
+
1405
+ .session-name-row {
1406
+ display: flex;
1407
+ align-items: center;
1408
+ gap: 4px;
1409
+ }
1410
+
1411
+ .session-name-row .session-name {
1412
+ flex: 1;
1413
+ min-width: 0;
1414
+ }
1415
+
1209
1416
  /* Conflict dialog */
1210
1417
  .conflict-overlay {
1211
1418
  position: fixed;
@@ -1493,7 +1700,10 @@
1493
1700
  <aside class="sidebar" id="sessionsTab">
1494
1701
  <div class="sidebar-header">
1495
1702
  <h2>Sessions</h2>
1496
- <span class="session-count" id="sessionCount">0</span>
1703
+ <div style="display:flex;align-items:center;gap:8px;">
1704
+ <span class="session-count" id="sessionCount">0</span>
1705
+ <button class="new-session-btn" onclick="openNewSessionModal()" title="New session">+</button>
1706
+ </div>
1497
1707
  </div>
1498
1708
  <div class="session-list" id="sessionList">
1499
1709
  <div class="empty-state" id="emptyState">
@@ -1528,6 +1738,8 @@
1528
1738
  <button class="action-btn action-arrow" onclick="sendKey('Down')" title="Arrow Down">&darr;</button>
1529
1739
  <button class="action-btn action-arrow" onclick="sendKey('Left')" title="Arrow Left">&larr;</button>
1530
1740
  <button class="action-btn action-arrow" onclick="sendKey('Right')" title="Arrow Right">&rarr;</button>
1741
+ <span class="action-separator"></span>
1742
+ <button class="action-btn danger" onclick="closeActiveSession()" title="Close session">&times; Close</button>
1531
1743
  </div>
1532
1744
  </div>
1533
1745
  <div class="terminal-container" id="terminalContainer"></div>
@@ -1581,6 +1793,29 @@
1581
1793
  </div>
1582
1794
  </div>
1583
1795
 
1796
+ <!-- New Session modal -->
1797
+ <div class="modal-backdrop" id="newSessionModal" style="display:none" onclick="closeNewSessionModal(event)">
1798
+ <div class="modal-dialog" onclick="event.stopPropagation()">
1799
+ <h3>New Session</h3>
1800
+ <div class="modal-field">
1801
+ <label for="newSessionName">Topic Name</label>
1802
+ <input type="text" id="newSessionName" placeholder="e.g. dashboard-redesign" maxlength="128"
1803
+ onkeydown="if(event.key==='Enter')createNewSession()" autofocus>
1804
+ </div>
1805
+ <div class="modal-field">
1806
+ <label class="modal-toggle">
1807
+ <input type="checkbox" id="newSessionHeadless">
1808
+ <span>Headless (no Telegram topic)</span>
1809
+ </label>
1810
+ <div class="modal-hint">Headless sessions still appear in the dashboard but won't create a Telegram topic.</div>
1811
+ </div>
1812
+ <div class="modal-actions">
1813
+ <button class="modal-btn-cancel" onclick="closeNewSessionModal()">Cancel</button>
1814
+ <button class="modal-btn-create" id="createSessionBtn" onclick="createNewSession()">Create Session</button>
1815
+ </div>
1816
+ </div>
1817
+ </div>
1818
+
1584
1819
  <!-- Drop Zone tab -->
1585
1820
  <div class="dropzone-container" id="dropzoneTab" style="display:none">
1586
1821
  <div class="dropzone-panel">
@@ -1646,6 +1881,9 @@
1646
1881
  let activeSession = null;
1647
1882
  let term = null;
1648
1883
  let fitAddon = null;
1884
+ let historyLinesLoaded = 2000; // matches initial subscribe capture
1885
+ let historyLoading = false;
1886
+ let historyExhausted = false; // true when we've loaded all available history
1649
1887
 
1650
1888
  // ── Auth ─────────────────────────────────────────────────
1651
1889
  function authenticate() {
@@ -1769,6 +2007,26 @@
1769
2007
  }
1770
2008
  break;
1771
2009
 
2010
+ case 'history':
2011
+ if (msg.session === activeSession && term) {
2012
+ const prevLineCount = historyLinesLoaded;
2013
+ historyLinesLoaded = msg.lines;
2014
+ // Check if we got more content than before
2015
+ const newLineCount = (msg.data || '').split('\n').length;
2016
+ const oldLineCount = prevLineCount;
2017
+ if (newLineCount <= oldLineCount + 10) {
2018
+ // No meaningful new content — we've hit the buffer limit
2019
+ historyExhausted = true;
2020
+ }
2021
+ // Rewrite terminal with expanded history
2022
+ term.clear();
2023
+ term.write(msg.data);
2024
+ // Don't auto-scroll — user is reading history
2025
+ historyLoading = false;
2026
+ hideHistorySpinner();
2027
+ }
2028
+ break;
2029
+
1772
2030
  case 'session_ended':
1773
2031
  if (msg.session === activeSession) {
1774
2032
  // Session ended — show in terminal
@@ -1892,7 +2150,10 @@
1892
2150
  }
1893
2151
 
1894
2152
  el.innerHTML = `
1895
- <div class="session-name">${escapeHtml(session.name)}</div>
2153
+ <div class="session-name-row">
2154
+ <div class="session-name">${escapeHtml(session.name)}</div>
2155
+ <button class="session-close-btn" data-tmux="${escapeHtml(session.tmuxSession)}" data-name="${escapeHtml(session.name)}" onclick="event.stopPropagation();closeSession(this.dataset.tmux,this.dataset.name)" title="Close session">&times;</button>
2156
+ </div>
1896
2157
  <div class="session-meta">
1897
2158
  <span class="type-badge ${sType}">${sType}</span>
1898
2159
  <span class="model-badge ${model}">${model}</span>
@@ -2050,6 +2311,96 @@
2050
2311
  wsSend({ type: 'key', session: activeSession, key: text });
2051
2312
  }
2052
2313
 
2314
+ // ── New Session Modal ────────────────────────────────────
2315
+ function openNewSessionModal() {
2316
+ document.getElementById('newSessionModal').style.display = 'flex';
2317
+ const nameInput = document.getElementById('newSessionName');
2318
+ nameInput.value = '';
2319
+ document.getElementById('newSessionHeadless').checked = false;
2320
+ setTimeout(() => nameInput.focus(), 50);
2321
+ }
2322
+
2323
+ function closeNewSessionModal(e) {
2324
+ if (e && e.target !== e.currentTarget) return;
2325
+ document.getElementById('newSessionModal').style.display = 'none';
2326
+ }
2327
+
2328
+ async function createNewSession() {
2329
+ const nameInput = document.getElementById('newSessionName');
2330
+ const headless = document.getElementById('newSessionHeadless').checked;
2331
+ const name = nameInput.value.trim();
2332
+ const btn = document.getElementById('createSessionBtn');
2333
+
2334
+ if (!name) {
2335
+ nameInput.focus();
2336
+ nameInput.style.borderColor = 'var(--red)';
2337
+ setTimeout(() => nameInput.style.borderColor = '', 2000);
2338
+ return;
2339
+ }
2340
+
2341
+ btn.disabled = true;
2342
+ btn.textContent = 'Creating...';
2343
+
2344
+ try {
2345
+ const resp = await fetch('/sessions/create', {
2346
+ method: 'POST',
2347
+ headers: {
2348
+ 'Authorization': `Bearer ${token}`,
2349
+ 'Content-Type': 'application/json',
2350
+ },
2351
+ body: JSON.stringify({ name, headless }),
2352
+ });
2353
+ const data = await resp.json();
2354
+
2355
+ if (!resp.ok) {
2356
+ showToast(data.error || 'Failed to create session', 'error');
2357
+ return;
2358
+ }
2359
+
2360
+ showToast(`Session "${name}" created${data.topicId ? ' with Telegram topic' : ' (headless)'}`, 'success');
2361
+ document.getElementById('newSessionModal').style.display = 'none';
2362
+ } catch (err) {
2363
+ showToast('Network error: ' + (err.message || 'Cannot reach server'), 'error');
2364
+ } finally {
2365
+ btn.disabled = false;
2366
+ btn.textContent = 'Create Session';
2367
+ }
2368
+ }
2369
+
2370
+ // ── Close Session ─────────────────────────────────────────
2371
+ async function closeSession(tmuxSession, sessionName) {
2372
+ if (!confirm(`Close session "${sessionName}"?`)) return;
2373
+
2374
+ try {
2375
+ const resp = await fetch(`/sessions/${encodeURIComponent(tmuxSession)}`, {
2376
+ method: 'DELETE',
2377
+ headers: { 'Authorization': `Bearer ${token}` },
2378
+ });
2379
+ const data = await resp.json();
2380
+
2381
+ if (!resp.ok) {
2382
+ showToast(data.error || 'Failed to close session', 'error');
2383
+ return;
2384
+ }
2385
+
2386
+ showToast(`Session "${sessionName}" closed`, 'success');
2387
+
2388
+ // If this was the active session, clear terminal
2389
+ if (activeSession === tmuxSession) {
2390
+ goBack();
2391
+ }
2392
+ } catch (err) {
2393
+ showToast('Network error: ' + (err.message || 'Cannot reach server'), 'error');
2394
+ }
2395
+ }
2396
+
2397
+ function closeActiveSession() {
2398
+ if (!activeSession) return;
2399
+ const session = sessions.find(s => s.tmuxSession === activeSession);
2400
+ const name = session ? session.name : activeSession;
2401
+ closeSession(activeSession, name);
2402
+ }
2403
+
2053
2404
  // ── Helpers ───────────────────────────────────────────────
2054
2405
  function formatElapsed(isoStr) {
2055
2406
  const ms = Date.now() - new Date(isoStr).getTime();
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwPH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAoiCD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAk3EtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwPH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAgkCD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAy5EtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
@@ -283,7 +283,7 @@ async function spawnSessionForTopic(sessionManager, telegram, sessionName, topic
283
283
  let usedFallback = false;
284
284
  if (topicMemory?.isReady()) {
285
285
  try {
286
- contextContent = topicMemory.formatContextForSession(topicId, 30);
286
+ contextContent = topicMemory.formatContextForSession(topicId, 50);
287
287
  }
288
288
  catch (err) {
289
289
  // @silent-fallback-ok — TopicMemory format, JSONL fallback
@@ -294,12 +294,16 @@ async function spawnSessionForTopic(sessionManager, telegram, sessionName, topic
294
294
  if (!contextContent) {
295
295
  usedFallback = true;
296
296
  try {
297
- const history = telegram.getTopicHistory(topicId, 20);
297
+ const history = telegram.getTopicHistory(topicId, 50);
298
298
  if (history.length > 0) {
299
299
  const lines = [];
300
300
  lines.push(`--- Thread History (last ${history.length} messages) ---`);
301
301
  lines.push(`IMPORTANT: Read this history carefully before taking any action.`);
302
302
  lines.push(`Your task is to continue THIS conversation, not start something new.`);
303
+ const topicName = telegram.getTopicName?.(topicId);
304
+ if (topicName) {
305
+ lines.push(`Topic: ${topicName}`);
306
+ }
303
307
  lines.push(``);
304
308
  for (const m of history) {
305
309
  // Use actual sender name if available (multi-user topics), fall back to generic
@@ -307,7 +311,7 @@ async function spawnSessionForTopic(sessionManager, telegram, sessionName, topic
307
311
  ? (m.senderName || 'User')
308
312
  : 'Agent';
309
313
  const ts = m.timestamp ? new Date(m.timestamp).toISOString().slice(11, 19) : '??:??';
310
- const text = (m.text || '').slice(0, 300);
314
+ const text = (m.text || '').slice(0, 2000);
311
315
  lines.push(`[${ts}] ${sender}: ${text}`);
312
316
  }
313
317
  lines.push(``);
@@ -434,13 +438,30 @@ async function spawnSessionForTopic(sessionManager, telegram, sessionName, topic
434
438
  if (resumeSessionId) {
435
439
  _topicResumeMap?.remove(topicId);
436
440
  }
441
+ // Proactive UUID save — schedule an immediate discovery attempt after spawn.
442
+ // The JSONL file appears ~3-5 seconds after Claude Code starts.
443
+ // This is belt-and-suspenders alongside the 60s heartbeat and beforeSessionKill.
444
+ if (_topicResumeMap && !resumeSessionId) {
445
+ setTimeout(() => {
446
+ try {
447
+ const uuid = _topicResumeMap.findClaudeSessionUuid();
448
+ if (uuid) {
449
+ _topicResumeMap.save(topicId, uuid, newSessionName);
450
+ console.log(`[spawnSessionForTopic] Proactive UUID save: ${uuid} for topic ${topicId}`);
451
+ }
452
+ }
453
+ catch (err) {
454
+ console.error(`[spawnSessionForTopic] Proactive UUID save failed:`, err);
455
+ }
456
+ }, 8000);
457
+ }
437
458
  return newSessionName;
438
459
  }
439
460
  /**
440
461
  * Respawn a session for a topic, including thread history in the bootstrap.
441
462
  * This prevents "thread drift" where respawned sessions lose context.
442
463
  */
443
- async function respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, latestMessage, topicMemory, userProfile) {
464
+ async function respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, latestMessage, topicMemory, userProfile, recoveryPrompt) {
444
465
  console.log(`[telegram→session] Session "${targetSession}" needs respawn for topic ${topicId}`);
445
466
  // Save the old session's Claude UUID before respawning so --resume can reattach context
446
467
  if (_topicResumeMap) {
@@ -459,7 +480,12 @@ async function respawnSessionForTopic(sessionManager, telegram, targetSession, t
459
480
  // Use topic name, not tmux session name — tmux names include the project prefix
460
481
  // which causes cascading names like ai-guy-ai-guy-ai-guy-topic-1 on each respawn.
461
482
  const topicName = storedName || `topic-${topicId}`;
462
- const newSessionName = await spawnSessionForTopic(sessionManager, telegram, topicName, topicId, latestMessage, topicMemory, userProfile);
483
+ // If this is a recovery respawn, prepend the recovery context to the message
484
+ // so the session knows what happened and can avoid repeating the failure.
485
+ const effectiveMessage = recoveryPrompt
486
+ ? `${recoveryPrompt}\n\n${latestMessage || 'Session recovered — continue where you left off.'}`
487
+ : latestMessage;
488
+ const newSessionName = await spawnSessionForTopic(sessionManager, telegram, topicName, topicId, effectiveMessage, topicMemory, userProfile);
463
489
  telegram.registerTopicSession(topicId, newSessionName, topicName);
464
490
  await telegram.sendToTopic(topicId, `Session respawned.`);
465
491
  console.log(`[telegram→session] Respawned "${newSessionName}" for topic ${topicId}`);
@@ -2013,6 +2039,26 @@ export async function startServer(options) {
2013
2039
  resumeHeartbeatInterval.unref();
2014
2040
  console.log(pc.green(' Resume heartbeat: active (60s interval)'));
2015
2041
  }
2042
+ // Save Claude session UUID before any session kill so the topic can be
2043
+ // resumed later with --resume. This fires BEFORE the tmux session is
2044
+ // destroyed, so the UUID can still be discovered from the JSONL mtime.
2045
+ if (_topicResumeMap && telegram) {
2046
+ sessionManager.on('beforeSessionKill', (session) => {
2047
+ try {
2048
+ const topicId = telegram.getTopicForSession(session.tmuxSession);
2049
+ if (!topicId)
2050
+ return;
2051
+ const uuid = _topicResumeMap.findUuidForSession(session.tmuxSession);
2052
+ if (uuid) {
2053
+ _topicResumeMap.save(topicId, uuid, session.tmuxSession);
2054
+ console.log(`[beforeSessionKill] Saved resume UUID ${uuid} for topic ${topicId} (session: ${session.name})`);
2055
+ }
2056
+ }
2057
+ catch (err) {
2058
+ console.error(`[beforeSessionKill] Failed to save resume UUID:`, err);
2059
+ }
2060
+ });
2061
+ }
2016
2062
  if (scheduler) {
2017
2063
  sessionManager.on('sessionComplete', (session) => {
2018
2064
  scheduler.processQueue();
@@ -2450,6 +2496,25 @@ export async function startServer(options) {
2450
2496
  const { SubagentTracker } = await import('../monitoring/SubagentTracker.js');
2451
2497
  const subagentTracker = new SubagentTracker({ stateDir: config.stateDir });
2452
2498
  console.log(pc.green(' Subagent tracker enabled'));
2499
+ // Wire subagent awareness into zombie cleanup — prevents killing sessions
2500
+ // that are idle at the prompt but waiting for subagent results.
2501
+ const MAX_SUBAGENT_WAIT_MS = 60 * 60_000; // 60 minutes — stale subagent safety cap
2502
+ sessionManager.setSubagentChecker((session) => {
2503
+ if (!session.claudeSessionId)
2504
+ return false;
2505
+ const active = subagentTracker.getActiveSubagents(session.claudeSessionId);
2506
+ if (active.length === 0)
2507
+ return false;
2508
+ // Safety cap: if ALL active subagents have been running > 60 minutes,
2509
+ // treat them as stale (likely missed a SubagentStop event) and allow the kill.
2510
+ const now = Date.now();
2511
+ const allStale = active.every(a => now - new Date(a.startedAt).getTime() > MAX_SUBAGENT_WAIT_MS);
2512
+ if (allStale) {
2513
+ console.warn(`[SessionManager] Session "${session.name}" has ${active.length} stale subagent(s) (>60m). Allowing zombie kill.`);
2514
+ return false;
2515
+ }
2516
+ return true;
2517
+ });
2453
2518
  // Worktree Monitor — detects orphaned worktrees after sessions complete
2454
2519
  const { WorktreeMonitor } = await import('../monitoring/WorktreeMonitor.js');
2455
2520
  const worktreeMonitor = new WorktreeMonitor({