shennian 0.2.29 → 0.2.30
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/src/commands/upgrade.d.ts +6 -2
- package/dist/src/commands/upgrade.js +6 -6
- package/dist/src/daemon-log.d.ts +9 -0
- package/dist/src/daemon-log.js +58 -0
- package/dist/src/index.js +13 -9
- package/dist/src/native-fusion/parsers.js +77 -55
- package/dist/src/native-fusion/service.js +3 -0
- package/dist/src/session/manager.d.ts +2 -1
- package/dist/src/session/manager.js +7 -3
- package/dist/src/upgrade/engine.d.ts +4 -2
- package/dist/src/upgrade/engine.js +29 -4
- package/package.json +1 -1
|
@@ -11,6 +11,10 @@ type RestartAfterUpgradeDeps = {
|
|
|
11
11
|
};
|
|
12
12
|
export declare function restartCurrentDaemonAfterUpgrade(deps?: RestartAfterUpgradeDeps): void;
|
|
13
13
|
export declare function registerUpgradeCommand(program: Command): void;
|
|
14
|
-
export declare function handleUpgradeStart(client: CliRelayClient, reqId: string, targetVersion?: string
|
|
15
|
-
|
|
14
|
+
export declare function handleUpgradeStart(client: CliRelayClient, reqId: string, targetVersion?: string, opts?: {
|
|
15
|
+
currentVersion?: string;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export declare function handleUpgradeStatus(client: CliRelayClient, reqId: string, opts?: {
|
|
18
|
+
currentVersion?: string;
|
|
19
|
+
}): Promise<void>;
|
|
16
20
|
export {};
|
|
@@ -134,11 +134,11 @@ export function registerUpgradeCommand(program) {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
// ─── Relay-triggered upgrade (called from session manager) ───────────────────
|
|
137
|
-
export async function handleUpgradeStart(client, reqId, targetVersion) {
|
|
137
|
+
export async function handleUpgradeStart(client, reqId, targetVersion, opts = {}) {
|
|
138
138
|
const sendEvent = (payload) => {
|
|
139
139
|
client.sendEvent({ type: 'event', event: 'upgrade', payload });
|
|
140
140
|
};
|
|
141
|
-
const current = getCurrentVersion();
|
|
141
|
+
const current = opts.currentVersion ?? getCurrentVersion();
|
|
142
142
|
// Resolve target version
|
|
143
143
|
let latest;
|
|
144
144
|
try {
|
|
@@ -168,7 +168,7 @@ export async function handleUpgradeStart(client, reqId, targetVersion) {
|
|
|
168
168
|
sendEvent({ state: 'restarting', machineId: 'self', from: current, to: latest });
|
|
169
169
|
break;
|
|
170
170
|
}
|
|
171
|
-
});
|
|
171
|
+
}, { currentVersion: current });
|
|
172
172
|
if (result.ok) {
|
|
173
173
|
sendEvent({ state: 'restarting', machineId: 'self', from: result.from, to: result.to });
|
|
174
174
|
// Brief delay so the event is flushed before we exit
|
|
@@ -185,14 +185,14 @@ export async function handleUpgradeStart(client, reqId, targetVersion) {
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
export async function handleUpgradeStatus(client, reqId) {
|
|
189
|
-
const result = await checkForUpdate();
|
|
188
|
+
export async function handleUpgradeStatus(client, reqId, opts = {}) {
|
|
189
|
+
const result = await checkForUpdate(opts.currentVersion);
|
|
190
190
|
client.sendRes({
|
|
191
191
|
type: 'res',
|
|
192
192
|
id: reqId,
|
|
193
193
|
ok: true,
|
|
194
194
|
payload: result.hasUpdate
|
|
195
195
|
? { hasUpdate: true, current: result.current, latest: result.latest, changeType: result.changeType }
|
|
196
|
-
: { hasUpdate: false, current: getCurrentVersion() },
|
|
196
|
+
: { hasUpdate: false, current: opts.currentVersion ?? getCurrentVersion() },
|
|
197
197
|
});
|
|
198
198
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function getDaemonLogRetention(): {
|
|
2
|
+
maxLines: number;
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
};
|
|
5
|
+
export declare function trimDaemonLogFile(filePath?: string, retention?: {
|
|
6
|
+
maxLines: number;
|
|
7
|
+
maxBytes: number;
|
|
8
|
+
}): boolean;
|
|
9
|
+
export declare function startDaemonLogRetention(): ReturnType<typeof setInterval>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { resolveShennianPath } from './config/index.js';
|
|
3
|
+
const DEFAULT_MAX_LINES = 5_000;
|
|
4
|
+
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
const TRIM_INTERVAL_MS = 5 * 60_000;
|
|
6
|
+
function readPositiveInt(value, fallback) {
|
|
7
|
+
const parsed = Number(value);
|
|
8
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
9
|
+
}
|
|
10
|
+
export function getDaemonLogRetention() {
|
|
11
|
+
return {
|
|
12
|
+
maxLines: readPositiveInt(process.env.SHENNIAN_DAEMON_LOG_MAX_LINES, DEFAULT_MAX_LINES),
|
|
13
|
+
maxBytes: readPositiveInt(process.env.SHENNIAN_DAEMON_LOG_MAX_BYTES, DEFAULT_MAX_BYTES),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function trimDaemonLogFile(filePath = resolveShennianPath('daemon.log'), retention = getDaemonLogRetention()) {
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.statSync(filePath);
|
|
19
|
+
if (stat.size === 0)
|
|
20
|
+
return false;
|
|
21
|
+
const bytesToRead = Math.min(stat.size, retention.maxBytes);
|
|
22
|
+
const fd = fs.openSync(filePath, 'r');
|
|
23
|
+
let text = '';
|
|
24
|
+
try {
|
|
25
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
26
|
+
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
|
|
27
|
+
text = buffer.toString('utf8');
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
fs.closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
if (bytesToRead < stat.size) {
|
|
33
|
+
const firstNewline = text.indexOf('\n');
|
|
34
|
+
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : '';
|
|
35
|
+
}
|
|
36
|
+
const hadTrailingNewline = text.endsWith('\n');
|
|
37
|
+
let lines = text.split('\n');
|
|
38
|
+
if (hadTrailingNewline)
|
|
39
|
+
lines = lines.slice(0, -1);
|
|
40
|
+
if (lines.length > retention.maxLines) {
|
|
41
|
+
lines = lines.slice(-retention.maxLines);
|
|
42
|
+
}
|
|
43
|
+
const next = `${lines.join('\n')}${lines.length > 0 ? '\n' : ''}`;
|
|
44
|
+
if (next.length === stat.size && bytesToRead === stat.size)
|
|
45
|
+
return false;
|
|
46
|
+
fs.writeFileSync(filePath, next, 'utf8');
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function startDaemonLogRetention() {
|
|
54
|
+
trimDaemonLogFile();
|
|
55
|
+
return setInterval(() => {
|
|
56
|
+
trimDaemonLogFile();
|
|
57
|
+
}, TRIM_INTERVAL_MS);
|
|
58
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ const AUTO_UPGRADE_POLL_INTERVAL_MS = 5 * 60_000;
|
|
|
21
21
|
import { getCachedAgentInfos, resolveAgentInfos } from './agents/model-registry.js';
|
|
22
22
|
import { initCliLogReporter, reportLog } from './log-reporter.js';
|
|
23
23
|
import { NativeSessionFusionService } from './native-fusion/service.js';
|
|
24
|
+
import { startDaemonLogRetention } from './daemon-log.js';
|
|
24
25
|
const SHENNIAN_DIR = getShennianDir();
|
|
25
26
|
const PID_FILE = resolveShennianPath('daemon.pid');
|
|
26
27
|
function httpToWs(url) {
|
|
@@ -74,10 +75,6 @@ program
|
|
|
74
75
|
.description('(internal) Connect to relay server, called by the background service')
|
|
75
76
|
.option('--api <url>', 'Server URL override')
|
|
76
77
|
.action(async (opts) => {
|
|
77
|
-
if (isRemoteAccessDisabled()) {
|
|
78
|
-
console.log(`[${new Date().toISOString()}] remote access disabled, service start skipped`);
|
|
79
|
-
process.exit(0);
|
|
80
|
-
}
|
|
81
78
|
const envFile = resolveShennianPath('env.json');
|
|
82
79
|
try {
|
|
83
80
|
const saved = JSON.parse(fs.readFileSync(envFile, 'utf-8'));
|
|
@@ -89,6 +86,12 @@ program
|
|
|
89
86
|
catch {
|
|
90
87
|
// env.json may not exist yet
|
|
91
88
|
}
|
|
89
|
+
const logRetentionTimer = startDaemonLogRetention();
|
|
90
|
+
if (isRemoteAccessDisabled()) {
|
|
91
|
+
console.log(`[${new Date().toISOString()}] remote access disabled, service start skipped`);
|
|
92
|
+
clearInterval(logRetentionTimer);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
92
95
|
// Single-instance guard. Service-manager starts are authoritative and may
|
|
93
96
|
// need to take over from an older detached process after app/daemon upgrades.
|
|
94
97
|
const pidFile = resolveShennianPath('daemon.pid');
|
|
@@ -216,7 +219,7 @@ program
|
|
|
216
219
|
}
|
|
217
220
|
})
|
|
218
221
|
.catch(() => { });
|
|
219
|
-
void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch');
|
|
222
|
+
void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch', cliVersion);
|
|
220
223
|
nativeFusion?.handleConnected();
|
|
221
224
|
},
|
|
222
225
|
onDisconnected: (info) => {
|
|
@@ -250,7 +253,7 @@ program
|
|
|
250
253
|
process.env.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
|
|
251
254
|
? null
|
|
252
255
|
: new NativeSessionFusionService(client);
|
|
253
|
-
const sessionManager = new SessionManager(client, nativeFusion);
|
|
256
|
+
const sessionManager = new SessionManager(client, nativeFusion, cliVersion);
|
|
254
257
|
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
255
258
|
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
256
259
|
writeDaemonLauncher(process.pid);
|
|
@@ -259,6 +262,7 @@ program
|
|
|
259
262
|
const shutdown = () => {
|
|
260
263
|
console.log(chalk.gray('\nDisconnecting...'));
|
|
261
264
|
reportLog({ level: 'info', wsEvent: 'daemon.stop' });
|
|
265
|
+
clearInterval(logRetentionTimer);
|
|
262
266
|
sessionManager.cleanup();
|
|
263
267
|
nativeFusion?.stop();
|
|
264
268
|
client.disconnect();
|
|
@@ -319,7 +323,7 @@ registerAgentCommand(program);
|
|
|
319
323
|
registerUpgradeCommand(program);
|
|
320
324
|
program.parse();
|
|
321
325
|
// ─── Auto-upgrade helper ──────────────────────────────────────────────────────
|
|
322
|
-
async function scheduleAutoUpgrade(client, policy) {
|
|
326
|
+
async function scheduleAutoUpgrade(client, policy, currentVersion) {
|
|
323
327
|
if (policy === 'none')
|
|
324
328
|
return;
|
|
325
329
|
let upgradeInFlight = false;
|
|
@@ -328,7 +332,7 @@ async function scheduleAutoUpgrade(client, policy) {
|
|
|
328
332
|
return;
|
|
329
333
|
let result;
|
|
330
334
|
try {
|
|
331
|
-
result = await checkForUpdate();
|
|
335
|
+
result = await checkForUpdate(currentVersion);
|
|
332
336
|
}
|
|
333
337
|
catch {
|
|
334
338
|
return; // silently skip on network error
|
|
@@ -348,7 +352,7 @@ async function scheduleAutoUpgrade(client, policy) {
|
|
|
348
352
|
try {
|
|
349
353
|
const { handleUpgradeStart } = await import('./commands/upgrade.js');
|
|
350
354
|
// Use a dummy reqId since this is self-triggered
|
|
351
|
-
await handleUpgradeStart(client, 'auto-upgrade', latest);
|
|
355
|
+
await handleUpgradeStart(client, 'auto-upgrade', latest, { currentVersion });
|
|
352
356
|
}
|
|
353
357
|
catch (err) {
|
|
354
358
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { buildUserMessagePayload, isToolPayload } from '@shennian/wire';
|
|
8
8
|
import { resolveBuiltinCommand, spawnResolvedCommandSync } from '../agents/command-spec.js';
|
|
9
|
+
const MAX_JSONL_LINE_BYTES = 64 * 1024 * 1024;
|
|
9
10
|
function normalizeText(text) {
|
|
10
11
|
return stripGitDirectiveArtifacts(text.replace(/\r\n/g, '\n').trim());
|
|
11
12
|
}
|
|
@@ -38,6 +39,51 @@ function safeParse(line) {
|
|
|
38
39
|
return null;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
function readJsonlLines(filePath, startOffset, onLine) {
|
|
43
|
+
const stat = fs.statSync(filePath);
|
|
44
|
+
const fileSize = stat.size;
|
|
45
|
+
if (startOffset >= fileSize)
|
|
46
|
+
return fileSize;
|
|
47
|
+
const fd = fs.openSync(filePath, 'r');
|
|
48
|
+
try {
|
|
49
|
+
const chunkSize = 256 * 1024;
|
|
50
|
+
const buffer = Buffer.allocUnsafe(chunkSize);
|
|
51
|
+
let position = startOffset;
|
|
52
|
+
let nextOffset = startOffset;
|
|
53
|
+
let carry = Buffer.alloc(0);
|
|
54
|
+
while (position < fileSize) {
|
|
55
|
+
const bytesRead = fs.readSync(fd, buffer, 0, Math.min(chunkSize, fileSize - position), position);
|
|
56
|
+
if (bytesRead <= 0)
|
|
57
|
+
break;
|
|
58
|
+
position += bytesRead;
|
|
59
|
+
let chunk = buffer.subarray(0, bytesRead);
|
|
60
|
+
if (carry.length > 0) {
|
|
61
|
+
chunk = Buffer.concat([carry, chunk]);
|
|
62
|
+
carry = Buffer.alloc(0);
|
|
63
|
+
}
|
|
64
|
+
let lineStart = 0;
|
|
65
|
+
while (lineStart < chunk.length) {
|
|
66
|
+
const newlineIndex = chunk.indexOf(0x0a, lineStart);
|
|
67
|
+
if (newlineIndex < 0)
|
|
68
|
+
break;
|
|
69
|
+
const lineBuffer = chunk.subarray(lineStart, newlineIndex);
|
|
70
|
+
const lineOffset = nextOffset;
|
|
71
|
+
nextOffset += newlineIndex - lineStart + 1;
|
|
72
|
+
lineStart = newlineIndex + 1;
|
|
73
|
+
if (lineBuffer.length > 0 && lineBuffer.length <= MAX_JSONL_LINE_BYTES) {
|
|
74
|
+
onLine(lineBuffer.toString('utf8'), lineOffset);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (lineStart < chunk.length) {
|
|
78
|
+
carry = Buffer.from(chunk.subarray(lineStart));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return nextOffset;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fs.closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
41
87
|
function readClaudeEventCwd(parsed) {
|
|
42
88
|
return typeof parsed.cwd === 'string' && parsed.cwd.trim() ? parsed.cwd : null;
|
|
43
89
|
}
|
|
@@ -627,9 +673,10 @@ function lookupCodexThreadName(sourceSessionKey) {
|
|
|
627
673
|
return null;
|
|
628
674
|
}
|
|
629
675
|
export function listCodexRolloutFiles() {
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
676
|
+
const roots = [
|
|
677
|
+
path.join(os.homedir(), '.codex', 'sessions'),
|
|
678
|
+
path.join(os.homedir(), '.codex', 'archived_sessions'),
|
|
679
|
+
];
|
|
633
680
|
const files = [];
|
|
634
681
|
const walk = (dir) => {
|
|
635
682
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -640,8 +687,11 @@ export function listCodexRolloutFiles() {
|
|
|
640
687
|
files.push(full);
|
|
641
688
|
}
|
|
642
689
|
};
|
|
643
|
-
|
|
644
|
-
|
|
690
|
+
for (const root of roots) {
|
|
691
|
+
if (fs.existsSync(root))
|
|
692
|
+
walk(root);
|
|
693
|
+
}
|
|
694
|
+
return [...new Set(files)].sort();
|
|
645
695
|
}
|
|
646
696
|
export function listClaudeTranscriptFiles() {
|
|
647
697
|
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
@@ -666,61 +716,47 @@ export function lookupClaudeTranscriptCwd(sourceSessionKey) {
|
|
|
666
716
|
const match = listClaudeTranscriptFiles().find((filePath) => path.basename(filePath, '.jsonl') === sourceSessionKey);
|
|
667
717
|
if (!match)
|
|
668
718
|
return null;
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if (!line.trim())
|
|
672
|
-
|
|
719
|
+
let found = null;
|
|
720
|
+
readJsonlLines(match, 0, (line) => {
|
|
721
|
+
if (found || !line.trim())
|
|
722
|
+
return;
|
|
673
723
|
const parsed = safeParse(line);
|
|
674
724
|
if (!parsed)
|
|
675
|
-
|
|
725
|
+
return;
|
|
676
726
|
const cwd = readClaudeEventCwd(parsed);
|
|
677
727
|
if (cwd)
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
return
|
|
728
|
+
found = cwd;
|
|
729
|
+
});
|
|
730
|
+
return found;
|
|
681
731
|
}
|
|
682
732
|
export function parseCodexRolloutChunk(filePath, startOffset) {
|
|
683
|
-
const stat = fs.statSync(filePath);
|
|
684
|
-
const fileSize = stat.size;
|
|
685
|
-
if (startOffset >= fileSize)
|
|
686
|
-
return { nextOffset: fileSize, events: [] };
|
|
687
|
-
const chunkBuffer = fs.readFileSync(filePath).subarray(startOffset);
|
|
688
|
-
const lastNewline = chunkBuffer.lastIndexOf(0x0a);
|
|
689
|
-
if (lastNewline < 0)
|
|
690
|
-
return { nextOffset: startOffset, events: [] };
|
|
691
|
-
const nextOffset = startOffset + lastNewline + 1;
|
|
692
|
-
const chunk = chunkBuffer.subarray(0, lastNewline + 1).toString('utf8');
|
|
693
|
-
const lines = chunk.split('\n').filter(Boolean);
|
|
694
733
|
const events = [];
|
|
695
734
|
let { sourceSessionKey, workDir, modelId, title } = readCodexSessionMeta(filePath);
|
|
696
735
|
let pendingTerminal = false;
|
|
697
|
-
|
|
698
|
-
const line = lines[index];
|
|
736
|
+
const nextOffset = readJsonlLines(filePath, startOffset, (line, lineOffset) => {
|
|
699
737
|
const parsed = safeParse(line);
|
|
700
|
-
const lineOffset = offset;
|
|
701
|
-
offset += Buffer.byteLength(line, 'utf8') + 1;
|
|
702
738
|
if (!parsed)
|
|
703
|
-
|
|
739
|
+
return;
|
|
704
740
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
|
705
741
|
const payload = typeof parsed.payload === 'object' && parsed.payload !== null
|
|
706
742
|
? parsed.payload
|
|
707
743
|
: null;
|
|
708
744
|
const ts = readTimestamp(parsed.timestamp);
|
|
709
745
|
if (!payload || !ts)
|
|
710
|
-
|
|
746
|
+
return;
|
|
711
747
|
if (type === 'session_meta') {
|
|
712
748
|
sourceSessionKey = typeof payload.id === 'string' ? payload.id : sourceSessionKey;
|
|
713
749
|
workDir = typeof payload.cwd === 'string' ? payload.cwd : workDir;
|
|
714
750
|
modelId = readCodexModelId(payload) ?? modelId;
|
|
715
751
|
if (!title)
|
|
716
752
|
title = lookupCodexThreadName(sourceSessionKey) ?? '';
|
|
717
|
-
|
|
753
|
+
return;
|
|
718
754
|
}
|
|
719
755
|
if (!sourceSessionKey)
|
|
720
|
-
|
|
756
|
+
return;
|
|
721
757
|
if (type === 'response_item') {
|
|
722
758
|
parseCodexResponseItem(events, filePath, lineOffset, payload, sourceSessionKey, ts, title, modelId, workDir);
|
|
723
|
-
|
|
759
|
+
return;
|
|
724
760
|
}
|
|
725
761
|
if (type === 'event_msg') {
|
|
726
762
|
const eventType = typeof payload.type === 'string' ? payload.type : '';
|
|
@@ -737,7 +773,7 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
|
|
|
737
773
|
pendingTerminal = false;
|
|
738
774
|
break;
|
|
739
775
|
}
|
|
740
|
-
|
|
776
|
+
return;
|
|
741
777
|
}
|
|
742
778
|
if (eventType === 'user_message') {
|
|
743
779
|
const parsedUser = parseCodexUserMessage(payload);
|
|
@@ -751,43 +787,29 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
|
|
|
751
787
|
pendingTerminal = false;
|
|
752
788
|
}
|
|
753
789
|
}
|
|
754
|
-
}
|
|
790
|
+
});
|
|
755
791
|
return { nextOffset, events };
|
|
756
792
|
}
|
|
757
793
|
export function parseClaudeTranscriptChunk(filePath, startOffset) {
|
|
758
|
-
const stat = fs.statSync(filePath);
|
|
759
|
-
const fileSize = stat.size;
|
|
760
|
-
if (startOffset >= fileSize)
|
|
761
|
-
return { nextOffset: fileSize, events: [] };
|
|
762
|
-
const chunkBuffer = fs.readFileSync(filePath).subarray(startOffset);
|
|
763
|
-
const lastNewline = chunkBuffer.lastIndexOf(0x0a);
|
|
764
|
-
if (lastNewline < 0)
|
|
765
|
-
return { nextOffset: startOffset, events: [] };
|
|
766
|
-
const nextOffset = startOffset + lastNewline + 1;
|
|
767
|
-
const chunk = chunkBuffer.subarray(0, lastNewline + 1).toString('utf8');
|
|
768
|
-
const lines = chunk.split('\n').filter(Boolean);
|
|
769
794
|
const events = [];
|
|
770
795
|
const sourceSessionKey = path.basename(filePath, '.jsonl');
|
|
771
796
|
const fallbackWorkDir = relativeProjectDir(path.dirname(filePath).replace(path.join(os.homedir(), '.claude', 'projects') + path.sep, ''));
|
|
772
797
|
let title = '';
|
|
773
|
-
|
|
774
|
-
const line = lines[index];
|
|
798
|
+
const nextOffset = readJsonlLines(filePath, startOffset, (line, lineOffset) => {
|
|
775
799
|
const parsed = safeParse(line);
|
|
776
|
-
const lineOffset = offset;
|
|
777
|
-
offset += Buffer.byteLength(line, 'utf8') + 1;
|
|
778
800
|
if (!parsed)
|
|
779
|
-
|
|
801
|
+
return;
|
|
780
802
|
const ts = readTimestamp(parsed.timestamp);
|
|
781
803
|
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
|
782
804
|
if (!ts || !type)
|
|
783
|
-
|
|
805
|
+
return;
|
|
784
806
|
if (type === 'user') {
|
|
785
807
|
const message = typeof parsed.message === 'object' && parsed.message !== null
|
|
786
808
|
? parsed.message
|
|
787
809
|
: null;
|
|
788
810
|
const text = typeof message?.content === 'string' ? normalizeText(message.content) : '';
|
|
789
811
|
if (!text)
|
|
790
|
-
|
|
812
|
+
return;
|
|
791
813
|
if (!title)
|
|
792
814
|
title = text.slice(0, 80);
|
|
793
815
|
events.push({
|
|
@@ -815,7 +837,7 @@ export function parseClaudeTranscriptChunk(filePath, startOffset) {
|
|
|
815
837
|
.filter(Boolean)
|
|
816
838
|
.join('\n\n'));
|
|
817
839
|
if (!text)
|
|
818
|
-
|
|
840
|
+
return;
|
|
819
841
|
const modelId = typeof message?.model === 'string' ? message.model : null;
|
|
820
842
|
events.push({
|
|
821
843
|
agentType: 'claude',
|
|
@@ -831,6 +853,6 @@ export function parseClaudeTranscriptChunk(filePath, startOffset) {
|
|
|
831
853
|
workDir: readClaudeEventCwd(parsed) ?? fallbackWorkDir,
|
|
832
854
|
});
|
|
833
855
|
}
|
|
834
|
-
}
|
|
856
|
+
});
|
|
835
857
|
return { nextOffset, events };
|
|
836
858
|
}
|
|
@@ -42,6 +42,9 @@ export class NativeSessionFusionService {
|
|
|
42
42
|
}
|
|
43
43
|
handleConnected() {
|
|
44
44
|
this.start();
|
|
45
|
+
void this.scanNow().catch((error) => {
|
|
46
|
+
console.error('[native-fusion] initial scan failed', error);
|
|
47
|
+
});
|
|
45
48
|
}
|
|
46
49
|
registerManagedSend(params) {
|
|
47
50
|
if (!params.canonicalMessageId)
|
|
@@ -11,6 +11,7 @@ export declare function resolveSessionWorkDir(input: string): string;
|
|
|
11
11
|
export declare class SessionManager {
|
|
12
12
|
private client;
|
|
13
13
|
private nativeFusion;
|
|
14
|
+
private cliVersion?;
|
|
14
15
|
private sessions;
|
|
15
16
|
/** Track processed request IDs to deduplicate replayed offline messages */
|
|
16
17
|
private processedReqIds;
|
|
@@ -18,7 +19,7 @@ export declare class SessionManager {
|
|
|
18
19
|
private runTextAcc;
|
|
19
20
|
/** In-flight chunked uploads: transferId → metadata */
|
|
20
21
|
private pendingTransfers;
|
|
21
|
-
constructor(client: CliRelayClient, nativeFusion?: NativeSessionFusionService | null);
|
|
22
|
+
constructor(client: CliRelayClient, nativeFusion?: NativeSessionFusionService | null, cliVersion?: string | undefined);
|
|
22
23
|
private getRuntime;
|
|
23
24
|
private reloadCustomAgents;
|
|
24
25
|
handleReq(req: ReqFrame): Promise<void>;
|
|
@@ -50,6 +50,7 @@ export function resolveSessionWorkDir(input) {
|
|
|
50
50
|
export class SessionManager {
|
|
51
51
|
client;
|
|
52
52
|
nativeFusion;
|
|
53
|
+
cliVersion;
|
|
53
54
|
sessions = new Map();
|
|
54
55
|
/** Track processed request IDs to deduplicate replayed offline messages */
|
|
55
56
|
processedReqIds = new Set();
|
|
@@ -57,9 +58,10 @@ export class SessionManager {
|
|
|
57
58
|
runTextAcc = new Map();
|
|
58
59
|
/** In-flight chunked uploads: transferId → metadata */
|
|
59
60
|
pendingTransfers = new Map();
|
|
60
|
-
constructor(client, nativeFusion = null) {
|
|
61
|
+
constructor(client, nativeFusion = null, cliVersion) {
|
|
61
62
|
this.client = client;
|
|
62
63
|
this.nativeFusion = nativeFusion;
|
|
64
|
+
this.cliVersion = cliVersion;
|
|
63
65
|
this.reloadCustomAgents();
|
|
64
66
|
}
|
|
65
67
|
getRuntime() {
|
|
@@ -124,10 +126,12 @@ export class SessionManager {
|
|
|
124
126
|
await handleRegionSwitch(runtime, req);
|
|
125
127
|
break;
|
|
126
128
|
case 'upgrade.start':
|
|
127
|
-
await handleUpgradeStart(this.client, req.id, req.params.version
|
|
129
|
+
await handleUpgradeStart(this.client, req.id, req.params.version, {
|
|
130
|
+
currentVersion: this.cliVersion,
|
|
131
|
+
});
|
|
128
132
|
break;
|
|
129
133
|
case 'upgrade.status':
|
|
130
|
-
await handleUpgradeStatus(this.client, req.id);
|
|
134
|
+
await handleUpgradeStatus(this.client, req.id, { currentVersion: this.cliVersion });
|
|
131
135
|
break;
|
|
132
136
|
case 'upgrade.set-policy':
|
|
133
137
|
await handleUpgradeSetPolicy(runtime, req);
|
|
@@ -68,7 +68,9 @@ export declare function clearUpgradeFailure(version: string): void;
|
|
|
68
68
|
* Returns true if rollback was performed (caller should exit and let service manager restart).
|
|
69
69
|
*/
|
|
70
70
|
export declare function handleStartupCrashCheck(): Promise<boolean>;
|
|
71
|
-
export declare function performUpgrade(targetVersion: string, onProgress: (p: UpgradeProgress) => void
|
|
71
|
+
export declare function performUpgrade(targetVersion: string, onProgress: (p: UpgradeProgress) => void, opts?: {
|
|
72
|
+
currentVersion?: string;
|
|
73
|
+
}): Promise<UpgradeResult>;
|
|
72
74
|
export type VersionCheckResult = {
|
|
73
75
|
hasUpdate: false;
|
|
74
76
|
} | {
|
|
@@ -77,5 +79,5 @@ export type VersionCheckResult = {
|
|
|
77
79
|
latest: string;
|
|
78
80
|
changeType: 'patch' | 'minor' | 'major';
|
|
79
81
|
};
|
|
80
|
-
export declare function checkForUpdate(): Promise<VersionCheckResult>;
|
|
82
|
+
export declare function checkForUpdate(currentVersion?: string): Promise<VersionCheckResult>;
|
|
81
83
|
export {};
|
|
@@ -212,8 +212,9 @@ export async function handleStartupCrashCheck() {
|
|
|
212
212
|
return false;
|
|
213
213
|
}
|
|
214
214
|
// ─── Core upgrade ─────────────────────────────────────────────────────────────
|
|
215
|
-
export async function performUpgrade(targetVersion, onProgress) {
|
|
216
|
-
const currentVersion = getCurrentVersion();
|
|
215
|
+
export async function performUpgrade(targetVersion, onProgress, opts = {}) {
|
|
216
|
+
const currentVersion = opts.currentVersion ?? getCurrentVersion();
|
|
217
|
+
const installedVersion = getCurrentVersion();
|
|
217
218
|
if (currentVersion === targetVersion) {
|
|
218
219
|
clearUpgradeFailure(targetVersion);
|
|
219
220
|
return { ok: false, error: `Already on version ${targetVersion}` };
|
|
@@ -226,6 +227,30 @@ export async function performUpgrade(targetVersion, onProgress) {
|
|
|
226
227
|
catch {
|
|
227
228
|
return { ok: false, error: 'npm is not available in PATH' };
|
|
228
229
|
}
|
|
230
|
+
if (installedVersion === targetVersion) {
|
|
231
|
+
onProgress({ step: 'verifying', version: targetVersion });
|
|
232
|
+
try {
|
|
233
|
+
const binScript = getGlobalBinScript();
|
|
234
|
+
const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000 });
|
|
235
|
+
if (!stdout.trim())
|
|
236
|
+
throw new Error('Empty output from --version check');
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: `Smoke test failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
writeUpgradeAttempt({
|
|
245
|
+
from: currentVersion,
|
|
246
|
+
to: targetVersion,
|
|
247
|
+
attemptCount: 0,
|
|
248
|
+
attemptAt: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
clearUpgradeFailure(targetVersion);
|
|
251
|
+
onProgress({ step: 'restarting', from: currentVersion, to: targetVersion });
|
|
252
|
+
return { ok: true, from: currentVersion, to: targetVersion };
|
|
253
|
+
}
|
|
229
254
|
// Step 2: Backup current version
|
|
230
255
|
onProgress({ step: 'backing-up' });
|
|
231
256
|
try {
|
|
@@ -284,8 +309,8 @@ export async function performUpgrade(targetVersion, onProgress) {
|
|
|
284
309
|
onProgress({ step: 'restarting', from: currentVersion, to: targetVersion });
|
|
285
310
|
return { ok: true, from: currentVersion, to: targetVersion };
|
|
286
311
|
}
|
|
287
|
-
export async function checkForUpdate() {
|
|
288
|
-
const current = getCurrentVersion();
|
|
312
|
+
export async function checkForUpdate(currentVersion) {
|
|
313
|
+
const current = currentVersion ?? getCurrentVersion();
|
|
289
314
|
const latest = await fetchLatestVersion();
|
|
290
315
|
const changeType = compareVersions(current, latest);
|
|
291
316
|
if (changeType === 'none')
|