pi-dag-workflow 0.0.0-stage → 0.7.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +454 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -2
  4. package/README.zh-CN.md +212 -0
  5. package/docs/COMMUNICATION.md +129 -0
  6. package/docs/RELEASING.md +66 -0
  7. package/package.json +70 -4
  8. package/src/agents/commands/child.ts +148 -0
  9. package/src/agents/commands/register.ts +404 -0
  10. package/src/agents/domain/context.ts +183 -0
  11. package/src/agents/domain/diagnostics.ts +32 -0
  12. package/src/agents/domain/owner.ts +448 -0
  13. package/src/agents/domain/profiles.ts +216 -0
  14. package/src/agents/domain/runtime.ts +603 -0
  15. package/src/agents/domain/schema.ts +15 -0
  16. package/src/agents/index.ts +5 -0
  17. package/src/agents/io/contracts.ts +50 -0
  18. package/src/agents/io/host.ts +16 -0
  19. package/src/agents/io/notices.ts +27 -0
  20. package/src/agents/io/persistence.ts +210 -0
  21. package/src/agents/io/process.ts +100 -0
  22. package/src/agents/io/profile-file.ts +16 -0
  23. package/src/agents/io/skills.ts +46 -0
  24. package/src/goal/commands/register.ts +235 -0
  25. package/src/goal/domain/failure.ts +14 -0
  26. package/src/goal/domain/owner.ts +575 -0
  27. package/src/goal/domain/policy.ts +8 -0
  28. package/src/goal/domain/prompt.ts +57 -0
  29. package/src/goal/domain/state.ts +176 -0
  30. package/src/goal/index.ts +5 -0
  31. package/src/goal/io/host.ts +8 -0
  32. package/src/plan/commands/register.ts +101 -0
  33. package/src/plan/domain/owner.ts +51 -0
  34. package/src/plan/domain/policy.ts +27 -0
  35. package/src/plan/domain/state.ts +15 -0
  36. package/src/plan/index.ts +13 -0
  37. package/src/plan/io/records.ts +3 -0
  38. package/src/retry/commands/menu.ts +158 -0
  39. package/src/retry/commands/register.ts +26 -0
  40. package/src/retry/domain/owner.ts +418 -0
  41. package/src/retry/domain/state.ts +71 -0
  42. package/src/retry/index.ts +5 -0
  43. package/src/retry/io/host.ts +15 -0
  44. package/src/shared/agent-view.ts +18 -0
  45. package/src/shared/completion.ts +111 -0
  46. package/src/shared/config-file.ts +106 -0
  47. package/src/shared/config.ts +112 -0
  48. package/src/shared/dag/cache.ts +26 -0
  49. package/src/shared/dag/graph.ts +170 -0
  50. package/src/shared/dag/layout.ts +130 -0
  51. package/src/shared/goal-view.ts +2 -0
  52. package/src/shared/i18n.ts +62 -0
  53. package/src/shared/message-types.ts +12 -0
  54. package/src/shared/messages.en.ts +1290 -0
  55. package/src/shared/passive-message.ts +50 -0
  56. package/src/shared/pi-cli.ts +29 -0
  57. package/src/shared/profile-launch.ts +110 -0
  58. package/src/shared/state-types.ts +5 -0
  59. package/src/shared/task-schema.ts +39 -0
  60. package/src/shared/task-types.ts +25 -0
  61. package/src/shared/terminal.ts +19 -0
  62. package/src/shared/tool-info.ts +8 -0
  63. package/src/shared/ui/filter.ts +144 -0
  64. package/src/shared/ui/menu.ts +447 -0
  65. package/src/shared/ui/panel.ts +768 -0
  66. package/src/shared/ui/presentation.ts +624 -0
  67. package/src/shared/ui/render-cache.ts +63 -0
  68. package/src/shared/ui/render.ts +641 -0
  69. package/src/shared/ui/symbols.ts +136 -0
  70. package/src/team/commands/bootstrap.ts +52 -0
  71. package/src/team/commands/controls.ts +91 -0
  72. package/src/team/commands/history.ts +100 -0
  73. package/src/team/commands/menu.ts +455 -0
  74. package/src/team/commands/register.ts +1321 -0
  75. package/src/team/domain/inbox.ts +178 -0
  76. package/src/team/domain/launcher.ts +355 -0
  77. package/src/team/domain/lifecycle.ts +34 -0
  78. package/src/team/domain/teams.ts +161 -0
  79. package/src/team/index.ts +4 -0
  80. package/src/team/io/inbox.ts +255 -0
  81. package/src/team/io/launcher.ts +270 -0
  82. package/src/team/io/lock.ts +19 -0
  83. package/src/team/io/registry.ts +477 -0
  84. package/src/team/io/runtime.ts +353 -0
  85. package/src/team/io/session-activity.ts +185 -0
  86. package/src/team/io/session-delete.ts +98 -0
  87. package/src/team/io/session-history.ts +153 -0
  88. package/src/team/io/session-tail.ts +132 -0
  89. package/src/team/io/sid-lock.ts +115 -0
  90. package/src/team/io/sid-session.ts +98 -0
  91. package/src/todos/commands/checkpoints.ts +31 -0
  92. package/src/todos/commands/register.ts +395 -0
  93. package/src/todos/domain/checkpoint-state.ts +32 -0
  94. package/src/todos/domain/checkpoint-summary.ts +146 -0
  95. package/src/todos/domain/cleanup.ts +25 -0
  96. package/src/todos/domain/owner.ts +109 -0
  97. package/src/todos/domain/presets.ts +192 -0
  98. package/src/todos/domain/state.ts +287 -0
  99. package/src/todos/index.ts +15 -0
  100. package/src/todos/io/records.ts +8 -0
  101. package/src/ui/index.ts +4 -0
  102. package/src/workflow/commands/connect.ts +81 -0
  103. package/src/workflow/commands/view.ts +121 -0
  104. package/src/workflow/domain/features.ts +5 -0
  105. package/src/workflow/domain/view-owner.ts +18 -0
  106. package/src/workflow/domain/view-state.ts +12 -0
  107. package/src/workflow/index.ts +225 -0
  108. package/src/workflow/io/session.ts +48 -0
