shennian 0.2.48 → 0.2.50

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
+ }
@@ -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];
@@ -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
@@ -1,2 +1,2 @@
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 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\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;
@@ -30,11 +30,17 @@ export const MANAGER_SYSTEM_PROMPT = `你是项目经理,是当前项目的管
30
30
  - shennian manager sessions list --json
31
31
  - shennian manager sessions start --agent codex --workdir <path> --message <text>
32
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>
33
37
  - shennian manager sessions stop --session-id <id>
34
38
  - shennian manager sessions read --session-id <id> --limit 200 --json
35
39
  - shennian manager memory path
36
40
  - shennian manager external send --text <text>
37
41
 
42
+ 默认用 sessions send 排队发送 worker 消息:worker 正忙时消息会在本机 daemon 队列里等待,worker 空闲时自动执行。队列里的未执行消息可以 list/edit/delete;已经开始执行的消息不能编辑或删除,只能 stop 后重新发送。只有明确需要打断顺序时才使用 --direct。
43
+
38
44
  这些命令已经由神念注入当前 Manager 身份和同项目权限边界。不要尝试伪造 Manager session id。`;
39
45
  export function buildManagerPrompt(userText) {
40
46
  return 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);
@@ -442,6 +492,14 @@ export class ManagerRuntimeService {
442
492
  params: { sessionId, text, agentType, workDir, agentSessionId, modelId },
443
493
  });
444
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
+ }
445
503
  wakeManagerForWorker(managerSessionId, worker, state, message) {
446
504
  const manager = this.registry.getManager(managerSessionId);
447
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
  });
@@ -282,7 +283,7 @@ export async function handleChatSend(runtime, req) {
282
283
  return;
283
284
  }
284
285
  rememberProcessedReqId(runtime, req.id);
285
- const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, clientMessageId, sessionListProjection } = req.params;
286
+ const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, clientMessageId, sessionListProjection, waitForDispatch } = req.params;
286
287
  mergeProjectedSessions(sessionListProjection);
287
288
  if (!sessionId || !text) {
288
289
  runtime.processedReqIds.delete(req.id);
@@ -353,23 +354,6 @@ export async function handleChatSend(runtime, req) {
353
354
  return;
354
355
  }
355
356
  }
