shennian 0.2.26 → 0.2.28
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 +3 -1
- package/dist/bin/shennian.js +0 -0
- package/dist/src/agents/command-spec.js +3 -0
- package/dist/src/agents/model-registry/discovery.js +10 -1
- package/dist/src/agents/model-registry/parsers.d.ts +1 -0
- package/dist/src/agents/model-registry/parsers.js +13 -0
- package/dist/src/agents/opencode.d.ts +21 -0
- package/dist/src/agents/opencode.js +230 -0
- package/dist/src/commands/daemon.d.ts +6 -0
- package/dist/src/commands/daemon.js +92 -11
- package/dist/src/commands/pair.d.ts +8 -0
- package/dist/src/commands/pair.js +56 -0
- package/dist/src/index.js +6 -2
- package/dist/src/native-fusion/parsers.d.ts +29 -0
- package/dist/src/native-fusion/parsers.js +116 -0
- package/dist/src/native-fusion/service.js +14 -7
- package/dist/src/native-fusion/types.d.ts +2 -2
- package/dist/src/session/manager.d.ts +1 -0
- package/dist/src/session/manager.js +1 -0
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @shennian/cli
|
|
2
2
|
|
|
3
|
-
> Shennian AI Agent
|
|
3
|
+
> Shennian AI Agent 控制面的本机后台服务 / CLI
|
|
4
4
|
>
|
|
5
5
|
> **架构设计** → [docs/architecture/cli/](../../docs/architecture/cli/)
|
|
6
6
|
> - [Daemon 架构](../../docs/architecture/cli/daemon.md) — 模块拓扑、数据流、守护进程
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
| `shennian status` | 查看后台服务状态 |
|
|
21
21
|
| `shennian logs -n 100` | 查看最近日志 |
|
|
22
22
|
| `shennian config` | 查看本地配置 |
|
|
23
|
+
| `shennian config set server cn\|global` | 切换国内 / 海外中继节点 |
|
|
23
24
|
| `shennian agent add/list/remove` | 管理自定义 Agent |
|
|
24
25
|
| `shennian upgrade` | 升级 CLI |
|
|
25
26
|
|
|
26
27
|
`start`、`stop`、`status` 可选加 `--json`。`logs` 输出日志文本,不支持 `--json`。
|
|
28
|
+
切换节点后运行 `shennian stop && shennian start` 让后台服务重新连接。
|
|
27
29
|
|
|
28
30
|
## 开发
|
|
29
31
|
|
package/dist/bin/shennian.js
CHANGED
|
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;
|
|
@@ -6,12 +6,13 @@ import path from 'node:path';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import { execSync, spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { getShennianDir, resolveShennianPath } from '../config/index.js';
|
|
9
|
+
import { getShennianDir, loadConfig, resolveShennianPath, saveConfig } from '../config/index.js';
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
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,
|
|
@@ -114,6 +179,14 @@ export function getDaemonStatus(opts = {}) {
|
|
|
114
179
|
function printJson(value) {
|
|
115
180
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
116
181
|
}
|
|
182
|
+
function persistServerUrlOverride(serverUrl) {
|
|
183
|
+
const normalized = serverUrl?.trim();
|
|
184
|
+
if (!normalized)
|
|
185
|
+
return;
|
|
186
|
+
const config = loadConfig();
|
|
187
|
+
config.serverUrl = normalized;
|
|
188
|
+
saveConfig(config);
|
|
189
|
+
}
|
|
117
190
|
// ─── Service definitions ─────────────────────────────────────────────────────
|
|
118
191
|
const LAUNCHD_LABEL = 'com.shennian.agent';
|
|
119
192
|
const LAUNCHD_PLIST = path.join(os.homedir(), 'Library/LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
@@ -454,7 +527,7 @@ export function startDaemonProcess(opts = {}) {
|
|
|
454
527
|
});
|
|
455
528
|
child.unref();
|
|
456
529
|
fs.closeSync(logFd);
|
|
457
|
-
|
|
530
|
+
recordStartedDaemon(child.pid);
|
|
458
531
|
if (!opts.quiet) {
|
|
459
532
|
console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
|
|
460
533
|
console.log(chalk.gray(` Logs: ${LOG_FILE}`));
|
|
@@ -570,9 +643,11 @@ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
|
|
|
570
643
|
catch {
|
|
571
644
|
// Best-effort cleanup after the process exits.
|
|
572
645
|
}
|
|
646
|
+
clearDaemonLauncher();
|
|
573
647
|
return result;
|
|
574
648
|
}
|
|
575
649
|
function enableRemoteAccess(opts = {}) {
|
|
650
|
+
persistServerUrlOverride(opts.api);
|
|
576
651
|
try {
|
|
577
652
|
fs.unlinkSync(REMOTE_ACCESS_DISABLED_FILE);
|
|
578
653
|
}
|
|
@@ -660,6 +735,7 @@ async function waitForPidExit(pid, timeoutMs = 5000) {
|
|
|
660
735
|
return !isRunning(pid);
|
|
661
736
|
}
|
|
662
737
|
async function daemonRestart(opts = {}) {
|
|
738
|
+
persistServerUrlOverride(opts.api);
|
|
663
739
|
const pid = readPid();
|
|
664
740
|
if (pid !== null && isRunning(pid)) {
|
|
665
741
|
try {
|
|
@@ -675,6 +751,7 @@ async function daemonRestart(opts = {}) {
|
|
|
675
751
|
catch {
|
|
676
752
|
// Best-effort cleanup after restart stop.
|
|
677
753
|
}
|
|
754
|
+
clearDaemonLauncher();
|
|
678
755
|
if (!opts.json) {
|
|
679
756
|
console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
|
|
680
757
|
}
|
|
@@ -697,6 +774,7 @@ async function daemonRestart(opts = {}) {
|
|
|
697
774
|
catch {
|
|
698
775
|
// Stale pid file is already gone.
|
|
699
776
|
}
|
|
777
|
+
clearDaemonLauncher();
|
|
700
778
|
if (!opts.json) {
|
|
701
779
|
console.log(chalk.yellow(`⚠ Service process no longer exists, cleaned up stale PID ${pid}`));
|
|
702
780
|
}
|
|
@@ -820,6 +898,7 @@ export function registerDaemonCommand(program) {
|
|
|
820
898
|
.command('start')
|
|
821
899
|
.description('Start the background service')
|
|
822
900
|
.option('--json', 'Print machine-readable status JSON')
|
|
901
|
+
.option('--api <url>', 'Server URL override')
|
|
823
902
|
.action((opts) => daemonStart(opts));
|
|
824
903
|
program
|
|
825
904
|
.command('stop')
|
|
@@ -845,6 +924,7 @@ export function registerDaemonCommand(program) {
|
|
|
845
924
|
.command('start')
|
|
846
925
|
.description('Start the background service')
|
|
847
926
|
.option('--json', 'Print machine-readable status JSON')
|
|
927
|
+
.option('--api <url>', 'Server URL override')
|
|
848
928
|
.action((opts) => {
|
|
849
929
|
warnDeprecated('start', opts.json);
|
|
850
930
|
daemonStart(opts);
|
|
@@ -861,6 +941,7 @@ export function registerDaemonCommand(program) {
|
|
|
861
941
|
.command('restart')
|
|
862
942
|
.description('Restart the background service')
|
|
863
943
|
.option('--json', 'Print machine-readable status JSON')
|
|
944
|
+
.option('--api <url>', 'Server URL override')
|
|
864
945
|
.action((opts) => {
|
|
865
946
|
warnDeprecated('stop && shennian start', opts.json);
|
|
866
947
|
daemonRestart(opts);
|
|
@@ -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
|
|
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
|
|
102
|
-
const
|
|
103
|
-
?
|
|
104
|
-
:
|
|
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.
|
|
4
|
-
"description": "Shennian — AI Agent
|
|
3
|
+
"version": "0.2.28",
|
|
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
|
+
}
|