shennian 0.2.11 → 0.2.13

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/README.md CHANGED
@@ -15,8 +15,14 @@
15
15
  |------|------|
16
16
  | `shennian` | 智能启动:必要时 pair 再启动 daemon |
17
17
  | `shennian pair` | 重新与账号 / server 配对 |
18
+ | `shennian restart` | 重启 daemon;未运行则启动 |
18
19
  | `shennian daemon status` | 查看 daemon 运行状态 |
20
+ | `shennian daemon stop` | 停止 daemon |
21
+ | `shennian daemon restart` | 重启 daemon |
22
+ | `shennian daemon logs -n 100` | 查看最近日志 |
19
23
  | `shennian config` | 查看本地配置 |
24
+ | `shennian agent add/list/remove` | 管理自定义 Agent |
25
+ | `shennian upgrade` | 升级 CLI |
20
26
 
21
27
  ## 开发
22
28
 
@@ -1,5 +1,15 @@
1
1
  import type { Command } from 'commander';
2
+ type Platform = 'darwin' | 'linux' | 'win32';
2
3
  type ServiceLaunchMode = 'direct' | 'global-shim' | 'npx';
4
+ export type DaemonStatus = {
5
+ running: boolean;
6
+ pid: number | null;
7
+ stale: boolean;
8
+ platform: Platform;
9
+ shennianDir: string;
10
+ pidFile: string;
11
+ logFile: string;
12
+ };
3
13
  export type ServiceLaunchSpec = {
4
14
  command: string;
5
15
  args: string[];
@@ -12,6 +22,9 @@ export declare function resolveServiceLaunchSpec(input: {
12
22
  shennianCommandPath?: string | null;
13
23
  npxPath?: string | null;
14
24
  }): ServiceLaunchSpec;
25
+ export declare function getDaemonStatus(opts?: {
26
+ cleanupStale?: boolean;
27
+ }): DaemonStatus;
15
28
  export declare function buildWindowsLauncherCommand(spec: ServiceLaunchSpec, logFile: string): string;
16
29
  export declare function buildWindowsStartupVbs(cmdPath: string): string;
17
30
  export declare function buildWindowsScheduledTaskXml(input: {
@@ -25,7 +38,9 @@ export declare function captureEnvForService(): Record<string, string>;
25
38
  * This is critical for launchd/systemd which start with minimal env.
26
39
  */
27
40
  export declare function saveEnvSnapshot(): void;
28
- export declare function startDaemonProcess(): void;
41
+ export declare function startDaemonProcess(opts?: {
42
+ quiet?: boolean;
43
+ }): void;
29
44
  /**
30
45
  * Install the platform-native auto-start service.
31
46
  * Returns true if the service was immediately started (so caller can skip startDaemonProcess).
@@ -75,6 +75,31 @@ function isRunning(pid) {
75
75
  return false;
76
76
  }
77
77
  }
78
+ export function getDaemonStatus(opts = {}) {
79
+ const pid = readPid();
80
+ const running = pid !== null && isRunning(pid);
81
+ const stale = pid !== null && !running;
82
+ if (stale && opts.cleanupStale) {
83
+ try {
84
+ fs.unlinkSync(PID_FILE);
85
+ }
86
+ catch {
87
+ // Another process may have cleaned up the stale pid file first.
88
+ }
89
+ }
90
+ return {
91
+ running,
92
+ pid,
93
+ stale,
94
+ platform: getPlatform(),
95
+ shennianDir: SHENNIAN_DIR,
96
+ pidFile: PID_FILE,
97
+ logFile: LOG_FILE,
98
+ };
99
+ }
100
+ function printJson(value) {
101
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
102
+ }
78
103
  // ─── Service definitions ─────────────────────────────────────────────────────
79
104
  const LAUNCHD_LABEL = 'com.shennian.agent';
80
105
  const LAUNCHD_PLIST = path.join(os.homedir(), 'Library/LaunchAgents', `${LAUNCHD_LABEL}.plist`);
@@ -142,12 +167,7 @@ function isWindowsCmdScript(filePath) {
142
167
  export function buildWindowsLauncherCommand(spec, logFile) {
143
168
  const commandLine = [spec.command, ...spec.args].map(quoteCmdArg).join(' ');
144
169
  const invocation = isWindowsCmdScript(spec.command) ? `call ${commandLine}` : commandLine;
145
- return [
146
- '@echo off',
147
- 'setlocal',
148
- `${invocation} >> ${quoteCmdArg(logFile)} 2>&1`,
149
- '',
150
- ].join('\r\n');
170
+ return ['@echo off', 'setlocal', `${invocation} >> ${quoteCmdArg(logFile)} 2>&1`, ''].join('\r\n');
151
171
  }
152
172
  export function buildWindowsStartupVbs(cmdPath) {
153
173
  return [
@@ -269,7 +289,9 @@ function installWindowsScheduledTask() {
269
289
  });
270
290
  fs.writeFileSync(WINDOWS_TASK_XML, taskXml, 'utf8');
271
291
  try {
272
- execSync(`schtasks /create /tn "${WINDOWS_TASK_NAME}" /xml "${WINDOWS_TASK_XML}" /f`, { stdio: 'pipe' });
292
+ execSync(`schtasks /create /tn "${WINDOWS_TASK_NAME}" /xml "${WINDOWS_TASK_XML}" /f`, {
293
+ stdio: 'pipe',
294
+ });
273
295
  execSync(`schtasks /run /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe' });
274
296
  return true;
275
297
  }
@@ -279,12 +301,27 @@ function installWindowsScheduledTask() {
279
301
  }
280
302
  export function captureEnvForService() {
281
303
  const keep = [
282
- 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG',
283
- 'LC_ALL', 'LC_CTYPE', 'SSH_AUTH_SOCK', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
284
- 'TEMP', 'TMP',
285
- 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
286
- 'OPENAI_API_KEY', 'OPENAI_BASE_URL',
287
- 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
304
+ 'PATH',
305
+ 'HOME',
306
+ 'USER',
307
+ 'LOGNAME',
308
+ 'SHELL',
309
+ 'TMPDIR',
310
+ 'LANG',
311
+ 'LC_ALL',
312
+ 'LC_CTYPE',
313
+ 'SSH_AUTH_SOCK',
314
+ 'XDG_CONFIG_HOME',
315
+ 'XDG_DATA_HOME',
316
+ 'TEMP',
317
+ 'TMP',
318
+ 'ANTHROPIC_API_KEY',
319
+ 'ANTHROPIC_AUTH_TOKEN',
320
+ 'ANTHROPIC_BASE_URL',
321
+ 'OPENAI_API_KEY',
322
+ 'OPENAI_BASE_URL',
323
+ 'GEMINI_API_KEY',
324
+ 'GOOGLE_API_KEY',
288
325
  ];
289
326
  const env = {};
290
327
  for (const k of keep) {
@@ -379,11 +416,13 @@ export function saveEnvSnapshot() {
379
416
  fs.writeFileSync(resolveShennianPath('env.json'), JSON.stringify(snapshot, null, 2));
380
417
  }
381
418
  // ─── Exported helpers (used by pair.ts) ─────────────────────────────────────
382
- export function startDaemonProcess() {
419
+ export function startDaemonProcess(opts = {}) {
383
420
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
384
421
  const pid = readPid();
385
422
  if (pid !== null && isRunning(pid)) {
386
- console.log(chalk.green(`✓ Background service already running (PID ${pid})`));
423
+ if (!opts.quiet) {
424
+ console.log(chalk.green(`✓ Background service already running (PID ${pid})`));
425
+ }
387
426
  return;
388
427
  }
389
428
  const logFd = fs.openSync(LOG_FILE, 'a');
@@ -397,8 +436,10 @@ export function startDaemonProcess() {
397
436
  child.unref();
398
437
  fs.closeSync(logFd);
399
438
  fs.writeFileSync(PID_FILE, String(child.pid));
400
- console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
401
- console.log(chalk.gray(` Logs: ${LOG_FILE}`));
439
+ if (!opts.quiet) {
440
+ console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
441
+ console.log(chalk.gray(` Logs: ${LOG_FILE}`));
442
+ }
402
443
  }
403
444
  /**
404
445
  * Install the platform-native auto-start service.
@@ -409,8 +450,8 @@ export function installService() {
409
450
  const platform = getPlatform();
410
451
  const spec = resolveCurrentServiceLaunchSpec();
411
452
  if (spec.mode === 'direct' && isEphemeralCliPath(SHENNIAN_SCRIPT)) {
412
- console.warn(chalk.yellow('⚠ Warning: Current CLI path is temporary (npx). Auto-start may not work after reboot.\n'
413
- + ' Run `npm install -g shennian` for reliable auto-start.'));
453
+ console.warn(chalk.yellow('⚠ Warning: Current CLI path is temporary (npx). Auto-start may not work after reboot.\n' +
454
+ ' Run `npm install -g shennian` for reliable auto-start.'));
414
455
  }
415
456
  switch (platform) {
416
457
  case 'darwin': {
@@ -438,7 +479,9 @@ export function installService() {
438
479
  fs.mkdirSync(dir, { recursive: true });
439
480
  fs.writeFileSync(SYSTEMD_UNIT, buildSystemdUnit());
440
481
  try {
441
- execSync('systemctl --user daemon-reload && systemctl --user enable shennian', { stdio: 'pipe' });
482
+ execSync('systemctl --user daemon-reload && systemctl --user enable shennian', {
483
+ stdio: 'pipe',
484
+ });
442
485
  }
443
486
  catch {
444
487
  // Some Linux environments lack systemd user services; keep best-effort behavior.
@@ -459,10 +502,20 @@ export function installService() {
459
502
  }
460
503
  }
461
504
  // ─── Subcommand implementations ──────────────────────────────────────────────
462
- function daemonStop() {
505
+ function daemonStart(opts) {
506
+ startDaemonProcess({ quiet: opts.json });
507
+ if (opts.json)
508
+ printJson(getDaemonStatus());
509
+ }
510
+ function daemonStop(opts = {}) {
463
511
  const pid = readPid();
464
512
  if (pid === null) {
465
- console.log(chalk.yellow('⚠ No running background service found'));
513
+ if (opts.json) {
514
+ printJson(getDaemonStatus());
515
+ }
516
+ else {
517
+ console.log(chalk.yellow('⚠ No running background service found'));
518
+ }
466
519
  return;
467
520
  }
468
521
  if (!isRunning(pid)) {
@@ -472,7 +525,12 @@ function daemonStop() {
472
525
  catch {
473
526
  // Stale pid file is already gone.
474
527
  }
475
- console.log(chalk.yellow('⚠ Service process no longer exists, cleaned up'));
528
+ if (opts.json) {
529
+ printJson(getDaemonStatus());
530
+ }
531
+ else {
532
+ console.log(chalk.yellow('⚠ Service process no longer exists, cleaned up'));
533
+ }
476
534
  return;
477
535
  }
478
536
  try {
@@ -483,31 +541,100 @@ function daemonStop() {
483
541
  catch {
484
542
  // Best-effort cleanup after SIGTERM.
485
543
  }
486
- console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
544
+ if (opts.json) {
545
+ printJson(getDaemonStatus());
546
+ }
547
+ else {
548
+ console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
549
+ }
487
550
  }
488
551
  catch (err) {
489
- console.error(chalk.red(`✗ Failed to stop: ${err instanceof Error ? err.message : String(err)}`));
552
+ const message = err instanceof Error ? err.message : String(err);
553
+ if (opts.json) {
554
+ printJson({ ...getDaemonStatus(), error: message });
555
+ }
556
+ else {
557
+ console.error(chalk.red(`✗ Failed to stop: ${message}`));
558
+ }
490
559
  }
491
560
  }
492
- function daemonStatus() {
493
- const pid = readPid();
494
- if (pid === null) {
495
- console.log(chalk.gray('● Background service not running'));
496
- return;
561
+ async function waitForPidExit(pid, timeoutMs = 5000) {
562
+ const startedAt = Date.now();
563
+ while (Date.now() - startedAt < timeoutMs) {
564
+ if (!isRunning(pid))
565
+ return true;
566
+ await new Promise((resolve) => setTimeout(resolve, 100));
497
567
  }
498
- if (isRunning(pid)) {
499
- console.log(chalk.green(`● Background service running (PID ${pid})`));
500
- console.log(chalk.gray(` Logs: ${LOG_FILE}`));
568
+ return !isRunning(pid);
569
+ }
570
+ async function daemonRestart(opts = {}) {
571
+ const pid = readPid();
572
+ if (pid !== null && isRunning(pid)) {
573
+ try {
574
+ process.kill(pid, 'SIGTERM');
575
+ const stopped = await waitForPidExit(pid);
576
+ if (!stopped) {
577
+ process.kill(pid, 'SIGKILL');
578
+ await waitForPidExit(pid, 2000);
579
+ }
580
+ try {
581
+ fs.unlinkSync(PID_FILE);
582
+ }
583
+ catch {
584
+ // Best-effort cleanup after restart stop.
585
+ }
586
+ if (!opts.json) {
587
+ console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
588
+ }
589
+ }
590
+ catch (err) {
591
+ const message = err instanceof Error ? err.message : String(err);
592
+ if (opts.json) {
593
+ printJson({ ...getDaemonStatus(), error: message });
594
+ }
595
+ else {
596
+ console.error(chalk.red(`✗ Failed to stop: ${message}`));
597
+ }
598
+ return;
599
+ }
501
600
  }
502
- else {
503
- console.log(chalk.yellow(`● Background service stopped (PID ${pid} is stale)`));
601
+ else if (pid !== null) {
504
602
  try {
505
603
  fs.unlinkSync(PID_FILE);
506
604
  }
507
605
  catch {
508
- // Another process may have cleaned up the stale pid file first.
606
+ // Stale pid file is already gone.
607
+ }
608
+ if (!opts.json) {
609
+ console.log(chalk.yellow(`⚠ Service process no longer exists, cleaned up stale PID ${pid}`));
509
610
  }
510
611
  }
612
+ else {
613
+ if (!opts.json) {
614
+ console.log(chalk.gray('● Background service not running, starting it now'));
615
+ }
616
+ }
617
+ startDaemonProcess({ quiet: opts.json });
618
+ if (opts.json)
619
+ printJson(getDaemonStatus());
620
+ }
621
+ function daemonStatus(opts = {}) {
622
+ const status = getDaemonStatus({ cleanupStale: true });
623
+ if (opts.json) {
624
+ printJson(status);
625
+ return;
626
+ }
627
+ if (status.pid === null) {
628
+ console.log(chalk.gray('● Background service not running'));
629
+ return;
630
+ }
631
+ if (status.running) {
632
+ console.log(chalk.green(`● Background service running (PID ${status.pid})`));
633
+ console.log(chalk.gray(` Logs: ${LOG_FILE}`));
634
+ }
635
+ else {
636
+ console.log(chalk.yellow(`● Background service stopped (PID ${status.pid} is stale)`));
637
+ }
511
638
  }
512
639
  function daemonLogs(opts) {
513
640
  if (!fs.existsSync(LOG_FILE)) {
@@ -589,24 +716,32 @@ function daemonUninstall() {
589
716
  }
590
717
  // ─── Register command ─────────────────────────────────────────────────────────
591
718
  export function registerDaemonCommand(program) {
592
- const daemon = program
593
- .command('daemon')
594
- .description('Manage the background service');
719
+ const daemon = program.command('daemon').description('Manage the background service');
720
+ program.command('restart').description('Restart the background service').action(() => daemonRestart());
721
+ daemon
722
+ .command('start')
723
+ .description('Start the background service')
724
+ .option('--json', 'Print machine-readable status JSON')
725
+ .action((opts) => daemonStart(opts));
595
726
  daemon
596
727
  .command('stop')
597
728
  .description('Stop the background service')
598
- .action(daemonStop);
729
+ .option('--json', 'Print machine-readable status JSON')
730
+ .action((opts) => daemonStop(opts));
731
+ daemon
732
+ .command('restart')
733
+ .description('Restart the background service')
734
+ .option('--json', 'Print machine-readable status JSON')
735
+ .action((opts) => daemonRestart(opts));
599
736
  daemon
600
737
  .command('status')
601
738
  .description('Show background service status')
602
- .action(daemonStatus);
739
+ .option('--json', 'Print machine-readable status JSON')
740
+ .action((opts) => daemonStatus(opts));
603
741
  daemon
604
742
  .command('logs')
605
743
  .description('Show recent logs')
606
744
  .option('-n, --lines <n>', 'Number of lines', '50')
607
745
  .action((opts) => daemonLogs({ lines: parseInt(opts.lines, 10) }));
608
- daemon
609
- .command('uninstall')
610
- .description('Uninstall auto-start service')
611
- .action(daemonUninstall);
746
+ daemon.command('uninstall').description('Uninstall auto-start service').action(daemonUninstall);
612
747
  }
package/dist/src/index.js CHANGED
@@ -4,7 +4,7 @@ import { Command } from 'commander';
4
4
  import chalk from 'chalk';
5
5
  import os from 'node:os';
6
6
  import fs from 'node:fs';
7
- import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath } from './config/index.js';
7
+ import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath, } from './config/index.js';
8
8
  import { CliRelayClient } from './relay/client.js';
9
9
  import { registerPairCommand, runSmartStart } from './commands/pair.js';
10
10
  import { registerDaemonCommand } from './commands/daemon.js';
@@ -37,10 +37,7 @@ function formatDisconnectInfo(info) {
37
37
  return parts.join(' ');
38
38
  }
39
39
  const program = new Command();
40
- program
41
- .name('shennian')
42
- .description('Shennian — AI Agent Mobile Console')
43
- .version(cliVersion);
40
+ program.name('shennian').description('Shennian — AI Agent Mobile Console').version(cliVersion);
44
41
  program
45
42
  .option('--api <url>', 'Server URL override')
46
43
  .option('--name <name>', 'Machine name', os.hostname())
@@ -81,12 +78,18 @@ program
81
78
  }
82
79
  }
83
80
  }
84
- catch { /* noop */ }
85
- fs.writeFileSync(pidFile, String(process.pid));
86
- process.on('exit', () => { try {
87
- fs.unlinkSync(pidFile);
81
+ catch {
82
+ /* noop */
88
83
  }
89
- catch { /* noop */ } });
84
+ fs.writeFileSync(pidFile, String(process.pid));
85
+ process.on('exit', () => {
86
+ try {
87
+ fs.unlinkSync(pidFile);
88
+ }
89
+ catch {
90
+ /* noop */
91
+ }
92
+ });
90
93
  // Crash detection: if we're recovering from a failed upgrade, rollback and exit
91
94
  const didRollback = await handleStartupCrashCheck();
92
95
  if (didRollback) {
@@ -109,7 +112,11 @@ program
109
112
  initCliLogReporter(serverUrl, config.machineId);
110
113
  }
111
114
  console.log(`[${new Date().toISOString()}] Connecting to ${wsUrl}... (v${cliVersion}) agents: ${agentList.join(',')}`);
112
- reportLog({ level: 'info', wsEvent: 'daemon.start', metadata: { version: cliVersion, agents: agentList } });
115
+ reportLog({
116
+ level: 'info',
117
+ wsEvent: 'daemon.start',
118
+ metadata: { version: cliVersion, agents: agentList },
119
+ });
113
120
  let nativeFusion = null;
114
121
  const client = new CliRelayClient({
115
122
  serverUrl: wsUrl,
@@ -132,7 +139,8 @@ program
132
139
  void resolveAgentInfos(detectedAgents, {
133
140
  serverUrl: config.serverUrl ?? serverUrl,
134
141
  authToken: config.machineToken ?? config.accessToken,
135
- }).then((agents) => {
142
+ })
143
+ .then((agents) => {
136
144
  if (JSON.stringify(agents) === JSON.stringify(cachedAgentInfos))
137
145
  return;
138
146
  client.sendEvent({
@@ -143,8 +151,10 @@ program
143
151
  agents,
144
152
  },
145
153
  });
146
- }).catch(() => { });
147
- import('./upgrade/engine.js').then(({ readUpgradeAttempt, clearUpgradeAttempt }) => {
154
+ })
155
+ .catch(() => { });
156
+ import('./upgrade/engine.js')
157
+ .then(({ readUpgradeAttempt, clearUpgradeAttempt }) => {
148
158
  const attempt = readUpgradeAttempt();
149
159
  clearUpgradeAttempt();
150
160
  if (attempt) {
@@ -152,10 +162,16 @@ program
152
162
  client.sendEvent({
153
163
  type: 'event',
154
164
  event: 'upgrade',
155
- payload: { state: 'success', machineId: 'self', from: attempt.from, to: attempt.to },
165
+ payload: {
166
+ state: 'success',
167
+ machineId: 'self',
168
+ from: attempt.from,
169
+ to: attempt.to,
170
+ },
156
171
  });
157
172
  }
158
- }).catch(() => { });
173
+ })
174
+ .catch(() => { });
159
175
  void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch');
160
176
  nativeFusion?.handleConnected();
161
177
  },
@@ -176,13 +192,20 @@ program
176
192
  },
