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.
Files changed (50) hide show
  1. package/dist/assets/{BossLogsModal-DNQC-Ewi.js → BossLogsModal-B5wtQ9Fh.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DSpPdHfB.js → BossSpawnModal-B8twkNNt.js} +1 -1
  3. package/dist/assets/{ControlsModal-TPM0WTd7.js → ControlsModal-BKUVlO45.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BsEfK9sY.js → DockerLogsModal-Oa0WYexL.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-B7pzeHYJ.js → EmbeddedEditor-Cnkfr9vJ.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BD0lLD6-.js → GmailOAuthSetup-Cfsgqs5D.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-q_I-vMvg.js → GoogleOAuthSetup-BZCY7oIc.js} +1 -1
  8. package/dist/assets/{IframeModal-C0-b5Igy.js → IframeModal-BVaOFzOL.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BPWcucBR.js → IntegrationsPanel-CdcwTSOY.js} +2 -2
  10. package/dist/assets/{LogViewerModal-D1puFD4z.js → LogViewerModal-U0o-ixDu.js} +1 -1
  11. package/dist/assets/{MonitoringModal-Dy3YmxW7.js → MonitoringModal-CdYBNEBK.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-DFNTztfk.js → PM2LogsModal-cxSSfOTz.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CokHqFKe.js → RestoreArchivedAreaModal-BkzbecLN.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-Dr1l-tVX.js → Scene2DCanvas-GGc__FVu.js} +1 -1
  15. package/dist/assets/{SceneManager-IcoT7Cdk.js → SceneManager-BWyie0tH.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Cc1zvnfm.js → SkillsPanel-DS7QNt0-.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Cjva8TJX.js → SlackMultiInstanceSetup-CjHaygiR.js} +1 -1
  18. package/dist/assets/{SpawnModal-DHKupuXe.js → SpawnModal-MWySBWkA.js} +1 -1
  19. package/dist/assets/{StatisticsModal-DZXDkn4d.js → StatisticsModal-D08htYhH.js} +1 -1
  20. package/dist/assets/{SubordinateAssignmentModal-DLJNIySK.js → SubordinateAssignmentModal-DULM1xpx.js} +1 -1
  21. package/dist/assets/{TriggerManagerPanel-Bd5ZGBaw.js → TriggerManagerPanel-nB6R7Nk4.js} +1 -1
  22. package/dist/assets/{WorkflowEditorPanel-D1YylR-A.js → WorkflowEditorPanel-CKgW8DhB.js} +1 -1
  23. package/dist/assets/{area-logos-C1EKS2S6.js → area-logos-CuU1qdkU.js} +1 -1
  24. package/dist/assets/{index-_wxXq7jG.js → index-8DzLSviG.js} +1 -1
  25. package/dist/assets/{index-0yPqh1QY.js → index-B6TRH1E2.js} +1 -1
  26. package/dist/assets/{index-7EfWB7v9.js → index-CLmNQzTF.js} +2 -2
  27. package/dist/assets/{index-E4hM-HPm.js → index-CQPpjjTd.js} +1 -1
  28. package/dist/assets/{index-IPKznDm8.js → index-CWYcGGCJ.js} +3 -3
  29. package/dist/assets/{index-BLrXnQWB.js → index-CWiGs6nn.js} +1 -1
  30. package/dist/assets/{index-BmUoFUZP.js → index-Cc_Ys4_9.js} +1 -1
  31. package/dist/assets/{index-C87bCtYJ.js → index-Csnk6THj.js} +1 -1
  32. package/dist/assets/{index-Ch70UsM8.js → index-ih8yuWhL.js} +1 -1
  33. package/dist/assets/{main-DhXGHPKx.js → main-CHE4wC7P.js} +113 -113
  34. package/dist/assets/main-DM0iS4EN.css +1 -0
  35. package/dist/assets/{web-CHTqiViU.js → web-C4yrL2Hp.js} +1 -1
  36. package/dist/assets/{web-g3yRdGTs.js → web-D1Ur_Mtd.js} +1 -1
  37. package/dist/assets/{web-CDTjrhUg.js → web-dmnfXY_q.js} +1 -1
  38. package/dist/index.html +2 -2
  39. package/dist/locales/en/terminal.json +13 -0
  40. package/dist/src/packages/server/claude/interactive/interactive-runner.js +8 -0
  41. package/dist/src/packages/server/claude/runner/recovery-store.js +102 -6
  42. package/dist/src/packages/server/claude/runner.js +30 -10
  43. package/dist/src/packages/server/data/index.js +1 -0
  44. package/dist/src/packages/server/services/auto-collapse-service.js +8 -2
  45. package/dist/src/packages/server/services/collapse-context.js +52 -12
  46. package/dist/src/packages/server/services/subagent-jsonl-watcher.js +112 -82
  47. package/dist/src/packages/server/websocket/handlers/agent-handler.js +3 -0
  48. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +40 -30
  49. package/package.json +1 -1
  50. package/dist/assets/main-rrlO1Py8.css +0 -1
@@ -1 +1 @@
1
- import{cO as a}from"./main-DhXGHPKx.js";import{ImpactStyle as i,NotificationType as r}from"./index-7EfWB7v9.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{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-DhXGHPKx.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
+ 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-DhXGHPKx.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{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-DhXGHPKx.js"></script>
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-rrlO1Py8.css">
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
- // Only clear THIS provider's entries from the persist file, not the whole file.
135
- // Other runners haven't recovered yet and still need their entries.
136
- const remaining = savedProcesses.filter((p) => {
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
- if (remaining.length > 0) {
141
- saveRunningProcesses(remaining);
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: savedProcess?.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
- // Resume tailing the log file from where we left off
564
- const tailer = this.stdoutPipeline.handleTmuxLog(agentId, logFile, offset);
565
- activeProcess.tmuxTailer = tailer;
566
- // Ensure the agent status reflects that we're connected
567
- const agent = agentService.getAgent(agentId);
568
- if (agent && agent.status === 'working') {
569
- // Keep it as working it was mid-task when the server restarted
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.log(`✅ [TMUX] Reconnected to tmux session ${sessionName} for agent ${agentId}`);
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
- const result = await collapseAgentContext(agentId, { waitForIdle: true });
70
- log.log(`Auto-collapse fired for agent ${agentId} ('${expression}' ${timezone}): ${result.status}`);
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. Two callers use it: the REST
6
- * endpoint `POST /api/agents/:id/collapse-context` and the WS
7
- * `collapse_context` handler.
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 Set();
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 set is the gate that
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
- if (!pending.has(agent.id))
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
- pending.delete(agent.id);
33
- void drain(agent.id);
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
- async function drain(agentId) {
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
- // Set.add is idempotent — multiple `waitForIdle` calls coalesce into one drain.
62
- pending.add(agentId);
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
  }