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.
- package/dist/assets/{BossLogsModal-c_-m-65A.js → BossLogsModal-Bgg8MqNZ.js} +1 -1
- package/dist/assets/{BossSpawnModal-xoclsIKS.js → BossSpawnModal-SQNEPxH1.js} +1 -1
- package/dist/assets/{ControlsModal-DvbJLRQI.js → ControlsModal-ImEPi76l.js} +1 -1
- package/dist/assets/{DockerLogsModal-BEwlDjvB.js → DockerLogsModal-Cs3jjehG.js} +1 -1
- package/dist/assets/{EmbeddedEditor-Ce0A9MPZ.js → EmbeddedEditor-Cg3lq_Cv.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-CxPmSlXq.js → GmailOAuthSetup-1Gh5-yZt.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-C5XDtkWo.js → GoogleOAuthSetup-DmXPQyOR.js} +1 -1
- package/dist/assets/{IframeModal-B30rL5T9.js → IframeModal-BEItm7bf.js} +1 -1
- package/dist/assets/{IntegrationsPanel-DOiXFhKJ.js → IntegrationsPanel-epI8eaV_.js} +2 -2
- package/dist/assets/{LogViewerModal-BDDX94V7.js → LogViewerModal-B4ryb52t.js} +1 -1
- package/dist/assets/{MonitoringModal-Bb-ofaiK.js → MonitoringModal-CzUjqD5Y.js} +1 -1
- package/dist/assets/{PM2LogsModal-BbwXHEqR.js → PM2LogsModal-Ex9G2Mly.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-DDndv4rZ.js → RestoreArchivedAreaModal-DIsamY35.js} +1 -1
- package/dist/assets/{Scene2DCanvas-D0mV81Bm.js → Scene2DCanvas-B5VdT-R2.js} +1 -1
- package/dist/assets/{SceneManager-BT7vcVki.js → SceneManager-CgkTa3Uf.js} +1 -1
- package/dist/assets/{SkillsPanel-Db_tfT8F.js → SkillsPanel-DXxAlZbz.js} +1 -1
- package/dist/assets/{SpawnModal-BgBB9Nre.js → SpawnModal-_1Af5rTQ.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CsNUXzAa.js → SubordinateAssignmentModal-CZ1p4KxH.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-DLjvwFj_.js → TriggerManagerPanel-DnOvAQq6.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-CtWq2X20.js → WorkflowEditorPanel-Dr896thl.js} +1 -1
- package/dist/assets/{index-CeGEneIm.js → index-C70mj1bd.js} +1 -1
- package/dist/assets/{index-dz45uNT2.js → index-CgPa_6TH.js} +1 -1
- package/dist/assets/{index-BKRYCHvF.js → index-CkhtIUBY.js} +1 -1
- package/dist/assets/{index-DjcUPqW1.js → index-CoUtzDXD.js} +1 -1
- package/dist/assets/{index-BWFXqJh3.js → index-DCHHa29_.js} +1 -1
- package/dist/assets/{index-Bx7MluX4.js → index-DVqDWfDB.js} +2 -2
- package/dist/assets/{index-Blb2sMy-.js → index-DvRrAd9Y.js} +1 -1
- package/dist/assets/{index-kLrB6kGv.js → index-OYmdvKdM.js} +3 -3
- package/dist/assets/{index-B-kiGa62.js → index-ave_XgRW.js} +1 -1
- package/dist/assets/{main-C0wLbrjb.js → main-CHfGSVnw.js} +94 -93
- package/dist/assets/main-DmmvYoEs.css +1 -0
- package/dist/assets/{web-CQeInubf.js → web-D1aYcbd6.js} +1 -1
- package/dist/assets/{web-Dpd6fLBM.js → web-rv0WAVTk.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/claude/runner/internal-events.js +6 -3
- package/dist/src/packages/server/claude/runner/tmux-helper.js +10 -0
- package/dist/src/packages/server/claude/runner/watchdog.js +14 -0
- package/dist/src/packages/server/claude/runner.js +35 -13
- package/dist/src/packages/server/claude/session-loader.js +240 -0
- package/dist/src/packages/server/routes/agents.js +4 -0
- package/dist/src/packages/server/routes/index.js +2 -0
- package/dist/src/packages/server/routes/sessions.js +93 -0
- package/dist/src/packages/server/services/runtime-command-execution.js +13 -0
- package/dist/src/packages/server/services/runtime-service.js +7 -0
- package/dist/src/packages/server/utils/index.js +1 -0
- package/dist/src/packages/server/utils/log-context.js +10 -0
- package/dist/src/packages/server/utils/logger.js +6 -1
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +17 -5
- package/package.json +1 -1
- package/dist/assets/main-DrNeyjdE.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cg as a}from"./main-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
19
|
-
handler
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|