177
193
  onReq: (req) => {
178
194
  console.log(`[${new Date().toISOString()}] [req] ${req.method}`);
179
- reportLog({ level: 'info', type: 'ws', wsEvent: req.method, wsDirection: 'in', traceId: req.traceId });
195
+ reportLog({
196
+ level: 'info',
197
+ type: 'ws',
198
+ wsEvent: req.method,
199
+ wsDirection: 'in',
200
+ traceId: req.traceId,
201
+ });
180
202
  void sessionManager.handleReq(req);
181
203
  },
182
204
  });
183
- nativeFusion = process.env.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
184
- ? null
185
- : new NativeSessionFusionService(client);
205
+ nativeFusion =
206
+ process.env.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
207
+ ? null
208
+ : new NativeSessionFusionService(client);
186
209
  const sessionManager = new SessionManager(client, nativeFusion);
187
210
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
188
211
  fs.writeFileSync(PID_FILE, String(process.pid));
@@ -197,16 +220,16 @@ program
197
220
  try {
198
221
  fs.unlinkSync(PID_FILE);
199
222
  }
200
- catch { /* noop */ }
223
+ catch {
224
+ /* noop */
225
+ }
201
226
  process.exit(0);
202
227
  };
203
228
  process.on('SIGINT', shutdown);
