@evandu/super-pi 0.1.145 → 0.1.147

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 (93) hide show
  1. package/agents/super-pi-task/core.ts +74 -2
  2. package/agents/super-pi-task/index.ts +7 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +11 -11
  5. package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
  6. package/dist/client/assets/{dist-DM4Olwfx.js → dist-1Innu17E.js} +1 -1
  7. package/dist/client/assets/{dist-bIDce5j-.js → dist-1lynFOc5.js} +1 -1
  8. package/dist/client/assets/{dist-BlxwDnWr.js → dist-B1qLZVMK.js} +1 -1
  9. package/dist/client/assets/{dist-BTVbZigv.js → dist-B4pz_xdd.js} +1 -1
  10. package/dist/client/assets/{dist-UJ46aPS3.js → dist-B6KwDpeI.js} +1 -1
  11. package/dist/client/assets/{dist-CK2KFFtT.js → dist-BJVI1Unv.js} +1 -1
  12. package/dist/client/assets/{dist-CRnm1rnO.js → dist-BWvzD8Pe.js} +1 -1
  13. package/dist/client/assets/{dist-DSHM-PAs.js → dist-BayWssd0.js} +1 -1
  14. package/dist/client/assets/{dist-B9DE7sc5.js → dist-Bk_pkBVH.js} +1 -1
  15. package/dist/client/assets/{dist-D-o7iQ8c.js → dist-BpeClAv0.js} +1 -1
  16. package/dist/client/assets/{dist-CQFASxyb.js → dist-BsmJg5PX.js} +1 -1
  17. package/dist/client/assets/{dist-Ds5gfeAi.js → dist-Bx7s4rni.js} +1 -1
  18. package/dist/client/assets/{dist-UPJPMjWv.js → dist-CDBKM9tL.js} +1 -1
  19. package/dist/client/assets/{dist-CIgjifH-.js → dist-CGzXCzCG.js} +1 -1
  20. package/dist/client/assets/{dist-BAiV8I_O.js → dist-CRxCwn-Z.js} +1 -1
  21. package/dist/client/assets/{dist-Br-AuQUG.js → dist-CTDgUnBG.js} +1 -1
  22. package/dist/client/assets/{dist-4JbFTwg2.js → dist-D1VfMWmf.js} +1 -1
  23. package/dist/client/assets/{dist-BYOpvW02.js → dist-DTZLNEci.js} +1 -1
  24. package/dist/client/assets/{dist-CQV5kOCE.js → dist-DuUi29km.js} +1 -1
  25. package/dist/client/assets/{dist-CjszAoXk.js → dist-F3S0t1oM.js} +1 -1
  26. package/dist/client/assets/{dist-2boAf_pN.js → dist-QZ-FAVJV.js} +1 -1
  27. package/dist/client/assets/{dist-B1p4YF9C.js → dist-u0nBg0fZ.js} +1 -1
  28. package/dist/client/assets/index-BMpqviwU.css +1 -0
  29. package/dist/client/assets/index-Dzyp_7sf.js +102 -0
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/active-memory.d.ts +17 -0
  32. package/dist/server/active-memory.js +39 -0
  33. package/dist/server/agents.d.ts +1 -1
  34. package/dist/server/agents.js +3 -1
  35. package/dist/server/application/conversation-service.d.ts +1 -1
  36. package/dist/server/application/conversation-service.js +12 -4
  37. package/dist/server/application/run-service.d.ts +32 -2
  38. package/dist/server/application/run-service.js +296 -40
  39. package/dist/server/application/scheduled-task-service.d.ts +14 -1
  40. package/dist/server/application/scheduled-task-service.js +27 -2
  41. package/dist/server/application/session-orchestrator-service.d.ts +114 -3
  42. package/dist/server/application/session-orchestrator-service.js +406 -35
  43. package/dist/server/application/session-watchdog.d.ts +159 -0
  44. package/dist/server/application/session-watchdog.js +244 -0
  45. package/dist/server/command-handler.js +1 -1
  46. package/dist/server/conversation-usage.d.ts +19 -0
  47. package/dist/server/conversation-usage.js +40 -0
  48. package/dist/server/file-diff.d.ts +3 -0
  49. package/dist/server/file-diff.js +4 -4
  50. package/dist/server/file-index.d.ts +4 -2
  51. package/dist/server/file-index.js +14 -9
  52. package/dist/server/file-name-index.d.ts +5 -2
  53. package/dist/server/file-name-index.js +22 -5
  54. package/dist/server/git.d.ts +3 -3
  55. package/dist/server/git.js +10 -9
  56. package/dist/server/http-server.js +256 -34
  57. package/dist/server/index.js +3 -0
  58. package/dist/server/knowledge-vectors.d.ts +15 -0
  59. package/dist/server/knowledge-vectors.js +118 -37
  60. package/dist/server/knowledge.d.ts +7 -8
  61. package/dist/server/knowledge.js +133 -55
  62. package/dist/server/length-continuation.d.ts +50 -0
  63. package/dist/server/length-continuation.js +55 -0
  64. package/dist/server/model-context-window.d.ts +46 -0
  65. package/dist/server/model-context-window.js +121 -0
  66. package/dist/server/pi-session-events.js +5 -1
  67. package/dist/server/pi-session-pool.d.ts +2 -0
  68. package/dist/server/pi-session-pool.js +16 -0
  69. package/dist/server/pi-session.d.ts +6 -0
  70. package/dist/server/pi-session.js +15 -1
  71. package/dist/server/runtime.d.ts +3 -0
  72. package/dist/server/runtime.js +23 -7
  73. package/dist/server/session-cwd.d.ts +17 -0
  74. package/dist/server/session-cwd.js +43 -0
  75. package/dist/server/session-ledger.d.ts +7 -0
  76. package/dist/server/session-tree-scope.d.ts +125 -0
  77. package/dist/server/session-tree-scope.js +337 -0
  78. package/dist/server/store.d.ts +35 -0
  79. package/dist/server/store.js +87 -2
  80. package/dist/server/task-tool.d.ts +2 -1
  81. package/dist/server/task-tool.js +4 -1
  82. package/dist/server/terminal.d.ts +1 -0
  83. package/dist/server/terminal.js +5 -3
  84. package/dist/server/turn-failure.d.ts +26 -0
  85. package/dist/server/turn-failure.js +67 -1
  86. package/dist/server/types.d.ts +33 -1
  87. package/dist/server/version.d.ts +25 -2
  88. package/dist/server/version.js +98 -26
  89. package/dist/server/ws-server.d.ts +1 -1
  90. package/dist/server/ws-server.js +7 -5
  91. package/package.json +36 -3
  92. package/dist/client/assets/index-BGsP8pt_.js +0 -100
  93. package/dist/client/assets/index-CiIc734l.css +0 -1
