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.
@@ -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): Promise<void>;
15
- export declare function handleUpgradeStatus(client: CliRelayClient, reqId: string): Promise<void>;
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 root = path.join(os.homedir(), '.codex', 'sessions');
631
- if (!fs.existsSync(root))
632
- return [];
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
- walk(root);
644
- return files.sort();
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
- const content = fs.readFileSync(match, 'utf8');
670
- for (const line of content.split('\n')) {
671
- if (!line.trim())
672
- continue;
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
- continue;
725
+ return;
676
726
  const cwd = readClaudeEventCwd(parsed);
677
727
  if (cwd)
678
- return cwd;
679
- }
680
- return null;
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
- for (let index = 0, offset = startOffset; index < lines.length; index++) {
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
- continue;
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
- continue;
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
- continue;
753
+ return;
718
754
  }
719
755
  if (!sourceSessionKey)
720
- continue;
756
+ return;
721
757
  if (type === 'response_item') {
722
758
  parseCodexResponseItem(events, filePath, lineOffset, payload, sourceSessionKey, ts, title, modelId, workDir);
723
- continue;
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
- continue;
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
- for (let index = 0, offset = startOffset; index < lines.length; index++) {
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
- continue;
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
- continue;
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
- continue;
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
- continue;
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): Promise<UpgradeResult>;
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {