shennian 0.2.47 → 0.2.49

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.
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { AgentAdapter, registerAgent } from './adapter.js';
4
4
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
5
5
  import { buildAgentProcessEnv } from '../agent-env.js';
6
+ import { buildPlatformInstructions } from './platform-instructions.js';
6
7
  export function normalizeClaudeModelId(modelId) {
7
8
  const trimmed = modelId?.trim();
8
9
  return trimmed || 'default';
@@ -34,8 +35,9 @@ export class ClaudeAdapter extends AgentAdapter {
34
35
  this.runId = randomUUID();
35
36
  this.resetRunState();
36
37
  const args = ['-p', text, '--output-format', 'stream-json', '--verbose'];
37
- if (this.options.systemPrompt) {
38
- args.push('--append-system-prompt', this.options.systemPrompt);
38
+ const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd());
39
+ if (systemPrompt) {
40
+ args.push('--append-system-prompt', systemPrompt);
39
41
  }
40
42
  if (this.options.hidden) {
41
43
  args.push('--name', 'Shennian Manager Substrate');
@@ -55,8 +57,9 @@ export class ClaudeAdapter extends AgentAdapter {
55
57
  this.runId = randomUUID();
56
58
  this.resetRunState();
57
59
  const resumeArgs = ['--resume', agentSessionId, '--output-format', 'stream-json', '--verbose'];
58
- if (this.options.systemPrompt) {
59
- resumeArgs.push('--append-system-prompt', this.options.systemPrompt);
60
+ const systemPrompt = this.options.systemPrompt ?? buildPlatformInstructions(this.workDir ?? process.cwd());
61
+ if (systemPrompt) {
62
+ resumeArgs.push('--append-system-prompt', systemPrompt);
60
63
  }
61
64
  if (this.options.hidden) {
62
65
  resumeArgs.push('--name', 'Shennian Manager Substrate');
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import { AgentAdapter, registerAgent } from './adapter.js';
6
6
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
7
7
  import { buildAgentProcessEnv } from '../agent-env.js';
8
+ import { ensurePlatformInstructionsFile } from './platform-instructions.js';
8
9
  export class CodexAdapter extends AgentAdapter {
9
10
  options;
10
11
  type = 'codex';
@@ -125,8 +126,9 @@ export class CodexAdapter extends AgentAdapter {
125
126
  return;
126
127
  }
127
128
  const args = ['app-server', '--listen', 'stdio://'];
128
- if (this.options.modelInstructionsFile) {
129
- args.push('-c', `model_instructions_file=${JSON.stringify(this.options.modelInstructionsFile)}`);
129
+ const modelInstructionsFile = this.options.modelInstructionsFile ?? ensurePlatformInstructionsFile(this.workDir ?? process.cwd());
130
+ if (modelInstructionsFile) {
131
+ args.push('-c', `model_instructions_file=${JSON.stringify(modelInstructionsFile)}`);
130
132
  }
131
133
  const proc = spawnResolvedCommand(spec, args, {
132
134
  cwd: this.workDir ?? undefined,
@@ -0,0 +1,3 @@
1
+ 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;
@@ -0,0 +1,50 @@
1
+ // @arch docs/architecture/cli/agent-adapters.md#平台输出规范
2
+ // @test src/__tests__/platform-instructions.test.ts
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { resolveShennianPath } from '../config/index.js';
7
+ export const PLATFORM_OUTPUT_INSTRUCTIONS = `## Shennian Output Instructions
8
+
9
+ 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:
10
+
11
+ - Use [filename.ext](</absolute/path/to/filename.ext>) for absolute Unix, macOS, or Windows paths.
12
+ - Use [filename.ext](<relative/path/to/filename.ext>) for paths relative to the current working directory.
13
+ - Do not use file:// URLs for local files.
14
+ - Do not put user-openable file paths only inside code blocks.
15
+ - Keep normal http:// and https:// links unchanged.`;
16
+ function readProjectAgentsMd(workDir) {
17
+ const agentsPath = path.join(workDir, 'AGENTS.md');
18
+ try {
19
+ return fs.readFileSync(agentsPath, 'utf8').trim();
20
+ }
21
+ catch {
22
+ return '';
23
+ }
24
+ }
25
+ export function buildPlatformInstructions(workDir) {
26
+ const projectInstructions = readProjectAgentsMd(workDir);
27
+ const sections = [
28
+ '# Shennian Agent Instructions',
29
+ projectInstructions
30
+ ? `## Project Instructions\n\n${projectInstructions}`
31
+ : '',
32
+ PLATFORM_OUTPUT_INSTRUCTIONS,
33
+ ].filter(Boolean);
34
+ return `${sections.join('\n\n')}\n`;
35
+ }
36
+ export function ensurePlatformInstructionsFile(workDir) {
37
+ const key = createHash('sha256').update(path.resolve(workDir)).digest('hex').slice(0, 16);
38
+ const filePath = resolveShennianPath('runtime', 'agent-instructions', `${key}.md`);
39
+ const content = buildPlatformInstructions(workDir);
40
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
41
+ try {
42
+ if (fs.readFileSync(filePath, 'utf8') === content)
43
+ return filePath;
44
+ }
45
+ catch {
46
+ // Write the generated instruction file below.
47
+ }
48
+ fs.writeFileSync(filePath, content);
49
+ return filePath;
50
+ }
@@ -59,4 +59,8 @@ export interface ExternalChannelAdapter {
59
59
  ok: boolean;
60
60
  message?: string;
61
61
  }>;
62
+ defaultConversation?(config: ExternalChannelConfig): Promise<{
63
+ conversationId: string;
64
+ conversationName?: string;
65
+ }>;
62
66
  }
@@ -24,6 +24,10 @@ export declare class ChannelRuntime {
24
24
  ok: false;
25
25
  error: string;
26
26
  }>;
27
+ getDefaultReplyTarget(managerSessionId: string): Promise<{
28
+ channelId: string;
29
+ conversationId: string;
30
+ }>;
27
31
  getManagerChannel(managerSessionId: string, type: ExternalChannelConfig['type'], opts?: {
28
32
  includeSecret?: boolean;
29
33
  }): ExternalChannelView | null;
@@ -59,6 +59,19 @@ 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);
64
+ if (!config)
65
+ throw new Error('No enabled external channel is bound to this manager');
66
+ const adapter = this.adapters.get(config.type);
67
+ if (!adapter?.defaultConversation)
68
+ throw new Error(`External channel ${config.type} has no default conversation`);
69
+ const conversation = await adapter.defaultConversation(config);
70
+ return {
71
+ channelId: config.id,
72
+ conversationId: conversation.conversationId,
73
+ };
74
+ }
62
75
  getManagerChannel(managerSessionId, type, opts = {}) {
63
76
  const configs = this.configs.list()
64
77
  .filter((channel) => channel.managerSessionId === managerSessionId && channel.type === type);
@@ -26,7 +26,9 @@ export class ChannelSecretRegistry {
26
26
  try {
27
27
  fs.chmodSync(SECRETS_PATH, 0o600);
28
28
  }
29
- catch { }
29
+ catch {
30
+ // Best effort on filesystems that do not support POSIX modes.
31
+ }
30
32
  }
31
33
  get(secretRef) {
32
34
  return this.load().secrets[secretRef];
@@ -32,6 +32,10 @@ export declare class ExternalWebSocketChannelAdapter implements ExternalChannelA
32
32
  ok: boolean;
33
33
  message?: string;
34
34
  }>;
35
+ defaultConversation(config: ExternalChannelConfig): Promise<{
36
+ conversationId: string;
37
+ conversationName?: string;
38
+ }>;
35
39
  private ensureConnection;
36
40
  private readSecret;
37
41
  private openConnection;
@@ -80,6 +80,33 @@ export class ExternalWebSocketChannelAdapter {
80
80
  ? { ok: true }
81
81
  : { ok: false, message: 'External websocket disconnected' };
82
82
  }
83
+ async defaultConversation(config) {
84
+ const secret = this.readSecret(config);
85
+ const url = new URL(secret.wsUrl);
86
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
87
+ url.pathname = url.pathname.replace(/\/ws\/?$/, '/subscription/self');
88
+ url.search = '';
89
+ const response = await fetch(url, {
90
+ headers: {
91
+ Authorization: `Bearer ${secret.token}`,
92
+ },
93
+ });
94
+ const data = await response.json().catch(() => null);
95
+ if (!response.ok || !data?.ok) {
96
+ throw new Error(data?.error || `External websocket metadata failed: ${response.status}`);
97
+ }
98
+ if (data.subscription?.allowSend === false) {
99
+ throw new Error('External websocket subscription does not allow send');
100
+ }
101
+ const conversationId = String(data.subscription?.defaultConversationId || '').trim();
102
+ if (!conversationId) {
103
+ throw new Error('External websocket subscription has no single default conversation');
104
+ }
105
+ return {
106
+ conversationId,
107
+ conversationName: String(data.subscription?.defaultConversationName || '').trim() || undefined,
108
+ };
109
+ }
83
110
  ensureConnection(config) {
84
111
  let conn = this.connections.get(config.id);
85
112
  if (!conn) {
@@ -550,7 +550,7 @@ export function installService() {
550
550
  const spec = resolveCurrentServiceLaunchSpec();
551
551
  if (spec.mode === 'direct' && isEphemeralCliPath(SHENNIAN_SCRIPT)) {
552
552
  console.warn(chalk.yellow('⚠ Warning: Current CLI path is temporary (npx). Auto-start may not work after reboot.\n' +
553
- ' Run `npm install -g shennian` for reliable auto-start.'));
553
+ ' Run `npm install -g shennian@latest` for reliable auto-start.'));
554
554
  }
555
555
  switch (platform) {
556
556
  case 'darwin': {
@@ -78,12 +78,60 @@ export function registerManagerCommand(program) {
78
78
  });
79
79
  sessions
80
80
  .command('send')
81
- .description('Send a message to a managed worker')
81
+ .description('Queue a message for a managed worker; runs immediately if idle')
82
82
  .requiredOption('--session-id <id>', 'Worker session id')
83
83
  .option('--message <text>', 'Message text')
84
84
  .option('--message-file <path>', 'Read message from file')
85
+ .option('--direct', 'Bypass queue and send immediately')
85
86
  .action(async (opts) => {
86
- await ipc('/sessions/send', { sessionId: opts.sessionId, message: readMessage(opts) });
87
+ await ipc('/sessions/send', {
88
+ sessionId: opts.sessionId,
89
+ message: readMessage(opts),
90
+ enqueue: !opts.direct,
91
+ });
92
+ console.log('ok');
93
+ });
94
+ const queue = sessions.command('queue').description('Inspect and edit pending worker messages');
95
+ queue
96
+ .command('list')
97
+ .description('List pending messages for a managed worker')
98
+ .requiredOption('--session-id <id>', 'Worker session id')
99
+ .option('--json', 'Print JSON')
100
+ .action(async (opts) => {
101
+ const result = await ipc('/sessions/queue', { sessionId: opts.sessionId });
102
+ if (opts.json)
103
+ printJson(result);
104
+ else {
105
+ const pending = result.queue?.pending ?? [];
106
+ for (const message of pending)
107
+ console.log(`${message.id}\t${message.text.replace(/\s+/g, ' ').slice(0, 120)}`);
108
+ }
109
+ });
110
+ queue
111
+ .command('edit')
112
+ .description('Edit a pending worker message')
113
+ .requiredOption('--session-id <id>', 'Worker session id')
114
+ .requiredOption('--message-id <id>', 'Queued message id')
115
+ .option('--message <text>', 'Replacement message text')
116
+ .option('--message-file <path>', 'Read replacement message from file')
117
+ .action(async (opts) => {
118
+ await ipc('/sessions/queue/edit', {
119
+ sessionId: opts.sessionId,
120
+ queueMessageId: opts.messageId,
121
+ message: readMessage(opts),
122
+ });
123
+ console.log('ok');
124
+ });
125
+ queue
126
+ .command('delete')
127
+ .description('Delete a pending worker message')
128
+ .requiredOption('--session-id <id>', 'Worker session id')
129
+ .requiredOption('--message-id <id>', 'Queued message id')
130
+ .action(async (opts) => {
131
+ await ipc('/sessions/queue/delete', {
132
+ sessionId: opts.sessionId,
133
+ queueMessageId: opts.messageId,
134
+ });
87
135
  console.log('ok');
88
136
  });
89
137
  sessions
@@ -117,6 +165,16 @@ export function registerManagerCommand(program) {
117
165
  console.log(result.path);
118
166
  });
119
167
  const external = manager.command('external').description('External channel tools');
168
+ const sendExternal = async (opts) => {
169
+ await ipc('/external/reply', opts);
170
+ console.log('ok');
171
+ };
172
+ external
173
+ .command('send')
174
+ .description('Send a message to the Manager-bound external channel')
175
+ .requiredOption('--text <text>', 'Message text')
176
+ .option('--idempotency-key <key>', 'Idempotency key')
177
+ .action(sendExternal);
120
178
  external
121
179
  .command('reply')
122
180
  .description('Send an explicit reply to an external channel')
@@ -125,10 +183,7 @@ export function registerManagerCommand(program) {
125
183
  .option('--conversation-id <id>', 'Conversation id')
126
184
  .requiredOption('--text <text>', 'Reply text')
127
185
  .option('--idempotency-key <key>', 'Idempotency key')
128
- .action(async (opts) => {
129
- await ipc('/external/reply', opts);
130
- console.log('ok');
131
- });
186
+ .action(sendExternal);
132
187
  const channel = manager.command('channel').description('Manager external channel config');
133
188
  channel
134
189
  .command('get')
@@ -1,2 +1,2 @@
1
- export declare const MANAGER_SYSTEM_PROMPT = "\u4F60\u662F\u795E\u5FF5\u91CC\u7684 Manager Agent\uFF0C\u662F\u5F53\u524D\u9879\u76EE\u7684\u7BA1\u7406\u8005\u3002\n\n\u4F60\u7684\u804C\u8D23\uFF1A\n- \u7406\u89E3\u7528\u6237\u76EE\u6807\u3002\n- \u62C6\u89E3\u4EFB\u52A1\u3002\n- \u521B\u5EFA\u3001\u6307\u6D3E\u3001\u89C2\u5BDF\u548C\u505C\u6B62\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u4E0B\u7684 worker Agent session\u3002\n- \u6C47\u603B worker \u7ED3\u679C\u3002\n- \u5224\u65AD\u662F\u5426\u9700\u8981\u7EE7\u7EED\u7B49\u5F85\u3001\u8C03\u6574\u5B89\u6392\u3001\u8BE2\u95EE\u7528\u6237\u6216\u9A8C\u6536\u3002\n- \u5728\u9879\u76EE .shennian/ \u76EE\u5F55\u4E0B\u7EF4\u62A4\u5FC5\u8981\u7684\u8BA1\u5212\u3001\u8BB0\u5F55\u548C\u9879\u76EE\u8BB0\u5FC6\u3002\n\n\u4F60\u7684\u8FB9\u754C\uFF1A\n- \u4E0D\u8981\u628A\u81EA\u5DF1\u5F53\u4F5C\u4E3B\u8981\u6267\u884C\u8005\u3002\n- \u4E0D\u8981\u76F4\u63A5\u7F16\u8F91\u4E1A\u52A1\u4EE3\u7801\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u4EB2\u81EA\u6267\u884C\u3002\n- \u53EF\u4EE5\u8BFB\u53D6\u6587\u4EF6\u3001\u641C\u7D22\u9879\u76EE\u548C\u68C0\u67E5\u4E0A\u4E0B\u6587\uFF0C\u4EE5\u4FBF\u505A\u5224\u65AD\u3002\n- \u9700\u8981\u4FEE\u6539\u4EE3\u7801\u3001\u8FD0\u884C\u6D4B\u8BD5\u3001\u8C03\u7814\u65B9\u6848\u65F6\uFF0C\u4F18\u5148\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u521B\u5EFA worker \u540E\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u5F53\u573A\u7EE7\u7EED\u8C03\u5EA6\uFF0C\u5426\u5219\u56DE\u590D\u7528\u6237\u5DF2\u5B89\u6392\u5E76\u7ED3\u675F\u5F53\u524D turn\uFF1B\u4E0D\u8981\u4E3B\u52A8\u8F6E\u8BE2 worker \u72B6\u6001\uFF0C\u795E\u5FF5\u4F1A\u5728 worker \u7EC8\u6001\u6216\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u91CD\u65B0\u5524\u9192\u4F60\u3002\n- sessions read \u8FD4\u56DE\u7684\u662F\u7ED9\u7BA1\u7406\u8005\u770B\u7684\u7B80\u6D01\u8FDB\u5C55\u3001\u5DE5\u5177\u6458\u8981\u548C\u6700\u7EC8\u7ED3\u679C\uFF0C\u4E0D\u662F\u539F\u59CB\u6D41\u5F0F token\uFF1B\u4E0D\u8981\u8981\u6C42\u8BFB\u53D6\u6216\u8F6C\u8FF0\u5B8C\u6574\u6D41\u5F0F\u65E5\u5FD7\u3002\n- \u53EA\u80FD\u7BA1\u7406\u4E0E\u4F60\u5904\u4E8E\u540C\u4E00\u53F0\u673A\u5668\u3001\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u7684\u4F1A\u8BDD\uFF1B\u4E0D\u8981\u8DE8\u673A\u5668\u6216\u8DE8\u9879\u76EE\u8C03\u5EA6\u3002\n- \u4E0D\u8981\u672A\u7ECF\u7528\u6237\u5141\u8BB8 commit\u3001push\u3001deploy\u3002\n- \u4E0D\u8981\u65E0\u9650\u5FAA\u73AF\uFF1B\u6CA1\u6709\u660E\u786E\u4E0B\u4E00\u6B65\u65F6\u8BE2\u95EE\u7528\u6237\u6216\u7ED3\u675F\u5F53\u524D turn \u7B49\u5F85\u7CFB\u7EDF\u4E8B\u4EF6\u3002\n- \u4E0D\u8981\u81EA\u5DF1\u8BBE\u7F6E\u5B9A\u65F6\u5524\u9192\uFF1B\u795E\u5FF5\u4F1A\u5728\u7528\u6237\u6D88\u606F\u3001worker \u7EC8\u6001\u6216 worker \u957F\u8FD0\u884C\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u5524\u9192\u4F60\u3002\n- \u5916\u90E8\u6D88\u606F\u901A\u9053\u4E8B\u4EF6\u4F1A\u50CF\u666E\u901A\u7528\u6237\u6D88\u606F\u4E00\u6837\u9001\u8FBE\uFF0C\u683C\u5F0F\u7C7B\u4F3C\u201C\u5916\u90E8\u6D88\u606F / \u53D1\u9001\u4EBA: \u5185\u5BB9\u201D\uFF1B\u5185\u5BB9\u53EF\u80FD\u662F\u5916\u90E8\u7FA4\u5185\u4E00\u6BB5\u65F6\u95F4\u7684\u5408\u5E76\u6D88\u606F\uFF0C\u6BCF\u6761\u4F1A\u5305\u542B\u5916\u90E8\u5E73\u53F0\u7528\u6237 ID \u548C\u65F6\u95F4\u6233\u3002\u8FD9\u91CC\u7684\u7528\u6237 ID \u662F\u4F01\u4E1A\u5FAE\u4FE1/\u5916\u90E8\u5E73\u53F0 ID\uFF0C\u4E0D\u662F\u795E\u5FF5\u7528\u6237 ID\u3002\n- \u5916\u90E8\u6D88\u606F\u53EF\u80FD\u5305\u542B\u56FE\u7247\u3001\u89C6\u9891\u6216\u6587\u4EF6 URL\u3002\u9700\u8981\u7406\u89E3\u5A92\u4F53\u5185\u5BB9\u65F6\uFF0C\u5148\u57FA\u4E8E URL/\u9644\u4EF6\u4FE1\u606F\u5224\u65AD\u662F\u5426\u80FD\u76F4\u63A5\u5904\u7406\uFF1B\u4E0D\u80FD\u5904\u7406\u65F6\uFF0C\u7B80\u6D01\u8BF4\u660E\u9700\u8981\u7528\u6237\u8865\u5145\u6216\u5B89\u6392 worker \u67E5\u770B\u3002\n- \u4F60\u5FC5\u987B\u663E\u5F0F\u51B3\u5B9A\u662F\u5426\u4EE5\u53CA\u5982\u4F55\u56DE\u590D\u5916\u90E8\u7528\u6237\u3002\u4E0E\u5F53\u524D\u9879\u76EE\u6216\u7528\u6237\u76EE\u6807\u65E0\u5173\u7684\u5916\u90E8\u6D88\u606F\u53EF\u4EE5\u5FFD\u7565\uFF0C\u4E0D\u8981\u4E3A\u4E86\u793C\u8C8C\u5F3A\u884C\u56DE\u590D\u3002\n- \u5982\u679C\u5916\u90E8\u8BF7\u6C42\u9700\u8981\u8F83\u957F\u65F6\u95F4\u5904\u7406\uFF0C\u5148\u7528\u5916\u90E8\u56DE\u590D\u7B80\u77ED\u786E\u8BA4\u201C\u6536\u5230\uFF0C\u6211\u5148\u5904\u7406/\u5B89\u6392\u4E00\u4E0B\u201D\uFF0C\u518D\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u5982\u679C\u5F53\u524D Manager \u914D\u7F6E\u4E86\u5916\u90E8\u6D88\u606F\u901A\u9053\uFF0C\u4F60\u53EF\u4EE5\u628A\u5B83\u5F53\u6210\u4E00\u4E2A\u53EF\u7528\u7684\u6536\u53D1\u6E20\u9053\u3002\u56DE\u590D\u6536\u5230\u7684\u5916\u90E8\u6D88\u606F\u65F6\uFF0C\u59CB\u7EC8\u76F4\u63A5\u8C03\u7528 shennian manager external reply --text \"<\u56DE\u590D\u5185\u5BB9>\"\uFF1B\u4E0D\u8981\u4E3A\u4E86\u56DE\u590D\u5916\u90E8\u6D88\u606F\u5148\u67E5\u8BE2 channel\uFF0C\u4E5F\u4E0D\u8981\u624B\u5DE5\u62FC channelId \u6216 conversationId\u3002\n- \u53EA\u6709\u5728\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u4E3B\u52A8\u53D1\u5230\u67D0\u4E2A\u975E\u6700\u8FD1\u5916\u90E8\u6D88\u606F\u7684\u6307\u5B9A\u5916\u90E8\u4F1A\u8BDD\u65F6\uFF0C\u624D\u7528 shennian manager channel get --json \u67E5\u770B channelId\u3001enabled\u3001canReply\uFF0C\u518D\u7528 channelId + conversationId \u8C03\u7528 external reply\u3002\n- \u4E0D\u8981\u628A\u6240\u6709\u7EC6\u8282\u585E\u8FDB\u5BF9\u8BDD\u4E0A\u4E0B\u6587\uFF1B\u9700\u8981\u957F\u671F\u4FDD\u5B58\u7684\u4FE1\u606F\u5199\u5230\u9879\u76EE .shennian/ \u4E0B\u3002\n\n\u9700\u8981\u7BA1\u7406 worker \u6216\u5916\u90E8\u901A\u9053\u65F6\uFF0C\u4F7F\u7528\u672C\u5730\u547D\u4EE4\uFF1A\n- shennian manager sessions list --json\n- shennian manager sessions start --agent codex --workdir <path> --message <text>\n- shennian manager sessions send --session-id <id> --message <text>\n- shennian manager sessions stop --session-id <id>\n- shennian manager sessions read --session-id <id> --limit 200 --json\n- shennian manager memory path\n- shennian manager external reply --text <text>\n- shennian manager external reply --reply-target <id> --text <text>\n- shennian manager external reply --channel-id <id> --conversation-id <id> --text <text>\n\n\u8FD9\u4E9B\u547D\u4EE4\u5DF2\u7ECF\u7531\u795E\u5FF5\u6CE8\u5165\u5F53\u524D Manager \u8EAB\u4EFD\u548C\u540C\u9879\u76EE\u6743\u9650\u8FB9\u754C\u3002\u4E0D\u8981\u5C1D\u8BD5\u4F2A\u9020 Manager session id\u3002";
1
+ export declare const MANAGER_SYSTEM_PROMPT = "\u4F60\u662F\u9879\u76EE\u7ECF\u7406\uFF0C\u662F\u5F53\u524D\u9879\u76EE\u7684\u7BA1\u7406\u8005\u3002\n\n\u4F60\u7684\u804C\u8D23\uFF1A\n- \u7406\u89E3\u7528\u6237\u76EE\u6807\u3002\n- \u62C6\u89E3\u4EFB\u52A1\u3002\n- \u521B\u5EFA\u3001\u6307\u6D3E\u3001\u89C2\u5BDF\u548C\u505C\u6B62\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u4E0B\u7684 worker Agent session\u3002\n- \u6C47\u603B worker \u7ED3\u679C\u3002\n- \u5224\u65AD\u662F\u5426\u9700\u8981\u7EE7\u7EED\u7B49\u5F85\u3001\u8C03\u6574\u5B89\u6392\u3001\u8BE2\u95EE\u7528\u6237\u6216\u9A8C\u6536\u3002\n- \u5728\u9879\u76EE .shennian/ \u76EE\u5F55\u4E0B\u7EF4\u62A4\u5FC5\u8981\u7684\u8BA1\u5212\u3001\u8BB0\u5F55\u548C\u9879\u76EE\u8BB0\u5FC6\u3002\n\n\u4F60\u7684\u8FB9\u754C\uFF1A\n- \u4E0D\u8981\u628A\u81EA\u5DF1\u5F53\u4F5C\u4E3B\u8981\u6267\u884C\u8005\u3002\n- \u4E0D\u8981\u76F4\u63A5\u7F16\u8F91\u4E1A\u52A1\u4EE3\u7801\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u4EB2\u81EA\u6267\u884C\u3002\n- \u53EF\u4EE5\u8BFB\u53D6\u6587\u4EF6\u3001\u641C\u7D22\u9879\u76EE\u548C\u68C0\u67E5\u4E0A\u4E0B\u6587\uFF0C\u4EE5\u4FBF\u505A\u5224\u65AD\u3002\n- \u9700\u8981\u4FEE\u6539\u4EE3\u7801\u3001\u8FD0\u884C\u6D4B\u8BD5\u3001\u8C03\u7814\u65B9\u6848\u65F6\uFF0C\u4F18\u5148\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u521B\u5EFA worker \u540E\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u5F53\u573A\u7EE7\u7EED\u8C03\u5EA6\uFF0C\u5426\u5219\u56DE\u590D\u7528\u6237\u5DF2\u5B89\u6392\u5E76\u7ED3\u675F\u5F53\u524D turn\uFF1B\u4E0D\u8981\u4E3B\u52A8\u8F6E\u8BE2 worker \u72B6\u6001\uFF0C\u795E\u5FF5\u4F1A\u5728 worker \u7EC8\u6001\u6216\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u91CD\u65B0\u5524\u9192\u4F60\u3002\n- sessions read \u8FD4\u56DE\u7684\u662F\u7ED9\u7BA1\u7406\u8005\u770B\u7684\u7B80\u6D01\u8FDB\u5C55\u3001\u5DE5\u5177\u6458\u8981\u548C\u6700\u7EC8\u7ED3\u679C\uFF0C\u4E0D\u662F\u539F\u59CB\u6D41\u5F0F token\uFF1B\u4E0D\u8981\u8981\u6C42\u8BFB\u53D6\u6216\u8F6C\u8FF0\u5B8C\u6574\u6D41\u5F0F\u65E5\u5FD7\u3002\n- \u53EA\u80FD\u7BA1\u7406\u4E0E\u4F60\u5904\u4E8E\u540C\u4E00\u53F0\u673A\u5668\u3001\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u7684\u4F1A\u8BDD\uFF1B\u4E0D\u8981\u8DE8\u673A\u5668\u6216\u8DE8\u9879\u76EE\u8C03\u5EA6\u3002\n- \u4E0D\u8981\u65E0\u9650\u5FAA\u73AF\uFF1B\u6CA1\u6709\u660E\u786E\u4E0B\u4E00\u6B65\u65F6\u8BE2\u95EE\u7528\u6237\u6216\u7ED3\u675F\u5F53\u524D turn \u7B49\u5F85\u7CFB\u7EDF\u4E8B\u4EF6\u3002\n- \u4E0D\u8981\u81EA\u5DF1\u8BBE\u7F6E\u5B9A\u65F6\u5524\u9192\uFF1B\u795E\u5FF5\u4F1A\u5728\u7528\u6237\u6D88\u606F\u3001worker \u7EC8\u6001\u6216 worker \u957F\u8FD0\u884C\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u5524\u9192\u4F60\u3002\n- \u5916\u90E8\u6D88\u606F\u901A\u9053\u4E8B\u4EF6\u4F1A\u50CF\u666E\u901A\u7528\u6237\u6D88\u606F\u4E00\u6837\u9001\u8FBE\uFF0C\u683C\u5F0F\u7C7B\u4F3C\u201C\u5916\u90E8\u6D88\u606F / \u53D1\u9001\u4EBA\\n\u5185\u5BB9\u201D\uFF0C\u53EF\u80FD\u662F\u5408\u5E76\u6D88\u606F\uFF0C\u4E5F\u53EF\u80FD\u5305\u542B\u56FE\u7247\u3001\u89C6\u9891\u6216\u6587\u4EF6 URL\u3002\n- \u5BF9\u5916\u4F60\u662F\u5F53\u524D\u9879\u76EE\u7684\u9879\u76EE\u7ECF\u7406\uFF0C\u4E0D\u8981\u81EA\u79F0\u795E\u5FF5\u3001Manager Agent \u6216 worker\uFF0C\u4E5F\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u8C03\u5EA6\u673A\u5236\uFF1B\u53EA\u5728\u9700\u8981\u65F6\u7528\u201C\u6211\u8FD9\u8FB9/\u6211\u4EEC\u8FD9\u8FB9\u201D\u6C9F\u901A\u3002\n- \u5916\u90E8\u6D88\u606F\u4E0E\u5F53\u524D\u9879\u76EE\u65E0\u5173\u65F6\u53EF\u4EE5\u5FFD\u7565\uFF1B\u9700\u8981\u8F83\u957F\u5904\u7406\u65F6\uFF0C\u5148\u7B80\u77ED\u56DE\u590D\u201C\u6536\u5230\uFF0C\u6211\u5148\u5904\u7406/\u5B89\u6392\u4E00\u4E0B\u201D\uFF0C\u518D\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u5411\u5916\u90E8\u7FA4\u53D1\u6D88\u606F\u4E00\u5F8B\u8C03\u7528 shennian manager external send --text \"<\u6D88\u606F\u5185\u5BB9>\"\n- \u4E0D\u8981\u628A\u6240\u6709\u7EC6\u8282\u585E\u8FDB\u5BF9\u8BDD\u4E0A\u4E0B\u6587\uFF1B\u9700\u8981\u957F\u671F\u4FDD\u5B58\u7684\u4FE1\u606F\u5199\u5230\u9879\u76EE .shennian/ \u4E0B\u3002\n\n\u9700\u8981\u7BA1\u7406 worker \u6216\u5916\u90E8\u901A\u9053\u65F6\uFF0C\u4F7F\u7528\u672C\u5730\u547D\u4EE4\uFF1A\n- shennian manager sessions list --json\n- shennian manager sessions start --agent codex --workdir <path> --message <text>\n- shennian manager sessions send --session-id <id> --message <text>\n- shennian manager sessions send --session-id <id> --message <text> --direct\n- shennian manager sessions queue list --session-id <id> --json\n- shennian manager sessions queue edit --session-id <id> --message-id <queueMessageId> --message <text>\n- shennian manager sessions queue delete --session-id <id> --message-id <queueMessageId>\n- shennian manager sessions stop --session-id <id>\n- shennian manager sessions read --session-id <id> --limit 200 --json\n- shennian manager memory path\n- shennian manager external send --text <text>\n\n\u9ED8\u8BA4\u7528 sessions send \u6392\u961F\u53D1\u9001 worker \u6D88\u606F\uFF1Aworker \u6B63\u5FD9\u65F6\u6D88\u606F\u4F1A\u5728\u672C\u673A daemon \u961F\u5217\u91CC\u7B49\u5F85\uFF0Cworker \u7A7A\u95F2\u65F6\u81EA\u52A8\u6267\u884C\u3002\u961F\u5217\u91CC\u7684\u672A\u6267\u884C\u6D88\u606F\u53EF\u4EE5 list/edit/delete\uFF1B\u5DF2\u7ECF\u5F00\u59CB\u6267\u884C\u7684\u6D88\u606F\u4E0D\u80FD\u7F16\u8F91\u6216\u5220\u9664\uFF0C\u53EA\u80FD stop \u540E\u91CD\u65B0\u53D1\u9001\u3002\u53EA\u6709\u660E\u786E\u9700\u8981\u6253\u65AD\u987A\u5E8F\u65F6\u624D\u4F7F\u7528 --direct\u3002\n\n\u8FD9\u4E9B\u547D\u4EE4\u5DF2\u7ECF\u7531\u795E\u5FF5\u6CE8\u5165\u5F53\u524D Manager \u8EAB\u4EFD\u548C\u540C\u9879\u76EE\u6743\u9650\u8FB9\u754C\u3002\u4E0D\u8981\u5C1D\u8BD5\u4F2A\u9020 Manager session id\u3002";
2
2
  export declare function buildManagerPrompt(userText: string): string;
@@ -1,6 +1,6 @@
1
1
  // @arch docs/features/manager-agent.md
2
2
  // @test src/__tests__/manager-runtime.test.ts
3
- export const MANAGER_SYSTEM_PROMPT = `你是神念里的 Manager Agent,是当前项目的管理者。
3
+ export const MANAGER_SYSTEM_PROMPT = `你是项目经理,是当前项目的管理者。
4
4
 
5
5
  你的职责:
6
6
  - 理解用户目标。
@@ -18,27 +18,28 @@ export const MANAGER_SYSTEM_PROMPT = `你是神念里的 Manager Agent,是当
18
18
  - 创建 worker 后,除非用户明确要求你当场继续调度,否则回复用户已安排并结束当前 turn;不要主动轮询 worker 状态,神念会在 worker 终态或健康摘要到来时重新唤醒你。
19
19
  - sessions read 返回的是给管理者看的简洁进展、工具摘要和最终结果,不是原始流式 token;不要要求读取或转述完整流式日志。
20
20
  - 只能管理与你处于同一台机器、同一项目目录的会话;不要跨机器或跨项目调度。
21
- - 不要未经用户允许 commit、push、deploy。
22
21
  - 不要无限循环;没有明确下一步时询问用户或结束当前 turn 等待系统事件。
23
22
  - 不要自己设置定时唤醒;神念会在用户消息、worker 终态或 worker 长运行健康摘要到来时唤醒你。
24
- - 外部消息通道事件会像普通用户消息一样送达,格式类似“外部消息 / 发送人: 内容”;内容可能是外部群内一段时间的合并消息,每条会包含外部平台用户 ID 和时间戳。这里的用户 ID 是企业微信/外部平台 ID,不是神念用户 ID
25
- - 外部消息可能包含图片、视频或文件 URL。需要理解媒体内容时,先基于 URL/附件信息判断是否能直接处理;不能处理时,简洁说明需要用户补充或安排 worker 查看。
26
- - 你必须显式决定是否以及如何回复外部用户。与当前项目或用户目标无关的外部消息可以忽略,不要为了礼貌强行回复。
27
- - 如果外部请求需要较长时间处理,先用外部回复简短确认“收到,我先处理/安排一下”,再创建或指派 worker。
28
- - 如果当前 Manager 配置了外部消息通道,你可以把它当成一个可用的收发渠道。回复收到的外部消息时,始终直接调用 shennian manager external reply --text "<回复内容>";不要为了回复外部消息先查询 channel,也不要手工拼 channelId 或 conversationId。
29
- - 只有在用户明确要求你主动发到某个非最近外部消息的指定外部会话时,才用 shennian manager channel get --json 查看 channelId、enabled、canReply,再用 channelId + conversationId 调用 external reply。
23
+ - 外部消息通道事件会像普通用户消息一样送达,格式类似“外部消息 / 发送人\\n内容”,可能是合并消息,也可能包含图片、视频或文件 URL
24
+ - 对外你是当前项目的项目经理,不要自称神念、Manager Agent worker,也不要解释内部调度机制;只在需要时用“我这边/我们这边”沟通。
25
+ - 外部消息与当前项目无关时可以忽略;需要较长处理时,先简短回复“收到,我先处理/安排一下”,再创建或指派 worker。
26
+ - 向外部群发消息一律调用 shennian manager external send --text "<消息内容>"
30
27
  - 不要把所有细节塞进对话上下文;需要长期保存的信息写到项目 .shennian/ 下。
31
28
 
32
29
  需要管理 worker 或外部通道时,使用本地命令:
33
30
  - shennian manager sessions list --json
34
31
  - shennian manager sessions start --agent codex --workdir <path> --message <text>
35
32
  - shennian manager sessions send --session-id <id> --message <text>
33
+ - shennian manager sessions send --session-id <id> --message <text> --direct
34
+ - shennian manager sessions queue list --session-id <id> --json
35
+ - shennian manager sessions queue edit --session-id <id> --message-id <queueMessageId> --message <text>
36
+ - shennian manager sessions queue delete --session-id <id> --message-id <queueMessageId>
36
37
  - shennian manager sessions stop --session-id <id>
37
38
  - shennian manager sessions read --session-id <id> --limit 200 --json
38
39
  - shennian manager memory path
39
- - shennian manager external reply --text <text>
40
- - shennian manager external reply --reply-target <id> --text <text>
41
- - shennian manager external reply --channel-id <id> --conversation-id <id> --text <text>
40
+ - shennian manager external send --text <text>
41
+
42
+ 默认用 sessions send 排队发送 worker 消息:worker 正忙时消息会在本机 daemon 队列里等待,worker 空闲时自动执行。队列里的未执行消息可以 list/edit/delete;已经开始执行的消息不能编辑或删除,只能 stop 后重新发送。只有明确需要打断顺序时才使用 --direct。
42
43
 
43
44
  这些命令已经由神念注入当前 Manager 身份和同项目权限边界。不要尝试伪造 Manager session id。`;
44
45
  export function buildManagerPrompt(userText) {
@@ -41,6 +41,7 @@ export declare class ManagerRuntimeService {
41
41
  private broadcastManagerChannelStatus;
42
42
  private handleIpc;
43
43
  private dispatchChatSend;
44
+ private dispatchChatEnqueue;
44
45
  private wakeManagerForWorker;
45
46
  private handleExternalMessage;
46
47
  private scanWorkerHealth;
@@ -341,13 +341,63 @@ export class ManagerRuntimeService {
341
341
  if (url.pathname === '/sessions/send') {
342
342
  const sessionId = String(body.sessionId || '');
343
343
  const message = String(body.message || '');
344
+ const enqueue = body.enqueue === undefined ? true : Boolean(body.enqueue);
344
345
  const worker = this.registry.getWorkerForManager(managerSessionId, sessionId);
345
346
  if (!worker)
346
347
  throw new Error('Worker not found in this manager scope');
347
- await this.dispatchChatSend(worker.sessionId, worker.agentType, worker.workDir, message, worker.agentSessionId ?? null, String(body.modelId || ''));
348
+ if (enqueue) {
349
+ await this.dispatchChatEnqueue(worker.sessionId, worker.agentType, worker.workDir, message, worker.agentSessionId ?? null, String(body.modelId || ''));
350
+ }
351
+ else {
352
+ await this.dispatchChatSend(worker.sessionId, worker.agentType, worker.workDir, message, worker.agentSessionId ?? null, String(body.modelId || ''));
353
+ }
348
354
  json(res, 200, { ok: true });
349
355
  return;
350
356
  }
357
+ if (url.pathname === '/sessions/queue') {
358
+ const sessionId = String(body.sessionId || '');
359
+ const worker = this.registry.getWorkerForManager(managerSessionId, sessionId);
360
+ if (!worker)
361
+ throw new Error('Worker not found in this manager scope');
362
+ const queue = this.opts.getRuntime().chatQueue?.getSnapshot(sessionId);
363
+ json(res, 200, { ok: true, queue });
364
+ return;
365
+ }
366
+ if (url.pathname === '/sessions/queue/edit') {
367
+ const sessionId = String(body.sessionId || '');
368
+ const worker = this.registry.getWorkerForManager(managerSessionId, sessionId);
369
+ if (!worker)
370
+ throw new Error('Worker not found in this manager scope');
371
+ await this.opts.dispatchReq({
372
+ type: 'req',
373
+ id: `manager-queue-edit-${randomUUID()}`,
374
+ method: 'chat.queue.edit',
375
+ params: {
376
+ sessionId,
377
+ queueMessageId: String(body.queueMessageId || body.messageId || ''),
378
+ text: String(body.message || body.text || ''),
379
+ },
380
+ });
381
+ json(res, 200, { ok: true, queue: this.opts.getRuntime().chatQueue?.getSnapshot(sessionId) });
382
+ return;
383
+ }
384
+ if (url.pathname === '/sessions/queue/delete') {
385
+ const sessionId = String(body.sessionId || '');
386
+ const worker = this.registry.getWorkerForManager(managerSessionId, sessionId);
387
+ if (!worker)
388
+ throw new Error('Worker not found in this manager scope');
389
+ await this.opts.dispatchReq({
390
+ type: 'req',
391
+ id: `manager-queue-delete-${randomUUID()}`,
392
+ method: 'chat.queue.delete',
393
+ params: {
394
+ sessionId,
395
+ queueMessageId: String(body.queueMessageId || body.messageId || ''),
396
+ },
397
+ });
398
+ json(res, 200, { ok: true, queue: this.opts.getRuntime().chatQueue?.getSnapshot(sessionId) });
399
+ return;
400
+ }
351
401
  if (url.pathname === '/sessions/stop') {
352
402
  const sessionId = String(body.sessionId || '');
353
403
  const worker = this.registry.getWorkerForManager(managerSessionId, sessionId);
@@ -380,11 +430,15 @@ export class ManagerRuntimeService {
380
430
  const replyTarget = typeof body.replyTarget === 'string'
381
431
  ? this.registry.getReplyTarget(body.replyTarget)
382
432
  : this.registry.getLatestReplyTargetForManager(managerSessionId);
383
- const channelId = replyTarget?.channelId ?? String(body.channelId || '');
384
- const conversationId = replyTarget?.conversationId ?? String(body.conversationId || '');
385
- if (!channelId || !conversationId) {
386
- throw new Error('No external reply target is available; pass --channel-id and --conversation-id explicitly');
387
- }
433
+ const explicitChannelId = String(body.channelId || '');
434
+ const explicitConversationId = String(body.conversationId || '');
435
+ const defaultTarget = !replyTarget && (!explicitChannelId || !explicitConversationId)
436
+ ? await this.channelRuntime.getDefaultReplyTarget(managerSessionId)
437
+ : null;
438
+ const channelId = replyTarget?.channelId || explicitChannelId || defaultTarget?.channelId || '';
439
+ const conversationId = replyTarget?.conversationId || explicitConversationId || defaultTarget?.conversationId || '';
440
+ if (!channelId || !conversationId)
441
+ throw new Error('No external channel target is available for this Manager');
388
442
  const result = await this.channelRuntime.reply({
389
443
  managerSessionId,
390
444
  channelId,
@@ -438,6 +492,14 @@ export class ManagerRuntimeService {
438
492
  params: { sessionId, text, agentType, workDir, agentSessionId, modelId },
439
493
  });
440
494
  }
495
+ async dispatchChatEnqueue(sessionId, agentType, workDir, text, agentSessionId, modelId) {
496
+ await this.opts.dispatchReq({
497
+ type: 'req',
498
+ id: `manager-enqueue-${randomUUID()}`,
499
+ method: 'chat.enqueue',
500
+ params: { sessionId, text, agentType, workDir, agentSessionId, modelId },
501
+ });
502
+ }
441
503
  wakeManagerForWorker(managerSessionId, worker, state, message) {
442
504
  const manager = this.registry.getManager(managerSessionId);
443
505
  if (!manager)
@@ -214,6 +214,7 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
214
214
  activeSession?.currentRunId === event.runId) {
215
215
  activeSession.currentRunId = null;
216
216
  activeSession.nextEventSeq = 0;
217
+ runtime.chatQueue?.noteTerminal(sessionId);
217
218
  }
218
219
  sendAgentEvent(event, extra);
219
220
  });
@@ -21,6 +21,7 @@ export declare class SessionManager {
21
21
  /** In-flight chunked uploads: transferId → metadata */
22
22
  private pendingTransfers;
23
23
  private managerRuntime;
24
+ private chatQueue;
24
25
  constructor(client: CliRelayClient, nativeFusion?: NativeSessionFusionService | null, cliVersion?: string | undefined);
25
26
  private getRuntime;
26
27
  private reloadCustomAgents;
@@ -11,6 +11,7 @@ import { handleChatAbort, handleChatSend } from './handlers/chat.js';
11
11
  import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
12
12
  import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
13
13
  import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
14
+ import { ChatQueueManager } from './queue.js';
14
15
  // Side-effect imports to register built-in agent adapters.
15
16
  import '../agents/claude.js';
16
17
  import '../agents/codex.js';
@@ -61,6 +62,7 @@ export class SessionManager {
61
62
  /** In-flight chunked uploads: transferId → metadata */
62
63
  pendingTransfers = new Map();
63
64
  managerRuntime;
65
+ chatQueue;
64
66
  constructor(client, nativeFusion = null, cliVersion) {
65
67
  this.client = client;
66
68
  this.nativeFusion = nativeFusion;
@@ -69,6 +71,10 @@ export class SessionManager {
69
71
  getRuntime: () => this.getRuntime(),
70
72
  dispatchReq: (req) => this.handleReq(req),
71
73
  });
74
+ this.chatQueue = new ChatQueueManager({
75
+ getRuntime: () => this.getRuntime(),
76
+ dispatchReq: (req) => this.handleReq(req),
77
+ });
72
78
  setManagerRuntimeService(this.managerRuntime);
73
79
  void this.managerRuntime.start();
74
80
  this.reloadCustomAgents();
@@ -85,6 +91,7 @@ export class SessionManager {
85
91
  evictIdleSessions: () => this.evictIdleSessions(),
86
92
  nativeFusion: this.nativeFusion,
87
93
  managerRuntime: this.managerRuntime,
94
+ chatQueue: this.chatQueue,
88
95
  };
89
96
  }
90
97
  reloadCustomAgents() {
@@ -105,6 +112,18 @@ export class SessionManager {
105
112
  case 'chat.send':
106
113
  await handleChatSend(runtime, req);
107
114
  break;
115
+ case 'chat.enqueue':
116
+ await this.chatQueue.handleEnqueue(req);
117
+ break;
118
+ case 'chat.queue.get':
119
+ await this.chatQueue.handleGet(req);
120
+ break;
121
+ case 'chat.queue.edit':
122
+ await this.chatQueue.handleEdit(req);
123
+ break;
124
+ case 'chat.queue.delete':
125
+ await this.chatQueue.handleDelete(req);
126
+ break;
108
127
  case 'chat.abort':
109
128
  await handleChatAbort(runtime, req);
110
129
  break;
@@ -0,0 +1,20 @@
1
+ import type { ChatQueueSnapshot, ReqFrame } from '@shennian/wire';
2
+ import type { LocalReqDispatcher } from '../manager/runtime.js';
3
+ import type { SessionManagerRuntime } from './types.js';
4
+ export declare class ChatQueueManager {
5
+ private opts;
6
+ private draining;
7
+ constructor(opts: {
8
+ getRuntime: () => SessionManagerRuntime;
9
+ dispatchReq: LocalReqDispatcher;
10
+ });
11
+ getSnapshot(sessionId: string): ChatQueueSnapshot;
12
+ handleEnqueue(req: ReqFrame): Promise<void>;
13
+ handleGet(req: ReqFrame): Promise<void>;
14
+ handleEdit(req: ReqFrame): Promise<void>;
15
+ handleDelete(req: ReqFrame): Promise<void>;
16
+ noteTerminal(sessionId: string): void;
17
+ private drainNext;
18
+ private dispatchQueuedMessage;
19
+ private broadcast;
20
+ }
@@ -0,0 +1,246 @@
1
+ // @arch docs/features/session-message-queue.md
2
+ // @test src/__tests__/session-manager.test.ts
3
+ import fs from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { resolveShennianPath } from '../config/index.js';
6
+ const QUEUE_FILE = resolveShennianPath('chat-queue.json');
7
+ function emptyQueue() {
8
+ return { sessions: {} };
9
+ }
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+ function readQueue() {
14
+ try {
15
+ const parsed = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
16
+ return {
17
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
18
+ };
19
+ }
20
+ catch {
21
+ return emptyQueue();
22
+ }
23
+ }
24
+ function writeQueue(queue) {
25
+ fs.mkdirSync(resolveShennianPath(''), { recursive: true });
26
+ fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
27
+ }
28
+ function normalizeAttachments(value) {
29
+ if (!Array.isArray(value))
30
+ return undefined;
31
+ const attachments = value
32
+ .map((item) => {
33
+ if (!item || typeof item !== 'object')
34
+ return null;
35
+ const entry = item;
36
+ const attachment = {
37
+ path: typeof entry.path === 'string' ? entry.path : '',
38
+ name: typeof entry.name === 'string' ? entry.name : '',
39
+ mimeType: typeof entry.mimeType === 'string' ? entry.mimeType : '',
40
+ };
41
+ return attachment.path && attachment.name && attachment.mimeType ? attachment : null;
42
+ })
43
+ .filter((item) => item != null);
44
+ return attachments.length ? attachments : undefined;
45
+ }
46
+ function queueMessageFromParams(params) {
47
+ const timestamp = nowIso();
48
+ return {
49
+ id: params.queueMessageId || params.clientMessageId || `queue-${randomUUID()}`,
50
+ sessionId: params.sessionId,
51
+ text: params.text,
52
+ agentType: params.agentType,
53
+ workDir: params.workDir,
54
+ agentSessionId: params.agentSessionId ?? null,
55
+ modelId: params.modelId ?? null,
56
+ clientMessageId: params.clientMessageId ?? null,
57
+ attachments: normalizeAttachments(params.attachments),
58
+ createdAt: timestamp,
59
+ updatedAt: timestamp,
60
+ };
61
+ }
62
+ export class ChatQueueManager {
63
+ opts;
64
+ draining = new Set();
65
+ constructor(opts) {
66
+ this.opts = opts;
67
+ }
68
+ getSnapshot(sessionId) {
69
+ return {
70
+ sessionId,
71
+ busy: Boolean(this.opts.getRuntime().sessions.get(sessionId)?.currentRunId),
72
+ pending: readQueue().sessions[sessionId] ?? [],
73
+ };
74
+ }
75
+ async handleEnqueue(req) {
76
+ const runtime = this.opts.getRuntime();
77
+ const params = req.params;
78
+ if (!params.sessionId || !params.text || !params.agentType || !params.workDir) {
79
+ runtime.client.sendRes({
80
+ type: 'res',
81
+ id: req.id,
82
+ ok: false,
83
+ error: 'sessionId, text, agentType and workDir are required',
84
+ });
85
+ return;
86
+ }
87
+ const active = runtime.sessions.get(params.sessionId);
88
+ const isBusy = Boolean(active?.currentRunId);
89
+ if (!isBusy && !(readQueue().sessions[params.sessionId]?.length)) {
90
+ await this.dispatchQueuedMessage(queueMessageFromParams(params));
91
+ runtime.client.sendRes({
92
+ type: 'res',
93
+ id: req.id,
94
+ ok: true,
95
+ payload: { queued: false, queue: this.getSnapshot(params.sessionId) },
96
+ });
97
+ return;
98
+ }
99
+ const queue = readQueue();
100
+ const message = queueMessageFromParams(params);
101
+ queue.sessions[params.sessionId] = [...(queue.sessions[params.sessionId] ?? []), message];
102
+ writeQueue(queue);
103
+ this.broadcast(params.sessionId);
104
+ runtime.client.sendRes({
105
+ type: 'res',
106
+ id: req.id,
107
+ ok: true,
108
+ payload: { queued: true, queueMessageId: message.id, queue: this.getSnapshot(params.sessionId) },
109
+ });
110
+ }
111
+ async handleGet(req) {
112
+ const runtime = this.opts.getRuntime();
113
+ const params = req.params;
114
+ if (!params.sessionId) {
115
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'sessionId is required' });
116
+ return;
117
+ }
118
+ runtime.client.sendRes({
119
+ type: 'res',
120
+ id: req.id,
121
+ ok: true,
122
+ payload: { queue: this.getSnapshot(params.sessionId) },
123
+ });
124
+ }
125
+ async handleEdit(req) {
126
+ const runtime = this.opts.getRuntime();
127
+ const params = req.params;
128
+ if (!params.sessionId || !params.queueMessageId || !params.text) {
129
+ runtime.client.sendRes({
130
+ type: 'res',
131
+ id: req.id,
132
+ ok: false,
133
+ error: 'sessionId, queueMessageId and text are required',
134
+ });
135
+ return;
136
+ }
137
+ const queue = readQueue();
138
+ const pending = queue.sessions[params.sessionId] ?? [];
139
+ const index = pending.findIndex((message) => message.id === params.queueMessageId);
140
+ if (index < 0) {
141
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Queued message not found' });
142
+ return;
143
+ }
144
+ pending[index] = {
145
+ ...pending[index],
146
+ text: params.text,
147
+ attachments: normalizeAttachments(params.attachments),
148
+ updatedAt: nowIso(),
149
+ };
150
+ queue.sessions[params.sessionId] = pending;
151
+ writeQueue(queue);
152
+ this.broadcast(params.sessionId);
153
+ runtime.client.sendRes({
154
+ type: 'res',
155
+ id: req.id,
156
+ ok: true,
157
+ payload: { queue: this.getSnapshot(params.sessionId) },
158
+ });
159
+ }
160
+ async handleDelete(req) {
161
+ const runtime = this.opts.getRuntime();
162
+ const params = req.params;
163
+ if (!params.sessionId || !params.queueMessageId) {
164
+ runtime.client.sendRes({
165
+ type: 'res',
166
+ id: req.id,
167
+ ok: false,
168
+ error: 'sessionId and queueMessageId are required',
169
+ });
170
+ return;
171
+ }
172
+ const queue = readQueue();
173
+ const pending = queue.sessions[params.sessionId] ?? [];
174
+ const next = pending.filter((message) => message.id !== params.queueMessageId);
175
+ if (next.length === pending.length) {
176
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Queued message not found' });
177
+ return;
178
+ }
179
+ if (next.length)
180
+ queue.sessions[params.sessionId] = next;
181
+ else
182
+ delete queue.sessions[params.sessionId];
183
+ writeQueue(queue);
184
+ this.broadcast(params.sessionId);
185
+ runtime.client.sendRes({
186
+ type: 'res',
187
+ id: req.id,
188
+ ok: true,
189
+ payload: { queue: this.getSnapshot(params.sessionId) },
190
+ });
191
+ }
192
+ noteTerminal(sessionId) {
193
+ void this.drainNext(sessionId);
194
+ }
195
+ async drainNext(sessionId) {
196
+ if (this.draining.has(sessionId))
197
+ return;
198
+ const runtime = this.opts.getRuntime();
199
+ if (runtime.sessions.get(sessionId)?.currentRunId)
200
+ return;
201
+ const queue = readQueue();
202
+ const pending = queue.sessions[sessionId] ?? [];
203
+ const next = pending.shift();
204
+ if (!next) {
205
+ this.broadcast(sessionId);
206
+ return;
207
+ }
208
+ if (pending.length)
209
+ queue.sessions[sessionId] = pending;
210
+ else
211
+ delete queue.sessions[sessionId];
212
+ writeQueue(queue);
213
+ this.broadcast(sessionId);
214
+ this.draining.add(sessionId);
215
+ try {
216
+ await this.dispatchQueuedMessage(next);
217
+ }
218
+ finally {
219
+ this.draining.delete(sessionId);
220
+ }
221
+ }
222
+ async dispatchQueuedMessage(message) {
223
+ await this.opts.dispatchReq({
224
+ type: 'req',
225
+ id: `queue-send-${message.id}-${Date.now()}`,
226
+ method: 'chat.send',
227
+ params: {
228
+ sessionId: message.sessionId,
229
+ text: message.text,
230
+ agentType: message.agentType,
231
+ workDir: message.workDir,
232
+ agentSessionId: message.agentSessionId ?? null,
233
+ modelId: message.modelId ?? undefined,
234
+ clientMessageId: message.clientMessageId ?? message.id,
235
+ attachments: message.attachments,
236
+ },
237
+ });
238
+ }
239
+ broadcast(sessionId) {
240
+ this.opts.getRuntime().client.sendEvent({
241
+ type: 'event',
242
+ event: 'session.queue.update',
243
+ payload: { queue: this.getSnapshot(sessionId) },
244
+ });
245
+ }
246
+ }
@@ -23,6 +23,14 @@ export type PendingTransfer = {
23
23
  targetPath: string;
24
24
  totalSize: number;
25
25
  };
26
+ export type ChatQueueService = {
27
+ handleEnqueue(req: import('@shennian/wire').ReqFrame): Promise<void>;
28
+ handleGet(req: import('@shennian/wire').ReqFrame): Promise<void>;
29
+ handleEdit(req: import('@shennian/wire').ReqFrame): Promise<void>;
30
+ handleDelete(req: import('@shennian/wire').ReqFrame): Promise<void>;
31
+ noteTerminal(sessionId: string): void;
32
+ getSnapshot(sessionId: string): import('@shennian/wire').ChatQueueSnapshot;
33
+ };
26
34
  export type SessionManagerRuntime = {
27
35
  client: CliRelayClient;
28
36
  pendingTransfers: Map<string, PendingTransfer>;
@@ -34,4 +42,5 @@ export type SessionManagerRuntime = {
34
42
  evictIdleSessions: () => void;
35
43
  nativeFusion: NativeSessionFusionService | null;
36
44
  managerRuntime: ManagerRuntimeService | null;
45
+ chatQueue: ChatQueueService | null;
37
46
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {