tide-commander 1.82.1 → 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 (47) hide show
  1. package/dist/assets/{BossLogsModal-BGN24o2y.js → BossLogsModal-Bgg8MqNZ.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BSB9h0Ko.js → BossSpawnModal-SQNEPxH1.js} +1 -1
  3. package/dist/assets/{ControlsModal-CNHpqsZm.js → ControlsModal-ImEPi76l.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BTh78qNq.js → DockerLogsModal-Cs3jjehG.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Dnd_Znj1.js → EmbeddedEditor-Cg3lq_Cv.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-J8iVq9Mn.js → GmailOAuthSetup-1Gh5-yZt.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-BUhMeJcp.js → GoogleOAuthSetup-DmXPQyOR.js} +1 -1
  8. package/dist/assets/{IframeModal-BMghSZpp.js → IframeModal-BEItm7bf.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-Bjx4j7mz.js → IntegrationsPanel-epI8eaV_.js} +2 -2
  10. package/dist/assets/{LogViewerModal-Bi7agxtr.js → LogViewerModal-B4ryb52t.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CXfskkz6.js → MonitoringModal-CzUjqD5Y.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-BGp4S4yX.js → PM2LogsModal-Ex9G2Mly.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CIfO0VHD.js → RestoreArchivedAreaModal-DIsamY35.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-D3NA3HHw.js → Scene2DCanvas-B5VdT-R2.js} +1 -1
  15. package/dist/assets/{SceneManager-DaCAID2D.js → SceneManager-CgkTa3Uf.js} +1 -1
  16. package/dist/assets/{SkillsPanel-L4wQq1xA.js → SkillsPanel-DXxAlZbz.js} +1 -1
  17. package/dist/assets/{SpawnModal-S5rxcqjR.js → SpawnModal-_1Af5rTQ.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-BfZ-kiW-.js → SubordinateAssignmentModal-CZ1p4KxH.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-C9RFBt0o.js → TriggerManagerPanel-DnOvAQq6.js} +1 -1
  20. package/dist/assets/{WorkflowEditorPanel-Zgcx-wAd.js → WorkflowEditorPanel-Dr896thl.js} +1 -1
  21. package/dist/assets/{index-0lq0DGnZ.js → index-C70mj1bd.js} +1 -1
  22. package/dist/assets/{index-DBsSwev4.js → index-CgPa_6TH.js} +1 -1
  23. package/dist/assets/{index-BVkGtT57.js → index-CkhtIUBY.js} +1 -1
  24. package/dist/assets/{index-OXP5W30H.js → index-CoUtzDXD.js} +1 -1
  25. package/dist/assets/{index-DbPVYX_Y.js → index-DCHHa29_.js} +1 -1
  26. package/dist/assets/{index-Du9hl6DN.js → index-DVqDWfDB.js} +2 -2
  27. package/dist/assets/{index-DnVqOEVx.js → index-DvRrAd9Y.js} +1 -1
  28. package/dist/assets/{index-D5Jg-Dl7.js → index-OYmdvKdM.js} +3 -3
  29. package/dist/assets/{index-KOekme1j.js → index-ave_XgRW.js} +1 -1
  30. package/dist/assets/{main-DnI9VapU.js → main-CHfGSVnw.js} +94 -93
  31. package/dist/assets/main-DmmvYoEs.css +1 -0
  32. package/dist/assets/{web-Bq91XvJE.js → web-D1aYcbd6.js} +1 -1
  33. package/dist/assets/{web-ttfy3Zlk.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.js +15 -12
  37. package/dist/src/packages/server/claude/session-loader.js +240 -0
  38. package/dist/src/packages/server/routes/agents.js +4 -0
  39. package/dist/src/packages/server/routes/index.js +2 -0
  40. package/dist/src/packages/server/routes/sessions.js +93 -0
  41. package/dist/src/packages/server/services/runtime-command-execution.js +13 -0
  42. package/dist/src/packages/server/utils/index.js +1 -0
  43. package/dist/src/packages/server/utils/log-context.js +10 -0
  44. package/dist/src/packages/server/utils/logger.js +6 -1
  45. package/dist/src/packages/server/websocket/handlers/agent-handler.js +17 -5
  46. package/package.json +1 -1
  47. package/dist/assets/main-DrNeyjdE.css +0 -1
@@ -1 +1 @@
1
- import{cg as a}from"./main-DnI9VapU.js";import{ImpactStyle as i,NotificationType as r}from"./index-Du9hl6DN.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-DnI9VapU.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-DnI9VapU.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
  }
@@ -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';
@@ -333,9 +334,12 @@ export class ClaudeRunner {
333
334
  }
334
335
  }
335
336
  async run(request) {
336
- await this.lifecycle.run(request);
337
+ await withAgentContext(request.agentId, () => this.lifecycle.run(request));
337
338
  }
