tide-commander 1.8.2 → 1.8.3
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/{index-rAvAlVdj.js → index-B6Hy-8p3.js} +2 -2
- package/dist/assets/{main-DscwhTSu.js → main-BkgbNnt9.js} +88 -88
- package/dist/assets/main-CfLrhuQx.css +1 -0
- package/dist/assets/{web-BSATmfpK.js → web-Cc07_VE8.js} +1 -1
- package/dist/assets/{web-mzT7C3OO.js → web-lhvbQgV6.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/claude/runner/recovery-store.js +15 -0
- package/dist/src/packages/server/codex/json-event-parser.js +37 -6
- package/dist/src/packages/server/services/agent-service.js +119 -3
- package/dist/src/packages/server/services/runtime-command-execution.js +4 -1
- package/dist/src/packages/server/services/runtime-events.js +43 -13
- package/dist/src/packages/server/services/runtime-service.js +4 -4
- package/dist/src/packages/server/services/runtime-status-sync.js +10 -32
- package/dist/src/packages/server/services/runtime-watchdog.js +7 -0
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +29 -1
- package/package.json +1 -1
- package/dist/assets/main-B8W4PG4C.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as a}from"./main-
|
|
1
|
+
import{W as a}from"./main-BkgbNnt9.js";import{ImpactStyle as i,NotificationType as r}from"./index-B6Hy-8p3.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react-uS-d4TUT.js";import"./vendor-three-DJ4p3FLF.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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as s}from"./main-
|
|
1
|
+
import{W as s}from"./main-BkgbNnt9.js";import"./modulepreload-polyfill-B5Qt9EMX.js";import"./vendor-react-uS-d4TUT.js";import"./vendor-three-DJ4p3FLF.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};
|
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-BkgbNnt9.js"></script>
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-uS-d4TUT.js">
|
|
28
28
|
<link rel="modulepreload" crossorigin href="/assets/vendor-three-DJ4p3FLF.js">
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/main-CfLrhuQx.css">
|
|
30
30
|
</head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="app"></div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { saveRunningProcesses, loadRunningProcesses, isProcessRunning, clearRunningProcesses, } from '../../data/index.js';
|
|
2
|
+
import * as agentService from '../../services/agent-service.js';
|
|
2
3
|
import { createLogger } from '../../utils/logger.js';
|
|
3
4
|
const log = createLogger('Runner');
|
|
4
5
|
export class RunnerRecoveryStore {
|
|
@@ -14,6 +15,7 @@ export class RunnerRecoveryStore {
|
|
|
14
15
|
const processes = [];
|
|
15
16
|
for (const [agentId, activeProcess] of this.activeProcesses) {
|
|
16
17
|
if (activeProcess.process.pid) {
|
|
18
|
+
const agent = agentService.getAgent(agentId);
|
|
17
19
|
processes.push({
|
|
18
20
|
agentId,
|
|
19
21
|
pid: activeProcess.process.pid,
|
|
@@ -22,6 +24,7 @@ export class RunnerRecoveryStore {
|
|
|
22
24
|
outputFile: activeProcess.outputFile,
|
|
23
25
|
stderrFile: activeProcess.stderrFile,
|
|
24
26
|
lastRequest: activeProcess.lastRequest,
|
|
27
|
+
agentStatus: agent?.status,
|
|
25
28
|
});
|
|
26
29
|
}
|
|
27
30
|
}
|
|
@@ -48,6 +51,12 @@ export class RunnerRecoveryStore {
|
|
|
48
51
|
}
|
|
49
52
|
log.log(`❌ Process for agent ${savedProcess.agentId} (PID ${savedProcess.pid}) is no longer running`);
|
|
50
53
|
if (!this.backend.requiresStdinInput() && savedProcess.sessionId && savedProcess.lastRequest) {
|
|
54
|
+
// Only resume agents that were actively working when persisted.
|
|
55
|
+
// Idle agents with live processes (e.g. Codex waiting for stdin) should not be resumed.
|
|
56
|
+
if (savedProcess.agentStatus && savedProcess.agentStatus !== 'working') {
|
|
57
|
+
log.log(`🔄 [RESUME] Skipping resume for agent ${savedProcess.agentId} - was ${savedProcess.agentStatus} (not working)`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
51
60
|
toResume.push(savedProcess);
|
|
52
61
|
}
|
|
53
62
|
}
|
|
@@ -57,6 +66,12 @@ export class RunnerRecoveryStore {
|
|
|
57
66
|
}
|
|
58
67
|
setTimeout(() => {
|
|
59
68
|
for (const saved of toResume) {
|
|
69
|
+
// Verify the agent still exists (it may have been deleted)
|
|
70
|
+
const agent = agentService.getAgent(saved.agentId);
|
|
71
|
+
if (!agent) {
|
|
72
|
+
log.log(`🔄 [RESUME] Skipping resume for agent ${saved.agentId} - agent no longer exists`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
60
75
|
const lastRequest = saved.lastRequest;
|
|
61
76
|
log.log(`🔄 [RESUME] Resuming codex session for agent ${saved.agentId} (session ${saved.sessionId})`);
|
|
62
77
|
this.run({
|
|
@@ -102,9 +102,17 @@ function parseResponsePayload(payload) {
|
|
|
102
102
|
return {
|
|
103
103
|
type: asString(payload.type),
|
|
104
104
|
status: asString(payload.status),
|
|
105
|
+
text: asString(payload.text),
|
|
105
106
|
action: parseAction(payload.action),
|
|
106
107
|
item: parseItem(payload.item),
|
|
107
108
|
usage: parseUsage(payload.usage),
|
|
109
|
+
info: isObject(payload.info)
|
|
110
|
+
? {
|
|
111
|
+
model_context_window: asNumber(payload.info.model_context_window),
|
|
112
|
+
last_token_usage: parseUsage(payload.info.last_token_usage),
|
|
113
|
+
}
|
|
114
|
+
: undefined,
|
|
115
|
+
model_context_window: asNumber(payload.model_context_window),
|
|
108
116
|
call_id: asString(payload.call_id),
|
|
109
117
|
name: asString(payload.name),
|
|
110
118
|
input: asString(payload.input),
|
|
@@ -119,6 +127,7 @@ function parseResponsePayload(payload) {
|
|
|
119
127
|
export class CodexJsonEventParser {
|
|
120
128
|
activeToolByItemId = new Map();
|
|
121
129
|
lastAgentMessageText;
|
|
130
|
+
lastModelUsageSnapshot;
|
|
122
131
|
enableFileDiffEnrichment;
|
|
123
132
|
workingDirectory;
|
|
124
133
|
gitRootCache = new Map();
|
|
@@ -148,7 +157,7 @@ export class CodexJsonEventParser {
|
|
|
148
157
|
if (!event?.type)
|
|
149
158
|
return [];
|
|
150
159
|
if (event.type === 'event_msg') {
|
|
151
|
-
return this.parseEventMsg(
|
|
160
|
+
return this.parseEventMsg(event.payload);
|
|
152
161
|
}
|
|
153
162
|
if (event.type === 'response_item') {
|
|
154
163
|
return this.parseResponseItem(event.payload);
|
|
@@ -172,15 +181,19 @@ export class CodexJsonEventParser {
|
|
|
172
181
|
}
|
|
173
182
|
return [this.buildUnknownEventFallback(`Unhandled Codex event type: ${event.type}`, rawEvent)];
|
|
174
183
|
}
|
|
175
|
-
parseEventMsg(
|
|
176
|
-
if (!
|
|
177
|
-
return [];
|
|
178
|
-
const payload = rawEvent.payload;
|
|
179
|
-
if (!isObject(payload))
|
|
184
|
+
parseEventMsg(payload) {
|
|
185
|
+
if (!payload)
|
|
180
186
|
return [];
|
|
181
187
|
const payloadType = asString(payload.type);
|
|
182
188
|
// token_count: Silent. Token accounting is handled by turn.completed.
|
|
183
189
|
if (payloadType === 'token_count') {
|
|
190
|
+
const usage = payload.info?.last_token_usage;
|
|
191
|
+
this.lastModelUsageSnapshot = {
|
|
192
|
+
contextWindow: payload.info?.model_context_window ?? this.lastModelUsageSnapshot?.contextWindow,
|
|
193
|
+
inputTokens: usage?.input_tokens,
|
|
194
|
+
outputTokens: usage?.output_tokens,
|
|
195
|
+
cacheReadInputTokens: usage?.cached_input_tokens,
|
|
196
|
+
};
|
|
184
197
|
return [];
|
|
185
198
|
}
|
|
186
199
|
// agent_reasoning: Map to thinking event
|
|
@@ -196,6 +209,12 @@ export class CodexJsonEventParser {
|
|
|
196
209
|
}
|
|
197
210
|
// task_started: Silent. Envelope event with no user-facing content.
|
|
198
211
|
if (payloadType === 'task_started') {
|
|
212
|
+
if (payload.model_context_window) {
|
|
213
|
+
this.lastModelUsageSnapshot = {
|
|
214
|
+
...this.lastModelUsageSnapshot,
|
|
215
|
+
contextWindow: payload.model_context_window,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
199
218
|
return [];
|
|
200
219
|
}
|
|
201
220
|
// user_message / agent_message are handled via item.completed, skip if seen here
|
|
@@ -434,6 +453,18 @@ export class CodexJsonEventParser {
|
|
|
434
453
|
event.resultText = this.lastAgentMessageText;
|
|
435
454
|
this.lastAgentMessageText = undefined; // Reset for next turn
|
|
436
455
|
}
|
|
456
|
+
if (this.lastModelUsageSnapshot && (this.lastModelUsageSnapshot.contextWindow
|
|
457
|
+
|| this.lastModelUsageSnapshot.inputTokens !== undefined
|
|
458
|
+
|| this.lastModelUsageSnapshot.outputTokens !== undefined
|
|
459
|
+
|| this.lastModelUsageSnapshot.cacheReadInputTokens !== undefined)) {
|
|
460
|
+
event.modelUsage = {
|
|
461
|
+
contextWindow: this.lastModelUsageSnapshot.contextWindow,
|
|
462
|
+
inputTokens: this.lastModelUsageSnapshot.inputTokens ?? 0,
|
|
463
|
+
outputTokens: this.lastModelUsageSnapshot.outputTokens ?? 0,
|
|
464
|
+
cacheReadInputTokens: this.lastModelUsageSnapshot.cacheReadInputTokens ?? 0,
|
|
465
|
+
};
|
|
466
|
+
this.lastModelUsageSnapshot = undefined;
|
|
467
|
+
}
|
|
437
468
|
return [event];
|
|
438
469
|
}
|
|
439
470
|
parseFileChange(item) {
|
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
* Business logic for managing agents
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
import { loadAgents, saveAgents, saveAgentsAsync, getDataDir } from '../data/index.js';
|
|
7
9
|
import { listSessions, getSessionSummary, loadSession, loadToolHistory, searchSession, } from '../claude/session-loader.js';
|
|
8
10
|
import { loadSubagentHistory } from '../claude/subagent-history-loader.js';
|
|
9
11
|
import { logger, generateId } from '../utils/index.js';
|
|
10
12
|
const log = logger.agent;
|
|
11
13
|
const CLAUDE_MODELS = new Set(['sonnet', 'opus', 'haiku']);
|
|
14
|
+
const DEFAULT_CLAUDE_CONTEXT_LIMIT = 200000;
|
|
15
|
+
const DEFAULT_CODEX_CONTEXT_LIMIT = 258400;
|
|
16
|
+
function getDefaultContextLimit(provider) {
|
|
17
|
+
return provider === 'codex' ? DEFAULT_CODEX_CONTEXT_LIMIT : DEFAULT_CLAUDE_CONTEXT_LIMIT;
|
|
18
|
+
}
|
|
12
19
|
// In-memory agent storage
|
|
13
20
|
const agents = new Map();
|
|
14
21
|
const listeners = new Set();
|
|
@@ -29,6 +36,101 @@ export function sanitizeCodexModel(model) {
|
|
|
29
36
|
const trimmed = model.trim();
|
|
30
37
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
31
38
|
}
|
|
39
|
+
function findFileRecursively(rootDir, pattern) {
|
|
40
|
+
if (!fs.existsSync(rootDir))
|
|
41
|
+
return null;
|
|
42
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
const nested = findFileRecursively(fullPath, pattern);
|
|
47
|
+
if (nested)
|
|
48
|
+
return nested;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (entry.isFile() && entry.name.includes(pattern) && entry.name.endsWith('.jsonl')) {
|
|
52
|
+
return fullPath;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function findCodexRolloutPath(sessionId) {
|
|
58
|
+
const codexHome = path.join(os.homedir(), '.codex');
|
|
59
|
+
return findFileRecursively(path.join(codexHome, 'sessions'), sessionId)
|
|
60
|
+
|| findFileRecursively(path.join(codexHome, 'archived_sessions'), sessionId);
|
|
61
|
+
}
|
|
62
|
+
function getCodexLogPath() {
|
|
63
|
+
return path.join(os.homedir(), '.codex', 'log', 'codex-tui.log');
|
|
64
|
+
}
|
|
65
|
+
function parseCodexEstimatedContextSnapshot(sessionId, contextLimit) {
|
|
66
|
+
const logPath = getCodexLogPath();
|
|
67
|
+
if (!fs.existsSync(logPath))
|
|
68
|
+
return null;
|
|
69
|
+
const lines = fs.readFileSync(logPath, 'utf8').split('\n');
|
|
70
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
71
|
+
const line = lines[index];
|
|
72
|
+
if (!line.includes(sessionId) || !line.includes('estimated_token_count=Some('))
|
|
73
|
+
continue;
|
|
74
|
+
const estimatedMatch = line.match(/estimated_token_count=Some\((\d+)\)/);
|
|
75
|
+
if (!estimatedMatch)
|
|
76
|
+
continue;
|
|
77
|
+
const estimatedTokens = Number(estimatedMatch[1]);
|
|
78
|
+
if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0)
|
|
79
|
+
continue;
|
|
80
|
+
return {
|
|
81
|
+
contextUsed: Math.max(0, Math.round(estimatedTokens)),
|
|
82
|
+
contextLimit,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function parseCodexContextSnapshot(rolloutPath) {
|
|
88
|
+
if (!fs.existsSync(rolloutPath))
|
|
89
|
+
return null;
|
|
90
|
+
const lines = fs.readFileSync(rolloutPath, 'utf8').split('\n');
|
|
91
|
+
let contextLimit = DEFAULT_CODEX_CONTEXT_LIMIT;
|
|
92
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
93
|
+
const line = lines[index].trim();
|
|
94
|
+
if (!line)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(line);
|
|
98
|
+
const payload = parsed?.payload;
|
|
99
|
+
if (payload?.type === 'token_count' && payload.info) {
|
|
100
|
+
const modelContextWindow = Number(payload.info.model_context_window);
|
|
101
|
+
if (Number.isFinite(modelContextWindow) && modelContextWindow > 0) {
|
|
102
|
+
contextLimit = modelContextWindow;
|
|
103
|
+
}
|
|
104
|
+
const inputTokens = Number(payload.info?.last_token_usage?.input_tokens);
|
|
105
|
+
if (Number.isFinite(inputTokens) && inputTokens >= 0) {
|
|
106
|
+
return {
|
|
107
|
+
contextUsed: Math.min(Math.round(inputTokens), contextLimit),
|
|
108
|
+
contextLimit,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (payload?.type === 'task_started') {
|
|
113
|
+
const modelContextWindow = Number(payload.model_context_window);
|
|
114
|
+
if (Number.isFinite(modelContextWindow) && modelContextWindow > 0) {
|
|
115
|
+
contextLimit = modelContextWindow;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Ignore malformed lines and continue scanning older entries.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
export function getCodexContextSnapshotFromSession(sessionId) {
|
|
126
|
+
if (!sessionId)
|
|
127
|
+
return null;
|
|
128
|
+
const rolloutPath = findCodexRolloutPath(sessionId);
|
|
129
|
+
const rolloutSnapshot = rolloutPath ? parseCodexContextSnapshot(rolloutPath) : null;
|
|
130
|
+
const contextLimit = rolloutSnapshot?.contextLimit ?? DEFAULT_CODEX_CONTEXT_LIMIT;
|
|
131
|
+
const estimatedSnapshot = parseCodexEstimatedContextSnapshot(sessionId, contextLimit);
|
|
132
|
+
return estimatedSnapshot ?? rolloutSnapshot;
|
|
133
|
+
}
|
|
32
134
|
// ============================================================================
|
|
33
135
|
// Initialization
|
|
34
136
|
// ============================================================================
|
|
@@ -36,16 +138,29 @@ export function initAgents() {
|
|
|
36
138
|
try {
|
|
37
139
|
const storedAgents = loadAgents();
|
|
38
140
|
for (const stored of storedAgents) {
|
|
39
|
-
const
|
|
141
|
+
const isCodexProvider = (stored.provider ?? 'claude') === 'codex';
|
|
142
|
+
const repairedCodexContext = isCodexProvider
|
|
143
|
+
? getCodexContextSnapshotFromSession(stored.sessionId)
|
|
144
|
+
: null;
|
|
145
|
+
const defaultContextLimit = getDefaultContextLimit(stored.provider ?? 'claude');
|
|
146
|
+
const migratedPersistedContextLimit = isCodexProvider
|
|
147
|
+
&& (stored.contextLimit === undefined || stored.contextLimit === DEFAULT_CLAUDE_CONTEXT_LIMIT)
|
|
148
|
+
? defaultContextLimit
|
|
149
|
+
: stored.contextLimit;
|
|
150
|
+
const contextLimit = repairedCodexContext?.contextLimit ?? migratedPersistedContextLimit ?? defaultContextLimit;
|
|
40
151
|
const tokensUsed = stored.tokensUsed ?? 0;
|
|
41
152
|
// Preserve persisted context usage. Falling back to lifetime tokens can
|
|
42
153
|
// inflate context on restart because tokensUsed is cumulative over time.
|
|
43
154
|
const persistedContextUsed = typeof stored.contextUsed === 'number'
|
|
44
155
|
? stored.contextUsed
|
|
45
156
|
: tokensUsed;
|
|
157
|
+
const baseContextUsed = repairedCodexContext?.contextUsed ?? persistedContextUsed;
|
|
46
158
|
// Don't clamp to contextLimit - contextUsed can legitimately exceed the default
|
|
47
159
|
// 200k limit for models with larger context windows (up to 1M).
|
|
48
|
-
const contextUsed = Math.max(0,
|
|
160
|
+
const contextUsed = Math.max(0, baseContextUsed);
|
|
161
|
+
const clearStaleContextStats = isCodexProvider
|
|
162
|
+
&& stored.contextStats
|
|
163
|
+
&& (stored.contextStats.contextWindow !== contextLimit || stored.contextStats.totalTokens !== contextUsed);
|
|
49
164
|
const agent = {
|
|
50
165
|
...stored,
|
|
51
166
|
status: 'idle', // Ready to receive commands
|
|
@@ -56,6 +171,7 @@ export function initAgents() {
|
|
|
56
171
|
// Ensure context fields have defaults (migration for existing agents)
|
|
57
172
|
contextUsed,
|
|
58
173
|
contextLimit,
|
|
174
|
+
contextStats: clearStaleContextStats ? undefined : stored.contextStats,
|
|
59
175
|
taskCount: stored.taskCount ?? 0, // Migration for existing agents
|
|
60
176
|
permissionMode: stored.permissionMode ?? 'bypass', // Migration for existing agents
|
|
61
177
|
useChrome: stored.useChrome, // Restore Chrome flag
|
|
@@ -169,7 +285,7 @@ export async function createAgent(name, agentClass, cwd, position, sessionId, us
|
|
|
169
285
|
codexConfig,
|
|
170
286
|
tokensUsed: 0,
|
|
171
287
|
contextUsed: 0,
|
|
172
|
-
contextLimit:
|
|
288
|
+
contextLimit: getDefaultContextLimit(provider),
|
|
173
289
|
taskCount: 0, // Initialize task counter
|
|
174
290
|
createdAt: Date.now(),
|
|
175
291
|
lastActivity: Date.now(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as agentService from './agent-service.js';
|
|
2
|
-
import { clearPendingSilentContextRefresh, hasPendingSilentContextRefresh, markPendingSilentContextRefresh, startStdinWatchdog, } from './runtime-watchdog.js';
|
|
2
|
+
import { clearPendingSilentContextRefresh, clearStdinWatchdog, hasPendingSilentContextRefresh, markPendingSilentContextRefresh, startStdinWatchdog, } from './runtime-watchdog.js';
|
|
3
3
|
export function createRuntimeCommandExecution(deps) {
|
|
4
4
|
const { log, getRunner, getRunnerForAgent, notifyCommandStarted, emitOutput, killDetachedProviderProcessInCwd, } = deps;
|
|
5
5
|
async function executeCommand(agentId, command, systemPrompt, forceNewSession, customAgent, silent) {
|
|
@@ -139,6 +139,9 @@ export function createRuntimeCommandExecution(deps) {
|
|
|
139
139
|
await executeCommand(agentId, command, undefined, undefined, undefined, true);
|
|
140
140
|
}
|
|
141
141
|
async function stopAgent(agentId) {
|
|
142
|
+
// Cancel any pending stdin watchdog timer to prevent it from respawning
|
|
143
|
+
// the process after we've stopped it
|
|
144
|
+
clearStdinWatchdog(agentId);
|
|
142
145
|
const runner = getRunnerForAgent(agentId);
|
|
143
146
|
if (runner) {
|
|
144
147
|
await runner.stop(agentId);
|
|
@@ -3,7 +3,8 @@ import * as agentService from './agent-service.js';
|
|
|
3
3
|
import * as supervisorService from './supervisor-service.js';
|
|
4
4
|
import { clearPendingSilentContextRefresh, consumeStepCompleteReceived, markStepCompleteReceived, } from './runtime-watchdog.js';
|
|
5
5
|
import { handleTaskToolResult, handleTaskToolStart } from './runtime-subagents.js';
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_CLAUDE_CONTEXT_WINDOW = 200000;
|
|
7
|
+
const DEFAULT_CODEX_CONTEXT_WINDOW = 258400;
|
|
7
8
|
const CODEX_ROLLING_CONTEXT_TURNS = 40;
|
|
8
9
|
const CODEX_PLAUSIBLE_USAGE_MULTIPLIER = 1.2;
|
|
9
10
|
const CODEX_RECOVERABLE_RESUME_ERRORS = [
|
|
@@ -36,6 +37,9 @@ function estimateTokensFromText(text) {
|
|
|
36
37
|
return 0;
|
|
37
38
|
return Math.max(1, Math.ceil(normalized.length / 4));
|
|
38
39
|
}
|
|
40
|
+
function getDefaultContextWindow(provider) {
|
|
41
|
+
return provider === 'codex' ? DEFAULT_CODEX_CONTEXT_WINDOW : DEFAULT_CLAUDE_CONTEXT_WINDOW;
|
|
42
|
+
}
|
|
39
43
|
function updateCodexRollingContextEstimate(agentId, turnGrowth) {
|
|
40
44
|
const history = codexContextGrowthHistory.get(agentId) || [];
|
|
41
45
|
history.push(Math.max(0, Math.round(turnGrowth)));
|
|
@@ -65,7 +69,7 @@ function buildCodexRecoverySystemPrompt(sessionId, messages) {
|
|
|
65
69
|
].join('\n\n');
|
|
66
70
|
}
|
|
67
71
|
function buildEstimatedContextStats(totalTokens, contextWindow, model) {
|
|
68
|
-
const safeWindow = contextWindow > 0 ? contextWindow :
|
|
72
|
+
const safeWindow = contextWindow > 0 ? contextWindow : DEFAULT_CLAUDE_CONTEXT_WINDOW;
|
|
69
73
|
const usedPercent = Math.min(100, Math.max(0, Math.round((totalTokens / safeWindow) * 100)));
|
|
70
74
|
const freeTokens = Math.max(0, safeWindow - totalTokens);
|
|
71
75
|
const messagesPercent = Number(((totalTokens / safeWindow) * 100).toFixed(1));
|
|
@@ -179,7 +183,7 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
179
183
|
// Output tokens are the model's response and don't count toward the limit.
|
|
180
184
|
const snapshotContextUsed = cacheRead + cacheCreation + inputTokens;
|
|
181
185
|
if (snapshotContextUsed > 0) {
|
|
182
|
-
const effectiveLimit = agent.contextLimit ||
|
|
186
|
+
const effectiveLimit = agent.contextLimit || getDefaultContextWindow(agent.provider);
|
|
183
187
|
// Guard against cumulative session totals: if the sum exceeds the
|
|
184
188
|
// context window, it can't represent per-request context fill.
|
|
185
189
|
if (snapshotContextUsed > effectiveLimit) {
|
|
@@ -233,7 +237,7 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
233
237
|
const lastTask = agent.lastAssignedTask?.trim() || '';
|
|
234
238
|
const isContextCommand = lastTask === '/context' || lastTask === '/cost' || lastTask === '/compact';
|
|
235
239
|
let contextUsed = agent.contextUsed || 0;
|
|
236
|
-
let contextLimit = agent.contextLimit ||
|
|
240
|
+
let contextLimit = agent.contextLimit || getDefaultContextWindow(agent.provider);
|
|
237
241
|
// IMPORTANT: event.modelUsage is often {} (empty object) which is truthy.
|
|
238
242
|
// We must check that it has actual data before using it, otherwise we'd
|
|
239
243
|
// zero out contextUsed (since all fields would be undefined → 0).
|
|
@@ -262,12 +266,16 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
262
266
|
contextLimit = event.modelUsage.contextWindow;
|
|
263
267
|
}
|
|
264
268
|
const turnGrowthEstimate = estimateTokensFromText(agent.lastAssignedTask) + outputTokens;
|
|
265
|
-
|
|
269
|
+
updateCodexRollingContextEstimate(agentId, turnGrowthEstimate);
|
|
266
270
|
const plausibleSnapshotLimit = contextLimit * CODEX_PLAUSIBLE_USAGE_MULTIPLIER;
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
const hasAuthoritativeSnapshot = inputTokens > 0 && inputTokens <= plausibleSnapshotLimit;
|
|
272
|
+
if (hasAuthoritativeSnapshot) {
|
|
273
|
+
contextUsed = inputTokens;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const rollingEstimate = updateCodexRollingContextEstimate(agentId, 0);
|
|
277
|
+
contextUsed = rollingEstimate;
|
|
278
|
+
}
|
|
271
279
|
log.log(`[step_complete] Codex modelUsage for ${agentId}: input=${inputTokens}, contextWindow=${event.modelUsage.contextWindow || 'none'}`);
|
|
272
280
|
}
|
|
273
281
|
else if (event.tokens) {
|
|
@@ -292,12 +300,12 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
292
300
|
const stats = agent.contextStats;
|
|
293
301
|
if (stats && stats.contextWindow > 0) {
|
|
294
302
|
contextUsed = Math.max(0, stats.totalTokens || 0);
|
|
295
|
-
contextLimit = Math.max(1, stats.contextWindow ||
|
|
303
|
+
contextLimit = Math.max(1, stats.contextWindow || getDefaultContextWindow(agent.provider));
|
|
296
304
|
log.log(`[step_complete] Context command for ${agentId}; preserving context from context_stats ${contextUsed}/${contextLimit}`);
|
|
297
305
|
}
|
|
298
306
|
else {
|
|
299
307
|
contextUsed = agent.contextUsed || 0;
|
|
300
|
-
contextLimit = agent.contextLimit ||
|
|
308
|
+
contextLimit = agent.contextLimit || getDefaultContextWindow(agent.provider);
|
|
301
309
|
log.log(`[step_complete] Context command for ${agentId}; preserving context values from tracked fields`);
|
|
302
310
|
}
|
|
303
311
|
}
|
|
@@ -380,12 +388,34 @@ export function createRuntimeEventHandlers(deps) {
|
|
|
380
388
|
}
|
|
381
389
|
function handleComplete(agentId, success) {
|
|
382
390
|
const receivedStepComplete = consumeStepCompleteReceived(agentId);
|
|
383
|
-
agentService.
|
|
391
|
+
const agent = agentService.getAgent(agentId);
|
|
392
|
+
const isCodexProvider = (agent?.provider ?? 'claude') === 'codex';
|
|
393
|
+
const finalCodexSnapshot = isCodexProvider
|
|
394
|
+
? agentService.getCodexContextSnapshotFromSession(agent?.sessionId)
|
|
395
|
+
: null;
|
|
396
|
+
const completionUpdates = {
|
|
384
397
|
status: 'idle',
|
|
385
398
|
currentTask: undefined,
|
|
386
399
|
currentTool: undefined,
|
|
387
400
|
isDetached: false,
|
|
388
|
-
}
|
|
401
|
+
};
|
|
402
|
+
if (finalCodexSnapshot) {
|
|
403
|
+
completionUpdates.contextUsed = finalCodexSnapshot.contextUsed;
|
|
404
|
+
completionUpdates.contextLimit = finalCodexSnapshot.contextLimit;
|
|
405
|
+
// Build proper contextStats from the snapshot. Don't blindly copy
|
|
406
|
+
// finalCodexSnapshot.contextStats — CodexContextSnapshot doesn't have
|
|
407
|
+
// that field, so it would be `undefined` and wipe the valid stats
|
|
408
|
+
// set by step_complete.
|
|
409
|
+
const existingStats = agent?.contextStats;
|
|
410
|
+
if (existingStats && existingStats.lastUpdated) {
|
|
411
|
+
completionUpdates.contextStats = updateContextStatsTokens(existingStats, finalCodexSnapshot.contextUsed, finalCodexSnapshot.contextLimit);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
completionUpdates.contextStats = buildEstimatedContextStats(finalCodexSnapshot.contextUsed, finalCodexSnapshot.contextLimit, agent?.codexModel || agent?.model);
|
|
415
|
+
}
|
|
416
|
+
log.log(`[complete] Refreshed Codex context for ${agentId}: ${finalCodexSnapshot.contextUsed}/${finalCodexSnapshot.contextLimit}`);
|
|
417
|
+
}
|
|
418
|
+
agentService.updateAgent(agentId, completionUpdates);
|
|
389
419
|
emitComplete(agentId, success);
|
|
390
420
|
// Real-time context tracking via usage_snapshot events replaces automatic /context refresh.
|
|
391
421
|
// The /context command is now only triggered manually via the UI refresh button.
|
|
@@ -238,9 +238,9 @@ function _checkForOrphanedProcess(agentId) {
|
|
|
238
238
|
return false;
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
export async function syncAgentStatus(agentId
|
|
242
|
-
await statusSync.syncAgentStatus(agentId
|
|
241
|
+
export async function syncAgentStatus(agentId) {
|
|
242
|
+
await statusSync.syncAgentStatus(agentId);
|
|
243
243
|
}
|
|
244
|
-
export async function syncAllAgentStatus(
|
|
245
|
-
await statusSync.syncAllAgentStatus(
|
|
244
|
+
export async function syncAllAgentStatus() {
|
|
245
|
+
await statusSync.syncAllAgentStatus();
|
|
246
246
|
}
|
|
@@ -36,15 +36,20 @@ export function createRuntimeStatusSync(deps) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
async function syncAgentStatus(agentId
|
|
39
|
+
async function syncAgentStatus(agentId) {
|
|
40
40
|
const agent = agentService.getAgent(agentId);
|
|
41
41
|
if (!agent)
|
|
42
42
|
return;
|
|
43
|
+
// Only working agents need sync — idle agents stay idle.
|
|
44
|
+
// We intentionally do NOT promote idle agents to working based on
|
|
45
|
+
// orphaned processes or session activity. That caused agents to
|
|
46
|
+
// incorrectly resume after backend restarts/reconnects.
|
|
47
|
+
if (agent.status !== 'working')
|
|
48
|
+
return;
|
|
43
49
|
const isTrackedProcess = getRunnerForAgent(agentId)?.isRunning(agentId) ?? false;
|
|
44
50
|
if (isTrackedProcess)
|
|
45
51
|
return;
|
|
46
52
|
let isRecentlyActive = false;
|
|
47
|
-
let hasOrphanedProcess = false;
|
|
48
53
|
if (agent.sessionId && agent.cwd) {
|
|
49
54
|
try {
|
|
50
55
|
const activity = await getSessionActivityStatus(agent.cwd, agent.sessionId, 60);
|
|
@@ -55,20 +60,8 @@ export function createRuntimeStatusSync(deps) {
|
|
|
55
60
|
catch {
|
|
56
61
|
// Session activity check failed, assume not active.
|
|
57
62
|
}
|
|
58
|
-
if (agent.status === 'idle') {
|
|
59
|
-
try {
|
|
60
|
-
const provider = agent.provider ?? 'claude';
|
|
61
|
-
hasOrphanedProcess = await isProviderProcessRunningInCwd(provider, agent.cwd);
|
|
62
|
-
if (hasOrphanedProcess) {
|
|
63
|
-
log.log(`[syncAgentStatus] Agent ${agentId}: Found orphaned ${provider} process, isRecentlyActive=${isRecentlyActive}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
log.error(`[syncAgentStatus] Agent ${agentId}: Failed to check for orphaned process:`, err);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
63
|
}
|
|
71
|
-
if (
|
|
64
|
+
if (!isRecentlyActive) {
|
|
72
65
|
agentService.updateAgent(agentId, {
|
|
73
66
|
status: 'idle',
|
|
74
67
|
currentTask: undefined,
|
|
@@ -76,25 +69,10 @@ export function createRuntimeStatusSync(deps) {
|
|
|
76
69
|
isDetached: false,
|
|
77
70
|
});
|
|
78
71
|
}
|
|
79
|
-
else if (agent.status === 'idle' && hasOrphanedProcess && isRecentlyActive) {
|
|
80
|
-
const provider = agent.provider ?? 'claude';
|
|
81
|
-
log.log(`Agent ${agentId} has orphaned ${provider} process with recent activity - marking as working (detached)`);
|
|
82
|
-
agentService.updateAgent(agentId, {
|
|
83
|
-
status: 'working',
|
|
84
|
-
currentTask: 'Processing (detached)...',
|
|
85
|
-
isDetached: true,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
else if (isStartupSync && agent.status === 'idle' && isRecentlyActive) {
|
|
89
|
-
agentService.updateAgent(agentId, {
|
|
90
|
-
status: 'working',
|
|
91
|
-
currentTask: 'Processing...',
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
72
|
}
|
|
95
|
-
async function syncAllAgentStatus(
|
|
73
|
+
async function syncAllAgentStatus() {
|
|
96
74
|
const agents = agentService.getAllAgents();
|
|
97
|
-
await Promise.all(agents.map((agent) => syncAgentStatus(agent.id
|
|
75
|
+
await Promise.all(agents.map((agent) => syncAgentStatus(agent.id)));
|
|
98
76
|
}
|
|
99
77
|
return {
|
|
100
78
|
pollOrphanedAgents,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as agentService from './agent-service.js';
|
|
1
2
|
import { logger } from '../utils/logger.js';
|
|
2
3
|
const log = logger.claude;
|
|
3
4
|
const pendingSilentContextRefresh = new Set();
|
|
@@ -32,6 +33,12 @@ export function startStdinWatchdog(options) {
|
|
|
32
33
|
log.log(`[STDIN-WATCHDOG] Starting watchdog for ${agentId}, timeout=${STDIN_ACTIVITY_TIMEOUT_MS}ms`);
|
|
33
34
|
const watchdogTimer = setTimeout(async () => {
|
|
34
35
|
stdinWatchdogTimers.delete(agentId);
|
|
36
|
+
// Don't respawn if the agent was explicitly stopped (status is idle)
|
|
37
|
+
const agent = agentService.getAgent(agentId);
|
|
38
|
+
if (!agent || agent.status !== 'working') {
|
|
39
|
+
log.log(`[STDIN-WATCHDOG] Agent ${agentId}: Skipping respawn - agent is ${agent?.status ?? 'not found'}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
35
42
|
if (runner && !runner.hasRecentActivity(agentId, STDIN_ACTIVITY_TIMEOUT_MS)) {
|
|
36
43
|
log.warn(`[STDIN-WATCHDOG] Agent ${agentId}: No activity after stdin message, respawning process...`);
|
|
37
44
|
await runner.stop(agentId);
|
|
@@ -440,7 +440,8 @@ function buildStatsFromTrackedData(agent) {
|
|
|
440
440
|
// Do NOT prefer agent.contextStats here — those can become stale when previous
|
|
441
441
|
// fallback calls save their (potentially wrong) output back to agent.contextStats
|
|
442
442
|
// via broadcastContextStats, creating a self-reinforcing feedback loop.
|
|
443
|
-
const
|
|
443
|
+
const defaultContextLimit = (agent.provider ?? 'claude') === 'codex' ? 258400 : 200000;
|
|
444
|
+
const contextLimit = Math.max(1, Math.round(agent.contextLimit || defaultContextLimit));
|
|
444
445
|
const rawContextUsed = Math.max(0, Math.round(agent.contextUsed || 0));
|
|
445
446
|
// Guard: contextUsed can never exceed contextLimit. Values above the limit are
|
|
446
447
|
// cumulative session totals from result events, not per-request context fill.
|
|
@@ -511,6 +512,7 @@ export async function handleRequestContextStats(ctx, payload) {
|
|
|
511
512
|
return;
|
|
512
513
|
}
|
|
513
514
|
const isClaudeProvider = (agent.provider ?? 'claude') === 'claude';
|
|
515
|
+
const isCodexProvider = (agent.provider ?? 'claude') === 'codex';
|
|
514
516
|
// For Claude agents with an active session, fetch real context stats from the CLI
|
|
515
517
|
if (isClaudeProvider && agent.sessionId) {
|
|
516
518
|
log.log(`[contextStats] Fetching real context from CLI for ${agent.name} (session=${agent.sessionId})`);
|
|
@@ -543,6 +545,32 @@ export async function handleRequestContextStats(ctx, payload) {
|
|
|
543
545
|
log.error(`[contextStats] In-session /context failed: ${err}`);
|
|
544
546
|
}
|
|
545
547
|
}
|
|
548
|
+
if (isCodexProvider && agent.sessionId) {
|
|
549
|
+
const codexSnapshot = agentService.getCodexContextSnapshotFromSession(agent.sessionId);
|
|
550
|
+
if (codexSnapshot) {
|
|
551
|
+
const contextLimit = Math.max(1, codexSnapshot.contextLimit);
|
|
552
|
+
const contextUsed = Math.max(0, Math.min(codexSnapshot.contextUsed, contextLimit));
|
|
553
|
+
const usedPercent = Math.min(100, Math.round((contextUsed / contextLimit) * 100));
|
|
554
|
+
const freeTokens = Math.max(0, contextLimit - contextUsed);
|
|
555
|
+
const stats = {
|
|
556
|
+
model: agent.codexModel || agent.model || 'codex',
|
|
557
|
+
contextWindow: contextLimit,
|
|
558
|
+
totalTokens: contextUsed,
|
|
559
|
+
usedPercent,
|
|
560
|
+
categories: {
|
|
561
|
+
systemPrompt: { tokens: 0, percent: 0 },
|
|
562
|
+
systemTools: { tokens: 0, percent: 0 },
|
|
563
|
+
messages: { tokens: contextUsed, percent: Number(((contextUsed / contextLimit) * 100).toFixed(1)) },
|
|
564
|
+
freeSpace: { tokens: freeTokens, percent: Number(((freeTokens / contextLimit) * 100).toFixed(1)) },
|
|
565
|
+
autocompactBuffer: { tokens: 0, percent: 0 },
|
|
566
|
+
},
|
|
567
|
+
lastUpdated: Date.now(),
|
|
568
|
+
};
|
|
569
|
+
log.log(`[contextStats] Codex session snapshot for ${agent.name}: ${contextUsed}/${contextLimit}`);
|
|
570
|
+
broadcastContextStats(ctx, payload.agentId, stats, 'from Codex session');
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
546
574
|
// Fallback: generate from tracked data
|
|
547
575
|
const latestAgent = agentService.getAgent(payload.agentId) || agent;
|
|
548
576
|
const stats = buildStatsFromTrackedData(latestAgent);
|