tide-commander 1.82.0 → 1.83.0

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 (50) hide show
  1. package/dist/assets/{BossLogsModal-c_-m-65A.js → BossLogsModal-Bgg8MqNZ.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-xoclsIKS.js → BossSpawnModal-SQNEPxH1.js} +1 -1
  3. package/dist/assets/{ControlsModal-DvbJLRQI.js → ControlsModal-ImEPi76l.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BEwlDjvB.js → DockerLogsModal-Cs3jjehG.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Ce0A9MPZ.js → EmbeddedEditor-Cg3lq_Cv.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-CxPmSlXq.js → GmailOAuthSetup-1Gh5-yZt.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-C5XDtkWo.js → GoogleOAuthSetup-DmXPQyOR.js} +1 -1
  8. package/dist/assets/{IframeModal-B30rL5T9.js → IframeModal-BEItm7bf.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-DOiXFhKJ.js → IntegrationsPanel-epI8eaV_.js} +2 -2
  10. package/dist/assets/{LogViewerModal-BDDX94V7.js → LogViewerModal-B4ryb52t.js} +1 -1
  11. package/dist/assets/{MonitoringModal-Bb-ofaiK.js → MonitoringModal-CzUjqD5Y.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-BbwXHEqR.js → PM2LogsModal-Ex9G2Mly.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-DDndv4rZ.js → RestoreArchivedAreaModal-DIsamY35.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-D0mV81Bm.js → Scene2DCanvas-B5VdT-R2.js} +1 -1
  15. package/dist/assets/{SceneManager-BT7vcVki.js → SceneManager-CgkTa3Uf.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Db_tfT8F.js → SkillsPanel-DXxAlZbz.js} +1 -1
  17. package/dist/assets/{SpawnModal-BgBB9Nre.js → SpawnModal-_1Af5rTQ.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-CsNUXzAa.js → SubordinateAssignmentModal-CZ1p4KxH.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-DLjvwFj_.js → TriggerManagerPanel-DnOvAQq6.js} +1 -1
  20. package/dist/assets/{WorkflowEditorPanel-CtWq2X20.js → WorkflowEditorPanel-Dr896thl.js} +1 -1
  21. package/dist/assets/{index-CeGEneIm.js → index-C70mj1bd.js} +1 -1
  22. package/dist/assets/{index-dz45uNT2.js → index-CgPa_6TH.js} +1 -1
  23. package/dist/assets/{index-BKRYCHvF.js → index-CkhtIUBY.js} +1 -1
  24. package/dist/assets/{index-DjcUPqW1.js → index-CoUtzDXD.js} +1 -1
  25. package/dist/assets/{index-BWFXqJh3.js → index-DCHHa29_.js} +1 -1
  26. package/dist/assets/{index-Bx7MluX4.js → index-DVqDWfDB.js} +2 -2
  27. package/dist/assets/{index-Blb2sMy-.js → index-DvRrAd9Y.js} +1 -1
  28. package/dist/assets/{index-kLrB6kGv.js → index-OYmdvKdM.js} +3 -3
  29. package/dist/assets/{index-B-kiGa62.js → index-ave_XgRW.js} +1 -1
  30. package/dist/assets/{main-C0wLbrjb.js → main-CHfGSVnw.js} +94 -93
  31. package/dist/assets/main-DmmvYoEs.css +1 -0
  32. package/dist/assets/{web-CQeInubf.js → web-D1aYcbd6.js} +1 -1
  33. package/dist/assets/{web-Dpd6fLBM.js → web-rv0WAVTk.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/dist/src/packages/server/claude/runner/internal-events.js +6 -3
  36. package/dist/src/packages/server/claude/runner/tmux-helper.js +10 -0
  37. package/dist/src/packages/server/claude/runner/watchdog.js +14 -0
  38. package/dist/src/packages/server/claude/runner.js +35 -13
  39. package/dist/src/packages/server/claude/session-loader.js +240 -0
  40. package/dist/src/packages/server/routes/agents.js +4 -0
  41. package/dist/src/packages/server/routes/index.js +2 -0
  42. package/dist/src/packages/server/routes/sessions.js +93 -0
  43. package/dist/src/packages/server/services/runtime-command-execution.js +13 -0
  44. package/dist/src/packages/server/services/runtime-service.js +7 -0
  45. package/dist/src/packages/server/utils/index.js +1 -0
  46. package/dist/src/packages/server/utils/log-context.js +10 -0
  47. package/dist/src/packages/server/utils/logger.js +6 -1
  48. package/dist/src/packages/server/websocket/handlers/agent-handler.js +17 -5
  49. package/package.json +1 -1
  50. package/dist/assets/main-DrNeyjdE.css +0 -1
@@ -1 +1 @@
1
- import{cg as a}from"./main-C0wLbrjb.js";import{ImpactStyle as i,NotificationType as r}from"./index-Bx7MluX4.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{cg as a}from"./main-CHfGSVnw.js";import{ImpactStyle as i,NotificationType as r}from"./index-DVqDWfDB.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
@@ -1 +1 @@
1
- import{cg as s}from"./main-C0wLbrjb.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{cg as s}from"./main-CHfGSVnw.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
package/dist/index.html CHANGED
@@ -22,10 +22,10 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-C0wLbrjb.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-CHfGSVnw.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
- <link rel="stylesheet" crossorigin href="/assets/main-DrNeyjdE.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/main-DmmvYoEs.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="app"></div>
@@ -1,3 +1,4 @@
1
+ import { withAgentContext } from '../../utils/log-context.js';
1
2
  export class RunnerInternalEventBus {
2
3
  handlers = new Map();
3
4
  on(type, handler) {
@@ -15,8 +16,10 @@ export class RunnerInternalEventBus {
15
16
  if (!handlers || handlers.size === 0) {
16
17
  return;
17
18
  }
18
- for (const handler of handlers) {
19
- handler(event);
20
- }
19
+ withAgentContext(event.agentId, () => {
20
+ for (const handler of handlers) {
21
+ handler(event);
22
+ }
23
+ });
21
24
  }
22
25
  }
@@ -294,6 +294,16 @@ export function createFileTailer(logFile, onLine) {
294
294
  // Poll every 100ms for new data
295
295
  pollInterval = setInterval(readNewData, 100);
296
296
  },
297
+ drain() {
298
+ // Final synchronous read. Closes the polling-vs-watchdog race: when the
299
+ // tmux session ends, the CLI's last bytes (step_complete, final result)
300
+ // may still be sitting in the log file unread because the 100ms poll
301
+ // hasn't fired yet. Calling drain() before stop() guarantees those
302
+ // events flow through onLine → bus.emit → turnState updates so the
303
+ // caller (the watchdog) sees the post-step_complete state instead of a
304
+ // stale 'processing' state.
305
+ readNewData();
306
+ },
297
307
  stop() {
298
308
  watching = false;
299
309
  if (pollInterval) {
@@ -47,6 +47,20 @@ export class RunnerWatchdog {
47
47
  else {
48
48
  log.error(`🐕 [WATCHDOG] Agent ${agentId}: tmux session ${activeProcess.tmuxSession} is alive but CLI '${expected}' is gone (zombie session)`);
49
49
  }
50
+ // Drain any unread bytes from the log file BEFORE stopping the
51
+ // tailer. The CLI's final step_complete event is often still
52
+ // sitting in the file when the 5s watchdog beats the 100ms tailer
53
+ // poll; without this drain those bytes are lost, turnState never
54
+ // transitions to 'waiting_for_input', and the
55
+ // watchdog_missing_process handler in runner.ts marks a clean
56
+ // turn-end exit as a mid-turn death (status=error, spurious
57
+ // auto-restart loop).
58
+ try {
59
+ activeProcess.tmuxTailer?.drain();
60
+ }
61
+ catch (err) {
62
+ log.error(`🐕 [WATCHDOG] Agent ${agentId}: tailer drain failed: ${String(err)}`);
63
+ }
50
64
  activeProcess.tmuxTailer?.stop();
51
65
  this.recordDeath({
52
66
  agentId,
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { ClaudeBackend } from './backend.js';
6
6
  import { createLogger } from '../utils/logger.js';
7
+ import { withAgentContext } from '../utils/log-context.js';
7
8
  import { isProcessRunning } from '../data/index.js';
8
9
  import { sendToTmux, hasTmuxSession, tmuxSessionName, getTmuxPanePid } from './runner/tmux-helper.js';
9
10
  import { spawn } from 'child_process';
@@ -28,6 +29,7 @@ export class ClaudeRunner {
28
29
  autoRestartEnabled = true;
29
30
  persistTimer = null;
30
31
  watchdogTimer = null;
32
+ started = false;
31
33
  bus;
32
34
  stdoutPipeline;
33
35
  lifecycle;
@@ -80,6 +82,24 @@ export class ClaudeRunner {
80
82
  activeProcesses: this.activeProcesses,
81
83
  });
82
84
  this.wireInternalEvents();
85
+ }
86
+ /**
87
+ * Begin background work: orphan recovery from a prior commander instance,
88
+ * the periodic running-processes persist, and the watchdog.
89
+ *
90
+ * Call this exactly once from the canonical production entry point
91
+ * (`runtime-service.init()`). Tests, REPLs, scripts, sidecar containers
92
+ * and any other context that imports the runner code MUST NOT call this:
93
+ * those contexts share `~/.local/share/tide-commander/`, and running the
94
+ * orphan-recovery cleanup from a non-server process kills `tc-*` tmux
95
+ * sessions belonging to the live server.
96
+ *
97
+ * Idempotent — repeat calls are no-ops.
98
+ */
99
+ start() {
100
+ if (this.started)
101
+ return;
102
+ this.started = true;
83
103
  this.persistTimer = setInterval(() => {
84
104
  this.recoveryStore.persistRunningProcesses();
85
105
  }, PERSIST_INTERVAL);
@@ -87,7 +107,7 @@ export class ClaudeRunner {
87
107
  this.watchdog.runWatchdog();
88
108
  }, WATCHDOG_INTERVAL);
89
109
  this.recoveryStore.recoverOrphanedProcesses();
90
- log.log('🛡️ Runner initialized with auto-restart and watchdog enabled');
110
+ log.log('🛡️ Runner started with auto-restart and watchdog enabled');
91
111
  }
92
112
  wireInternalEvents() {
93
113
  this.bus.on('runner.activity', ({ agentId, timestamp }) => {
@@ -314,9 +334,12 @@ export class ClaudeRunner {
314
334
  }
315
335
  }
316
336
  async run(request) {
317
- await this.lifecycle.run(request);
337
+ await withAgentContext(request.agentId, () => this.lifecycle.run(request));
318
338
  }
319
339
  sendMessage(agentId, message) {
340
+ return withAgentContext(agentId, () => this.sendMessageImpl(agentId, message));
341
+ }
342
+ sendMessageImpl(agentId, message) {
320
343
  const activeProcess = this.activeProcesses.get(agentId);
321
344
  if (!activeProcess) {
322
345
  log.error(`❌ [SEND_MESSAGE] No active process for agent ${agentId}`);
@@ -552,23 +575,22 @@ export class ClaudeRunner {
552
575
  log.log(`✅ [TMUX] Reconnected to tmux session ${sessionName} for agent ${agentId}`);
553
576
  }
554
577
  interrupt(agentId) {
555
- return this.lifecycle.interrupt(agentId);
578
+ return withAgentContext(agentId, () => this.lifecycle.interrupt(agentId));
556
579
  }
557
580
  getActiveProcessCount() {
558
581
  return this.activeProcesses.size;
559
582
  }
560
583
  async stop(agentId, clearQueue = true) {
561
- // Only clear queued messages on explicit user-initiated stops.
562
- // Respawns (watchdog, auto-restart) should preserve the queue so
563
- // messages sent while the agent was processing are not lost.
564
- if (clearQueue && this.messageQueue.has(agentId)) {
565
- const count = this.messageQueue.get(agentId).length;
566
- this.messageQueue.delete(agentId);
567
- if (count > 0) {
568
- log.log(`🗑️ [QUEUE] Agent ${agentId}: Discarded ${count} queued message(s) on stop`);
584
+ await withAgentContext(agentId, async () => {
585
+ if (clearQueue && this.messageQueue.has(agentId)) {
586
+ const count = this.messageQueue.get(agentId).length;
587
+ this.messageQueue.delete(agentId);
588
+ if (count > 0) {
589
+ log.log(`🗑️ [QUEUE] Agent ${agentId}: Discarded ${count} queued message(s) on stop`);
590
+ }
569
591
  }
570
- }
571
- await this.lifecycle.stop(agentId);
592
+ await this.lifecycle.stop(agentId);
593
+ });
572
594
  }
573
595
  async stopAll(killProcesses = true, clearQueue = true) {
574
596
  if (this.persistTimer) {
@@ -417,6 +417,246 @@ export async function listSessions(cwd) {
417
417
  sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
418
418
  return sessions;
419
419
  }
420
+ const FIRST_PROMPT_MAX_LEN = 240;
421
+ const SEARCH_SNIPPET_MAX_LEN = 280;
422
+ function truncate(text, max) {
423
+ if (text.length <= max)
424
+ return text;
425
+ return text.slice(0, max - 1) + '…';
426
+ }
427
+ function extractClaudeMessageText(message) {
428
+ if (!message || typeof message !== 'object')
429
+ return '';
430
+ const m = message;
431
+ const content = m.content;
432
+ if (typeof content === 'string')
433
+ return content;
434
+ if (Array.isArray(content)) {
435
+ const parts = [];
436
+ for (const block of content) {
437
+ if (block && typeof block === 'object' && 'type' in block && block.type === 'text') {
438
+ const text = block.text;
439
+ if (typeof text === 'string')
440
+ parts.push(text);
441
+ }
442
+ }
443
+ return parts.join('\n');
444
+ }
445
+ return '';
446
+ }
447
+ /**
448
+ * Read the first up-to-`maxLines` JSONL records of a session file. Used to
449
+ * cheaply recover `cwd` and the first user prompt without parsing the whole
450
+ * conversation. Falls back to a single readFileSync slice for very small files.
451
+ */
452
+ async function readSessionHeader(filePath, maxLines = 20) {
453
+ let cwd = '';
454
+ let firstPrompt = '';
455
+ try {
456
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
457
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
458
+ let lineCount = 0;
459
+ for await (const line of rl) {
460
+ if (!line.trim())
461
+ continue;
462
+ lineCount += 1;
463
+ try {
464
+ const obj = JSON.parse(line);
465
+ if (!cwd && typeof obj.cwd === 'string')
466
+ cwd = obj.cwd;
467
+ // queue-operation enqueue rows carry the user's prompt as `content`
468
+ if (!firstPrompt && obj.type === 'queue-operation' && obj.operation === 'enqueue') {
469
+ const content = obj.content;
470
+ if (typeof content === 'string')
471
+ firstPrompt = content;
472
+ }
473
+ if (!firstPrompt && obj.type === 'user') {
474
+ const text = extractClaudeMessageText(obj.message);
475
+ if (text)
476
+ firstPrompt = text;
477
+ }
478
+ if (cwd && firstPrompt)
479
+ break;
480
+ }
481
+ catch {
482
+ // ignore malformed lines
483
+ }
484
+ if (lineCount >= maxLines)
485
+ break;
486
+ }
487
+ rl.close();
488
+ stream.destroy();
489
+ }
490
+ catch {
491
+ // unreadable file — return empty defaults
492
+ }
493
+ return { cwd, firstPrompt: truncate(firstPrompt, FIRST_PROMPT_MAX_LEN) };
494
+ }
495
+ /**
496
+ * List every Claude session across every project directory.
497
+ *
498
+ * Cheap: only stats each .jsonl and reads the first few lines for cwd/firstPrompt.
499
+ * Skips message-count computation by default to keep the operation fast even
500
+ * across hundreds of sessions.
501
+ */
502
+ export async function listAllSessions(options) {
503
+ if (!fs.existsSync(PROJECTS_DIR))
504
+ return [];
505
+ const projectDirs = fs.readdirSync(PROJECTS_DIR);
506
+ const all = [];
507
+ for (const projectDir of projectDirs) {
508
+ const fullProjectPath = path.join(PROJECTS_DIR, projectDir);
509
+ let entries;
510
+ try {
511
+ entries = fs.readdirSync(fullProjectPath, { withFileTypes: true });
512
+ }
513
+ catch {
514
+ continue;
515
+ }
516
+ for (const entry of entries) {
517
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
518
+ continue;
519
+ const sessionId = entry.name.replace(/\.jsonl$/, '');
520
+ const filePath = path.join(fullProjectPath, entry.name);
521
+ let stats;
522
+ try {
523
+ stats = fs.statSync(filePath);
524
+ }
525
+ catch {
526
+ continue;
527
+ }
528
+ all.push({
529
+ sessionId,
530
+ projectPath: '',
531
+ projectDir,
532
+ lastModified: stats.mtime,
533
+ messageCount: 0,
534
+ firstPrompt: '',
535
+ sizeBytes: stats.size,
536
+ });
537
+ }
538
+ }
539
+ all.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
540
+ const limit = options?.limit ?? all.length;
541
+ const limited = all.slice(0, limit);
542
+ // Enrich with cwd + firstPrompt (and optionally messageCount). Run concurrently
543
+ // but cap concurrency so we don't open hundreds of file handles at once.
544
+ const concurrency = 8;
545
+ let cursor = 0;
546
+ const workers = [];
547
+ for (let i = 0; i < concurrency; i++) {
548
+ workers.push((async () => {
549
+ while (cursor < limited.length) {
550
+ const idx = cursor++;
551
+ const item = limited[idx];
552
+ const filePath = path.join(PROJECTS_DIR, item.projectDir, `${item.sessionId}.jsonl`);
553
+ const header = await readSessionHeader(filePath);
554
+ item.projectPath = header.cwd;
555
+ item.firstPrompt = header.firstPrompt;
556
+ if (options?.includeMessageCount) {
557
+ try {
558
+ const content = fs.readFileSync(filePath, 'utf-8');
559
+ const lines = content.split('\n');
560
+ let count = 0;
561
+ for (const line of lines) {
562
+ if (!line.trim())
563
+ continue;
564
+ try {
565
+ const obj = JSON.parse(line);
566
+ if (obj.type === 'user' || obj.type === 'assistant')
567
+ count++;
568
+ }
569
+ catch { /* ignore */ }
570
+ }
571
+ item.messageCount = count;
572
+ }
573
+ catch { /* ignore */ }
574
+ }
575
+ }
576
+ })());
577
+ }
578
+ await Promise.all(workers);
579
+ return limited;
580
+ }
581
+ /**
582
+ * Full-text search every Claude session for `query`. Streams files line-by-line
583
+ * so memory usage stays bounded even with many large sessions.
584
+ */
585
+ export async function searchAllSessions(query, options) {
586
+ const trimmed = query.trim();
587
+ if (!trimmed)
588
+ return [];
589
+ const queryLower = trimmed.toLowerCase();
590
+ const limit = options?.limit ?? 100;
591
+ const cwdFilter = options?.cwdFilter?.toLowerCase();
592
+ const sessions = await listAllSessions({ limit: 1000 });
593
+ const out = [];
594
+ const concurrency = 6;
595
+ let cursor = 0;
596
+ const workers = [];
597
+ for (let i = 0; i < concurrency; i++) {
598
+ workers.push((async () => {
599
+ while (cursor < sessions.length) {
600
+ const idx = cursor++;
601
+ const session = sessions[idx];
602
+ if (cwdFilter && !session.projectPath.toLowerCase().includes(cwdFilter))
603
+ continue;
604
+ const filePath = path.join(PROJECTS_DIR, session.projectDir, `${session.sessionId}.jsonl`);
605
+ let totalMatches = 0;
606
+ let snippet = '';
607
+ try {
608
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
609
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
610
+ for await (const line of rl) {
611
+ if (!line)
612
+ continue;
613
+ // Skip the cheap path: just lowercase the raw line and substring search.
614
+ // This avoids parsing every JSON record but matches inside any field
615
+ // (message text, tool args, etc.) — fine for a UX search.
616
+ if (!line.toLowerCase().includes(queryLower))
617
+ continue;
618
+ totalMatches++;
619
+ if (!snippet) {
620
+ // try to extract a clean text snippet from message content
621
+ try {
622
+ const obj = JSON.parse(line);
623
+ if (obj.type === 'user' || obj.type === 'assistant') {
624
+ const text = extractClaudeMessageText(obj.message);
625
+ if (text)
626
+ snippet = text;
627
+ }
628
+ else if (obj.type === 'queue-operation' && typeof obj.content === 'string') {
629
+ snippet = obj.content;
630
+ }
631
+ }
632
+ catch { /* fall through to raw-line snippet */ }
633
+ if (!snippet)
634
+ snippet = line;
635
+ snippet = truncate(snippet.replace(/\s+/g, ' ').trim(), SEARCH_SNIPPET_MAX_LEN);
636
+ }
637
+ }
638
+ rl.close();
639
+ stream.destroy();
640
+ }
641
+ catch { /* unreadable — skip */ }
642
+ if (totalMatches > 0) {
643
+ out.push({
644
+ sessionId: session.sessionId,
645
+ projectPath: session.projectPath,
646
+ projectDir: session.projectDir,
647
+ lastModified: session.lastModified,
648
+ totalMatches,
649
+ snippet,
650
+ firstPrompt: session.firstPrompt,
651
+ });
652
+ }
653
+ }
654
+ })());
655
+ }
656
+ await Promise.all(workers);
657
+ out.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
658
+ return out.slice(0, limit);
659
+ }
420
660
  /**
421
661
  * Wait for file to be stable (not actively being written)
422
662
  * Checks if file size remains constant over a small interval
@@ -13,6 +13,7 @@ import { loadSession } from '../claude/session-loader.js';
13
13
  import { getAllCustomClasses } from '../services/custom-class-service.js';
14
14
  // Session listing is done inline for performance
15
15
  import { createLogger } from '../utils/logger.js';
16
+ import { withAgentContext } from '../utils/log-context.js';
16
17
  import { truncateOrEmpty } from '../utils/string.js';
17
18
  import { buildCustomAgentConfig } from '../websocket/handlers/command-handler.js';
18
19
  import { clearDelegation, getBossForSubordinate } from '../websocket/handlers/boss-response-handler.js';
@@ -21,6 +22,9 @@ import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnable
21
22
  import { getBackupStatus, setBackupEnabled } from '../services/backup-service.js';
22
23
  const log = createLogger('Routes');
23
24
  const router = Router();
25
+ router.param('id', (req, _res, next, id) => {
26
+ withAgentContext(typeof id === 'string' ? id : undefined, () => next());
27
+ });
24
28
  // Store for broadcasting via WebSocket
25
29
  let broadcastFn = null;
26
30
  /**
@@ -22,6 +22,7 @@ import triggerRouter, { setBroadcast as setTriggerBroadcast } from './trigger-ro
22
22
  import integrationRouter from './integration-routes.js';
23
23
  import eventRouter from './event-routes.js';
24
24
  import workflowRouter from './workflow-routes.js';
25
+ import sessionsRouter from './sessions.js';
25
26
  import { getPlugins } from '../integrations/integration-registry.js';
26
27
  const router = Router();
27
28
  // Health check
@@ -46,6 +47,7 @@ router.use('/triggers', triggerRouter);
46
47
  router.use('/integrations', integrationRouter);
47
48
  router.use('/events', eventRouter);
48
49
  router.use('/workflows', workflowRouter);
50
+ router.use('/sessions', sessionsRouter);
49
51
  // Integration plugin routes (e.g. /api/slack/*, /api/documents/*, /api/jira/*)
50
52
  // Uses lazy lookup so plugins can be registered after route setup
51
53
  router.use((req, res, next) => {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Sessions Routes
3
+ *
4
+ * Cross-project (cwd-agnostic) session discovery and preview. Pairs with the
5
+ * global Session Finder modal in the client. Restoring a session onto an agent
6
+ * is still done over the WebSocket `restore_session` message, which now accepts
7
+ * an optional `cwd` to support cross-project attach.
8
+ */
9
+ import { Router } from 'express';
10
+ import { listAllSessions, searchAllSessions, loadSession } from '../claude/session-loader.js';
11
+ import { createLogger } from '../utils/logger.js';
12
+ const log = createLogger('Routes');
13
+ const router = Router();
14
+ // GET /api/sessions/global?limit=200&includeMessageCount=false
15
+ router.get('/global', async (req, res) => {
16
+ try {
17
+ const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 200, 1000));
18
+ const includeMessageCount = req.query.includeMessageCount === 'true';
19
+ const sessions = await listAllSessions({ limit, includeMessageCount });
20
+ res.json({
21
+ sessions: sessions.map((s) => ({
22
+ sessionId: s.sessionId,
23
+ projectPath: s.projectPath,
24
+ projectDir: s.projectDir,
25
+ lastModified: s.lastModified.toISOString(),
26
+ messageCount: s.messageCount,
27
+ firstPrompt: s.firstPrompt,
28
+ sizeBytes: s.sizeBytes,
29
+ })),
30
+ });
31
+ }
32
+ catch (err) {
33
+ log.error(' Failed to list global sessions:', err);
34
+ res.status(500).json({ error: err.message });
35
+ }
36
+ });
37
+ // GET /api/sessions/search?q=...&limit=100&cwdFilter=...
38
+ router.get('/search', async (req, res) => {
39
+ try {
40
+ const q = req.query.q || '';
41
+ if (!q.trim()) {
42
+ res.status(400).json({ error: 'Query parameter "q" is required' });
43
+ return;
44
+ }
45
+ const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 100, 500));
46
+ const cwdFilter = req.query.cwdFilter || undefined;
47
+ const matches = await searchAllSessions(q, { limit, cwdFilter });
48
+ res.json({
49
+ query: q,
50
+ matches: matches.map((m) => ({
51
+ sessionId: m.sessionId,
52
+ projectPath: m.projectPath,
53
+ projectDir: m.projectDir,
54
+ lastModified: m.lastModified.toISOString(),
55
+ totalMatches: m.totalMatches,
56
+ snippet: m.snippet,
57
+ firstPrompt: m.firstPrompt,
58
+ })),
59
+ });
60
+ }
61
+ catch (err) {
62
+ log.error(' Failed to search global sessions:', err);
63
+ res.status(500).json({ error: err.message });
64
+ }
65
+ });
66
+ // GET /api/sessions/preview?cwd=...&sessionId=...&limit=30
67
+ router.get('/preview', async (req, res) => {
68
+ try {
69
+ const cwd = req.query.cwd || '';
70
+ const sessionId = req.query.sessionId || '';
71
+ if (!cwd || !sessionId) {
72
+ res.status(400).json({ error: 'Both "cwd" and "sessionId" query parameters are required' });
73
+ return;
74
+ }
75
+ const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 30, 200));
76
+ const history = await loadSession(cwd, sessionId, limit, 0);
77
+ if (!history) {
78
+ res.status(404).json({ error: 'Session not found' });
79
+ return;
80
+ }
81
+ res.json({
82
+ sessionId: history.sessionId,
83
+ cwd: history.cwd,
84
+ messages: history.messages,
85
+ totalCount: history.totalCount,
86
+ });
87
+ }
88
+ catch (err) {
89
+ log.error(' Failed to load session preview:', err);
90
+ res.status(500).json({ error: err.message });
91
+ }
92
+ });
93
+ export default router;
@@ -1,8 +1,12 @@
1
1
  import * as agentService from './agent-service.js';
2
+ import { withAgentContext } from '../utils/log-context.js';
2
3
  import { clearPendingSilentContextRefresh, clearStdinWatchdog, hasPendingSilentContextRefresh, markPendingSilentContextRefresh, startStdinWatchdog, } from './runtime-watchdog.js';
3
4
  export function createRuntimeCommandExecution(deps) {
4
5
  const { log, getRunner, getRunnerForAgent, notifyCommandStarted, emitOutput, killDetachedProviderProcessInCwd, } = deps;
5
6
  async function executeCommand(agentId, command, systemPrompt, forceNewSession, customAgent, silent, skipNotify) {
7
+ return withAgentContext(agentId, () => executeCommandImpl(agentId, command, systemPrompt, forceNewSession, customAgent, silent, skipNotify));
8
+ }
9
+ async function executeCommandImpl(agentId, command, systemPrompt, forceNewSession, customAgent, silent, skipNotify) {
6
10
  const agent = agentService.getAgent(agentId);
7
11
  if (!agent) {
8
12
  throw new Error(`Agent not found: ${agentId}`);
@@ -59,6 +63,9 @@ export function createRuntimeCommandExecution(deps) {
59
63
  });
60
64
  }
61
65
  async function sendCommand(agentId, command, systemPrompt, forceNewSession, customAgent) {
66
+ return withAgentContext(agentId, () => sendCommandImpl(agentId, command, systemPrompt, forceNewSession, customAgent));
67
+ }
68
+ async function sendCommandImpl(agentId, command, systemPrompt, forceNewSession, customAgent) {
62
69
  const agent = agentService.getAgent(agentId);
63
70
  if (!agent) {
64
71
  throw new Error(`Agent not found: ${agentId}`);
@@ -181,6 +188,9 @@ export function createRuntimeCommandExecution(deps) {
181
188
  await executeCommand(agentId, command, systemPrompt, forceNewSession, customAgent);
182
189
  }
183
190
  async function sendSilentCommand(agentId, command) {
191
+ return withAgentContext(agentId, () => sendSilentCommandImpl(agentId, command));
192
+ }
193
+ async function sendSilentCommandImpl(agentId, command) {
184
194
  const agent = agentService.getAgent(agentId);
185
195
  if (!agent) {
186
196
  throw new Error(`Agent not found: ${agentId}`);
@@ -210,6 +220,9 @@ export function createRuntimeCommandExecution(deps) {
210
220
  await executeCommand(agentId, command, undefined, undefined, undefined, true);
211
221
  }
212
222
  async function stopAgent(agentId) {
223
+ return withAgentContext(agentId, () => stopAgentImpl(agentId));
224
+ }
225
+ async function stopAgentImpl(agentId) {
213
226
  // Cancel any pending stdin watchdog timer to prevent it from respawning
214
227
  // the process after we've stopped it
215
228
  clearStdinWatchdog(agentId);
@@ -141,6 +141,13 @@ export function init() {
141
141
  onComplete: runtimeEvents.handleComplete,
142
142
  onError: runtimeEvents.handleError,
143
143
  }));
144
+ // Start each runner's background work (orphan recovery, persist timer,
145
+ // watchdog). The constructor is now side-effect-free so non-canonical
146
+ // contexts (tests, scripts, sidecars) that import the runner code don't
147
+ // race the live server on the shared data dir / tmux sessions.
148
+ for (const runner of runners.values()) {
149
+ runner.start?.();
150
+ }
144
151
  if (statusPollTimer) {
145
152
  clearInterval(statusPollTimer);
146
153
  }
@@ -1,4 +1,5 @@
1
1
  export * from './logger.js';
2
+ export * from './log-context.js';
2
3
  export * from './unicode.js';
3
4
  export * from './string.js';
4
5
  export * from './tool-formatting.js';
@@ -0,0 +1,10 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ const als = new AsyncLocalStorage();
3
+ export function withAgentContext(agentId, fn) {
4
+ if (!agentId)
5
+ return fn();
6
+ return als.run({ agentId }, fn);
7
+ }
8
+ export function getCurrentAgentId() {
9
+ return als.getStore()?.agentId;
10
+ }