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.
@@ -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>;
@@ -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,2 @@
1
+ import type { ExternalChannelSessionStatus } from '@shennian/wire';
2
+ export declare function buildExternalChannelInstructions(channel?: ExternalChannelSessionStatus | null): string;
@@ -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
  }
@@ -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;
@@ -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: SYSTEM_PROMPT + `\n\n当前工作目录:${workDir}`,
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').update(path.resolve(workDir)).digest('hex').slice(0, 16);
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: (managerSessionId: string, event: ExternalMessageEvent) => void, createReplyTarget: (input: {
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(managerSessionId: string): Promise<{
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 managerSessionId = event.managerSessionId ?? config?.managerSessionId;
34
- if (!managerSessionId)
35
- throw new Error(`No manager bound for channel ${event.channelId}`);
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(managerSessionId, { ...event, replyTarget });
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 manager' };
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(managerSessionId) {
63
- const config = this.configs.list().find((channel) => channel.managerSessionId === managerSessionId && channel.enabled);
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 manager');
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
- managerSessionId: input.managerSessionId,
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.managerSessionId === input.managerSessionId && channel.type === input.type
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.managerSessionId === input.managerSessionId && config.type === input.type && config.enabled) {
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(input.managerSessionId, input.type, { includeSecret: true });
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 process.env.SHENNIAN_DESKTOP_CLI_SCRIPT ? 'desktop-managed' : 'global-cli';
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
- try {
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,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerExternalCommand(program: Command): void;
@@ -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.SHENNIAN_DESKTOP_CLI_SCRIPT ||
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('managerSessionId is required');
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
- workDir: manager.workDir,
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: String(body.text || ''),
448
- idempotencyKey: String(body.idempotencyKey || randomUUID()),
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
- if (!manager)
520
- return;
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
- void this.interruptAndResumeManager(manager, visibleMessage);
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.
@@ -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
- ...(session.agentType === 'manager'
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
- ...(input.agentType === 'manager'
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:')
@@ -15,6 +15,7 @@ export declare class ChatQueueManager {
15
15
  handleDelete(req: ReqFrame): Promise<void>;
16
16
  noteTerminal(sessionId: string): void;
17
17
  private drainNext;
18
+ private mergeExternalMessages;
18
19
  private dispatchQueuedMessage;
19
20
  private broadcast;
20
21
  }
@@ -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(next);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.52",
3
+ "version": "0.2.53",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {