tide-commander 1.40.2 → 1.40.4

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 (58) hide show
  1. package/dist/assets/{BossLogsModal-CaDrNfm1.js → BossLogsModal-CehuxNwV.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-DLJInoVf.js → BossSpawnModal-Cohl0WaH.js} +1 -1
  3. package/dist/assets/{ControlsModal-Cnnhwfr_.js → ControlsModal-CAVkg9mM.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-D4CbWK3-.js → DockerLogsModal-BXv9Hzg-.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-36YBGNE2.js → EmbeddedEditor-CTlTVKdD.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-DI5j7wkF.js → GmailOAuthSetup-B1zQWbRR.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-Bsj02mND.js → GoogleOAuthSetup-CmayoZ-D.js} +1 -1
  8. package/dist/assets/{IframeModal-BJ4oeDBn.js → IframeModal-DlS7y7PK.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-DLmIJEUy.js → IntegrationsPanel-BPKoNwZm.js} +2 -2
  10. package/dist/assets/{LogViewerModal-D7CXMmZf.js → LogViewerModal-Bx9IDiFM.js} +1 -1
  11. package/dist/assets/{MonitoringModal-PnEKvezF.js → MonitoringModal-DzqEa0fQ.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-EB_Au0iK.js → PM2LogsModal-DZXPRV5-.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-C-X90Qxe.js → RestoreArchivedAreaModal-BajWqtWn.js} +1 -1
  14. package/dist/assets/{SaveSnapshotModal-D6XAjL2T.js → SaveSnapshotModal-LqaLezB-.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-DlVguRVM.js → Scene2DCanvas-DWDmyh_y.js} +1 -1
  16. package/dist/assets/{SceneManager-OOaJ_z8v.js → SceneManager-BgkqWsDb.js} +1 -1
  17. package/dist/assets/{SkillsPanel-D5wo2MHh.js → SkillsPanel-CO-Zt0Tq.js} +1 -1
  18. package/dist/assets/{SnapshotManager-BLjpctX_.js → SnapshotManager-D4Uzw6Tp.js} +1 -1
  19. package/dist/assets/{SpawnModal-DQ4uUm1y.js → SpawnModal-BdCgTKpM.js} +1 -1
  20. package/dist/assets/{SubordinateAssignmentModal-BDUDt6WC.js → SubordinateAssignmentModal-BoaofULj.js} +1 -1
  21. package/dist/assets/{SupervisorPanel-BZaBI9BA.js → SupervisorPanel-D8Cp77my.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-DuBiclsE.js → TriggerManagerPanel-CFf3Tud5.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-D49yDqth.js → WorkflowEditorPanel-EtzhBZWw.js} +1 -1
  24. package/dist/assets/{index-DZ3f9mNd.js → index-2YWd58Wh.js} +1 -1
  25. package/dist/assets/{index-DJdXbeWz.js → index-BKRKKMPQ.js} +3 -3
  26. package/dist/assets/{index-BwkNteiZ.js → index-BgTRovwT.js} +1 -1
  27. package/dist/assets/{index-mQmnkGdF.js → index-BiJ_hIit.js} +1 -1
  28. package/dist/assets/{index-Bxjd0N-j.js → index-CDAtstMx.js} +1 -1
  29. package/dist/assets/{index-BxbFh16W.js → index-DBpmD8r7.js} +2 -2
  30. package/dist/assets/{index-BiGvhrCO.js → index-DByGRjfY.js} +1 -1
  31. package/dist/assets/index-VO6fNr63.js +1 -0
  32. package/dist/assets/main-BFSqr5mR.css +1 -0
  33. package/dist/assets/main-BSyGZK2z.js +152 -0
  34. package/dist/assets/{web-DAqM9uoZ.js → web-1BwWjkKM.js} +1 -1
  35. package/dist/assets/{web-DQ10iSod.js → web-BXQj42md.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/locales/en/config.json +1 -0
  38. package/dist/src/packages/server/claude/backend.js +9 -0
  39. package/dist/src/packages/server/claude/runner/process-lifecycle.js +79 -7
  40. package/dist/src/packages/server/claude/runner/recovery-store.js +73 -10
  41. package/dist/src/packages/server/claude/runner/restart-policy.js +9 -0
  42. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +28 -4
  43. package/dist/src/packages/server/claude/runner/tmux-helper.js +264 -0
  44. package/dist/src/packages/server/claude/runner/watchdog.js +27 -0
  45. package/dist/src/packages/server/claude/runner.js +84 -9
  46. package/dist/src/packages/server/cli.js +17 -1
  47. package/dist/src/packages/server/data/builtin-skills/boss-instructions.js +2 -2
  48. package/dist/src/packages/server/data/index.js +6 -0
  49. package/dist/src/packages/server/opencode/backend.js +1 -0
  50. package/dist/src/packages/server/opencode/index.js +1 -0
  51. package/dist/src/packages/server/routes/agents.js +29 -1
  52. package/dist/src/packages/server/services/system-prompt-service.js +38 -0
  53. package/dist/src/packages/server/websocket/handlers/boss-response-handler.js +5 -1
  54. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +19 -1
  55. package/package.json +1 -1
  56. package/dist/assets/index-BSl18-4e.js +0 -1
  57. package/dist/assets/main-CUx2B9bn.css +0 -1
  58. package/dist/assets/main-DULfVodw.js +0 -152
@@ -1 +1 @@
1
- import{bx as s}from"./main-DULfVodw.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class f 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{f as LocalNotificationsWeb};
1
+ import{bx as s}from"./main-BSyGZK2z.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class f 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{f as LocalNotificationsWeb};
@@ -1 +1 @@
1
- import{bx as a}from"./main-DULfVodw.js";import{ImpactStyle as i,NotificationType as r}from"./index-BxbFh16W.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class u 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{u as HapticsWeb};
1
+ import{bx as a}from"./main-BSyGZK2z.js";import{ImpactStyle as i,NotificationType as r}from"./index-DBpmD8r7.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class u 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{u as HapticsWeb};
package/dist/index.html CHANGED
@@ -22,11 +22,11 @@
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-DULfVodw.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-BSyGZK2z.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
28
28
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
29
- <link rel="stylesheet" crossorigin href="/assets/main-CUx2B9bn.css">
29
+ <link rel="stylesheet" crossorigin href="/assets/main-BFSqr5mR.css">
30
30
  </head>
31
31
  <body>
32
32
  <div id="app"></div>
@@ -25,6 +25,7 @@
25
25
  "showFPS": "Show FPS",
26
26
  "fpsLimit": "FPS Limit",
27
27
  "powerSaving": "Power Saving",
28
+ "tmuxMode": "Tmux Process Persistence",
28
29
  "tts": "Text-to-Speech",
29
30
  "stt": "Speech-to-Text",
30
31
  "externalEditor": "External Editor",
@@ -284,6 +284,15 @@ export class ClaudeBackend {
284
284
  if (event.subtype === 'task_started' && event.task_id) {
285
285
  log.log(`parseSystemEvent: task_started - task_id=${event.task_id}, tool_use_id=${event.tool_use_id}`);
286
286
  }
287
+ // Context compaction status
288
+ if (event.subtype === 'status' && event.status === 'compacting') {
289
+ log.log(`parseSystemEvent: compacting status received, session_id=${event.session_id}`);
290
+ return {
291
+ type: 'compacting',
292
+ sessionId: event.session_id,
293
+ uuid: event.uuid,
294
+ };
295
+ }
287
296
  return null;
288
297
  }
289
298
  parseAssistantEvent(event) {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { StringDecoder } from 'string_decoder';
3
3
  import { createLogger } from '../../utils/logger.js';
4
+ import { isTmuxEnabled, checkTmuxAvailability, spawnInTmux, killTmuxSession, interruptTmuxSession, } from './tmux-helper.js';
4
5
  const log = createLogger('Runner');
5
6
  export class RunnerProcessLifecycle {
6
7
  backend;
@@ -44,15 +45,71 @@ export class RunnerProcessLifecycle {
44
45
  log.log(`🚀 Spawning: ${executable} ${args.join(' ')}`);
45
46
  const isWindows = process.platform === 'win32';
46
47
  const extraEnv = this.backend.getExtraEnv?.() ?? {};
48
+ const env = {
49
+ ...process.env,
50
+ LANG: 'en_US.UTF-8',
51
+ LC_ALL: 'en_US.UTF-8',
52
+ TIDE_SERVER: `http://localhost:${process.env.TIDE_PORT || process.env.PORT || 5174}`,
53
+ ...extraEnv,
54
+ };
55
+ // ---- tmux mode ----
56
+ checkTmuxAvailability();
57
+ const useTmux = !isWindows && isTmuxEnabled();
58
+ if (useTmux) {
59
+ // For backends that need stdin input (e.g. claude --print --input-format stream-json),
60
+ // pass the initial prompt directly into the shell command so it's available on stdin
61
+ // immediately at process start — avoids the race where the CLI checks stdin before
62
+ // tmux send-keys can deliver the prompt.
63
+ let initialStdin;
64
+ if (this.backend.requiresStdinInput()) {
65
+ initialStdin = this.backend.formatStdinInput(prompt);
66
+ log.log(`📤 [TMUX-STDIN] Passing initial prompt (${initialStdin.length} chars) via shell pipe for agent ${agentId}`);
67
+ }
68
+ const tmuxResult = spawnInTmux(executable, args, {
69
+ agentId,
70
+ cwd: workingDir,
71
+ env,
72
+ initialStdin,
73
+ closeStdinAfterPrompt: this.backend.shouldCloseStdinAfterPrompt?.() ?? false,
74
+ });
75
+ const activeProcess = {
76
+ agentId,
77
+ sessionId,
78
+ startTime: Date.now(),
79
+ process: tmuxResult.launcherProcess,
80
+ lastRequest: request,
81
+ restartCount: 0,
82
+ turnState: 'processing',
83
+ tmuxSession: tmuxResult.sessionName,
84
+ tmuxLogFile: tmuxResult.logFile,
85
+ };
86
+ this.activeProcesses.set(agentId, activeProcess);
87
+ // Use file-tailing stdout pipeline for tmux mode
88
+ const tailer = this.stdoutPipeline.handleTmuxLog(agentId, tmuxResult.logFile);
89
+ activeProcess.tmuxTailer = tailer;
90
+ // The tmux launcher process exits quickly — we don't listen to its close
91
+ // as the real process lives inside the tmux session.
92
+ tmuxResult.launcherProcess.on('error', (err) => {
93
+ this.bus.emit({
94
+ type: 'runner.process_spawn_error',
95
+ agentId,
96
+ error: err,
97
+ });
98
+ });
99
+ // Emit spawned event after a short delay (tmux session takes a moment)
100
+ setTimeout(() => {
101
+ this.bus.emit({
102
+ type: 'runner.process_spawned',
103
+ agentId,
104
+ pid: tmuxResult.launcherProcess.pid,
105
+ });
106
+ }, 600);
107
+ return;
108
+ }
109
+ // ---- normal pipe mode ----
47
110
  const childProcess = spawn(executable, args, {
48
111
  cwd: workingDir,
49
- env: {
50
- ...process.env,
51
- LANG: 'en_US.UTF-8',
52
- LC_ALL: 'en_US.UTF-8',
53
- TIDE_SERVER: `http://localhost:${process.env.TIDE_PORT || process.env.PORT || 5174}`,
54
- ...extraEnv,
55
- },
112
+ env,
56
113
  shell: isWindows ? true : false,
57
114
  detached: isWindows ? false : true,
58
115
  });
@@ -126,6 +183,10 @@ export class RunnerProcessLifecycle {
126
183
  if (!activeProcess) {
127
184
  return false;
128
185
  }
186
+ // tmux mode: send C-c via tmux
187
+ if (activeProcess.tmuxSession) {
188
+ return interruptTmuxSession(agentId);
189
+ }
129
190
  const pid = activeProcess.process.pid;
130
191
  if (!pid) {
131
192
  return false;
@@ -146,8 +207,19 @@ export class RunnerProcessLifecycle {
146
207
  }
147
208
  const pid = activeProcess.process.pid;
148
209
  log.log(`🛑 Stopping agent ${agentId} (pid ${pid})`);
210
+ // Stop tmux tailer if active
211
+ if (activeProcess.tmuxTailer) {
212
+ activeProcess.tmuxTailer.stop();
213
+ }
149
214
  this.activeProcesses.delete(agentId);
150
215
  this.activityCallbacks.delete(agentId);
216
+ // tmux mode: kill the tmux session and clean up
217
+ if (activeProcess.tmuxSession) {
218
+ killTmuxSession(agentId);
219
+ this.callbacks.onComplete(agentId, false);
220
+ return;
221
+ }
222
+ // Normal pipe mode
151
223
  if (pid) {
152
224
  try {
153
225
  process.kill(-pid, 'SIGINT');
@@ -1,38 +1,51 @@
1
1
  import { saveRunningProcesses, loadRunningProcesses, isProcessRunning, clearRunningProcesses, } from '../../data/index.js';
2
2
  import * as agentService from '../../services/agent-service.js';
3
3
  import { createLogger } from '../../utils/logger.js';
4
+ import { hasTmuxSession, isTmuxEnabled, tmuxLogPath } from './tmux-helper.js';
4
5
  const log = createLogger('Runner');
5
6
  export class RunnerRecoveryStore {
6
7
  backend;
7
8
  activeProcesses;
8
9
  run;
10
+ reconnectTmux;
9
11
  constructor(deps) {
10
12
  this.backend = deps.backend;
11
13
  this.activeProcesses = deps.activeProcesses;
12
14
  this.run = deps.run;
15
+ this.reconnectTmux = deps.reconnectTmux;
13
16
  }
14
17
  persistRunningProcesses() {
15
- const processes = [];
18
+ const myProcesses = [];
16
19
  for (const [agentId, activeProcess] of this.activeProcesses) {
17
- if (activeProcess.process.pid) {
20
+ const hasPid = !!activeProcess.process.pid;
21
+ const hasTmux = !!activeProcess.tmuxSession;
22
+ if (hasPid || hasTmux) {
18
23
  const agent = agentService.getAgent(agentId);
19
- processes.push({
24
+ myProcesses.push({
20
25
  agentId,
21
- pid: activeProcess.process.pid,
26
+ pid: activeProcess.process.pid ?? 0,
22
27
  sessionId: activeProcess.sessionId,
23
28
  startTime: activeProcess.startTime,
24
29
  outputFile: activeProcess.outputFile,
25
30
  stderrFile: activeProcess.stderrFile,
26
31
  lastRequest: activeProcess.lastRequest,
27
32
  agentStatus: agent?.status,
33
+ tmuxSession: activeProcess.tmuxSession,
34
+ tmuxLogOffset: activeProcess.tmuxTailer?.getOffset(),
35
+ provider: this.backend.name,
28
36
  });
29
37
  }
30
38
  }
31
- if (processes.length > 0) {
32
- saveRunningProcesses(processes);
33
- return;
39
+ // Multiple runners (claude, codex, opencode) share the same persist file.
40
+ // Merge: keep entries from OTHER providers, replace entries from OUR provider.
41
+ const existing = loadRunningProcesses();
42
+ const otherProviders = existing.filter((p) => p.provider && p.provider !== this.backend.name);
43
+ const merged = [...otherProviders, ...myProcesses];
44
+ if (merged.length > 0) {
45
+ saveRunningProcesses(merged);
34
46
  }
35
- clearRunningProcesses();
47
+ // NOTE: Do NOT call clearRunningProcesses() when the merged list is empty.
48
+ // The file is cleaned up by recoverOrphanedProcesses() on startup instead.
36
49
  }
37
50
  clearPersistedProcesses() {
38
51
  clearRunningProcesses();
@@ -42,9 +55,32 @@ export class RunnerRecoveryStore {
42
55
  if (savedProcesses.length === 0) {
43
56
  return;
44
57
  }
45
- log.log(`🔍 Checking ${savedProcesses.length} processes from previous commander instance...`);
58
+ log.log(`🔍 [${this.backend.name}] Checking ${savedProcesses.length} processes from previous commander instance...`);
46
59
  const toResume = [];
60
+ const toReconnectTmux = [];
47
61
  for (const savedProcess of savedProcesses) {
62
+ // Only recover processes that belong to this runner's provider.
63
+ // Each provider (claude, codex, opencode) has its own runner and event parser.
64
+ // If provider is missing (old persist format / stale .bak), look up the agent's actual provider.
65
+ const effectiveProvider = savedProcess.provider ?? agentService.getAgent(savedProcess.agentId)?.provider ?? 'claude';
66
+ if (effectiveProvider !== this.backend.name) {
67
+ continue;
68
+ }
69
+ // Skip agents already tracked (another runner may have recovered them)
70
+ if (this.activeProcesses.has(savedProcess.agentId)) {
71
+ continue;
72
+ }
73
+ // tmux mode: check if the tmux session is still alive
74
+ if (savedProcess.tmuxSession && isTmuxEnabled()) {
75
+ if (hasTmuxSession(savedProcess.agentId)) {
76
+ log.log(`✅ [TMUX] Found live tmux session for agent ${savedProcess.agentId} (${savedProcess.tmuxSession}) - will reconnect`);
77
+ toReconnectTmux.push(savedProcess);
78
+ }
79
+ else {
80
+ log.log(`❌ [TMUX] tmux session ${savedProcess.tmuxSession} for agent ${savedProcess.agentId} no longer exists`);
81
+ }
82
+ continue;
83
+ }
48
84
  if (isProcessRunning(savedProcess.pid)) {
49
85
  log.log(`✅ Found orphaned process for agent ${savedProcess.agentId} (PID ${savedProcess.pid}) - still running`);
50
86
  continue;
@@ -61,7 +97,34 @@ export class RunnerRecoveryStore {
61
97
  toResume.push(savedProcess);
62
98
  }
63
99
  }
64
- clearRunningProcesses();
100
+ // Only clear THIS provider's entries from the persist file, not the whole file.
101
+ // Other runners haven't recovered yet and still need their entries.
102
+ const remaining = savedProcesses.filter((p) => {
103
+ const prov = p.provider ?? agentService.getAgent(p.agentId)?.provider ?? 'claude';
104
+ return prov !== this.backend.name;
105
+ });
106
+ if (remaining.length > 0) {
107
+ saveRunningProcesses(remaining);
108
+ }
109
+ else {
110
+ clearRunningProcesses();
111
+ }
112
+ // Reconnect to live tmux sessions (just resume log tailing)
113
+ if (toReconnectTmux.length > 0 && this.reconnectTmux) {
114
+ setTimeout(() => {
115
+ for (const saved of toReconnectTmux) {
116
+ const agent = agentService.getAgent(saved.agentId);
117
+ if (!agent) {
118
+ log.log(`🔄 [TMUX] Skipping reconnect for agent ${saved.agentId} - agent no longer exists`);
119
+ continue;
120
+ }
121
+ const logFile = tmuxLogPath(saved.agentId);
122
+ const offset = saved.tmuxLogOffset ?? 0;
123
+ log.log(`🔄 [TMUX] Reconnecting to tmux session for agent ${saved.agentId} at offset ${offset}`);
124
+ this.reconnectTmux(saved.agentId, logFile, offset, saved);
125
+ }
126
+ }, 1000);
127
+ }
65
128
  if (toResume.length === 0) {
66
129
  return;
67
130
  }
@@ -38,6 +38,15 @@ export class RunnerRestartPolicy {
38
38
  log.log(`🔄 [AUTO-RESTART] Process ${agentId} was stopped intentionally (${signal}), not restarting`);
39
39
  return;
40
40
  }
41
+ // If the process had completed its turn and was waiting for new input,
42
+ // its exit is a normal completion — not a crash. This is especially
43
+ // important for backends like Codex that exit after each task rather
44
+ // than staying resident for stdin-based follow-ups.
45
+ if (activeProcess.turnState === 'waiting_for_input') {
46
+ log.log(`🔄 [AUTO-RESTART] Process ${agentId} exited after completing its turn (turnState=waiting_for_input), not restarting`);
47
+ this.callbacks.onComplete(agentId, true);
48
+ return;
49
+ }
41
50
  const restartCount = activeProcess.restartCount || 0;
42
51
  const lastRestartTime = activeProcess.lastRestartTime || 0;
43
52
  const timeSinceLastRestart = Date.now() - lastRestartTime;
@@ -1,5 +1,6 @@
1
1
  import { StringDecoder } from 'string_decoder';
2
2
  import { createLogger } from '../../utils/logger.js';
3
+ import { createFileTailer } from './tmux-helper.js';
3
4
  const log = createLogger('Runner');
4
5
  export class RunnerStdoutPipeline {
5
6
  backend;
@@ -53,6 +54,20 @@ export class RunnerStdoutPipeline {
53
54
  });
54
55
  });
55
56
  }
57
+ /**
58
+ * tmux mode: tail a log file instead of reading process.stdout.
59
+ * Lines are processed identically to the pipe-based path.
60
+ */
61
+ handleTmuxLog(agentId, logFile, startOffset) {
62
+ const tailer = createFileTailer(logFile, (line) => {
63
+ this.processLine(agentId, line);
64
+ });
65
+ if (startOffset !== undefined) {
66
+ tailer.setOffset(startOffset);
67
+ }
68
+ tailer.start();
69
+ return tailer;
70
+ }
56
71
  processLine(agentId, line) {
57
72
  try {
58
73
  const rawEvent = JSON.parse(line);
@@ -86,12 +101,17 @@ export class RunnerStdoutPipeline {
86
101
  }
87
102
  }
88
103
  handleEvent(agentId, event) {
89
- // After notification is sent, suppress ALL further events from the agentic loop.
104
+ // After notification is sent, suppress output-producing events from the agentic loop.
90
105
  // This prevents status flickering (working → idle → working) caused by OpenCode
91
106
  // giving the model additional turns after the completion notification.
92
- // Only 'init' events (new user message) can reset this gate.
93
- if (this.notificationSent.has(agentId) && event.type !== 'init') {
94
- return;
107
+ // Allow through: init (resets gate), step_complete (needed for idle transition),
108
+ // usage_snapshot (context tracking), error (always important).
109
+ if (this.notificationSent.has(agentId)) {
110
+ const passthrough = event.type === 'init' || event.type === 'step_complete'
111
+ || event.type === 'usage_snapshot' || event.type === 'error' || event.type === 'compacting';
112
+ if (!passthrough) {
113
+ return;
114
+ }
95
115
  }
96
116
  const now = Date.now();
97
117
  this.bus.emit({ type: 'runner.activity', agentId, timestamp: now });
@@ -194,6 +214,10 @@ export class RunnerStdoutPipeline {
194
214
  this.callbacks.onOutput(agentId, event.contextStatsRaw, false, undefined, event.uuid);
195
215
  }
196
216
  break;
217
+ case 'compacting':
218
+ // Emit as output so runtime-listeners can broadcast it to clients
219
+ this.callbacks.onOutput(agentId, '[System] Compacting context...', false, undefined, event.uuid);
220
+ break;
197
221
  default:
198
222
  break;
199
223
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * tmux-based process persistence for CLI agent processes.
3
+ *
4
+ * When TIDE_USE_TMUX=1 (or "true"), agent CLI processes are spawned inside
5
+ * tmux sessions so that stdin/stdout survive server restarts. The tmux
6
+ * server keeps the process alive; we reconnect by tailing a per-agent log
7
+ * file rather than relying on Node.js pipe file descriptors.
8
+ *
9
+ * Default: OFF — the existing pipe-based behaviour is unchanged.
10
+ */
11
+ import { execSync, spawn } from 'child_process';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import { createLogger } from '../../utils/logger.js';
16
+ import { isTmuxModeEnabled } from '../../services/system-prompt-service.js';
17
+ const log = createLogger('Tmux');
18
+ // ---------------------------------------------------------------------------
19
+ // Public helpers
20
+ // ---------------------------------------------------------------------------
21
+ /** Returns true when the user opted in (via Settings) AND tmux is available. */
22
+ export function isTmuxEnabled() {
23
+ if (!isTmuxModeEnabled()) {
24
+ return false;
25
+ }
26
+ return isTmuxInstalled();
27
+ }
28
+ /** Warn once at startup if the setting is on but tmux is missing. */
29
+ let warnedMissing = false;
30
+ export function checkTmuxAvailability() {
31
+ if (!isTmuxModeEnabled()) {
32
+ return;
33
+ }
34
+ if (!isTmuxInstalled() && !warnedMissing) {
35
+ warnedMissing = true;
36
+ log.error('Tmux mode is enabled in settings but tmux is not installed — falling back to pipe-based mode');
37
+ }
38
+ }
39
+ /** Canonical tmux session name for an agent. */
40
+ export function tmuxSessionName(agentId) {
41
+ return `tc-${agentId}`;
42
+ }
43
+ /** Canonical log-file path for an agent's stdout. */
44
+ export function tmuxLogPath(agentId) {
45
+ return path.join(os.tmpdir(), `tc-agent-${agentId}.log`);
46
+ }
47
+ /**
48
+ * Spawn a CLI executable inside a tmux session.
49
+ *
50
+ * Stdout is redirected to a log file **inside the shell command** so we get
51
+ * clean, raw JSON output (no terminal escape codes or line wrapping).
52
+ * Stdin still comes from the tmux pane (via send-keys / paste-buffer).
53
+ *
54
+ * Returns a short-lived ChildProcess (the tmux launcher), the session name,
55
+ * and the log file path.
56
+ */
57
+ export function spawnInTmux(executable, args, options) {
58
+ const sessionName = tmuxSessionName(options.agentId);
59
+ const logFile = tmuxLogPath(options.agentId);
60
+ const stderrFile = `${logFile}.stderr`;
61
+ // Ensure the log file exists (truncate if leftover from a previous run)
62
+ fs.writeFileSync(logFile, '');
63
+ fs.writeFileSync(stderrFile, '');
64
+ // Kill any stale session with the same name (ignore errors)
65
+ try {
66
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
67
+ }
68
+ catch {
69
+ // no existing session — that's fine
70
+ }
71
+ // Build the full command string for tmux to run.
72
+ // Redirect stdout to the log file so we get clean JSON (no ANSI escapes).
73
+ // Stderr goes to its own file for debugging.
74
+ // Stdin remains connected to the tmux pane for send-keys input.
75
+ const escapedArgs = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
76
+ let fullCmd;
77
+ if (options.initialStdin) {
78
+ const initialStdinFile = path.join(os.tmpdir(), `tc-initial-${options.agentId}.tmp`);
79
+ fs.writeFileSync(initialStdinFile, options.initialStdin + '\n');
80
+ if (options.closeStdinAfterPrompt) {
81
+ // Pipe the prompt then close stdin (EOF). For one-shot backends
82
+ // like opencode that need EOF to start processing.
83
+ fullCmd = `cat '${initialStdinFile}' | ${executable} ${escapedArgs} > '${logFile}' 2> '${stderrFile}'`;
84
+ }
85
+ else {
86
+ // Pipe the prompt then keep stdin open via the tmux pane pty.
87
+ // The second `cat` reads from the pane so sendToTmux() still works.
88
+ fullCmd = `(cat '${initialStdinFile}'; cat) | ${executable} ${escapedArgs} > '${logFile}' 2> '${stderrFile}'`;
89
+ }
90
+ }
91
+ else {
92
+ fullCmd = `${executable} ${escapedArgs} > '${logFile}' 2> '${stderrFile}'`;
93
+ }
94
+ // Spawn the tmux session
95
+ const launcherProcess = spawn('tmux', [
96
+ 'new-session',
97
+ '-d', // detached
98
+ '-s', sessionName, // session name
99
+ '-x', '200', // width
100
+ '-y', '50', // height
101
+ '--', 'sh', '-c', fullCmd,
102
+ ], {
103
+ cwd: options.cwd,
104
+ env: options.env,
105
+ detached: true,
106
+ stdio: ['ignore', 'ignore', 'ignore'],
107
+ });
108
+ launcherProcess.unref();
109
+ log.log(`Spawned tmux session ${sessionName}: ${executable} ${escapedArgs} (stdout -> ${logFile})`);
110
+ return { launcherProcess, sessionName, logFile };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Sending input
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Send text to a tmux session's active pane via `send-keys`.
117
+ * Returns true on success.
118
+ */
119
+ export function sendToTmux(agentId, text) {
120
+ const sessionName = tmuxSessionName(agentId);
121
+ try {
122
+ // Write the text to a temp file and use load-buffer + paste-buffer
123
+ // to avoid shell escaping issues with send-keys
124
+ const tmpFile = path.join(os.tmpdir(), `tc-stdin-${agentId}.tmp`);
125
+ fs.writeFileSync(tmpFile, text + '\n');
126
+ execSync(`tmux load-buffer -b tc-input ${tmpFile} && tmux paste-buffer -b tc-input -t ${sessionName} -d`, { stdio: 'ignore', timeout: 5000 });
127
+ fs.unlinkSync(tmpFile);
128
+ return true;
129
+ }
130
+ catch (err) {
131
+ log.error(`Failed to send input to tmux session ${sessionName}:`, err);
132
+ return false;
133
+ }
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Session management
137
+ // ---------------------------------------------------------------------------
138
+ /** Check whether a tmux session exists. */
139
+ export function hasTmuxSession(agentId) {
140
+ const sessionName = tmuxSessionName(agentId);
141
+ try {
142
+ execSync(`tmux has-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /** Kill a tmux session and clean up its log files. */
150
+ export function killTmuxSession(agentId) {
151
+ const sessionName = tmuxSessionName(agentId);
152
+ const logFile = tmuxLogPath(agentId);
153
+ const stderrFile = `${logFile}.stderr`;
154
+ try {
155
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
156
+ log.log(`Killed tmux session ${sessionName}`);
157
+ }
158
+ catch {
159
+ // already gone
160
+ }
161
+ for (const f of [logFile, stderrFile]) {
162
+ try {
163
+ if (fs.existsSync(f)) {
164
+ fs.unlinkSync(f);
165
+ }
166
+ }
167
+ catch {
168
+ // ignore cleanup errors
169
+ }
170
+ }
171
+ }
172
+ /** Send SIGINT to the process inside a tmux session. */
173
+ export function interruptTmuxSession(agentId) {
174
+ const sessionName = tmuxSessionName(agentId);
175
+ try {
176
+ execSync(`tmux send-keys -t ${sessionName} C-c`, { stdio: 'ignore', timeout: 5000 });
177
+ return true;
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
183
+ /**
184
+ * Create a file tailer that reads new lines appended to a log file.
185
+ * Uses `fs.watchFile` (polling) for reliability with pipe-pane output.
186
+ */
187
+ export function createFileTailer(logFile, onLine) {
188
+ let offset = 0;
189
+ let watching = false;
190
+ let pollInterval = null;
191
+ function readNewData() {
192
+ try {
193
+ const stat = fs.statSync(logFile);
194
+ if (stat.size <= offset)
195
+ return;
196
+ const fd = fs.openSync(logFile, 'r');
197
+ const buf = Buffer.alloc(stat.size - offset);
198
+ fs.readSync(fd, buf, 0, buf.length, offset);
199
+ fs.closeSync(fd);
200
+ offset = stat.size;
201
+ const text = buf.toString('utf8');
202
+ const lines = text.split('\n');
203
+ for (const line of lines) {
204
+ if (line.trim()) {
205
+ onLine(line);
206
+ }
207
+ }
208
+ }
209
+ catch {
210
+ // file may not exist yet or be temporarily unavailable
211
+ }
212
+ }
213
+ return {
214
+ start() {
215
+ if (watching)
216
+ return;
217
+ watching = true;
218
+ // Initial read for anything already in the file
219
+ readNewData();
220
+ // Poll every 100ms for new data
221
+ pollInterval = setInterval(readNewData, 100);
222
+ },
223
+ stop() {
224
+ watching = false;
225
+ if (pollInterval) {
226
+ clearInterval(pollInterval);
227
+ pollInterval = null;
228
+ }
229
+ },
230
+ getOffset() {
231
+ return offset;
232
+ },
233
+ setOffset(newOffset) {
234
+ offset = newOffset;
235
+ },
236
+ };
237
+ }
238
+ /**
239
+ * Get the PID of the process running inside a tmux session's active pane.
240
+ * Returns undefined if the session doesn't exist or the PID can't be determined.
241
+ */
242
+ export function getTmuxPanePid(agentId) {
243
+ const sessionName = tmuxSessionName(agentId);
244
+ try {
245
+ const output = execSync(`tmux list-panes -t ${sessionName} -F '#{pane_pid}'`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
246
+ const pid = parseInt(output.split('\n')[0], 10);
247
+ return Number.isFinite(pid) ? pid : undefined;
248
+ }
249
+ catch {
250
+ return undefined;
251
+ }
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // Internal
255
+ // ---------------------------------------------------------------------------
256
+ function isTmuxInstalled() {
257
+ try {
258
+ execSync('which tmux', { stdio: 'ignore' });
259
+ return true;
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }