tide-commander 1.153.0 → 1.154.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 (70) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/assets/{BossLogsModal-Dgz-N5gw.js → BossLogsModal-Bo0pUCWU.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BK_rYu8s.js → BossSpawnModal-BOOHaqaD.js} +1 -1
  4. package/dist/assets/{ControlsModal-hC_AiCpk.js → ControlsModal-DItGV-CW.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-D7ogyaZr.js → DockerLogsModal-DjAZ3GL7.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-BFzUOmwz.js → EmbeddedEditor-Cihkr8Xb.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-C_Yc5Ypv.js → GmailOAuthSetup-G1XqiM44.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-CefQSOYm.js → GoogleOAuthSetup-Ce6gojdm.js} +1 -1
  9. package/dist/assets/{IframeModal-BbeuiE04.js → IframeModal-CSSZJzPv.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-xh3TgJLH.js → IntegrationsPanel-EspPH09n.js} +2 -2
  11. package/dist/assets/{LogViewerModal-CE2L8nWJ.js → LogViewerModal-Bn-HDysM.js} +1 -1
  12. package/dist/assets/{MonitoringModal-A2SN3uAJ.js → MonitoringModal-DuSLTKVR.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-DdlhCa8u.js → PM2LogsModal-Bo2OtaQd.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-BvHo-OxE.js → RestoreArchivedAreaModal-BOx-V6tl.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-t6RuUegr.js → Scene2DCanvas-BIUbi14G.js} +1 -1
  16. package/dist/assets/{SceneManager-TQ2_AvFn.js → SceneManager-CIWrGc-K.js} +4 -4
  17. package/dist/assets/{SkillsPanel-PjzjcaoG.js → SkillsPanel-_KHtTDhC.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-ms4r32Z8.js → SlackMultiInstanceSetup-DbQ3rUtd.js} +1 -1
  19. package/dist/assets/{SpawnModal-B1FS8m9h.js → SpawnModal-CYEoOMPW.js} +1 -1
  20. package/dist/assets/{StatisticsModal-mmz8hnD5.js → StatisticsModal-BJ-coeis.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-BXoG-Xgd.js → SubordinateAssignmentModal-5Z3jvbM9.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-ODJyjvY-.js → TriggerManagerPanel-DvC0xP9r.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-9wtY0Tn3.js → WorkflowEditorPanel-6ym1_csr.js} +1 -1
  24. package/dist/assets/{area-logos-BLyzmh47.js → area-logos-mWPCtCnf.js} +1 -1
  25. package/dist/assets/{index-CN2p2fxH.js → index-8VVbTmtb.js} +1 -1
  26. package/dist/assets/index-BPjQZacc.css +1 -0
  27. package/dist/assets/index-C34l72MW.js +1 -0
  28. package/dist/assets/{index-1hwiSf7o.js → index-CS8OutmQ.js} +1 -1
  29. package/dist/assets/{index-Cf-PINah.js → index-CT2vtbed.js} +3 -3
  30. package/dist/assets/{index-D8vFfLs4.js → index-CY_NzVPm.js} +1 -1
  31. package/dist/assets/index-CiKbhMfN.js +1 -0
  32. package/dist/assets/{index-Ci2ZKu0n.js → index-CnESn83U.js} +2 -2
  33. package/dist/assets/{index-CBkh8xcE.js → index-DZiNmiyC.js} +1 -1
  34. package/dist/assets/{index-CdbLxNJr.js → index-DxyZ-rYT.js} +1 -1
  35. package/dist/assets/main-BHBs2E1L.css +1 -0
  36. package/dist/assets/{main-9TcmAtcq.js → main-D5j8Ul0m.js} +113 -113
  37. package/dist/assets/{web-BVIPIXA1.js → web-BvAQVbIF.js} +1 -1
  38. package/dist/assets/{web-BAkfcL4w.js → web-Cd0-XOkz.js} +1 -1
  39. package/dist/assets/{web-Cz3TYK-R.js → web-DMxl9AVF.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 +5 -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/routes/agents.js +29 -1
  59. package/dist/src/packages/server/routes/system.js +110 -1
  60. package/dist/src/packages/server/runtime/codex-runner-router.js +99 -0
  61. package/dist/src/packages/server/runtime/codex-runtime-provider.js +5 -3
  62. package/dist/src/packages/server/services/claude-credentials-service.js +208 -1
  63. package/dist/src/packages/server/services/claude-usage-service.js +35 -28
  64. package/dist/src/packages/server/services/provider-credentials-service.js +338 -0
  65. package/dist/src/packages/server/services/system-prompt-service.js +46 -0
  66. package/package.json +1 -1
  67. package/dist/assets/index-CbfoJ_Vc.js +0 -1
  68. package/dist/assets/index-E5IMeM80.js +0 -1
  69. package/dist/assets/index-XI0_ICy6.css +0 -1
  70. 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
+ }
@@ -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 } from '../services/system-prompt-service.js';
21
+ import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnabled, setEchoPromptEnabled, getCodexBinaryPath, setCodexBinaryPath, isTmuxModeEnabled, setTmuxModeEnabled, isInteractiveModeEnabled, setInteractiveModeEnabled, isCodexAppServerModeEnabled, setCodexAppServerModeEnabled } 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';
@@ -1501,6 +1501,34 @@ router.post('/system-settings/interactive-mode', (req, res) => {
1501
1501
  res.status(500).json({ error: err.message });
1502
1502
  }
1503
1503
  });
1504
+ // GET /api/system-settings/codex-app-server-mode - Get experimental Codex app-server (streaming) mode setting
1505
+ router.get('/system-settings/codex-app-server-mode', (_req, res) => {
1506
+ try {
1507
+ const enabled = isCodexAppServerModeEnabled();
1508
+ res.json({ enabled });
1509
+ }
1510
+ catch (err) {
1511
+ log.error(' Failed to get codex app-server mode setting:', err);
1512
+ res.status(500).json({ error: err.message });
1513
+ }
1514
+ });
1515
+ // POST /api/system-settings/codex-app-server-mode - Update experimental Codex app-server (streaming) mode setting
1516
+ router.post('/system-settings/codex-app-server-mode', (req, res) => {
1517
+ try {
1518
+ const { enabled } = req.body;
1519
+ if (typeof enabled !== 'boolean') {
1520
+ res.status(400).json({ error: 'enabled must be a boolean' });
1521
+ return;
1522
+ }
1523
+ setCodexAppServerModeEnabled(enabled);
1524
+ log.log(` Codex app-server mode setting updated: enabled=${enabled}`);
1525
+ res.json({ success: true, enabled });
1526
+ }
1527
+ catch (err) {
1528
+ log.error(' Failed to set codex app-server mode setting:', err);
1529
+ res.status(500).json({ error: err.message });
1530
+ }
1531
+ });
1504
1532
  // GET /api/system-settings/backup - Get hourly backup scheduler status
