@foxden-app/foxclaw 0.6.8 → 0.6.10
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/CHANGELOG.md +20 -0
- package/dist/controller/session_observer.d.ts +7 -0
- package/dist/controller/session_observer.js +97 -16
- package/dist/lock.js +57 -7
- package/dist/main.js +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.6.10 - 2026-08-23
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复 Linux 重启后旧 `bridge.lock` 中的 PID 被无关进程复用时,FoxClaw 误判已有实例并陷入 systemd 重启失败的问题。新锁记录系统启动 ID 和进程启动标识,同时兼容清理上个系统启动遗留的纯 PID 锁。
|
|
9
|
+
- 修复 `doctor` 在显式配置的 `CODEX_CLI_BIN` 或 `OPENCODE_CLI_BIN` 不存在时仍因 PATH 中有同名命令而误报 `[OK]` 的问题;显式路径现在必须真实存在且可执行。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Fixed FoxClaw mistaking an unrelated process for the existing bridge when Linux reuses a PID left in an old `bridge.lock` after reboot. New locks record the boot ID and process start identity while still cleaning legacy PID-only locks from a previous boot.
|
|
13
|
+
- Fixed `doctor` reporting `[OK]` when an explicitly configured `CODEX_CLI_BIN` or `OPENCODE_CLI_BIN` is missing but a same-named command exists on PATH. Explicit paths must now exist and be executable.
|
|
14
|
+
|
|
15
|
+
## 0.6.9 - 2026-08-15
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 补齐 Codex CLI 0.147.0 会话日志的其他观察兼容:新的 `item_completed` / `UserMessage` 会继续把 CLI 侧补充输入镜像到 Telegram,保持原有 `codex-cli-user` 体验。
|
|
19
|
+
- 识别新的 `custom_tool_call`、`function_call` 及其 output 记录,严格按 `call_id` 跨轮询分片配对,让长时间工具调用恢复“正在运行/已运行”状态;未知或孤立 output 保持静默,不猜测配对。`Reasoning`、`ContextCompaction`、`Extension` 等内部记录仍不会污染聊天。
|
|
20
|
+
|
|
21
|
+
### English
|
|
22
|
+
- Completed the remaining Codex CLI 0.147.0 session-log observation compatibility. New `item_completed` / `UserMessage` records continue mirroring CLI-side follow-up input to Telegram with the existing `codex-cli-user` experience.
|
|
23
|
+
- Recognizes new `custom_tool_call`, `function_call`, and matching output records across polling chunks, pairing them strictly by `call_id` so long-running tools regain Running/Completed status. Unknown or orphan outputs stay silent, while internal `Reasoning`, `ContextCompaction`, and `Extension` records remain chat-invisible.
|
|
24
|
+
|
|
5
25
|
## 0.6.8 - 2026-08-15
|
|
6
26
|
|
|
7
27
|
### 中文
|
|
@@ -2,6 +2,7 @@ import { type TurnActivityEvent } from './activity.js';
|
|
|
2
2
|
export interface SessionLogCursor {
|
|
3
3
|
activeTurnId: string | null;
|
|
4
4
|
nextMessageIndex: number;
|
|
5
|
+
pendingToolCalls?: SessionToolCall[];
|
|
5
6
|
}
|
|
6
7
|
export interface SessionLogBootstrap {
|
|
7
8
|
cursor: SessionLogCursor;
|
|
@@ -17,6 +18,12 @@ export interface SplitJsonlChunk {
|
|
|
17
18
|
lines: string[];
|
|
18
19
|
remainder: string;
|
|
19
20
|
}
|
|
21
|
+
interface SessionToolCall {
|
|
22
|
+
callId: string;
|
|
23
|
+
kind: 'custom' | 'function';
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
20
26
|
export declare function splitJsonlChunk(remainder: string, chunk: string): SplitJsonlChunk;
|
|
21
27
|
export declare function bootstrapSessionLog(lines: string[]): SessionLogBootstrap;
|
|
22
28
|
export declare function applySessionLog(lines: string[], cursor: SessionLogCursor): SessionLogDiff;
|
|
29
|
+
export {};
|
|
@@ -13,34 +13,30 @@ export function splitJsonlChunk(remainder, chunk) {
|
|
|
13
13
|
}
|
|
14
14
|
export function bootstrapSessionLog(lines) {
|
|
15
15
|
const records = parseRecords(lines);
|
|
16
|
-
let
|
|
17
|
-
let nextMessageIndex = 0;
|
|
16
|
+
let state = { activeTurnId: null, nextMessageIndex: 0 };
|
|
18
17
|
let events = [];
|
|
19
18
|
for (const record of records) {
|
|
20
|
-
const next = applySessionRecord(record,
|
|
19
|
+
const next = applySessionRecord(record, state);
|
|
21
20
|
if (next.startedTurnId) {
|
|
22
|
-
|
|
23
|
-
nextMessageIndex = 0;
|
|
21
|
+
state = next.cursor;
|
|
24
22
|
events = [];
|
|
25
23
|
continue;
|
|
26
24
|
}
|
|
27
|
-
if (!activeTurnId) {
|
|
25
|
+
if (!state.activeTurnId) {
|
|
28
26
|
continue;
|
|
29
27
|
}
|
|
30
28
|
if (next.turnCompleted) {
|
|
31
|
-
|
|
32
|
-
nextMessageIndex = 0;
|
|
29
|
+
state = next.cursor;
|
|
33
30
|
events = [];
|
|
34
31
|
continue;
|
|
35
32
|
}
|
|
36
33
|
events.push(...next.events);
|
|
37
|
-
|
|
38
|
-
nextMessageIndex = next.cursor.nextMessageIndex;
|
|
34
|
+
state = next.cursor;
|
|
39
35
|
}
|
|
40
36
|
return {
|
|
41
|
-
cursor:
|
|
37
|
+
cursor: state,
|
|
42
38
|
events,
|
|
43
|
-
startedTurnId: activeTurnId,
|
|
39
|
+
startedTurnId: state.activeTurnId,
|
|
44
40
|
};
|
|
45
41
|
}
|
|
46
42
|
export function applySessionLog(lines, cursor) {
|
|
@@ -51,10 +47,7 @@ export function applySessionLog(lines, cursor) {
|
|
|
51
47
|
for (const record of records) {
|
|
52
48
|
const next = applySessionRecord(record, state);
|
|
53
49
|
if (next.startedTurnId) {
|
|
54
|
-
state =
|
|
55
|
-
activeTurnId: next.startedTurnId,
|
|
56
|
-
nextMessageIndex: 0,
|
|
57
|
-
};
|
|
50
|
+
state = next.cursor;
|
|
58
51
|
startedTurnIds.push(next.startedTurnId);
|
|
59
52
|
continue;
|
|
60
53
|
}
|
|
@@ -114,6 +107,25 @@ function applySessionRecord(record, cursor) {
|
|
|
114
107
|
}
|
|
115
108
|
return createSessionTextEvents(activeTurnId, cursor, text, typeof payload.item.phase === 'string' ? payload.item.phase : null, false, false, typeof payload.item.id === 'string' ? payload.item.id : null);
|
|
116
109
|
}
|
|
110
|
+
if (type === 'event_msg'
|
|
111
|
+
&& payload?.type === 'item_completed'
|
|
112
|
+
&& payload?.turn_id === activeTurnId
|
|
113
|
+
&& normalizeSessionItemType(payload?.item) === 'usermessage') {
|
|
114
|
+
const text = extractSessionItemText(payload.item)?.trim() ?? '';
|
|
115
|
+
if (!text) {
|
|
116
|
+
return { cursor, events: [], startedTurnId: null, turnCompleted: false };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
cursor,
|
|
120
|
+
events: [{
|
|
121
|
+
kind: 'user_message',
|
|
122
|
+
turnId: activeTurnId,
|
|
123
|
+
text,
|
|
124
|
+
}],
|
|
125
|
+
startedTurnId: null,
|
|
126
|
+
turnCompleted: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
117
129
|
if (type === 'response_item' && payload?.type === 'plan' && typeof payload.text === 'string') {
|
|
118
130
|
return createSessionTextEvents(activeTurnId, cursor, payload.text, 'commentary', true, true);
|
|
119
131
|
}
|
|
@@ -150,6 +162,18 @@ function applySessionRecord(record, cursor) {
|
|
|
150
162
|
turnCompleted: false,
|
|
151
163
|
};
|
|
152
164
|
}
|
|
165
|
+
if (type === 'response_item' && payload?.type === 'custom_tool_call') {
|
|
166
|
+
return createSessionToolStart(activeTurnId, cursor, payload, 'custom');
|
|
167
|
+
}
|
|
168
|
+
if (type === 'response_item' && payload?.type === 'function_call') {
|
|
169
|
+
return createSessionToolStart(activeTurnId, cursor, payload, 'function');
|
|
170
|
+
}
|
|
171
|
+
if (type === 'response_item' && payload?.type === 'custom_tool_call_output') {
|
|
172
|
+
return createSessionToolEnd(activeTurnId, cursor, payload, 'custom');
|
|
173
|
+
}
|
|
174
|
+
if (type === 'response_item' && payload?.type === 'function_call_output') {
|
|
175
|
+
return createSessionToolEnd(activeTurnId, cursor, payload, 'function');
|
|
176
|
+
}
|
|
153
177
|
if (type === 'event_msg' && payload?.type === 'exec_command_end' && payload?.turn_id === activeTurnId) {
|
|
154
178
|
const exec = createExecEndEvent(payload);
|
|
155
179
|
if (!exec) {
|
|
@@ -209,6 +233,7 @@ function createSessionTextEvents(activeTurnId, cursor, text, phase, forceComment
|
|
|
209
233
|
const streamOutputKind = forceCommentary ? 'commentary' : classifyAgentOutput(phase, false);
|
|
210
234
|
return {
|
|
211
235
|
cursor: {
|
|
236
|
+
...cursor,
|
|
212
237
|
activeTurnId,
|
|
213
238
|
nextMessageIndex: cursor.nextMessageIndex + 1,
|
|
214
239
|
},
|
|
@@ -243,6 +268,62 @@ function createSessionTextEvents(activeTurnId, cursor, text, phase, forceComment
|
|
|
243
268
|
turnCompleted: false,
|
|
244
269
|
};
|
|
245
270
|
}
|
|
271
|
+
function createSessionToolStart(turnId, cursor, payload, kind) {
|
|
272
|
+
const callId = typeof payload?.call_id === 'string' ? payload.call_id : null;
|
|
273
|
+
const name = typeof payload?.name === 'string' ? payload.name.trim() : '';
|
|
274
|
+
if (!callId || !name) {
|
|
275
|
+
return { cursor, events: [], startedTurnId: null, turnCompleted: false };
|
|
276
|
+
}
|
|
277
|
+
const pendingToolCalls = [
|
|
278
|
+
...(cursor.pendingToolCalls ?? []).filter(call => call.callId !== callId),
|
|
279
|
+
{ callId, kind, name },
|
|
280
|
+
];
|
|
281
|
+
const exec = createSessionToolEvent(turnId, callId, name);
|
|
282
|
+
return {
|
|
283
|
+
cursor: { ...cursor, pendingToolCalls },
|
|
284
|
+
events: [{
|
|
285
|
+
kind: 'tool_started',
|
|
286
|
+
turnId,
|
|
287
|
+
exec,
|
|
288
|
+
state: inferToolActivityState(exec),
|
|
289
|
+
}],
|
|
290
|
+
startedTurnId: null,
|
|
291
|
+
turnCompleted: false,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function createSessionToolEnd(turnId, cursor, payload, kind) {
|
|
295
|
+
const callId = typeof payload?.call_id === 'string' ? payload.call_id : null;
|
|
296
|
+
const pending = callId
|
|
297
|
+
? (cursor.pendingToolCalls ?? []).find(call => call.callId === callId && call.kind === kind)
|
|
298
|
+
: null;
|
|
299
|
+
if (!pending) {
|
|
300
|
+
return { cursor, events: [], startedTurnId: null, turnCompleted: false };
|
|
301
|
+
}
|
|
302
|
+
const exec = createSessionToolEvent(turnId, pending.callId, pending.name);
|
|
303
|
+
return {
|
|
304
|
+
cursor: {
|
|
305
|
+
...cursor,
|
|
306
|
+
pendingToolCalls: (cursor.pendingToolCalls ?? []).filter(call => call !== pending),
|
|
307
|
+
},
|
|
308
|
+
events: [{
|
|
309
|
+
kind: 'tool_completed',
|
|
310
|
+
turnId,
|
|
311
|
+
exec,
|
|
312
|
+
state: inferToolActivityState(exec),
|
|
313
|
+
}],
|
|
314
|
+
startedTurnId: null,
|
|
315
|
+
turnCompleted: false,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function createSessionToolEvent(turnId, callId, name) {
|
|
319
|
+
return {
|
|
320
|
+
callId,
|
|
321
|
+
turnId,
|
|
322
|
+
command: [`Tool ${name}`],
|
|
323
|
+
cwd: null,
|
|
324
|
+
parsedCmd: [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
246
327
|
function normalizeSessionItemType(item) {
|
|
247
328
|
if (typeof item?.type !== 'string') {
|
|
248
329
|
return null;
|
package/dist/lock.js
CHANGED
|
@@ -15,7 +15,10 @@ export function acquireProcessLock(lockPath) {
|
|
|
15
15
|
function acquireProcessLockInternal(lockPath, allowStaleRetry) {
|
|
16
16
|
try {
|
|
17
17
|
const fd = fs.openSync(lockPath, 'wx');
|
|
18
|
-
fs.writeFileSync(fd, `${
|
|
18
|
+
fs.writeFileSync(fd, `${JSON.stringify({
|
|
19
|
+
pid: process.pid,
|
|
20
|
+
processIdentity: readLinuxProcessIdentity(process.pid),
|
|
21
|
+
})}\n`, 'utf8');
|
|
19
22
|
let released = false;
|
|
20
23
|
return {
|
|
21
24
|
release() {
|
|
@@ -42,26 +45,73 @@ function acquireProcessLockInternal(lockPath, allowStaleRetry) {
|
|
|
42
45
|
if (!isAlreadyExistsError(error)) {
|
|
43
46
|
throw error;
|
|
44
47
|
}
|
|
45
|
-
const
|
|
46
|
-
if (allowStaleRetry && pid !== null && !
|
|
48
|
+
const record = readLockRecord(lockPath);
|
|
49
|
+
if (allowStaleRetry && record.pid !== null && !isLockOwnerAlive(lockPath, record)) {
|
|
47
50
|
fs.rmSync(lockPath, { force: true });
|
|
48
51
|
return acquireProcessLockInternal(lockPath, false);
|
|
49
52
|
}
|
|
50
|
-
throw new LockHeldError(lockPath, pid);
|
|
53
|
+
throw new LockHeldError(lockPath, record.pid);
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
function
|
|
56
|
+
function readLockRecord(lockPath) {
|
|
54
57
|
try {
|
|
55
58
|
const value = fs.readFileSync(lockPath, 'utf8').trim();
|
|
56
59
|
if (!value) {
|
|
57
|
-
return null;
|
|
60
|
+
return { pid: null, processIdentity: null };
|
|
61
|
+
}
|
|
62
|
+
if (value.startsWith('{')) {
|
|
63
|
+
const parsed = JSON.parse(value);
|
|
64
|
+
return {
|
|
65
|
+
pid: typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) ? parsed.pid : null,
|
|
66
|
+
processIdentity: typeof parsed.processIdentity === 'string' ? parsed.processIdentity : null,
|
|
67
|
+
};
|
|
58
68
|
}
|
|
59
69
|
const pid = Number.parseInt(value, 10);
|
|
60
|
-
return Number.isFinite(pid) ? pid : null;
|
|
70
|
+
return { pid: Number.isFinite(pid) ? pid : null, processIdentity: null };
|
|
61
71
|
}
|
|
62
72
|
catch {
|
|
73
|
+
return { pid: null, processIdentity: null };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function isLockOwnerAlive(lockPath, record) {
|
|
77
|
+
if (record.pid === null || !isProcessAlive(record.pid)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const currentIdentity = readLinuxProcessIdentity(record.pid);
|
|
81
|
+
if (record.processIdentity !== null && currentIdentity !== null) {
|
|
82
|
+
return record.processIdentity === currentIdentity;
|
|
83
|
+
}
|
|
84
|
+
return !wasLockCreatedBeforeCurrentBoot(lockPath);
|
|
85
|
+
}
|
|
86
|
+
function readLinuxProcessIdentity(pid) {
|
|
87
|
+
if (process.platform !== 'linux') {
|
|
63
88
|
return null;
|
|
64
89
|
}
|
|
90
|
+
try {
|
|
91
|
+
const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
92
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
93
|
+
const commandEnd = stat.lastIndexOf(')');
|
|
94
|
+
const startTicks = commandEnd >= 0 ? stat.slice(commandEnd + 2).split(' ')[19] : undefined;
|
|
95
|
+
return bootId && startTicks ? `${bootId}:${startTicks}` : null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function wasLockCreatedBeforeCurrentBoot(lockPath) {
|
|
102
|
+
if (process.platform !== 'linux') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const bootTimeSeconds = fs.readFileSync('/proc/stat', 'utf8').match(/^btime (\d+)$/m)?.[1];
|
|
107
|
+
if (!bootTimeSeconds) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return fs.statSync(lockPath).mtimeMs < Number(bootTimeSeconds) * 1000;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
65
115
|
}
|
|
66
116
|
function isProcessAlive(pid) {
|
|
67
117
|
if (!Number.isFinite(pid) || pid <= 0) {
|
package/dist/main.js
CHANGED
|
@@ -1784,13 +1784,13 @@ function runDoctorChecks() {
|
|
|
1784
1784
|
const configuredCodexBin = process.env.CODEX_CLI_BIN;
|
|
1785
1785
|
const checks = [
|
|
1786
1786
|
['node >= 24', Number(process.versions.node.split('.')[0]) >= 24],
|
|
1787
|
-
['codex cli available',
|
|
1787
|
+
['codex cli available', hasConfiguredCommand(configuredCodexBin, 'codex')],
|
|
1788
1788
|
['telegram bot token(s) configured', Boolean(process.env.TG_BOT_TOKENS?.trim() || process.env.TG_BOT_TOKEN?.trim())],
|
|
1789
1789
|
['telegram allowed user configured', Boolean(process.env.TG_ALLOWED_USER_ID)],
|
|
1790
1790
|
];
|
|
1791
1791
|
if (process.env.OPENCODE_BOT_TOKEN?.trim()) {
|
|
1792
1792
|
const configuredOpencodeBin = process.env.OPENCODE_CLI_BIN;
|
|
1793
|
-
checks.push(['opencode cli available',
|
|
1793
|
+
checks.push(['opencode cli available', hasConfiguredCommand(configuredOpencodeBin, 'opencode')]);
|
|
1794
1794
|
const codexTokens = [
|
|
1795
1795
|
...(process.env.TG_BOT_TOKENS ?? '').split(','),
|
|
1796
1796
|
process.env.TG_BOT_TOKEN ?? '',
|
|
@@ -2337,9 +2337,9 @@ function resolveCommand(commandName) {
|
|
|
2337
2337
|
return null;
|
|
2338
2338
|
}
|
|
2339
2339
|
}
|
|
2340
|
-
function
|
|
2341
|
-
if (!binPath
|
|
2342
|
-
return
|
|
2340
|
+
function hasConfiguredCommand(binPath, fallbackCommand) {
|
|
2341
|
+
if (!binPath?.trim())
|
|
2342
|
+
return hasCommand(fallbackCommand);
|
|
2343
2343
|
try {
|
|
2344
2344
|
fs.accessSync(binPath, fs.constants.X_OK);
|
|
2345
2345
|
return true;
|