shennian 0.2.25 → 0.2.27

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
@@ -1,6 +1,6 @@
1
1
  # @shennian/cli
2
2
 
3
- > Shennian AI Agent 移动控制台的本机后台服务 / CLI
3
+ > Shennian AI Agent 控制面的本机后台服务 / CLI
4
4
  >
5
5
  > **架构设计** → [docs/architecture/cli/](../../docs/architecture/cli/)
6
6
  > - [Daemon 架构](../../docs/architecture/cli/daemon.md) — 模块拓扑、数据流、守护进程
File without changes
@@ -19,6 +19,9 @@ const BUILTIN_COMMANDS = {
19
19
  openclaw: [
20
20
  { command: 'openclaw', args: [], display: 'openclaw' },
21
21
  ],
22
+ opencode: [
23
+ { command: 'opencode', args: [], display: 'opencode' },
24
+ ],
22
25
  pi: [
23
26
  { command: 'shennian', args: [], display: 'shennian' },
24
27
  ],
@@ -2,7 +2,7 @@
2
2
  // @test src/__tests__/model-switching.test.ts
3
3
  import { createInterface } from 'node:readline';
4
4
  import { resolveBuiltinCommand, spawnResolvedCommand } from '../command-spec.js';
5
- import { fallbackClaudeAliasModels, fallbackGeminiModels, parseCodexAppServerModels, parseCursorModels, parseOpenClawModels, } from './parsers.js';
5
+ import { fallbackClaudeAliasModels, fallbackGeminiModels, parseCodexAppServerModels, parseCursorModels, parseOpenCodeModels, parseOpenClawModels, } from './parsers.js';
6
6
  import { runResolvedCommand } from './runner.js';
7
7
  import { DISCOVERY_WORKDIR } from './types.js';
8
8
  function sendAppServerRpc(proc, pending, id, method, params, timeoutMs) {
@@ -97,6 +97,13 @@ async function discoverOpenClawModels() {
97
97
  const result = await runResolvedCommand(spec, ['models', 'list', '--json']);
98
98
  return parseOpenClawModels(result.stdout);
99
99
  }
100
+ async function discoverOpenCodeModels() {
101
+ const spec = resolveBuiltinCommand('opencode');
102
+ if (!spec)
103
+ return [];
104
+ const result = await runResolvedCommand(spec, ['models']);
105
+ return parseOpenCodeModels(result.stdout);
106
+ }
100
107
  async function discoverClaudeModels() {
101
108
  if (!resolveBuiltinCommand('claude'))
102
109
  return [];
@@ -154,6 +161,8 @@ async function discoverBuiltinModels(type, context) {
154
161
  return discoverCursorModels();
155
162
  case 'openclaw':
156
163
  return discoverOpenClawModels();
164
+ case 'opencode':
165
+ return discoverOpenCodeModels();
157
166
  case 'pi':
158
167
  return discoverPiModels(context);
159
168
  }
@@ -2,6 +2,7 @@ import type { ModelInfo } from '@shennian/wire';
2
2
  export declare function stripAnsi(text: string): string;
3
3
  export declare function parseCursorModels(raw: string): ModelInfo[];
4
4
  export declare function parseOpenClawModels(raw: string): ModelInfo[];
5
+ export declare function parseOpenCodeModels(raw: string): ModelInfo[];
5
6
  export declare function parseClaudeModels(raw: string): ModelInfo[];
6
7
  type EnvLike = Record<string, string | undefined>;
7
8
  export declare function applyClaudeModelEnvOverrides(models: ModelInfo[], env?: EnvLike): ModelInfo[];
@@ -94,6 +94,19 @@ export function parseOpenClawModels(raw) {
94
94
  return [];
95
95
  }
96
96
  }
97
+ export function parseOpenCodeModels(raw) {
98
+ const models = stripAnsi(raw)
99
+ .split('\n')
100
+ .map((line) => line.trim())
101
+ .filter(Boolean)
102
+ .filter((line) => !line.includes(' '))
103
+ .map((id) => ({
104
+ id,
105
+ name: id,
106
+ provider: id.includes('/') ? id.split('/')[0] : undefined,
107
+ }));
108
+ return uniqueModels(models);
109
+ }
97
110
  export function parseClaudeModels(raw) {
98
111
  const rawClean = stripAnsi(raw).replace(/\s+/g, ' ');
99
112
  const patterns = [
@@ -0,0 +1,21 @@
1
+ import { AgentAdapter } from './adapter.js';
2
+ export declare class OpenCodeAdapter extends AgentAdapter {
3
+ readonly type: "opencode";
4
+ private process;
5
+ private agentSessionId;
6
+ private workDir;
7
+ private seq;
8
+ private runId;
9
+ private terminalState;
10
+ start(_sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
11
+ send(text: string, modelId?: string): Promise<void>;
12
+ resume(agentSessionId: string): Promise<void>;
13
+ stop(): Promise<void>;
14
+ private resetRunState;
15
+ private spawnAndParse;
16
+ private handleStreamEvent;
17
+ private emitEvent;
18
+ private emitFinalIfOpen;
19
+ private emitErrorIfOpen;
20
+ private killProcess;
21
+ }
@@ -0,0 +1,230 @@
1
+ // @arch docs/architecture/cli/agent-adapters.md
2
+ // @test src/__tests__/opencode-adapter.test.ts
3
+ import { createInterface } from 'node:readline';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { AgentAdapter, registerAgent } from './adapter.js';
6
+ import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
7
+ function usageFromTokens(tokens) {
8
+ if (!tokens)
9
+ return undefined;
10
+ const input = tokens.input;
11
+ const output = tokens.output;
12
+ if (typeof input !== 'number' && typeof output !== 'number')
13
+ return undefined;
14
+ return {
15
+ inputTokens: typeof input === 'number' ? input : 0,
16
+ outputTokens: typeof output === 'number' ? output : 0,
17
+ };
18
+ }
19
+ function stringifyResult(value) {
20
+ if (value == null)
21
+ return '';
22
+ if (typeof value === 'string')
23
+ return value;
24
+ try {
25
+ return JSON.stringify(value);
26
+ }
27
+ catch {
28
+ return String(value);
29
+ }
30
+ }
31
+ function extractErrorMessage(value) {
32
+ if (!value)
33
+ return 'opencode request failed';
34
+ if (typeof value === 'string')
35
+ return value;
36
+ if (typeof value !== 'object')
37
+ return String(value);
38
+ const record = value;
39
+ if (typeof record.message === 'string')
40
+ return record.message;
41
+ if (typeof record.name === 'string')
42
+ return record.name;
43
+ if ('data' in record)
44
+ return extractErrorMessage(record.data);
45
+ return stringifyResult(value) || 'opencode request failed';
46
+ }
47
+ export class OpenCodeAdapter extends AgentAdapter {
48
+ type = 'opencode';
49
+ process = null;
50
+ agentSessionId = null;
51
+ workDir = null;
52
+ seq = 0;
53
+ runId = '';
54
+ terminalState = 'closed';
55
+ async start(_sessionId, workDir, agentSessionId) {
56
+ this.workDir = workDir;
57
+ if (agentSessionId)
58
+ this.agentSessionId = agentSessionId;
59
+ this.resetRunState();
60
+ }
61
+ async send(text, modelId) {
62
+ await this.killProcess();
63
+ this.runId = randomUUID();
64
+ this.resetRunState();
65
+ const args = [
66
+ 'run',
67
+ '--format',
68
+ 'json',
69
+ '--dangerously-skip-permissions',
70
+ '--dir',
71
+ this.workDir ?? process.cwd(),
72
+ ];
73
+ if (this.agentSessionId) {
74
+ args.push('--session', this.agentSessionId);
75
+ }
76
+ if (modelId) {
77
+ args.push('--model', modelId);
78
+ }
79
+ args.push(text);
80
+ this.spawnAndParse(args);
81
+ }
82
+ async resume(agentSessionId) {
83
+ await this.killProcess();
84
+ this.agentSessionId = agentSessionId;
85
+ this.runId = randomUUID();
86
+ this.resetRunState();
87
+ this.emitEvent({ state: 'start', agentSessionId });
88
+ this.emitFinalIfOpen({ state: 'final', agentSessionId });
89
+ }
90
+ async stop() {
91
+ await this.killProcess();
92
+ }
93
+ resetRunState() {
94
+ this.seq = 0;
95
+ this.terminalState = 'open';
96
+ }
97
+ spawnAndParse(args) {
98
+ const spec = resolveBuiltinCommand('opencode');
99
+ if (!spec) {
100
+ this.emit('error', new Error('Command "opencode" not found. Install it with "npm i -g opencode-ai@latest".'));
101
+ return;
102
+ }
103
+ const proc = spawnResolvedCommand(spec, args, {
104
+ cwd: this.workDir ?? undefined,
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ env: { ...process.env, NO_COLOR: '1' },
107
+ });
108
+ this.process = proc;
109
+ const rl = createInterface({ input: proc.stdout });
110
+ rl.on('line', (line) => {
111
+ if (!line.trim())
112
+ return;
113
+ try {
114
+ const obj = JSON.parse(line);
115
+ this.handleStreamEvent(obj);
116
+ }
117
+ catch {
118
+ this.emitEvent({ state: 'delta', text: line });
119
+ }
120
+ });
121
+ let stderrBuf = '';
122
+ proc.stderr?.on('data', (chunk) => {
123
+ stderrBuf += chunk.toString();
124
+ });
125
+ proc.on('close', (code) => {
126
+ if (this.process !== proc)
127
+ return;
128
+ this.process = null;
129
+ if (this.terminalState !== 'open')
130
+ return;
131
+ const stderr = stderrBuf.trim();
132
+ if (code !== 0 && code !== null) {
133
+ this.emitErrorIfOpen({ state: 'error', message: stderr || `opencode exited with code ${code}` });
134
+ return;
135
+ }
136
+ this.emitFinalIfOpen({ state: 'final', agentSessionId: this.agentSessionId ?? undefined });
137
+ });
138
+ proc.on('error', (err) => {
139
+ if (this.process !== proc)
140
+ return;
141
+ this.process = null;
142
+ if (err.code === 'ENOENT') {
143
+ this.emit('error', new Error('Command "opencode" not found. Install it with "npm i -g opencode-ai@latest".'));
144
+ }
145
+ else {
146
+ this.emit('error', err);
147
+ }
148
+ });
149
+ }
150
+ handleStreamEvent(obj) {
151
+ if (obj.sessionID && obj.sessionID !== this.agentSessionId) {
152
+ this.agentSessionId = obj.sessionID;
153
+ this.emitEvent({ state: 'start', agentSessionId: obj.sessionID });
154
+ }
155
+ const part = obj.part;
156
+ if (obj.type === 'text' && part?.text) {
157
+ this.emitEvent({ state: 'delta', text: part.text });
158
+ return;
159
+ }
160
+ if (obj.type === 'reasoning' && part?.text) {
161
+ this.emitEvent({ state: 'delta', text: part.text, thinking: true });
162
+ return;
163
+ }
164
+ if (obj.type === 'tool_use' && part?.type === 'tool') {
165
+ if (part.state?.status === 'completed' || part.state?.status === 'error') {
166
+ this.emitEvent({
167
+ state: 'tool-result',
168
+ name: part.tool ?? 'tool',
169
+ result: part.state.status === 'error'
170
+ ? part.state.error ?? 'tool failed'
171
+ : stringifyResult(part.state?.output),
172
+ });
173
+ }
174
+ else {
175
+ this.emitEvent({
176
+ state: 'tool-call',
177
+ name: part.tool ?? 'tool',
178
+ args: part.state?.input ?? {},
179
+ });
180
+ }
181
+ return;
182
+ }
183
+ if (obj.type === 'error') {
184
+ this.emitErrorIfOpen({ state: 'error', message: extractErrorMessage(obj.error) });
185
+ return;
186
+ }
187
+ if (obj.type === 'step_finish') {
188
+ this.emitFinalIfOpen({
189
+ state: 'final',
190
+ agentSessionId: this.agentSessionId ?? obj.sessionID,
191
+ usage: usageFromTokens(part?.tokens),
192
+ });
193
+ }
194
+ }
195
+ emitEvent(partial) {
196
+ const event = {
197
+ ...partial,
198
+ runId: this.runId,
199
+ seq: this.seq++,
200
+ };
201
+ this.emit('agentEvent', event);
202
+ }
203
+ emitFinalIfOpen(partial) {
204
+ if (this.terminalState !== 'open')
205
+ return;
206
+ this.terminalState = 'closed';
207
+ this.emitEvent(partial);
208
+ }
209
+ emitErrorIfOpen(partial) {
210
+ if (this.terminalState !== 'open')
211
+ return;
212
+ this.terminalState = 'closed';
213
+ this.emitEvent(partial);
214
+ }
215
+ async killProcess() {
216
+ const proc = this.process;
217
+ if (!proc)
218
+ return;
219
+ this.process = null;
220
+ proc.kill('SIGTERM');
221
+ await new Promise((resolve) => {
222
+ proc.on('close', resolve);
223
+ setTimeout(() => {
224
+ proc.kill('SIGKILL');
225
+ resolve();
226
+ }, 3000);
227
+ });
228
+ }
229
+ }
230
+ registerAgent('opencode', () => new OpenCodeAdapter());
@@ -1,11 +1,13 @@
1
1
  import type { Command } from 'commander';
2
2
  type Platform = 'darwin' | 'linux' | 'win32';
3
3
  type ServiceLaunchMode = 'direct' | 'global-shim' | 'npx';
4
+ type DaemonLauncher = 'desktop-managed' | 'global-cli' | 'unknown';
4
5
  export type DaemonStatus = {
5
6
  running: boolean;
6
7
  pid: number | null;
7
8
  stale: boolean;
8
9
  remoteAccessDisabled: boolean;
10
+ launcher: DaemonLauncher;
9
11
  platform: Platform;
10
12
  shennianDir: string;
11
13
  pidFile: string;
@@ -25,6 +27,10 @@ export declare function resolveServiceLaunchSpec(input: {
25
27
  desktopBridgePath?: string | null;
26
28
  }): ServiceLaunchSpec;
27
29
  export declare function isRemoteAccessDisabled(): boolean;
30
+ export declare function getCurrentProcessDaemonLauncher(): DaemonLauncher;
31
+ export declare function writeDaemonLauncher(pid: number, launcher?: DaemonLauncher): void;
32
+ export declare function clearDaemonLauncher(): void;
33
+ export declare function recordStartedDaemon(childPid: number | undefined): void;
28
34
  export declare function getDaemonStatus(opts?: {
29
35
  cleanupStale?: boolean;
30
36
  }): DaemonStatus;
@@ -12,6 +12,7 @@ const SHENNIAN_DIR = getShennianDir();
12
12
  const PID_FILE = resolveShennianPath('daemon.pid');
13
13
  const LOG_FILE = resolveShennianPath('daemon.log');
14
14
  const REMOTE_ACCESS_DISABLED_FILE = resolveShennianPath('remote-access.disabled');
15
+ const LAUNCHER_FILE = resolveShennianPath('daemon-launcher.json');
15
16
  const SHENNIAN_SCRIPT = path.resolve(__dirname, '../../bin/shennian.js');
16
17
  const NODE_EXEC = process.execPath;
17
18
  function getPlatform() {
@@ -29,16 +30,17 @@ export function isEphemeralCliPath(candidate) {
29
30
  normalized.startsWith(tmp.endsWith('/') ? tmp : `${tmp}/`));
30
31
  }
31
32
  export function resolveServiceLaunchSpec(input) {
33
+ if (process.env.ELECTRON_RUN_AS_NODE === '1' &&
34
+ input.desktopBridgePath &&
35
+ fs.existsSync(input.scriptPath) &&
36
+ fs.existsSync(input.desktopBridgePath)) {
37
+ return {
38
+ command: input.nodeExec,
39
+ args: [input.desktopBridgePath, input.scriptPath, 'run-service'],
40
+ mode: 'direct',
41
+ };
42
+ }
32
43
  if (fs.existsSync(input.scriptPath) && !isEphemeralCliPath(input.scriptPath)) {
33
- if (process.env.ELECTRON_RUN_AS_NODE === '1' &&
34
- input.desktopBridgePath &&
35
- fs.existsSync(input.desktopBridgePath)) {
36
- return {
37
- command: input.nodeExec,
38
- args: [input.desktopBridgePath, input.scriptPath, 'run-service'],
39
- mode: 'direct',
40
- };
41
- }
42
44
  return {
43
45
  command: input.nodeExec,
44
46
  args: [input.scriptPath, 'run-service'],
@@ -88,6 +90,68 @@ function isRunning(pid) {
88
90
  export function isRemoteAccessDisabled() {
89
91
  return fs.existsSync(REMOTE_ACCESS_DISABLED_FILE);
90
92
  }
93
+ export function getCurrentProcessDaemonLauncher() {
94
+ return process.env.SHENNIAN_DESKTOP_CLI_SCRIPT ? 'desktop-managed' : 'global-cli';
95
+ }
96
+ export function writeDaemonLauncher(pid, launcher = getCurrentProcessDaemonLauncher()) {
97
+ fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
98
+ fs.writeFileSync(LAUNCHER_FILE, JSON.stringify({
99
+ pid,
100
+ launcher,
101
+ updatedAt: new Date().toISOString(),
102
+ }, null, 2));
103
+ }
104
+ export function clearDaemonLauncher() {
105
+ try {
106
+ fs.unlinkSync(LAUNCHER_FILE);
107
+ }
108
+ catch {
109
+ // Launcher metadata may already be absent.
110
+ }
111
+ }
112
+ export function recordStartedDaemon(childPid) {
113
+ if (childPid)
114
+ writeDaemonLauncher(childPid);
115
+ }
116
+ function readDaemonLauncher(pid, running) {
117
+ if (!pid || !running)
118
+ return 'unknown';
119
+ try {
120
+ const raw = JSON.parse(fs.readFileSync(LAUNCHER_FILE, 'utf-8'));
121
+ if (raw.pid !== pid)
122
+ return 'unknown';
123
+ if (raw.launcher === 'desktop-managed' || raw.launcher === 'global-cli')
124
+ return raw.launcher;
125
+ }
126
+ catch {
127
+ // Older daemons do not have launcher metadata.
128
+ }
129
+ return inferDaemonLauncherFromProcess(pid);
130
+ }
131
+ function inferDaemonLauncherFromProcess(pid) {
132
+ if (getPlatform() === 'win32')
133
+ return 'unknown';
134
+ try {
135
+ const command = execSync(`ps -p ${pid} -o command=`, {
136
+ encoding: 'utf-8',
137
+ stdio: ['ignore', 'pipe', 'ignore'],
138
+ timeout: 1000,
139
+ }).replace(/\\/g, '/');
140
+ if (command.includes('/cli-bridge.js') || command.includes('/Resources/daemon/')) {
141
+ return 'desktop-managed';
142
+ }
143
+ if (command.includes('/node_modules/shennian/') ||
144
+ command.includes('/bin/shennian') ||
145
+ command.includes(' shennian run-service') ||
146
+ command.includes(' shennian.js run-service')) {
147
+ return 'global-cli';
148
+ }
149
+ }
150
+ catch {
151
+ // Process command inspection is best effort.
152
+ }
153
+ return 'unknown';
154
+ }
91
155
  export function getDaemonStatus(opts = {}) {
92
156
  const pid = readPid();
93
157
  const running = pid !== null && isRunning(pid);
@@ -105,6 +169,7 @@ export function getDaemonStatus(opts = {}) {
105
169
  pid,
106
170
  stale,
107
171
  remoteAccessDisabled: isRemoteAccessDisabled(),
172
+ launcher: readDaemonLauncher(pid, running),
108
173
  platform: getPlatform(),
109
174
  shennianDir: SHENNIAN_DIR,
110
175
  pidFile: PID_FILE,
@@ -454,7 +519,7 @@ export function startDaemonProcess(opts = {}) {
454
519
  });
455
520
  child.unref();
456
521
  fs.closeSync(logFd);
457
- fs.writeFileSync(PID_FILE, String(child.pid));
522
+ recordStartedDaemon(child.pid);
458
523
  if (!opts.quiet) {
459
524
  console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
460
525
  console.log(chalk.gray(` Logs: ${LOG_FILE}`));
@@ -570,6 +635,7 @@ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
570
635
  catch {
571
636
  // Best-effort cleanup after the process exits.
572
637
  }
638
+ clearDaemonLauncher();
573
639
  return result;
574
640
  }
575
641
  function enableRemoteAccess(opts = {}) {
@@ -675,6 +741,7 @@ async function daemonRestart(opts = {}) {
675
741
  catch {
676
742
  // Best-effort cleanup after restart stop.
677
743
  }
744
+ clearDaemonLauncher();
678
745
  if (!opts.json) {
679
746
  console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
680
747
  }
@@ -697,6 +764,7 @@ async function daemonRestart(opts = {}) {
697
764
  catch {
698
765
  // Stale pid file is already gone.
699
766
  }
767
+ clearDaemonLauncher();
700
768
  if (!opts.json) {
701
769
  console.log(chalk.yellow(`⚠ Service process no longer exists, cleaned up stale PID ${pid}`));
702
770
  }
@@ -4,5 +4,13 @@ export declare function runPairFlow(opts: {
4
4
  machineName: string;
5
5
  force?: boolean;
6
6
  }): Promise<void>;
7
+ export declare function registerDesktopMachine(opts: {
8
+ serverUrl: string;
9
+ authToken: string;
10
+ machineName: string;
11
+ }): Promise<{
12
+ machineId: string;
13
+ machineName: string;
14
+ }>;
7
15
  export declare function runSmartStart(serverUrl: string | undefined, machineName: string): Promise<void>;
8
16
  export declare function registerPairCommand(program: Command): void;
@@ -115,6 +115,40 @@ export async function runPairFlow(opts) {
115
115
  saveConfig(config);
116
116
  console.log(chalk.green(`\n✓ Paired successfully! Machine "${result.machineName}" is now linked.`));
117
117
  }
118
+ export async function registerDesktopMachine(opts) {
119
+ const config = loadConfig();
120
+ const detectedAgents = detectAgents();
121
+ const agentList = detectedAgents.map((a) => a.type);
122
+ const platform = os.platform();
123
+ const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'macos' : 'linux';
124
+ const res = await fetch(`${opts.serverUrl}/api/machines/desktop/register`, {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ Authorization: `Bearer ${opts.authToken}`,
129
+ },
130
+ body: JSON.stringify({
131
+ machineId: config.machineId,
132
+ name: opts.machineName,
133
+ os: osName,
134
+ agentList,
135
+ }),
136
+ });
137
+ if (!res.ok) {
138
+ const body = await res.json().catch(() => ({}));
139
+ const message = body.error ?? res.statusText;
140
+ throw new Error(`Failed to register desktop machine: ${message}`);
141
+ }
142
+ const data = (await res.json());
143
+ if (!data.machine?.id || !data.machineToken) {
144
+ throw new Error('Failed to register desktop machine: missing machine token');
145
+ }
146
+ config.machineId = data.machine.id;
147
+ config.machineToken = data.machineToken;
148
+ config.serverUrl = opts.serverUrl;
149
+ saveConfig(config);
150
+ return { machineId: data.machine.id, machineName: data.machine.name };
151
+ }
118
152
  export async function runSmartStart(serverUrl, machineName) {
119
153
  const config = loadConfig();
120
154
  if (!config.machineToken) {
@@ -132,6 +166,28 @@ export async function runSmartStart(serverUrl, machineName) {
132
166
  }
133
167
  }
134
168
  export function registerPairCommand(program) {
169
+ program
170
+ .command('desktop-register', { hidden: true })
171
+ .description('Register this desktop-managed machine to the authenticated account')
172
+ .requiredOption('--api <url>', 'Server URL')
173
+ .option('--name <name>', 'Machine name', os.hostname())
174
+ .action(async (opts) => {
175
+ try {
176
+ const token = process.env.SHENNIAN_DESKTOP_AUTH_TOKEN;
177
+ if (!token)
178
+ throw new Error('Missing desktop auth token');
179
+ const result = await registerDesktopMachine({
180
+ serverUrl: opts.api,
181
+ authToken: token,
182
+ machineName: opts.name,
183
+ });
184
+ console.log(JSON.stringify({ ok: true, ...result }));
185
+ }
186
+ catch (error) {
187
+ console.error(error instanceof Error ? error.message : String(error));
188
+ process.exit(1);
189
+ }
190
+ });
135
191
  program
136
192
  .command('pair')
137
193
  .description('Pair this machine to a Shennian account (re-pair if already paired)')
package/dist/src/index.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath, } from './config/index.js';
9
9
  import { CliRelayClient } from './relay/client.js';
10
10
  import { registerPairCommand, runSmartStart } from './commands/pair.js';
11
- import { isRemoteAccessDisabled, registerDaemonCommand } from './commands/daemon.js';
11
+ import { clearDaemonLauncher, isRemoteAccessDisabled, registerDaemonCommand, writeDaemonLauncher, } from './commands/daemon.js';
12
12
  import { registerAgentCommand } from './commands/agent.js';
13
13
  import { registerUpgradeCommand } from './commands/upgrade.js';
14
14
  import { SessionManager } from './session/manager.js';
@@ -59,7 +59,7 @@ async function waitForPidExit(pid, timeoutMs = 5000) {
59
59
  }
60
60
  }
61
61
  const program = new Command();
62
- program.name('shennian').description('Shennian — AI Agent Mobile Console').version(cliVersion);
62
+ program.name('shennian').description('Shennian — AI Agent Control Plane').version(cliVersion);
63
63
  program
64
64
  .option('--api <url>', 'Server URL override')
65
65
  .option('--name <name>', 'Machine name', os.hostname())
@@ -120,6 +120,7 @@ program
120
120
  /* noop */
121
121
  }
122
122
  fs.writeFileSync(pidFile, String(process.pid));
123
+ writeDaemonLauncher(process.pid);
123
124
  process.on('exit', () => {
124
125
  try {
125
126
  fs.unlinkSync(pidFile);
@@ -127,6 +128,7 @@ program
127
128
  catch {
128
129
  /* noop */
129
130
  }
131
+ clearDaemonLauncher();
130
132
  });
131
133
  // Crash detection: if we're recovering from a failed upgrade, rollback and exit
132
134
  const didRollback = await handleStartupCrashCheck();
@@ -247,6 +249,7 @@ program
247
249
  const sessionManager = new SessionManager(client, nativeFusion);
248
250
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
249
251
  fs.writeFileSync(PID_FILE, String(process.pid));
252
+ writeDaemonLauncher(process.pid);
250
253
  client.connect();
251
254
  process.stdin.resume();
252
255
  const shutdown = () => {
@@ -261,6 +264,7 @@ program
261
264
  catch {
262
265
  /* noop */
263
266
  }
267
+ clearDaemonLauncher();
264
268
  process.exit(0);
265
269
  };
266
270
  process.on('SIGINT', shutdown);
@@ -1,4 +1,32 @@
1
1
  import type { ParsedNativeEvent } from './types.js';
2
+ type OpenCodeSnapshot = {
3
+ sessions: Array<{
4
+ id: string;
5
+ title: string;
6
+ directory: string;
7
+ time_created?: number;
8
+ time_updated?: number;
9
+ }>;
10
+ messages: Array<{
11
+ id: string;
12
+ session_id: string;
13
+ time_created: number;
14
+ data: string;
15
+ }>;
16
+ parts: Array<{
17
+ id: string;
18
+ message_id: string;
19
+ session_id: string;
20
+ time_created: number;
21
+ data: string;
22
+ }>;
23
+ };
24
+ export declare function parseOpenCodeSessionSnapshot(snapshotPath: string, snapshot: OpenCodeSnapshot): ParsedNativeEvent[];
25
+ export declare function listOpenCodeSessionFiles(): string[];
26
+ export declare function parseOpenCodeSessionFile(filePath: string, startOffset: number): {
27
+ nextOffset: number;
28
+ events: ParsedNativeEvent[];
29
+ };
2
30
  export declare function listCodexRolloutFiles(): string[];
3
31
  export declare function listClaudeTranscriptFiles(): string[];
4
32
  export declare function lookupClaudeTranscriptCwd(sourceSessionKey: string): string | null;
@@ -10,3 +38,4 @@ export declare function parseClaudeTranscriptChunk(filePath: string, startOffset
10
38
  nextOffset: number;
11
39
  events: ParsedNativeEvent[];
12
40
  };
41
+ export {};
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { buildUserMessagePayload, isToolPayload } from '@shennian/wire';
8
+ import { resolveBuiltinCommand, spawnResolvedCommandSync } from '../agents/command-spec.js';
8
9
  function normalizeText(text) {
9
10
  return stripGitDirectiveArtifacts(text.replace(/\r\n/g, '\n').trim());
10
11
  }
@@ -277,6 +278,121 @@ function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts
277
278
  function pushCodexToolEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, toolName, title, modelId, workDir, args, result) {
278
279
  pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, buildToolPayload(toolName, args, result), title, modelId, workDir);
279
280
  }
281
+ function parseJsonObject(value) {
282
+ try {
283
+ const parsed = JSON.parse(value);
284
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ }
290
+ function textFromOpenCodeParts(parts) {
291
+ return parts
292
+ .filter((part) => part.type === 'text' || part.type === 'reasoning')
293
+ .map((part) => typeof part.text === 'string' ? part.text : '')
294
+ .filter(Boolean)
295
+ .join('\n\n')
296
+ .trim();
297
+ }
298
+ function pushOpenCodeEvent(events, snapshotPath, session, message, role, payload, modelId, terminal) {
299
+ if (!payload)
300
+ return;
301
+ events.push({
302
+ agentType: 'opencode',
303
+ sourceMode: 'opencode_session_import',
304
+ sourceSessionKey: session.id,
305
+ sourceEventKey: `opencode:${session.id}:${message.id}:${role}`,
306
+ cursor: `${snapshotPath}:${message.time_created}:${message.id}`,
307
+ role,
308
+ ts: message.time_created,
309
+ payload,
310
+ title: session.title,
311
+ modelId,
312
+ workDir: session.directory,
313
+ terminal,
314
+ });
315
+ }
316
+ export function parseOpenCodeSessionSnapshot(snapshotPath, snapshot) {
317
+ const events = [];
318
+ const sessionsById = new Map(snapshot.sessions.map((session) => [session.id, session]));
319
+ const partsByMessage = new Map();
320
+ for (const partRow of snapshot.parts) {
321
+ const data = parseJsonObject(partRow.data);
322
+ if (!data)
323
+ continue;
324
+ const list = partsByMessage.get(partRow.message_id) ?? [];
325
+ list.push(data);
326
+ partsByMessage.set(partRow.message_id, list);
327
+ }
328
+ for (const message of [...snapshot.messages].sort((left, right) => left.time_created - right.time_created)) {
329
+ const session = sessionsById.get(message.session_id);
330
+ if (!session)
331
+ continue;
332
+ const data = parseJsonObject(message.data);
333
+ if (!data)
334
+ continue;
335
+ const role = data.role === 'user' ? 'user' : data.role === 'assistant' ? 'agent' : null;
336
+ if (!role)
337
+ continue;
338
+ const parts = partsByMessage.get(message.id) ?? [];
339
+ const payload = textFromOpenCodeParts(parts);
340
+ const modelId = typeof data.modelID === 'string'
341
+ ? `${typeof data.providerID === 'string' ? `${data.providerID}/` : ''}${data.modelID}`
342
+ : null;
343
+ pushOpenCodeEvent(events, snapshotPath, session, message, role, payload, modelId, role === 'agent');
344
+ }
345
+ return events;
346
+ }
347
+ export function listOpenCodeSessionFiles() {
348
+ const dataDir = path.join(os.homedir(), '.local', 'share', 'opencode');
349
+ const snapshot = path.join(dataDir, 'shennian-opencode-session-snapshot.opencode-session.json');
350
+ refreshOpenCodeSessionSnapshot(snapshot);
351
+ return fs.existsSync(snapshot) ? [snapshot] : [];
352
+ }
353
+ function queryOpenCodeDb(query) {
354
+ const spec = resolveBuiltinCommand('opencode');
355
+ if (!spec)
356
+ return [];
357
+ const result = spawnResolvedCommandSync(spec, ['db', query, '--format', 'json'], {
358
+ encoding: 'utf-8',
359
+ stdio: ['ignore', 'pipe', 'ignore'],
360
+ timeout: 10_000,
361
+ });
362
+ if (result.status !== 0)
363
+ return [];
364
+ try {
365
+ const parsed = JSON.parse(String(result.stdout ?? ''));
366
+ return Array.isArray(parsed) ? parsed : [];
367
+ }
368
+ catch {
369
+ return [];
370
+ }
371
+ }
372
+ function refreshOpenCodeSessionSnapshot(snapshotPath) {
373
+ try {
374
+ const sessions = queryOpenCodeDb('select id, title, directory, time_created, time_updated from session where time_archived is null order by time_updated asc');
375
+ if (sessions.length === 0)
376
+ return;
377
+ const messages = queryOpenCodeDb('select id, session_id, time_created, data from message order by time_created asc, id asc');
378
+ const parts = queryOpenCodeDb('select id, message_id, session_id, time_created, data from part order by message_id asc, id asc');
379
+ fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
380
+ fs.writeFileSync(snapshotPath, JSON.stringify({ sessions, messages, parts }));
381
+ }
382
+ catch {
383
+ /* best-effort native import */
384
+ }
385
+ }
386
+ export function parseOpenCodeSessionFile(filePath, startOffset) {
387
+ const stat = fs.statSync(filePath);
388
+ if (startOffset >= stat.size)
389
+ return { nextOffset: stat.size, events: [] };
390
+ const snapshot = JSON.parse(fs.readFileSync(filePath, 'utf8'));
391
+ return {
392
+ nextOffset: stat.size,
393
+ events: parseOpenCodeSessionSnapshot(filePath, snapshot),
394
+ };
395
+ }
280
396
  function parseCodexResponseItem(events, filePath, lineOffset, payload, sourceSessionKey, ts, title, modelId, workDir) {
281
397
  const itemType = typeof payload.type === 'string' ? payload.type : '';
282
398
  if (!itemType)
@@ -2,7 +2,7 @@
2
2
  // @test src/__tests__/native-fusion-parsers.test.ts
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
- import { listClaudeTranscriptFiles, listCodexRolloutFiles, parseClaudeTranscriptChunk, parseCodexRolloutChunk, } from './parsers.js';
5
+ import { listClaudeTranscriptFiles, listCodexRolloutFiles, listOpenCodeSessionFiles, parseClaudeTranscriptChunk, parseCodexRolloutChunk, parseOpenCodeSessionFile, } from './parsers.js';
6
6
  import { loadNativeScannerState, saveNativeScannerState } from './state.js';
7
7
  const SCAN_INTERVAL_MS = 60_000;
8
8
  const CLAIM_TTL_MS = SCAN_INTERVAL_MS * 2;
@@ -46,7 +46,7 @@ export class NativeSessionFusionService {
46
46
  registerManagedSend(params) {
47
47
  if (!params.canonicalMessageId)
48
48
  return;
49
- if (params.agentType !== 'codex' && params.agentType !== 'claude')
49
+ if (params.agentType !== 'codex' && params.agentType !== 'claude' && params.agentType !== 'opencode')
50
50
  return;
51
51
  this.pendingClaims.set(params.sessionId, {
52
52
  sessionId: params.sessionId,
@@ -93,15 +93,22 @@ export class NativeSessionFusionService {
93
93
  const nextFilesState = { ...state.files };
94
94
  const backfillWindowsCodex = shouldBackfillWindowsCodex(state);
95
95
  const batches = [];
96
- const files = [...listCodexRolloutFiles(), ...listClaudeTranscriptFiles()];
96
+ const files = [...listCodexRolloutFiles(), ...listClaudeTranscriptFiles(), ...listOpenCodeSessionFiles()];
97
97
  for (const filePath of files) {
98
98
  const stat = fs.statSync(filePath);
99
99
  const current = state.files[filePath];
100
100
  const isCodex = isCodexRolloutPath(filePath);
101
- const startOffset = backfillWindowsCodex && isCodex ? 0 : current?.offset ?? 0;
102
- const parsed = isCodex
103
- ? parseCodexRolloutChunk(filePath, startOffset)
104
- : parseClaudeTranscriptChunk(filePath, startOffset);
101
+ const isOpenCode = filePath.endsWith('.opencode-session.json');
102
+ const startOffset = isOpenCode && current?.mtimeMs !== stat.mtimeMs
103
+ ? 0
104
+ : backfillWindowsCodex && isCodex
105
+ ? 0
106
+ : current?.offset ?? 0;
107
+ const parsed = isOpenCode
108
+ ? parseOpenCodeSessionFile(filePath, startOffset)
109
+ : isCodex
110
+ ? parseCodexRolloutChunk(filePath, startOffset)
111
+ : parseClaudeTranscriptChunk(filePath, startOffset);
105
112
  nextFilesState[filePath] = {
106
113
  offset: parsed.nextOffset,
107
114
  mtimeMs: stat.mtimeMs,
@@ -9,7 +9,7 @@ export type NativeScannerState = {
9
9
  export type ParsedNativeEvent = NativeSessionEventPayload;
10
10
  export type PendingManagedClaim = {
11
11
  sessionId: string;
12
- agentType: 'codex' | 'claude';
12
+ agentType: 'codex' | 'claude' | 'opencode';
13
13
  canonicalMessageId: string;
14
14
  text: string;
15
15
  createdAt: number;
@@ -18,7 +18,7 @@ export type PendingManagedClaim = {
18
18
  };
19
19
  export type ManagedSuppressionWindow = {
20
20
  sessionId: string;
21
- agentType: 'codex' | 'claude';
21
+ agentType: 'codex' | 'claude' | 'opencode';
22
22
  sourceSessionKey: string;
23
23
  startedAt: number;
24
24
  endsAt: number;
@@ -6,6 +6,7 @@ import '../agents/codex.js';
6
6
  import '../agents/gemini.js';
7
7
  import '../agents/cursor.js';
8
8
  import '../agents/openclaw.js';
9
+ import '../agents/opencode.js';
9
10
  import '../agents/pi.js';
10
11
  export declare function resolveSessionWorkDir(input: string): string;
11
12
  export declare class SessionManager {
@@ -16,6 +16,7 @@ import '../agents/codex.js';
16
16
  import '../agents/gemini.js';
17
17
  import '../agents/cursor.js';
18
18
  import '../agents/openclaw.js';
19
+ import '../agents/opencode.js';
19
20
  import '../agents/pi.js';
20
21
  import { registerCustomAgent } from '../agents/custom.js';
21
22
  const MAX_SESSIONS = 50;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.25",
4
- "description": "Shennian — AI Agent Mobile Console CLI",
3
+ "version": "0.2.27",
4
+ "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "shennian": "dist/bin/shennian.js"
@@ -32,20 +32,14 @@
32
32
  "engines": {
33
33
  "node": ">=18"
34
34
  },
35
- "scripts": {
36
- "build": "tsc",
37
- "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json",
38
- "dev": "tsc --watch",
39
- "prepublishOnly": "pnpm build:publish"
40
- },
41
35
  "dependencies": {
42
36
  "@mariozechner/pi-agent-core": "^0.64.0",
43
37
  "@sinclair/typebox": "^0.34.49",
44
- "@shennian/wire": "^0.1.2",
45
38
  "chalk": "^5.4.1",
46
39
  "commander": "^13.1.0",
47
40
  "qrcode-terminal": "^0.12.0",
48
- "ws": "^8.18.1"
41
+ "ws": "^8.18.1",
42
+ "@shennian/wire": "0.1.2"
49
43
  },
50
44
  "devDependencies": {
51
45
  "@types/node": "^20",
@@ -53,5 +47,10 @@
53
47
  "@types/ws": "^8.18.1",
54
48
  "tsx": "^4.19.4",
55
49
  "typescript": "^5.9.3"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc",
53
+ "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json",
54
+ "dev": "tsc --watch"
56
55
  }
57
- }
56
+ }