1505
1533
  router.get('/system-settings/backup', (_req, res) => {
1506
1534
  try {
@@ -11,7 +11,8 @@ import { createLogger } from '../utils/logger.js';
11
11
  import { fetchLatestNpmVersion, getVersionRelation } from '../../shared/version.js';
12
12
  import { endUpdate, getInstallInfo, isAutoUpdateSupported, isUpdateInProgress, runNpmGlobalUpdate, schedulePostUpdateRestart, tryBeginUpdate, } from '../services/self-update-service.js';
13
13
  import { getAutoUpdateStatus, setAutoUpdateEnabled } from '../services/auto-update-service.js';
14
- import { deleteClaudeCredentialProfile, listClaudeCredentialProfiles, renameClaudeCredentialProfile, saveActiveClaudeCredentialProfile, switchClaudeCredentialProfile, } from '../services/claude-credentials-service.js';
14
+ import { deleteClaudeCredentialProfile, getClaudeCredentialProfilesUsage, listClaudeCredentialProfiles, renameClaudeCredentialProfile, saveActiveClaudeCredentialProfile, switchClaudeCredentialProfile, } from '../services/claude-credentials-service.js';
15
+ import { deleteProviderCredentialProfile, listProviderCredentialProfiles, renameProviderCredentialProfile, saveActiveProviderCredentialProfile, switchProviderCredentialProfile, } from '../services/provider-credentials-service.js';
15
16
  const log = createLogger('SystemRoutes');
16
17
  const router = Router();
17
18
  const PACKAGE_NAME = 'tide-commander';
@@ -429,6 +430,21 @@ router.get('/claude-credentials', (_req, res) => {
429
430
  res.status(500).json({ error: message });
430
431
  }
431
432
  });
433
+ /**
434
+ * Session (5h) + weekly rate-limit gauges per stored credential profile,
435
+ * keyed by token fingerprint. Cached server-side; expired dormant tokens are
436
+ * reported (with last-known gauges when available) instead of fetched.
437
+ */
438
+ router.get('/claude-credentials/usage', async (_req, res) => {
439
+ try {
440
+ res.json(await getClaudeCredentialProfilesUsage());
441
+ }
442
+ catch (err) {
443
+ const message = err.message;
444
+ log.error(`Failed to fetch Claude credentials usage: ${message}`);
445
+ res.status(500).json({ error: message });
446
+ }
447
+ });
432
448
  router.post('/claude-credentials/switch', (req, res) => {
433
449
  try {
434
450
  const name = req.body?.name;
@@ -503,4 +519,97 @@ router.delete('/claude-credentials/:name', (req, res) => {
503
519
  res.status(status).json({ error: message });
504
520
  }
505
521
  });
522
+ /**
523
+ * Grok + Codex OAuth credential profiles — same account-switcher model as
524
+ * Claude, generalized (~/.grok/auth.json, ~/.codex/auth.json + named copies).
525
+ * Mounted per provider at /grok-credentials/* and /codex-credentials/*.
526
+ * Tokens are never returned — only non-secret metadata + fingerprints.
527
+ */
528
+ function mountProviderCredentialRoutes(provider, basePath) {
529
+ router.get(basePath, (_req, res) => {
530
+ try {
531
+ res.json(listProviderCredentialProfiles(provider));
532
+ }
533
+ catch (err) {
534
+ const message = err.message;
535
+ log.error(`Failed to list ${provider} credentials: ${message}`);
536
+ res.status(500).json({ error: message });
537
+ }
538
+ });
539
+ router.post(`${basePath}/switch`, (req, res) => {
540
+ try {
541
+ const name = req.body?.name;
542
+ const stashActiveAs = req.body?.stashActiveAs;
543
+ if (typeof name !== 'string' || !name.trim()) {
544
+ res.status(400).json({ error: 'Body must include { name: string }' });
545
+ return;
546
+ }
547
+ if (stashActiveAs !== undefined && typeof stashActiveAs !== 'string') {
548
+ res.status(400).json({ error: 'stashActiveAs must be a string when provided' });
549
+ return;
550
+ }
551
+ res.json(switchProviderCredentialProfile(provider, name.trim(), {
552
+ stashActiveAs: typeof stashActiveAs === 'string' ? stashActiveAs.trim() : undefined,
553
+ }));
554
+ }
555
+ catch (err) {
556
+ const message = err.message;
557
+ log.error(`Failed to switch ${provider} credentials: ${message}`);
558
+ const status = /not found|Invalid|required|already exists|cannot be|no valid/i.test(message) ? 400 : 500;
559
+ res.status(status).json({ error: message });
560
+ }
561
+ });
562
+ router.post(`${basePath}/save`, (req, res) => {
563
+ try {
564
+ const name = req.body?.name;
565
+ const force = Boolean(req.body?.force);
566
+ if (typeof name !== 'string' || !name.trim()) {
567
+ res.status(400).json({ error: 'Body must include { name: string }' });
568
+ return;
569
+ }
570
+ res.json(saveActiveProviderCredentialProfile(provider, name.trim(), { force }));
571
+ }
572
+ catch (err) {
573
+ const message = err.message;
574
+ log.error(`Failed to save ${provider} credentials profile: ${message}`);
575
+ const status = /not found|Invalid|already exists|No valid/i.test(message) ? 400 : 500;
576
+ res.status(status).json({ error: message });
577
+ }
578
+ });
579
+ router.post(`${basePath}/rename`, (req, res) => {
580
+ try {
581
+ const from = req.body?.from;
582
+ const to = req.body?.to;
583
+ if (typeof from !== 'string' || !from.trim() || typeof to !== 'string' || !to.trim()) {
584
+ res.status(400).json({ error: 'Body must include { from: string, to: string }' });
585
+ return;
586
+ }
587
+ res.json(renameProviderCredentialProfile(provider, from.trim(), to.trim()));
588
+ }
589
+ catch (err) {
590
+ const message = err.message;
591
+ log.error(`Failed to rename ${provider} credentials profile: ${message}`);
592
+ const status = /not found|Invalid|already exists/i.test(message) ? 400 : 500;
593
+ res.status(status).json({ error: message });
594
+ }
595
+ });
596
+ router.delete(`${basePath}/:name`, (req, res) => {
597
+ try {
598
+ const name = typeof req.params.name === 'string' ? req.params.name : '';
599
+ if (!name) {
600
+ res.status(400).json({ error: 'Profile name required' });
601
+ return;
602
+ }
603
+ res.json(deleteProviderCredentialProfile(provider, name));
604
+ }
605
+ catch (err) {
606
+ const message = err.message;
607
+ log.error(`Failed to delete ${provider} credentials profile: ${message}`);
608
+ const status = /not found|Invalid/i.test(message) ? 400 : 500;
609
+ res.status(status).json({ error: message });
610
+ }
611
+ });
612
+ }
613
+ mountProviderCredentialRoutes('grok', '/grok-credentials');
614
+ mountProviderCredentialRoutes('codex', '/codex-credentials');
506
615
  export default router;