356
- sendSessionUpdateEvent(runtime, {
357
- sessionId,
358
- agentType: requestedAgentType,
359
- workDir: resolvedWorkDir,
360
- agentSessionId: session.agentSessionId ?? incomingAgentSid ?? null,
361
- modelId,
362
- });
363
- session.currentRunId = null;
364
- session.nextEventSeq = 0;
365
- runtime.nativeFusion?.registerManagedSend({
366
- sessionId,
367
- agentType: requestedAgentType,
368
- sourceAgentType: getNativeSourceAgentType(requestedAgentType, modelId),
369
- canonicalMessageId: clientMessageId ?? null,
370
- sourceSessionKey: session.agentSessionId ?? incomingAgentSid ?? null,
371
- text,
372
- });
373
357
  const userEnvelope = {
374
358
  id: clientMessageId ?? `user-${req.id}`,
375
359
  sessionId,
@@ -377,36 +361,39 @@ export async function handleChatSend(runtime, req) {
377
361
  ts: Date.now(),
378
362
  payload: text,
379
363
  };
380
- appendMessage(sessionId, userEnvelope);
381
- sendSessionMessageEvent(runtime, userEnvelope, {
382
- agentType: requestedAgentType,
383
- workDir: resolvedWorkDir,
384
- agentSessionId: session.agentSessionId ?? incomingAgentSid ?? null,
385
- modelId,
386
- });
387
364
  reportLog({
388
365
  level: 'info',
389
366
  sessionId,
390
367
  wsEvent: 'chat.send.start',
391
368
  metadata: { reqId: req.id, agentType: requestedAgentType, modelId },
392
369
  });
393
- runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
394
- reportLog({
395
- level: 'info',
396
- sessionId,
397
- wsEvent: 'chat.send.res',
398
- metadata: { reqId: req.id, ok: true },
399
- });
400
- void session.adapter.send(text, modelId)
401
- .then(() => {
402
- reportLog({
403
- level: 'info',
370
+ const markAccepted = () => {
371
+ sendSessionUpdateEvent(runtime, {
404
372
  sessionId,
405
- wsEvent: 'chat.send.done',
406
- metadata: { reqId: req.id },
373
+ agentType: requestedAgentType,
374
+ workDir: resolvedWorkDir,
375
+ agentSessionId: session.agentSessionId ?? incomingAgentSid ?? null,
376
+ modelId,
407
377
  });
408
- })
409
- .catch(async (err) => {
378
+ session.currentRunId = null;
379
+ session.nextEventSeq = 0;
380
+ runtime.nativeFusion?.registerManagedSend({
381
+ sessionId,
382
+ agentType: requestedAgentType,
383
+ sourceAgentType: getNativeSourceAgentType(requestedAgentType, modelId),
384
+ canonicalMessageId: clientMessageId ?? null,
385
+ sourceSessionKey: session.agentSessionId ?? incomingAgentSid ?? null,
386
+ text,
387
+ });
388
+ appendMessage(sessionId, userEnvelope);
389
+ sendSessionMessageEvent(runtime, userEnvelope, {
390
+ agentType: requestedAgentType,
391
+ workDir: resolvedWorkDir,
392
+ agentSessionId: session.agentSessionId ?? incomingAgentSid ?? null,
393
+ modelId,
394
+ });
395
+ };
396
+ const handleSendFailure = async (err, respondToReq) => {
410
397
  const message = `Agent send failed: ${err instanceof Error ? err.message : String(err)}`;
411
398
  console.error(`[chat.send] send failed reqId=${req.id} sessionId=${sessionId} agentType=${agentType} workDir=${resolvedWorkDir} agentSessionId=${session.agentSessionId ?? incomingAgentSid ?? ''}: ${message}`);
412
399
  runtime.sessions.delete(sessionId);
@@ -425,6 +412,59 @@ export async function handleChatSend(runtime, req) {
425
412
  seq: 0,
426
413
  },
427
414
  });
415
+ if (respondToReq) {
416
+ runtime.processedReqIds.delete(req.id);
417
+ runtime.client.sendRes({
418
+ type: 'res',
419
+ id: req.id,
420
+ ok: false,
421
+ error: message,
422
+ });
423
+ }
424
+ };
425
+ if (waitForDispatch) {
426
+ try {
427
+ await session.adapter.send(text, modelId);
428
+ reportLog({
429
+ level: 'info',
430
+ sessionId,
431
+ wsEvent: 'chat.send.done',
432
+ metadata: { reqId: req.id },
433
+ });
434
+ }
435
+ catch (err) {
436
+ await handleSendFailure(err, true);
437
+ return;
438
+ }
439
+ markAccepted();
440
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
441
+ reportLog({
442
+ level: 'info',
443
+ sessionId,
444
+ wsEvent: 'chat.send.res',
445
+ metadata: { reqId: req.id, ok: true },
446
+ });
447
+ return;
448
+ }
449
+ markAccepted();
450
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
451
+ reportLog({
452
+ level: 'info',
453
+ sessionId,
454
+ wsEvent: 'chat.send.res',
455
+ metadata: { reqId: req.id, ok: true },
456
+ });
457
+ void session.adapter.send(text, modelId)
458
+ .then(() => {
459
+ reportLog({
460
+ level: 'info',
461
+ sessionId,
462
+ wsEvent: 'chat.send.done',
463
+ metadata: { reqId: req.id },
464
+ });
465
+ })
466
+ .catch((err) => {
467
+ void handleSendFailure(err, false);
428
468
  });
429
469
  }
430
470
  export async function handleChatAbort(runtime, req) {
@@ -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,248 @@
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.opts.dispatchReq({
91
+ ...req,
92
+ method: 'chat.send',
93
+ params: {
94
+ ...params,
95
+ clientMessageId: params.clientMessageId ?? params.queueMessageId,
96
+ waitForDispatch: true,
97
+ },
98
+ });
99
+ return;
100
+ }
101
+ const queue = readQueue();
102
+ const message = queueMessageFromParams(params);
103
+ queue.sessions[params.sessionId] = [...(queue.sessions[params.sessionId] ?? []), message];
104
+ writeQueue(queue);
105
+ this.broadcast(params.sessionId);
106
+ runtime.client.sendRes({
107
+ type: 'res',
108
+ id: req.id,
109
+ ok: true,
110
+ payload: { queued: true, queueMessageId: message.id, queue: this.getSnapshot(params.sessionId) },
111
+ });
112
+ }
113
+ async handleGet(req) {
114
+ const runtime = this.opts.getRuntime();
115
+ const params = req.params;
116
+ if (!params.sessionId) {
117
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'sessionId is required' });
118
+ return;
119
+ }
120
+ runtime.client.sendRes({
121
+ type: 'res',
122
+ id: req.id,
123
+ ok: true,
124
+ payload: { queue: this.getSnapshot(params.sessionId) },
125
+ });
126
+ }
127
+ async handleEdit(req) {
128
+ const runtime = this.opts.getRuntime();
129
+ const params = req.params;
130
+ if (!params.sessionId || !params.queueMessageId || !params.text) {
131
+ runtime.client.sendRes({
132
+ type: 'res',
133
+ id: req.id,
134
+ ok: false,
135
+ error: 'sessionId, queueMessageId and text are required',
136
+ });
137
+ return;
138
+ }
139
+ const queue = readQueue();
140
+ const pending = queue.sessions[params.sessionId] ?? [];
141
+ const index = pending.findIndex((message) => message.id === params.queueMessageId);
142
+ if (index < 0) {
143
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Queued message not found' });
144
+ return;
145
+ }
146
+ pending[index] = {
147
+ ...pending[index],
148
+ text: params.text,
149
+ attachments: normalizeAttachments(params.attachments),
150
+ updatedAt: nowIso(),
151
+ };
152
+ queue.sessions[params.sessionId] = pending;
153
+ writeQueue(queue);
154
+ this.broadcast(params.sessionId);
155
+ runtime.client.sendRes({
156
+ type: 'res',
157
+ id: req.id,
158
+ ok: true,
159
+ payload: { queue: this.getSnapshot(params.sessionId) },
160
+ });
161
+ }
162
+ async handleDelete(req) {
163
+ const runtime = this.opts.getRuntime();
164
+ const params = req.params;
165
+ if (!params.sessionId || !params.queueMessageId) {
166
+ runtime.client.sendRes({
167
+ type: 'res',
168
+ id: req.id,
169
+ ok: false,
170
+ error: 'sessionId and queueMessageId are required',
171
+ });
172
+ return;
173
+ }
174
+ const queue = readQueue();
175
+ const pending = queue.sessions[params.sessionId] ?? [];
176
+ const next = pending.filter((message) => message.id !== params.queueMessageId);
177
+ if (next.length === pending.length) {
178
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Queued message not found' });
179
+ return;
180
+ }
181
+ if (next.length)
182
+ queue.sessions[params.sessionId] = next;
183
+ else
184
+ delete queue.sessions[params.sessionId];
185
+ writeQueue(queue);
186
+ this.broadcast(params.sessionId);
187
+ runtime.client.sendRes({
188
+ type: 'res',
189
+ id: req.id,
190
+ ok: true,
191
+ payload: { queue: this.getSnapshot(params.sessionId) },
192
+ });
193
+ }
194
+ noteTerminal(sessionId) {
195
+ void this.drainNext(sessionId);
196
+ }
197
+ async drainNext(sessionId) {
198
+ if (this.draining.has(sessionId))
199
+ return;
200
+ const runtime = this.opts.getRuntime();
201
+ if (runtime.sessions.get(sessionId)?.currentRunId)
202
+ return;
203
+ const queue = readQueue();
204
+ const pending = queue.sessions[sessionId] ?? [];
205
+ const next = pending.shift();
206
+ if (!next) {
207
+ this.broadcast(sessionId);
208
+ return;
209
+ }
210
+ if (pending.length)
211
+ queue.sessions[sessionId] = pending;
212
+ else
213
+ delete queue.sessions[sessionId];
214
+ writeQueue(queue);
215
+ this.broadcast(sessionId);
216
+ this.draining.add(sessionId);
217
+ try {
218
+ await this.dispatchQueuedMessage(next);
219
+ }
220
+ finally {
221
+ this.draining.delete(sessionId);
222
+ }
223
+ }
224
+ async dispatchQueuedMessage(message) {
225
+ await this.opts.dispatchReq({
226
+ type: 'req',
227
+ id: `queue-send-${message.id}-${Date.now()}`,
228
+ method: 'chat.send',
229
+ params: {
230
+ sessionId: message.sessionId,
231
+ text: message.text,
232
+ agentType: message.agentType,
233
+ workDir: message.workDir,
234
+ agentSessionId: message.agentSessionId ?? null,
235
+ modelId: message.modelId ?? undefined,
236
+ clientMessageId: message.clientMessageId ?? message.id,
237
+ attachments: message.attachments,
238
+ },
239
+ });
240
+ }
241
+ broadcast(sessionId) {
242
+ this.opts.getRuntime().client.sendEvent({
243
+ type: 'event',
244
+ event: 'session.queue.update',
245
+ payload: { queue: this.getSnapshot(sessionId) },
246
+ });
247
+ }
248
+ }
@@ -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.48",
3
+ "version": "0.2.50",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {