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.
- package/dist/src/agents/claude.js +7 -4
- package/dist/src/agents/codex.js +4 -2
- package/dist/src/agents/platform-instructions.d.ts +3 -0
- package/dist/src/agents/platform-instructions.js +50 -0
- package/dist/src/channels/base.d.ts +4 -0
- package/dist/src/channels/runtime.d.ts +4 -0
- package/dist/src/channels/runtime.js +13 -0
- package/dist/src/channels/secret-registry.js +3 -1
- package/dist/src/channels/websocket.d.ts +4 -0
- package/dist/src/channels/websocket.js +27 -0
- package/dist/src/commands/daemon.js +1 -1
- package/dist/src/commands/manager.js +61 -6
- package/dist/src/manager/prompt.d.ts +1 -1
- package/dist/src/manager/prompt.js +12 -11
- package/dist/src/manager/runtime.d.ts +1 -0
- package/dist/src/manager/runtime.js +68 -6
- package/dist/src/session/handlers/chat.js +1 -0
- package/dist/src/session/manager.d.ts +1 -0
- package/dist/src/session/manager.js +19 -0
- package/dist/src/session/queue.d.ts +20 -0
- package/dist/src/session/queue.js +246 -0
- package/dist/src/session/types.d.ts +9 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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');
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
@@ -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);
|
|
@@ -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('
|
|
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', {
|
|
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(
|
|
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\
|
|
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 =
|
|
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
|
-
- 外部消息通道事件会像普通用户消息一样送达,格式类似“外部消息 /
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
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
|
};
|