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.
- package/dist/assets/{BossLogsModal-CaDrNfm1.js → BossLogsModal-CehuxNwV.js} +1 -1
- package/dist/assets/{BossSpawnModal-DLJInoVf.js → BossSpawnModal-Cohl0WaH.js} +1 -1
- package/dist/assets/{ControlsModal-Cnnhwfr_.js → ControlsModal-CAVkg9mM.js} +1 -1
- package/dist/assets/{DockerLogsModal-D4CbWK3-.js → DockerLogsModal-BXv9Hzg-.js} +1 -1
- package/dist/assets/{EmbeddedEditor-36YBGNE2.js → EmbeddedEditor-CTlTVKdD.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-DI5j7wkF.js → GmailOAuthSetup-B1zQWbRR.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-Bsj02mND.js → GoogleOAuthSetup-CmayoZ-D.js} +1 -1
- package/dist/assets/{IframeModal-BJ4oeDBn.js → IframeModal-DlS7y7PK.js} +1 -1
- package/dist/assets/{IntegrationsPanel-DLmIJEUy.js → IntegrationsPanel-BPKoNwZm.js} +2 -2
- package/dist/assets/{LogViewerModal-D7CXMmZf.js → LogViewerModal-Bx9IDiFM.js} +1 -1
- package/dist/assets/{MonitoringModal-PnEKvezF.js → MonitoringModal-DzqEa0fQ.js} +1 -1
- package/dist/assets/{PM2LogsModal-EB_Au0iK.js → PM2LogsModal-DZXPRV5-.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-C-X90Qxe.js → RestoreArchivedAreaModal-BajWqtWn.js} +1 -1
- package/dist/assets/{SaveSnapshotModal-D6XAjL2T.js → SaveSnapshotModal-LqaLezB-.js} +1 -1
- package/dist/assets/{Scene2DCanvas-DlVguRVM.js → Scene2DCanvas-DWDmyh_y.js} +1 -1
- package/dist/assets/{SceneManager-OOaJ_z8v.js → SceneManager-BgkqWsDb.js} +1 -1
- package/dist/assets/{SkillsPanel-D5wo2MHh.js → SkillsPanel-CO-Zt0Tq.js} +1 -1
- package/dist/assets/{SnapshotManager-BLjpctX_.js → SnapshotManager-D4Uzw6Tp.js} +1 -1
- package/dist/assets/{SpawnModal-DQ4uUm1y.js → SpawnModal-BdCgTKpM.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BDUDt6WC.js → SubordinateAssignmentModal-BoaofULj.js} +1 -1
- package/dist/assets/{SupervisorPanel-BZaBI9BA.js → SupervisorPanel-D8Cp77my.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-DuBiclsE.js → TriggerManagerPanel-CFf3Tud5.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-D49yDqth.js → WorkflowEditorPanel-EtzhBZWw.js} +1 -1
- package/dist/assets/{index-DZ3f9mNd.js → index-2YWd58Wh.js} +1 -1
- package/dist/assets/{index-DJdXbeWz.js → index-BKRKKMPQ.js} +3 -3
- package/dist/assets/{index-BwkNteiZ.js → index-BgTRovwT.js} +1 -1
- package/dist/assets/{index-mQmnkGdF.js → index-BiJ_hIit.js} +1 -1
- package/dist/assets/{index-Bxjd0N-j.js → index-CDAtstMx.js} +1 -1
- package/dist/assets/{index-BxbFh16W.js → index-DBpmD8r7.js} +2 -2
- package/dist/assets/{index-BiGvhrCO.js → index-DByGRjfY.js} +1 -1
- package/dist/assets/index-VO6fNr63.js +1 -0
- package/dist/assets/main-BFSqr5mR.css +1 -0
- package/dist/assets/main-BSyGZK2z.js +152 -0
- package/dist/assets/{web-DAqM9uoZ.js → web-1BwWjkKM.js} +1 -1
- package/dist/assets/{web-DQ10iSod.js → web-BXQj42md.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/config.json +1 -0
- package/dist/src/packages/server/claude/backend.js +9 -0
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +79 -7
- package/dist/src/packages/server/claude/runner/recovery-store.js +73 -10
- package/dist/src/packages/server/claude/runner/restart-policy.js +9 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +28 -4
- package/dist/src/packages/server/claude/runner/tmux-helper.js +264 -0
- package/dist/src/packages/server/claude/runner/watchdog.js +27 -0
- package/dist/src/packages/server/claude/runner.js +84 -9
- package/dist/src/packages/server/cli.js +17 -1
- package/dist/src/packages/server/data/builtin-skills/boss-instructions.js +2 -2
- package/dist/src/packages/server/data/index.js +6 -0
- package/dist/src/packages/server/opencode/backend.js +1 -0
- package/dist/src/packages/server/opencode/index.js +1 -0
- package/dist/src/packages/server/routes/agents.js +29 -1
- package/dist/src/packages/server/services/system-prompt-service.js +38 -0
- package/dist/src/packages/server/websocket/handlers/boss-response-handler.js +5 -1
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +19 -1
- package/package.json +1 -1
- package/dist/assets/index-BSl18-4e.js +0 -1
- package/dist/assets/main-CUx2B9bn.css +0 -1
- package/dist/assets/main-DULfVodw.js +0 -152
|
@@ -1 +1 @@
|
|
|
1
|
-
import{bx as s}from"./main-
|
|
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-
|
|
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-
|
|
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-
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/main-BFSqr5mR.css">
|
|
30
30
|
</head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="app"></div>
|
|
@@ -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
|
|
18
|
+
const myProcesses = [];
|
|
16
19
|
for (const [agentId, activeProcess] of this.activeProcesses) {
|
|
17
|
-
|
|
20
|
+
const hasPid = !!activeProcess.process.pid;
|
|
21
|
+
const hasTmux = !!activeProcess.tmuxSession;
|
|
22
|
+
if (hasPid || hasTmux) {
|
|
18
23
|
const agent = agentService.getAgent(agentId);
|
|
19
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|