338
339
  sendMessage(agentId, message) {
340
+ return withAgentContext(agentId, () => this.sendMessageImpl(agentId, message));
341
+ }
342
+ sendMessageImpl(agentId, message) {
339
343
  const activeProcess = this.activeProcesses.get(agentId);
340
344
  if (!activeProcess) {
341
345
  log.error(`❌ [SEND_MESSAGE] No active process for agent ${agentId}`);
@@ -571,23 +575,22 @@ export class ClaudeRunner {
571
575
  log.log(`✅ [TMUX] Reconnected to tmux session ${sessionName} for agent ${agentId}`);
572
576
  }
573
577
  interrupt(agentId) {
574
- return this.lifecycle.interrupt(agentId);
578
+ return withAgentContext(agentId, () => this.lifecycle.interrupt(agentId));
575
579
  }
576
580
  getActiveProcessCount() {
577
581
  return this.activeProcesses.size;
578
582
  }
579
583
  async stop(agentId, clearQueue = true) {
580
- // Only clear queued messages on explicit user-initiated stops.
581
- // Respawns (watchdog, auto-restart) should preserve the queue so
582
- // messages sent while the agent was processing are not lost.
583
- if (clearQueue && this.messageQueue.has(agentId)) {
584
- const count = this.messageQueue.get(agentId).length;
585
- this.messageQueue.delete(agentId);
586
- if (count > 0) {
587
- 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
+ }
588
591
  }
589
- }
590
- await this.lifecycle.stop(agentId);
592
+ await this.lifecycle.stop(agentId);
593
+ });
591
594
  }
592
595
  async stopAll(killProcesses = true, clearQueue = true) {
593
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);
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
+ import { getCurrentAgentId } from './log-context.js';
8
9
  const fileLogConfig = {
9
10
  enabled: true,
10
11
  directory: path.join(process.cwd(), 'logs'),
@@ -180,6 +181,10 @@ function formatMessage(level, context, message, ...args) {
180
181
  const timestampStr = `${colors.dim}${timestamp}${colors.reset}`;
181
182
  const levelStr = `${color}${colors.bright}${label.padStart(7)}${colors.reset}`;
182
183
  const contextStr = `${colors.yellow}[${context}]${colors.reset}`;
184
+ const agentId = getCurrentAgentId();
185
+ const agentStr = agentId
186
+ ? ` ${colors.cyan}[agentId=${agentId}]${colors.reset}`
187
+ : '';
183
188
  let formattedMessage = message;
184
189
  if (args.length > 0) {
185
190
  const argsStr = args.map(arg => {
@@ -195,7 +200,7 @@ function formatMessage(level, context, message, ...args) {
195
200
  }).join(' ');
196
201
  formattedMessage = `${message} ${colors.cyan}${argsStr}${colors.reset}`;
197
202
  }
198
- return `${appName} ${pidStr} - ${timestampStr} ${levelStr} ${contextStr} ${formattedMessage}`;
203
+ return `${appName} ${pidStr} - ${timestampStr} ${levelStr} ${contextStr}${agentStr} ${formattedMessage}`;
199
204
  }
200
205
  class Logger {
201
206
  context;
@@ -175,6 +175,11 @@ export async function handleClearContext(ctx, payload) {
175
175
  }
176
176
  /**
177
177
  * Handle restore_session message - restores a previous session for an agent
178
+ *
179
+ * Optional `cwd` enables cross-project restore from the global Session Finder:
180
+ * if the chosen session lives in a different project directory than the agent
181
+ * currently uses, we update the agent's cwd alongside its sessionId so Claude
182
+ * Code can resolve the JSONL on the next run.
178
183
  */
179
184
  export async function handleRestoreSession(ctx, payload) {
180
185
  const agent = agentService.getAgent(payload.agentId);
@@ -182,13 +187,14 @@ export async function handleRestoreSession(ctx, payload) {
182
187
  ctx.sendError(`Agent not found: ${payload.agentId}`);
183
188
  return;
184
189
  }
185
- log.log(`Agent ${agent.name}: Restoring session ${payload.sessionId}`);
190
+ const targetCwd = payload.cwd && payload.cwd !== agent.cwd ? payload.cwd : undefined;
191
+ log.log(`Agent ${agent.name}: Restoring session ${payload.sessionId}` +
192
+ (targetCwd ? ` (cwd change: ${agent.cwd} -> ${targetCwd})` : ''));
186
193
  // Archive the current session first (if any)
187
194
  agentService.archiveCurrentSession(payload.agentId);
188
195
  // Stop any running process
189
196
  await runtimeService.stopAgent(payload.agentId);
190
- // Set the restored session ID
191
- agentService.updateAgent(payload.agentId, {
197
+ const updates = {
192
198
  status: 'idle',
193
199
  currentTask: undefined,
194
200
  taskLabel: undefined,
@@ -197,8 +203,14 @@ export async function handleRestoreSession(ctx, payload) {
197
203
  tokensUsed: 0,
198
204
  contextUsed: 0,
199
205
  contextStats: undefined,
200
- });
201
- ctx.sendActivity(payload.agentId, `Session restored - will resume on next command`);
206
+ };
207
+ if (targetCwd) {
208
+ updates.cwd = targetCwd;
209
+ }
210
+ agentService.updateAgent(payload.agentId, updates);
211
+ ctx.sendActivity(payload.agentId, targetCwd
212
+ ? `Session restored from ${targetCwd} - will resume on next command`
213
+ : `Session restored - will resume on next command`);
202
214
  log.log(`Agent ${agent.name}: Session restored to ${payload.sessionId}`);
203
215
  }
204
216
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.82.1",
3
+ "version": "1.83.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",