shennian 0.2.52 → 0.2.53
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/dist/src/agents/adapter.d.ts +5 -1
- package/dist/src/agents/claude.d.ts +7 -0
- package/dist/src/agents/claude.js +9 -3
- package/dist/src/agents/codex.d.ts +7 -0
- package/dist/src/agents/codex.js +10 -4
- package/dist/src/agents/external-channel-instructions.d.ts +2 -0
- package/dist/src/agents/external-channel-instructions.js +22 -0
- package/dist/src/agents/manager.js +2 -0
- package/dist/src/agents/pi.d.ts +7 -0
- package/dist/src/agents/pi.js +15 -3
- package/dist/src/agents/platform-instructions.d.ts +3 -2
- package/dist/src/agents/platform-instructions.js +12 -4
- package/dist/src/channels/base.d.ts +8 -0
- package/dist/src/channels/runtime.d.ts +11 -2
- package/dist/src/channels/runtime.js +53 -19
- package/dist/src/commands/daemon.d.ts +1 -1
- package/dist/src/commands/daemon.js +10 -24
- package/dist/src/commands/external.d.ts +2 -0
- package/dist/src/commands/external.js +45 -0
- package/dist/src/commands/pair.js +3 -1
- package/dist/src/index.js +3 -2
- package/dist/src/manager/runtime.d.ts +4 -0
- package/dist/src/manager/runtime.js +100 -12
- package/dist/src/relay/client.d.ts +2 -0
- package/dist/src/relay/client.js +32 -0
- package/dist/src/session/handlers/chat.js +53 -9
- package/dist/src/session/queue.d.ts +1 -0
- package/dist/src/session/queue.js +27 -1
- package/dist/src/session/types.d.ts +3 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { AgentType } from '@shennian/wire';
|
|
2
|
+
import type { AgentType, ExternalChannelSessionStatus } from '@shennian/wire';
|
|
3
3
|
export type AgentEvent = {
|
|
4
4
|
state: string;
|
|
5
5
|
runId: string;
|
|
@@ -24,6 +24,10 @@ export interface AgentAdapterEvents {
|
|
|
24
24
|
}
|
|
25
25
|
export declare abstract class AgentAdapter extends EventEmitter<AgentAdapterEvents> {
|
|
26
26
|
abstract readonly type: AgentType;
|
|
27
|
+
configure?(options: {
|
|
28
|
+
externalChannel?: ExternalChannelSessionStatus | null;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
}): void;
|
|
27
31
|
abstract start(sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
|
|
28
32
|
abstract send(text: string, modelId?: string, reasoningEffort?: string): Promise<void>;
|
|
29
33
|
abstract resume(agentSessionId: string): Promise<void>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentAdapter } from './adapter.js';
|
|
2
|
+
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
3
|
export declare function normalizeClaudeModelId(modelId?: string | null): string;
|
|
3
4
|
export declare function normalizeClaudeReasoningEffort(reasoningEffort?: string | null): string | undefined;
|
|
4
5
|
export declare class ClaudeAdapter extends AgentAdapter {
|
|
@@ -16,6 +17,12 @@ export declare class ClaudeAdapter extends AgentAdapter {
|
|
|
16
17
|
systemPrompt?: string;
|
|
17
18
|
hidden?: boolean;
|
|
18
19
|
});
|
|
20
|
+
private externalChannel;
|
|
21
|
+
private extraEnv;
|
|
22
|
+
configure(options: {
|
|
23
|
+
externalChannel?: ExternalChannelSessionStatus | null;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
}): void;
|
|
19
26
|
start(sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
|
|
20
27
|
send(text: string, modelId?: string, reasoningEffort?: string): Promise<void>;
|
|
21
28
|
resume(agentSessionId: string): Promise<void>;
|
|
@@ -32,6 +32,12 @@ export class ClaudeAdapter extends AgentAdapter {
|
|
|
32
32
|
super();
|
|
33
33
|
this.options = options;
|
|
34
34
|
}
|
|
35
|
+
externalChannel = null;
|
|
36
|
+
extraEnv = {};
|
|
37
|
+
configure(options) {
|
|
38
|
+
this.externalChannel = options.externalChannel ?? null;
|
|
39
|
+
this.extraEnv = options.env ?? {};
|
|
40
|
+
}
|
|
35
41
|
async start(sessionId, workDir, agentSessionId) {
|
|
36
42
|
this.sessionId = sessionId;
|
|
37
43
|
this.workDir = workDir;
|
|
@@ -44,7 +50,7 @@ export class ClaudeAdapter extends AgentAdapter {
|
|
|
44
50
|
this.runId = randomUUID();
|
|
45
51
|
this.resetRunState();
|
|
46
52
|
const args = ['-p', text, '--output-format', 'stream-json', '--verbose'];
|
|
47
|
-
const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd());
|
|
53
|
+
const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd(), this.externalChannel);
|
|
48
54
|
if (systemPrompt) {
|
|
49
55
|
args.push('--append-system-prompt', systemPrompt);
|
|
50
56
|
}
|
|
@@ -70,7 +76,7 @@ export class ClaudeAdapter extends AgentAdapter {
|
|
|
70
76
|
this.runId = randomUUID();
|
|
71
77
|
this.resetRunState();
|
|
72
78
|
const resumeArgs = ['--resume', agentSessionId, '--output-format', 'stream-json', '--verbose'];
|
|
73
|
-
const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd());
|
|
79
|
+
const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd(), this.externalChannel);
|
|
74
80
|
if (systemPrompt) {
|
|
75
81
|
resumeArgs.push('--append-system-prompt', systemPrompt);
|
|
76
82
|
}
|
|
@@ -94,7 +100,7 @@ export class ClaudeAdapter extends AgentAdapter {
|
|
|
94
100
|
const proc = spawnResolvedCommand(spec, args, {
|
|
95
101
|
cwd: this.workDir ?? undefined,
|
|
96
102
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
97
|
-
env: buildAgentProcessEnv(),
|
|
103
|
+
env: buildAgentProcessEnv(this.extraEnv),
|
|
98
104
|
});
|
|
99
105
|
this.process = proc;
|
|
100
106
|
const rl = createInterface({ input: proc.stdout });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentAdapter } from './adapter.js';
|
|
2
|
+
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
3
|
export declare class CodexAdapter extends AgentAdapter {
|
|
3
4
|
private readonly options;
|
|
4
5
|
readonly type: "codex";
|
|
@@ -25,6 +26,12 @@ export declare class CodexAdapter extends AgentAdapter {
|
|
|
25
26
|
modelInstructionsFile?: string;
|
|
26
27
|
hidden?: boolean;
|
|
27
28
|
});
|
|
29
|
+
private externalChannel;
|
|
30
|
+
private extraEnv;
|
|
31
|
+
configure(options: {
|
|
32
|
+
externalChannel?: ExternalChannelSessionStatus | null;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
}): void;
|
|
28
35
|
start(_sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
|
|
29
36
|
send(text: string, modelId?: string, reasoningEffort?: string): Promise<void>;
|
|
30
37
|
resume(agentSessionId: string): Promise<void>;
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -32,6 +32,12 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
32
32
|
super();
|
|
33
33
|
this.options = options;
|
|
34
34
|
}
|
|
35
|
+
externalChannel = null;
|
|
36
|
+
extraEnv = {};
|
|
37
|
+
configure(options) {
|
|
38
|
+
this.externalChannel = options.externalChannel ?? null;
|
|
39
|
+
this.extraEnv = options.env ?? {};
|
|
40
|
+
}
|
|
35
41
|
async start(_sessionId, workDir, agentSessionId) {
|
|
36
42
|
this.workDir = workDir;
|
|
37
43
|
this.seq = 0;
|
|
@@ -82,7 +88,7 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
82
88
|
const proc = spawnResolvedCommand(spec, args, {
|
|
83
89
|
cwd: this.workDir ?? undefined,
|
|
84
90
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
85
|
-
env: buildAgentProcessEnv(),
|
|
91
|
+
env: buildAgentProcessEnv(this.extraEnv),
|
|
86
92
|
});
|
|
87
93
|
this.process = proc;
|
|
88
94
|
const rl = createInterface({ input: proc.stdout });
|
|
@@ -127,14 +133,14 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
129
135
|
const args = ['app-server', '--listen', 'stdio://'];
|
|
130
|
-
const modelInstructionsFile = this.options.modelInstructionsFile ?? ensurePlatformInstructionsFile(this.workDir ?? process.cwd());
|
|
136
|
+
const modelInstructionsFile = this.options.modelInstructionsFile ?? ensurePlatformInstructionsFile(this.workDir ?? process.cwd(), this.externalChannel);
|
|
131
137
|
if (modelInstructionsFile) {
|
|
132
138
|
args.push('-c', `model_instructions_file=${JSON.stringify(modelInstructionsFile)}`);
|
|
133
139
|
}
|
|
134
140
|
const proc = spawnResolvedCommand(spec, args, {
|
|
135
141
|
cwd: this.workDir ?? undefined,
|
|
136
142
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
137
|
-
env: buildAgentProcessEnv({ NO_COLOR: '1' }),
|
|
143
|
+
env: buildAgentProcessEnv({ NO_COLOR: '1', ...this.extraEnv }),
|
|
138
144
|
});
|
|
139
145
|
this.process = proc;
|
|
140
146
|
this.stderrBuf = '';
|
|
@@ -253,7 +259,7 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
253
259
|
}
|
|
254
260
|
catch (error) {
|
|
255
261
|
if (reasoningEffort && isCodexUnsupportedEffortError(error)) {
|
|
256
|
-
throw new Error(`Codex app-server does not accept reasoning effort "${reasoningEffort}" for this turn. Refresh models or upgrade Codex CLI, then retry
|
|
262
|
+
throw new Error(`Codex app-server does not accept reasoning effort "${reasoningEffort}" for this turn. Refresh models or upgrade Codex CLI, then retry.`, { cause: error });
|
|
257
263
|
}
|
|
258
264
|
throw error;
|
|
259
265
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @arch docs/features/wecom-managed-channel.md
|
|
2
|
+
// @test src/__tests__/platform-instructions.test.ts
|
|
3
|
+
export function buildExternalChannelInstructions(channel) {
|
|
4
|
+
if (!channel?.configured && !channel?.connected)
|
|
5
|
+
return '';
|
|
6
|
+
const channelName = channel.name?.trim() || '外部消息通道';
|
|
7
|
+
const customPrompt = channel.systemPrompt?.trim();
|
|
8
|
+
const sections = [
|
|
9
|
+
`当前对话已接入外部消息通道:${channelName}。`,
|
|
10
|
+
`外部消息会以如下格式进入对话:\n外部消息\n<时间> <用户昵称>: <内容>`,
|
|
11
|
+
channel.canReply === false
|
|
12
|
+
? '当前通道只允许接收消息,不要尝试向外部通道发送回复。'
|
|
13
|
+
: [
|
|
14
|
+
'当用户明确要求你向外部消息通道发送内容,或你需要回复一条外部消息时,调用:',
|
|
15
|
+
'shennian external send --text "<要发送的消息>"',
|
|
16
|
+
'只发送用户可见的最终内容,不要发送内部推理、工具日志或实现细节。',
|
|
17
|
+
].join('\n'),
|
|
18
|
+
'如果外部消息和当前任务无关,可以忽略或简短说明无需处理;如果处理需要时间,先简短确认,再继续完成任务。',
|
|
19
|
+
customPrompt ? `本通道附加约束:${customPrompt}` : '',
|
|
20
|
+
].filter(Boolean);
|
|
21
|
+
return sections.join('\n\n');
|
|
22
|
+
}
|
|
@@ -7,6 +7,7 @@ import { MANAGER_SYSTEM_PROMPT, buildManagerPrompt } from '../manager/prompt.js'
|
|
|
7
7
|
import { getManagerRuntimeService } from '../manager/runtime.js';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { PLATFORM_OUTPUT_INSTRUCTIONS } from './platform-instructions.js';
|
|
10
11
|
function normalizeManagerModel(modelId) {
|
|
11
12
|
return modelId === 'claude' ? 'claude' : 'codex';
|
|
12
13
|
}
|
|
@@ -38,6 +39,7 @@ function buildStableManagerInstructions(workDir, managerSessionId) {
|
|
|
38
39
|
channelInstructions
|
|
39
40
|
? `## External Message Channel Instructions\n\n${channelInstructions}`
|
|
40
41
|
: '',
|
|
42
|
+
PLATFORM_OUTPUT_INSTRUCTIONS,
|
|
41
43
|
].filter(Boolean);
|
|
42
44
|
return `${sections.join('\n\n')}\n`;
|
|
43
45
|
}
|
package/dist/src/agents/pi.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentAdapter } from './adapter.js';
|
|
2
|
+
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
3
|
type ShellCommandSpec = {
|
|
3
4
|
file: string;
|
|
4
5
|
args: string[];
|
|
@@ -27,7 +28,13 @@ export declare class PiAdapter extends AgentAdapter {
|
|
|
27
28
|
private pendingBaseMessages;
|
|
28
29
|
private finalizePromise;
|
|
29
30
|
private sendGeneration;
|
|
31
|
+
private externalChannel;
|
|
32
|
+
private extraEnv;
|
|
30
33
|
private pendingSendStart;
|
|
34
|
+
configure(options: {
|
|
35
|
+
externalChannel?: ExternalChannelSessionStatus | null;
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
}): void;
|
|
31
38
|
start(sessionId: string, workDir: string, _agentSessionId?: string | null): Promise<void>;
|
|
32
39
|
send(text: string, modelId?: string): Promise<void>;
|
|
33
40
|
private initAgent;
|
package/dist/src/agents/pi.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
|
|
|
8
8
|
import { Agent, streamProxy } from '@mariozechner/pi-agent-core';
|
|
9
9
|
import { Type } from '@sinclair/typebox';
|
|
10
10
|
import { AgentAdapter, registerAgent } from './adapter.js';
|
|
11
|
+
import { buildExternalChannelInstructions } from './external-channel-instructions.js';
|
|
11
12
|
import { loadConfig, resolveShennianPath } from '../config/index.js';
|
|
12
13
|
import { SERVERS } from '../region.js';
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
@@ -186,7 +187,7 @@ async function requestProxySummary(proxyUrl, authToken, prompt) {
|
|
|
186
187
|
}
|
|
187
188
|
// ── Local tools ───────────────────────────────────────────────────────────────
|
|
188
189
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
189
|
-
function makeTools(workDir) {
|
|
190
|
+
function makeTools(workDir, extraEnv = {}) {
|
|
190
191
|
return [
|
|
191
192
|
{
|
|
192
193
|
name: 'read_file',
|
|
@@ -292,6 +293,7 @@ function makeTools(workDir) {
|
|
|
292
293
|
try {
|
|
293
294
|
const { stdout, stderr } = await execFileAsync(spec.file, spec.args, {
|
|
294
295
|
cwd: workDir,
|
|
296
|
+
env: { ...process.env, ...extraEnv },
|
|
295
297
|
timeout: 30_000,
|
|
296
298
|
signal,
|
|
297
299
|
maxBuffer: 1024 * 1024,
|
|
@@ -395,7 +397,13 @@ export class PiAdapter extends AgentAdapter {
|
|
|
395
397
|
pendingBaseMessages = [];
|
|
396
398
|
finalizePromise = Promise.resolve();
|
|
397
399
|
sendGeneration = 0;
|
|
400
|
+
externalChannel = null;
|
|
401
|
+
extraEnv = {};
|
|
398
402
|
pendingSendStart = null;
|
|
403
|
+
configure(options) {
|
|
404
|
+
this.externalChannel = options.externalChannel ?? null;
|
|
405
|
+
this.extraEnv = options.env ?? {};
|
|
406
|
+
}
|
|
399
407
|
async start(sessionId, workDir, _agentSessionId) {
|
|
400
408
|
this.sessionId = sessionId;
|
|
401
409
|
this.workDir = workDir;
|
|
@@ -483,10 +491,14 @@ export class PiAdapter extends AgentAdapter {
|
|
|
483
491
|
// ── Agent lifecycle ──────────────────────────────────────────────────────────
|
|
484
492
|
initAgent() {
|
|
485
493
|
const workDir = this.workDir ?? process.cwd();
|
|
486
|
-
const tools = makeTools(workDir);
|
|
494
|
+
const tools = makeTools(workDir, this.extraEnv);
|
|
487
495
|
const agent = new Agent({
|
|
488
496
|
initialState: {
|
|
489
|
-
systemPrompt:
|
|
497
|
+
systemPrompt: [
|
|
498
|
+
SYSTEM_PROMPT,
|
|
499
|
+
`当前工作目录:${workDir}`,
|
|
500
|
+
buildExternalChannelInstructions(this.externalChannel),
|
|
501
|
+
].filter(Boolean).join('\n\n'),
|
|
490
502
|
model: createPiModel(),
|
|
491
503
|
tools,
|
|
492
504
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
1
2
|
export declare const PLATFORM_OUTPUT_INSTRUCTIONS = "## Shennian Output Instructions\n\nWhen you mention a local file that the user may want to open in Shennian, format it as a Markdown link using the exact local path:\n\n- Use [filename.ext](</absolute/path/to/filename.ext>) for absolute Unix, macOS, or Windows paths.\n- Use [filename.ext](<relative/path/to/filename.ext>) for paths relative to the current working directory.\n- Do not use file:// URLs for local files.\n- Do not put user-openable file paths only inside code blocks.\n- Keep normal http:// and https:// links unchanged.";
|
|
2
|
-
export declare function buildPlatformInstructions(workDir: string): string;
|
|
3
|
-
export declare function ensurePlatformInstructionsFile(workDir: string): string;
|
|
3
|
+
export declare function buildPlatformInstructions(workDir: string, externalChannel?: ExternalChannelSessionStatus | null): string;
|
|
4
|
+
export declare function ensurePlatformInstructionsFile(workDir: string, externalChannel?: ExternalChannelSessionStatus | null): string;
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { resolveShennianPath } from '../config/index.js';
|
|
7
|
+
import { buildExternalChannelInstructions } from './external-channel-instructions.js';
|
|
7
8
|
export const PLATFORM_OUTPUT_INSTRUCTIONS = `## Shennian Output Instructions
|
|
8
9
|
|
|
9
10
|
When you mention a local file that the user may want to open in Shennian, format it as a Markdown link using the exact local path:
|
|
@@ -22,21 +23,28 @@ function readProjectAgentsMd(workDir) {
|
|
|
22
23
|
return '';
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
|
-
export function buildPlatformInstructions(workDir) {
|
|
26
|
+
export function buildPlatformInstructions(workDir, externalChannel) {
|
|
26
27
|
const projectInstructions = readProjectAgentsMd(workDir);
|
|
28
|
+
const channelInstructions = buildExternalChannelInstructions(externalChannel);
|
|
27
29
|
const sections = [
|
|
28
30
|
'# Shennian Agent Instructions',
|
|
29
31
|
projectInstructions
|
|
30
32
|
? `## Project Instructions\n\n${projectInstructions}`
|
|
31
33
|
: '',
|
|
32
34
|
PLATFORM_OUTPUT_INSTRUCTIONS,
|
|
35
|
+
channelInstructions
|
|
36
|
+
? `## External Message Channel Instructions\n\n${channelInstructions}`
|
|
37
|
+
: '',
|
|
33
38
|
].filter(Boolean);
|
|
34
39
|
return `${sections.join('\n\n')}\n`;
|
|
35
40
|
}
|
|
36
|
-
export function ensurePlatformInstructionsFile(workDir) {
|
|
37
|
-
const key = createHash('sha256')
|
|
41
|
+
export function ensurePlatformInstructionsFile(workDir, externalChannel) {
|
|
42
|
+
const key = createHash('sha256')
|
|
43
|
+
.update(`${path.resolve(workDir)}:${JSON.stringify(externalChannel ?? null)}`)
|
|
44
|
+
.digest('hex')
|
|
45
|
+
.slice(0, 16);
|
|
38
46
|
const filePath = resolveShennianPath('runtime', 'agent-instructions', `${key}.md`);
|
|
39
|
-
const content = buildPlatformInstructions(workDir);
|
|
47
|
+
const content = buildPlatformInstructions(workDir, externalChannel);
|
|
40
48
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
49
|
try {
|
|
42
50
|
if (fs.readFileSync(filePath, 'utf8') === content)
|
|
@@ -3,8 +3,12 @@ export type ExternalChannelConfig = {
|
|
|
3
3
|
id: string;
|
|
4
4
|
type: ExternalChannelType;
|
|
5
5
|
name: string;
|
|
6
|
+
sessionId?: string;
|
|
6
7
|
managerSessionId: string;
|
|
7
8
|
workDir: string;
|
|
9
|
+
agentType?: string;
|
|
10
|
+
agentSessionId?: string | null;
|
|
11
|
+
modelId?: string | null;
|
|
8
12
|
enabled: boolean;
|
|
9
13
|
secretRef: string;
|
|
10
14
|
};
|
|
@@ -12,8 +16,12 @@ export type ExternalChannelView = {
|
|
|
12
16
|
id: string;
|
|
13
17
|
type: ExternalChannelType;
|
|
14
18
|
name: string;
|
|
19
|
+
sessionId?: string;
|
|
15
20
|
managerSessionId: string;
|
|
16
21
|
workDir: string;
|
|
22
|
+
agentType?: string;
|
|
23
|
+
agentSessionId?: string | null;
|
|
24
|
+
modelId?: string | null;
|
|
17
25
|
enabled: boolean;
|
|
18
26
|
wsUrl?: string;
|
|
19
27
|
token?: string;
|
|
@@ -5,7 +5,7 @@ export declare class ChannelRuntime {
|
|
|
5
5
|
private configs;
|
|
6
6
|
private secrets;
|
|
7
7
|
private adapters;
|
|
8
|
-
constructor(onExternalMessage: (
|
|
8
|
+
constructor(onExternalMessage: (sessionId: string, event: ExternalMessageEvent) => void, createReplyTarget: (input: {
|
|
9
9
|
managerSessionId: string;
|
|
10
10
|
channelId: string;
|
|
11
11
|
conversationId: string;
|
|
@@ -24,19 +24,24 @@ export declare class ChannelRuntime {
|
|
|
24
24
|
ok: false;
|
|
25
25
|
error: string;
|
|
26
26
|
}>;
|
|
27
|
-
getDefaultReplyTarget(
|
|
27
|
+
getDefaultReplyTarget(sessionId: string): Promise<{
|
|
28
28
|
channelId: string;
|
|
29
29
|
conversationId: string;
|
|
30
30
|
}>;
|
|
31
31
|
getManagerChannel(managerSessionId: string, type: ExternalChannelConfig['type'], opts?: {
|
|
32
32
|
includeSecret?: boolean;
|
|
33
33
|
}): ExternalChannelView | null;
|
|
34
|
+
getChannelById(channelId: string, opts?: {
|
|
35
|
+
includeSecret?: boolean;
|
|
36
|
+
}): ExternalChannelView | null;
|
|
34
37
|
getManagerChannelStatus(managerSessionId: string): {
|
|
35
38
|
configured: boolean;
|
|
36
39
|
connected: boolean;
|
|
37
40
|
type?: string;
|
|
38
41
|
channelId?: string;
|
|
39
42
|
name?: string;
|
|
43
|
+
canReply?: boolean;
|
|
44
|
+
systemPrompt?: string;
|
|
40
45
|
} | null;
|
|
41
46
|
listManagerChannelStatuses(): Array<{
|
|
42
47
|
managerSessionId: string;
|
|
@@ -52,9 +57,13 @@ export declare class ChannelRuntime {
|
|
|
52
57
|
upsertManagerChannel(input: {
|
|
53
58
|
id: string;
|
|
54
59
|
managerSessionId: string;
|
|
60
|
+
sessionId?: string;
|
|
55
61
|
workDir: string;
|
|
56
62
|
type: 'websocket';
|
|
57
63
|
name?: string;
|
|
64
|
+
agentType?: string;
|
|
65
|
+
agentSessionId?: string | null;
|
|
66
|
+
modelId?: string | null;
|
|
58
67
|
enabled: boolean;
|
|
59
68
|
wsUrl?: string;
|
|
60
69
|
token?: string;
|
|
@@ -30,23 +30,23 @@ export class ChannelRuntime {
|
|
|
30
30
|
}
|
|
31
31
|
ingest(event) {
|
|
32
32
|
const config = this.configs.get(event.channelId);
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
throw new Error(`No
|
|
33
|
+
const sessionId = event.managerSessionId ?? config?.sessionId ?? config?.managerSessionId;
|
|
34
|
+
if (!sessionId)
|
|
35
|
+
throw new Error(`No session bound for channel ${event.channelId}`);
|
|
36
36
|
const replyTarget = event.replyTarget || this.createReplyTarget({
|
|
37
|
-
managerSessionId,
|
|
37
|
+
managerSessionId: sessionId,
|
|
38
38
|
channelId: event.channelId,
|
|
39
39
|
conversationId: event.conversationId,
|
|
40
40
|
messageId: event.messageId,
|
|
41
41
|
});
|
|
42
|
-
this.onExternalMessage(
|
|
42
|
+
this.onExternalMessage(sessionId, { ...event, replyTarget });
|
|
43
43
|
}
|
|
44
44
|
async reply(input) {
|
|
45
45
|
const config = this.configs.get(input.channelId);
|
|
46
46
|
if (!config)
|
|
47
47
|
return { ok: false, error: `Unknown channel: ${input.channelId}` };
|
|
48
|
-
if (config.managerSessionId !== input.managerSessionId) {
|
|
49
|
-
return { ok: false, error: 'Channel is not bound to this
|
|
48
|
+
if ((config.sessionId ?? config.managerSessionId) !== input.managerSessionId) {
|
|
49
|
+
return { ok: false, error: 'Channel is not bound to this session' };
|
|
50
50
|
}
|
|
51
51
|
const adapter = this.adapters.get(config.type);
|
|
52
52
|
if (!adapter)
|
|
@@ -59,10 +59,10 @@ export class ChannelRuntime {
|
|
|
59
59
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
async getDefaultReplyTarget(
|
|
63
|
-
const config = this.configs.list().find((channel) => channel.managerSessionId ===
|
|
62
|
+
async getDefaultReplyTarget(sessionId) {
|
|
63
|
+
const config = this.configs.list().find((channel) => (channel.sessionId ?? channel.managerSessionId) === sessionId && channel.enabled);
|
|
64
64
|
if (!config)
|
|
65
|
-
throw new Error('No enabled external channel is bound to this
|
|
65
|
+
throw new Error('No enabled external channel is bound to this session');
|
|
66
66
|
const adapter = this.adapters.get(config.type);
|
|
67
67
|
if (!adapter?.defaultConversation)
|
|
68
68
|
throw new Error(`External channel ${config.type} has no default conversation`);
|
|
@@ -74,7 +74,7 @@ export class ChannelRuntime {
|
|
|
74
74
|
}
|
|
75
75
|
getManagerChannel(managerSessionId, type, opts = {}) {
|
|
76
76
|
const configs = this.configs.list()
|
|
77
|
-
.filter((channel) => channel.managerSessionId === managerSessionId && channel.type === type);
|
|
77
|
+
.filter((channel) => (channel.sessionId ?? channel.managerSessionId) === managerSessionId && channel.type === type);
|
|
78
78
|
const config = configs.find((channel) => channel.enabled) ?? configs.at(-1);
|
|
79
79
|
if (!config)
|
|
80
80
|
return null;
|
|
@@ -83,8 +83,35 @@ export class ChannelRuntime {
|
|
|
83
83
|
id: config.id,
|
|
84
84
|
type: config.type,
|
|
85
85
|
name: config.name,
|
|
86
|
+
sessionId: config.sessionId ?? config.managerSessionId,
|
|
86
87
|
managerSessionId: config.managerSessionId,
|
|
87
88
|
workDir: config.workDir,
|
|
89
|
+
agentType: config.agentType,
|
|
90
|
+
agentSessionId: config.agentSessionId,
|
|
91
|
+
modelId: config.modelId,
|
|
92
|
+
enabled: config.enabled,
|
|
93
|
+
wsUrl: secret?.wsUrl ?? '',
|
|
94
|
+
token: opts.includeSecret ? secret?.token ?? '' : '',
|
|
95
|
+
tokenConfigured: Boolean(secret?.token),
|
|
96
|
+
canReply: Boolean(secret?.canReply),
|
|
97
|
+
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
getChannelById(channelId, opts = {}) {
|
|
101
|
+
const config = this.configs.get(channelId);
|
|
102
|
+
if (!config)
|
|
103
|
+
return null;
|
|
104
|
+
const secret = this.secrets.get(config.secretRef);
|
|
105
|
+
return {
|
|
106
|
+
id: config.id,
|
|
107
|
+
type: config.type,
|
|
108
|
+
name: config.name,
|
|
109
|
+
sessionId: config.sessionId ?? config.managerSessionId,
|
|
110
|
+
managerSessionId: config.managerSessionId,
|
|
111
|
+
workDir: config.workDir,
|
|
112
|
+
agentType: config.agentType,
|
|
113
|
+
agentSessionId: config.agentSessionId,
|
|
114
|
+
modelId: config.modelId,
|
|
88
115
|
enabled: config.enabled,
|
|
89
116
|
wsUrl: secret?.wsUrl ?? '',
|
|
90
117
|
token: opts.includeSecret ? secret?.token ?? '' : '',
|
|
@@ -94,7 +121,7 @@ export class ChannelRuntime {
|
|
|
94
121
|
};
|
|
95
122
|
}
|
|
96
123
|
getManagerChannelStatus(managerSessionId) {
|
|
97
|
-
const config = this.configs.list().find((channel) => channel.managerSessionId === managerSessionId && channel.enabled);
|
|
124
|
+
const config = this.configs.list().find((channel) => (channel.sessionId ?? channel.managerSessionId) === managerSessionId && channel.enabled);
|
|
98
125
|
if (!config)
|
|
99
126
|
return null;
|
|
100
127
|
const secret = this.secrets.get(config.secretRef);
|
|
@@ -104,32 +131,39 @@ export class ChannelRuntime {
|
|
|
104
131
|
type: config.type,
|
|
105
132
|
channelId: config.id,
|
|
106
133
|
name: config.name,
|
|
134
|
+
canReply: Boolean(secret?.canReply),
|
|
135
|
+
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
107
136
|
};
|
|
108
137
|
}
|
|
109
138
|
listManagerChannelStatuses() {
|
|
110
139
|
return this.configs.list()
|
|
111
140
|
.filter((channel) => channel.enabled)
|
|
112
141
|
.map((channel) => ({
|
|
113
|
-
managerSessionId: channel.managerSessionId,
|
|
114
|
-
status: this.getManagerChannelStatus(channel.managerSessionId),
|
|
142
|
+
managerSessionId: channel.sessionId ?? channel.managerSessionId,
|
|
143
|
+
status: this.getManagerChannelStatus(channel.sessionId ?? channel.managerSessionId),
|
|
115
144
|
}))
|
|
116
145
|
.filter((entry) => Boolean(entry.status));
|
|
117
146
|
}
|
|
118
147
|
listManagerChannelSystemPrompts(managerSessionId) {
|
|
119
148
|
return this.configs.list()
|
|
120
|
-
.filter((channel) => channel.enabled && channel.managerSessionId === managerSessionId)
|
|
149
|
+
.filter((channel) => channel.enabled && (channel.sessionId ?? channel.managerSessionId) === managerSessionId)
|
|
121
150
|
.map((channel) => this.secrets.get(channel.secretRef)?.systemPrompt)
|
|
122
151
|
.filter((prompt) => typeof prompt === 'string' && prompt.trim().length > 0);
|
|
123
152
|
}
|
|
124
153
|
async upsertManagerChannel(input) {
|
|
125
154
|
const previous = this.configs.get(input.id);
|
|
126
155
|
const allConfigs = this.configs.list();
|
|
156
|
+
const boundSessionId = input.sessionId || input.managerSessionId;
|
|
127
157
|
const nextConfig = {
|
|
128
158
|
id: input.id,
|
|
129
159
|
type: input.type,
|
|
130
160
|
name: input.name?.trim() || previous?.name || '外部消息通道',
|
|
131
|
-
|
|
161
|
+
sessionId: boundSessionId,
|
|
162
|
+
managerSessionId: boundSessionId,
|
|
132
163
|
workDir: input.workDir,
|
|
164
|
+
agentType: input.agentType || previous?.agentType,
|
|
165
|
+
agentSessionId: input.agentSessionId ?? previous?.agentSessionId ?? null,
|
|
166
|
+
modelId: input.modelId ?? previous?.modelId ?? null,
|
|
133
167
|
enabled: input.enabled,
|
|
134
168
|
secretRef: previous?.secretRef || `channel:${input.id}`,
|
|
135
169
|
};
|
|
@@ -143,7 +177,7 @@ export class ChannelRuntime {
|
|
|
143
177
|
}
|
|
144
178
|
const configs = allConfigs
|
|
145
179
|
.filter((channel) => channel.id !== nextConfig.id)
|
|
146
|
-
.map((channel) => channel.
|
|
180
|
+
.map((channel) => (channel.sessionId ?? channel.managerSessionId) === boundSessionId && channel.type === input.type
|
|
147
181
|
? { ...channel, enabled: false }
|
|
148
182
|
: channel);
|
|
149
183
|
configs.push(nextConfig);
|
|
@@ -159,13 +193,13 @@ export class ChannelRuntime {
|
|
|
159
193
|
}
|
|
160
194
|
const adapter = this.adapters.get(nextConfig.type);
|
|
161
195
|
for (const config of allConfigs) {
|
|
162
|
-
if (config.
|
|
196
|
+
if ((config.sessionId ?? config.managerSessionId) === boundSessionId && config.type === input.type && config.enabled) {
|
|
163
197
|
await this.adapters.get(config.type)?.disconnect(config).catch(() => { });
|
|
164
198
|
}
|
|
165
199
|
}
|
|
166
200
|
if (nextConfig.enabled) {
|
|
167
201
|
void adapter?.connect(nextConfig).catch(() => { });
|
|
168
202
|
}
|
|
169
|
-
return this.getManagerChannel(
|
|
203
|
+
return this.getManagerChannel(boundSessionId, input.type, { includeSecret: true });
|
|
170
204
|
}
|
|
171
205
|
}
|
|
@@ -28,9 +28,9 @@ export declare function resolveServiceLaunchSpec(input: {
|
|
|
28
28
|
scriptPath: string;
|
|
29
29
|
shennianCommandPath?: string | null;
|
|
30
30
|
npxPath?: string | null;
|
|
31
|
-
desktopBridgePath?: string | null;
|
|
32
31
|
}): ServiceLaunchSpec;
|
|
33
32
|
export declare function isRemoteAccessDisabled(): boolean;
|
|
33
|
+
export declare function clearRemoteAccessDisabled(): void;
|
|
34
34
|
export declare function getCurrentProcessDaemonLauncher(): DaemonLauncher;
|
|
35
35
|
export declare function writeDaemonLauncher(pid: number, launcher?: DaemonLauncher): void;
|
|
36
36
|
export declare function clearDaemonLauncher(): void;
|
|
@@ -33,9 +33,6 @@ const SAFE_SNAPSHOT_ENV_KEYS = new Set([
|
|
|
33
33
|
'TMP',
|
|
34
34
|
'APPDATA',
|
|
35
35
|
'LOCALAPPDATA',
|
|
36
|
-
'ELECTRON_RUN_AS_NODE',
|
|
37
|
-
'SHENNIAN_DESKTOP_CLI_SCRIPT',
|
|
38
|
-
'SHENNIAN_DESKTOP_CLI_BRIDGE',
|
|
39
36
|
'SHENNIAN_DESKTOP_SERVER_URL',
|
|
40
37
|
'SHENNIAN_HOME',
|
|
41
38
|
]);
|
|
@@ -57,16 +54,6 @@ export function isEphemeralCliPath(candidate) {
|
|
|
57
54
|
normalized.startsWith(tmp.endsWith('/') ? tmp : `${tmp}/`));
|
|
58
55
|
}
|
|
59
56
|
export function resolveServiceLaunchSpec(input) {
|
|
60
|
-
if (process.env.ELECTRON_RUN_AS_NODE === '1' &&
|
|
61
|
-
input.desktopBridgePath &&
|
|
62
|
-
fs.existsSync(input.scriptPath) &&
|
|
63
|
-
fs.existsSync(input.desktopBridgePath)) {
|
|
64
|
-
return {
|
|
65
|
-
command: input.nodeExec,
|
|
66
|
-
args: [input.desktopBridgePath, input.scriptPath, 'run-service'],
|
|
67
|
-
mode: 'direct',
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
57
|
if (fs.existsSync(input.scriptPath) && !isEphemeralCliPath(input.scriptPath)) {
|
|
71
58
|
return {
|
|
72
59
|
command: input.nodeExec,
|
|
@@ -117,8 +104,16 @@ function isRunning(pid) {
|
|
|
117
104
|
export function isRemoteAccessDisabled() {
|
|
118
105
|
return fs.existsSync(REMOTE_ACCESS_DISABLED_FILE);
|
|
119
106
|
}
|
|
107
|
+
export function clearRemoteAccessDisabled() {
|
|
108
|
+
try {
|
|
109
|
+
fs.unlinkSync(REMOTE_ACCESS_DISABLED_FILE);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Remote access may already be enabled.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
120
115
|
export function getCurrentProcessDaemonLauncher() {
|
|
121
|
-
return
|
|
116
|
+
return 'global-cli';
|
|
122
117
|
}
|
|
123
118
|
export function writeDaemonLauncher(pid, launcher = getCurrentProcessDaemonLauncher()) {
|
|
124
119
|
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
@@ -164,9 +159,6 @@ function inferDaemonLauncherFromProcess(pid) {
|
|
|
164
159
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
165
160
|
timeout: 1000,
|
|
166
161
|
}).replace(/\\/g, '/');
|
|
167
|
-
if (command.includes('/cli-bridge.js') || command.includes('/Resources/daemon/')) {
|
|
168
|
-
return 'desktop-managed';
|
|
169
|
-
}
|
|
170
162
|
if (command.includes('/node_modules/shennian/') ||
|
|
171
163
|
command.includes('/bin/shennian') ||
|
|
172
164
|
command.includes(' shennian run-service') ||
|
|
@@ -259,7 +251,6 @@ function resolveCurrentServiceLaunchSpec() {
|
|
|
259
251
|
scriptPath: SHENNIAN_SCRIPT,
|
|
260
252
|
shennianCommandPath: findCommandPath('shennian'),
|
|
261
253
|
npxPath: resolveNpxPath(),
|
|
262
|
-
desktopBridgePath: process.env.SHENNIAN_DESKTOP_CLI_BRIDGE,
|
|
263
254
|
});
|
|
264
255
|
}
|
|
265
256
|
function escapeXml(text) {
|
|
@@ -655,12 +646,7 @@ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
|
|
|
655
646
|
}
|
|
656
647
|
function enableRemoteAccess(opts = {}) {
|
|
657
648
|
persistServerUrlOverride(resolveServerUrlOverride(opts.api));
|
|
658
|
-
|
|
659
|
-
fs.unlinkSync(REMOTE_ACCESS_DISABLED_FILE);
|
|
660
|
-
}
|
|
661
|
-
catch {
|
|
662
|
-
// Remote access may already be enabled.
|
|
663
|
-
}
|
|
649
|
+
clearRemoteAccessDisabled();
|
|
664
650
|
const startedByService = installService();
|
|
665
651
|
if (!startedByService) {
|
|
666
652
|
startDaemonProcess({ quiet: opts.json });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @arch docs/features/wecom-managed-channel.md
|
|
2
|
+
// @test src/__tests__/platform-instructions.test.ts
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
function requireExternalContext() {
|
|
5
|
+
const url = process.env.SHENNIAN_MANAGER_IPC_URL;
|
|
6
|
+
const token = process.env.SHENNIAN_MANAGER_IPC_TOKEN;
|
|
7
|
+
const sessionId = process.env.SHENNIAN_EXTERNAL_SESSION_ID || process.env.SHENNIAN_MANAGER_SESSION_ID;
|
|
8
|
+
if (!url || !token || !sessionId) {
|
|
9
|
+
console.error(chalk.red('✗ This command must run inside a Shennian conversation with an external channel.'));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
return { url, token, sessionId };
|
|
13
|
+
}
|
|
14
|
+
async function sendExternal(text, idempotencyKey) {
|
|
15
|
+
const ctx = requireExternalContext();
|
|
16
|
+
const response = await fetch(`${ctx.url}/external/reply`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
authorization: `Bearer ${ctx.token}`,
|
|
20
|
+
'content-type': 'application/json',
|
|
21
|
+
'x-shennian-manager-session-id': ctx.sessionId,
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
managerSessionId: ctx.sessionId,
|
|
25
|
+
text,
|
|
26
|
+
idempotencyKey,
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
const data = await response.json().catch(() => ({ ok: false, error: response.statusText }));
|
|
30
|
+
if (!response.ok || !data.ok) {
|
|
31
|
+
throw new Error(data.error || `External send failed: ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
console.log('ok');
|
|
34
|
+
}
|
|
35
|
+
export function registerExternalCommand(program) {
|
|
36
|
+
const external = program.command('external').description('External channel tools for this Shennian conversation');
|
|
37
|
+
external
|
|
38
|
+
.command('send')
|
|
39
|
+
.description('Send a message to the external channel bound to this conversation')
|
|
40
|
+
.requiredOption('--text <text>', 'Message text')
|
|
41
|
+
.option('--idempotency-key <key>', 'Idempotency key')
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
await sendExternal(opts.text, opts.idempotencyKey);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import readline from 'node:readline';
|
|
4
4
|
import { loadConfig, saveConfig } from '../config/index.js';
|
|
5
5
|
import { detectAgents } from '../agents/detect.js';
|
|
6
|
-
import { startDaemonProcess, installService, saveEnvSnapshot } from './daemon.js';
|
|
6
|
+
import { clearRemoteAccessDisabled, startDaemonProcess, installService, saveEnvSnapshot, } from './daemon.js';
|
|
7
7
|
import { detectAndChooseServer } from '../region.js';
|
|
8
8
|
import { buildPairQrPayload, PAIR_QR_RENDER_OPTIONS } from './pair-qr.js';
|
|
9
9
|
const POLL_INTERVAL_MS = 3000;
|
|
@@ -214,6 +214,7 @@ export async function runSmartStart(serverUrl, machineName) {
|
|
|
214
214
|
console.log(chalk.green(`✓ Already paired (machine ID: ${config.machineId})`));
|
|
215
215
|
}
|
|
216
216
|
saveEnvSnapshot();
|
|
217
|
+
clearRemoteAccessDisabled();
|
|
217
218
|
console.log(chalk.gray('\nStarting background service...'));
|
|
218
219
|
const startedByService = installService();
|
|
219
220
|
if (!startedByService) {
|
|
@@ -271,6 +272,7 @@ export function registerPairCommand(program) {
|
|
|
271
272
|
const serverUrl = opts.server ?? opts.api ?? (await detectAndChooseServer());
|
|
272
273
|
await runPairFlow({ serverUrl, machineName: opts.name, force: true, json: Boolean(opts.json) });
|
|
273
274
|
saveEnvSnapshot();
|
|
275
|
+
clearRemoteAccessDisabled();
|
|
274
276
|
if (opts.json)
|
|
275
277
|
emitPairJson({ type: 'daemon.starting' });
|
|
276
278
|
else
|
package/dist/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerPairCommand, runSmartStart } from './commands/pair.js';
|
|
|
11
11
|
import { clearDaemonLauncher, isRemoteAccessDisabled, registerDaemonCommand, writeDaemonLauncher, } from './commands/daemon.js';
|
|
12
12
|
import { registerAgentCommand } from './commands/agent.js';
|
|
13
13
|
import { registerManagerCommand } from './commands/manager.js';
|
|
14
|
+
import { registerExternalCommand } from './commands/external.js';
|
|
14
15
|
import { registerUpgradeCommand } from './commands/upgrade.js';
|
|
15
16
|
import { SessionManager } from './session/manager.js';
|
|
16
17
|
import { SERVERS, regionToUrl, urlToRegion } from './region.js';
|
|
@@ -106,8 +107,7 @@ program
|
|
|
106
107
|
process.kill(oldPid, 0);
|
|
107
108
|
const serviceManagedStart = Boolean(process.env.INVOCATION_ID ||
|
|
108
109
|
process.env.JOURNAL_STREAM ||
|
|
109
|
-
process.env.
|
|
110
|
-
process.env.SHENNIAN_DESKTOP_CLI_BRIDGE);
|
|
110
|
+
process.env.SHENNIAN_DESKTOP_SERVER_URL);
|
|
111
111
|
if (serviceManagedStart) {
|
|
112
112
|
console.log(`[${new Date().toISOString()}] managed start taking over from existing daemon (PID ${oldPid})`);
|
|
113
113
|
process.kill(oldPid, 'SIGTERM');
|
|
@@ -325,6 +325,7 @@ registerPairCommand(program);
|
|
|
325
325
|
registerDaemonCommand(program);
|
|
326
326
|
registerAgentCommand(program);
|
|
327
327
|
registerManagerCommand(program);
|
|
328
|
+
registerExternalCommand(program);
|
|
328
329
|
registerUpgradeCommand(program);
|
|
329
330
|
program.parse();
|
|
330
331
|
// ─── Auto-upgrade helper ──────────────────────────────────────────────────────
|
|
@@ -42,8 +42,10 @@ export declare class ManagerRuntimeService {
|
|
|
42
42
|
private handleIpc;
|
|
43
43
|
private dispatchChatSend;
|
|
44
44
|
private dispatchChatEnqueue;
|
|
45
|
+
private sendManagedWeComReply;
|
|
45
46
|
private wakeManagerForWorker;
|
|
46
47
|
private handleExternalMessage;
|
|
48
|
+
private dispatchExternalMessage;
|
|
47
49
|
private scanWorkerHealth;
|
|
48
50
|
private interruptAndResumeManager;
|
|
49
51
|
bindManagerAdapterEvents(sessionId: string, adapter: AgentAdapter): void;
|
|
@@ -54,6 +56,8 @@ export declare class ManagerRuntimeService {
|
|
|
54
56
|
type?: string;
|
|
55
57
|
channelId?: string;
|
|
56
58
|
name?: string;
|
|
59
|
+
canReply?: boolean;
|
|
60
|
+
systemPrompt?: string;
|
|
57
61
|
} | null;
|
|
58
62
|
getManagerExternalChannelSystemPrompt(managerSessionId: string): string;
|
|
59
63
|
}
|
|
@@ -107,6 +107,9 @@ function appJson(runtime, reqId, ok, payload) {
|
|
|
107
107
|
...(ok ? { payload } : { error: String(payload.error || 'unknown error') }),
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
|
+
function shouldFallbackToLocalChannel(error) {
|
|
111
|
+
return /binding not found|unknown method|not supported|relay is not connected|no external channel/i.test(error);
|
|
112
|
+
}
|
|
110
113
|
export class ManagerRuntimeService {
|
|
111
114
|
opts;
|
|
112
115
|
registry = new ManagerRegistry();
|
|
@@ -237,10 +240,8 @@ export class ManagerRuntimeService {
|
|
|
237
240
|
try {
|
|
238
241
|
const managerSessionId = String(body.managerSessionId || body.sessionId || '');
|
|
239
242
|
if (!managerSessionId)
|
|
240
|
-
throw new Error('
|
|
243
|
+
throw new Error('sessionId is required');
|
|
241
244
|
const manager = this.registry.getManager(managerSessionId);
|
|
242
|
-
if (!manager)
|
|
243
|
-
throw new Error('Manager runtime is not registered');
|
|
244
245
|
if (req.method === 'manager.channel.get') {
|
|
245
246
|
appJson(runtime, req.id, true, {
|
|
246
247
|
channel: this.channelRuntime.getManagerChannel(managerSessionId, 'websocket', { includeSecret: true }),
|
|
@@ -251,9 +252,13 @@ export class ManagerRuntimeService {
|
|
|
251
252
|
const channel = await this.channelRuntime.upsertManagerChannel({
|
|
252
253
|
id: String(body.id || `websocket:${managerSessionId}`),
|
|
253
254
|
managerSessionId,
|
|
254
|
-
|
|
255
|
+
sessionId: managerSessionId,
|
|
256
|
+
workDir: String(body.workDir || manager?.workDir || ''),
|
|
255
257
|
type: 'websocket',
|
|
256
258
|
name: typeof body.name === 'string' ? body.name : undefined,
|
|
259
|
+
agentType: typeof body.agentType === 'string' ? body.agentType : undefined,
|
|
260
|
+
agentSessionId: typeof body.agentSessionId === 'string' ? body.agentSessionId : null,
|
|
261
|
+
modelId: typeof body.modelId === 'string' ? body.modelId : null,
|
|
257
262
|
enabled: Boolean(body.enabled),
|
|
258
263
|
wsUrl: typeof body.wsUrl === 'string' ? body.wsUrl : undefined,
|
|
259
264
|
token: typeof body.token === 'string' ? body.token : undefined,
|
|
@@ -287,7 +292,7 @@ export class ManagerRuntimeService {
|
|
|
287
292
|
payload: {
|
|
288
293
|
session: {
|
|
289
294
|
id: managerSessionId,
|
|
290
|
-
agentType: 'manager',
|
|
295
|
+
agentType: manager ? 'manager' : undefined,
|
|
291
296
|
agentSessionId: manager?.agentSessionId ?? null,
|
|
292
297
|
modelId: manager?.modelId ?? null,
|
|
293
298
|
workDir: manager?.workDir,
|
|
@@ -308,9 +313,9 @@ export class ManagerRuntimeService {
|
|
|
308
313
|
if (!managerSessionId)
|
|
309
314
|
throw new Error('managerSessionId is required');
|
|
310
315
|
const manager = this.registry.getManager(managerSessionId);
|
|
311
|
-
if (!manager)
|
|
312
|
-
throw new Error('Manager runtime is not registered');
|
|
313
316
|
if (url.pathname === '/sessions/list') {
|
|
317
|
+
if (!manager)
|
|
318
|
+
throw new Error('Manager runtime is not registered');
|
|
314
319
|
const runningSessionIds = new Set(this.opts.getRuntime().sessions.keys());
|
|
315
320
|
json(res, 200, {
|
|
316
321
|
ok: true,
|
|
@@ -319,6 +324,8 @@ export class ManagerRuntimeService {
|
|
|
319
324
|
return;
|
|
320
325
|
}
|
|
321
326
|
if (url.pathname === '/sessions/start') {
|
|
327
|
+
if (!manager)
|
|
328
|
+
throw new Error('Manager runtime is not registered');
|
|
322
329
|
const agentType = String(body.agentType || body.agent || 'codex');
|
|
323
330
|
if (agentType === 'manager')
|
|
324
331
|
throw new Error('Manager cannot start another manager as a worker');
|
|
@@ -423,6 +430,8 @@ export class ManagerRuntimeService {
|
|
|
423
430
|
return;
|
|
424
431
|
}
|
|
425
432
|
if (url.pathname === '/memory/path') {
|
|
433
|
+
if (!manager)
|
|
434
|
+
throw new Error('Manager runtime is not registered');
|
|
426
435
|
json(res, 200, { ok: true, path: path.join(manager.workDir, '.shennian') });
|
|
427
436
|
return;
|
|
428
437
|
}
|
|
@@ -430,6 +439,28 @@ export class ManagerRuntimeService {
|
|
|
430
439
|
const replyTarget = typeof body.replyTarget === 'string'
|
|
431
440
|
? this.registry.getReplyTarget(body.replyTarget)
|
|
432
441
|
: this.registry.getLatestReplyTargetForManager(managerSessionId);
|
|
442
|
+
const text = String(body.text || '');
|
|
443
|
+
const idempotencyKey = String(body.idempotencyKey || randomUUID());
|
|
444
|
+
try {
|
|
445
|
+
const relayResult = await this.sendManagedWeComReply({
|
|
446
|
+
managerSessionId,
|
|
447
|
+
text,
|
|
448
|
+
idempotencyKey,
|
|
449
|
+
});
|
|
450
|
+
if (relayResult.ok) {
|
|
451
|
+
json(res, 200, { ok: true, payload: relayResult.payload });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!shouldFallbackToLocalChannel(relayResult.error || '')) {
|
|
455
|
+
json(res, 400, { ok: false, error: relayResult.error || 'External send failed' });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (!shouldFallbackToLocalChannel(error instanceof Error ? error.message : String(error))) {
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
433
464
|
const explicitChannelId = String(body.channelId || '');
|
|
434
465
|
const explicitConversationId = String(body.conversationId || '');
|
|
435
466
|
const defaultTarget = !replyTarget && (!explicitChannelId || !explicitConversationId)
|
|
@@ -444,8 +475,8 @@ export class ManagerRuntimeService {
|
|
|
444
475
|
channelId,
|
|
445
476
|
conversationId,
|
|
446
477
|
messageId: replyTarget?.messageId ?? undefined,
|
|
447
|
-
text
|
|
448
|
-
idempotencyKey
|
|
478
|
+
text,
|
|
479
|
+
idempotencyKey,
|
|
449
480
|
});
|
|
450
481
|
json(res, result.ok ? 200 : 400, result);
|
|
451
482
|
return;
|
|
@@ -458,6 +489,8 @@ export class ManagerRuntimeService {
|
|
|
458
489
|
return;
|
|
459
490
|
}
|
|
460
491
|
if (url.pathname === '/channel/upsert') {
|
|
492
|
+
if (!manager)
|
|
493
|
+
throw new Error('Manager runtime is not registered');
|
|
461
494
|
const channel = await this.channelRuntime.upsertManagerChannel({
|
|
462
495
|
id: String(body.id || `websocket:${managerSessionId}`),
|
|
463
496
|
managerSessionId,
|
|
@@ -500,6 +533,27 @@ export class ManagerRuntimeService {
|
|
|
500
533
|
params: { sessionId, text, agentType, workDir, agentSessionId, modelId },
|
|
501
534
|
});
|
|
502
535
|
}
|
|
536
|
+
async sendManagedWeComReply(input) {
|
|
537
|
+
if (!input.text.trim())
|
|
538
|
+
return { ok: false, error: 'text is required' };
|
|
539
|
+
const client = this.opts.getRuntime().client;
|
|
540
|
+
if (!client || typeof client.sendReq !== 'function') {
|
|
541
|
+
return { ok: false, error: 'Relay is not connected' };
|
|
542
|
+
}
|
|
543
|
+
const frame = await client.sendReq({
|
|
544
|
+
type: 'req',
|
|
545
|
+
id: `wecom-send-${randomUUID()}`,
|
|
546
|
+
method: 'wecom.send',
|
|
547
|
+
params: {
|
|
548
|
+
managerSessionId: input.managerSessionId,
|
|
549
|
+
text: input.text,
|
|
550
|
+
idempotencyKey: input.idempotencyKey,
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
return frame.ok
|
|
554
|
+
? { ok: true, payload: frame.payload }
|
|
555
|
+
: { ok: false, error: frame.error || 'External send failed' };
|
|
556
|
+
}
|
|
503
557
|
wakeManagerForWorker(managerSessionId, worker, state, message) {
|
|
504
558
|
const manager = this.registry.getManager(managerSessionId);
|
|
505
559
|
if (!manager)
|
|
@@ -515,11 +569,45 @@ ${message || worker.summary || '(无可见摘要)'}
|
|
|
515
569
|
void this.interruptAndResumeManager(manager, prompt, state === 'final' ? 'worker.final' : `worker.${state}`);
|
|
516
570
|
}
|
|
517
571
|
handleExternalMessage(managerSessionId, event) {
|
|
572
|
+
const config = this.channelRuntime.getChannelById(event.channelId)
|
|
573
|
+
?? this.channelRuntime.getManagerChannel(managerSessionId, event.channelType);
|
|
518
574
|
const manager = this.registry.getManager(managerSessionId);
|
|
519
|
-
|
|
520
|
-
|
|
575
|
+
const agentType = (config?.agentType || (manager ? 'manager' : 'codex'));
|
|
576
|
+
const workDir = config?.workDir || manager?.workDir || process.cwd();
|
|
577
|
+
const agentSessionId = config?.agentSessionId ?? manager?.agentSessionId ?? null;
|
|
578
|
+
const modelId = config?.modelId || manager?.modelId || '';
|
|
521
579
|
const visibleMessage = `外部消息 / ${event.sender.name || event.sender.id}\n${event.text}`;
|
|
522
|
-
|
|
580
|
+
this.registry.createReplyTarget({
|
|
581
|
+
managerSessionId,
|
|
582
|
+
channelId: event.channelId,
|
|
583
|
+
conversationId: event.conversationId,
|
|
584
|
+
messageId: event.messageId,
|
|
585
|
+
});
|
|
586
|
+
void this.dispatchExternalMessage({
|
|
587
|
+
sessionId: managerSessionId,
|
|
588
|
+
agentType,
|
|
589
|
+
workDir,
|
|
590
|
+
agentSessionId,
|
|
591
|
+
modelId,
|
|
592
|
+
text: visibleMessage,
|
|
593
|
+
replyTarget: event.replyTarget,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async dispatchExternalMessage(input) {
|
|
597
|
+
await this.opts.dispatchReq({
|
|
598
|
+
type: 'req',
|
|
599
|
+
id: `external-enqueue-${randomUUID()}`,
|
|
600
|
+
method: 'chat.enqueue',
|
|
601
|
+
params: {
|
|
602
|
+
sessionId: input.sessionId,
|
|
603
|
+
text: input.text,
|
|
604
|
+
agentType: input.agentType,
|
|
605
|
+
workDir: input.workDir,
|
|
606
|
+
agentSessionId: input.agentSessionId,
|
|
607
|
+
modelId: input.modelId,
|
|
608
|
+
origin: 'external',
|
|
609
|
+
},
|
|
610
|
+
});
|
|
523
611
|
}
|
|
524
612
|
scanWorkerHealth() {
|
|
525
613
|
const now = Date.now();
|
|
@@ -33,11 +33,13 @@ export declare class CliRelayClient {
|
|
|
33
33
|
/** Buffered agent events awaiting server ack, keyed by event id */
|
|
34
34
|
private sendBuffer;
|
|
35
35
|
private pendingAcks;
|
|
36
|
+
private pendingRequests;
|
|
36
37
|
constructor(options: CliRelayOptions);
|
|
37
38
|
connect(): void;
|
|
38
39
|
disconnect(): void;
|
|
39
40
|
sendRes(res: ResFrame): void;
|
|
40
41
|
sendEvent(event: EventFrame): void;
|
|
42
|
+
sendReq(req: ReqFrame, timeoutMs?: number): Promise<ResFrame>;
|
|
41
43
|
sendBufferedEvent(event: EventFrame, timeoutMs?: number): Promise<void>;
|
|
42
44
|
/**
|
|
43
45
|
* Send an agent event with at-least-once delivery guarantee.
|
package/dist/src/relay/client.js
CHANGED
|
@@ -26,6 +26,7 @@ export class CliRelayClient {
|
|
|
26
26
|
/** Buffered agent events awaiting server ack, keyed by event id */
|
|
27
27
|
sendBuffer = new Map();
|
|
28
28
|
pendingAcks = new Map();
|
|
29
|
+
pendingRequests = new Map();
|
|
29
30
|
constructor(options) {
|
|
30
31
|
this.options = options;
|
|
31
32
|
}
|
|
@@ -121,6 +122,26 @@ export class CliRelayClient {
|
|
|
121
122
|
event.traceId = generateTraceId();
|
|
122
123
|
this.ws.send(JSON.stringify(event));
|
|
123
124
|
}
|
|
125
|
+
sendReq(req, timeoutMs = 60_000) {
|
|
126
|
+
if (this.state !== 'connected' || !this.ws) {
|
|
127
|
+
return Promise.reject(new Error('Relay is not connected'));
|
|
128
|
+
}
|
|
129
|
+
if (!req.traceId)
|
|
130
|
+
req.traceId = generateTraceId();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const existing = this.pendingRequests.get(req.id);
|
|
133
|
+
if (existing) {
|
|
134
|
+
clearTimeout(existing.timer);
|
|
135
|
+
existing.reject(new Error('Superseded by a newer relay request'));
|
|
136
|
+
}
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
this.pendingRequests.delete(req.id);
|
|
139
|
+
reject(new Error('Relay request timed out'));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
this.pendingRequests.set(req.id, { resolve, reject, timer });
|
|
142
|
+
this.ws?.send(JSON.stringify(req));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
124
145
|
sendBufferedEvent(event, timeoutMs = 120_000) {
|
|
125
146
|
if (!event.traceId)
|
|
126
147
|
event.traceId = generateTraceId();
|
|
@@ -172,6 +193,12 @@ export class CliRelayClient {
|
|
|
172
193
|
else
|
|
173
194
|
pending.reject(new Error(frame.error ?? 'Relay event failed'));
|
|
174
195
|
}
|
|
196
|
+
const pendingRequest = this.pendingRequests.get(frame.id);
|
|
197
|
+
if (pendingRequest) {
|
|
198
|
+
this.pendingRequests.delete(frame.id);
|
|
199
|
+
clearTimeout(pendingRequest.timer);
|
|
200
|
+
pendingRequest.resolve(frame);
|
|
201
|
+
}
|
|
175
202
|
return;
|
|
176
203
|
}
|
|
177
204
|
if (frame.type === 'req') {
|
|
@@ -300,6 +327,11 @@ export class CliRelayClient {
|
|
|
300
327
|
// already closed
|
|
301
328
|
}
|
|
302
329
|
}
|
|
330
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
331
|
+
clearTimeout(pending.timer);
|
|
332
|
+
pending.reject(new Error('Relay client disconnected'));
|
|
333
|
+
this.pendingRequests.delete(id);
|
|
334
|
+
}
|
|
303
335
|
if (rejectAll) {
|
|
304
336
|
for (const [id, pending] of this.pendingAcks) {
|
|
305
337
|
clearTimeout(pending.timer);
|
|
@@ -6,6 +6,7 @@ import { reportLog } from '../../log-reporter.js';
|
|
|
6
6
|
import { lookupClaudeTranscriptCwd } from '../../native-fusion/parsers.js';
|
|
7
7
|
import { appendMessage, recordSession } from '../store.js';
|
|
8
8
|
import { mergeProjectedSessions } from '../projection.js';
|
|
9
|
+
import { getManagerRuntimeService } from '../../manager/runtime.js';
|
|
9
10
|
function extractSummary(text) {
|
|
10
11
|
const newline = text.indexOf('\n');
|
|
11
12
|
const end = newline > 0 ? Math.min(newline, 80) : Math.min(text.length, 80);
|
|
@@ -30,9 +31,7 @@ function sendSessionMessageEvent(runtime, envelope, session) {
|
|
|
30
31
|
modelId: session.modelId ?? null,
|
|
31
32
|
workDir: session.workDir,
|
|
32
33
|
status: 'active',
|
|
33
|
-
|
|
34
|
-
? { externalChannel: runtime.managerRuntime?.getExternalChannelStatus(envelope.sessionId) ?? null }
|
|
35
|
-
: {}),
|
|
34
|
+
externalChannel: getSessionExternalChannel(runtime, envelope.sessionId, session.agentType),
|
|
36
35
|
},
|
|
37
36
|
},
|
|
38
37
|
});
|
|
@@ -56,13 +55,53 @@ function sendSessionUpdateEvent(runtime, input) {
|
|
|
56
55
|
modelId: input.modelId ?? null,
|
|
57
56
|
workDir: input.workDir,
|
|
58
57
|
status: 'active',
|
|
59
|
-
|
|
60
|
-
? { externalChannel: runtime.managerRuntime?.getExternalChannelStatus(input.sessionId) ?? null }
|
|
61
|
-
: {}),
|
|
58
|
+
externalChannel: getSessionExternalChannel(runtime, input.sessionId, input.agentType),
|
|
62
59
|
},
|
|
63
60
|
},
|
|
64
61
|
});
|
|
65
62
|
}
|
|
63
|
+
function normalizeExternalChannel(value) {
|
|
64
|
+
if (!value || typeof value !== 'object')
|
|
65
|
+
return null;
|
|
66
|
+
const raw = value;
|
|
67
|
+
return {
|
|
68
|
+
configured: raw.configured === undefined ? undefined : Boolean(raw.configured),
|
|
69
|
+
connected: Boolean(raw.connected),
|
|
70
|
+
type: typeof raw.type === 'string' ? raw.type : null,
|
|
71
|
+
channelId: typeof raw.channelId === 'string' ? raw.channelId : null,
|
|
72
|
+
name: typeof raw.name === 'string' ? raw.name : null,
|
|
73
|
+
canReply: raw.canReply === undefined || raw.canReply === null ? null : Boolean(raw.canReply),
|
|
74
|
+
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function externalChannelEnabled(channel) {
|
|
78
|
+
return Boolean(channel?.configured ?? channel?.connected);
|
|
79
|
+
}
|
|
80
|
+
function externalChannelEnv(sessionId, channel) {
|
|
81
|
+
if (!externalChannelEnabled(channel))
|
|
82
|
+
return {};
|
|
83
|
+
const service = getManagerRuntimeService();
|
|
84
|
+
const injected = service?.getInjectedEnv(sessionId, null, process.cwd(), 'external') ?? {};
|
|
85
|
+
return {
|
|
86
|
+
...injected,
|
|
87
|
+
SHENNIAN_EXTERNAL_SESSION_ID: sessionId,
|
|
88
|
+
SHENNIAN_MANAGER_SESSION_ID: sessionId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function configureAdapterForSession(adapter, sessionId, channel) {
|
|
92
|
+
adapter.configure?.({
|
|
93
|
+
externalChannel: channel ?? null,
|
|
94
|
+
env: externalChannelEnv(sessionId, channel),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function getSessionExternalChannel(runtime, sessionId, agentType) {
|
|
98
|
+
const active = runtime.sessions.get(sessionId);
|
|
99
|
+
if (active?.externalChannel)
|
|
100
|
+
return active.externalChannel;
|
|
101
|
+
if (agentType === 'manager')
|
|
102
|
+
return runtime.managerRuntime?.getExternalChannelStatus(sessionId) ?? null;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
66
105
|
function maybeResolveClaudeImportedWorkDir(agentType, workDir, agentSessionId) {
|
|
67
106
|
if (agentType !== 'claude')
|
|
68
107
|
return workDir;
|
|
@@ -239,11 +278,12 @@ async function disposeSession(session) {
|
|
|
239
278
|
session.adapter.removeAllListeners();
|
|
240
279
|
await session.adapter.stop().catch(() => { });
|
|
241
280
|
}
|
|
242
|
-
async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDir, incomingAgentSid) {
|
|
281
|
+
async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDir, incomingAgentSid, externalChannel) {
|
|
243
282
|
runtime.evictIdleSessions();
|
|
244
283
|
const adapter = createAgent(agentType);
|
|
245
284
|
if (!adapter)
|
|
246
285
|
throw new Error(`Unsupported agent: ${agentType}`);
|
|
286
|
+
configureAdapterForSession(adapter, sessionId, externalChannel);
|
|
247
287
|
await adapter.start(sessionId, resolvedWorkDir, incomingAgentSid);
|
|
248
288
|
const session = {
|
|
249
289
|
adapter,
|
|
@@ -254,6 +294,8 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
|
|
|
254
294
|
currentRunId: null,
|
|
255
295
|
nextEventSeq: 0,
|
|
256
296
|
pendingTextEvent: null,
|
|
297
|
+
externalChannel: externalChannel ?? null,
|
|
298
|
+
externalChannelEnv: externalChannelEnv(sessionId, externalChannel),
|
|
257
299
|
};
|
|
258
300
|
runtime.sessions.set(sessionId, session);
|
|
259
301
|
bindAdapterEvents(runtime, sessionId, agentType, adapter);
|
|
@@ -285,6 +327,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
285
327
|
rememberProcessedReqId(runtime, req.id);
|
|
286
328
|
const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, reasoningEffort, clientMessageId, sessionListProjection, waitForDispatch } = req.params;
|
|
287
329
|
mergeProjectedSessions(sessionListProjection);
|
|
330
|
+
const incomingExternalChannel = normalizeExternalChannel(req.params.externalChannel);
|
|
288
331
|
if (!sessionId || !text) {
|
|
289
332
|
runtime.processedReqIds.delete(req.id);
|
|
290
333
|
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'sessionId and text are required' });
|
|
@@ -301,7 +344,8 @@ export async function handleChatSend(runtime, req) {
|
|
|
301
344
|
if (session) {
|
|
302
345
|
session.lastActiveAt = Date.now();
|
|
303
346
|
const sessionDrifted = session.agentType !== requestedAgentType ||
|
|
304
|
-
session.workDir !== resolvedWorkDir
|
|
347
|
+
session.workDir !== resolvedWorkDir ||
|
|
348
|
+
JSON.stringify(session.externalChannel ?? null) !== JSON.stringify(incomingExternalChannel ?? null);
|
|
305
349
|
if (sessionDrifted) {
|
|
306
350
|
runtime.sessions.delete(sessionId);
|
|
307
351
|
try {
|
|
@@ -331,7 +375,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
331
375
|
}
|
|
332
376
|
if (!session) {
|
|
333
377
|
try {
|
|
334
|
-
session = await createActiveSession(runtime, sessionId, requestedAgentType, resolvedWorkDir, incomingAgentSid);
|
|
378
|
+
session = await createActiveSession(runtime, sessionId, requestedAgentType, resolvedWorkDir, incomingAgentSid, incomingExternalChannel);
|
|
335
379
|
}
|
|
336
380
|
catch (err) {
|
|
337
381
|
const message = err instanceof Error && err.message.startsWith('Unsupported agent:')
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { resolveShennianPath } from '../config/index.js';
|
|
6
|
+
import { mergeProjectedSessions } from './projection.js';
|
|
6
7
|
const QUEUE_FILE = resolveShennianPath('chat-queue.json');
|
|
7
8
|
function emptyQueue() {
|
|
8
9
|
return { sessions: {} };
|
|
@@ -56,6 +57,8 @@ function queueMessageFromParams(params) {
|
|
|
56
57
|
reasoningEffort: params.reasoningEffort ?? null,
|
|
57
58
|
clientMessageId: params.clientMessageId ?? null,
|
|
58
59
|
attachments: normalizeAttachments(params.attachments),
|
|
60
|
+
externalChannel: params.externalChannel ?? null,
|
|
61
|
+
origin: params.origin,
|
|
59
62
|
createdAt: timestamp,
|
|
60
63
|
updatedAt: timestamp,
|
|
61
64
|
};
|
|
@@ -76,6 +79,7 @@ export class ChatQueueManager {
|
|
|
76
79
|
async handleEnqueue(req) {
|
|
77
80
|
const runtime = this.opts.getRuntime();
|
|
78
81
|
const params = req.params;
|
|
82
|
+
mergeProjectedSessions(params.sessionListProjection);
|
|
79
83
|
if (!params.sessionId || !params.text || !params.agentType || !params.workDir) {
|
|
80
84
|
runtime.client.sendRes({
|
|
81
85
|
type: 'res',
|
|
@@ -208,6 +212,9 @@ export class ChatQueueManager {
|
|
|
208
212
|
this.broadcast(sessionId);
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
215
|
+
const dispatchMessage = next.origin === 'external'
|
|
216
|
+
? this.mergeExternalMessages(next, pending)
|
|
217
|
+
: next;
|
|
211
218
|
if (pending.length)
|
|
212
219
|
queue.sessions[sessionId] = pending;
|
|
213
220
|
else
|
|
@@ -216,12 +223,30 @@ export class ChatQueueManager {
|
|
|
216
223
|
this.broadcast(sessionId);
|
|
217
224
|
this.draining.add(sessionId);
|
|
218
225
|
try {
|
|
219
|
-
await this.dispatchQueuedMessage(
|
|
226
|
+
await this.dispatchQueuedMessage(dispatchMessage);
|
|
220
227
|
}
|
|
221
228
|
finally {
|
|
222
229
|
this.draining.delete(sessionId);
|
|
223
230
|
}
|
|
224
231
|
}
|
|
232
|
+
mergeExternalMessages(first, pending) {
|
|
233
|
+
const batch = [first];
|
|
234
|
+
while (pending[0]?.origin === 'external') {
|
|
235
|
+
batch.push(pending.shift());
|
|
236
|
+
}
|
|
237
|
+
if (batch.length === 1)
|
|
238
|
+
return first;
|
|
239
|
+
return {
|
|
240
|
+
...first,
|
|
241
|
+
id: `external-batch-${first.id}`,
|
|
242
|
+
text: batch.map((message, index) => {
|
|
243
|
+
const label = batch.length > 1 ? `外部消息 ${index + 1}/${batch.length}` : '外部消息';
|
|
244
|
+
return `${label}\n${message.text}`;
|
|
245
|
+
}).join('\n\n'),
|
|
246
|
+
attachments: batch.flatMap((message) => message.attachments ?? []),
|
|
247
|
+
updatedAt: nowIso(),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
225
250
|
async dispatchQueuedMessage(message) {
|
|
226
251
|
await this.opts.dispatchReq({
|
|
227
252
|
type: 'req',
|
|
@@ -237,6 +262,7 @@ export class ChatQueueManager {
|
|
|
237
262
|
reasoningEffort: message.reasoningEffort ?? undefined,
|
|
238
263
|
clientMessageId: message.clientMessageId ?? message.id,
|
|
239
264
|
attachments: message.attachments,
|
|
265
|
+
externalChannel: message.externalChannel,
|
|
240
266
|
},
|
|
241
267
|
});
|
|
242
268
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentType } from '@shennian/wire';
|
|
1
|
+
import type { AgentType, ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
2
|
import type { AgentAdapter } from '../agents/adapter.js';
|
|
3
3
|
import type { CliRelayClient } from '../relay/client.js';
|
|
4
4
|
import type { NativeSessionFusionService } from '../native-fusion/service.js';
|
|
@@ -17,6 +17,8 @@ export type ActiveSession = {
|
|
|
17
17
|
text: string;
|
|
18
18
|
thinking: boolean;
|
|
19
19
|
} | null;
|
|
20
|
+
externalChannel?: ExternalChannelSessionStatus | null;
|
|
21
|
+
externalChannelEnv?: NodeJS.ProcessEnv;
|
|
20
22
|
};
|
|
21
23
|
export type PendingTransfer = {
|
|
22
24
|
tempPath: string;
|