@@ -0,0 +1,404 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { resolveChildExtensionPaths } from '../../shared/config.ts';
3
+ import { Text } from '@earendil-works/pi-tui';
4
+ import { AgentSendError, type JobSummary } from '../domain/runtime.ts';
5
+ import { safeDiagnostic } from '../domain/diagnostics.ts';
6
+ import type { Profile } from '../domain/profiles.ts';
7
+ import { choose, readView, withMenuScope, panelOptions, type MenuCursor, type MenuItem, type PanelNavigation, panelInput, panelEdit, panelConfirm } from '../../shared/ui/menu.ts';
8
+ import { jobSummary, jobProfileLabel, modelBudget, modelLabel, modelSummary, profileSummary, sendFailureLines, toolDisplay } from '../../shared/ui/presentation.ts';
9
+ import { notify } from '../../shared/terminal.ts';
10
+ import { workflowNamespace, readOnly } from '../../shared/tool-info.ts';
11
+ import { completeArguments, type CompletionSpec } from '../../shared/completion.ts';
12
+ import { chinese, type Translator } from '../../shared/i18n.ts';
13
+ import { createAgentsOwner, type Hooks } from '../domain/owner.ts';
14
+ import { AgentSpawnParamsSchema, AgentInspectParamsSchema, AgentSendParamsSchema, AgentCancelParamsSchema } from '../domain/schema.ts';
15
+ import { glyph } from '../../shared/ui/symbols.ts';
16
+ export { AGENTS_TYPE, claimedBy } from '../domain/owner.ts';
17
+
18
+ const active = (job: JobSummary) => ["starting", "running", "waiting"].includes(job.status);
19
+ export function registerAgents(pi: ExtensionAPI, hooks: Hooks & { panel?: PanelNavigation }) {
20
+ const msg = hooks.msg ?? chinese;
21
+ const dialogs = hooks.panel ?? { input: panelInput, edit: panelEdit, confirm: panelConfirm };
22
+ pi.registerFlag("dag-workflow-test-child-provider", { type: "string", description: "Test-only: explicit trusted offline child provider extension" });
23
+ const owner = createAgentsOwner(pi, hooks);
24
+ const jobStatusLabel = (msg: Translator): Record<JobSummary['status'], string> => ({ starting: msg('启动中'), running: msg('运行中'), waiting: msg('等待回复'), completed: msg('已返回'), failed: msg('失败'), cancelled: msg('已取消'), interrupted: msg('已中断') });
25
+ const statusLabel = (job: { status: JobSummary['status']; reportDelivery?: JobSummary['reportDelivery']; reportEmpty?: true }) => job.status === 'completed' ? (job.reportEmpty ? msg('已结束·无报告') : job.reportDelivery === 'pending' ? msg('待交付') : jobStatusLabel(msg)[job.status]) : jobStatusLabel(msg)[job.status];
26
+ const activeStatuses = new Set<JobSummary['status']>(['starting', 'running', 'waiting']);
27
+ const completion: CompletionSpec = {
28
+ actions: [
29
+ { action: 'list', description: msg('查看 Job 与暂停状态') },
30
+ { action: 'wait', description: msg('等待 Job 结果:wait jobId') },
31
+ { action: 'send', description: msg('给 Job 发消息:send jobId 消息') },
32
+ { action: 'reply', description: msg('回答子 Agent 请求:reply requestId 回答') },
33
+ { action: 'cancel', description: msg('停止 Job:cancel jobId') },
34
+ { action: 'remove', description: msg('停止并移除记录:remove jobId') },
35
+ { action: 'pause', description: msg('暂停结果自动唤醒') },
36
+ { action: 'resume', description: msg('恢复结果自动唤醒') },
37
+ { action: 'profiles', description: msg('查看已保存 Profile') },
38
+ { action: 'profile', description: msg('保存 Profile:profile 名称 provider/model [thinking] [工具]') },
39
+ { action: 'unprofile', description: msg('删除 Profile:unprofile 名称') },
40
+ { action: 'reset', description: msg('清除 Agent 运行记录') },
41
+ { action: 'help', description: msg('查看命令帮助') },
42
+ ],
43
+ freeText: ['profile', 'reply'],
44
+ tokens: (action) => {
45
+ if (action === 'unprofile') return (owner.profiles()?.list() ?? []).map((profile) => ({ token: profile.name, label: profile.name, description: profile.model ? `${profile.model.provider}/${profile.model.id}` : msg('继承主会话') }));
46
+ if (!['wait', 'send', 'cancel', 'remove'].includes(action)) return null;
47
+ return (owner.runtime()?.viewSummaries() ?? [])
48
+ .filter((job) => action !== 'send' || activeStatuses.has(job.status))
49
+ .map((job) => ({ token: job.id, label: `${job.id}${job.todoId ? ` / #${job.todoId}` : ''} · ${jobProfileLabel(job)}`, description: statusLabel(job) }));
50
+ },
51
+ };
52
+ const renderResult = (result: { content: { type: string; text?: string }[]; details?: unknown; isError?: boolean }, _options: unknown, theme: ExtensionContext["ui"]["theme"]) => new Text(theme.fg(result.isError ? "error" : "text", toolDisplay(result.details, result.content.map((item) => item.text ?? "").join("\n"), msg)), 0, 0);
53
+ /** AgentSendError carries structured direction/preflight details; ordinary causes keep safeDiagnostic. */
54
+ const structuredFailure = (cause: unknown): Record<string, unknown> | undefined => cause instanceof AgentSendError ? cause.details as unknown as Record<string, unknown> : undefined;
55
+ const diagnostic = async (cause: unknown): Promise<string> => {
56
+ const details = structuredFailure(cause);
57
+ if (details) return sendFailureLines(details, msg).join('\n');
58
+ const original = safeDiagnostic(String(cause));
59
+ const rejected = /Child tool is not trusted or supported: ([^\s]+)\. Allowed:/.exec(original);
60
+ if (rejected) {
61
+ const source = (await owner.parentTools()).find(tool => tool.delegable && tool.name === rejected[1])?.path;
62
+ if (source) return `${original}\n${msg`${rejected[1]} 由 ${source} 提供;`}\n${msg('把该扩展加入某个 Profile 的 childExtensions(/agents → 展开 Profile)后可用。')}`;
63
+ }
64
+ return original;
65
+ };
66
+ const fail = async (cause: unknown) => { const details = structuredFailure(cause); const error = await diagnostic(cause); return { isError: true, content: [{ type: 'text' as const, text: error }], details: details ? { ...details, error } : { error } }; };
67
+
68
+ pi.registerTool({ name: "subagent_spawn", label: "Agent", namespace: workflowNamespace, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, description: "Start a child and return jobId, status and resolved configuration. Parent defaults, selected profile, then call overrides apply. resumeFrom reuses the last actual configuration; profile:inherit resets it. Discover available tools with subagent_inspect tools:true. Skills are local references, not auto-discovered or system prompts; read is never added implicitly.",
69
+ promptSnippet: "Use subagent_spawn to start one isolated child agent for independent work",
70
+ parameters: AgentSpawnParamsSchema, executionMode: "sequential", renderResult,
71
+ async execute(_id, params, signal, _update, ctx) { try { return await owner.spawn(params, signal, ctx); } catch (cause) { return fail(cause); } },
72
+ });
73
+ pi.registerTool({ name: "subagent_inspect", label: "Agents", namespace: workflowNamespace, annotations: readOnly, description: "Inspect child status or discover tools/profiles/models. tools:true lists delegable tools with name, description and core/extension origin; exclusive with model/job/profile/output/wait fields. models:true returns available provider/id/name/reasoning and context limits; query searches provider/id/name, limit defaults to 20 (max100), offset defaults to 0; total and nextOffset paginate. Model discovery is exclusive with job/profile/output/wait fields; query/limit/offset require models:true. With output:true and jobId, collect output/question and todoStatus; timeout:0 is immediate. Default until:update waits up to 5s; until:finish up to 30s, max300s; after selects a newer reportVersion. Timeout/abort only ends waiting, not the child. Verify work yourself; do independent work instead of polling. profiles:'summary' lists profile settings; profile:NAME fetches one; profiles:true fetches all instructions. Directory queries cannot collect output.",
74
+ promptSnippet: "Use subagent_inspect for child status, output and profile/model discovery",
75
+ promptGuidelines: ["Discover profiles on demand: profiles:'summary' lists names/model/thinking/tools, profile:NAME returns one full profile with instructions, and profiles:true returns everything. Never guess a profile name or bulk-read instructions. Discover available models with models:true and optional query/limit/offset; use exact provider/id for spawn overrides."],
76
+ parameters: AgentInspectParamsSchema, executionMode: 'parallel', renderResult,
77
+ async execute(_id, params, signal, _update, ctx) { try { return await owner.inspect(params, signal, ctx); } catch (cause) { return fail(cause); } },
78
+ });
79
+ pi.registerTool({ name: "subagent_send", label: "Agent message", namespace: workflowNamespace, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, description: "Send direction to a recipient job or answer a requestId; exactly one target. interrupt:true clears queued direction/questions and resumes the same child context. tools replaces its active set and requires recipient + interrupt:true. Returns delivery status and confirmed tools.",
80
+ promptSnippet: "Use subagent_send to direct a child job or answer its question", parameters: AgentSendParamsSchema, executionMode: "sequential", renderResult,
81
+ async execute(_id, params, signal, _update, ctx) { try { return await owner.send(params, signal, ctx); } catch (cause) { return fail(cause); } },
82
+ });
83
+ pi.registerTool({ name: "subagent_cancel", label: "Stop Agent", namespace: workflowNamespace, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, description: "Stop a child. Optional remove discards its retained record and pending notices; never completes/deletes the Todo or reverts files.",
84
+ promptSnippet: "Use subagent_cancel to stop one child job", parameters: AgentCancelParamsSchema, executionMode: "sequential", renderResult,
85
+ async execute(_id, params, signal, _update, ctx) { try { return await owner.cancel(params, signal, ctx); } catch (cause) { return fail(cause); } },
86
+ });
87
+
88
+ const panel = (id: string) => panelOptions(hooks.panel, { id: 'agents', label: 'Agents' }, `agents:${id}`);
89
+ async function modelsMenu(ctx: ExtensionContext) {
90
+ const epoch = owner.generation(), cursor: MenuCursor = {}, current = () => epoch === owner.generation() && !hooks.panel?.requested();
91
+ const owned = <T>(title: string, run: () => Promise<T>) => hooks.userPrompt ? hooks.userPrompt(title, run) : run();
92
+ const title = msg('模型目录');
93
+ while (current()) {
94
+ const models = owner.availableModels(ctx);
95
+ const selected = await owned(title, () => choose(ctx, title, models.map((model, index) => ({ id: String(index), kind: 'action', icon: glyph('models'), label: model.name, value: model.provider, valueTone: 'muted', description: model.id })), { msg, panel: { ...panel('models'), itemsZone: 'upper' }, cursor, searchable: true, isCurrent: current, lines: [msg('仅查看准确 provider/id;不切换主模型或保存 Profile')] }));
96
+ if (!current() || selected === undefined) return;
97
+ const model = models[Number(selected)];
98
+ if (model) await owned(title, () => readView(ctx, title, modelSummary(model, msg).join('\n'), { msg, isCurrent: current }));
99
+ }
100
+ }
101
+ function profileText(profile: Profile, field: string, value: string): Profile {
102
+ const next = { ...profile }, text = value.trim();
103
+ if (field === 'model') {
104
+ if (!text) delete next.model;
105
+ else { const slash = text.indexOf('/'); if (slash < 1 || slash === text.length - 1) throw new Error(msg('模型要写成 provider/id')); next.model = { provider: text.slice(0, slash), id: text.slice(slash + 1) }; }
106
+ } else if (field === 'thinking') {
107
+ if (!text || text === 'inherit') delete next.thinking;
108
+ else next.thinking = text as NonNullable<Profile['thinking']>;
109
+ } else if (field === 'tools') {
110
+ if (!text || text === 'inherit') delete next.tools;
111
+ else next.tools = text === 'none' ? [] : text.split(',').map(tool => tool.trim());
112
+ } else if (field === 'instructions') {
113
+ if (!text) delete next.instructions; else next.instructions = value;
114
+ } else if (field === 'skills') next.skills = value.split('\n').map(path => path.trim()).filter(Boolean);
115
+ return next;
116
+ }
117
+ function profileEntity(profile: Profile, width: number, english: boolean, availableExtensions: readonly { path: string; tools: number }[] = [], onAdd?: (path: string) => Promise<void>) {
118
+ const model = profile.model ? modelLabel(profile.model.id, modelBudget(width)) : msg('继承主会话');
119
+ const tools = profile.tools === undefined ? (english ? 'Inherit tools' : '继承工具') : english ? `${profile.tools.length} tools` : `${profile.tools.length} 工具`;
120
+ const field = (id: string, label: string, value: string, icon: string) => ({ id: `${profile.name}/field/${id}`, label, value, icon, kind: 'readonly' as const, valueTone: 'muted' as const });
121
+ const instructions = Boolean(profile.instructions?.trim());
122
+ const skillsValue = profile.skills?.length ? english ? `${profile.skills.length} items` : `${profile.skills.length} 项` : english ? 'Not configured' : '未配置';
123
+ const extensions = profile.childExtensions ?? [];
124
+ // ProfileStore speaks English because its messages are written for tool callers. The panel is where a
125
+ // human types, so the same rules are restated in the session language; anything unrecognised keeps the raw
126
+ // diagnostic rather than inventing a translation.
127
+ const storeText = (text: string): string => {
128
+ if (/^instructions must be at most \d+ characters/.test(text)) return msg('Profile 指令最多 2000 个字符。');
129
+ if (/^instructions must contain text/.test(text)) return msg('Profile 指令不能为空;留空表示移除此 Profile 的指令。');
130
+ if (/^Skill paths must be text|^Skill path must contain text/.test(text)) return msg('技能入口需要非空的本地 Markdown 路径。');
131
+ if (/^Skill path must be at most \d+ characters/.test(text)) return msg('技能入口路径过长:每个最多 2048 个字符。');
132
+ if (/^Skill path must not contain control characters/.test(text)) return msg('技能入口路径里不能有控制字符。');
133
+ if (/^Skill must be a local Markdown file path, not a URL/.test(text)) return msg('技能入口必须是本地 Markdown 文件,不能是网址。');
134
+ if (/^Profile skills accepts at most \d+ files/.test(text)) return msg('一个 Profile 最多 8 个技能文件。');
135
+ if (/^Invalid thinking level/.test(text)) return msg('thinking 取值不合法:inherit/off/minimal/low/medium/high/xhigh/max。');
136
+ if (/^Selected model does not support thinking/.test(text)) return msg('所选模型不支持 thinking:请设为 off 或改用支持推理的模型。');
137
+ if (/^Select a parent model or configure a named profile model/.test(text)) return msg('没有可继承的模型:先选主模型,或给这个 Profile 指定 model。');
138
+ if (/^Unknown model: /.test(text)) return msg('这个模型不在本机注册表里:检查模型配置或凭据。');
139
+ if (/^Profile name must be /.test(text)) return msg('Profile 名只能是 1–48 个字母、数字、_ 或 -,且不能叫 inherit。');
140
+ return safeDiagnostic(text);
141
+ };
142
+ const validate = (field: string) => (value: string): string | undefined => {
143
+ try { owner.profiles()!.validateDraft(profileText(profile, field, value)); return undefined; }
144
+ catch (cause) { return storeText(String(cause)); }
145
+ };
146
+ // The four fields a user owns are editable here; the saved Profile is what a later dispatch reads.
147
+ const modelText = profile.model ? `${profile.model.provider}/${profile.model.id}` : '';
148
+ const thinkingText = profile.thinking ?? '';
149
+ const toolsText = profile.tools === undefined ? '' : profile.tools.join(',');
150
+ const toolsValue = profile.tools === undefined ? (english ? 'Inherit parent built-in tools' : '继承父会话内置工具') : profile.tools.length ? (english ? `${profile.tools.length} items` : `${profile.tools.length} 项`) : (english ? 'Empty set (no tools)' : '空集(不加载工具)');
151
+ const children = [
152
+ { id: `${profile.name}/model`, label: english ? 'Model' : '模型', value: modelText || msg('继承主会话'), icon: glyph('models'), kind: 'input' as const, valueTone: 'muted' as const, input: { title: msg('模型 provider/id(留空=继承主会话)'), value: modelText, validate: validate('model') }, description: msg('必须是与注册表一致的 provider/id;留空即继承主会话模型。只影响之后的派发,不切换当前模型。') },
153
+ { id: `${profile.name}/thinking`, label: 'thinking', value: thinkingText || msg('继承主会话'), icon: glyph('settings'), kind: 'input' as const, valueTone: 'muted' as const, input: { title: msg('thinking:inherit/off/minimal/low/medium/high/xhigh/max'), value: thinkingText || 'inherit', validate: validate('thinking') }, description: msg('inherit(或留空)表示继承;其余取现有枚举。只影响之后的派发。') },
154
+ { id: `${profile.name}/tools`, label: msg('工具(tools)'), value: toolsValue, icon: glyph('config-tools'), kind: 'input' as const, valueTone: 'muted' as const, input: { title: msg('工具:inherit / none / 逗号分隔的工具名'), value: toolsText || (profile.tools === undefined ? 'inherit' : 'none'), validate: validate('tools') }, description: msg('inherit(或留空)= 继承父会话当时可用的内置工具选择集(不是固定四个 core 工具);none = 空集,一个工具都不加载;否则是逗号分隔的工具名。三者不混同。') },
155
+ { id: `${profile.name}/isolate`, label: msg('隔离'), value: profile.isolate === true ? msg('开启') : msg('关闭'), icon: glyph('toggle'), kind: 'toggle' as const, checked: profile.isolate === true, description: msg('Team/手工会话启动时的扩展隔离选项:子进程的派发与工具集不受它影响(执行快照里没有这个字段),也不改当前模型或运行中的 Job。') },
156
+ field('skills', english ? 'Skills' : '技能', skillsValue, glyph('skills')),
157
+ field('instructions-state', english ? 'Instructions' : msg('指令'), instructions ? (english ? 'Configured' : '已配置') : (english ? 'Not configured' : '未配置'), glyph('edit')),
158
+ field('extensions-state', msg('子扩展'), extensions.length ? (english ? `${extensions.length} files` : `${extensions.length} 项`) : (english ? 'Not configured' : '未配置'), glyph('extensions')),
159
+ ...extensions.map((path, index) => ({ id: `${profile.name}/extension/${index}`, label: path, kind: 'action' as const, path: true, destructive: true, confirmation: { title: msg`从 Profile ${profile.name} 移除扩展 ${path}?`, consequences: msg('只修改此 Profile 的子扩展配置;不删除扩展文件,不影响运行中的 Job。'), action: msg('移除') }, icon: glyph('extensions'), description: msg('再按一次确认移除该扩展。') + '\n' + msg('只影响这个 Profile 的子进程;工具权限仍由派发时的选择集决定。') })),
160
+ { id: `${profile.name}/add-extension`, label: msg('从已加载扩展选择'), icon: glyph('new'), kind: 'entity' as const, ...(!availableExtensions.some(source => !extensions.includes(source.path)) ? { disabledReason: msg('没有可添加的已加载扩展;可手动填写本地扩展路径。') } : {}), description: msg('展开选择本会话已加载的可信扩展;不改变当前会话的工具。'), children: availableExtensions.filter(source => !extensions.includes(source.path)).map((source, index) => ({ id: `${profile.name}/add-extension-path/${index}`, kind: 'action' as const, label: source.path, path: true, icon: glyph('extensions'), value: msg`${source.tools} 个工具`, valueTone: 'muted' as const, run: () => onAdd?.(source.path) })) },
161
+ { id: `${profile.name}/add-extension-manual`, label: msg('手动输入扩展路径'), icon: glyph('edit'), kind: 'input' as const, input: { title: msg('子扩展路径'), validate: (value: string) => { const path = value.trim(); if (!path) return msg('子扩展路径不能为空。'); if (/\p{Cc}|\p{Bidi_Control}/u.test(value)) return msg('子扩展路径里不能有控制字符。'); if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) && !/^[a-z]:[\\/]/i.test(path)) return msg('子扩展必须是本地文件路径,不能是网址。'); return undefined; } }, description: msg('填一个本地扩展文件路径;保存前会验证它可加载,仅影响之后的派发。') },
162
+ { id: `${profile.name}/instructions`, label: msg('编辑 Profile 指令'), icon: glyph('edit'), kind: 'input' as const, description: msg('最多 2000 个字符;留空移除此 Profile 的指令,不改变运行中的 Job。'), input: { title: msg('Profile 指令'), value: profile.instructions ?? '', multiline: true, validate: validate('instructions') } },
163
+ { id: `${profile.name}/skills`, label: msg('编辑技能文件入口'), icon: glyph('skills'), kind: 'input' as const, description: msg('每行一个本地 Markdown 文件,最多 8 个;留空清空,仅影响后续派发。'), input: { title: msg('技能文件:每行一个本地 Markdown 路径'), value: (profile.skills ?? []).join('\n'), multiline: true, validate: validate('skills') } },
164
+ { id: `${profile.name}/read`, label: msg('读取完整指令'), icon: glyph('summary'), kind: 'action' as const, description: msg('只读查看这个 Profile 的指令全文;不派发、不修改配置。'), ...(!instructions ? { disabledReason: english ? 'Instructions are not configured' : '指令未配置' } : {}) },
165
+ { id: `${profile.name}/delete`, label: english ? 'Delete Profile' : '删除 Profile', icon: glyph('delete'), kind: 'action' as const, destructive: true, confirmation: { title: msg`删除 Profile ${profile.name}?`, consequences: msg('删除配置定义并保存;会话、项目文件和运行中的 Job 保留。'), action: msg('删除') }, description: msg('删除这条 Profile 定义并保存;不删除任何会话、文件或运行中的 Job。再按一次确认。') },
166
+ ];
167
+ return { id: `profile:${profile.name}`, kind: 'entity' as const, label: profile.name, icon: glyph('agents'), values: [{ text: model, tone: 'muted' as const }, { text: tools, tone: 'muted' as const }], description: msg`Profile ${profile.name}`, children };
168
+ }
169
+ async function agentsMenu(ctx: ExtensionContext) {
170
+ const epoch = owner.generation(), cursor: MenuCursor = {}, detailCursor: MenuCursor = {}, current = () => epoch === owner.generation() && !hooks.panel?.requested();
171
+ const owned = <T>(title: string, run: () => Promise<T>) => hooks.userPrompt ? hooks.userPrompt(title, run) : run();
172
+ let filter: 'attention' | 'all' = 'attention';
173
+ const help: Record<string, string> = {
174
+ spawn: msg('行内填写子任务的完整范围。') + '\n' + msg('明确提交后派发;不自动完成关联的 Todo。'),
175
+ wait: msg('并行等待所有 Job,最多等待 5 秒。') + '\n' + msg('显示当前报告;超时不会停止子进程。'),
176
+ models: msg('查看准确的 provider/id 与模型能力。') + '\n' + msg('查看不会切换主模型或修改 Profile。'),
177
+ delivery: msg('关闭报告自动唤醒,也会暂停当前 Goal(如有);子 Agent 继续运行。恢复 Goal 仍需明确启用。'),
178
+ summary: msg('切换当前模块的常驻小组件。') + '\n' + msg('隐藏显示不会停止子 Agent。'),
179
+ filter: msg('切换需要关注与全部 Job。') + '\n' + msg('只过滤显示,不移除记录。'),
180
+ profiles: msg('查看并管理 Profile、指令、技能与子扩展。') + '\n' + msg('只有明确保存才会写入配置。'),
181
+ reset: msg('清除 Agent 运行记录。') + '\n' + msg('先停止所有子 Agent,不撤销文件修改;键入 /agents 可重新开始。'),
182
+ };
183
+ const targets = new Map<string, { job?: JobSummary; profile?: Profile; action: string }>(), profileById = new Map<string, Profile>();
184
+ let assignments = new Set<string>();
185
+ const actions = (job: JobSummary) => [
186
+ { id: 'output', icon: glyph('summary'), label: msg('阅读报告与诊断') },
187
+ { id: 'send', icon: glyph('skills'), label: msg('发送方向'), ...(!active(job) ? { disabledReason: msg('子 Agent 已停止') } : {}) },
188
+ { id: 'answer', icon: glyph('skills'), label: msg('回答问题'), ...(job.pendingRequests === 0 ? { disabledReason: msg('当前没有待答复问题') } : {}) },
189
+ { id: 'cancel', icon: glyph('stop'), label: msg('停止此子 Agent'), destructive: true, confirmation: { title: msg`停止 Job ${job.id}?`, consequences: msg('停止子 Agent;保留记录和已有输出,不撤销文件修改。'), action: msg('停止') }, ...(!active(job) ? { disabledReason: msg('子 Agent 已停止') } : {}) },
190
+ { id: 'remove', icon: glyph('delete'), label: msg('移除记录'), destructive: true, confirmation: { title: msg`移除 Job ${job.id} 的记录?`, consequences: msg('停止仍在运行的子 Agent,并丢弃保留报告和记录;项目文件保留。'), action: msg('移除') } },
191
+ ...(['failed', 'interrupted'].includes(job.status) ? [{ id: 'redispatch', icon: glyph('autoresearch'), label: msg('重派(保留范围)'), ...(hooks.state().plan ? { disabledReason: msg('Plan 模式为只读;先 /plan off 再执行。') } : !assignments.has(job.id) ? { disabledReason: msg('原任务范围不可用') } : {}) }] : []),
192
+ ].map(row => { const id = `${job.id}/${row.id}`; targets.set(id, { job, action: row.id }); return { ...row, id, kind: 'action' as const, description: job.id + '\n' + msg('不撤销文件修改;移除会丢弃保留报告和记录。') }; });
193
+ /** Profile actions mutate only the freshly reloaded definition, never a stale copy. */
194
+ async function profileAction(profile: Profile, action: string, value?: string | boolean) {
195
+ const profileName = profile.name, english = msg('模型目录') === 'Model catalog';
196
+ // A missing extension path must never block editing the list that contains it.
197
+ try { await owner.childEnvironment(); } catch { /* refreshed again before saving */ }
198
+ if (!current()) return;
199
+ await owner.profiles()!.load(); if (!current()) return;
200
+ const fresh = owner.profiles()!.get(profileName); if (!fresh) return;
201
+ if (action === 'read') { await owned(msg`Profile ${profileName}`, () => readView(ctx, msg`Profile ${profileName}`, fresh.instructions?.trim() || msg('指令:未配置'), { msg, isCurrent: current })); return; }
202
+ if (action === 'delete') {
203
+ if (JSON.stringify(fresh) !== JSON.stringify(profile)) { notify(ctx, english ? 'Profile changed; the stale definition was not deleted' : 'Profile 已改变,未删除过期定义', 'warning'); return; }
204
+ owner.profiles()!.delete(profileName); await owner.profiles()!.save(); return;
205
+ }
206
+ /** `remove` clears a field back to "inherit": a saved Profile may not keep an empty placeholder. */
207
+ const save = async (patch: Partial<Profile>, remove: readonly (keyof Profile)[] = []) => {
208
+ if (JSON.stringify(fresh) !== JSON.stringify(profile)) { notify(ctx, msg('Profile 已改变,未保存过期输入'), 'warning'); return; }
209
+ // Re-read the live child environment immediately before saving so new extension tools are trusted.
210
+ try { await owner.childEnvironment(); } catch { /* a broken path stays editable so it can be removed */ }
211
+ if (!current()) return;
212
+ await owner.profiles()!.load();
213
+ if (!current()) return;
214
+ const live = owner.profiles()!.get(profileName);
215
+ if (!live || JSON.stringify(live) !== JSON.stringify(profile)) { notify(ctx, msg('Profile 已改变,未保存过期输入'), 'warning'); return; }
216
+ const next = { ...live, ...patch } as Record<string, unknown>;
217
+ for (const key of remove) delete next[key as string];
218
+ owner.profiles()!.set(next as unknown as Profile); await owner.profiles()!.save();
219
+ };
220
+ const extensions = fresh.childExtensions ?? [];
221
+ if (action.startsWith('extension/')) {
222
+ const index = Number(action.slice('extension/'.length));
223
+ if (!Number.isSafeInteger(index) || index < 0 || index >= extensions.length) return;
224
+ await save({ childExtensions: extensions.filter((_, at) => at !== index) }); return;
225
+ }
226
+ if (action === 'add-extension') {
227
+ const loaded = [...new Set((await owner.parentTools()).filter(tool => tool.delegable && tool.path).map(tool => tool.path!))].filter(path => !extensions.includes(path));
228
+ const counts = new Map<string, number>();
229
+ for (const tool of await owner.parentTools()) if (tool.delegable && tool.path) counts.set(tool.path, (counts.get(tool.path) ?? 0) + 1);
230
+ const pickerTitle = msg('从已加载扩展中选择');
231
+ const picked = await owned(pickerTitle, () => choose(ctx, pickerTitle, loaded.map(path => ({ id: path, kind: 'action' as const, icon: glyph('extensions'), label: path, path: true, value: msg`${counts.get(path) ?? 0} 个工具`, valueTone: 'muted' as const })), { msg, panel: panel('extension-picker'), isCurrent: current }));
232
+ if (!current() || picked === undefined) return;
233
+ try { await resolveChildExtensionPaths([picked], msg); } catch (cause) { notify(ctx, await diagnostic(cause), 'error'); return; }
234
+ if (!current()) return;
235
+ await save({ childExtensions: [...extensions, picked] }); return;
236
+ }
237
+ if (value === undefined) return;
238
+ // A toggle arrives as a boolean, not as text: the isolation flag is stored as such.
239
+ if (action === 'isolate') { await save({ isolate: value === true }); return; }
240
+ // Everything below is text; a non-text value never reaches the text handlers.
241
+ if (typeof value !== 'string') return;
242
+ if (action === 'add-extension-manual') {
243
+ const entry = value.trim();
244
+ if (!entry) return;
245
+ try { await resolveChildExtensionPaths([entry], msg); } catch (cause) { notify(ctx, await diagnostic(cause), 'error'); return; }
246
+ if (!current()) return;
247
+ await save({ childExtensions: [...new Set([...extensions, entry])] }); return;
248
+ }
249
+ if (['model', 'thinking', 'tools', 'instructions', 'skills'].includes(action)) {
250
+ const next = profileText(fresh, action, value);
251
+ const removed = (['model', 'thinking', 'tools', 'instructions', 'skills'] as const).filter(key => !(key in next));
252
+ await save(next, removed);
253
+ }
254
+ }
255
+ while (current()) {
256
+ targets.clear(); profileById.clear();
257
+ // A broken extension list or profile file must not block the root menu; profile edits reload below.
258
+ try { await owner.childEnvironment(); } catch { /* reported by the extensions sub-screen */ }
259
+ try { await owner.profiles()!.load(); } catch { /* shown on edit */ }
260
+ if (!current()) return;
261
+ const english = msg('模型目录') === 'Model catalog';
262
+ const profiles = owner.profiles()!.list();
263
+ const all = owner.ready(ctx, false).exportSummaries();
264
+ assignments = new Set((owner.runtime()?.exportRecords() ?? []).filter(job => job.assignment?.trim()).map(job => job.id));
265
+ // One row per loaded source, with the number of tools it can delegate: deduplicated here once.
266
+ const extensionSources = [...(await owner.parentTools()).filter(tool => tool.delegable && tool.path).reduce((counts, tool) => counts.set(tool.path!, (counts.get(tool.path!) ?? 0) + 1), new Map<string, number>())].map(([path, tools]) => ({ path, tools }));
267
+ const matchesFilter = (job: JobSummary) => filter === 'all' || active(job) || job.reportDelivery === 'pending' || job.todoId !== undefined && owner.todoStatusOf(job.todoId) !== 'completed' || ['failed', 'interrupted'].includes(job.status) && !job.dismissed;
268
+ const rows = all.filter(matchesFilter), title = msg('子 Agent');
269
+ const elapsedLabel = (job: JobSummary) => { const elapsed = Math.max(0, (job.endedAt ?? Date.now()) - job.startedAt); return elapsed < 60_000 ? `${Math.floor(elapsed / 1000)}s` : `${Math.floor(elapsed / 60_000)}m`; };
270
+ const jobTone = (job: JobSummary) => job.reportDelivery === 'pending' ? 'warning' as const : job.status === 'failed' ? 'error' as const : job.status === 'completed' ? 'success' as const : job.status === 'waiting' ? 'warning' as const : active(job) ? 'accent' as const : 'muted' as const;
271
+ const jobEntity = (job: JobSummary) => ({
272
+ id: job.id, kind: 'entity' as const, label: `${job.id} ${job.label ?? (job.todoId ? `Task #${job.todoId}` : msg('独立任务'))}`, icon: job.status === 'completed' ? glyph('complete') : glyph('settings'),
273
+ values: [{ text: statusLabel(job), tone: jobTone(job) }, { text: job.todoId ? `Task #${job.todoId}` : elapsedLabel(job), tone: 'muted' as const }],
274
+ tone: jobTone(job), description: jobSummary(job, msg).slice(0, 2).join('\n'), children: actions(job).map(action => ({ ...action, kind: 'action' as const })),
275
+ });
276
+ const selected = await owned(title, () => choose(ctx, title, [...rows.map(job => ({ id: job.id, label: `${job.id} ${job.label ?? ''}`, value: statusLabel(job), description: jobSummary(job, msg).slice(1, 3).join(' · ') }))], {
277
+ msg, refreshInterval: 1000, panel: { ...panel('root'), single: false, itemsZone: 'upper', upper: (width, _query) => {
278
+ // Read the roster and the job list again on every render: the panel repaints once a second, so a
279
+ // job that starts, reports or fails shows up without the user moving the cursor.
280
+ // A protected or restoring runtime cannot answer: fall back to the snapshot instead of throwing mid-render.
281
+ const live = (() => { try { return owner.ready(ctx, false).exportSummaries().filter(matchesFilter); } catch { return rows; } })();
282
+ return { rows: [
283
+ { id: 'group:jobs', kind: 'heading' as const, collapsible: true, groupCount: live.length, label: msg('Job'), description: msg('Enter 或 l 展开,h 收起;g/G 跳到上区/下区首行。') },
284
+ ...live.map(jobEntity),
285
+ { id: 'group:profiles', kind: 'heading' as const, collapsible: true, groupCount: profiles.length, label: msg('Profile'), description: msg('Enter 或 l 展开;展开后可直接编辑指令、技能与子扩展。') + '\n' + msg('Profile 只能由你在面板里修改:模型侧只有派发时的只读引用,没有写入 Profile 的工具。') },
286
+ ...profiles.map(profile => { const entity = profileEntity(profile, width, english, extensionSources, async path => { try { await profileAction(profile, 'add-extension-manual', path); } catch (cause) { notify(ctx, await diagnostic(cause), 'error'); } }); profileById.set(entity.id, profile); for (const child of entity.children) targets.set(child.id, { profile, action: child.id.slice(profile.name.length + 1) }); return entity; }),
287
+ ] }; } }, cursor, searchable: true, isCurrent: current, lines: item => { const job = targets.get(item.id)?.job ?? owner.runtime()?.exportSummaries().find(value => value.id === item.id); if (job) return jobSummary({ ...job, ...(owner.todoStatusOf(job.todoId) ? { todoStatus: owner.todoStatusOf(job.todoId)! } : {}) }, msg); const profile = profileById.get(item.id); return profile ? profileSummary(profile, msg) : []; }, actions: [...(ctx.mode === 'tui' ? [{ id: 'spawn', kind: 'input' as const, icon: glyph('spawn'), ...(hooks.state().plan ? { disabledReason: msg('Plan 模式为只读;先 /plan off 再执行。') } : {}), input: { title: msg('子任务范围'), multiline: true }, run: async (value: unknown) => { const task = String(value); if (current() && task.trim()) await owner.spawn({ task }, undefined, ctx); }, label: msg('派发新 Job') }, { id: 'wait', kind: 'action' as const, icon: glyph('wait'), label: msg('等待所有 Job') }, { id: 'new-profile', kind: 'input' as const, icon: glyph('new'), input: { title: msg('Profile 名(字母、数字、_ 或 -)'), validate: (value: string) => { const name = value.trim(); try { owner.profiles()!.validateDraft({ name }); if (owner.profiles()!.get(name)) return msg`Profile ${name} 已存在;请选择该 Profile 编辑,或使用其他名称。`; return undefined; } catch (cause) { return safeDiagnostic(String(cause)); } } }, run: async (value: unknown) => { const name = String(value).trim(); if (!current() || !name) return; try { await owner.profiles()!.load(); // `set` upserts: an existing name would silently replace that Profile, so a duplicate is refused here.
288
+ if (owner.profiles()!.get(name)) { notify(ctx, msg`Profile ${name} 已存在:未新建,也未改动它`, 'warning'); return; }
289
+ owner.profiles()!.set({ name }); await owner.profiles()!.save(); notify(ctx, msg`已新建 Profile ${name}:模型、thinking、工具与隔离都继承`, 'info'); } catch (cause) { if (current()) notify(ctx, await diagnostic(cause), 'error'); } }, label: msg('新建 Profile'), description: msg('只写名称,其余全部继承:模型、thinking、工具、技能、指令与隔离。') + '\n' + msg('Enter 行内填写名称;Esc 取消,不创建。') }] : []), { id: 'models', kind: 'directory' as const, label: msg('模型目录'), icon: glyph('catalog'), description: msg('打开可用模型列表,按名称搜索并查看准确 provider/id;只读,不切换主模型或保存 Profile。') }, { id: 'filter', kind: 'cycle' as const, icon: glyph('filter'), choices: [msg('需要关注'), msg('全部')], run: (value: unknown) => { filter = value === msg('全部') ? 'all' : 'attention'; cursor.page = 0; }, label: msg('筛选'), value: filter === 'attention' ? msg('需要关注') : msg('全部'), valueTone: 'muted' as const }, { id: 'delivery', kind: 'toggle' as const, icon: glyph('delivery'), checked: !owner.paused(), run: (value: unknown) => { if (value !== true) { owner.pauseAutomatic(); hooks.pauseAuto?.(ctx); } else if (!hooks.resumeAuto || hooks.resumeAuto()) owner.resumeAutomatic(); else notify(ctx, msg('Goal 续跑仍暂停;先明确 /goal enable'), 'warning'); }, label: msg('结果自动唤醒'), value: owner.paused() ? msg('暂停') : msg('开启') }, { id: 'reset', kind: 'action' as const, icon: glyph('delete'), destructive: true, confirmation: { title: msg`清除 ${all.length} 个 Agent 运行记录?`, consequences: msg('先停止全部子 Agent,再清除运行记录;不撤销文件修改,历史保留。'), action: msg('清除') }, label: msg('清除 Agent 运行记录'), value: String(all.length) }, ...(ctx.mode === 'tui' ? [] : [{ id: 'profiles', kind: 'directory' as const, icon: glyph('profiles'), label: msg('Profile 列表') }])].map(item => ({ ...item, description: ('description' in item && item.description) || help[item.id] || msg('展开查看可用操作。') })) }));
290
+ if (!current() || selected === undefined) return;
291
+ if (cursor.handled) continue;
292
+ if (selected === 'spawn') { const task = await dialogs.edit({ ctx, title: msg('子任务范围'), value: '', isCurrent: current }); if (current() && task?.trim()) await owner.spawn({ task }, undefined, ctx); continue; }
293
+ if (selected === 'wait') { const results = await Promise.all(all.map(job => owner.ready(ctx, false).wait(job.id, { timeout: 5, until: 'finish', ...(ctx.signal ? { signal: ctx.signal } : {}) }))); if (current()) await readView(ctx, msg('等待所有 Job'), results.map(job => jobSummary(job, msg).join('\n')).join('\n\n') || msg('暂无 Job'), { msg, isCurrent: current }); continue; }
294
+ if (selected === 'models') { await modelsMenu(ctx); continue; }
295
+ if (selected === 'filter') { filter = filter === 'all' ? 'attention' : 'all'; cursor.page = 0; continue; }
296
+ if (selected === 'summary') { hooks.setSummary?.(hooks.summary?.() === false, ctx); continue; }
297
+ if (selected === 'delivery') { if (!owner.paused()) { owner.pauseAutomatic(); hooks.pauseAuto?.(ctx); } else if (!hooks.resumeAuto || hooks.resumeAuto()) { owner.resumeAutomatic(); } else notify(ctx, msg('Goal 续跑仍暂停;先明确 /goal enable'), 'warning'); continue; }
298
+ if (selected === 'reset') {
299
+ const ownGeneration = owner.generation();
300
+ if (current() && ownGeneration === owner.generation()) await owner.reset(ctx, ownGeneration);
301
+ continue;
302
+ }
303
+ if (selected === 'profiles') {
304
+ const title = msg('Profile'), list = owner.profiles()!.list();
305
+ const name = await owned(title, () => choose(ctx, title, list.map(profile => ({ id: profile.name, label: profile.name, value: profile.model ? `${profile.model.provider}/${profile.model.id}` : msg('继承主会话'), description: profile.tools?.join(', ') ?? msg('继承主会话核心工具') })), { msg, panel: panel('profile'), isCurrent: current, searchable: true }));
306
+ if (!current() || name === undefined) continue;
307
+ const profile = list.find(value => value.name === name); if (!profile) continue;
308
+ const detail = msg`Profile ${profile.name}`;
309
+ // The single-object Profile chooser is a text-client fallback only; TUI expands it in place.
310
+ const action = ctx.mode !== 'tui' ? await owned(detail, () => choose(ctx, detail, profileEntity(profile, 96, msg('模型目录') === 'Model catalog').children.map((child: MenuItem) => ({ id: child.id.slice(profile.name.length + 1), label: child.label, description: child.description ?? '', ...(child.destructive ? { destructive: true, ...(child.confirmation ? { confirmation: child.confirmation } : {}) } : {}) })), { msg, panel: panel(`profile:${profile.name}`), isCurrent: current, searchable: true, lines: profileSummary(profile, msg) })) : undefined;
311
+ if (!current() || action === undefined) continue;
312
+ const inputTitle = action === 'instructions' ? msg('Profile 指令') : action === 'skills' ? msg('技能文件:每行一个本地 Markdown 路径') : action === 'add-extension-manual' ? msg('子扩展路径') : undefined;
313
+ const input = inputTitle === undefined ? undefined : await owned(inputTitle, () => dialogs.edit({ ctx, title: inputTitle, value: action === 'instructions' ? profile.instructions ?? '' : action === 'skills' ? (profile.skills ?? []).join('\n') : '', isCurrent: current }));
314
+ if (!current()) continue;
315
+ try { await profileAction(profile, action, input === undefined ? undefined : String(input)); } catch (cause) { if (current()) notify(ctx, await diagnostic(cause), 'error'); }
316
+ continue;
317
+ }
318
+ const target = targets.get(selected);
319
+ if (target?.profile) {
320
+ // The panel hands back what the row declared: a text value or a toggle's boolean. `profileAction`
321
+ // knows which action takes which, so nothing here decodes the row kind.
322
+ try { await profileAction(target.profile, target.action, cursor.value as string | boolean | undefined); } catch (cause) { if (current()) notify(ctx, await diagnostic(cause), 'error'); }
323
+ continue;
324
+ }
325
+ const job = owner.runtime()?.exportSummaries().find(job => job.id === (target?.job?.id ?? selected)); if (!job) continue;
326
+ const jobTitle = `${job.id} · ${job.label ?? jobProfileLabel(job)}`;
327
+ const snapshot = owner.ready(ctx, false).exportRecords().find(record => record.id === job.id)!;
328
+ const action = target?.action ?? (ctx.mode !== 'tui' ? await owned(jobTitle, () => choose(ctx, jobTitle, [
329
+ { id: 'output', label: msg('阅读报告与诊断') },
330
+ { id: 'send', label: msg('发送方向'), ...(!active(job) ? { disabledReason: msg('子 Agent 已停止') } : {}) },
331
+ { id: 'answer', label: msg('回答问题'), ...(job.pendingRequests === 0 ? { disabledReason: msg('当前没有待答复问题') } : {}) },
332
+ { id: 'cancel', label: msg('停止此子 Agent'), destructive: true, confirmation: { title: msg`停止 Job ${job.id}?`, consequences: msg('停止子 Agent;保留记录和已有输出,不撤销文件修改。'), action: msg('停止') }, ...(!active(job) ? { disabledReason: msg('子 Agent 已停止') } : {}) },
333
+ { id: 'remove', label: msg('移除记录'), tone: 'error', destructive: true, confirmation: { title: msg`移除 Job ${job.id} 的记录?`, consequences: msg('停止仍在运行的子 Agent,并丢弃保留报告和记录;项目文件保留。'), action: msg('移除') } },
334
+ ...(['failed', 'interrupted'].includes(job.status) ? [{ id: 'redispatch', label: msg('重派(保留范围)'), ...(!snapshot.assignment ? { disabledReason: msg('原任务范围不可用') } : {}) }] : []),
335
+ ], { msg, panel: panel(`job:${job.id}`), cursor: detailCursor, isCurrent: current, lines: () => jobSummary(owner.runtime()?.exportSummaries().find((value) => value.id === job.id) ?? job, msg) })) : undefined);
336
+ if (!current() || action === undefined) continue;
337
+ if (action === 'redispatch') { await owner.spawn({ resumeFrom: job.id, task: snapshot.assignment! }, undefined, ctx); if (!current()) return; continue; }
338
+ if (action === 'output') { const output = await owner.ready(ctx, false).wait(job.id, { timeout: 0 }); if (current()) await owned(jobTitle, () => readView(ctx, jobTitle, jobSummary({ ...output, ...(owner.todoStatusOf(output.todoId) ? { todoStatus: owner.todoStatusOf(output.todoId)! } : {}) }, msg).join('\n'), { msg, isCurrent: current })); }
339
+ if (action === 'send' || action === 'answer') {
340
+ const requests = action === 'answer' ? (await owner.ready(ctx, false).wait(job.id, { timeout: 0 })).requests : [];
341
+ if (!current()) return;
342
+ const question = requests.length ? requests[0] : undefined;
343
+ const inputTitle = action === 'answer' ? msg('答复子 Agent') : msg('调整子 Agent 方向');
344
+ if (action === 'answer' && !question) { notify(ctx, msg('问题已被处理,请刷新后查看。'), 'warning'); continue; }
345
+ const input = await owned(inputTitle, () => dialogs.edit({ ctx, title: question ? `${inputTitle} · ${question.message}` : inputTitle, value: '', isCurrent: current }));
346
+ if (current() && input?.trim()) await owner.ready(ctx).send({ ...(action === 'answer' && question ? { requestId: question.requestId } : { recipient: job.id }), message: input });
347
+ }
348
+ if (action === 'cancel' || action === 'remove') {
349
+ // choose owns the operation-specific confirmation in both terminal and text clients.
350
+ if (current()) { if (action === 'remove' || job.status !== 'completed' || job.reportDelivery !== 'pending') owner.dropNotice(job.id); await owner.ready(ctx, false).cancel(job.id, { remove: action === 'remove' }); }
351
+ }
352
+ }
353
+ }
354
+ const command = async (args: string, ctx: ExtensionContext) => withMenuScope(ctx, async () => {
355
+ try {
356
+ const [action, ...parts] = args.trim().split(/\s+/);
357
+ if (action === 'help') { ctx.ui.notify(msg('/agents list · wait jobId · send jobId 消息 · reply requestId 回答 · cancel/remove jobId · pause/resume · profiles · profile 名称 provider/model [thinking] [工具逗号列表] · unprofile 名称 · reset · Profile 的 instructions 写在配置文件里,作为该子任务的被动指导消息'), 'info'); return; }
358
+ if (action === "reset") {
359
+ const ownGeneration = owner.generation();
360
+ if (!ctx.hasUI || !await dialogs.confirm({ ctx, msg, title: msg('清除 Agent 运行记录?'), value: msg('先停止所有子 Agent,不撤销文件修改;历史记录保留。'), isCurrent: () => ownGeneration === owner.generation() })) return;
361
+ if (ownGeneration !== owner.generation()) return;
362
+ await owner.reset(ctx, ownGeneration); return;
363
+ }
364
+ const agent = owner.ready(ctx, false);
365
+ if (!args.trim()) { await agentsMenu(ctx); return; }
366
+ if (action === 'list') {
367
+ const jobs = agent.exportSummaries();
368
+ ctx.ui.notify(jobs.length ? jobs.map((job) => jobSummary({ ...job, ...(owner.todoStatusOf(job.todoId) ? { todoStatus: owner.todoStatusOf(job.todoId)! } : {}) }, msg).join('\n')).join('\n\n') : msg('暂无 Job'), 'info');
369
+ return;
370
+ }
371
+ if (action === 'show' || action === 'hide') { hooks.setSummary?.(action === 'show', ctx); return; }
372
+ if (action === "pause") { owner.pauseAutomatic(); hooks.pauseAuto?.(ctx); ctx.ui.notify(msg("已关闭报告自动唤醒,并暂停当前 Goal(如有)。子 Agent 继续运行;停止请用 /agents cancel jobId。"), "info"); return; }
373
+ if (action === "resume") { owner.ready(ctx); if (hooks.resumeAuto ? !hooks.resumeAuto() : hooks.canWake?.() === false) throw new Error(msg("Goal 续跑仍暂停;先明确 /goal enable")); owner.resumeAutomatic(); ctx.ui.notify(msg("后续新结果可自动唤醒;暂停期间旧报告仍可 /agents wait 查看"), "info"); return; }
374
+ if (action === "cancel" || action === "remove") { if (parts.length !== 1) throw new Error(msg`/agents ${action} jobId`); const job = agent.inspect(parts[0]!)[0]!; if (action === 'remove' || job.status !== 'completed' || job.reportDelivery !== 'pending') owner.dropNotice(parts[0]!); await agent.cancel(parts[0]!, { remove: action === "remove" }); return; }
375
+ if (action === 'wait') { if (parts.length !== 1) throw new Error(msg('/agents wait jobId')); const epoch = owner.generation(); const job = await agent.wait(parts[0]!, { timeout: 0 }); if (epoch !== owner.generation()) return; ctx.ui.notify(jobSummary({ ...job, ...(owner.todoStatusOf(job.todoId) ? { todoStatus: owner.todoStatusOf(job.todoId)! } : {}) }, msg).join('\n'), 'info'); return; }
376
+ if (action === "send" || action === "reply") { owner.ready(ctx); const target = parts.shift(); if (!target || !parts.length) throw new Error(msg`/agents ${action} 编号 消息`); const interrupt = action === 'send' && parts[0] === '--interrupt'; if (interrupt) parts.shift(); if (!parts.length) throw new Error(msg('/agents send jobId [--interrupt] 消息')); await agent.send({ ...(action === "send" ? { recipient: target } : { requestId: target }), message: parts.join(" "), ...(interrupt ? { interrupt: true } : {}) }); if (action === "send") owner.adjusted(target); return; }
377
+ if (action === 'profiles') {
378
+ await owner.childEnvironment(); await owner.profiles()!.load();
379
+ const all = owner.profiles()!.list();
380
+ ctx.ui.notify(all.length ? all.map((profile) => profileSummary(profile, msg).join('\n')).join('\n\n') : msg('未配置 Profile'), 'info');
381
+ return;
382
+ }
383
+ if (action === "profile") {
384
+ owner.ready(ctx);
385
+ const [name, model, thinking, tools] = parts;
386
+ if (!name || !model || parts.length > 4 || !model.includes("/")) throw new Error(msg("/agents profile 名称 provider/model [thinking] [工具逗号列表]"));
387
+ const split = model.indexOf("/");
388
+ await owner.childEnvironment(); await owner.profiles()!.load();
389
+ const previous = owner.profiles()!.get(name);
390
+ owner.profiles()!.set({ ...previous, name, model: { provider: model.slice(0, split), id: model.slice(split + 1) }, ...(thinking ? { thinking: thinking as NonNullable<Profile["thinking"]> } : {}), ...(tools ? { tools: tools.split(",") } : {}), ...(previous?.instructions ? { instructions: previous.instructions } : {}), ...(previous?.skills !== undefined ? { skills: previous.skills } : {}) });
391
+ await owner.profiles()!.save(); ctx.ui.notify(msg`已保存 Profile ${name}`, "info"); return;
392
+ }
393
+ if (action === "unprofile") { owner.ready(ctx); if (parts.length !== 1) throw new Error(msg("/agents unprofile 名称")); await owner.childEnvironment(); await owner.profiles()!.load(); owner.profiles()!.delete(parts[0]!); await owner.profiles()!.save(); return; }
394
+ pi.sendUserMessage(`Manage the current child agents/Profiles: ${args}`, { deliverAs: "followUp" });
395
+ } catch (cause) { notify(ctx, await diagnostic(cause), "error"); }
396
+ });
397
+ hooks.panel?.register(ctx => command('', ctx));
398
+ pi.registerCommand('agents', { description: msg('子 Agent 状态/消息/取消与 Profile;也可直接描述需求'), getArgumentCompletions: prefix => completeArguments(prefix, completion), handler: async (args, ctx) => {
399
+ if (!args.trim() && ctx.mode === 'tui' && hooks.panel) { await hooks.panel.open(ctx); return; }
400
+ await command(args, ctx);
401
+ } });
402
+ const { pauseAutomatic, resumeAutomatic, summaries, cleanupProtection, cleanupRecords, assertPlanEntry, assertTodoMutation, afterTodoMutation } = owner;
403
+ return { spawn: owner.spawn, pauseAutomatic, resumeAutomatic, summaries, cleanupProtection, cleanupRecords, assertPlanEntry, assertTodoMutation, afterTodoMutation };
404
+ }