@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
@@ -4,11 +4,13 @@ import { newSubagentRunId } from "../subagents.js";
4
4
  import { readAgents } from "../agents.js";
5
5
  import { verifyCapability } from "../capability.js";
6
6
  import { capText, validateWorkflowPlan, workflowSummary } from "../workflow.js";
7
- import { ensureNameIndex } from "../file-name-index.js";
8
- import { formatTurnFailure } from "../turn-failure.js";
7
+ import { ensureNameIndex, recordWorkspacePath } from "../file-name-index.js";
8
+ import { formatTurnFailure, isLengthExceededError } from "../turn-failure.js";
9
+ import { isLengthContinuation, lengthContinuationText } from "../length-continuation.js";
9
10
  import { describeThrownWithStack } from "../failure-text.js";
10
11
  import { toWorkspaceRelative } from "../files.js";
11
12
  import { refreshWorkspaceIndex } from "../file-index.js";
13
+ import { resolveSessionCwd } from "../session-cwd.js";
12
14
  import { execFile } from "node:child_process";
13
15
  import { promisify } from "node:util";
14
16
  import { randomUUID } from "node:crypto";
@@ -56,10 +58,38 @@ async function expandAtReferences(store, workspace, content) {
56
58
  * The envelope and the prose are NOT redundant: the envelope is what the reader sees in the transcript
57
59
  * column, what they cancel or reorder; the prose is what leaves the server for Pi.
58
60
  */
61
+ /**
62
+ * THE TEXT THE MODEL IS HANDED WHEN A 后台回传 BECOMES ITS TURN.
63
+ *
64
+ * Pi's SDK has ONE entry point for a new turn - `session.prompt(text)` - and every text sent
65
+ * through it is a USER MESSAGE in Pi's transcript. There is no "the agent said this" channel to
66
+ * use. So a delivered result has to travel as prose, and the only question left is what that prose
67
+ * SAYS.
68
+ *
69
+ * IT USED TO SAY `[后台任务「...」已完成]`, which reads as a thing the PERSON said - a bracketed
70
+ * label, then the body, with nothing saying who is speaking. The model therefore answered it as a
71
+ * user would be answered ("已收到:[后台任务「...」]"), and the reader saw their own result coming
72
+ * back in the user's voice, inside the user's own turn. The delivery had become a quotation.
73
+ *
74
+ * So the prose now says WHO IS SPEAKING and that this is information, not an instruction:
75
+ * 「子会话「...」已完成,结果如下」. The model is told what happened and what it received, and it
76
+ * answers about THAT - which is what a primary agent should do with a child's result.
77
+ */
59
78
  function composePrompt(message) {
60
79
  if (message.role !== "tool")
61
80
  return message.content;
62
81
  const content = message.content;
82
+ // A CONTINUATION ORDER IS NOT AN ORDER FROM A PARENT AGENT. It is the system telling the session
83
+ // to finish an answer that was cut short, and the model has to hear THAT - "here is a task you
84
+ // were given" would make it start the work over rather than finish what it had already said.
85
+ if (isLengthContinuation(content)) {
86
+ return `(上一条回答因达到输出长度上限被截断,请接着写完)\n\n${lengthContinuationText(content)}`;
87
+ }
88
+ // A DISPATCHED ORDER IS NOT A DELIVERED RESULT. The two both travel as a `tool` message, and the
89
+ // envelope tells them apart: a result carries `<task ...>`, an order does not.
90
+ if (!/<task id="[^"]+" state="[^"]+"/.test(content)) {
91
+ return `上级智能体下发的任务:\n\n${content}`;
92
+ }
63
93
  const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
64
94
  const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
65
95
  const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
@@ -74,7 +104,10 @@ function composePrompt(message) {
74
104
  const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*)\n?<\/(?:task_result|task_error)>/.exec(content);
75
105
  const body = bodyMatch ? bodyMatch[1] : content;
76
106
  const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
77
- return `[后台任务「${summary}」${stateLabel}]\n\n${body}`;
107
+ // THE SPEAKER IS NAMED, AND IT IS NOT THE READER. A line that starts 「[」 looks like a note the
108
+ // person pasted in; a line that starts 「子会话」 or 「后台任务」 says the SYSTEM is reporting.
109
+ const speaker = /kind="session"/.test(content) ? "子会话" : "后台任务";
110
+ return `${speaker}「${summary}」${stateLabel},结果如下:\n\n${body}`;
78
111
  }
79
112
  /**
80
113
  * Service managing execution of conversation turns, message queueing,
@@ -286,20 +319,20 @@ export class RunService {
286
319
  // "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
287
320
  // processed automatically" contract, and the auto-triggered turn goes through the same
288
321
  // `composePrompt` path the success case does.
289
- if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
290
- void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
291
- console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
292
- });
293
- }
294
- // BUSY: the row stays in the waiting queue, and the turn that is running takes it when that
295
- // turn ends - one message per turn, in the queue's own order. Nothing of a background result
296
- // goes into Pi's follow-up queue any more.
322
+ //
323
+ // BUSY: steer the result into the running turn so Pi drains it at the very next tool batch
324
+ // boundary instead of waiting for the whole turn to complete.
325
+ void this.dispatchBackgroundResult(task.conversationId, queued.id).catch(async (error) => {
326
+ console.warn(`Super Pi: dispatch background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
327
+ await this.settleBackgroundStatus(task.conversationId);
328
+ });
297
329
  }
298
330
  catch (error) {
299
331
  console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
300
332
  }
301
333
  finally {
302
334
  await this.store.removeBackgroundTask(sessionId);
335
+ await this.settleBackgroundStatus(task.conversationId);
303
336
  this.broadcaster.broadcastState();
304
337
  }
305
338
  })());
@@ -345,13 +378,68 @@ export class RunService {
345
378
  });
346
379
  if (!queued)
347
380
  return;
348
- if (!this.activeRuns.has(parentId) && !this.interruptTargets.has(parentId)) {
349
- void this.processWaitingMessage(parentId, queued.id).catch((error) => {
350
- console.warn(`Super Pi: auto-trigger for child session result failed: ${error instanceof Error ? error.message : "unknown error"}`);
351
- });
352
- }
381
+ void this.dispatchBackgroundResult(parentId, queued.id).catch((error) => {
382
+ console.warn(`Super Pi: dispatch child session result failed: ${error instanceof Error ? error.message : "unknown error"}`);
383
+ });
353
384
  })());
354
385
  }
386
+ /**
387
+ * Dispatches a background result (exec or child session) into the conversation.
388
+ *
389
+ * IDLE: starts a fresh turn via `processWaitingMessage`.
390
+ * BUSY: attempts to steer the message into the running turn so it is delivered at the
391
+ * very next tool batch boundary rather than waiting for the entire turn to complete.
392
+ * INTERRUPTING: quietly leaves the message waiting; the interrupted or next turn will pick it up.
393
+ */
394
+ async dispatchBackgroundResult(conversationId, messageId) {
395
+ if (this.interruptTargets.has(conversationId))
396
+ return;
397
+ if (!this.activeRuns.has(conversationId)) {
398
+ await this.processWaitingMessage(conversationId, messageId);
399
+ return;
400
+ }
401
+ // Conversation is busy. Attempt to steer the background result directly into the active turn.
402
+ try {
403
+ // THE LANE IS RELEASED BEFORE THE FALLBACK. `processWaitingMessage` acquires this same lane,
404
+ // so calling it from inside the serialized callback re-enters the lane and deadlocks: the
405
+ // outer work waits for the inner one, and the inner one waits for the outer one.
406
+ const startWaiting = await this.serializeConversation(conversationId, async () => {
407
+ if (this.interruptTargets.has(conversationId))
408
+ return false;
409
+ // `processWaitingMessage` acquires this lane, so the fallback must run after serialization exits.
410
+ if (!this.activeRuns.has(conversationId))
411
+ return true;
412
+ if (!this.runtime.steer)
413
+ return false;
414
+ if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
415
+ return false;
416
+ const waiting = this.store.listWaitingMessages(conversationId);
417
+ const item = waiting.find((m) => m.id === messageId && m.delivery === "waiting");
418
+ if (!item)
419
+ return false;
420
+ await this.moveAheadOfUnclaimed(conversationId, messageId);
421
+ this.broadcaster.broadcastState();
422
+ const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
423
+ this.broadcaster.broadcastState();
424
+ try {
425
+ await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
426
+ }
427
+ catch (error) {
428
+ await this.store.releaseWaitingMessage(conversationId, claimed.id);
429
+ this.broadcaster.broadcastState();
430
+ if (!(error instanceof SteerUnavailableError)) {
431
+ console.warn(`Super Pi: steer background result failed: ${error instanceof Error ? error.message : "unknown error"}`);
432
+ }
433
+ }
434
+ return false;
435
+ });
436
+ if (startWaiting)
437
+ await this.processWaitingMessage(conversationId, messageId);
438
+ }
439
+ catch (error) {
440
+ console.warn(`Super Pi: steer background result failed: ${error instanceof Error ? error.message : "unknown error"}`);
441
+ }
442
+ }
355
443
  /**
356
444
  * Stops an active background task (spawned process or detached subagent) on user request.
357
445
  *
@@ -382,8 +470,29 @@ export class RunService {
382
470
  }
383
471
  }
384
472
  await this.store.removeBackgroundTask(taskId);
473
+ if (task?.conversationId) {
474
+ await this.settleBackgroundStatus(task.conversationId);
475
+ }
385
476
  this.broadcaster.broadcastState();
386
477
  }
478
+ hasActiveBackgroundTasks(conversationId) {
479
+ const tasks = this.store.snapshot().backgroundTasks ?? [];
480
+ return tasks.some((t) => t.conversationId === conversationId && (t.status === "running" || !t.status));
481
+ }
482
+ async settleBackgroundStatus(conversationId) {
483
+ const conv = this.store.conversation(conversationId);
484
+ if (!conv || conv.status !== "awaiting_background")
485
+ return;
486
+ if (this.activeRuns.has(conversationId))
487
+ return;
488
+ if (this.hasActiveBackgroundTasks(conversationId))
489
+ return;
490
+ const hasWaiting = this.store.listWaitingMessages(conversationId).some(m => m.delivery === "waiting");
491
+ if (!hasWaiting) {
492
+ await this.store.setStatus(conversationId, "completed");
493
+ this.broadcaster.broadcastState();
494
+ }
495
+ }
387
496
  /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
388
497
  * two browser tabs cannot both observe an idle conversation and start duplicate turns. */
389
498
  conversationOps = new Map();
@@ -563,9 +672,9 @@ export class RunService {
563
672
  /**
564
673
  * Resolves conversation and workspace working directory for subagent dispatch.
565
674
  */
566
- dispatchContext(conversationId) {
675
+ async dispatchContext(conversationId) {
567
676
  const { conversation, workspace } = this.turnContext(conversationId);
568
- return { conversation, cwd: workspace.path };
677
+ return { conversation, cwd: await resolveSessionCwd(workspace.path, conversation.cwd) };
569
678
  }
570
679
  /**
571
680
  * Checks whether a conversation has an active turn running.
@@ -663,6 +772,17 @@ export class RunService {
663
772
  // the SDK's five-second backstop. Do this before any await so a stale stop cannot reach new work.
664
773
  this.subagents.stopConversation(conversationId);
665
774
  this.workflows.stopConversation(conversationId);
775
+ const bgTasks = (this.store.snapshot().backgroundTasks ?? []).filter(t => t.conversationId === conversationId);
776
+ for (const task of bgTasks) {
777
+ if (this.execService) {
778
+ try {
779
+ this.execService.kill(task.id);
780
+ }
781
+ catch { /* ignore if already exited */ }
782
+ }
783
+ this.subagents.stop(task.id);
784
+ await this.store.removeBackgroundTask(task.id);
785
+ }
666
786
  // Cancel human-facing waits BEFORE asking Pi to become idle. A session can be blocked inside an
