@evandu/super-pi 0.1.130 → 0.1.132

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 (88) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-documents/index.ts +92 -0
  3. package/agents/super-pi-feishu/index.ts +88 -0
  4. package/agents/super-pi-knowledge/index.ts +32 -1
  5. package/agents/super-pi-task/core.ts +217 -0
  6. package/agents/super-pi-task/index.ts +74 -1
  7. package/agents/super-pi-web/index.ts +32 -0
  8. package/assets/fonts/LICENSE +92 -0
  9. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  10. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  11. package/dist/client/.super-pi-client-sources.json +13 -10
  12. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  13. package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
  14. package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
  15. package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
  16. package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
  17. package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
  18. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
  19. package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
  20. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
  21. package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
  22. package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
  23. package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
  26. package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
  27. package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
  28. package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
  29. package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
  30. package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
  31. package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
  32. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
  33. package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
  34. package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
  35. package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
  36. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/server/application/document-service.d.ts +68 -0
  39. package/dist/server/application/document-service.js +120 -0
  40. package/dist/server/application/feishu-service.d.ts +132 -0
  41. package/dist/server/application/feishu-service.js +264 -0
  42. package/dist/server/application/run-service.js +49 -7
  43. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  44. package/dist/server/application/scheduled-task-service.js +350 -0
  45. package/dist/server/command-handler.d.ts +25 -1
  46. package/dist/server/command-handler.js +60 -1
  47. package/dist/server/documents.d.ts +152 -0
  48. package/dist/server/documents.js +998 -0
  49. package/dist/server/feishu-cli.d.ts +207 -0
  50. package/dist/server/feishu-cli.js +419 -0
  51. package/dist/server/file-changes.js +6 -4
  52. package/dist/server/files.d.ts +32 -0
  53. package/dist/server/files.js +31 -3
  54. package/dist/server/http-server.js +324 -2
  55. package/dist/server/index.js +41 -2
  56. package/dist/server/inline-thinking.d.ts +90 -0
  57. package/dist/server/inline-thinking.js +181 -0
  58. package/dist/server/knowledge.d.ts +24 -14
  59. package/dist/server/knowledge.js +57 -34
  60. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  61. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  62. package/dist/server/pi-session-pool.js +2 -1
  63. package/dist/server/pi-session.d.ts +4 -0
  64. package/dist/server/pi-session.js +14 -0
  65. package/dist/server/presentations/schema.d.ts +120 -0
  66. package/dist/server/presentations/schema.js +128 -0
  67. package/dist/server/runtime.d.ts +67 -0
  68. package/dist/server/runtime.js +204 -18
  69. package/dist/server/scheduled-tasks.d.ts +101 -0
  70. package/dist/server/scheduled-tasks.js +138 -0
  71. package/dist/server/server-context.d.ts +19 -0
  72. package/dist/server/store.d.ts +30 -0
  73. package/dist/server/store.js +81 -2
  74. package/dist/server/subagents.js +33 -6
  75. package/dist/server/task-tool.d.ts +18 -0
  76. package/dist/server/task-tool.js +18 -0
  77. package/dist/server/types.d.ts +56 -0
  78. package/dist/server/web.d.ts +24 -0
  79. package/dist/server/web.js +180 -0
  80. package/package.json +45 -6
  81. package/skills/presentation/SKILL.md +30 -0
  82. package/skills/presentation/references/data.md +10 -0
  83. package/skills/presentation/references/quality.md +12 -0
  84. package/skills/presentation/references/story.md +12 -0
  85. package/skills/presentation/references/templates-and-editing.md +11 -0
  86. package/skills/presentation/references/visual-system.md +10 -0
  87. package/dist/.super-pi-check-inputs.json +0 -4
  88. package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