@@ -1,8 +1,21 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { capText } from "../workflow.js";
4
+ import { assertSessionCwdInsideWorkspace } from "../session-cwd.js";
5
+ import { SessionWatchdog } from "./session-watchdog.js";
6
+ import { isLengthExceededError } from "../turn-failure.js";
7
+ import { LENGTH_CONTINUATION_PROMPT } from "../length-continuation.js";
4
8
  export const SESSION_OUTPUT_CHAR_LIMIT = 64 * 1024;
5
9
  export const sessionResultRelativePath = (conversationId) => `.super-pi/session-results/${conversationId}.md`;
10
+ /**
11
+ * HOW MANY TIMES A CHILD MAY BE TOLD TO CONTINUE AFTER ITS OUTPUT WAS TRUNCATED.
12
+ *
13
+ * The bound is not about tokens - it is about a model that truncates on EVERY response. Three
14
+ * continuations cover the ordinary case (a long answer split across a few responses) and stop the
15
+ * pathological one. Past the bound the child is reported as unfinished, which is a true sentence
16
+ * about what happened, instead of a loop that never ends.
17
+ */
18
+ export const MAX_LENGTH_CONTINUATIONS = 3;
6
19
  /**
7
20
  * Orchestrator service coordinating sibling and child conversations.
8
21
  *
@@ -15,25 +28,90 @@ export class SessionOrchestratorService {
15
28
  runService;
16
29
  ledger;
17
30
  broadcaster;
18
- constructor(store, conversationService, runService, ledger, broadcaster) {
31
+ watchdog;
32
+ /**
33
+ * HOW MANY TIMES EACH CHILD HAS BEEN TOLD TO CONTINUE AFTER A TRUNCATION.
34
+ *
35
+ * Per child rather than per dispatch, so a child that is given several tasks cannot extend itself
36
+ * indefinitely by being handed a new task each time it runs out of output budget. The entry is
37
+ * dropped as soon as the child finishes, so a child that continues successfully starts fresh on
38
+ * its next task.
39
+ */
40
+ lengthContinuations = new Map();
41
+ /**
42
+ * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT.
43
+ *
44
+ * A child can report explicitly - the `report` action - and that report is the child's own
45
+ * statement of what it did. When the child's turn then ends, the automatic completion report
46
+ * would deliver the same thing a second time, so the automatic path steps aside for a child that
47
+ * is in this set.
48
+ *
49
+ * The entry is added only by a TERMINAL report (`completed` / `failed`), because an interim
50
+ * update is not the child's final word: the task is still running, and the parent will want to
51
+ * hear how it ended.
52
+ */
53
+ reportedChildren = new Set();
54
+ constructor(store, conversationService, runService, ledger, broadcaster, watchdog) {
19
55
  this.store = store;
20
56
  this.conversationService = conversationService;
21
57
  this.runService = runService;
22
58
  this.ledger = ledger;
23
59
  this.broadcaster = broadcaster;
60
+ this.watchdog = watchdog ?? new SessionWatchdog({
61
+ onTimeout: async (event) => {
62
+ await this.handleWatchdogTimeout(event);
63
+ },
64
+ workspacePath: (workspaceId) => this.store.workspace(workspaceId)?.path,
65
+ workspaceIds: () => this.store.workspaces().map((w) => w.id),
66
+ isChildWorking: (childConversationId) => {
67
+ const conversation = this.store.conversation(childConversationId);
68
+ return conversation ? (conversation.status === "running" || conversation.status === "waiting") : false;
69
+ },
70
+ warn: (message) => console.warn(message),
71
+ });
72
+ }
73
+ /**
74
+ * The watchdog timer fired: the child has been silent for longer than the parent declared.
75
+ * Delivers a reminder message to the parent session. Does NOT interrupt or abort the child session.
76
+ */
77
+ async handleWatchdogTimeout(event) {
78
+ const { childConversationId, parentConversationId, timeoutSeconds, elapsedSeconds, childWorking } = event;
79
+ const conversation = this.store.conversation(childConversationId);
80
+ const workspace = conversation ? this.store.workspace(conversation.workspaceId) : undefined;
81
+ const statusText = childWorking ? "运行中" : "空闲";
82
+ const minutes = Math.max(1, Math.round(elapsedSeconds / 60));
83
+ const output = `[Watchdog 提醒] 子会话 ${childConversationId} 已经超过 ${elapsedSeconds} 秒(约 ${minutes} 分钟)未汇报。当前状态:${statusText}。如果需要,你可以主动查看子会话状态或打断其执行。`;
84
+ const resultPath = sessionResultRelativePath(childConversationId);
85
+ if (workspace) {
86
+ try {
87
+ const resultDirectory = join(workspace.path, ".super-pi", "session-results");
88
+ await mkdir(resultDirectory, { recursive: true });
89
+ await writeFile(join(resultDirectory, `${childConversationId}.md`), output, "utf8");
90
+ }
91
+ catch (error) {
92
+ console.warn(`Super Pi: writing watchdog result file failed for ${childConversationId}:`, error instanceof Error ? error.message : String(error));
93
+ }
94
+ }
95
+ await this.runService.enqueueSessionResult({
96
+ conversationId: childConversationId,
97
+ parentConversationId,
98
+ output,
99
+ resultPath,
100
+ });
24
101
  }
