tide-commander 1.154.0 → 1.156.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 (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/{BossLogsModal-Bo0pUCWU.js → BossLogsModal-CwkpdKon.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BOOHaqaD.js → BossSpawnModal-q2-ZjkYi.js} +1 -1
  4. package/dist/assets/{ControlsModal-DItGV-CW.js → ControlsModal-CihuU69p.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-DjAZ3GL7.js → DockerLogsModal-BFHVU_1p.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-Cihkr8Xb.js → EmbeddedEditor-DZ5bE7j_.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-G1XqiM44.js → GmailOAuthSetup-Dj_nP_GS.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-Ce6gojdm.js → GoogleOAuthSetup-CWs9nRX5.js} +1 -1
  9. package/dist/assets/{IframeModal-CSSZJzPv.js → IframeModal-CfItUAJ8.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-EspPH09n.js → IntegrationsPanel-BVjniF4n.js} +2 -2
  11. package/dist/assets/{LogViewerModal-Bn-HDysM.js → LogViewerModal-B7Jgt6ds.js} +1 -1
  12. package/dist/assets/{MonitoringModal-DuSLTKVR.js → MonitoringModal-CS8nfYl3.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-Bo2OtaQd.js → PM2LogsModal-DmToSbls.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-BOx-V6tl.js → RestoreArchivedAreaModal-DD9YQJ47.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-BIUbi14G.js → Scene2DCanvas-CxLa2T6K.js} +1 -1
  16. package/dist/assets/{SceneManager-CIWrGc-K.js → SceneManager-gVDwrjJj.js} +1 -1
  17. package/dist/assets/{SkillsPanel-_KHtTDhC.js → SkillsPanel-DMmqzmAb.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-DbQ3rUtd.js → SlackMultiInstanceSetup-CvOqPr4x.js} +1 -1
  19. package/dist/assets/{SpawnModal-CYEoOMPW.js → SpawnModal-DwpQZPIm.js} +1 -1
  20. package/dist/assets/{StatisticsModal-BJ-coeis.js → StatisticsModal-DHwmDvMq.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-5Z3jvbM9.js → SubordinateAssignmentModal-vJ3xMaqe.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-DvC0xP9r.js → TriggerManagerPanel-B8iv-tyr.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-6ym1_csr.js → WorkflowEditorPanel-CznY7cLk.js} +1 -1
  24. package/dist/assets/{area-logos-mWPCtCnf.js → area-logos-CkHS76Vd.js} +1 -1
  25. package/dist/assets/{index-DZiNmiyC.js → index-B2XtMSw5.js} +1 -1
  26. package/dist/assets/{index-8VVbTmtb.js → index-BTvXV7CT.js} +1 -1
  27. package/dist/assets/index-BdDicCLy.js +2 -0
  28. package/dist/assets/index-CO38FP9S.js +1 -0
  29. package/dist/assets/{index-DxyZ-rYT.js → index-Ce90LqMC.js} +1 -1
  30. package/dist/assets/{index-CY_NzVPm.js → index-Cout14yl.js} +1 -1
  31. package/dist/assets/{index-CnESn83U.js → index-DpRKadlB.js} +2 -2
  32. package/dist/assets/{index-BPjQZacc.css → index-Ja-ujZ-0.css} +1 -1
  33. package/dist/assets/{index-CiKbhMfN.js → index-krziBkQk.js} +1 -1
  34. package/dist/assets/{index-CT2vtbed.js → index-oQI2DZbz.js} +3 -3
  35. package/dist/assets/{main-BHBs2E1L.css → main-C10O3GdP.css} +1 -1
  36. package/dist/assets/{main-D5j8Ul0m.js → main-Du_kWg5E.js} +10 -10
  37. package/dist/assets/{web-DMxl9AVF.js → web-C9cC59SM.js} +1 -1
  38. package/dist/assets/{web-Cd0-XOkz.js → web-DF-qnU6X.js} +1 -1
  39. package/dist/assets/{web-BvAQVbIF.js → web-tQ-Hwgn-.js} +1 -1
  40. package/dist/build-info.json +1 -1
  41. package/dist/index.html +2 -2
  42. package/dist/locales/en/config.json +1 -0
  43. package/dist/src/packages/server/opencode/json-event-parser.js +3 -1
  44. package/dist/src/packages/server/opencode/server/opencode-server-event-adapter.js +127 -0
  45. package/dist/src/packages/server/opencode/server/opencode-server-process.js +285 -0
  46. package/dist/src/packages/server/opencode/server/opencode-server-recovery-store.js +73 -0
  47. package/dist/src/packages/server/opencode/server/opencode-server-runner.js +384 -0
  48. package/dist/src/packages/server/routes/agents.js +29 -1
  49. package/dist/src/packages/server/runtime/opencode-runner-router.js +93 -0
  50. package/dist/src/packages/server/runtime/opencode-runtime-provider.js +5 -3
  51. package/dist/src/packages/server/services/system-prompt-service.js +49 -1
  52. package/package.json +1 -1
  53. package/dist/assets/index-C34l72MW.js +0 -1
  54. package/dist/assets/index-CS8OutmQ.js +0 -2
@@ -0,0 +1,384 @@
1
+ /**
2
+ * OpencodeServerRunner — experimental runtime that drives OpenCode agents through
3
+ * a persistent, restart-surviving `opencode serve` HTTP server instead of one
4
+ * `opencode run` process per turn.
5
+ *
6
+ * Why: `opencode run --format json` emits one full assistant message (no token
7
+ * deltas), so replies never typewriter. The server streams `message.part.delta`
8
+ * tokens over SSE. And a detached server survives commander restarts, keeping any
9
+ * in-flight turn running; on boot the runner reconnects the SSE stream and
10
+ * OpenCode's persisted sessions resume seamlessly.
11
+ *
12
+ * Downstream reuse: reconstructed StandardEvents feed a shared
13
+ * RunnerStdoutPipeline via `emitStandardEvent` (the OpencodeBackend's
14
+ * `.name === 'opencode'` turns on stream-coalescing), so websocket broadcast,
15
+ * client merge-by-uuid, boss delegation and context tracking are reused unchanged.
16
+ */
17
+ import * as os from 'os';
18
+ import { OpencodeBackend, buildOpencodePrompt } from '../backend.js';
19
+ import { RunnerInternalEventBus } from '../../claude/runner/internal-events.js';
20
+ import { RunnerStdoutPipeline } from '../../claude/runner/stdout-pipeline.js';
21
+ import { OpencodeServerProcess } from './opencode-server-process.js';
22
+ import { OpencodeServerEventAdapter } from './opencode-server-event-adapter.js';
23
+ import { loadOpencodeAgents, saveOpencodeAgents, } from './opencode-server-recovery-store.js';
24
+ import * as agentService from '../../services/agent-service.js';
25
+ import { createLogger } from '../../utils/logger.js';
26
+ import { withAgentContext } from '../../utils/log-context.js';
27
+ const log = createLogger('OpencodeServerRunner');
28
+ const RECOVER_DELAY_MS = 1500;
29
+ export class OpencodeServerRunner {
30
+ callbacks;
31
+ backend = new OpencodeBackend();
32
+ bus = new RunnerInternalEventBus();
33
+ pipeline;
34
+ agents = new Map();
35
+ sessionToAgent = new Map();
36
+ activityCallbacks = new Map();
37
+ process = null;
38
+ started = false;
39
+ recovered = false;
40
+ constructor(callbacks) {
41
+ this.callbacks = callbacks;
42
+ this.pipeline = new RunnerStdoutPipeline({
43
+ backend: this.backend,
44
+ callbacks: this.callbacks,
45
+ bus: this.bus,
46
+ });
47
+ }
48
+ start() {
49
+ if (this.started)
50
+ return;
51
+ this.started = true;
52
+ setTimeout(() => { void this.recover(); }, RECOVER_DELAY_MS);
53
+ log.log('🛡️ OpenCode server runner started (streaming, restart-surviving)');
54
+ }
55
+ ensureProcess(spawnCwd) {
56
+ if (this.process && this.process.isAlive())
57
+ return this.process;
58
+ this.process = new OpencodeServerProcess({
59
+ executablePath: this.backend.getExecutablePath(),
60
+ cwd: spawnCwd || os.homedir(),
61
+ extraEnv: this.backend.getExtraEnv(),
62
+ onEvent: (evt) => this.routeEvent(evt),
63
+ onDisconnect: () => this.handleDisconnect(),
64
+ });
65
+ return this.process;
66
+ }
67
+ async recover() {
68
+ const persisted = loadOpencodeAgents();
69
+ this.recovered = true;
70
+ if (persisted.length === 0) {
71
+ this.persistAgents();
72
+ return;
73
+ }
74
+ const spawnCwd = persisted[0].workingDir || os.homedir();
75
+ const proc = this.ensureProcess(spawnCwd);
76
+ try {
77
+ await proc.ensureStarted();
78
+ }
79
+ catch (err) {
80
+ const msg = err instanceof Error ? err.message : String(err);
81
+ log.warn(`Recovery: could not reach opencode server (${msg}); agents will lazy-resume on next message`);
82
+ this.finalizeStale(persisted);
83
+ return;
84
+ }
85
+ if (!proc.didReconnect()) {
86
+ log.log('Recovery: fresh server (old turns gone); finalizing mid-turn agents');
87
+ this.finalizeStale(persisted);
88
+ return;
89
+ }
90
+ log.log(`🔗 Recovery: rejoined server; re-attaching ${persisted.length} session(s)`);
91
+ const active = await proc.activeSessionIds();
92
+ for (const p of persisted) {
93
+ const state = {
94
+ sessionId: p.sessionId,
95
+ adapter: new OpencodeServerEventAdapter(),
96
+ turnState: p.turnState,
97
+ startTime: Date.now(),
98
+ lastActivityTime: Date.now(),
99
+ lastRequest: p.lastRequest,
100
+ queue: [],
101
+ };
102
+ this.agents.set(p.agentId, state);
103
+ this.sessionToAgent.set(p.sessionId, p.agentId);
104
+ if (active.has(p.sessionId)) {
105
+ // Turn still generating — the SSE stream will deliver its remaining
106
+ // deltas + session.idle, which finalizes it via applyEvent().
107
+ if (agentService.getAgent(p.agentId))
108
+ agentService.updateAgent(p.agentId, { status: 'working' });
109
+ state.turnState = 'processing';
110
+ }
111
+ else {
112
+ // Turn ended during downtime — finalize now (no session.idle will replay).
113
+ state.turnState = 'waiting_for_input';
114
+ this.callbacks.onComplete(p.agentId, true);
115
+ }
116
+ }
117
+ this.persistAgents();
118
+ }
119
+ finalizeStale(persisted) {
120
+ for (const p of persisted) {
121
+ if (p.agentStatus === 'working' || p.turnState === 'processing') {
122
+ this.callbacks.onComplete(p.agentId, true);
123
+ }
124
+ }
125
+ saveOpencodeAgents([]);
126
+ }
127
+ handleDisconnect() {
128
+ const agentIds = [...this.agents.keys()];
129
+ log.warn(`opencode server connection lost — finalizing ${agentIds.length} agent(s)`);
130
+ this.agents.clear();
131
+ this.sessionToAgent.clear();
132
+ this.process = null;
133
+ for (const agentId of agentIds)
134
+ this.callbacks.onComplete(agentId, false);
135
+ }
136
+ routeEvent(evt) {
137
+ const props = evt.properties || {};
138
+ const sessionId = props.sessionID || evt.sessionID;
139
+ if (!sessionId)
140
+ return;
141
+ const agentId = this.sessionToAgent.get(sessionId);
142
+ if (!agentId)
143
+ return;
144
+ const state = this.agents.get(agentId);
145
+ if (!state)
146
+ return;
147
+ state.lastActivityTime = Date.now();
148
+ this.fireActivity(agentId);
149
+ for (const event of state.adapter.handle(evt)) {
150
+ this.applyEvent(agentId, state, event);
151
+ }
152
+ }
153
+ applyEvent(agentId, state, event) {
154
+ if (event.type === 'text' || event.type === 'tool_start' || event.type === 'thinking' || event.type === 'init') {
155
+ state.turnState = 'processing';
156
+ }
157
+ this.pipeline.emitStandardEvent(agentId, event);
158
+ if (event.type === 'step_complete') {
159
+ state.turnState = 'waiting_for_input';
160
+ this.persistAgents();
161
+ // The server stays alive across turns, so there's no process 'close' to
162
+ // flip the agent idle. runtime-events skips the idle transition for
163
+ // opencode on step_complete (it expects it on process exit), so drive it here.
164
+ this.callbacks.onComplete(agentId, true);
165
+ this.drainQueue(agentId, state);
166
+ }
167
+ }
168
+ async run(request) {
169
+ await withAgentContext(request.agentId, () => this.runImpl(request));
170
+ }
171
+ async runImpl(request) {
172
+ const { agentId, workingDir } = request;
173
+ const proc = this.ensureProcess(workingDir);
174
+ try {
175
+ await proc.ensureStarted();
176
+ }
177
+ catch (err) {
178
+ const msg = err instanceof Error ? err.message : String(err);
179
+ log.error(`Failed to start opencode server for ${agentId.slice(0, 8)}: ${msg}`);
180
+ this.callbacks.onError(agentId, `OpenCode server failed to start: ${msg}`);
181
+ return;
182
+ }
183
+ const wantNew = request.forceNewSession || !request.sessionId;
184
+ let state = this.agents.get(agentId);
185
+ if (state && wantNew) {
186
+ this.sessionToAgent.delete(state.sessionId);
187
+ this.agents.delete(agentId);
188
+ state = undefined;
189
+ }
190
+ let injectInstructions = false;
191
+ if (!state) {
192
+ let sessionId;
193
+ if (!wantNew && request.sessionId) {
194
+ sessionId = request.sessionId; // resume an existing persisted session
195
+ }
196
+ else {
197
+ try {
198
+ sessionId = await proc.createSession(workingDir);
199
+ }
200
+ catch (err) {
201
+ const msg = err instanceof Error ? err.message : String(err);
202
+ log.error(`createSession failed for ${agentId.slice(0, 8)}: ${msg}`);
203
+ this.callbacks.onError(agentId, `OpenCode session create failed: ${msg}`);
204
+ return;
205
+ }
206
+ injectInstructions = true;
207
+ }
208
+ state = {
209
+ sessionId,
210
+ adapter: new OpencodeServerEventAdapter(),
211
+ turnState: 'processing',
212
+ startTime: Date.now(),
213
+ lastActivityTime: Date.now(),
214
+ lastRequest: request,
215
+ queue: [],
216
+ };
217
+ this.agents.set(agentId, state);
218
+ this.sessionToAgent.set(sessionId, agentId);
219
+ agentService.updateAgent(agentId, { sessionId });
220
+ this.callbacks.onSessionId(agentId, sessionId);
221
+ this.pipeline.emitStandardEvent(agentId, { type: 'init', sessionId, model: request.model || 'opencode' });
222
+ }
223
+ else {
224
+ state.lastRequest = request;
225
+ }
226
+ await this.startTurn(agentId, state, request.prompt, proc, injectInstructions);
227
+ this.persistAgents();
228
+ }
229
+ modelRef(model) {
230
+ if (!model)
231
+ return undefined;
232
+ const slash = model.indexOf('/');
233
+ if (slash <= 0)
234
+ return undefined; // no provider prefix → let the server default
235
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
236
+ }
237
+ async startTurn(agentId, state, prompt, proc, injectInstructions) {
238
+ // Reuse the exec prompt builder. Passing sessionId makes it send only the
239
+ // user prompt on follow-ups (the instruction block is already in the session
240
+ // history); on the very first turn of a new session we omit sessionId so the
241
+ // full block is injected once — same behaviour as the exec resume path.
242
+ const config = {
243
+ agentId,
244
+ sessionId: injectInstructions ? undefined : state.sessionId,
245
+ model: state.lastRequest.model,
246
+ workingDir: state.lastRequest.workingDir,
247
+ systemPrompt: state.lastRequest.systemPrompt,
248
+ customAgent: state.lastRequest.customAgent,
249
+ prompt,
250
+ };
251
+ const promptText = buildOpencodePrompt(config);
252
+ state.turnState = 'processing';
253
+ state.lastActivityTime = Date.now();
254
+ try {
255
+ await proc.sendPrompt(state.sessionId, promptText, this.modelRef(state.lastRequest.model));
256
+ }
257
+ catch (err) {
258
+ const msg = err instanceof Error ? err.message : String(err);
259
+ log.error(`sendPrompt failed for ${agentId.slice(0, 8)}: ${msg}`);
260
+ this.callbacks.onError(agentId, `OpenCode prompt failed: ${msg}`);
261
+ }
262
+ }
263
+ drainQueue(agentId, state) {
264
+ if (state.queue.length === 0)
265
+ return;
266
+ const next = state.queue.shift();
267
+ const proc = this.process;
268
+ if (!proc || !proc.isAlive()) {
269
+ state.queue.unshift(next);
270
+ return;
271
+ }
272
+ log.log(`📤 Delivering queued follow-up to ${agentId.slice(0, 8)} (${state.queue.length} remaining)`);
273
+ agentService.updateAgent(agentId, { status: 'working', currentTask: next.substring(0, 100) });
274
+ void this.startTurn(agentId, state, next, proc, false);
275
+ }
276
+ sendMessage(agentId, message) {
277
+ return withAgentContext(agentId, () => {
278
+ const state = this.agents.get(agentId);
279
+ if (!state) {
280
+ log.warn(`sendMessage: no live session for ${agentId.slice(0, 8)}`);
281
+ return false;
282
+ }
283
+ const proc = this.process;
284
+ if (!proc || !proc.isAlive())
285
+ return false;
286
+ if (state.turnState === 'processing') {
287
+ state.queue.push(message);
288
+ log.log(`📋 Queued mid-turn message for ${agentId.slice(0, 8)} (queue=${state.queue.length})`);
289
+ return true;
290
+ }
291
+ void this.startTurn(agentId, state, message, proc, false);
292
+ return true;
293
+ });
294
+ }
295
+ async stop(agentId, _clearQueue = true) {
296
+ await withAgentContext(agentId, async () => {
297
+ const state = this.agents.get(agentId);
298
+ if (!state)
299
+ return;
300
+ const proc = this.process;
301
+ if (proc && proc.isAlive() && state.turnState === 'processing') {
302
+ await proc.abort(state.sessionId);
303
+ }
304
+ this.sessionToAgent.delete(state.sessionId);
305
+ this.agents.delete(agentId);
306
+ this.activityCallbacks.delete(agentId);
307
+ this.persistAgents();
308
+ });
309
+ }
310
+ async stopAll(killProcesses = true, _clearQueue) {
311
+ this.agents.clear();
312
+ this.sessionToAgent.clear();
313
+ this.activityCallbacks.clear();
314
+ if (this.process) {
315
+ if (killProcesses) {
316
+ this.process.killDaemon();
317
+ saveOpencodeAgents([]);
318
+ }
319
+ else {
320
+ this.persistAgents();
321
+ this.process.disconnect();
322
+ }
323
+ this.process = null;
324
+ }
325
+ }
326
+ isRunning(agentId) {
327
+ return this.agents.has(agentId) && this.process !== null && this.process.isAlive();
328
+ }
329
+ supportsStdin() {
330
+ return true;
331
+ }
332
+ closesStdinAfterPrompt() {
333
+ return false;
334
+ }
335
+ getTurnState(agentId) {
336
+ return this.agents.get(agentId)?.turnState;
337
+ }
338
+ hasRecentActivity(agentId, withinMs) {
339
+ const state = this.agents.get(agentId);
340
+ if (!state)
341
+ return false;
342
+ const last = state.lastActivityTime || state.startTime;
343
+ return Date.now() - last < withinMs;
344
+ }
345
+ onNextActivity(agentId, callback) {
346
+ if (!this.activityCallbacks.has(agentId))
347
+ this.activityCallbacks.set(agentId, []);
348
+ this.activityCallbacks.get(agentId).push(callback);
349
+ }
350
+ fireActivity(agentId) {
351
+ const cbs = this.activityCallbacks.get(agentId);
352
+ if (!cbs || cbs.length === 0)
353
+ return;
354
+ this.activityCallbacks.delete(agentId);
355
+ for (const cb of cbs) {
356
+ try {
357
+ cb();
358
+ }
359
+ catch (err) {
360
+ log.error(`Activity callback error for ${agentId}:`, err);
361
+ }
362
+ }
363
+ }
364
+ persistAgents() {
365
+ if (!this.recovered)
366
+ return;
367
+ const list = [];
368
+ for (const [agentId, state] of this.agents) {
369
+ const agent = agentService.getAgent(agentId);
370
+ list.push({
371
+ agentId,
372
+ sessionId: state.sessionId,
373
+ workingDir: state.lastRequest.workingDir,
374
+ turnState: state.turnState,
375
+ agentStatus: agent?.status,
376
+ lastRequest: state.lastRequest,
377
+ });
378
+ }
379
+ saveOpencodeAgents(list);
380
+ }
381
+ getActiveProcessesState() {
382
+ return [...this.agents.keys()].map((agentId) => ({ agentId, pid: undefined }));
383
+ }
384
+ }
@@ -18,7 +18,7 @@ import { truncateOrEmpty } from '../utils/string.js';
18
18
  import { buildCustomAgentConfig, expandFileMentions } from '../websocket/handlers/command-handler.js';
19
19
  import { clearDelegation, getBossForSubordinate } from '../websocket/handlers/boss-response-handler.js';
20
20
  import { OpencodeBackend } from '../opencode/backend.js';
21
- import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnabled, setEchoPromptEnabled, getCodexBinaryPath, setCodexBinaryPath, isTmuxModeEnabled, setTmuxModeEnabled, isInteractiveModeEnabled, setInteractiveModeEnabled, isCodexAppServerModeEnabled, setCodexAppServerModeEnabled } from '../services/system-prompt-service.js';
21
+ import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnabled, setEchoPromptEnabled, getCodexBinaryPath, setCodexBinaryPath, isTmuxModeEnabled, setTmuxModeEnabled, isInteractiveModeEnabled, setInteractiveModeEnabled, isCodexAppServerModeEnabled, setCodexAppServerModeEnabled, isOpencodeServerModeEnabled, setOpencodeServerModeEnabled } from '../services/system-prompt-service.js';
22
22
  import { markInstructionsDirtyForAll } from '../services/instruction-refresh.js';