667
787
  // extension UI bridge or the question tool; Pi's idle promise cannot settle until that wait is
668
788
  // released, so cancelling after `runtime.stop()` turns a click into the full idle backstop.
@@ -1205,13 +1325,15 @@ export class RunService {
1205
1325
  // does not exist.
1206
1326
  const relative = toWorkspaceRelative(workspace.path, path);
1207
1327
  tracker.record(workspace.id, relative, { root: workspace.path, conversationId: conversation.id });
1208
- refreshWorkspaceIndex(this.fileIndexes, workspace.id, relative);
1328
+ refreshWorkspaceIndex(this.fileIndexes, workspace.id, relative, workspace.path);
1209
1329
  // The name index learns about the write as well, so the file the agent just created is
1210
1330
  // findable by name straight away rather than after the next background scan. It goes through
1211
1331
  // the SAME `ensureNameIndex` the HTTP layer uses, so there is one index per workspace rather
1212
1332
  // than one per layer.
1213
- if (this.nameIndexes)
1214
- ensureNameIndex(this.nameIndexes, workspace.id, workspace.path).upsert(relative);
1333
+ if (this.nameIndexes) {
1334
+ ensureNameIndex(this.nameIndexes, workspace.id, workspace.path);
1335
+ recordWorkspacePath(this.nameIndexes, workspace.id, workspace.path, relative);
1336
+ }
1215
1337
  if (toolCallId)
1216
1338
  fileWritePaths.set(toolCallId, relative);
1217
1339
  this.broadcastFileChanges(workspace.id);
@@ -1294,9 +1416,11 @@ export class RunService {
1294
1416
  const writtenPath = call.id ? fileWritePaths.get(call.id) : undefined;
1295
1417
  if (writtenPath) {
1296
1418
  this.fileChanges?.confirm(workspace.id, writtenPath, workspace.path);
1297
- refreshWorkspaceIndex(this.fileIndexes, workspace.id, writtenPath);
1298
- if (this.nameIndexes)
1299
- ensureNameIndex(this.nameIndexes, workspace.id, workspace.path).upsert(writtenPath);
1419
+ refreshWorkspaceIndex(this.fileIndexes, workspace.id, writtenPath, workspace.path);
1420
+ if (this.nameIndexes) {
1421
+ ensureNameIndex(this.nameIndexes, workspace.id, workspace.path);
1422
+ recordWorkspacePath(this.nameIndexes, workspace.id, workspace.path, writtenPath);
1423
+ }
1300
1424
  if (call.id)
1301
1425
  fileWritePaths.delete(call.id);
1302
1426
  this.broadcastFileChanges(workspace.id);
@@ -1377,7 +1501,7 @@ export class RunService {
1377
1501
  conversation,
1378
1502
  prompt,
1379
1503
  signal: controller.signal,
1380
- cwd: workspace.path,
1504
+ cwd: await resolveSessionCwd(workspace.path, conversation.cwd),
1381
1505
  host: {
1382
1506
  notify: (request) => { if (acceptsEvent("ui.notify"))
1383
1507
  uiHost.notify(request); },
@@ -1394,6 +1518,10 @@ export class RunService {
1394
1518
  if (acceptsEvent("transcript.file", true))
1395
1519
  await this.store.setConversationPiSessionFile(conversation.id, path);
1396
1520
  },
1521
+ onUsage: async (readout) => {
1522
+ if (acceptsEvent("usage", true))
1523
+ await this.store.recordConversationUsage(conversation.id, readout);
1524
+ },
1397
1525
  onToolCall,
1398
1526
  onFileWrite,
1399
1527
  onToolPreparing,
@@ -1551,6 +1679,7 @@ export class RunService {
1551
1679
  try {
1552
1680
  const agent = await this.runtime.agentFor?.(conversation);
1553
1681
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
1682
+ await this.assertModelSupportsImages(conversation, Boolean(initialImages?.length), firstMessageId);
1554
1683
  const opened = await this.openTurn(conversation.id, firstMessageId);
1555
1684
  await this.streamTurn(conversation, workspace, prompt, opened.assistant, controller, opened.user.id, initialImages, identity);
1556
1685
  for (;;) {
@@ -1563,6 +1692,7 @@ export class RunService {
1563
1692
  // answers them with a turn of their own, which is the behaviour there was before Pi's queue was
1564
1693
  // used at all. Nothing is lost, and nothing is delivered twice.
1565
1694
  await this.clearPiQueues(conversation.id);
1695
+ await this.store.releaseSendingMessages(conversation.id);
1566
1696
  // Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
1567
1697
  // every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
1568
1698
  // server that dies between the two has a message that is neither waiting nor answered, and the
@@ -1570,24 +1700,49 @@ export class RunService {
1570
1700
  const next = await this.store.takeNextWaitingMessage(conversation.id);
1571
1701
  if (!next)
1572
1702
  break;
1703
+ const current = this.turnContext(conversation.id).conversation;
1704
+ await this.assertModelSupportsImages(current, Boolean(next.images?.length), next.id);
1573
1705
  const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
1574
1706
  this.broadcaster.broadcastState();
1575
- const current = this.turnContext(conversation.id).conversation;
1707
+ const activeConversation = this.turnContext(conversation.id).conversation;
1576
1708
  // `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
1577
1709
  // column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
1578
- await this.streamTurn(current, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
1710
+ await this.streamTurn(activeConversation, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
1579
1711
  }
1580
1712
  }
1581
1713
  finally {
1582
1714
  await this.store.releaseSendingMessages(conversation.id);
1583
1715
  }
1584
1716
  }
1717
+ /**
1718
+ * Refuses a turn when the selected model cannot accept image input, either because the incoming
1719
+ * message carries images or because previous messages in the conversation contain images.
1720
+ */
1721
+ async assertModelSupportsImages(conversation, currentTurnHasImages, excludingMessageId) {
1722
+ const hasHistoryImages = conversation.messages.some((m) => m.id !== excludingMessageId && Boolean(m.images && m.images.length > 0));
1723
+ if (!currentTurnHasImages && !hasHistoryImages)
1724
+ return;
1725
+ const catalog = await this.runtime.catalog(conversation.model);
1726
+ const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
1727
+ ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
1728
+ if (currentModel && !currentModel.imageInput) {
1729
+ if (currentTurnHasImages) {
1730
+ throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
1731
+ }
1732
+ throw new Error(`会话历史包含图片,当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入;请切回支持图文的模型继续对话`);
1733
+ }
1734
+ }
1585
1735
  /** Keep admission owned through final persistence and recheck the durable queue before releasing. */
1586
1736
  async runUntilDrained(conversation, workspace, prompt, controller, messageId, images) {
1587
1737
  for (;;) {
1588
1738
  if (!controller.signal.aborted)
1589
1739
  await this.runConversationTurns(conversation, workspace, prompt, controller, messageId, images);
1590
- await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
1740
+ // The `runConversationTurns` loop above drains every waiting message before returning, so a
1741
+ // turn that ends with `hasBg` true really is idle - it is waiting on a background task, not
1742
+ // about to start another turn.
1743
+ const hasBg = this.hasActiveBackgroundTasks(conversation.id);
1744
+ const finalStatus = controller.signal.aborted ? "stopped" : hasBg ? "awaiting_background" : "completed";
1745
+ await this.store.setStatus(conversation.id, finalStatus);
1591
1746
  if (controller.signal.aborted)
1592
1747
  return;
1593
1748
  // No await between the last empty observation and releasing the slot.
@@ -1608,6 +1763,90 @@ export class RunService {
1608
1763
  /**
1609
1764
  * Enqueues or starts execution for a user message.
1610
1765
  */
1766
+ /**
1767
+ * STARTS A TURN FROM A SENDER THAT IS NOT THE READER.
1768
+ *
1769
+ * `enqueueAndRun` below is the person's path: it writes the words under the reader's own name, in
1770
+ * the reader's bubble, so the reader can see that they said it. The orchestrator's path is the
1771
+ * same machinery with a different speaker - a primary agent handing a task to a child session -
1772
+ * and writing THAT under the reader's name was the bug the reader reported: the child's
1773
+ * transcript showed an instruction they never typed, wearing their avatar.
1774
+ *
1775
+ * So the write goes through `addAgentMessage` instead, which records the words as the system's,
1776
+ * and the turn runs on them exactly as before. `composePrompt` is what tells the model who is
1777
+ * speaking, so the model is addressed in the same words.
1778
+ */
1779
+ async enqueueAndRunAsAgent(conversationId, content) {
1780
+ this.assertHistoryAvailable(conversationId);
1781
+ this.memory?.cancel(conversationId);
1782
+ const trimmed = content.trim();
1783
+ if (!trimmed)
1784
+ return;
1785
+ const { conversation, workspace } = this.turnContext(conversationId);
1786
+ const active = this.activeRuns.get(conversation.id);
1787
+ if (active?.signal.aborted)
1788
+ await this.awaitRunSlotReleased(conversation.id, false);
1789
+ this.assertHistoryAvailable(conversationId);
1790
+ const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
1791
+ const reservedController = busy ? undefined : new AbortController();
1792
+ if (reservedController) {
1793
+ this.cancelledQuestionConversations.delete(conversation.id);
1794
+ this.claimRunSlot(conversation.id, reservedController);
1795
+ }
1796
+ let message;
1797
+ try {
1798
+ message = await this.store.addAgentMessage(conversationId, trimmed, { ...(busy ? { delivery: "waiting" } : {}) });
1799
+ }
1800
+ catch (error) {
1801
+ if (reservedController)
1802
+ this.releaseRunSlot(conversation.id, reservedController);
1803
+ throw error;
1804
+ }
1805
+ this.broadcaster.broadcastState();
1806
+ if (!reservedController) {
1807
+ const waiting = this.store.listWaitingMessages(conversationId).find((item) => item.id === message.id && item.delivery === "waiting");
1808
+ if (!waiting)
1809
+ return;
1810
+ if (!this.activeRuns.has(conversationId) && !this.interruptTargets.has(conversationId))
1811
+ await this.processWaitingMessage(conversationId, message.id);
1812
+ else
1813
+ await this.submitToPiQueue(conversationId, message);
1814
+ return;
1815
+ }
1816
+ const controller = reservedController;
1817
+ try {
1818
+ if (controller.signal.aborted) {
1819
+ await this.store.setStatus(conversation.id, "stopped");
1820
+ return;
1821
+ }
1822
+ await this.assertModelSupportsImages(conversation, false, message.id);
1823
+ await this.runUntilDrained(conversation, workspace, composePrompt(message), controller, message.id);
1824
+ }
1825
+ catch (error) {
1826
+ // A TRUNCATED ANSWER IS NOT A DEAD CHILD. The model filled its single-response output budget
1827
+ // and stopped mid-sentence; the work it was doing is not over. The conversation goes back to
1828
+ // `idle` - usable, not failed - and the truncation is recorded as a WARNING, so the orchestrator
1829
+ // above can see that the child needs to be told to carry on rather than reported as broken.
1830
+ if (isLengthExceededError(error)) {
1831
+ await this.store.setStatus(conversation.id, "idle");
1832
+ await this.store.setWarningReason(conversationId, formatTurnFailure(error));
1833
+ console.warn("Super Pi agent-dispatched turn was truncated by the output limit:", describeThrownWithStack(error));
1834
+ throw error;
1835
+ }
1836
+ await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "failed");
1837
+ if (controller.signal.aborted)
1838
+ return;
1839
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1840
+ console.error("Super Pi agent-dispatched turn failed:", describeThrownWithStack(error));
1841
+ throw error;
1842
+ }
1843
+ finally {
1844
+ this.releaseRunSlot(conversation.id, controller);
1845
+ if (!this.activeRuns.has(conversation.id))
1846
+ this.extensionUiService.cancelPendingUi(conversation.id, "expired");
1847
+ this.broadcaster.broadcastState();
1848
+ }
1849
+ }
1611
1850
  async enqueueAndRun(conversationId, content, images, attachments, clientId) {
1612
1851
  this.assertHistoryAvailable(conversationId);
1613
1852
  /**
@@ -1696,14 +1935,8 @@ export class RunService {
1696
1935
  }
1697
1936
  let expanded;
1698
1937
  try {
1699
- if (validImages?.length) {
1700
- const catalog = await this.runtime.catalog(conversation.model);
1701
- const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
1702
- ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
1703
- if (currentModel && !currentModel.imageInput) {
1704
- throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
1705
- }
1706
- }
1938
+ const currentConv = this.turnContext(conversationId).conversation;
1939
+ await this.assertModelSupportsImages(currentConv, Boolean(validImages?.length), message.id);
1707
1940
  expanded = await expandAtReferences(this.store, workspace, trimmed);
1708
1941
  }
1709
1942
  catch (error) {
@@ -1770,6 +2003,25 @@ export class RunService {
1770
2003
  if (error?.__superPiPreflight) {
1771
2004
  throw error;
1772
2005
  }
2006
+ // A TRUNCATED ANSWER IS NOT A FAILED TURN. The model filled its single-response output budget
2007
+ // and stopped mid-sentence: everything it said is on screen, and the conversation is still
2008
+ // usable - the reader's next move is to let it carry on, not to retry from scratch.
2009
+ //
2010
+ // So the conversation goes back to `idle` rather than `failed`, and the fact that the answer
2011
+ // was cut short is recorded as a WARNING. Marking it `failed` is what made an orchestrated
2012
+ // child session die: the orchestrator read the status, told the parent the task had failed,
2013
+ // and a task that was one turn from done was thrown away.
2014
+ //
2015
+ // AND THE TURN DOES NOT THROW. The reader's message WAS accepted and WAS answered - partially.
2016
+ // Throwing here would tell the browser the send failed, and the browser's response to a failed
2017
+ // send is to offer a retry, which is the wrong move: retrying asks for the same answer again
2018
+ // instead of asking for the rest of this one.
2019
+ if (isLengthExceededError(error)) {
2020
+ await this.store.setStatus(conversation.id, "idle");
2021
+ await this.store.setWarningReason(conversation.id, formatTurnFailure(error));
2022
+ console.warn("Super Pi turn was truncated by the output limit:", describeThrownWithStack(error));
2023
+ return;
2024
+ }
1773
2025
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "failed");
1774
2026
  if (controller.signal.aborted)
1775
2027
  return;
@@ -1907,6 +2159,7 @@ export class RunService {
1907
2159
  const message = conversation.messages.find((item) => item.id === messageId);
1908
2160
  if (!message)
1909
2161
  throw new Error("消息不存在或已被撤回");
2162
+ await this.assertModelSupportsImages(conversation, Boolean(message.images?.length), message.id);
1910
2163
  // runConversationTurns opens this first message; do not create a second assistant placeholder.
1911
2164
  await this.runUntilDrained(conversation, workspace, composePrompt(message), controller, message.id, message.images);
1912
2165
  }
@@ -2004,7 +2257,7 @@ export class RunService {
2004
2257
  const task = params.task.trim();
2005
2258
  if (!task)
2006
2259
  throw new Error("子 agent 的任务不能为空");
2007
- const { conversation, cwd } = this.dispatchContext(params.conversationId);
2260
+ const { conversation, cwd } = await this.dispatchContext(params.conversationId);
2008
2261
  void this.trackDetached(this.subagents.dispatch({
2009
2262
  conversationId: conversation.id,
2010
2263
  agentId: params.agentId,
@@ -2039,7 +2292,7 @@ export class RunService {
2039
2292
  if (body.depth !== undefined)
2040
2293
  checkSubagentDepth(body.depth);
2041
2294
  const signal = this.activeRuns.get(conversationId)?.signal;
2042
- const { conversation, cwd } = this.dispatchContext(conversationId);
2295
+ const { conversation, cwd } = await this.dispatchContext(conversationId);
2043
2296
  const parent = body.parentRunId ? this.store.snapshot().subagentRuns.find((run) => run.id === body.parentRunId) : undefined;
2044
2297
  if (body.parentRunId && (!parent || parent.conversationId !== conversation.id)) {
2045
2298
  throw new Error("Invalid parentRunId: dispatch ancestry is not recorded for this conversation");
@@ -2100,8 +2353,9 @@ export class RunService {
2100
2353
  return row;
2101
2354
  });
2102
2355
  if (!signal?.aborted && run.status !== "stopped" && !this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
2103
- void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
2356
+ void this.processWaitingMessage(conversation.id, queued.id).catch(async (error) => {
2104
2357
  console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
2358
+ await this.settleBackgroundStatus(conversation.id);
2105
2359
  });
2106
2360
  }
2107
2361
  }
@@ -2109,6 +2363,7 @@ export class RunService {
2109
2363
  console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
2110
2364
  }
2111
2365
  await this.store.removeBackgroundTask(run.id);
2366
+ await this.settleBackgroundStatus(conversation.id);
2112
2367
  this.broadcaster.broadcastState();
2113
2368
  }).catch(async (error) => {
2114
2369
  const message = `<task id="${backgroundId}" state="error" kind="subagent">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
@@ -2126,6 +2381,7 @@ export class RunService {
2126
2381
  console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
2127
2382
  }
2128
2383
  await this.store.removeBackgroundTask(backgroundId);
2384
+ await this.settleBackgroundStatus(conversationId);
2129
2385
  this.broadcaster.broadcastState();
2130
2386
  console.warn(`Super Pi: background sub-agent dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
2131
2387
  }));
@@ -2173,7 +2429,7 @@ export class RunService {
2173
2429
  const capability = this.verifyPrimaryCapability(conversationId, body.capability);
2174
2430
  if (!capability.ok)
2175
2431
  throw new Error(capability.message);
2176
- const { conversation, cwd } = this.dispatchContext(conversationId);
2432
+ const { conversation, cwd } = await this.dispatchContext(conversationId);
2177
2433
  const signal = this.activeRuns.get(conversationId)?.signal;
2178
2434
  // Validated against the roster THIS CALL reads from disk, for the same freshness reason the task tool
2179
2435
  // builds its roster per turn: a sub-agent added while the server is running is dispatchable on the next
@@ -98,7 +98,20 @@ export declare class ScheduledTaskService {
98
98
  update(id: string, input: ScheduledTaskInput): Promise<ScheduledTask>;
99
99
  /** Turns a task on or off. */
100
100
  setEnabled(id: string, enabled: boolean): Promise<ScheduledTask>;
101
- /** Removes a task. Its conversation stays: it holds the answers the task produced. */
101
+ /**
102
+ * Removes a task. Its conversation is ARCHIVED, not deleted.
103
+ *
104
+ * THE ANSWERS ARE WORTH KEEPING, so the session is not thrown away - and archiving is the keeping
105
+ * that can be undone: the session moves into the sidebar's 已归档 group and can be restored from
106
+ * there. The old behaviour was to leave it in the LIVE list, and that was the defect: a 「定时:xxx」
107
+ * session with no task behind it is an orphan the reader cannot explain (there is no delete-session
108
+ * control at all) and cannot get rid of.
109
+ *
110
+ * THE DELETE ITSELF IS NEVER REFUSED. The task is removed first and the archive is best-effort: a
111
+ * conversation that is still running cannot be archived, and a reader who deletes a task must not be
112
+ * told "no" because a session happens to be busy. The session simply stays where it is, and the
113
+ * reason is in the server log rather than in the reader's face.
114
+ */
102
115
  remove(id: string): Promise<void>;
103
116
  /**
104
117
  * Runs a task NOW, once.
@@ -186,10 +186,35 @@ export class ScheduledTaskService {
186
186
  return task;
187
187
  return this.update(id, { enabled });
188
188
  }
189
- /** Removes a task. Its conversation stays: it holds the answers the task produced. */
189
+ /**
190
+ * Removes a task. Its conversation is ARCHIVED, not deleted.
191
+ *
192
+ * THE ANSWERS ARE WORTH KEEPING, so the session is not thrown away - and archiving is the keeping
193
+ * that can be undone: the session moves into the sidebar's 已归档 group and can be restored from
194
+ * there. The old behaviour was to leave it in the LIVE list, and that was the defect: a 「定时:xxx」
195
+ * session with no task behind it is an orphan the reader cannot explain (there is no delete-session
196
+ * control at all) and cannot get rid of.
197
+ *
198
+ * THE DELETE ITSELF IS NEVER REFUSED. The task is removed first and the archive is best-effort: a
199
+ * conversation that is still running cannot be archived, and a reader who deletes a task must not be
200
+ * told "no" because a session happens to be busy. The session simply stays where it is, and the
201
+ * reason is in the server log rather than in the reader's face.
202
+ */
190
203
  async remove(id) {
191
- this.require(id);
204
+ const task = this.require(id);
192
205
  await this.store.removeScheduledTask(id);
206
+ const conversationId = task.conversationId;
207
+ if (conversationId) {
208
+ const conversation = this.store.conversation(conversationId);
209
+ if (conversation && !conversation.archivedAt) {
210
+ try {
211
+ await this.conversationService.archiveConversation(conversationId);
212
+ }
213
+ catch (error) {
214
+ console.warn(`Super Pi: the deleted task's conversation ${conversationId} could not be archived:`, error instanceof Error ? error.message : String(error));
215
+ }
216
+ }
217
+ }
193
218
  this.broadcaster.broadcastState();
194
219
  }
195
220
  /**