204
229
  process.on('SIGTERM', shutdown);
205
230
  });
206
231
  // config: view and modify settings
207
- const configCmd = program
208
- .command('config')
209
- .description('View or modify configuration');
232
+ const configCmd = program.command('config').description('View or modify configuration');
210
233
  configCmd
211
234
  .command('show', { isDefault: true })
212
235
  .description('Show current configuration')
@@ -238,7 +261,7 @@ configCmd
238
261
  config.serverUrl = regionToUrl(region);
239
262
  saveConfig(config);
240
263
  console.log(chalk.green(`✓ Server set to ${SERVERS[region].label}`));
241
- console.log(chalk.yellow(' Restart the daemon for this to take effect: shennian daemon stop && shennian'));
264
+ console.log(chalk.yellow(' Restart the daemon for this to take effect: shennian restart'));
242
265
  return;
243
266
  }
244
267
  console.error(chalk.red(`✗ Unknown config key: ${key}. Supported: server`));
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { buildUserMessagePayload } from '@shennian/wire';
7
+ import { buildUserMessagePayload, isToolPayload } from '@shennian/wire';
8
8
  function normalizeText(text) {
9
9
  return stripGitDirectiveArtifacts(text.replace(/\r\n/g, '\n').trim());
10
10
  }
@@ -256,7 +256,7 @@ function parseCodexUserMessage(payload) {
256
256
  }
