tide-commander 1.134.2 → 1.136.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-DNQC-Ewi.js → BossLogsModal-B5wtQ9Fh.js} +1 -1
- package/dist/assets/{BossSpawnModal-DSpPdHfB.js → BossSpawnModal-B8twkNNt.js} +1 -1
- package/dist/assets/{ControlsModal-TPM0WTd7.js → ControlsModal-BKUVlO45.js} +1 -1
- package/dist/assets/{DockerLogsModal-BsEfK9sY.js → DockerLogsModal-Oa0WYexL.js} +1 -1
- package/dist/assets/{EmbeddedEditor-B7pzeHYJ.js → EmbeddedEditor-Cnkfr9vJ.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-BD0lLD6-.js → GmailOAuthSetup-Cfsgqs5D.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-q_I-vMvg.js → GoogleOAuthSetup-BZCY7oIc.js} +1 -1
- package/dist/assets/{IframeModal-C0-b5Igy.js → IframeModal-BVaOFzOL.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BPWcucBR.js → IntegrationsPanel-CdcwTSOY.js} +2 -2
- package/dist/assets/{LogViewerModal-D1puFD4z.js → LogViewerModal-U0o-ixDu.js} +1 -1
- package/dist/assets/{MonitoringModal-Dy3YmxW7.js → MonitoringModal-CdYBNEBK.js} +1 -1
- package/dist/assets/{PM2LogsModal-DFNTztfk.js → PM2LogsModal-cxSSfOTz.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CokHqFKe.js → RestoreArchivedAreaModal-BkzbecLN.js} +1 -1
- package/dist/assets/{Scene2DCanvas-Dr1l-tVX.js → Scene2DCanvas-GGc__FVu.js} +1 -1
- package/dist/assets/{SceneManager-IcoT7Cdk.js → SceneManager-BWyie0tH.js} +1 -1
- package/dist/assets/{SkillsPanel-Cc1zvnfm.js → SkillsPanel-DS7QNt0-.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Cjva8TJX.js → SlackMultiInstanceSetup-CjHaygiR.js} +1 -1
- package/dist/assets/{SpawnModal-DHKupuXe.js → SpawnModal-MWySBWkA.js} +1 -1
- package/dist/assets/{StatisticsModal-DZXDkn4d.js → StatisticsModal-D08htYhH.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DLJNIySK.js → SubordinateAssignmentModal-DULM1xpx.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-Bd5ZGBaw.js → TriggerManagerPanel-nB6R7Nk4.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-D1YylR-A.js → WorkflowEditorPanel-CKgW8DhB.js} +1 -1
- package/dist/assets/{area-logos-C1EKS2S6.js → area-logos-CuU1qdkU.js} +1 -1
- package/dist/assets/{index-_wxXq7jG.js → index-8DzLSviG.js} +1 -1
- package/dist/assets/{index-0yPqh1QY.js → index-B6TRH1E2.js} +1 -1
- package/dist/assets/{index-7EfWB7v9.js → index-CLmNQzTF.js} +2 -2
- package/dist/assets/{index-E4hM-HPm.js → index-CQPpjjTd.js} +1 -1
- package/dist/assets/{index-IPKznDm8.js → index-CWYcGGCJ.js} +3 -3
- package/dist/assets/{index-BLrXnQWB.js → index-CWiGs6nn.js} +1 -1
- package/dist/assets/{index-BmUoFUZP.js → index-Cc_Ys4_9.js} +1 -1
- package/dist/assets/{index-C87bCtYJ.js → index-Csnk6THj.js} +1 -1
- package/dist/assets/{index-Ch70UsM8.js → index-ih8yuWhL.js} +1 -1
- package/dist/assets/{main-DhXGHPKx.js → main-CHE4wC7P.js} +113 -113
- package/dist/assets/main-DM0iS4EN.css +1 -0
- package/dist/assets/{web-CHTqiViU.js → web-C4yrL2Hp.js} +1 -1
- package/dist/assets/{web-g3yRdGTs.js → web-D1Ur_Mtd.js} +1 -1
- package/dist/assets/{web-CDTjrhUg.js → web-dmnfXY_q.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/terminal.json +13 -0
- package/dist/src/packages/server/claude/interactive/interactive-runner.js +8 -0
- package/dist/src/packages/server/claude/runner/recovery-store.js +102 -6
- package/dist/src/packages/server/claude/runner.js +30 -10
- package/dist/src/packages/server/data/index.js +1 -0
- package/dist/src/packages/server/services/auto-collapse-service.js +8 -2
- package/dist/src/packages/server/services/collapse-context.js +52 -12
- package/dist/src/packages/server/services/subagent-jsonl-watcher.js +112 -82
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +3 -0
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +40 -30
- package/package.json +1 -1
- package/dist/assets/main-rrlO1Py8.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cO as a}from"./main-
|
|
1
|
+
import{cO as a}from"./main-CHE4wC7P.js";import{ImpactStyle as i,NotificationType as r}from"./index-CLmNQzTF.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{cO as t}from"./main-
|
|
1
|
+
import{cO as t}from"./main-CHE4wC7P.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cO as s}from"./main-
|
|
1
|
+
import{cO as s}from"./main-CHE4wC7P.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-CHE4wC7P.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-DM0iS4EN.css">
|
|
29
29
|
</head>
|
|
30
30
|
<body>
|
|
31
31
|
<div id="app"></div>
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
+
"search": {
|
|
3
|
+
"tabContent": "Content",
|
|
4
|
+
"tabFiles": "Files",
|
|
5
|
+
"typeToSearch": "Type at least 2 characters to search",
|
|
6
|
+
"indexing": "Indexing conversation…",
|
|
7
|
+
"noContent": "No matching messages",
|
|
8
|
+
"noFiles": "No referenced files match",
|
|
9
|
+
"opRead": "Read",
|
|
10
|
+
"opEdit": "Edited",
|
|
11
|
+
"opWrite": "Written",
|
|
12
|
+
"openFile": "Open file in a modal",
|
|
13
|
+
"goToMessage": "Go to the message in chat"
|
|
14
|
+
},
|
|
2
15
|
"header": {
|
|
3
16
|
"openSidebar": "Open sidebar",
|
|
4
17
|
"closeSidebar": "Close sidebar",
|
|
@@ -128,6 +128,14 @@ export class InteractiveClaudeRunner {
|
|
|
128
128
|
lastRequest: entry.lastRequest,
|
|
129
129
|
};
|
|
130
130
|
this.processes.set(entry.agentId, proc);
|
|
131
|
+
// loadAgentsFromDisk() flattens every agent to 'idle' on server start, so
|
|
132
|
+
// restore 'working' from the status captured at persist time. Must happen
|
|
133
|
+
// BEFORE watcher.start(): its synchronous replay delivers any end-of-turn
|
|
134
|
+
// events from the downtime, flipping the agent back to idle if the turn
|
|
135
|
+
// actually finished while we were down.
|
|
136
|
+
if (entry.agentStatus === 'working' && agentService.getAgent(entry.agentId)) {
|
|
137
|
+
agentService.updateAgent(entry.agentId, { status: 'working' });
|
|
138
|
+
}
|
|
131
139
|
// start() synchronously reads from the saved offset to EOF, emitting any
|
|
132
140
|
// events that landed while we were down (incl. a trailing end_turn →
|
|
133
141
|
// step_complete → idle), then continues live-tailing.
|
|
@@ -1,8 +1,58 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
1
2
|
import { saveRunningProcesses, loadRunningProcesses, isProcessRunning, clearRunningProcesses, } from '../../data/index.js';
|
|
2
3
|
import * as agentService from '../../services/agent-service.js';
|
|
3
4
|
import { createLogger } from '../../utils/logger.js';
|
|
4
|
-
import { hasTmuxSession, isTmuxEnabled, killTmuxSession, listAgentTmuxSessions, tmuxLogPath } from './tmux-helper.js';
|
|
5
|
+
import { hasTmuxSession, isTmuxEnabled, killTmuxSession, listAgentTmuxSessions, tmuxLogPath, tmuxSessionName } from './tmux-helper.js';
|
|
5
6
|
const log = createLogger('Runner');
|
|
7
|
+
const TAIL_SCAN_BYTES = 64 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Derive whether the CLI inside a live tmux session is mid-turn by scanning
|
|
10
|
+
* the tail of its log for the last complete stream-json event: claude emits
|
|
11
|
+
* `{type:'result'}` at end of turn, so a trailing result means the agent is
|
|
12
|
+
* waiting for input; any other trailing event means a turn is in flight.
|
|
13
|
+
* Used for sessions recovered WITHOUT a persist entry, where the persisted
|
|
14
|
+
* agentStatus is unavailable.
|
|
15
|
+
*/
|
|
16
|
+
function scanLogTailForStatus(logFile) {
|
|
17
|
+
let fd;
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.statSync(logFile);
|
|
20
|
+
if (stat.size === 0)
|
|
21
|
+
return 'idle';
|
|
22
|
+
const readLen = Math.min(stat.size, TAIL_SCAN_BYTES);
|
|
23
|
+
const buf = Buffer.alloc(readLen);
|
|
24
|
+
fd = fs.openSync(logFile, 'r');
|
|
25
|
+
fs.readSync(fd, buf, 0, readLen, stat.size - readLen);
|
|
26
|
+
fs.closeSync(fd);
|
|
27
|
+
fd = undefined;
|
|
28
|
+
// Walk complete lines from the end; the first parseable JSON event wins.
|
|
29
|
+
// Partial trailing writes and the (possibly truncated) chunk head fail
|
|
30
|
+
// JSON.parse and are skipped naturally.
|
|
31
|
+
const lines = buf.toString('utf8').split('\n');
|
|
32
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
33
|
+
const line = lines[i].trim();
|
|
34
|
+
if (!line)
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const event = JSON.parse(line);
|
|
38
|
+
if (event && typeof event.type === 'string') {
|
|
39
|
+
return event.type === 'result' ? 'idle' : 'working';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* partial/truncated line — keep walking back */ }
|
|
43
|
+
}
|
|
44
|
+
return 'idle';
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
if (fd !== undefined) {
|
|
48
|
+
try {
|
|
49
|
+
fs.closeSync(fd);
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
return 'idle';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
6
56
|
export class RunnerRecoveryStore {
|
|
7
57
|
backend;
|
|
8
58
|
activeProcesses;
|
|
@@ -131,14 +181,56 @@ export class RunnerRecoveryStore {
|
|
|
131
181
|
toResume.push(savedProcess);
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
184
|
+
// Persist-file entries can be lost (a restart landing before the
|
|
185
|
+
// post-reconnect persist below, or a hard crash), but the tmux sessions
|
|
186
|
+
// themselves survive. Treat live `tc-*` sessions as the source of truth:
|
|
187
|
+
// any session whose agent belongs to this provider and has no persist
|
|
188
|
+
// entry gets a synthesized reconnect entry tailing from the log's current
|
|
189
|
+
// end, with status derived from the last event in the log.
|
|
190
|
+
if (isTmuxEnabled()) {
|
|
191
|
+
const pending = new Set(toReconnectTmux.map((p) => p.agentId));
|
|
192
|
+
for (const agentId of listAgentTmuxSessions()) {
|
|
193
|
+
if (this.activeProcesses.has(agentId) || pending.has(agentId))
|
|
194
|
+
continue;
|
|
195
|
+
const agent = agentService.getAgent(agentId);
|
|
196
|
+
if (!agent)
|
|
197
|
+
continue; // killUnknownTmuxSessions() already handles these
|
|
198
|
+
if ((agent.provider ?? 'claude') !== this.backend.name)
|
|
199
|
+
continue;
|
|
200
|
+
const logFile = tmuxLogPath(agentId);
|
|
201
|
+
let offset = 0;
|
|
202
|
+
try {
|
|
203
|
+
offset = fs.statSync(logFile).size;
|
|
204
|
+
}
|
|
205
|
+
catch { /* no log yet — reconnect from 0 */ }
|
|
206
|
+
// Stdin-closed backends (codex/opencode) exit at end of turn, so a
|
|
207
|
+
// live session is mid-turn by definition.
|
|
208
|
+
const closesStdin = this.backend.shouldCloseStdinAfterPrompt?.() === true;
|
|
209
|
+
const agentStatus = closesStdin ? 'working' : scanLogTailForStatus(logFile);
|
|
210
|
+
log.log(`✅ [TMUX] Live session ${tmuxSessionName(agentId)} has no persist entry — reconnecting at EOF (offset=${offset}, derived status=${agentStatus})`);
|
|
211
|
+
toReconnectTmux.push({
|
|
212
|
+
agentId,
|
|
213
|
+
pid: 0,
|
|
214
|
+
sessionId: agent.sessionId,
|
|
215
|
+
startTime: Date.now(),
|
|
216
|
+
agentStatus,
|
|
217
|
+
tmuxSession: tmuxSessionName(agentId),
|
|
218
|
+
tmuxLogOffset: offset,
|
|
219
|
+
provider: this.backend.name,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Rewrite the persist file: other providers' entries stay untouched, and
|
|
224
|
+
// our to-reconnect tmux entries are KEPT (not consumed) — if the server
|
|
225
|
+
// dies again before the reconnect below re-persists, the next boot can
|
|
226
|
+
// still find them. Everything else of ours is consumed.
|
|
227
|
+
const otherProviders = savedProcesses.filter((p) => {
|
|
137
228
|
const prov = p.provider ?? agentService.getAgent(p.agentId)?.provider ?? 'claude';
|
|
138
229
|
return prov !== this.backend.name;
|
|
139
230
|
});
|
|
140
|
-
|
|
141
|
-
|
|
231
|
+
const preserved = [...otherProviders, ...toReconnectTmux];
|
|
232
|
+
if (preserved.length > 0) {
|
|
233
|
+
saveRunningProcesses(preserved);
|
|
142
234
|
}
|
|
143
235
|
else {
|
|
144
236
|
clearRunningProcesses();
|
|
@@ -157,6 +249,10 @@ export class RunnerRecoveryStore {
|
|
|
157
249
|
log.log(`🔄 [TMUX] Reconnecting to tmux session for agent ${saved.agentId} at offset ${offset}`);
|
|
158
250
|
this.reconnectTmux(saved.agentId, logFile, offset, saved);
|
|
159
251
|
}
|
|
252
|
+
// Re-persist immediately so the freshly tracked processes survive
|
|
253
|
+
// another quick restart (the periodic persist is up to 10s away, and
|
|
254
|
+
// tsx-watch dev restarts land inside that window all the time).
|
|
255
|
+
this.persistRunningProcesses();
|
|
160
256
|
}, 1000);
|
|
161
257
|
}
|
|
162
258
|
if (toResume.length === 0) {
|
|
@@ -547,32 +547,52 @@ export class ClaudeRunner {
|
|
|
547
547
|
// Create a dummy child process (we don't own the real one — tmux does)
|
|
548
548
|
const dummyProcess = spawn('true', [], { stdio: 'ignore' });
|
|
549
549
|
dummyProcess.unref();
|
|
550
|
+
// loadAgentsFromDisk() flattens every agent to 'idle' on server start, so
|
|
551
|
+
// the in-memory status can't tell us whether this agent was mid-task —
|
|
552
|
+
// use the status captured in the persist file (refreshed every 10s and on
|
|
553
|
+
// graceful shutdown) instead.
|
|
554
|
+
const agent = agentService.getAgent(agentId);
|
|
555
|
+
const wasWorking = agent?.status === 'working' || savedProcess?.agentStatus === 'working';
|
|
556
|
+
const lastRequest = savedProcess?.lastRequest;
|
|
550
557
|
const activeProcess = {
|
|
551
558
|
agentId,
|
|
552
559
|
sessionId: savedProcess?.sessionId,
|
|
553
560
|
startTime: savedProcess?.startTime ?? Date.now(),
|
|
554
561
|
process: dummyProcess,
|
|
555
|
-
lastRequest
|
|
562
|
+
lastRequest,
|
|
556
563
|
restartCount: 0,
|
|
557
|
-
turnState: 'waiting_for_input',
|
|
564
|
+
turnState: wasWorking ? 'processing' : 'waiting_for_input',
|
|
565
|
+
// Fresh baseline: with the saved startTime as fallback, the watchdog's
|
|
566
|
+
// idle-timeout would see hours of "inactivity" and kill the live
|
|
567
|
+
// session on its first tick after a restart.
|
|
568
|
+
lastActivityTime: Date.now(),
|
|
558
569
|
tmuxSession: sessionName,
|
|
559
570
|
tmuxLogFile: logFile,
|
|
560
571
|
isReconnected: true,
|
|
561
572
|
};
|
|
562
573
|
this.activeProcesses.set(agentId, activeProcess);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
574
|
+
if (agent && wasWorking) {
|
|
575
|
+
// Mid-task when the previous instance went down — re-assert 'working'
|
|
576
|
+
// so the UI doesn't show idle and the watchdog's idle-timeout doesn't
|
|
577
|
+
// kill the live session.
|
|
578
|
+
agentService.updateAgent(agentId, {
|
|
579
|
+
status: 'working',
|
|
580
|
+
...(typeof lastRequest?.prompt === 'string' && lastRequest.prompt.length > 0
|
|
581
|
+
? { currentTask: lastRequest.prompt.substring(0, 100) }
|
|
582
|
+
: {}),
|
|
583
|
+
});
|
|
570
584
|
}
|
|
571
585
|
else if (agent) {
|
|
572
586
|
// Set to idle — the tmux session is alive but waiting for input
|
|
573
587
|
agentService.updateAgent(agentId, { status: 'idle' });
|
|
574
588
|
}
|
|
575
|
-
log
|
|
589
|
+
// Resume tailing the log file from where we left off. start() replays any
|
|
590
|
+
// events that landed during the downtime synchronously, so this must run
|
|
591
|
+
// AFTER the status restore above: if the turn actually ended while we were
|
|
592
|
+
// down, the replayed end-of-turn events flip the agent back to idle.
|
|
593
|
+
const tailer = this.stdoutPipeline.handleTmuxLog(agentId, logFile, offset);
|
|
594
|
+
activeProcess.tmuxTailer = tailer;
|
|
595
|
+
log.log(`✅ [TMUX] Reconnected to tmux session ${sessionName} for agent ${agentId} (status=${wasWorking ? 'working' : 'idle'})`);
|
|
576
596
|
}
|
|
577
597
|
interrupt(agentId) {
|
|
578
598
|
return withAgentContext(agentId, () => this.lifecycle.interrupt(agentId));
|
|
@@ -147,6 +147,7 @@ function toStoredAgents(agents) {
|
|
|
147
147
|
autoCollapse: agent.autoCollapse,
|
|
148
148
|
autoCollapseCron: agent.autoCollapseCron,
|
|
149
149
|
autoCollapseTz: agent.autoCollapseTz,
|
|
150
|
+
autoCollapsePrompt: agent.autoCollapsePrompt,
|
|
150
151
|
isBoss: agent.isBoss,
|
|
151
152
|
subordinateIds: agent.subordinateIds,
|
|
152
153
|
bossId: agent.bossId,
|
|
@@ -66,8 +66,14 @@ function armJob(agentId, config) {
|
|
|
66
66
|
}
|
|
67
67
|
async function runCollapse(agentId, expression, timezone) {
|
|
68
68
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Read the prompt at fire time (not arm time) so edits to it apply on the
|
|
70
|
+
// next collapse without re-arming the cron job.
|
|
71
|
+
const afterPrompt = (agentService.getAgent(agentId)?.autoCollapsePrompt ?? '').trim() || undefined;
|
|
72
|
+
const result = await collapseAgentContext(agentId, {
|
|
73
|
+
waitForIdle: true,
|
|
74
|
+
...(afterPrompt ? { afterPrompt } : {}),
|
|
75
|
+
});
|
|
76
|
+
log.log(`Auto-collapse fired for agent ${agentId} ('${expression}' ${timezone}): ${result.status}${afterPrompt ? ' [post-collapse prompt armed]' : ''}`);
|
|
71
77
|
}
|
|
72
78
|
catch (err) {
|
|
73
79
|
log.error(`Auto-collapse failed for agent ${agentId}:`, err);
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Collapse-Context Service
|
|
3
3
|
*
|
|
4
4
|
* Owns the small queue of pending `/compact` requests that need to fire as
|
|
5
|
-
* soon as their agent transitions back to idle.
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
5
|
+
* soon as their agent transitions back to idle. Callers: the REST endpoint
|
|
6
|
+
* `POST /api/agents/:id/collapse-context`, the WS `collapse_context` handler,
|
|
7
|
+
* and the auto-collapse cron (which may also pass an `afterPrompt` — a command
|
|
8
|
+
* dispatched to the agent once its compact turn finishes).
|
|
8
9
|
*
|
|
9
10
|
* Why a separate module: the queue + idle-drain logic is the only piece of
|
|
10
11
|
* state we want to unit-test in isolation, and `runtime-service` is heavy with
|
|
@@ -13,43 +14,80 @@
|
|
|
13
14
|
* up the real agent service.
|
|
14
15
|
*/
|
|
15
16
|
export function createCollapseContextService(deps) {
|
|
16
|
-
const pending = new
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
// Prompts armed AFTER their `/compact` was dispatched — sent on the agent's
|
|
19
|
+
// next idle transition (i.e. when the compact turn finishes).
|
|
20
|
+
const afterPrompts = new Map();
|
|
17
21
|
let unsubscribe = null;
|
|
18
22
|
// Lazy subscription — we only attach the listener once a real queue exists.
|
|
19
|
-
// Stays attached for the process lifetime; the pending
|
|
20
|
-
// decides whether the listener does anything on each event.
|
|
23
|
+
// Stays attached for the process lifetime; the pending/afterPrompts maps are
|
|
24
|
+
// the gate that decides whether the listener does anything on each event.
|
|
21
25
|
function ensureSubscribed() {
|
|
22
26
|
if (unsubscribe)
|
|
23
27
|
return;
|
|
24
28
|
unsubscribe = deps.subscribe((event, data) => {
|
|
29
|
+
if (event === 'deleted' && typeof data === 'string') {
|
|
30
|
+
pending.delete(data);
|
|
31
|
+
afterPrompts.delete(data);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
25
34
|
if (event !== 'updated' || !data || typeof data === 'string')
|
|
26
35
|
return;
|
|
27
36
|
const agent = data;
|
|
28
37
|
if (agent.status !== 'idle')
|
|
29
38
|
return;
|
|
30
|
-
|
|
39
|
+
const queued = pending.get(agent.id);
|
|
40
|
+
if (queued) {
|
|
41
|
+
pending.delete(agent.id);
|
|
42
|
+
void drain(agent.id, queued.afterPrompt);
|
|
31
43
|
return;
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
}
|
|
45
|
+
const prompt = afterPrompts.get(agent.id);
|
|
46
|
+
if (prompt !== undefined) {
|
|
47
|
+
afterPrompts.delete(agent.id);
|
|
48
|
+
void sendAfterPrompt(agent.id, prompt);
|
|
49
|
+
}
|
|
34
50
|
});
|
|
35
51
|
}
|
|
36
|
-
|
|
52
|
+
// Register the post-collapse prompt only after `/compact` was actually sent:
|
|
53
|
+
// sendCommand flips the agent to 'working' synchronously, so the idle event
|
|
54
|
+
// this arms against is the one that ends the compact turn.
|
|
55
|
+
function armAfterPrompt(agentId, prompt) {
|
|
56
|
+
ensureSubscribed();
|
|
57
|
+
afterPrompts.set(agentId, prompt);
|
|
58
|
+
}
|
|
59
|
+
async function drain(agentId, afterPrompt) {
|
|
37
60
|
try {
|
|
38
61
|
await deps.sendCommand(agentId, '/compact');
|
|
39
62
|
deps.log?.info?.(`Drained queued collapse for ${agentId} after idle transition`);
|
|
63
|
+
if (afterPrompt)
|
|
64
|
+
armAfterPrompt(agentId, afterPrompt);
|
|
40
65
|
}
|
|
41
66
|
catch (err) {
|
|
42
67
|
const msg = err instanceof Error ? err.message : String(err);
|
|
43
68
|
deps.log?.error?.(`Failed to drain queued collapse for ${agentId}: ${msg}`);
|
|
44
69
|
}
|
|
45
70
|
}
|
|
71
|
+
async function sendAfterPrompt(agentId, prompt) {
|
|
72
|
+
try {
|
|
73
|
+
await deps.sendCommand(agentId, prompt);
|
|
74
|
+
deps.log?.info?.(`Sent post-collapse prompt to ${agentId}`);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
deps.log?.error?.(`Failed to send post-collapse prompt to ${agentId}: ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
46
81
|
async function collapse(agentId, opts = {}) {
|
|
47
82
|
const agent = deps.getAgent(agentId);
|
|
48
83
|
if (!agent)
|
|
49
84
|
return { status: 'not-found' };
|
|
85
|
+
const afterPrompt = opts.afterPrompt?.trim() || undefined;
|
|
50
86
|
if (agent.status === 'idle') {
|
|
51
87
|
try {
|
|
52
88
|
await deps.sendCommand(agentId, '/compact');
|
|
89
|
+
if (afterPrompt)
|
|
90
|
+
armAfterPrompt(agentId, afterPrompt);
|
|
53
91
|
return { status: 'collapse-initiated' };
|
|
54
92
|
}
|
|
55
93
|
catch (err) {
|
|
@@ -58,8 +96,9 @@ export function createCollapseContextService(deps) {
|
|
|
58
96
|
}
|
|
59
97
|
if (opts.waitForIdle) {
|
|
60
98
|
ensureSubscribed();
|
|
61
|
-
//
|
|
62
|
-
|
|
99
|
+
// Map.set is idempotent per agent — multiple `waitForIdle` calls coalesce
|
|
100
|
+
// into one drain; the latest call's afterPrompt wins.
|
|
101
|
+
pending.set(agentId, { afterPrompt });
|
|
63
102
|
return { status: 'queued' };
|
|
64
103
|
}
|
|
65
104
|
return { status: 'busy', currentStatus: agent.status };
|
|
@@ -68,5 +107,6 @@ export function createCollapseContextService(deps) {
|
|
|
68
107
|
collapse,
|
|
69
108
|
hasPending: (id) => pending.has(id),
|
|
70
109
|
pendingCount: () => pending.size,
|
|
110
|
+
hasPendingPrompt: (id) => afterPrompts.has(id),
|
|
71
111
|
};
|
|
72
112
|
}
|