25
102
  /**
26
103
  * Spawns a new sibling conversation and immediately dispatches its initial task.
27
104
  */
28
105
  async spawn(options) {
29
- let workspaceId = options.workspaceId;
30
- if (!workspaceId && options.parentConversationId) {
31
- const parent = this.store.conversation(options.parentConversationId);
32
- workspaceId = parent?.workspaceId;
33
- }
106
+ const workspaceId = options.workspaceId?.trim();
34
107
  if (!workspaceId) {
35
- throw new Error("创建兄弟会话需要指定工作区(或提供有效的父会话 ID)。");
108
+ throw new Error("创建兄弟会话必须显式指定 workspaceId;父会话只记录树形关系,不能代替目标工作区。");
36
109
  }
110
+ const workspace = this.store.workspace(workspaceId);
111
+ if (!workspace)
112
+ throw new Error("找不到这个工作区。");
113
+ await assertSessionCwdInsideWorkspace(workspace.path, options.cwd);
114
+ const cwd = options.cwd;
37
115
  // THE PARENTAGE IS WRITTEN AT CREATION TIME, onto the conversation itself - not only into the
38
116
  // in-memory ledger below. The ledger is task/status/progress metadata and dies with the process;
39
117
  // the conversation's parentConversationId/origin is what the sidebar's session tree redraws
@@ -41,7 +119,7 @@ export class SessionOrchestratorService {
41
119
  const conversationId = await this.conversationService.createConversation(workspaceId, {
42
120
  parentConversationId: options.parentConversationId,
43
121
  origin: options.parentConversationId ? "orchestrator" : "user",
44
- });
122
+ }, cwd);
45
123
  // Set title if requested or derive from task
46
124
  if (options.title) {
47
125
  await this.conversationService.renameConversation(conversationId, options.title);
@@ -69,6 +147,16 @@ export class SessionOrchestratorService {
69
147
  task: options.task,
70
148
  status: "idle",
71
149
  });