257
257
  return { payload: text, titleText: text };
258
258
  }
259
- function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, payload, title, modelId, workDir, role = 'agent') {
259
+ function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, payload, title, modelId, workDir, role = 'agent', terminal = false) {
260
260
  if (!payload)
261
261
  return;
262
262
  events.push({
@@ -271,6 +271,7 @@ function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts
271
271
  title,
272
272
  modelId,
273
273
  workDir,
274
+ terminal,
274
275
  });
275
276
  }
276
277
  function pushCodexToolEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, toolName, title, modelId, workDir, args, result) {
@@ -327,6 +328,17 @@ function parseCodexEventMessage(events, filePath, lineOffset, payload, sourceSes
327
328
  pushCodexEvent(events, filePath, lineOffset, eventType, sourceSessionKey, ts, text, title, modelId, workDir);
328
329
  return;
329
330
  }
331
+ if (eventType === 'turn_aborted' || eventType === 'error') {
332
+ const text = typeof payload.message === 'string'
333
+ ? normalizeText(payload.message)
334
+ : typeof payload.error === 'string'
335
+ ? normalizeText(payload.error)
336
+ : '';
337
+ if (!text)
338
+ return;
339
+ pushCodexEvent(events, filePath, lineOffset, eventType, sourceSessionKey, ts, text, title, modelId, workDir, 'agent', true);
340
+ return;
341
+ }
330
342
  if (eventType === 'exec_command_end') {
331
343
  const command = Array.isArray(payload.command) ? payload.command : payload.parsed_cmd ?? payload.command;
332
344
  const exitCode = payload.exit_code ?? payload.exitCode;
@@ -565,6 +577,7 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
565
577
  const lines = chunk.split('\n').filter(Boolean);
566
578
  const events = [];
567
579
  let { sourceSessionKey, workDir, modelId, title } = readCodexSessionMeta(filePath);
580
+ let pendingTerminal = false;
568
581
  for (let index = 0, offset = startOffset; index < lines.length; index++) {
569
582
  const line = lines[index];
570
583
  const parsed = safeParse(line);
@@ -595,12 +608,32 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
595
608
  }
596
609
  if (type === 'event_msg') {
597
610
  const eventType = typeof payload.type === 'string' ? payload.type : '';
611
+ if (eventType === 'turn_completed') {
612
+ for (let i = events.length - 1; i >= 0; i -= 1) {
613
+ const event = events[i];
614
+ if (event?.agentType !== 'codex')
615
+ continue;
616
+ if (event.role !== 'agent')
617
+ continue;
618
+ if (isToolPayload(event.payload))
619
+ continue;
620
+ event.terminal = true;
621
+ pendingTerminal = false;
622
+ break;
623
+ }
624
+ continue;
625
+ }
598
626
  if (eventType === 'user_message') {
599
627
  const parsedUser = parseCodexUserMessage(payload);
600
628
  if (parsedUser?.titleText && !title)
601
629
  title = parsedUser.titleText.slice(0, 80);
602
630
  }
631
+ const beforeCount = events.length;
603
632
  parseCodexEventMessage(events, filePath, lineOffset, payload, sourceSessionKey, ts, title, modelId, workDir);
633
+ if (pendingTerminal && events.length > beforeCount) {
634
+ events[events.length - 1].terminal = true;
635
+ pendingTerminal = false;
636
+ }
604
637
  }
605
638
  }
606
639
  return { nextOffset, events };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Shennian — AI Agent Mobile Console CLI",
5
5
  "type": "module",
6
6
  "bin": {