tide-commander 1.159.2 → 1.160.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 (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/assets/{BossLogsModal-D7DRbxuN.js → BossLogsModal-COg0RBw_.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-qWqV6cXb.js → BossSpawnModal-Bvkk2uFi.js} +1 -1
  4. package/dist/assets/{ControlsModal-Dx4Sd-zd.js → ControlsModal-BWcWGs2x.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-DnBm69YT.js → DockerLogsModal-C4tJ59Pf.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-egK0IcwH.js → EmbeddedEditor-DiDm5EvF.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-Bi51Jkf8.js → GmailOAuthSetup-GkYs6wUs.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-BAREZ_c-.js → GoogleOAuthSetup-KKwp2gEQ.js} +1 -1
  9. package/dist/assets/{IframeModal-Rh1yC6CF.js → IframeModal-D0iTkxZB.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-BPmG30Kf.js → IntegrationsPanel-CCKoizJa.js} +2 -2
  11. package/dist/assets/{LogViewerModal-V3LGKLOd.js → LogViewerModal-CSGEkyhZ.js} +1 -1
  12. package/dist/assets/{MonitoringModal-D5ObJhXH.js → MonitoringModal-Be6uEd7K.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-DyNAmc5D.js → PM2LogsModal-DZG7Tcw7.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-C1afIOVX.js → RestoreArchivedAreaModal-CigBbBB_.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-C7weuP-e.js → Scene2DCanvas-sJig3vJ4.js} +1 -1
  16. package/dist/assets/{SceneManager-Cub1EW5Y.js → SceneManager-BG_0UVJc.js} +1 -1
  17. package/dist/assets/{SkillsPanel-C3PG0A6A.js → SkillsPanel-BnAf8uyn.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-vq1FipyR.js → SlackMultiInstanceSetup-DGuexHRz.js} +1 -1
  19. package/dist/assets/{SpawnModal-Df2Vp-HA.js → SpawnModal-FjDJZlIm.js} +1 -1
  20. package/dist/assets/{StatisticsModal-D0yo2rbm.js → StatisticsModal-CbbdwSF0.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-BHQS1Xc_.js → SubordinateAssignmentModal-Dn2uuOcT.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-CEVMReTJ.js → TriggerManagerPanel-Cj9pqf-r.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-BQbr0HYZ.js → WorkflowEditorPanel-nucrx_0v.js} +1 -1
  24. package/dist/assets/{area-logos-CbdG7b49.js → area-logos-C93KZk3h.js} +1 -1
  25. package/dist/assets/{index-DN1jWADn.js → index-BkbKu66S.js} +1 -1
  26. package/dist/assets/{index-EAqEvdMn.js → index-CmmI2kZf.js} +2 -2
  27. package/dist/assets/{index-B4PPDHAS.js → index-D7YoMagO.js} +1 -1
  28. package/dist/assets/{index-BvEk2Spw.js → index-DIOhr3im.js} +3 -3
  29. package/dist/assets/{index-Vee1Cpl9.js → index-DqM0QltS.js} +1 -1
  30. package/dist/assets/{index--FFJ9DPS.js → index-Gjc7NXlZ.js} +1 -1
  31. package/dist/assets/{index-fFR_58oB.js → index-fh53j_4E.js} +1 -1
  32. package/dist/assets/{index-C57r58R1.js → index-iX9jPnEp.js} +1 -1
  33. package/dist/assets/{index-CxUhEi9L.js → index-ojmo-Cva.js} +1 -1
  34. package/dist/assets/{main-Btdl34Y5.js → main-BFW0rGXU.js} +8 -8
  35. package/dist/assets/{web-Brq808vy.js → web-8gl2noUE.js} +1 -1
  36. package/dist/assets/{web-CACmzQOn.js → web-D0uiHhnA.js} +1 -1
  37. package/dist/assets/{web-DPUDmaxp.js → web-M9cLwTrs.js} +1 -1
  38. package/dist/build-info.json +1 -1
  39. package/dist/index.html +1 -1
  40. package/dist/src/packages/server/claude/runner/tmux-helper.js +80 -18
  41. package/dist/src/packages/server/codex/app-server/codex-app-server-runner.js +19 -4
  42. package/dist/src/packages/server/runtime/codex-runner-router.js +5 -1
  43. package/dist/src/packages/server/websocket/handlers/agent-handler.js +5 -6
  44. package/package.json +1 -1
@@ -1 +1 @@
1
- import{df as a}from"./main-Btdl34Y5.js";import{ImpactStyle as i,NotificationType as r}from"./index-EAqEvdMn.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{df as a}from"./main-BFW0rGXU.js";import{ImpactStyle as i,NotificationType as r}from"./index-CmmI2kZf.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{df as s}from"./main-Btdl34Y5.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{df as s}from"./main-BFW0rGXU.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 +1 @@
1
- import{df as t}from"./main-Btdl34Y5.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{df as t}from"./main-BFW0rGXU.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
- {"version":"1.159.2"}
1
+ {"version":"1.160.0"}
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
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-Btdl34Y5.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-BFW0rGXU.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
28
  <link rel="stylesheet" crossorigin href="/assets/main-BZcOdtO_.css">
@@ -17,6 +17,35 @@ import { createLogger } from '../../utils/logger.js';
17
17
  import { isTmuxModeEnabled } from '../../services/system-prompt-service.js';
18
18
  const log = createLogger('Tmux');
19
19
  // ---------------------------------------------------------------------------
20
+ // Environment sanitisation
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Strip the caller's tmux client vars before invoking tmux.
24
+ *
25
+ * If the commander itself was launched from inside a tmux pane (e.g. pm2 was
26
+ * started from an agent's own tmux session, and `pm2 save` then baked that env
27
+ * into the process definition), we inherit `TMUX=<socket>,<pid>,<pane>`.
28
+ * tmux takes its socket path from `$TMUX` when set — and, unlike the default
29
+ * path, it will NOT create the parent directory for it. After a reboot wipes
30
+ * /tmp, that socket dir is gone, so every `tmux new-session` fails with
31
+ * "error creating /tmp/tmux-1000/default (No such file or directory)"
32
+ * ...while still exiting 0. The session never starts, the agent's log stays
33
+ * empty, and the failure is completely silent.
34
+ *
35
+ * Dropping TMUX/TMUX_PANE makes tmux compute its own socket path and create
36
+ * the directory as needed, so spawning works regardless of how we were started.
37
+ */
38
+ function withoutTmuxClientVars(env) {
39
+ const clean = { ...env };
40
+ delete clean.TMUX;
41
+ delete clean.TMUX_PANE;
42
+ return clean;
43
+ }
44
+ /** Sanitised copy of the current process env, for tmux control commands. */
45
+ function tmuxEnv() {
46
+ return withoutTmuxClientVars(process.env);
47
+ }
48
+ // ---------------------------------------------------------------------------
20
49
  // Public helpers
21
50
  // ---------------------------------------------------------------------------
22
51
  /** Returns true when the user opted in (via Settings) AND tmux is available. */
@@ -64,7 +93,7 @@ export function spawnInTmux(executable, args, options) {
64
93
  fs.writeFileSync(stderrFile, '');
65
94
  // Kill any stale session with the same name (ignore errors)
66
95
  try {
67
- execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
96
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
68
97
  }
69
98
  catch {
70
99
  // no existing session — that's fine
@@ -128,14 +157,46 @@ export function spawnInTmux(executable, args, options) {
128
157
  '--', 'sh', '-c', fullCmd,
129
158
  ], {
130
159
  cwd: options.cwd,
131
- env: options.env,
160
+ env: withoutTmuxClientVars(options.env),
132
161
  detached: true,
133
- stdio: ['ignore', 'ignore', 'ignore'],
162
+ // stderr is piped, not ignored: tmux reports fatal errors (bad socket
163
+ // dir, unusable session name) on stderr *and still exits 0*. Discarding
164
+ // it turned a failed spawn into a silent no-op that only surfaced later
165
+ // as a bogus "process died" from the watchdog.
166
+ stdio: ['ignore', 'ignore', 'pipe'],
134
167
  });
135
168
  launcherProcess.unref();
169
+ watchLauncherForFailure(launcherProcess, sessionName);
136
170
  log.log(`Spawned tmux session ${sessionName}: ${executable} ${escapedArgs} (stdout -> ${logFile})`);
137
171
  return { launcherProcess, sessionName, logFile };
138
172
  }
173
+ /**
174
+ * Surface a failed `tmux new-session`. tmux exits 0 even when it could not
175
+ * create the session, so the exit code alone tells us nothing — anything on
176
+ * stderr means the session did not start.
177
+ *
178
+ * Re-emits as an 'error' on the launcher so callers already listening for
179
+ * spawn failures report it, instead of waiting for the watchdog to misdiagnose
180
+ * the missing session as a crashed agent.
181
+ */
182
+ function watchLauncherForFailure(launcherProcess, sessionName) {
183
+ let stderr = '';
184
+ launcherProcess.stderr?.on('data', (chunk) => {
185
+ stderr += String(chunk);
186
+ });
187
+ launcherProcess.on('exit', (code) => {
188
+ const message = stderr.trim();
189
+ if (!message && code === 0) {
190
+ return;
191
+ }
192
+ const detail = message || `tmux exited with code ${code}`;
193
+ log.error(`Failed to create tmux session ${sessionName}: ${detail}`);
194
+ // Only emit if someone is listening — an unhandled 'error' event throws.
195
+ if (launcherProcess.listenerCount('error') > 0) {
196
+ launcherProcess.emit('error', new Error(`tmux new-session failed: ${detail}`));
197
+ }
198
+ });
199
+ }
139
200
  // ---------------------------------------------------------------------------
140
201
  // Sending input
141
202
  // ---------------------------------------------------------------------------
@@ -155,7 +216,7 @@ export function sendToTmux(agentId, text) {
155
216
  const tmpFile = path.join(os.tmpdir(), `tc-stdin-${agentId}.tmp`);
156
217
  const bufferName = `tc-input-${agentId}`;
157
218
  fs.writeFileSync(tmpFile, text + '\n');
158
- execSync(`tmux load-buffer -b ${bufferName} ${tmpFile} && tmux paste-buffer -b ${bufferName} -t ${sessionName} -r -d`, { stdio: 'ignore', timeout: 5000 });
219
+ execSync(`tmux load-buffer -b ${bufferName} ${tmpFile} && tmux paste-buffer -b ${bufferName} -t ${sessionName} -r -d`, { stdio: 'ignore', timeout: 5000, env: tmuxEnv() });
159
220
  fs.unlinkSync(tmpFile);
160
221
  return true;
161
222
  }
@@ -171,7 +232,7 @@ export function sendToTmux(agentId, text) {
171
232
  export function hasTmuxSession(agentId) {
172
233
  const sessionName = tmuxSessionName(agentId);
173
234
  try {
174
- execSync(`tmux has-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
235
+ execSync(`tmux has-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
175
236
  return true;
176
237
  }
177
238
  catch {
@@ -184,7 +245,7 @@ export function killTmuxSession(agentId) {
184
245
  const logFile = tmuxLogPath(agentId);
185
246
  const stderrFile = `${logFile}.stderr`;
186
247
  try {
187
- execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
248
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
188
249
  log.log(`Killed tmux session ${sessionName}`);
189
250
  }
190
251
  catch {
@@ -208,7 +269,7 @@ export function killTmuxSession(agentId) {
208
269
  */
209
270
  export function listAgentTmuxSessions() {
210
271
  try {
211
- const out = execSync(`tmux list-sessions -F '#{session_name}' 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
272
+ const out = execSync(`tmux list-sessions -F '#{session_name}' 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: tmuxEnv() });
212
273
  return out
213
274
  .split('\n')
214
275
  .map((s) => s.trim())
@@ -226,7 +287,7 @@ export function listAgentTmuxSessions() {
226
287
  export function interruptTmuxSession(agentId) {
227
288
  const sessionName = tmuxSessionName(agentId);
228
289
  try {
229
- execSync(`tmux send-keys -t ${sessionName} C-c`, { stdio: 'ignore', timeout: 5000 });
290
+ execSync(`tmux send-keys -t ${sessionName} C-c`, { stdio: 'ignore', timeout: 5000, env: tmuxEnv() });
230
291
  return true;
231
292
  }
232
293
  catch {
@@ -351,7 +412,7 @@ export function createFileTailer(logFile, onLine) {
351
412
  export function getTmuxPanePid(agentId) {
352
413
  const sessionName = tmuxSessionName(agentId);
353
414
  try {
354
- const output = execSync(`tmux list-panes -t ${sessionName} -F '#{pane_pid}'`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
415
+ const output = execSync(`tmux list-panes -t ${sessionName} -F '#{pane_pid}'`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: tmuxEnv() }).trim();
355
416
  const pid = parseInt(output.split('\n')[0], 10);
356
417
  return Number.isFinite(pid) ? pid : undefined;
357
418
  }
@@ -438,7 +499,7 @@ export function spawnInTmuxInteractive(executable, args, options) {
438
499
  const sessionName = interactiveTmuxSessionName(options.agentId);
439
500
  // Kill any stale session with the same name (ignore errors)
440
501
  try {
441
- execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
502
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
442
503
  }
443
504
  catch {
444
505
  // no existing session — that's fine
@@ -462,11 +523,12 @@ export function spawnInTmuxInteractive(executable, args, options) {
462
523
  '--', executable, ...args,
463
524
  ], {
464
525
  cwd: options.cwd,
465
- env: options.env,
526
+ env: withoutTmuxClientVars(options.env),
466
527
  detached: true,
467
- stdio: ['ignore', 'ignore', 'ignore'],
528
+ stdio: ['ignore', 'ignore', 'pipe'],
468
529
  });
469
530
  launcherProcess.unref();
531
+ watchLauncherForFailure(launcherProcess, sessionName);
470
532
  log.log(`Spawned interactive tmux session ${sessionName}: ${executable} ${args.join(' ')}`);
471
533
  return { launcherProcess, sessionName };
472
534
  }
@@ -489,7 +551,7 @@ export function sendPromptToTmuxInteractive(agentId, text) {
489
551
  // bracketed-paste mode before the submit keypress.
490
552
  execSync(`tmux load-buffer -b ${bufferName} ${tmpFile} && ` +
491
553
  `tmux paste-buffer -b ${bufferName} -t ${sessionName} -p -r -d && ` +
492
- `sleep 0.15 && tmux send-keys -t ${sessionName} Enter`, { stdio: 'ignore', timeout: 8000 });
554
+ `sleep 0.15 && tmux send-keys -t ${sessionName} Enter`, { stdio: 'ignore', timeout: 8000, env: tmuxEnv() });
493
555
  try {
494
556
  fs.unlinkSync(tmpFile);
495
557
  }
@@ -509,7 +571,7 @@ export function sendPromptToTmuxInteractive(agentId, text) {
509
571
  export function hasInteractiveTmuxSession(agentId) {
510
572
  const sessionName = interactiveTmuxSessionName(agentId);
511
573
  try {
512
- execSync(`tmux has-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
574
+ execSync(`tmux has-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
513
575
  return true;
514
576
  }
515
577
  catch {
@@ -520,7 +582,7 @@ export function hasInteractiveTmuxSession(agentId) {
520
582
  export function killInteractiveTmuxSession(agentId) {
521
583
  const sessionName = interactiveTmuxSessionName(agentId);
522
584
  try {
523
- execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore' });
585
+ execSync(`tmux kill-session -t ${sessionName} 2>/dev/null`, { stdio: 'ignore', env: tmuxEnv() });
524
586
  log.log(`Killed interactive tmux session ${sessionName}`);
525
587
  }
526
588
  catch {
@@ -531,7 +593,7 @@ export function killInteractiveTmuxSession(agentId) {
531
593
  export function interruptInteractiveTmuxSession(agentId) {
532
594
  const sessionName = interactiveTmuxSessionName(agentId);
533
595
  try {
534
- execSync(`tmux send-keys -t ${sessionName} Escape`, { stdio: 'ignore', timeout: 5000 });
596
+ execSync(`tmux send-keys -t ${sessionName} Escape`, { stdio: 'ignore', timeout: 5000, env: tmuxEnv() });
535
597
  return true;
536
598
  }
537
599
  catch {
@@ -544,7 +606,7 @@ export function interruptInteractiveTmuxSession(agentId) {
544
606
  */
545
607
  export function listInteractiveTmuxSessions() {
546
608
  try {
547
- const out = execSync(`tmux list-sessions -F '#{session_name}' 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
609
+ const out = execSync(`tmux list-sessions -F '#{session_name}' 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: tmuxEnv() });
548
610
  return out
549
611
  .split('\n')
550
612
  .map((s) => s.trim())
@@ -559,7 +621,7 @@ export function listInteractiveTmuxSessions() {
559
621
  export function getInteractiveTmuxPanePid(agentId) {
560
622
  const sessionName = interactiveTmuxSessionName(agentId);
561
623
  try {
562
- const output = execSync(`tmux list-panes -t ${sessionName} -F '#{pane_pid}'`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
624
+ const output = execSync(`tmux list-panes -t ${sessionName} -F '#{pane_pid}'`, { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: tmuxEnv() }).trim();
563
625
  const pid = parseInt(output.split('\n')[0], 10);
564
626
  return Number.isFinite(pid) ? pid : undefined;
565
627
  }
@@ -233,7 +233,10 @@ export class CodexAppServerRunner {
233
233
  };
234
234
  this.agents.set(agentId, state);
235
235
  this.threadToAgent.set(threadId, agentId);
236
- agentService.updateAgent(agentId, { sessionId: threadId });
236
+ agentService.updateAgent(agentId, {
237
+ sessionId: threadId,
238
+ ...(request.forkSession ? { forkSourceSessionId: undefined } : {}),
239
+ });
237
240
  this.callbacks.onSessionId(agentId, threadId);
238
241
  this.pipeline.emitStandardEvent(agentId, {
239
242
  type: 'init',
@@ -261,17 +264,29 @@ export class CodexAppServerRunner {
261
264
  baseParams.config = { model_reasoning_effort: effort };
262
265
  try {
263
266
  if (!wantNew && request.sessionId) {
264
- const result = (await proc.request('thread/resume', {
267
+ const method = request.forkSession ? 'thread/fork' : 'thread/resume';
268
+ const result = (await proc.request(method, {
265
269
  ...baseParams,
266
270
  threadId: request.sessionId,
267
271
  }));
268
- const id = result?.thread?.id || request.sessionId;
269
- log.log(`🔄 Resumed thread ${id.slice(0, 8)} for agent ${agentId.slice(0, 8)}`);
272
+ // A fork must always return its own id. Falling back to the source id
273
+ // would make both agents write to the same conversation.
274
+ const id = result?.thread?.id || (request.forkSession ? undefined : request.sessionId);
275
+ if (!id)
276
+ throw new Error(`${method} returned no thread id`);
277
+ log.log(`${request.forkSession ? '🌿 Forked' : '🔄 Resumed'} thread ${id.slice(0, 8)} for agent ${agentId.slice(0, 8)}`);
270
278
  return id;
271
279
  }
272
280
  }
273
281
  catch (err) {
274
282
  const msg = err instanceof Error ? err.message : String(err);
283
+ if (request.forkSession) {
284
+ // Do not silently create an empty thread: keep forkSourceSessionId in
285
+ // place so the user can retry after upgrading/fixing Codex.
286
+ log.error(`thread/fork failed for ${agentId.slice(0, 8)}: ${msg}`);
287
+ this.callbacks.onError(agentId, `Codex session fork failed: ${msg}`);
288
+ return null;
289
+ }
275
290
  log.warn(`thread/resume failed for ${agentId.slice(0, 8)} (${msg}) — starting a fresh thread`);
276
291
  }
277
292
  try {
@@ -48,7 +48,11 @@ export class CodexRunnerRouter {
48
48
  this.appServer.start?.();
49
49
  }
50
50
  async run(request) {
51
- const mode = this.currentMode();
51
+ // `codex exec` can resume threads but has no headless fork command. The
52
+ // app-server does (`thread/fork`), so route the first turn of a fork through
53
+ // it regardless of the streaming preference. Later turns may switch back
54
+ // to exec and resume the newly-created thread normally.
55
+ const mode = request.forkSession ? 'app-server' : this.currentMode();
52
56
  const prev = this.ownerOf(request.agentId);
53
57
  if (prev && prev !== mode) {
54
58
  log.log(`Agent ${request.agentId.slice(0, 8)} switching ${prev} → ${mode}; stopping old runner first`);
@@ -135,10 +135,9 @@ async function duplicateAgentConfig(source, name, position) {
135
135
  function duplicateOffset(source) {
136
136
  return { x: source.position.x + 2, y: 0, z: source.position.z + 2 };
137
137
  }
138
- // Providers whose conversation history can be forked (resume + native fork flag).
139
- // Codex is intentionally excluded for now (no headless fork; would need rollout
140
- // duplication) forking a Codex agent gracefully degrades to a plain clone.
141
- const FORKABLE_PROVIDERS = new Set(['claude', 'opencode', 'grok']);
138
+ // Providers whose conversation history can be forked. Codex forks through the
139
+ // app-server `thread/fork` method even when regular turns use `codex exec`.
140
+ const FORKABLE_PROVIDERS = new Set(['claude', 'codex', 'opencode', 'grok']);
142
141
  /**
143
142
  * Handle clone_agent message - duplicates an existing agent's configuration
144
143
  * into a brand-new agent (fresh session, no inherited context/memory).
@@ -168,9 +167,9 @@ export async function handleCloneAgent(ctx, payload) {
168
167
  * Handle fork_agent message - like clone, but the new agent also CONTINUES the
169
168
  * source's conversation history. On the new agent's first run the backend
170
169
  * resumes the source session and forks it into a fresh one (Claude
171
- * --fork-session / OpenCode --fork); the new session id is then captured and
170
+ * --fork-session / Codex thread/fork / OpenCode --fork); the new session id is then captured and
172
171
  * forkSourceSessionId is cleared. If the source has no session or its provider
173
- * isn't forkable (e.g. Codex), this degrades to a plain clone (fresh session).
172
+ * isn't forkable, this degrades to a plain clone (fresh session).
174
173
  */
175
174
  export async function handleForkAgent(ctx, payload) {
176
175
  const source = agentService.getAgent(payload.sourceAgentId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.159.2",
3
+ "version": "1.160.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",