23
23
  import { startAgentTerminal, stopAgentTerminal } from '../services/agent-terminal-service.js';
24
24
  import { buildClaudeUsageByAgentSummary, buildClaudeUsageByDaySummary, buildClaudeUsageSnapshot } from '../services/claude-usage-service.js';
@@ -1529,6 +1529,34 @@ router.post('/system-settings/codex-app-server-mode', (req, res) => {
1529
1529
  res.status(500).json({ error: err.message });
1530
1530
  }
1531
1531
  });
1532
+ // GET /api/system-settings/opencode-server-mode - Get experimental OpenCode server (streaming) mode setting
1533
+ router.get('/system-settings/opencode-server-mode', (_req, res) => {
1534
+ try {
1535
+ const enabled = isOpencodeServerModeEnabled();
1536
+ res.json({ enabled });
1537
+ }
1538
+ catch (err) {
1539
+ log.error(' Failed to get opencode server mode setting:', err);
1540
+ res.status(500).json({ error: err.message });
1541
+ }
1542
+ });
1543
+ // POST /api/system-settings/opencode-server-mode - Update experimental OpenCode server (streaming) mode setting
1544
+ router.post('/system-settings/opencode-server-mode', (req, res) => {
1545
+ try {
1546
+ const { enabled } = req.body;
1547
+ if (typeof enabled !== 'boolean') {
1548
+ res.status(400).json({ error: 'enabled must be a boolean' });
1549
+ return;
1550
+ }
1551
+ setOpencodeServerModeEnabled(enabled);
1552
+ log.log(` OpenCode server mode setting updated: enabled=${enabled}`);
1553
+ res.json({ success: true, enabled });
1554
+ }
1555
+ catch (err) {
1556
+ log.error(' Failed to set opencode server mode setting:', err);
1557
+ res.status(500).json({ error: err.message });
1558
+ }
1559
+ });
1532
1560
  // GET /api/system-settings/backup - Get hourly backup scheduler status
