tide-commander 1.153.0 → 1.155.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 (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/assets/{BossLogsModal-Dgz-N5gw.js → BossLogsModal-OFobifVq.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BK_rYu8s.js → BossSpawnModal-Dho-x6rm.js} +1 -1
  4. package/dist/assets/{ControlsModal-hC_AiCpk.js → ControlsModal-DJBDhvNO.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-D7ogyaZr.js → DockerLogsModal-C9TRTcsO.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-BFzUOmwz.js → EmbeddedEditor-GDE_Khwz.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-C_Yc5Ypv.js → GmailOAuthSetup-D9m5zMWC.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-CefQSOYm.js → GoogleOAuthSetup-BxkN7LjY.js} +1 -1
  9. package/dist/assets/{IframeModal-BbeuiE04.js → IframeModal-Do_I_wI7.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-xh3TgJLH.js → IntegrationsPanel-jRrwC2-G.js} +2 -2
  11. package/dist/assets/{LogViewerModal-CE2L8nWJ.js → LogViewerModal-BtcUe5i2.js} +1 -1
  12. package/dist/assets/{MonitoringModal-A2SN3uAJ.js → MonitoringModal-BTmSXrzv.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-DdlhCa8u.js → PM2LogsModal-CUf7NGHz.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-BvHo-OxE.js → RestoreArchivedAreaModal-B72UKeRP.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-t6RuUegr.js → Scene2DCanvas-BCfxX7JU.js} +1 -1
  16. package/dist/assets/{SceneManager-TQ2_AvFn.js → SceneManager-BQubdlgd.js} +4 -4
  17. package/dist/assets/{SkillsPanel-PjzjcaoG.js → SkillsPanel-3qreMiJs.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-ms4r32Z8.js → SlackMultiInstanceSetup-BSCT5nAB.js} +1 -1
  19. package/dist/assets/{SpawnModal-B1FS8m9h.js → SpawnModal-DaWjnugM.js} +1 -1
  20. package/dist/assets/{StatisticsModal-mmz8hnD5.js → StatisticsModal-DCjCAU2o.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-BXoG-Xgd.js → SubordinateAssignmentModal-9iPXraIi.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-ODJyjvY-.js → TriggerManagerPanel-B4dCLEi_.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-9wtY0Tn3.js → WorkflowEditorPanel-BaYXKfYH.js} +1 -1
  24. package/dist/assets/{area-logos-BLyzmh47.js → area-logos-CgVT13bp.js} +1 -1
  25. package/dist/assets/{index-CN2p2fxH.js → index-0y0WY2XK.js} +1 -1
  26. package/dist/assets/index-BPjQZacc.css +1 -0
  27. package/dist/assets/{index-CdbLxNJr.js → index-C-tbJxGb.js} +1 -1
  28. package/dist/assets/{index-1hwiSf7o.js → index-C3Yb-3TP.js} +1 -1
  29. package/dist/assets/index-C3jAbuv2.js +1 -0
  30. package/dist/assets/{index-Ci2ZKu0n.js → index-C7t_6aP3.js} +2 -2
  31. package/dist/assets/{index-CBkh8xcE.js → index-CetQ5OX1.js} +1 -1
  32. package/dist/assets/{index-Cf-PINah.js → index-D8dlRudN.js} +3 -3
  33. package/dist/assets/index-DNjdlOwh.js +1 -0
  34. package/dist/assets/{index-D8vFfLs4.js → index-DiZy3nuB.js} +1 -1
  35. package/dist/assets/main-BHBs2E1L.css +1 -0
  36. package/dist/assets/{main-9TcmAtcq.js → main-DDwa0x9e.js} +113 -113
  37. package/dist/assets/{web-BVIPIXA1.js → web-Dj2DYQG1.js} +1 -1
  38. package/dist/assets/{web-Cz3TYK-R.js → web-EBdZk56y.js} +1 -1
  39. package/dist/assets/{web-BAkfcL4w.js → web-PXjd4vZ8.js} +1 -1
  40. package/dist/build-info.json +1 -1
  41. package/dist/index.html +2 -2
  42. package/dist/locales/de/config.json +1 -0
  43. package/dist/locales/en/config.json +6 -1
  44. package/dist/locales/en/terminal.json +7 -1
  45. package/dist/locales/es/config.json +4 -1
  46. package/dist/locales/es/terminal.json +8 -0
  47. package/dist/locales/fr/config.json +1 -0
  48. package/dist/locales/hi/config.json +1 -0
  49. package/dist/locales/it/config.json +1 -0
  50. package/dist/locales/ja/config.json +1 -0
  51. package/dist/locales/pt/config.json +1 -0
  52. package/dist/locales/ru/config.json +1 -0
  53. package/dist/locales/zh-CN/config.json +1 -0
  54. package/dist/src/packages/server/codex/app-server/app-server-event-adapter.js +222 -0
  55. package/dist/src/packages/server/codex/app-server/app-server-process.js +304 -0
  56. package/dist/src/packages/server/codex/app-server/app-server-recovery-store.js +76 -0
  57. package/dist/src/packages/server/codex/app-server/codex-app-server-runner.js +453 -0
  58. package/dist/src/packages/server/opencode/json-event-parser.js +3 -1
  59. package/dist/src/packages/server/opencode/server/opencode-server-event-adapter.js +127 -0
  60. package/dist/src/packages/server/opencode/server/opencode-server-process.js +285 -0
  61. package/dist/src/packages/server/opencode/server/opencode-server-recovery-store.js +73 -0
  62. package/dist/src/packages/server/opencode/server/opencode-server-runner.js +384 -0
  63. package/dist/src/packages/server/routes/agents.js +57 -1
  64. package/dist/src/packages/server/routes/system.js +110 -1
  65. package/dist/src/packages/server/runtime/codex-runner-router.js +99 -0
  66. package/dist/src/packages/server/runtime/codex-runtime-provider.js +5 -3
  67. package/dist/src/packages/server/runtime/opencode-runner-router.js +93 -0
  68. package/dist/src/packages/server/runtime/opencode-runtime-provider.js +5 -3
  69. package/dist/src/packages/server/services/claude-credentials-service.js +208 -1
  70. package/dist/src/packages/server/services/claude-usage-service.js +35 -28
  71. package/dist/src/packages/server/services/provider-credentials-service.js +338 -0
  72. package/dist/src/packages/server/services/system-prompt-service.js +94 -0
  73. package/package.json +1 -1
  74. package/dist/assets/index-CbfoJ_Vc.js +0 -1
  75. package/dist/assets/index-E5IMeM80.js +0 -1
  76. package/dist/assets/index-XI0_ICy6.css +0 -1
  77. package/dist/assets/main-DP0iv8Le.css +0 -1
@@ -0,0 +1,453 @@
1
+ /**
2
+ * CodexAppServerRunner — experimental runtime that drives Codex agents through a
3
+ * persistent, restart-surviving `codex app-server` (WebSocket transport) instead
4
+ * of one `codex exec` process per turn.
5
+ *
6
+ * Why it exists: `codex exec --experimental-json` emits the whole assistant
7
+ * reply as a single `item.completed`, so Codex replies can never typewriter.
8
+ * app-server streams `item/agentMessage/delta` tokens.
9
+ *
10
+ * Persistence: the app-server is spawned DETACHED on a localhost WebSocket, so
11
+ * it outlives commander restarts and keeps running any in-flight turn. On boot
12
+ * the runner rejoins the live daemon and `thread/resume` re-subscribes to each
13
+ * agent's thread — the server even replays the turn's remaining deltas. State
14
+ * needed to reconnect is persisted in a dedicated recovery file.
15
+ *
16
+ * Downstream reuse: reconstructed StandardEvents are fed into a shared
17
+ * RunnerStdoutPipeline via `emitStandardEvent` (the CodexBackend's
18
+ * `.name === 'codex'` turns on the pipeline's stream-coalescing), so websocket
19
+ * broadcast, client merge-by-uuid, boss delegation and context tracking are all
20
+ * reused unchanged from the exec path.
21
+ */
22
+ import * as os from 'os';
23
+ import { CodexBackend, buildCodexPrompt } from '../backend.js';
24
+ import { RunnerInternalEventBus } from '../../claude/runner/internal-events.js';
25
+ import { RunnerStdoutPipeline } from '../../claude/runner/stdout-pipeline.js';
26
+ import { CodexAppServerProcess } from './app-server-process.js';
27
+ import { CodexAppServerEventAdapter } from './app-server-event-adapter.js';
28
+ import { loadAppServerAgents, saveAppServerAgents, } from './app-server-recovery-store.js';
29
+ import * as agentService from '../../services/agent-service.js';
30
+ import { createLogger } from '../../utils/logger.js';
31
+ import { withAgentContext } from '../../utils/log-context.js';
32
+ const log = createLogger('CodexAppServerRunner');
33
+ /** Defer recovery slightly so the rest of the server is up before we replay. */
34
+ const RECOVER_DELAY_MS = 1500;
35
+ export class CodexAppServerRunner {
36
+ callbacks;
37
+ backend = new CodexBackend();
38
+ bus = new RunnerInternalEventBus();
39
+ pipeline;
40
+ agents = new Map();
41
+ threadToAgent = new Map();
42
+ activityCallbacks = new Map();
43
+ process = null;
44
+ started = false;
45
+ /** Guard so run/stop during the recovery window can't clobber the file first. */
46
+ recovered = false;
47
+ constructor(callbacks) {
48
+ this.callbacks = callbacks;
49
+ this.pipeline = new RunnerStdoutPipeline({
50
+ backend: this.backend,
51
+ callbacks: this.callbacks,
52
+ bus: this.bus,
53
+ });
54
+ }
55
+ start() {
56
+ if (this.started)
57
+ return;
58
+ this.started = true;
59
+ setTimeout(() => { void this.recover(); }, RECOVER_DELAY_MS);
60
+ log.log('🛡️ Codex app-server runner started (streaming, restart-surviving)');
61
+ }
62
+ ensureProcess(spawnCwd) {
63
+ if (this.process && this.process.isAlive())
64
+ return this.process;
65
+ this.process = new CodexAppServerProcess({
66
+ executablePath: this.backend.getExecutablePath(),
67
+ cwd: spawnCwd || os.homedir(),
68
+ extraEnv: this.backend.getExtraEnv(),
69
+ onNotification: (method, params) => this.routeNotification(method, params),
70
+ onExit: () => this.handleProcessExit(),
71
+ });
72
+ return this.process;
73
+ }
74
+ /**
75
+ * On boot, rejoin the surviving daemon and re-subscribe each persisted agent's
76
+ * thread so an in-flight turn keeps streaming and finalizes correctly.
77
+ */
78
+ async recover() {
79
+ const persisted = loadAppServerAgents();
80
+ this.recovered = true;
81
+ if (persisted.length === 0) {
82
+ this.persistAgents();
83
+ return;
84
+ }
85
+ const spawnCwd = persisted[0].workingDir || os.homedir();
86
+ const proc = this.ensureProcess(spawnCwd);
87
+ try {
88
+ await proc.ensureStarted();
89
+ }
90
+ catch (err) {
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ log.warn(`Recovery: could not reach app-server (${msg}); agents will lazy-resume on next message`);
93
+ this.finalizeStale(persisted);
94
+ return;
95
+ }
96
+ if (!proc.didReconnect()) {
97
+ // A fresh daemon was spawned — the old in-flight turns are gone with the
98
+ // old daemon. sessionId is persisted on each agent, so the next message
99
+ // resumes the conversation; just finalize any that were mid-turn.
100
+ log.log('Recovery: fresh daemon (old turns gone); finalizing mid-turn agents');
101
+ this.finalizeStale(persisted);
102
+ return;
103
+ }
104
+ log.log(`🔗 Recovery: rejoined daemon; re-subscribing ${persisted.length} thread(s)`);
105
+ for (const p of persisted) {
106
+ await this.resubscribe(proc, p);
107
+ }
108
+ this.persistAgents();
109
+ }
110
+ finalizeStale(persisted) {
111
+ for (const p of persisted) {
112
+ if (p.agentStatus === 'working' || p.turnState === 'processing') {
113
+ this.callbacks.onComplete(p.agentId, true);
114
+ }
115
+ }
116
+ saveAppServerAgents([]);
117
+ }
118
+ async resubscribe(proc, p) {
119
+ const state = {
120
+ threadId: p.threadId,
121
+ sessionId: p.threadId,
122
+ adapter: new CodexAppServerEventAdapter(),
123
+ turnState: p.turnState,
124
+ startTime: Date.now(),
125
+ lastActivityTime: Date.now(),
126
+ lastRequest: p.lastRequest,
127
+ queue: [],
128
+ };
129
+ this.agents.set(p.agentId, state);
130
+ this.threadToAgent.set(p.threadId, p.agentId);
131
+ if (p.agentStatus === 'working' && agentService.getAgent(p.agentId)) {
132
+ agentService.updateAgent(p.agentId, { status: 'working' });
133
+ }
134
+ // thread/resume re-attaches this connection to the thread's live stream (the
135
+ // server replays an in-flight turn's remaining deltas). thread/read tells us
136
+ // whether the turn already ended while we were down.
137
+ proc.request('thread/resume', { threadId: p.threadId }).catch(() => { });
138
+ let read = null;
139
+ try {
140
+ read = await proc.request('thread/read', { threadId: p.threadId, includeTurns: true });
141
+ }
142
+ catch { /* ignore */ }
143
+ const turns = read?.thread?.turns
144
+ ?? read?.turns;
145
+ const latest = Array.isArray(turns) && turns.length > 0 ? turns[turns.length - 1] : undefined;
146
+ if (latest && latest.status && latest.status !== 'inProgress') {
147
+ // The turn finished during the downtime — we won't get a fresh
148
+ // turn/completed, so finalize now.
149
+ state.turnState = 'waiting_for_input';
150
+ this.callbacks.onComplete(p.agentId, latest.status !== 'failed');
151
+ }
152
+ // Otherwise it's still inProgress: thread/resume subscribed us, and the
153
+ // replayed deltas + turn/completed will finalize it via applyEvent().
154
+ }
155
+ handleProcessExit() {
156
+ // The WebSocket to the daemon dropped. Finalize live agents so none stays
157
+ // stuck 'working'; the daemon itself likely survives, so the next message
158
+ // reconnects and resumes.
159
+ const agentIds = [...this.agents.keys()];
160
+ log.warn(`app-server connection lost — finalizing ${agentIds.length} agent(s)`);
161
+ this.agents.clear();
162
+ this.threadToAgent.clear();
163
+ this.process = null;
164
+ for (const agentId of agentIds)
165
+ this.callbacks.onComplete(agentId, false);
166
+ }
167
+ routeNotification(method, params) {
168
+ const threadId = typeof params.threadId === 'string' ? params.threadId : undefined;
169
+ if (!threadId)
170
+ return;
171
+ const agentId = this.threadToAgent.get(threadId);
172
+ if (!agentId)
173
+ return;
174
+ const state = this.agents.get(agentId);
175
+ if (!state)
176
+ return;
177
+ state.lastActivityTime = Date.now();
178
+ this.fireActivity(agentId);
179
+ for (const event of state.adapter.handle(method, params)) {
180
+ this.applyEvent(agentId, state, event);
181
+ }
182
+ }
183
+ applyEvent(agentId, state, event) {
184
+ if (event.type === 'text' || event.type === 'tool_start' || event.type === 'thinking' || event.type === 'init') {
185
+ state.turnState = 'processing';
186
+ }
187
+ this.pipeline.emitStandardEvent(agentId, event);
188
+ if (event.type === 'step_complete') {
189
+ state.turnState = 'waiting_for_input';
190
+ this.persistAgents();
191
+ // The daemon stays alive across turns, so there's no process 'close' to
192
+ // flip the agent idle. runtime-events skips the idle transition for codex
193
+ // on step_complete (it expects it on process exit), so drive it here.
194
+ this.callbacks.onComplete(agentId, true);
195
+ this.drainQueue(agentId, state);
196
+ }
197
+ }
198
+ async run(request) {
199
+ await withAgentContext(request.agentId, () => this.runImpl(request));
200
+ }
201
+ async runImpl(request) {
202
+ const { agentId, workingDir } = request;
203
+ const proc = this.ensureProcess(workingDir);
204
+ try {
205
+ await proc.ensureStarted();
206
+ }
207
+ catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ log.error(`Failed to start app-server for ${agentId.slice(0, 8)}: ${msg}`);
210
+ this.callbacks.onError(agentId, `Codex app-server failed to start: ${msg}`);
211
+ return;
212
+ }
213
+ const wantNew = request.forceNewSession || !request.sessionId;
214
+ let state = this.agents.get(agentId);
215
+ if (state && wantNew) {
216
+ this.threadToAgent.delete(state.threadId);
217
+ this.agents.delete(agentId);
218
+ state = undefined;
219
+ }
220
+ if (!state) {
221
+ const threadId = await this.openThread(agentId, request, proc, wantNew);
222
+ if (!threadId)
223
+ return;
224
+ state = {
225
+ threadId,
226
+ sessionId: threadId,
227
+ adapter: new CodexAppServerEventAdapter(),
228
+ turnState: 'processing',
229
+ startTime: Date.now(),
230
+ lastActivityTime: Date.now(),
231
+ lastRequest: request,
232
+ queue: [],
233
+ };
234
+ this.agents.set(agentId, state);
235
+ this.threadToAgent.set(threadId, agentId);
236
+ agentService.updateAgent(agentId, { sessionId: threadId });
237
+ this.callbacks.onSessionId(agentId, threadId);
238
+ this.pipeline.emitStandardEvent(agentId, {
239
+ type: 'init',
240
+ sessionId: threadId,
241
+ model: this.threadModel(request) || 'codex',
242
+ });
243
+ }
244
+ else {
245
+ state.lastRequest = request;
246
+ }
247
+ await this.startTurn(agentId, state, request.prompt, proc);
248
+ this.persistAgents();
249
+ }
250
+ async openThread(agentId, request, proc, wantNew) {
251
+ const baseParams = {
252
+ cwd: request.workingDir,
253
+ approvalPolicy: 'never',
254
+ sandbox: 'danger-full-access',
255
+ };
256
+ const model = this.threadModel(request);
257
+ if (model)
258
+ baseParams.model = model;
259
+ const effort = request.codexConfig?.reasoningEffort;
260
+ if (effort)
261
+ baseParams.config = { model_reasoning_effort: effort };
262
+ try {
263
+ if (!wantNew && request.sessionId) {
264
+ const result = (await proc.request('thread/resume', {
265
+ ...baseParams,
266
+ threadId: request.sessionId,
267
+ }));
268
+ const id = result?.thread?.id || request.sessionId;
269
+ log.log(`🔄 Resumed thread ${id.slice(0, 8)} for agent ${agentId.slice(0, 8)}`);
270
+ return id;
271
+ }
272
+ }
273
+ catch (err) {
274
+ const msg = err instanceof Error ? err.message : String(err);
275
+ log.warn(`thread/resume failed for ${agentId.slice(0, 8)} (${msg}) — starting a fresh thread`);
276
+ }
277
+ try {
278
+ const result = (await proc.request('thread/start', baseParams));
279
+ const id = result?.thread?.id;
280
+ if (!id)
281
+ throw new Error('thread/start returned no thread id');
282
+ log.log(`🆕 Started thread ${id.slice(0, 8)} for agent ${agentId.slice(0, 8)}`);
283
+ return id;
284
+ }
285
+ catch (err) {
286
+ const msg = err instanceof Error ? err.message : String(err);
287
+ log.error(`thread/start failed for ${agentId.slice(0, 8)}: ${msg}`);
288
+ this.callbacks.onError(agentId, `Codex app-server thread failed: ${msg}`);
289
+ return null;
290
+ }
291
+ }
292
+ threadModel(request) {
293
+ const model = request.model;
294
+ if (!model)
295
+ return undefined;
296
+ if (model === 'codex' || model === 'sonnet' || model === 'opus' || model === 'haiku' || model.startsWith('claude-')) {
297
+ return undefined;
298
+ }
299
+ return model;
300
+ }
301
+ async startTurn(agentId, state, prompt, proc) {
302
+ const config = {
303
+ agentId,
304
+ sessionId: state.threadId,
305
+ model: state.lastRequest.model,
306
+ workingDir: state.lastRequest.workingDir,
307
+ systemPrompt: state.lastRequest.systemPrompt,
308
+ customAgent: state.lastRequest.customAgent,
309
+ codexConfig: state.lastRequest.codexConfig,
310
+ prompt,
311
+ };
312
+ const promptText = buildCodexPrompt(config);
313
+ state.turnState = 'processing';
314
+ state.lastActivityTime = Date.now();
315
+ try {
316
+ await proc.request('turn/start', {
317
+ threadId: state.threadId,
318
+ input: [{ type: 'text', text: promptText }],
319
+ });
320
+ }
321
+ catch (err) {
322
+ const msg = err instanceof Error ? err.message : String(err);
323
+ log.error(`turn/start failed for ${agentId.slice(0, 8)}: ${msg}`);
324
+ this.callbacks.onError(agentId, `Codex turn failed: ${msg}`);
325
+ }
326
+ }
327
+ drainQueue(agentId, state) {
328
+ if (state.queue.length === 0)
329
+ return;
330
+ const next = state.queue.shift();
331
+ const proc = this.process;
332
+ if (!proc || !proc.isAlive()) {
333
+ state.queue.unshift(next);
334
+ return;
335
+ }
336
+ log.log(`📤 Delivering queued follow-up to ${agentId.slice(0, 8)} (${state.queue.length} remaining)`);
337
+ agentService.updateAgent(agentId, { status: 'working', currentTask: next.substring(0, 100) });
338
+ void this.startTurn(agentId, state, next, proc);
339
+ }
340
+ sendMessage(agentId, message) {
341
+ return withAgentContext(agentId, () => {
342
+ const state = this.agents.get(agentId);
343
+ if (!state) {
344
+ log.warn(`sendMessage: no live thread for ${agentId.slice(0, 8)}`);
345
+ return false;
346
+ }
347
+ const proc = this.process;
348
+ if (!proc || !proc.isAlive())
349
+ return false;
350
+ if (state.turnState === 'processing') {
351
+ state.queue.push(message);
352
+ log.log(`📋 Queued mid-turn message for ${agentId.slice(0, 8)} (queue=${state.queue.length})`);
353
+ return true;
354
+ }
355
+ void this.startTurn(agentId, state, message, proc);
356
+ return true;
357
+ });
358
+ }
359
+ async stop(agentId, _clearQueue = true) {
360
+ await withAgentContext(agentId, async () => {
361
+ const state = this.agents.get(agentId);
362
+ if (!state)
363
+ return;
364
+ const proc = this.process;
365
+ if (proc && proc.isAlive() && state.turnState === 'processing') {
366
+ try {
367
+ await proc.request('turn/interrupt', { threadId: state.threadId });
368
+ }
369
+ catch { /* best-effort */ }
370
+ }
371
+ this.threadToAgent.delete(state.threadId);
372
+ this.agents.delete(agentId);
373
+ this.activityCallbacks.delete(agentId);
374
+ this.persistAgents();
375
+ });
376
+ }
377
+ async stopAll(killProcesses = true, _clearQueue) {
378
+ this.agents.clear();
379
+ this.threadToAgent.clear();
380
+ this.activityCallbacks.clear();
381
+ if (this.process) {
382
+ if (killProcesses) {
383
+ // Explicit full shutdown → kill the daemon and drop recovery state.
384
+ this.process.killDaemon();
385
+ saveAppServerAgents([]);
386
+ }
387
+ else {
388
+ // Graceful restart → leave the daemon running so we rejoin it on boot.
389
+ this.persistAgents();
390
+ this.process.disconnect();
391
+ }
392
+ this.process = null;
393
+ }
394
+ }
395
+ isRunning(agentId) {
396
+ return this.agents.has(agentId) && this.process !== null && this.process.isAlive();
397
+ }
398
+ supportsStdin() {
399
+ return true;
400
+ }
401
+ closesStdinAfterPrompt() {
402
+ return false;
403
+ }
404
+ getTurnState(agentId) {
405
+ return this.agents.get(agentId)?.turnState;
406
+ }
407
+ hasRecentActivity(agentId, withinMs) {
408
+ const state = this.agents.get(agentId);
409
+ if (!state)
410
+ return false;
411
+ const last = state.lastActivityTime || state.startTime;
412
+ return Date.now() - last < withinMs;
413
+ }
414
+ onNextActivity(agentId, callback) {
415
+ if (!this.activityCallbacks.has(agentId))
416
+ this.activityCallbacks.set(agentId, []);
417
+ this.activityCallbacks.get(agentId).push(callback);
418
+ }
419
+ fireActivity(agentId) {
420
+ const cbs = this.activityCallbacks.get(agentId);
421
+ if (!cbs || cbs.length === 0)
422
+ return;
423
+ this.activityCallbacks.delete(agentId);
424
+ for (const cb of cbs) {
425
+ try {
426
+ cb();
427
+ }
428
+ catch (err) {
429
+ log.error(`Activity callback error for ${agentId}:`, err);
430
+ }
431
+ }
432
+ }
433
+ persistAgents() {
434
+ if (!this.recovered)
435
+ return; // don't clobber the recovery file before reading it
436
+ const list = [];
437
+ for (const [agentId, state] of this.agents) {
438
+ const agent = agentService.getAgent(agentId);
439
+ list.push({
440
+ agentId,
441
+ threadId: state.threadId,
442
+ workingDir: state.lastRequest.workingDir,
443
+ turnState: state.turnState,
444
+ agentStatus: agent?.status,
445
+ lastRequest: state.lastRequest,
446
+ });
447
+ }
448
+ saveAppServerAgents(list);
449
+ }
450
+ getActiveProcessesState() {
451
+ return [...this.agents.keys()].map((agentId) => ({ agentId, pid: undefined }));
452
+ }
453
+ }
@@ -55,7 +55,9 @@ export class OpencodeJsonEventParser {
55
55
  if (!event || typeof event !== 'object' || !event.type) {
56
56
  return [];
57
57
  }
58
- log.log(`parseEvent: type=${event.type}, sessionID=${event.sessionID || 'none'}`);
58
+ // debug-level: in `opencode serve` mode this fires once per token delta, so
59
+ // keep it out of the default log stream to avoid per-token log spam.
60
+ log.debug(`parseEvent: type=${event.type}, sessionID=${event.sessionID || 'none'}`);
59
61
  switch (event.type) {
60
62
  case 'step_start':
61
63
  return this.parseStepStart(event);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * OpencodeServerEventAdapter
3
+ *
4
+ * Translates `opencode serve` SSE events into Tide StandardEvents, one adapter
5
+ * per agent. Text/reasoning streaming and turn-end finalization are delegated to
6
+ * the existing OpencodeJsonEventParser (its `message.part.delta` /
7
+ * `message.part.updated` handling already matches the server's `properties`
8
+ * shape exactly), so the streaming behaviour is identical to the exec path.
9
+ *
10
+ * The adapter only adds what the server surfaces differently from `opencode run`:
11
+ * - tool parts (`part.type === 'tool'`) → tool_start (once) + tool_result (on
12
+ * completion), since the server streams them as message.part.updated rather
13
+ * than the `tool_use` events the parser expects;
14
+ * - `session.idle` → a synthetic `step_finish` fed through the parser so its
15
+ * stream-finalization + step_complete/resultText logic is reused verbatim;
16
+ * - assistant token/cost capture from `message.updated`, injected into that
17
+ * synthetic step_finish for context tracking.
18
+ */
19
+ import { OpencodeJsonEventParser } from '../json-event-parser.js';
20
+ const TOOL_NAME_MAP = {
21
+ bash: 'Bash', read: 'Read', write: 'Write', edit: 'Edit', glob: 'Glob', grep: 'Grep',
22
+ task: 'Task', agent: 'Agent', skill: 'Skill', webfetch: 'WebFetch', websearch: 'WebSearch',
23
+ todowrite: 'TodoWrite', notebookedit: 'NotebookEdit', askuserquestion: 'AskUserQuestion',
24
+ };
25
+ function normalizeToolName(raw) {
26
+ return TOOL_NAME_MAP[raw.toLowerCase()] || raw;
27
+ }
28
+ export class OpencodeServerEventAdapter {
29
+ parser = new OpencodeJsonEventParser();
30
+ toolStarted = new Set();
31
+ toolInputSent = new Set();
32
+ toolDone = new Set();
33
+ lastTokens;
34
+ lastCost;
35
+ handle(event) {
36
+ const type = event.type;
37
+ if (!type)
38
+ return [];
39
+ const props = event.properties || {};
40
+ switch (type) {
41
+ case 'message.part.delta':
42
+ return this.parser.parseEvent(event);
43
+ case 'message.part.updated': {
44
+ const part = props.part || undefined;
45
+ const out = [];
46
+ // Text/reasoning parts stream through the shared parser (tool parts have
47
+ // no `text`, so the parser safely ignores them).
48
+ out.push(...this.parser.parseEvent(event));
49
+ if (part?.type === 'tool')
50
+ out.push(...this.handleToolPart(part));
51
+ return out;
52
+ }
53
+ case 'message.updated': {
54
+ const info = props.info;
55
+ if (info?.role === 'assistant') {
56
+ if (info.tokens)
57
+ this.lastTokens = info.tokens;
58
+ if (typeof info.cost === 'number')
59
+ this.lastCost = info.cost;
60
+ }
61
+ return [];
62
+ }
63
+ case 'session.idle':
64
+ return this.finalizeTurn();
65
+ case 'session.error': {
66
+ const err = props.error;
67
+ const errorMessage = err?.message || err?.data?.message || 'OpenCode session error';
68
+ return [{ type: 'error', errorMessage }];
69
+ }
70
+ default:
71
+ return [];
72
+ }
73
+ }
74
+ handleToolPart(part) {
75
+ const callId = part.callID || part.id;
76
+ if (!callId)
77
+ return [];
78
+ const toolName = normalizeToolName(part.tool || 'unknown');
79
+ const status = part.state?.status;
80
+ const input = part.state?.input;
81
+ const hasInput = !!input && Object.keys(input).length > 0;
82
+ const events = [];
83
+ if (!this.toolStarted.has(callId)) {
84
+ this.toolStarted.add(callId);
85
+ if (hasInput)
86
+ this.toolInputSent.add(callId);
87
+ events.push({ type: 'tool_start', toolName, toolInput: input, uuid: callId, toolUseId: callId });
88
+ }
89
+ else if (!this.toolInputSent.has(callId) && hasInput) {
90
+ // Input arrived after the first (pending) update — refresh the same row.
91
+ this.toolInputSent.add(callId);
92
+ events.push({ type: 'tool_start', toolName, toolInput: input, uuid: callId, toolUseId: callId });
93
+ }
94
+ if ((status === 'completed' || status === 'error') && !this.toolDone.has(callId)) {
95
+ this.toolDone.add(callId);
96
+ events.push({ type: 'tool_result', toolName, toolOutput: part.state?.output || '', uuid: callId, toolUseId: callId });
97
+ }
98
+ return events;
99
+ }
100
+ finalizeTurn() {
101
+ // Feed a synthetic step_finish so the parser finalizes any open streaming
102
+ // rows and emits usage_snapshot + step_complete (with resultText from the
103
+ // assistant text it accumulated). Reset per-turn tool tracking after.
104
+ const syntheticStepFinish = {
105
+ type: 'step_finish',
106
+ part: {
107
+ reason: 'stop',
108
+ cost: this.lastCost,
109
+ tokens: this.lastTokens
110
+ ? {
111
+ input: this.lastTokens.input || 0,
112
+ output: this.lastTokens.output || 0,
113
+ reasoning: this.lastTokens.reasoning || 0,
114
+ cache: { read: this.lastTokens.cache?.read || 0, write: this.lastTokens.cache?.write || 0 },
115
+ }
116
+ : undefined,
117
+ },
118
+ };
119
+ const events = this.parser.parseEvent(syntheticStepFinish);
120
+ this.toolStarted.clear();
121
+ this.toolInputSent.clear();
122
+ this.toolDone.clear();
123
+ this.lastTokens = undefined;
124
+ this.lastCost = undefined;
125
+ return events;
126
+ }
127
+ }