150
+ // Arm silence watchdog if configured and session has a direct parent
151
+ const watchdogTimeout = options.watchdogTimeoutSeconds ?? options.watchdog_timeout_seconds ?? options.watchdogSeconds;
152
+ if (watchdogTimeout && watchdogTimeout > 0 && options.parentConversationId) {
153
+ await this.watchdog.arm({
154
+ childConversationId: conversationId,
155
+ parentConversationId: options.parentConversationId,
156
+ workspaceId,
157
+ timeoutSeconds: watchdogTimeout,
158
+ });
159
+ }
72
160
  // Immediately dispatch initial work
73
161
  const prompt = options.initialPrompt ?? options.task;
74
162
  if (prompt && prompt.trim()) {
@@ -88,7 +176,11 @@ export class SessionOrchestratorService {
88
176
  this.ledger.update(conversationId, {
89
177
  status: "running",
90
178
  });
91
- const runPromise = this.runService.enqueueAndRun(conversationId, content);
179
+ // THE ORDER IS THE SYSTEM'S, NOT THE READER'S. It used to go through `enqueueAndRun`, which
180
+ // writes the words under the reader's own name - so the child's transcript showed an
181
+ // instruction the reader never typed, wearing the reader's avatar, and a reader who looked at
182
+ // the child session saw their own name on somebody else's order.
183
+ const runPromise = this.runService.enqueueAndRunAsAgent(conversationId, content);
92
184
  const completion = (async () => {
93
185
  let failure;
94
186
  try {
@@ -100,17 +192,62 @@ export class SessionOrchestratorService {
100
192
  failure = error;
101
193
  await this.runService.waitForConversationIdle(conversationId);
102
194
  }
195
+ // A TRUNCATED ANSWER IS NOT A DEAD CHILD. The model filled its single-response output budget
196
+ // and stopped mid-sentence; everything it said is already on the child's transcript, and the
197
+ // work it was doing is NOT over. Treating that as a failure is what killed the child: the run
198
+ // service stamped the conversation `failed`, the orchestrator reported a failure to the
199
+ // parent, and a task that was one turn from done was thrown away.
200
+ //
201
+ // So a length stop is CONTINUED, not reported. The child is told to pick up where the output
202
+ // stopped, and it runs again. The bound is real: a model that truncates forever would
203
+ // otherwise loop until the watchdog gave up, and a task that cannot finish is better REPORTED
204
+ // as unfinished than retried until the server falls over.
205
+ //
206
+ // A CONTINUED CHILD IS NOT A FAILED ONE, SO THE FAILURE IS CLEARED. Everything below reads
207
+ // `failure` to decide what the parent is told and what the ledger records; a child that
208
+ // finished its answer has neither a failure to report nor a result to hand over - the parent
209
+ // reads the child's session, exactly as it does for a child that finished normally.
210
+ if (failure !== undefined && isLengthExceededError(failure)) {
211
+ const continued = await this.continueAfterLengthStop(conversationId, failure);
212
+ if (continued)
213
+ failure = undefined;
214
+ }
215
+ // THE RESULT FILE IS ALWAYS WRITTEN, AND THE PARENT IS ALWAYS TOLD.
216
+ //
217
+ // The file is the child's product and the reader may open it. The report is a message to the
218
+ // parent, and it goes out WHETHER THE CHILD SUCCEEDED OR FAILED - because a parent that
219
+ // delegated a task has to be told how it went. A parent that heard nothing would wait forever,
220
+ // or would have to poll the child's session to find out, which is exactly what the automatic
221
+ // report exists to avoid.
222
+ //
223
+ // The envelope already tells the two apart: `state="completed"` with `<task_result>` for a
224
+ // child that finished, `state="error"` with `<task_error>` for one that broke. Reporting only
225
+ // the failures was a real defect - it left a parent that had successfully delegated a task
226
+ // staring at an empty transcript, and the child looking finished to everyone except the one
227
+ // person waiting for it.
103
228
  try {
104
229
  const result = await this.persistSessionResult(conversationId, failure);
105
230
  if (result) {
106
231
  const current = this.store.conversation(conversationId);
107
- await this.runService.enqueueSessionResult({
108
- conversationId,
109
- parentConversationId: current?.parentConversationId,
110
- output: result.output,
111
- resultPath: result.resultPath,
112
- ...(failure !== undefined ? { error: result.output } : {}),
113
- });
232
+ // AN INTERRUPTED CHILD HAS NO RESULT TO REPORT. "stopped" means somebody pressed stop, and
233
+ // the half-finished answer that was on screen is not a result anybody stated. The parent is
234
+ // told nothing, which is right: the child can be asked again, and then it reports.
235
+ const interrupted = current?.status === "stopped";
236
+ // A CHILD THAT REPORTED ITSELF IS NOT REPORTED TWICE. The explicit report is the child's
237
+ // own statement of what it did, so the automatic completion steps aside for it.
238
+ const alreadyReported = this.reportedChildren.has(conversationId);
239
+ if (!interrupted && !alreadyReported) {
240
+ await this.runService.enqueueSessionResult({
241
+ conversationId,
242
+ parentConversationId: current?.parentConversationId,
243
+ output: result.output,
244
+ resultPath: result.resultPath,
245
+ // THE ERROR GOES ONLY WHEN THERE IS ONE. `enqueueSessionResult` reads the presence of
246
+ // `error` as "this failed", so a successful report must NOT carry the key - carrying it
247
+ // with the output would tell the parent the child failed.
248
+ ...(failure !== undefined ? { error: result.output } : {}),
249
+ });
250
+ }
114
251
  }
115
252
  }
116
253
  catch (error) {
@@ -130,11 +267,174 @@ export class SessionOrchestratorService {
130
267
  void completion.catch(() => undefined);
131
268
  return { conversationId };
132
269
  }
270
+ /**
271
+ * THE CHILD'S OUTPUT WAS CUT OFF. TELL IT TO CARRY ON, AND LET IT.
272
+ *
273
+ * This is the recovery for a length stop, and it exists because a truncated answer is not a
274
+ * finished one. The child has already said most of what it had to say - that text is on its
275
+ * transcript - so the work left to do is the tail of the answer, not the whole of it.
276
+ *
277
+ * WHY THE CONTINUATION IS A NEW TURN RATHER THAN A RESUME OF THE SAME ONE. Pi has already settled
278
+ * the turn that was truncated: the model stopped, the message was written, the run ended. There is
279
+ * no live turn to resume, so the only way to get more output is to ask again - and asking again in
280
+ * the SAME conversation is what makes the continuation work, because the child's own partial
281
+ * answer is in its context and the model can see exactly where it stopped.
282
+ *
283
+ * WHY IT IS AN AGENT-SENT ORDER RATHER THAN A USER MESSAGE. The person never typed this, and the
284
+ * child's transcript must not show words under their name that they never said - the same reason
285
+ * `dispatch` uses `enqueueAndRunAsAgent` in the first place.
286
+ *
287
+ * Returns true when the child was continued (and the caller must NOT report a failure), and false
288
+ * when the continuation could not be made or the bound was reached - in which case the caller
289
+ * reports the original failure, which is then the honest description of what happened.
290
+ */
291
+ async continueAfterLengthStop(conversationId, failure) {
292
+ if (!this.store.conversation(conversationId))
293
+ return false;
294
+ // THE LOOP, BECAUSE A CONTINUATION CAN BE TRUNCATED TOO. A long answer often needs more than
295
+ // one continuation: each response fills its own output budget, so a three-part answer runs out
296
+ // of room twice before it is done. The bound counts every continuation this child has been
297
+ // given, so the loop cannot run forever - a model that truncates on EVERY response reaches
298
+ // the bound and is reported as unfinished, which is the honest sentence about what happened.
299
+ for (;;) {
300
+ const used = this.lengthContinuations.get(conversationId) ?? 0;
301
+ if (used >= MAX_LENGTH_CONTINUATIONS) {
302
+ console.warn(`Super Pi: session ${conversationId} hit the output limit ${MAX_LENGTH_CONTINUATIONS} times; reporting it as unfinished.`);
303
+ this.lengthContinuations.delete(conversationId);
304
+ return false;
305
+ }
306
+ this.lengthContinuations.set(conversationId, used + 1);
307
+ const reason = failure instanceof Error ? failure.message : String(failure);
308
+ console.warn(`Super Pi: session ${conversationId} was truncated (${reason}); asking it to continue (${used + 1}/${MAX_LENGTH_CONTINUATIONS}).`);
309
+ let continuationFailure;
310
+ try {
311
+ // THE ORDER GOES IN AS A SYSTEM MESSAGE, AND THE RUN IS WAITED FOR. The child must be given
312
+ // the chance to finish before anything is reported to the parent - otherwise the parent would
313
+ // receive a result that is missing the tail the child was about to write.
314
+ await this.runService.enqueueAndRunAsAgent(conversationId, LENGTH_CONTINUATION_PROMPT);
315
+ await this.runService.waitForConversationIdle(conversationId);
316
+ }
317
+ catch (error) {
318
+ continuationFailure = error;
319
+ }
320
+ if (continuationFailure !== undefined) {
321
+ // A CONTINUATION THAT IS ITSELF TRUNCATED IS THE SAME SITUATION AGAIN, AND THE LOOP
322
+ // CONTINUES. It is only a different thing when the continuation failed for a reason that
323
+ // is NOT the output limit - a provider error, a refused tool - and those are the child's
324
+ // real failure, reported through the normal path below.
325
+ if (isLengthExceededError(continuationFailure)) {
326
+ failure = continuationFailure;
327
+ continue;
328
+ }
329
+ console.warn(`Super Pi: continuing session ${conversationId} failed:`, continuationFailure instanceof Error ? continuationFailure.message : String(continuationFailure));
330
+ this.lengthContinuations.delete(conversationId);
331
+ return false;
332
+ }
333
+ // THE CHILD SETTLED WITHOUT ANOTHER TRUNCATION. Its work is done: the answer on its transcript
334
+ // is now complete, and there is nothing to report to the parent - the parent reads the child's
335
+ // result from the session itself, exactly as it does for a child that finished normally.
336
+ //
337
+ // THE WARNING IS CLEARED HERE. It described the answer that was cut short, and the child has
338
+ // now finished that answer; leaving it up would tell the reader the answer is still truncated
339
+ // when it is not.
340
+ await this.store.setWarningReason(conversationId, undefined);
341
+ this.lengthContinuations.delete(conversationId);
342
+ this.ledger.update(conversationId, { status: "completed" });
343
+ return true;
344
+ }
345
+ }
346
+ /**
347
+ * Explicit report from a child session to its direct parent.
348
+ * Can be called multiple times at any point during execution.
349
+ */
350
+ async report(childConversationId, options) {
351
+ const conversation = this.store.conversation(childConversationId);
352
+ if (!conversation) {
353
+ throw new Error(`找不到会话:${childConversationId}`);
354
+ }
355
+ const parentConversationId = conversation.parentConversationId;
356
+ if (!parentConversationId) {
357
+ throw new Error(`会话 ${childConversationId} 没有直属父会话,无法汇报`);
358
+ }
359
+ const parent = this.store.conversation(parentConversationId);
360
+ if (!parent) {
361
+ throw new Error(`直属父会话 ${parentConversationId} 不存在`);
362
+ }
363
+ const content = options.content ?? "";
364
+ const status = options.status ?? "in_progress";
365
+ const failed = status === "failed";
366
+ const workspace = this.store.workspace(conversation.workspaceId);
367
+ const resultPath = sessionResultRelativePath(childConversationId);
368
+ if (workspace) {
369
+ const resultDirectory = join(workspace.path, ".super-pi", "session-results");
370
+ await mkdir(resultDirectory, { recursive: true });
371
+ await writeFile(join(resultDirectory, `${childConversationId}.md`), content, "utf8");
372
+ }
373
+ await this.runService.enqueueSessionResult({
374
+ conversationId: childConversationId,
375
+ parentConversationId,
376
+ output: content,
377
+ resultPath,
378
+ ...(failed ? { error: content } : {}),
379
+ });
380
+ // THE CHILD HAS NOW SPOKEN FOR ITSELF. The automatic report that fires when the child's turn
381
+ // ends would otherwise repeat what was just delivered - the parent would receive the same
382
+ // answer twice, once as the child's own report and once as the automatic completion.
383
+ //
384
+ // An EXPLICIT report is the child's own statement of what it did, so it is the better of the
385
+ // two and the automatic one steps aside. A report that is NOT terminal (an interim update)
386
+ // does not silence the automatic report: the child is still working, and the parent will want
387
+ // to hear how the task ended.
388
+ if (status === "completed" || status === "failed") {
389
+ this.reportedChildren.add(childConversationId);
390
+ await this.watchdog.cancel(childConversationId);
391
+ }
392
+ else {
393
+ await this.watchdog.noteSignal(childConversationId);
394
+ }
395
+ return { delivered: true, parentConversationId };
396
+ }
397
+ /**
398
+ * Sets or updates a silence watchdog timer on a child session.
399
+ */
400
+ async setWatchdog(options) {
401
+ const child = this.store.conversation(options.childConversationId);
402
+ if (!child) {
403
+ throw new Error(`找不到会话:${options.childConversationId}`);
404
+ }
405
+ await this.watchdog.arm({
406
+ childConversationId: options.childConversationId,
407
+ parentConversationId: options.parentConversationId,
408
+ workspaceId: child.workspaceId,
409
+ timeoutSeconds: options.timeoutSeconds,
410
+ });
411
+ return { ok: true, childConversationId: options.childConversationId, timeoutSeconds: options.timeoutSeconds };
412
+ }
413
+ /**
414
+ * Cancels a silence watchdog timer on a child session.
415
+ */
416
+ async cancelWatchdog(childConversationId) {
417
+ await this.watchdog.cancel(childConversationId);
418
+ return { ok: true };
419
+ }
420
+ /**
421
+ * Re-arms persisted watchdogs from disk.
422
+ */
423
+ async restoreWatchdogs() {
424
+ await this.watchdog.restore();
425
+ }
133
426
  async persistSessionResult(conversationId, failure) {
134
427
  const conversation = this.store.conversation(conversationId);
135
428
  const workspace = conversation ? this.store.workspace(conversation.workspaceId) : undefined;
136
429
  if (!conversation || !workspace)
137
430
  return undefined;
431
+ // THE FILE IS THE CHILD'S PRODUCT, AND THE REPORT USES IT. They used to be the same thing, and
432
+ // the split is what the user asked for:
433
+ // - the FILE is written on every turn, so the reader can open it and `status(includeOutput)`
434
+ // can return what the child actually produced;
435
+ // - the REPORT to the parent carries that same text, because the parent is waiting for an
436
+ // answer. A failure reports the DIAGNOSIS rather than whatever the model managed to say,
437
+ // because a broken turn's last words are not a diagnosis of why it broke.
138
438
  const output = failure !== undefined
139
439
  ? (failure instanceof Error ? failure.message : String(failure))
140
440
  : ([...conversation.messages].reverse().find((message) => message.role === "assistant")?.content ?? "");
@@ -175,11 +475,22 @@ export class SessionOrchestratorService {
175
475
  if (conversation.status === "running" || conversation.status === "waiting") {
176
476
  const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
177
477
  if (lastAssistant?.toolCalls?.length) {
178
- const lastCall = lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1];
179
- if (!lastCall.status || lastCall.live || lastCall.preparing) {
180
- activeTool = { label: lastCall.label, detail: lastCall.detail };
478
+ const isCompleted = (t) => t.status === "ok" || t.status === "error" || t.status === "done" || t.status === "failed";
479
+ const isRunning = (t) => !isCompleted(t) && (Boolean(t.preparing) || Boolean(t.live) || !t.status || t.status === "running");
480
+ const runningCall = [...lastAssistant.toolCalls].reverse().find(isRunning)
481
+ ?? (!isCompleted(lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1])
482
+ ? lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1]
483
+ : undefined);
484
+ if (runningCall) {
485
+ activeTool = {
486
+ label: runningCall.label || runningCall.name || "tool",
487
+ detail: runningCall.detail || runningCall.live,
488
+ };
181
489
  }
182
490
  }
491
+ if (!activeTool && ledgerEntry?.activeTool) {
492
+ activeTool = { ...ledgerEntry.activeTool };
493
+ }
183
494
  }
184
495
  const hasPendingQuestion = this.store.pendingQuestions(conversationId).length > 0;
185
496
  let output;
@@ -200,54 +511,113 @@ export class SessionOrchestratorService {
200
511
  }
201
512
  }
202
513
  }
514
+ const effectiveStatus = conversation.error
515
+ ? "failed"
516
+ : conversation.status;
517
+ const finalProgress = lastAssistantSnippet ?? ledgerEntry?.lastProgress ?? lastUserSnippet;
203
518
  // Sync ledger status if present
204
- if (ledgerEntry && ledgerEntry.status !== conversation.status) {
519
+ if (ledgerEntry && (ledgerEntry.status !== effectiveStatus || ledgerEntry.activeTool !== activeTool || ledgerEntry.lastProgress !== finalProgress)) {
205
520
  this.ledger.update(conversationId, {
206
- status: conversation.status,
207
- lastProgress: lastAssistantSnippet ?? ledgerEntry.lastProgress,
521
+ status: effectiveStatus,
522
+ lastProgress: finalProgress,
523
+ activeTool,
524
+ ...(conversation.error ? { error: conversation.error } : {}),
208
525
  });
209
526
  }
210
527
  return {
211
528
  conversationId: conversation.id,
212
529
  workspaceId: conversation.workspaceId,
213
530
  title: conversation.title,
214
- status: conversation.status,
215
- origin: ledgerEntry?.origin ?? "user",
531
+ status: effectiveStatus,
532
+ origin: ledgerEntry?.origin ?? conversation.origin ?? "user",
216
533
  parentConversationId: conversation.parentConversationId ?? ledgerEntry?.parentConversationId,
217
- task: ledgerEntry?.task,
534
+ task: ledgerEntry?.task ?? (conversation.title && conversation.title !== "新会话" ? conversation.title : undefined),
218
535
  messageCount: messages.length,
219
536
  turnCount,
220
537
  lastAssistantSnippet,
221
538
  lastUserSnippet,
222
539
  hasPendingQuestion,
223
540
  activeTool,
224
- error: conversation.error,
541
+ error: conversation.error ?? ledgerEntry?.error,
225
542
  updatedAt: conversation.updatedAt,
226
- createdAt: ledgerEntry?.createdAt,
227
- lastProgress: ledgerEntry?.lastProgress ?? lastAssistantSnippet,
543
+ createdAt: ledgerEntry?.createdAt ?? (conversation.messages?.[0]?.createdAt ? new Date(conversation.messages[0].createdAt).getTime() : (conversation.updatedAt ? new Date(conversation.updatedAt).getTime() : Date.now())),
544
+ lastProgress: finalProgress,
228
545
  ...(options?.includeOutput ? { outputPath: resultPath } : {}),
229
546
  ...(output !== undefined ? { output, outputTruncated } : {}),
230
547
  };
231
548
  }
232
549
  /**
233
550
  * Lists summaries of all sessions, optionally filtered by parent conversation.
551
+ *
552
+ * Persisted store conversations are the authoritative source for session tree
553
+ * parent-child relationships across server restarts, while SessionLedger
554
+ * enriches live in-memory task, progress, and activeTool state.
234
555
  */
235
556
  async list(parentConversationId) {
236
- const entries = parentConversationId
237
- ? this.ledger.listByParent(parentConversationId)
238
- : this.ledger.listAll();
557
+ const targetParentId = parentConversationId?.trim();
558
+ const candidateIds = new Set();
559
+ if (targetParentId) {
560
+ // 1. Check parent in store to know parent's workspace
561
+ const parentConv = this.store.conversation(targetParentId);
562
+ if (parentConv) {
563
+ const workspaceChildren = this.store.conversationsForWorkspace(parentConv.workspaceId);
564
+ for (const child of workspaceChildren) {
565
+ if (child.parentConversationId === targetParentId) {
566
+ candidateIds.add(child.id);
567
+ }
568
+ }
569
+ }
570
+ else {
571
+ // Fallback: check all conversations in store
572
+ const allConversations = this.store.snapshot().conversations;
573
+ for (const conv of allConversations) {
574
+ if (conv.parentConversationId === targetParentId) {
575
+ candidateIds.add(conv.id);
576
+ }
577
+ }
578
+ }
579
+ // 2. Also check in-memory ledger entries
580
+ const ledgerEntries = this.ledger.listByParent(targetParentId);
581
+ for (const entry of ledgerEntries) {
582
+ candidateIds.add(entry.conversationId);
583
+ }
584
+ }
585
+ else {
586
+ // No parent specified: list all orchestrator / child sessions
587
+ for (const conv of this.store.snapshot().conversations) {
588
+ if (conv.parentConversationId || conv.origin === "orchestrator") {
589
+ candidateIds.add(conv.id);
590
+ }
591
+ }
592
+ for (const entry of this.ledger.listAll()) {
593
+ candidateIds.add(entry.conversationId);
594
+ }
595
+ }
596
+ const parentConv = targetParentId ? this.store.conversation(targetParentId) : undefined;
239
597
  const summaries = [];
240
- for (const entry of entries) {
598
+ for (const conversationId of candidateIds) {
241
599
  try {
242
- const summary = await this.status(entry.conversationId);
600
+ const summary = await this.status(conversationId);
601
+ // Workspace boundary enforcement: do not leak sessions across different workspaces
602
+ if (parentConv && summary.workspaceId !== parentConv.workspaceId) {
603
+ continue;
604
+ }
605
+ if (targetParentId && summary.parentConversationId !== targetParentId) {
606
+ continue;
607
+ }
243
608
  summaries.push(summary);
244
609
  }
245
610
  catch {
246
611
  // Conversation was removed from store; prune ledger
247
- this.ledger.delete(entry.conversationId);
612
+ this.ledger.delete(conversationId);
248
613
  }
249
614
  }
250
- return summaries;
615
+ // Sort newest first by creation time or updated timestamp
616
+ return summaries.sort((a, b) => {
617
+ const timeA = a.createdAt ?? (a.updatedAt ? new Date(a.updatedAt).getTime() : 0);
618
+ const timeB = b.createdAt ?? (b.updatedAt ? new Date(b.updatedAt).getTime() : 0);
619
+ return timeB - timeA;
620
+ });
251
621
  }
252
622
  /**
253
623
  * Stops a target session.
@@ -284,6 +654,7 @@ export class SessionOrchestratorService {
284
654
  mode = "all";
285
655
  }
286
656
  this.ledger.update(conversationId, { status: "stopped" });
657
+ await this.watchdog.cancel(conversationId);
287
658
  return { stopped: true, mode };
288
659
  }
289
660
  /**