hob-bot 0.0.0-stage → 1.0.2

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
@@ -0,0 +1,730 @@
1
+ import { homedir } from "node:os";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, extname } from "node:path";
4
+ import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import type { ImageContent } from "@earendil-works/pi-ai";
6
+ import { commandSummary } from "../../shared/command.ts";
7
+ import type {
8
+ ActivityLine,
9
+ Approval,
10
+ ApprovalOption,
11
+ Job,
12
+ JobsPage,
13
+ JobStatus,
14
+ JobStep,
15
+ RoutineRun,
16
+ } from "../../shared/types.ts";
17
+ import { IMAGE_TYPES, isImagePath } from "../../shared/media.ts";
18
+ import { newId } from "./ids.ts";
19
+ import { imageConfigured } from "./image.ts";
20
+ import { jobModel } from "./models.ts";
21
+ import { overviewTool } from "./overview.ts";
22
+ import { pageTool } from "./pages.ts";
23
+ import { pushApproval, pushJobEnd } from "./push.ts";
24
+ import { buildWorkerPrompt, type Abilities, type PromptSettings } from "./prompt.ts";
25
+ import { availableSkills } from "./skills.ts";
26
+ import type { HobRuntime } from "./runtime.ts";
27
+ import { showPath } from "./text.ts";
28
+ import { monthDayTimeText } from "./time.ts";
29
+ import { STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
30
+ import { registerAskTool, registerImageTool, registerReportTool } from "./worker-tools.ts";
31
+ import { searchTools } from "./search.ts";
32
+
33
+ const WORKER_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
34
+ const ACTIVITY_LIMIT = 200;
35
+ const ENDED: JobStatus[] = ["done", "stuck", "failed", "stopped"];
36
+
37
+ const ALL_ABILITIES: Abilities = { exec: true, write: true, image: true };
38
+
39
+ /** 助手关掉的能力就不给对应工具(读总是有)。 */
40
+ export function workerTools(abilities: Abilities): string[] {
41
+ return WORKER_TOOLS.filter((name) => {
42
+ if (name === "bash") return abilities.exec;
43
+ if (name === "edit" || name === "write") return abilities.write;
44
+ return true;
45
+ });
46
+ }
47
+
48
+ /** 附件里有没有图片(按扩展名判断,不读文件)。 */
49
+ export function hasImageFiles(files: string[] | undefined): boolean {
50
+ return (files ?? []).some((file) => isImagePath(file));
51
+ }
52
+
53
+ /** 附件里的图片读成 pi 的图片块(读不出来就跳过,不把整个任务拖死)。 */
54
+ function imageContents(files: string[] | undefined): ImageContent[] {
55
+ const out: ImageContent[] = [];
56
+ for (const file of files ?? []) {
57
+ const mimeType = IMAGE_TYPES[extname(file).toLowerCase()];
58
+ if (!mimeType) continue;
59
+ try {
60
+ out.push({ type: "image", data: readFileSync(file).toString("base64"), mimeType });
61
+ } catch {
62
+ // 文件没了或读不了:不带图片,靠任务说明的路径自己去读
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export interface StartJobInput {
69
+ title: string;
70
+ instruction: string;
71
+ cwd?: string;
72
+ /** 交给任务的附件(图片会当图片给模型,其他只在说明里列路径) */
73
+ files?: string[];
74
+ model?: string;
75
+ thinking?: string;
76
+ routineId?: string;
77
+ delegationId?: string;
78
+ }
79
+
80
+ interface WorkerState {
81
+ job: Job;
82
+ session?: AgentSession;
83
+ manager?: SessionManager;
84
+ reported?: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] };
85
+ lastText?: string;
86
+ askedFollowUp: boolean;
87
+ lastActivityAt: number;
88
+ finishTimer?: NodeJS.Timeout;
89
+ ask?: (label: string) => void;
90
+ /** 用户叫停了:收尾期间这个 worker 的 report/追问都不再算数 */
91
+ stopped?: boolean;
92
+ /** 正在跑的工具步骤,按 toolCallId 存:输出更新、跑完都按同一个步骤 id 替换 */
93
+ steps: Map<string, JobStep>;
94
+ }
95
+
96
+ /** 推给助手的任务结果最多带这么多字的报告。 */
97
+ const EVENT_SUMMARY_MAX = 6000;
98
+
99
+ function trim(value: string, max = 240): string {
100
+ const oneLine = value.replace(/\s+/g, " ").trim();
101
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
102
+ }
103
+
104
+ function activityFor(toolName: string, args: Record<string, unknown>, cwd = ""): ActivityLine | undefined {
105
+ const path = showPath(String(args.path ?? args.file_path ?? args.filePath ?? ""), cwd);
106
+ switch (toolName) {
107
+ case "read":
108
+ return { at: new Date().toISOString(), verb: "读", text: path };
109
+ case "ls":
110
+ return { at: new Date().toISOString(), verb: "读", text: path || "." };
111
+ case "bash":
112
+ return { at: new Date().toISOString(), verb: "跑", text: trim(commandSummary(String(args.command ?? "")), 120) };
113
+ case "edit":
114
+ return { at: new Date().toISOString(), verb: "改", text: path };
115
+ case "write":
116
+ return { at: new Date().toISOString(), verb: "写", text: path };
117
+ case "grep":
118
+ return { at: new Date().toISOString(), verb: "搜", text: trim(String(args.pattern ?? ""), 80) };
119
+ case "find":
120
+ return { at: new Date().toISOString(), verb: "搜", text: trim(String(args.pattern ?? ""), 80) };
121
+ default:
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ /** 后台任务:一个 Job = 一个隔离的 Worker 会话,结束必须 report。 */
127
+ export class JobManager {
128
+ private workers = new Map<string, WorkerState>();
129
+ private queue: string[] = [];
130
+ private watchdog?: NodeJS.Timeout;
131
+
132
+ constructor(private readonly runtime: HobRuntime) {}
133
+
134
+ private get store() {
135
+ return this.runtime.store;
136
+ }
137
+
138
+ private get bus() {
139
+ return this.runtime.bus;
140
+ }
141
+
142
+ /** 提示词用的设置:现读 settings.json,所以改完下一个任务就按新时区算。 */
143
+ private promptSettings(): PromptSettings {
144
+ const { timezone, aboutYou } = this.runtime.settings.settings.general;
145
+ return { timezone, aboutYou, memory: this.runtime.settings.memory().provider };
146
+ }
147
+
148
+ jobsFor(assistantId: string): Job[] {
149
+ return this.store.jobs().filter((job) => job.assistantId === assistantId);
150
+ }
151
+
152
+ /** 任务记录:全部任务,新的在前,可按助手 / 状态筛选,按 before(上一次最后一页的那条)分页。 */
153
+ page(options: { assistantId?: string; status?: JobStatus; before?: string; limit?: number } = {}): JobsPage {
154
+ const limit = Math.max(1, Math.min(options.limit ?? 30, 200));
155
+ let list = this.store.jobs().reverse();
156
+ if (options.assistantId) list = list.filter((job) => job.assistantId === options.assistantId);
157
+ if (options.status) list = list.filter((job) => job.status === options.status);
158
+ if (options.before) {
159
+ const index = list.findIndex((job) => job.id === options.before);
160
+ if (index !== -1) list = list.slice(index + 1);
161
+ }
162
+ return { jobs: list.slice(0, limit), hasMore: list.length > limit };
163
+ }
164
+
165
+ /** 详情页要全文时拉它:从任务会话文件重新推导,长输出落到任务目录下。 */
166
+ transcript(jobId: string): JobStep[] {
167
+ const job = this.store.job(jobId);
168
+ if (!job) return [];
169
+ return deriveSteps(this.store.workerFile(jobId), {
170
+ cwd: job.cwd ?? "",
171
+ save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
172
+ });
173
+ }
174
+
175
+ async start(assistantId: string, input: StartJobInput): Promise<Job> {
176
+ const assistant = this.store.assistant(assistantId);
177
+ if (!assistant) throw new Error(`助手不存在:${assistantId}`);
178
+ await this.runtime.models.get();
179
+ const requested =
180
+ input.model || input.thinking
181
+ ? { ...(input.model ? { model: input.model } : {}), ...(input.thinking ? { thinking: input.thinking } : {}) }
182
+ : undefined;
183
+ const picked = jobModel({
184
+ settings: this.runtime.settings.settings.models,
185
+ assistant,
186
+ catalog: this.runtime.models,
187
+ ...(requested ? { requested } : {}),
188
+ ...(hasImageFiles(input.files) ? { hasImages: true } : {}),
189
+ });
190
+ const id = this.nextJobId();
191
+ const job: Job = {
192
+ id,
193
+ assistantId,
194
+ assistantName: assistant.name,
195
+ title: input.title,
196
+ instruction: input.instruction,
197
+ status: "queued",
198
+ createdAt: new Date().toISOString(),
199
+ stats: { reads: 0, commands: 0, edits: 0 },
200
+ activity: [],
201
+ cwd: input.cwd?.trim() ? input.cwd : this.store.workDir(id),
202
+ model: picked.model.ref,
203
+ thinking: picked.model.thinking,
204
+ ...(picked.modelReason ? { modelReason: picked.modelReason } : {}),
205
+ ...(input.files?.length ? { inputs: input.files } : {}),
206
+ ...(input.routineId ? { routineId: input.routineId } : {}),
207
+ ...(input.delegationId ? { delegationId: input.delegationId } : {}),
208
+ };
209
+ this.store.saveJob(job);
210
+ if (job.delegationId) this.runtime.delegations.addJob(job.delegationId, job.id);
211
+ // 定时跑的任务不出卡片:结果以带「⏱ 名字」的回复回到对话,过程在右栏和运行记录里看
212
+ if (!input.routineId) await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
213
+ this.bus.emit({ type: "job", job });
214
+ this.publishBusy(assistantId);
215
+ this.queue.push(job.id);
216
+ this.pump();
217
+ return job;
218
+ }
219
+
220
+ /** 任务会改变“忙不忙”(前端用它显示“正在想”),所以状态一变就告诉前端。 */
221
+ private publishBusy(assistantId: string): void {
222
+ const assistant = this.store.assistant(assistantId);
223
+ if (assistant) this.bus.emit({ type: "assistant", assistant: this.runtime.router.summary(assistant) });
224
+ }
225
+
226
+ private nextJobId(): string {
227
+ for (let attempt = 0; attempt < 50; attempt += 1) {
228
+ const candidate = newId("t-", 4);
229
+ if (!this.store.job(candidate)) return candidate;
230
+ }
231
+ return newId("t-", 8);
232
+ }
233
+
234
+ /** 并发名额:settings 的 maxJobs(改完立即生效,排队里的会接着启动)。 */
235
+ private runningCount(): number {
236
+ return this.store.jobs().filter((job) => job.status === "running" || job.status === "waiting").length;
237
+ }
238
+
239
+ pump(): void {
240
+ const max = this.runtime.settings.settings.work.maxJobs;
241
+ while (this.queue.length && this.runningCount() < max) {
242
+ const id = this.queue.shift()!;
243
+ const job = this.store.job(id);
244
+ if (!job || job.status !== "queued") continue;
245
+ void this.launch(job).catch((error) => this.failLaunch(job, error));
246
+ }
247
+ }
248
+
249
+ private async failLaunch(job: Job, error: unknown): Promise<void> {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ await this.finish(job.id, "failed", undefined, message);
252
+ }
253
+
254
+ private async launch(job: Job): Promise<void> {
255
+ const running: Job = {
256
+ ...job,
257
+ status: "running",
258
+ startedAt: new Date().toISOString(),
259
+ };
260
+ this.store.saveJob(running);
261
+ this.bus.emit({ type: "job", job: running });
262
+
263
+ const assistant = this.store.assistant(running.assistantId);
264
+ // 生图要两头都满足:助手开着,而且设置里真配了图像模型
265
+ const baseAbilities = assistant?.abilities ?? ALL_ABILITIES;
266
+ const abilities: Abilities = {
267
+ ...baseAbilities,
268
+ image: baseAbilities.image && imageConfigured(this.runtime.settings.settings.image),
269
+ };
270
+ const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id)];
271
+ if (abilities.image) customTools.push(registerImageTool(this.runtime, running.id));
272
+ if (this.runtime.settings.settings.general.showPage !== false) customTools.push(pageTool(this.runtime, running.assistantId, false));
273
+ customTools.push(...searchTools(this.runtime.home, () => this.runtime.settings.settings.search));
274
+ // 定时跑的就是 Worker:主助手的任务要看得到各助这段时间的动静才能写早报
275
+ if (assistant?.primary) customTools.push(overviewTool(this.runtime));
276
+ const opened = await this.runtime.pi.open({
277
+ file: this.store.workerFile(running.id),
278
+ cwd: running.cwd ?? homedir(),
279
+ model: running.model ?? this.runtime.settings.settings.models.work.ref,
280
+ thinking: running.thinking ?? this.runtime.settings.settings.models.work.thinking,
281
+ memory: this.runtime.settings.memory().provider,
282
+ skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled),
283
+ disabledSkills: this.runtime.settings.settings.skills?.disabled,
284
+ systemPrompt: () =>
285
+ buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities }),
286
+ tools: [
287
+ ...workerTools(abilities),
288
+ ...customTools.map((tool) => tool.name),
289
+ ],
290
+ customTools,
291
+ });
292
+
293
+ const worker: WorkerState = {
294
+ job: running,
295
+ session: opened.session,
296
+ manager: opened.manager,
297
+ askedFollowUp: false,
298
+ lastActivityAt: Date.now(),
299
+ steps: new Map(),
300
+ };
301
+ this.workers.set(running.id, worker);
302
+ const images = imageContents(running.inputs);
303
+
304
+ opened.session.subscribe((event) => {
305
+ if (event.type === "tool_execution_start") {
306
+ this.recordActivity(worker, event.toolName, event.args ?? {});
307
+ const step = toolStep({
308
+ id: event.toolCallId,
309
+ name: event.toolName,
310
+ args: event.args ?? {},
311
+ at: new Date().toISOString(),
312
+ cwd: worker.job.cwd ?? "",
313
+ running: true,
314
+ });
315
+ worker.steps.set(event.toolCallId, step);
316
+ this.emitStep(worker, step);
317
+ return;
318
+ }
319
+ if (event.type === "tool_execution_update") {
320
+ const step = worker.steps.get(event.toolCallId);
321
+ if (step) this.emitStep(worker, this.withStepOutput(worker, step, textOfContent(event.partialResult?.content)));
322
+ return;
323
+ }
324
+ if (event.type === "tool_execution_end") {
325
+ const step =
326
+ worker.steps.get(event.toolCallId) ??
327
+ toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "" });
328
+ worker.steps.delete(event.toolCallId);
329
+ this.emitStep(
330
+ worker,
331
+ this.withStepOutput(worker, step, textOfContent(event.result?.content), {
332
+ isError: event.isError,
333
+ durationMs: event.durationMs,
334
+ }),
335
+ );
336
+ return;
337
+ }
338
+ if (event.type === "message_end" && event.message.role === "assistant") {
339
+ const text = textOfContent(event.message.content).trim();
340
+ if (text) worker.lastText = text;
341
+ worker.lastActivityAt = Date.now();
342
+ this.recordSteps(worker, event.message);
343
+ this.addUsage(worker, event.message);
344
+ return;
345
+ }
346
+ if (event.type === "message_start" || event.type === "agent_start") {
347
+ worker.lastActivityAt = Date.now();
348
+ return;
349
+ }
350
+ if (event.type === "agent_settled") void this.onSettled(worker);
351
+ });
352
+
353
+ void opened.session
354
+ .prompt(running.instruction, images.length ? { images } : undefined)
355
+ .catch(async (error: unknown) => {
356
+ await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
357
+ });
358
+ }
359
+
360
+ private recordActivity(worker: WorkerState, toolName: string, args: Record<string, unknown>): void {
361
+ worker.lastActivityAt = Date.now();
362
+ const line = activityFor(toolName, args);
363
+ if (!line) return;
364
+ const job = this.store.job(worker.job.id);
365
+ if (!job) return;
366
+ const stats = { ...job.stats };
367
+ if (line.verb === "读") stats.reads += 1;
368
+ else if (line.verb === "跑") stats.commands += 1;
369
+ else if (line.verb === "改" || line.verb === "写") stats.edits += 1;
370
+ const activity = [...job.activity, line].slice(-ACTIVITY_LIMIT);
371
+ const next = { ...job, stats, activity };
372
+ this.store.saveJob(next);
373
+ worker.job = next;
374
+ this.bus.emit({ type: "activity", jobId: job.id, line, stats });
375
+ }
376
+
377
+ /** 推一步给前端;同一个 id 到了就是替换。 */
378
+ private emitStep(worker: WorkerState, step: JobStep): void {
379
+ this.bus.emit({ type: "job_step", jobId: worker.job.id, step });
380
+ }
381
+
382
+ /** 工具结果装进步骤:事件里只给 4KB,全文落盘给路径(详情页要全文时拉 transcript)。 */
383
+ private withStepOutput(
384
+ worker: WorkerState,
385
+ step: JobStep,
386
+ full: string,
387
+ extra: { isError?: boolean; durationMs?: number } = {},
388
+ ): JobStep {
389
+ const jobId = worker.job.id;
390
+ return withOutput(step, full, {
391
+ limit: STEP_EVENT_LIMIT,
392
+ ...extra,
393
+ save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
394
+ });
395
+ }
396
+
397
+ /** 助手消息里的说明文字和思考摘要也进时间线(工具步骤已由执行事件推过了)。 */
398
+ private recordSteps(worker: WorkerState, message: { timestamp?: number; content?: unknown }): void {
399
+ const blocks: any[] = Array.isArray(message.content) ? (message.content as any[]) : [];
400
+ const stamp = message.timestamp ?? Date.now();
401
+ const at = new Date(stamp).toISOString();
402
+ blocks.forEach((block, index) => {
403
+ if (block?.type === "text") {
404
+ const text = String(block.text ?? "").trim();
405
+ if (text) this.emitStep(worker, { id: `t${stamp}-${index}`, kind: "text", at, from: "assistant", text });
406
+ return;
407
+ }
408
+ if (block?.type === "thinking") {
409
+ const text = String(block.thinking ?? "").trim();
410
+ if (text && !block.redacted) this.emitStep(worker, { id: `k${stamp}-${index}`, kind: "thinking", at, text });
411
+ }
412
+ });
413
+ }
414
+
415
+ /** token 和花费累加到任务上,详情顶部实时看得到。 */
416
+ private addUsage(worker: WorkerState, message: { usage?: { input: number; output: number; cost: { total: number } } }): void {
417
+ const usage = message.usage;
418
+ if (!usage) return;
419
+ const job = this.store.job(worker.job.id);
420
+ if (!job) return;
421
+ const before = job.usage ?? { input: 0, output: 0, cost: 0 };
422
+ const next: Job = {
423
+ ...job,
424
+ usage: {
425
+ input: before.input + usage.input,
426
+ output: before.output + usage.output,
427
+ cost: Number((before.cost + usage.cost.total).toFixed(6)),
428
+ },
429
+ };
430
+ this.store.saveJob(next);
431
+ worker.job = next;
432
+ this.bus.emit({ type: "job", job: next });
433
+ }
434
+
435
+ private async onSettled(worker: WorkerState): Promise<void> {
436
+ if (worker.stopped) return;
437
+ const job = this.store.job(worker.job.id);
438
+ if (!job || job.status === "done" || job.status === "stuck" || job.status === "failed" || job.status === "stopped") return;
439
+ if (worker.reported) {
440
+ await this.applyReport(worker.job.id);
441
+ return;
442
+ }
443
+ if (worker.askedFollowUp) {
444
+ await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
445
+ return;
446
+ }
447
+ worker.askedFollowUp = true;
448
+ const session = worker.session;
449
+ if (!session) {
450
+ await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
451
+ return;
452
+ }
453
+ await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, summary)。");
454
+ }
455
+
456
+ /** report 工具收到结果后由 worker-tools 调用。 */
457
+ async applyReport(workerId: string): Promise<void> {
458
+ const worker = this.workers.get(workerId);
459
+ if (!worker?.reported || worker.stopped) return;
460
+ const status: JobStatus = worker.reported.outcome === "done" ? "done" : "stuck";
461
+ // 不写原因:outcome 只说明模型自己认为没做完,状态词(受阻、待确认)卡片上已经写过一遍,
462
+ // 当原因存下来就会重复。真正的原因由看门狗和停止操作给。
463
+ await this.finish(workerId, status, worker.reported.summary, undefined, worker.reported.files);
464
+ }
465
+
466
+ reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] }): void {
467
+ const worker = this.workers.get(jobId);
468
+ if (!worker) return;
469
+ worker.reported = data;
470
+ worker.finishTimer ??= setTimeout(() => void this.applyReport(jobId), 200);
471
+ }
472
+
473
+ private async finish(
474
+ jobId: string,
475
+ status: JobStatus,
476
+ summary?: string,
477
+ reason?: string,
478
+ files?: string[],
479
+ ): Promise<void> {
480
+ const job = this.store.job(jobId);
481
+ if (!job || ENDED.includes(job.status)) return;
482
+ const worker = this.workers.get(jobId);
483
+ if (worker?.finishTimer) clearTimeout(worker.finishTimer);
484
+ await this.settle(job, status, { summary, reason, files, lastText: worker?.lastText });
485
+
486
+ if (worker?.session) {
487
+ worker.session.dispose();
488
+ }
489
+ this.workers.delete(jobId);
490
+ this.pump();
491
+ }
492
+
493
+ /** 写 report.md、工作日志、推事件。 */
494
+ private async settle(
495
+ job: Job,
496
+ status: JobStatus,
497
+ options: { summary?: string; reason?: string; files?: string[]; lastText?: string },
498
+ ): Promise<void> {
499
+ const endedAt = new Date().toISOString();
500
+ // 报告原样保留(不压成一行、不截断):任务详情和 report.md 要完整的;只有推给助手的事件太长时才截
501
+ const cleanSummary = options.summary?.trim() || undefined;
502
+ const eventSummary = cleanSummary && cleanSummary.length > EVENT_SUMMARY_MAX
503
+ ? `${cleanSummary.slice(0, EVENT_SUMMARY_MAX)}…(报告过长已截断,完整内容在任务详情)`
504
+ : cleanSummary;
505
+ const statusText = status === "done" ? "已完成" : status === "stuck" ? "受阻" : status === "stopped" ? "已停止" : "失败";
506
+ const reportPath = this.store.writeReport(
507
+ job.id,
508
+ [
509
+ `# ${job.title}`,
510
+ "",
511
+ `状态:${statusText}${options.reason ? `(${options.reason})` : ""}`,
512
+ `结束时间:${monthDayTimeText(this.runtime.settings.settings.general.timezone)}`,
513
+ "",
514
+ "## 摘要",
515
+ "",
516
+ cleanSummary ?? "(没有摘要)",
517
+ "",
518
+ "## 任务最后一条回复",
519
+ "",
520
+ options.lastText ?? "(没有回复)",
521
+ "",
522
+ "## 任务说明",
523
+ "",
524
+ job.instruction,
525
+ ].join("\n"),
526
+ );
527
+ const next: Job = {
528
+ ...job,
529
+ status,
530
+ endedAt,
531
+ reportPath,
532
+ ...(job.startedAt ? { durationMs: Date.parse(endedAt) - Date.parse(job.startedAt) } : {}),
533
+ ...(cleanSummary ? { summary: cleanSummary } : {}),
534
+ ...(options.reason ? { statusReason: options.reason } : {}),
535
+ ...(options.files?.length ? { files: options.files } : {}),
536
+ };
537
+ this.store.saveJob(next);
538
+ this.bus.emit({ type: "job", job: next });
539
+ pushJobEnd({ push: this.runtime.push, store: this.store }, next);
540
+ this.publishBusy(job.assistantId);
541
+ const assistant = this.store.assistant(job.assistantId);
542
+ this.store.addWorklog(job.assistantId, {
543
+ at: endedAt,
544
+ text: `任务${statusText}:${job.title}`,
545
+ jobId: job.id,
546
+ });
547
+ this.bus.emit({ type: "profile", assistantId: job.assistantId });
548
+ const { note: filesNote, posted } = await this.postFiles(next);
549
+ // 图片已经开了这一组,回复接在图片后面
550
+ const cont = posted ? ({ cont: true } as const) : undefined;
551
+
552
+ if (job.routineId) {
553
+ const routine = this.store.routine(job.routineId);
554
+ if (routine) {
555
+ const run: RoutineRun = { jobId: job.id, at: endedAt, status };
556
+ const runs = [run, ...routine.runs.filter((item) => item.jobId !== job.id)].slice(0, 20);
557
+ const updated = { ...routine, runs };
558
+ this.store.saveRoutine(updated);
559
+ this.bus.emit({ type: "routine", routine: updated });
560
+ await this.runtime.router
561
+ .event(job.assistantId, `定时任务「${routine.name}」结果:${eventSummary ?? options.reason ?? statusText}${filesNote}`, {
562
+ routine: { id: routine.id, name: routine.name },
563
+ ...cont,
564
+ })
565
+ .catch(() => undefined);
566
+ }
567
+ } else if (assistant) {
568
+ await this.runtime.router
569
+ .event(
570
+ job.assistantId,
571
+ `任务「${job.title}」${statusText}${eventSummary ? `:${eventSummary}` : options.reason ? `:${options.reason}` : ""}${filesNote}`,
572
+ cont,
573
+ )
574
+ .catch(() => undefined);
575
+ }
576
+ }
577
+
578
+ /**
579
+ * 任务产出的图片先直接发到对话里(前端渲染成缩略图),再告诉助手发了什么、还有哪些别的文件,
580
+ * 免得它再派一个任务去"取回"图片。
581
+ */
582
+ private async postFiles(job: Job): Promise<{ note: string; posted: number }> {
583
+ const posted: string[] = [];
584
+ const others: string[] = [];
585
+ for (const path of job.files ?? []) {
586
+ if (isImagePath(path) && existsSync(path)) {
587
+ // 第一张开新的一组:任务结果不跟在派任务那轮回复后面
588
+ await this.runtime.router.addItem(job.assistantId, {
589
+ kind: "file",
590
+ path,
591
+ name: basename(path),
592
+ ...(posted.length ? {} : { lead: true as const }),
593
+ });
594
+ posted.push(basename(path));
595
+ } else {
596
+ others.push(path);
597
+ }
598
+ }
599
+ const lines: string[] = [];
600
+ if (posted.length) lines.push(`产出的图片已经直接发到对话里了(${posted.join("、")}),用户看得到,不要再发或再派任务去取。`);
601
+ if (others.length) lines.push(`产出文件:${others.join("、")}(要给用户就用 send_file)。`);
602
+ return { note: lines.length ? `\n${lines.join("\n")}` : "", posted: posted.length };
603
+ }
604
+
605
+ async reply(jobId: string, text: string): Promise<void> {
606
+ const worker = this.workers.get(jobId);
607
+ if (!worker?.session) throw new Error("这个任务当前未在运行。");
608
+ if (worker.session.isStreaming) await worker.session.steer(text);
609
+ else await worker.session.prompt(text);
610
+ }
611
+
612
+ async stop(jobId: string): Promise<void> {
613
+ const job = this.store.job(jobId);
614
+ if (!job) throw new Error("没有这个任务。");
615
+ const worker = this.workers.get(jobId);
616
+ if (worker) {
617
+ // 先标记再 abort:终止命令的过程中模型可能接着把 report 跑完,那不能把 stopped 改成完成
618
+ worker.stopped = true;
619
+ worker.ask?.("(任务被停了)");
620
+ worker.ask = undefined;
621
+ // abort 会把正在执行的工具(bash 子进程)一起终止
622
+ await worker.session?.abort().catch(() => undefined);
623
+ }
624
+ await this.finish(jobId, "stopped", undefined, "你叫停了");
625
+ }
626
+
627
+ /** Worker 的 ask_user:出审批卡、把任务标成待确认,阻塞到用户答复。 */
628
+ async askQuestion(
629
+ jobId: string,
630
+ input: { title: string; detail?: string; options: ApprovalOption[]; ruleText?: string },
631
+ ): Promise<string> {
632
+ const job = this.store.job(jobId);
633
+ if (!job) throw new Error(`任务不存在:${jobId}`);
634
+ const worker = this.workers.get(jobId);
635
+ const approval: Approval = {
636
+ id: newId("q-"),
637
+ assistantId: job.assistantId,
638
+ title: input.title,
639
+ ...(input.detail ? { detail: input.detail } : {}),
640
+ options: input.options,
641
+ status: "pending",
642
+ source: "job",
643
+ jobId,
644
+ ...(input.ruleText ? { ruleText: input.ruleText } : {}),
645
+ createdAt: new Date().toISOString(),
646
+ };
647
+ this.store.saveApproval(approval);
648
+ // 任务半路来问,是单独的一组,不贴着上一轮回复
649
+ await this.runtime.router.addItem(job.assistantId, { kind: "question", approvalId: approval.id, lead: true });
650
+ this.bus.emit({ type: "approval", approval });
651
+ pushApproval({ push: this.runtime.push, store: this.store }, approval);
652
+ const waiting: Job = { ...job, status: "waiting" };
653
+ this.store.saveJob(waiting);
654
+ this.bus.emit({ type: "job", job: waiting });
655
+ if (worker) worker.job = waiting;
656
+ return await new Promise<string>((resolve) => {
657
+ if (worker) worker.ask = resolve;
658
+ else resolve("(任务未在运行)");
659
+ });
660
+ }
661
+
662
+ /** Worker 的 ask_user 等答案时,审批被答复(answer 是回给模型的那句)。 */
663
+ resolveAsk(approval: Approval, answer: string): void {
664
+ const worker = approval.jobId ? this.workers.get(approval.jobId) : undefined;
665
+ const job = approval.jobId ? this.store.job(approval.jobId) : undefined;
666
+ if (job && job.status === "waiting") {
667
+ const next: Job = { ...job, status: "running" };
668
+ this.store.saveJob(next);
669
+ this.bus.emit({ type: "job", job: next });
670
+ if (worker) worker.job = next;
671
+ }
672
+ worker?.ask?.(answer);
673
+ if (worker) worker.ask = undefined;
674
+ }
675
+
676
+ startWatchdog(): void {
677
+ this.watchdog ??= setInterval(() => {
678
+ const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
679
+ for (const [jobId, worker] of this.workers) {
680
+ if (Date.now() - worker.lastActivityAt > idleMs) {
681
+ void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
682
+ }
683
+ }
684
+ // 并发小了以后(设置里改的),排队的任务还在等,这里补一次泵
685
+ this.pump();
686
+ }, 30_000);
687
+ this.watchdog.unref?.();
688
+ }
689
+
690
+ stopWatchdog(): void {
691
+ if (this.watchdog) clearInterval(this.watchdog);
692
+ this.watchdog = undefined;
693
+ }
694
+
695
+ /** 进程重启:没跑完的任务标失败。 */
696
+ recover(): void {
697
+ const now = new Date().toISOString();
698
+ for (const job of this.store.jobs()) {
699
+ if (job.status === "running" || job.status === "queued" || job.status === "waiting") {
700
+ const next: Job = { ...job, status: "failed", endedAt: now, statusReason: "Hob 重启,任务中断" };
701
+ this.store.saveJob(next);
702
+ this.bus.emit({ type: "job", job: next });
703
+ }
704
+ }
705
+ for (const approval of this.store.approvals()) {
706
+ if (approval.status === "pending" && approval.source === "job") {
707
+ this.store.saveApproval({ ...approval, status: "expired" });
708
+ }
709
+ }
710
+ this.queue = [];
711
+ }
712
+
713
+ disposeAll(): void {
714
+ this.stopWatchdog();
715
+ for (const worker of this.workers.values()) worker.session?.dispose();
716
+ this.workers.clear();
717
+ }
718
+
719
+ /** 测试用:等所有任务结束。 */
720
+ async waitIdle(timeoutMs = 15_000): Promise<void> {
721
+ const started = Date.now();
722
+ while (Date.now() - started < timeoutMs) {
723
+ const active = this.store
724
+ .jobs()
725
+ .filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting");
726
+ if (!active.length) return;
727
+ await new Promise((resolve) => setTimeout(resolve, 25));
728
+ }
729
+ }
730
+ }