@@ -60,7 +60,14 @@ function composePrompt(message) {
60
60
  const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
61
61
  const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
62
62
  const state = stateMatch ? stateMatch[1] : "completed";
63
- const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
63
+ // GREEDY ON PURPOSE. The envelope is machine-built with the command's RAW output interpolated before
64
+ // its closing tags, so the body may itself contain `</task_result>` - printed XML, a log excerpt, the
65
+ // model's own echoed envelope format. A non-greedy match stops at the FIRST such tag and hands the
66
+ // model a truncated half-result while the reader sees the full one (measured by
67
+ // scripts/background-result-body.test.mjs with `hello </task_result> world`). Matching greedily lands
68
+ // on the envelope's own closing tag - the LAST one in the content - which is where the body ends
69
+ // regardless of what the command printed.
70
+ const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*)\n?<\/(?:task_result|task_error)>/.exec(content);
64
71
  const body = bodyMatch ? bodyMatch[1] : content;
65
72
  const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
66
73
  return `[后台任务「${summary}」${stateLabel}]\n\n${body}`;
@@ -239,14 +246,33 @@ export class RunService {
239
246
  // processes the result" contract, and the auto-triggered turn goes through the same
240
247
  // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
241
248
  // prose the agent can react to).
249
+ // EXACTLY ONE DELIVERY PER RESULT. The custom channel hands the result straight to Pi's own
250
+ // follow-up queue, but it knows nothing about the `waiting` row queued above, and
251
+ // `takeNextWaitingMessage` knows nothing about Pi's queue - so delivering through the channel
252
+ // while leaving the row waiting gave the model the SAME result twice (once as the custom
253
+ // message, once as the row's own prose turn). Measured by
254
+ // scripts/background-result-delivery.test.mjs. So a successful custom delivery CONSUMES the row,
255
+ // and a failed one - `SteerUnavailableError`, which is what oneshot mode and a not-yet-created
256
+ // session throw - falls back to the queue path, the same semantics the sub-agent result path
257
+ // (processWaitingMessage only) has always had.
258
+ let deliveredViaCustom = false;
242
259
  const custom = this.runtime.sendCustomMessage;
243
260
  if (custom) {
244
- await custom.call(this.runtime, task.conversationId, {
245
- customType: "super-pi-task-result",
246
- content: body,
247
- display: "后台任务结果",
248
- details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
249
- });
261
+ try {
262
+ await custom.call(this.runtime, task.conversationId, {
263
+ customType: "super-pi-task-result",
264
+ content: body,
265
+ display: "后台任务结果",
266
+ details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
267
+ });
268
+ deliveredViaCustom = true;
269
+ }
270
+ catch (error) {
271
+ console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
272
+ }
273
+ }
274
+ if (deliveredViaCustom) {
275
+ await this.store.takeWaitingMessage(task.conversationId, queued.id);
250
276
  }
251
277
  else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
252
278
  void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
@@ -551,6 +577,22 @@ export class RunService {
551
577
  async stopRunUnsafe(conversationId) {
552
578
  this.memory?.cancel(conversationId);
553
579
  this.extensionUiService.cancelPendingUi(conversationId, "aborted");
580
+ // THE `question` TOOL IS A PENDING ASK THE STOP MUST SETTLE, not a client-only state. Its row only
581
+ // ever leaves `pending` through the user's `question.answer` command, so a stop used to leave the
582
+ // ask waiting forever: the extension's HTTP handler never returned, the ask's 250ms full-snapshot
583
+ // poll never cleared, and the card stayed interactive on a dead turn (measured by
584
+ // scripts/question-stop.test.mjs). The same settlement the Extension UI channel gets from
585
+ // `cancelPendingUi` above - answered `cancelled`, never a default - applied to the question rows.
586
+ const stopped = this.store.snapshot().conversations.find((item) => item.id === conversationId);
587
+ for (const message of stopped?.messages ?? []) {
588
+ for (let index = 0; index < (message.toolCalls?.length ?? 0); index += 1) {
589
+ const question = message.toolCalls?.[index]?.question;
590
+ if (question?.status !== "pending")
591
+ continue;
592
+ const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
593
+ this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
594
+ }
595
+ }
554
596
  await this.store.setStatus(conversationId, "stopped");
555
597
  if (this.subagents.stopConversation(conversationId)) {
556
598
  console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
@@ -0,0 +1,164 @@
1
+ import type { EventBroadcaster } from "../server-context.js";
2
+ import type { Store } from "../store.js";
3
+ import { describeSchedule, type ScheduledTask, type ScheduleSpec } from "../scheduled-tasks.js";
4
+ import type { ModelSelection } from "../types.js";
5
+ import type { ConversationService } from "./conversation-service.js";
6
+ import type { RunService } from "./run-service.js";
7
+ /** The constructor's last parameter: the only seams this service admits. */
8
+ export type ScheduledTaskServiceOptions = {
9
+ /**
10
+ * The TEST SEAM for the clock: how often the sweep runs. Production never passes this and gets the
11
+ * fifteen-second sweep; a test passes a few milliseconds and waits for real time instead of
12
+ * faking timers - the same role `sessionFactory` plays in `PiRpcRuntime`.
13
+ */
14
+ sweepIntervalMs?: number;
15
+ };
16
+ /** What a caller wants a task to be. Everything but the id and the bookkeeping is optional. */
17
+ export type ScheduledTaskInput = {
18
+ name?: string;
19
+ prompt?: string;
20
+ schedule?: unknown;
21
+ workspaceId?: string;
22
+ model?: ModelSelection | null;
23
+ agentId?: string | null;
24
+ enabled?: boolean;
25
+ };
26
+ /**
27
+ * THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
28
+ *
29
+ * WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
30
+ * clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
31
+ * opened, repaired and closed without a timer running behind it - and it is why a scheduled task
32
+ * survives a restart as a RECORD while the thing that fires it is built fresh from that record.
33
+ */
34
+ export declare class ScheduledTaskService {
35
+ private readonly store;
36
+ private readonly runService;
37
+ private readonly conversationService;
38
+ private readonly broadcaster;
39
+ /** The runs currently in flight, by task id. A task in here is not fired again. */
40
+ private readonly inFlight;
41
+ private timer?;
42
+ private stopped;
43
+ constructor(store: Store, runService: RunService, conversationService: ConversationService, broadcaster: EventBroadcaster, options?: ScheduledTaskServiceOptions);
44
+ private readonly sweepIntervalMs;
45
+ /**
46
+ * Starts the clock.
47
+ *
48
+ * `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
49
+ * the store, and a pending interval would keep the process alive after that, which is the difference
50
+ * between a server that exits and one that has to be killed.
51
+ */
52
+ start(): void;
53
+ /**
54
+ * Stops the clock and waits for whatever is running.
55
+ *
56
+ * The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
57
+ * a real conversation, and killing it because the server is shutting down would throw that work
58
+ * away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
59
+ * disk.
60
+ */
61
+ stop(): Promise<void>;
62
+ /** Every task on record, newest first. */
63
+ list(): ScheduledTask[];
64
+ /** The tasks of ONE workspace, which is what the panel shows. */
65
+ listForWorkspace(workspaceId: string | undefined): ScheduledTask[];
66
+ /**
67
+ * The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
68
+ *
69
+ * WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
70
+ * opens its conversation there, uses that workspace's defaults and its tools. A caller in another
71
+ * workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
72
+ * be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
73
+ * a task created from workspace B could be rewritten and then deleted by a conversation in
74
+ * workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
75
+ * - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
76
+ * TEST-026).
77
+ *
78
+ * THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
79
+ * to learn that a task exists in a workspace it cannot see.
80
+ */
81
+ requireForWorkspace(id: string, workspaceId: string): ScheduledTask;
82
+ /**
83
+ * Creates a task.
84
+ *
85
+ * `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
86
+ * asked for and a task an agent invented look identical in the list, and the difference is exactly
87
+ * what a person wants to know before they delete it.
88
+ */
89
+ create(input: ScheduledTaskInput, createdBy: "user" | "agent"): Promise<ScheduledTask>;
90
+ /**
91
+ * Changes a task.
92
+ *
93
+ * EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
94
+ * slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
95
+ * must not keep the old next time. The one exception is a manual trigger, which is not a change at
96
+ * all: it runs the task once and leaves the clock where it was.
97
+ */
98
+ update(id: string, input: ScheduledTaskInput): Promise<ScheduledTask>;
99
+ /** Turns a task on or off. */
100
+ setEnabled(id: string, enabled: boolean): Promise<ScheduledTask>;
101
+ /** Removes a task. Its conversation stays: it holds the answers the task produced. */
102
+ remove(id: string): Promise<void>;
103
+ /**
104
+ * Runs a task NOW, once.
105
+ *
106
+ * The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
107
+ * does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
108
+ * and conflating them would make 「先试一次」 silently rewrite the user's schedule.
109
+ *
110
+ * THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
111
+ * seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
112
+ * whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
113
+ * and arrives at the panel through the ordinary state broadcast, where a person reads it.
114
+ */
115
+ trigger(id: string): Promise<void>;
116
+ /**
117
+ * The sweep: every task whose moment has come runs once.
118
+ *
119
+ * The tasks that are due are collected BEFORE any of them runs, and each one advances its own
120
+ * `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
121
+ * sweep, and two sweeps can never both pick up the same task.
122
+ */
123
+ private sweep;
124
+ /**
125
+ * THE ACTUAL RUN, for both the clock and the button.
126
+ *
127
+ * The task gets its OWN conversation, created on the first run, and the prompt is sent into it
128
+ * verbatim as a user message - the same path a person's own message takes. That sameness is the point:
129
+ * a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
130
+ * the same model, the same tools, the same memory.
131
+ */
132
+ private fire;
133
+ /** The body of a run: get a conversation, put the prompt into it, remember how it went. */
134
+ private runTask;
135
+ /**
136
+ * The conversation the task runs in, created the first time it is needed.
137
+ *
138
+ * LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
139
+ * what the conversation is, because it appears in the conversation list like any other and the user
140
+ * has to be able to tell it apart from their own work.
141
+ */
142
+ private ensureConversation;
143
+ /**
144
+ * Puts the task's model and agent on its conversation.
145
+ *
146
+ * EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
147
+ * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
148
+ * cannot be silently ignored, because the run would then happen on some other model and the panel
149
+ * would say it succeeded.
150
+ */
151
+ private applyConfiguration;
152
+ /** Writes down how the run went, and tells the panel. */
153
+ private record;
154
+ /** The task, or a refusal in words the reader can act on. */
155
+ private require;
156
+ }
157
+ /**
158
+ * The schedule in words, for the text an agent reads back after creating a task.
159
+ *
160
+ * Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
161
+ * say the same thing about one schedule.
162
+ */
163
+ export { describeSchedule };
164
+ export type { ScheduledTask, ScheduleSpec };
@@ -0,0 +1,350 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { describeSchedule, nextRunAt, normalizeSchedule } from "../scheduled-tasks.js";
3
+ /**
4
+ * HOW OFTEN THE SCHEDULER LOOKS.
5
+ *
6
+ * A sweep every fifteen seconds, rather than one timer per task. A per-task timer would fire closer to
7
+ * the requested minute, and it would need a timer to be created, replaced and destroyed every time a
8
+ * task was edited - a class of bug (a timer left pointing at the old schedule) that a single sweep
9
+ * cannot have. The cost of the sweep is a scan of a list that is bounded at a hundred entries.
10
+ *
11
+ * FIFTEEN SECONDS IS ALSO THE HONEST ANSWER to "how precise is 'every day at 09:00'": a task can fire
12
+ * up to fifteen seconds after the minute it was asked for, and never before it.
13
+ */
14
+ const SWEEP_INTERVAL_MS = 15_000;
15
+ /**
16
+ * THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
17
+ *
18
+ * WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
19
+ * clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
20
+ * opened, repaired and closed without a timer running behind it - and it is why a scheduled task
21
+ * survives a restart as a RECORD while the thing that fires it is built fresh from that record.
22
+ */
23
+ export class ScheduledTaskService {
24
+ store;
25
+ runService;
26
+ conversationService;
27
+ broadcaster;
28
+ /** The runs currently in flight, by task id. A task in here is not fired again. */
29
+ inFlight = new Map();
30
+ timer;
31
+ stopped = true;
32
+ constructor(store, runService, conversationService, broadcaster, options = {}) {
33
+ this.store = store;
34
+ this.runService = runService;
35
+ this.conversationService = conversationService;
36
+ this.broadcaster = broadcaster;
37
+ this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
38
+ }
39
+ sweepIntervalMs;
40
+ /**
41
+ * Starts the clock.
42
+ *
43
+ * `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
44
+ * the store, and a pending interval would keep the process alive after that, which is the difference
45
+ * between a server that exits and one that has to be killed.
46
+ */
47
+ start() {
48
+ if (this.timer)
49
+ return;
50
+ this.stopped = false;
51
+ this.timer = setInterval(() => { void this.sweep(); }, this.sweepIntervalMs);
52
+ this.timer.unref?.();
53
+ }
54
+ /**
55
+ * Stops the clock and waits for whatever is running.
56
+ *
57
+ * The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
58
+ * a real conversation, and killing it because the server is shutting down would throw that work
59
+ * away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
60
+ * disk.
61
+ */
62
+ async stop() {
63
+ this.stopped = true;
64
+ if (this.timer) {
65
+ clearInterval(this.timer);
66
+ this.timer = undefined;
67
+ }
68
+ await Promise.allSettled([...this.inFlight.values()]);
69
+ }
70
+ /** Every task on record, newest first. */
71
+ list() {
72
+ return [...(this.store.snapshot().scheduledTasks ?? [])].sort((a, b) => b.createdAt - a.createdAt);
73
+ }
74
+ /** The tasks of ONE workspace, which is what the panel shows. */
75
+ listForWorkspace(workspaceId) {
76
+ if (!workspaceId)
77
+ return [];
78
+ return this.list().filter((task) => task.workspaceId === workspaceId);
79
+ }
80
+ /**
81
+ * The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
82
+ *
83
+ * WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
84
+ * opens its conversation there, uses that workspace's defaults and its tools. A caller in another
85
+ * workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
86
+ * be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
87
+ * a task created from workspace B could be rewritten and then deleted by a conversation in
88
+ * workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
89
+ * - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
90
+ * TEST-026).
91
+ *
92
+ * THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
93
+ * to learn that a task exists in a workspace it cannot see.
94
+ */
95
+ requireForWorkspace(id, workspaceId) {
96
+ const task = this.listForWorkspace(workspaceId).find((item) => item.id === id);
97
+ if (!task)
98
+ throw new Error("找不到这个定时任务。");
99
+ return task;
100
+ }
101
+ /**
102
+ * Creates a task.
103
+ *
104
+ * `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
105
+ * asked for and a task an agent invented look identical in the list, and the difference is exactly
106
+ * what a person wants to know before they delete it.
107
+ */
108
+ async create(input, createdBy) {
109
+ const prompt = (input.prompt ?? "").trim();
110
+ if (!prompt)
111
+ throw new Error("定时任务需要一段提示词:到点时这段话会被当作用户消息发给 agent。");
112
+ const workspaceId = (input.workspaceId ?? "").trim();
113
+ if (!workspaceId)
114
+ throw new Error("定时任务需要属于一个工作区。");
115
+ if (!this.store.snapshot().workspaces.some((workspace) => workspace.id === workspaceId))
116
+ throw new Error("找不到这个工作区。");
117
+ const schedule = normalizeSchedule(input.schedule);
118
+ const now = Date.now();
119
+ const firstRunAt = nextRunAt(schedule, now);
120
+ // A ONE-SHOT TASK WHOSE MOMENT HAS PASSED is not a task, it is a memory of one. Refusing here is
121
+ // what keeps it from being created with no next run and never firing - a task that exists but
122
+ // cannot run is the worst outcome, because nothing on screen explains why.
123
+ if (schedule.kind === "once" && firstRunAt === undefined)
124
+ throw new Error("一次性任务的触发时刻要在将来。");
125
+ const task = {
126
+ id: `scheduled_${randomUUID()}`,
127
+ name: (input.name ?? "").trim() || defaultName(prompt),
128
+ prompt,
129
+ workspaceId,
130
+ schedule,
131
+ enabled: input.enabled === false ? false : true,
132
+ nextRunAt: firstRunAt,
133
+ createdBy,
134
+ createdAt: now,
135
+ updatedAt: now,
136
+ ...(input.model ? { model: input.model } : {}),
137
+ ...(input.agentId ? { agentId: input.agentId } : {}),
138
+ };
139
+ const stored = await this.store.setScheduledTask(task);
140
+ this.broadcaster.broadcastState();
141
+ return stored;
142
+ }
143
+ /**
144
+ * Changes a task.
145
+ *
146
+ * EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
147
+ * slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
148
+ * must not keep the old next time. The one exception is a manual trigger, which is not a change at
149
+ * all: it runs the task once and leaves the clock where it was.
150
+ */
151
+ async update(id, input) {
152
+ const task = this.require(id);
153
+ const now = Date.now();
154
+ const schedule = input.schedule === undefined ? task.schedule : normalizeSchedule(input.schedule);
155
+ const updated = {
156
+ ...task,
157
+ name: input.name === undefined ? task.name : (input.name.trim() || task.name),
158
+ prompt: input.prompt === undefined ? task.prompt : (input.prompt.trim() || task.prompt),
159
+ schedule,
160
+ enabled: input.enabled === undefined ? task.enabled : input.enabled,
161
+ updatedAt: now,
162
+ };
163
+ if (input.model !== undefined) {
164
+ if (input.model)
165
+ updated.model = input.model;
166
+ else
167
+ delete updated.model;
168
+ }
169
+ if (input.agentId !== undefined) {
170
+ if (input.agentId)
171
+ updated.agentId = input.agentId;
172
+ else
173
+ delete updated.agentId;
174
+ }
175
+ // A DISABLED TASK HAS NO NEXT TIME, and an enabled one always has one - unless it is a one-shot
176
+ // whose moment has passed, which is a finished task rather than a broken one.
177
+ updated.nextRunAt = updated.enabled ? nextRunAt(schedule, now) : undefined;
178
+ const stored = await this.store.setScheduledTask(updated);
179
+ this.broadcaster.broadcastState();
180
+ return stored;
181
+ }
182
+ /** Turns a task on or off. */
183
+ async setEnabled(id, enabled) {
184
+ const task = this.require(id);
185
+ if (task.enabled === enabled)
186
+ return task;
187
+ return this.update(id, { enabled });
188
+ }
189
+ /** Removes a task. Its conversation stays: it holds the answers the task produced. */
190
+ async remove(id) {
191
+ this.require(id);
192
+ await this.store.removeScheduledTask(id);
193
+ this.broadcaster.broadcastState();
194
+ }
195
+ /**
196
+ * Runs a task NOW, once.
197
+ *
198
+ * The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
199
+ * does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
200
+ * and conflating them would make 「先试一次」 silently rewrite the user's schedule.
201
+ *
202
+ * THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
203
+ * seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
204
+ * whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
205
+ * and arrives at the panel through the ordinary state broadcast, where a person reads it.
206
+ */
207
+ async trigger(id) {
208
+ const task = this.require(id);
209
+ this.fire(task, "manual").catch(() => undefined);
210
+ }
211
+ /**
212
+ * The sweep: every task whose moment has come runs once.
213
+ *
214
+ * The tasks that are due are collected BEFORE any of them runs, and each one advances its own
215
+ * `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
216
+ * sweep, and two sweeps can never both pick up the same task.
217
+ */
218
+ async sweep() {
219
+ if (this.stopped)
220
+ return;
221
+ const now = Date.now();
222
+ const due = (this.store.snapshot().scheduledTasks ?? []).filter((task) => task.enabled && task.nextRunAt !== undefined && task.nextRunAt <= now);
223
+ for (const task of due) {
224
+ if (this.inFlight.has(task.id))
225
+ continue;
226
+ const next = nextRunAt(task.schedule, now);
227
+ await this.store.setScheduledTask({
228
+ ...task,
229
+ // A ONE-SHOT TASK IS FINISHED once it has fired. It is disabled rather than deleted, because
230
+ // the answers it produced are worth keeping and the user can see that it ran.
231
+ enabled: next === undefined ? false : task.enabled,
232
+ nextRunAt: next,
233
+ updatedAt: now,
234
+ });
235
+ this.broadcaster.broadcastState();
236
+ void this.fire(task, "schedule");
237
+ }
238
+ }
239
+ /**
240
+ * THE ACTUAL RUN, for both the clock and the button.
241
+ *
242
+ * The task gets its OWN conversation, created on the first run, and the prompt is sent into it
243
+ * verbatim as a user message - the same path a person's own message takes. That sameness is the point:
244
+ * a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
245
+ * the same model, the same tools, the same memory.
246
+ */
247
+ async fire(task, reason) {
248
+ const running = this.inFlight.get(task.id);
249
+ if (running) {
250
+ await running;
251
+ return;
252
+ }
253
+ const promise = this.runTask(task, reason).finally(() => { this.inFlight.delete(task.id); });
254
+ this.inFlight.set(task.id, promise);
255
+ await promise;
256
+ }
257
+ /** The body of a run: get a conversation, put the prompt into it, remember how it went. */
258
+ async runTask(task, reason) {
259
+ const startedAt = Date.now();
260
+ try {
261
+ const conversationId = await this.ensureConversation(task);
262
+ await this.runService.enqueueAndRun(conversationId, task.prompt);
263
+ await this.record(task, { status: "completed", message: reason === "manual" ? "手动触发完成" : "按时触发完成", at: startedAt });
264
+ }
265
+ catch (error) {
266
+ // A FAILED RUN IS A RECORD, not a silence. The task keeps its schedule - a broken model or a
267
+ // missing workspace is worth retrying at the next slot - and the failure is written where the
268
+ // panel can show it.
269
+ await this.record(task, { status: "failed", message: error instanceof Error ? error.message : String(error), at: startedAt });
270
+ }
271
+ }
272
+ /**
273
+ * The conversation the task runs in, created the first time it is needed.
274
+ *
275
+ * LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
276
+ * what the conversation is, because it appears in the conversation list like any other and the user
277
+ * has to be able to tell it apart from their own work.
278
+ */
279
+ async ensureConversation(task) {
280
+ const state = this.store.snapshot();
281
+ const existing = task.conversationId ? state.conversations.find((conversation) => conversation.id === task.conversationId) : undefined;
282
+ if (existing) {
283
+ await this.applyConfiguration(task, existing.id);
284
+ return existing.id;
285
+ }
286
+ const conversationId = await this.conversationService.createConversation(task.workspaceId);
287
+ await this.conversationService.renameConversation(conversationId, `定时:${task.name}`);
288
+ // THE CURRENT ROW, NOT THE STALE ONE. `task` is the snapshot the caller was holding, and its
289
+ // `nextRunAt` may already have been advanced by a sweep that fired this run - writing it back
290
+ // would move the clock backwards and the next sweep would fire the task a second time. The
291
+ // fresh row is read before the write, so the only field this changes is the one this line
292
+ // means to change.
293
+ const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
294
+ await this.store.setScheduledTask({ ...(current ?? task), conversationId, updatedAt: Date.now() });
295
+ await this.applyConfiguration(task, conversationId);
296
+ return conversationId;
297
+ }
298
+ /**
299
+ * Puts the task's model and agent on its conversation.
300
+ *
301
+ * EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
302
+ * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
303
+ * cannot be silently ignored, because the run would then happen on some other model and the panel
304
+ * would say it succeeded.
305
+ */
306
+ async applyConfiguration(task, conversationId) {
307
+ if (task.model) {
308
+ await this.conversationService.setModel(conversationId, task.model.provider, task.model.modelId);
309
+ }
310
+ if (task.agentId) {
311
+ await this.conversationService.setAgent(conversationId, task.agentId);
312
+ }
313
+ }
314
+ /** Writes down how the run went, and tells the panel. */
315
+ async record(task, outcome) {
316
+ const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
317
+ if (!current)
318
+ return;
319
+ await this.store.setScheduledTask({
320
+ ...current,
321
+ lastRunAt: outcome.at,
322
+ lastRunStatus: outcome.status,
323
+ lastRunMessage: outcome.message,
324
+ updatedAt: Date.now(),
325
+ });
326
+ this.broadcaster.broadcastState();
327
+ }
328
+ /** The task, or a refusal in words the reader can act on. */
329
+ require(id) {
330
+ const task = this.store.snapshot().scheduledTasks?.find((item) => item.id === id);
331
+ if (!task)
332
+ throw new Error("找不到这个定时任务。");
333
+ return task;
334
+ }
335
+ }
336
+ /**
337
+ * The name a task gets when nobody gave it one: the first line of its prompt, cut to something that
338
+ * fits on a line in the panel.
339
+ */
340
+ function defaultName(prompt) {
341
+ const firstLine = prompt.split("\n").map((line) => line.trim()).find(Boolean) ?? prompt.trim();
342
+ return firstLine.length > 24 ? `${firstLine.slice(0, 24)}…` : firstLine;
343
+ }
344
+ /**
345
+ * The schedule in words, for the text an agent reads back after creating a task.
346
+ *
347
+ * Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
348
+ * say the same thing about one schedule.
349
+ */
350
+ export { describeSchedule };
@@ -5,6 +5,7 @@ import type { RunService } from "./application/run-service.js";
5
5
  import type { ExtensionUiService } from "./application/extension-ui-service.js";
6
6
  import type { CatalogService } from "./application/catalog-service.js";
7
7
  import type { ProviderService } from "./application/provider-service.js";
8
+ import type { ScheduledTaskService } from "./application/scheduled-task-service.js";
8
9
  import type { TerminalService } from "./terminal.js";
9
10
  export type SocketSender = (event: ServerEvent) => void;
10
11
  /**
@@ -25,6 +26,14 @@ export declare class CommandHandler {
25
26
  * and a missing service has to say so rather than take the whole handler down.
26
27
  */
27
28
  private readonly providerService?;
29
+ /**
30
+ * The scheduled tasks, and the clock behind them.
31
+ *
32
+ * Optional because the handler is constructed by tests that have no clock to give it, and a missing
33
+ * service has to say so rather than take the whole handler down - the same rule `providerService`
34
+ * follows.
35
+ */
36
+ private readonly scheduledTaskService?;
28
37
  /**
29
38
  * The built-in terminal service.
30
39
  *
@@ -40,7 +49,15 @@ export declare class CommandHandler {
40
49
  * Optional because the command handler is constructed by tests that do not care about providers,
41
50
  * and a missing service has to say so rather than take the whole handler down.
42
51
  */
43
- providerService?: ProviderService | undefined);
52
+ providerService?: ProviderService | undefined,
53
+ /**
54
+ * The scheduled tasks, and the clock behind them.
55
+ *
56
+ * Optional because the handler is constructed by tests that have no clock to give it, and a missing
57
+ * service has to say so rather than take the whole handler down - the same rule `providerService`
58
+ * follows.
59
+ */
60
+ scheduledTaskService?: ScheduledTaskService | undefined);
44
61
  /**
45
62
  * Processes a client command.
46
63
  *
@@ -64,4 +81,11 @@ export declare class CommandHandler {
64
81
  /** The other half of `claimRevert`. Safe to call for a conversation nothing claimed. */
65
82
  releaseRevert(conversationId: string): void;
66
83
  handleCommand(command: ClientCommand, sendToSocket?: SocketSender, remoteAddress?: string): Promise<string | void>;
84
+ /**
85
+ * The scheduled-task service, or a refusal.
86
+ *
87
+ * A refusal in the client's own words rather than a crash: the panel that sent the command shows
88
+ * the message, and a handler that threw a TypeError would show nothing but a failed operation.
89
+ */
90
+ private requireScheduledTasks;
67
91
  }