1533
1561
  router.get('/system-settings/backup', (_req, res) => {
1534
1562
  try {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * OpencodeRunnerRouter
3
+ *
4
+ * OpenCode agents can run two ways: the default per-turn `opencode run` runner
5
+ * (ClaudeRunner driving an OpencodeBackend), or the experimental persistent
6
+ * `opencode serve` runner that streams word-by-word `message.part.delta` tokens
7
+ * over SSE and survives commander restarts. A NEW launch's mode is decided
8
+ * per-run from the LIVE setting (isOpencodeServerModeEnabled) — no restart to
9
+ * switch — mirroring ClaudeRunnerRouter and CodexRunnerRouter.
10
+ */
11
+ import { ClaudeRunner } from '../claude/runner.js';
12
+ import { OpencodeBackend } from '../opencode/backend.js';
13
+ import { OpencodeServerRunner } from '../opencode/server/opencode-server-runner.js';
14
+ import { isOpencodeServerModeEnabled } from '../services/system-prompt-service.js';
15
+ import { createLogger } from '../utils/logger.js';
16
+ const log = createLogger('OpencodeRouter');
17
+ export class OpencodeRunnerRouter {
18
+ run_;
19
+ server;
20
+ owner = new Map();
21
+ constructor(callbacks) {
22
+ this.run_ = new ClaudeRunner(callbacks, new OpencodeBackend());
23
+ this.server = new OpencodeServerRunner(callbacks);
24
+ }
25
+ currentMode() {
26
+ return isOpencodeServerModeEnabled() ? 'server' : 'run';
27
+ }
28
+ runnerFor(mode) {
29
+ return mode === 'server' ? this.server : this.run_;
30
+ }
31
+ ownerOf(agentId) {
32
+ if (this.server.isRunning(agentId))
33
+ return 'server';
34
+ if (this.run_.isRunning(agentId))
35
+ return 'run';
36
+ return this.owner.get(agentId);
37
+ }
38
+ ownerRunner(agentId) {
39
+ return this.runnerFor(this.ownerOf(agentId) ?? this.currentMode());
40
+ }
41
+ start() {
42
+ this.run_.start?.();
43
+ this.server.start?.();
44
+ }
45
+ async run(request) {
46
+ const mode = this.currentMode();
47
+ const prev = this.ownerOf(request.agentId);
48
+ if (prev && prev !== mode) {
49
+ log.log(`Agent ${request.agentId.slice(0, 8)} switching ${prev} → ${mode}; stopping old runner first`);
50
+ await this.runnerFor(prev).stop(request.agentId, false);
51
+ }
52
+ this.owner.set(request.agentId, mode);
53
+ return this.runnerFor(mode).run(request);
54
+ }
55
+ sendMessage(agentId, message) {
56
+ return this.ownerRunner(agentId).sendMessage(agentId, message);
57
+ }
58
+ async stop(agentId, clearQueue) {
59
+ await this.run_.stop(agentId, clearQueue);
60
+ await this.server.stop(agentId, clearQueue);
61
+ this.owner.delete(agentId);
62
+ }
63
+ async stopAll(killProcesses, clearQueue) {
64
+ await this.run_.stopAll(killProcesses, clearQueue);
65
+ await this.server.stopAll(killProcesses, clearQueue);
66
+ this.owner.clear();
67
+ }
68
+ isRunning(agentId) {
69
+ return this.run_.isRunning(agentId) || this.server.isRunning(agentId);
70
+ }
71
+ hasRecentActivity(agentId, withinMs) {
72
+ return this.ownerRunner(agentId).hasRecentActivity(agentId, withinMs);
73
+ }
74
+ onNextActivity(agentId, callback) {
75
+ this.ownerRunner(agentId).onNextActivity(agentId, callback);
76
+ }
77
+ supportsStdin() {
78
+ return true;
79
+ }
80
+ closesStdinAfterPrompt() {
81
+ // Mode-dependent: `opencode run` closes stdin per turn, the server keeps the
82
+ // session open. Report per the current setting so the delivery path matches.
83
+ return this.currentMode() === 'run';
84
+ }
85
+ getTurnState(agentId) {
86
+ return this.ownerRunner(agentId).getTurnState?.(agentId);
87
+ }
88
+ getActiveProcessesState() {
89
+ const runState = this.run_.getActiveProcessesState().map((p) => ({ agentId: p.agentId, pid: p.pid }));
90
+ const serverState = this.server.getActiveProcessesState();
91
+ return [...runState, ...serverState];
92
+ }
93
+ }
@@ -1,9 +1,11 @@
1
- import { ClaudeRunner } from '../claude/runner.js';
2
- import { OpencodeBackend } from '../opencode/backend.js';
1
+ import { OpencodeRunnerRouter } from './opencode-runner-router.js';
3
2
  class OpencodeRuntimeProvider {
4
3
  name = 'opencode';
5
4
  createRunner(callbacks) {
6
- return new ClaudeRunner(callbacks, new OpencodeBackend());
5
+ // The router picks per-launch between the default `opencode run` runner and
6
+ // the experimental persistent `opencode serve` runner (streaming), driven by
7
+ // the live isOpencodeServerModeEnabled() setting.
8
+ return new OpencodeRunnerRouter(callbacks);
7
9
  }
8
10
  }
9
11
  export function createOpencodeRuntimeProvider() {
@@ -319,7 +319,9 @@ export function isCodexAppServerModeEnabled() {
319
319
  catch (error) {
320
320
  log.error(` Failed to load codex app-server mode setting: ${error.message}`);
321
321
  }
322
- return false;
322
+ // Default ON: streaming (app-server) is the default for Codex agents. An
323
+ // explicit toggle-off is still respected (the file above wins).
324
+ return true;
323
325
  }
324
326
  /**
325
327
  * Enable/disable experimental Codex app-server (streaming) mode.
@@ -339,3 +341,49 @@ export function setCodexAppServerModeEnabled(enabled) {
339
341
  throw error;
340
342
  }
341
343
  }
344
+ // ============================================================================
345
+ // OpenCode Server Mode Setting
346
+ // ============================================================================
347
+ //
348
+ // Experimental: when enabled, OpenCode agents run through a persistent, detached
349
+ // `opencode serve` HTTP server instead of one `opencode run` process per turn.
350
+ // The server streams word-by-word `message.part.delta` tokens over SSE (which
351
+ // `opencode run --format json` never emits) and survives commander restarts.
352
+ // Read live at each launch — no server restart needed to switch.
353
+ const OPENCODE_SERVER_MODE_FILE = path.join(DATA_DIR, 'opencode-server-mode.json');
354
+ /**
355
+ * Check if experimental OpenCode server (streaming) mode is enabled.
356
+ */
357
+ export function isOpencodeServerModeEnabled() {
358
+ ensureDataDir();
359
+ try {
360
+ if (fs.existsSync(OPENCODE_SERVER_MODE_FILE)) {
361
+ const data = JSON.parse(fs.readFileSync(OPENCODE_SERVER_MODE_FILE, 'utf-8'));
362
+ return data.enabled;
363
+ }
364
+ }
365
+ catch (error) {
366
+ log.error(` Failed to load opencode server mode setting: ${error.message}`);
367
+ }
368
+ // Default ON: streaming (opencode serve) is the default for OpenCode agents.
369
+ // An explicit toggle-off is still respected (the file above wins).
370
+ return true;
371
+ }
372
+ /**
373
+ * Enable/disable experimental OpenCode server (streaming) mode.
374
+ */
375
+ export function setOpencodeServerModeEnabled(enabled) {
376
+ ensureDataDir();
377
+ const data = {
378
+ enabled,
379
+ updatedAt: Date.now(),
380
+ };
381
+ try {
382
+ fs.writeFileSync(OPENCODE_SERVER_MODE_FILE, JSON.stringify(data, null, 2), 'utf-8');
383
+ log.log(` OpenCode server mode setting updated: enabled=${enabled}`);
384
+ }
385
+ catch (error) {
386
+ log.error(` Failed to save opencode server mode setting: ${error.message}`);
387
+ throw error;
388
+ }
389
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.154.0",
3
+ "version": "1.156.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",