shennian 0.2.26 → 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 +1 -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 +78 -10
- 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
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;
|
|
@@ -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
|
-
|
|
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
|
|
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.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
|
+
}
|