@adecore/agents 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/chat-core.d.ts +7 -3
  3. package/dist/chat/chat-core.js +19 -9
  4. package/dist/chat/chat-log.js +2 -2
  5. package/dist/chat/chat-session.d.ts +13 -0
  6. package/dist/chat/chat-session.js +75 -5
  7. package/dist/chat/chat-store.d.ts +3 -2
  8. package/dist/chat/chat-store.js +44 -16
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-title.js +1 -1
  13. package/dist/chat/codex-backend.d.ts +2 -2
  14. package/dist/chat/codex-backend.js +5 -8
  15. package/dist/chat/codex-protocol.js +1 -1
  16. package/dist/chat/codex-thread.js +4 -5
  17. package/dist/chat/composer-preferences.js +2 -3
  18. package/dist/chat/limit-resume.js +2 -7
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/ui-queries.d.ts +12 -1
  22. package/dist/chat/ui-queries.js +107 -60
  23. package/dist/chat/visual-store.js +8 -6
  24. package/dist/context/refusal.js +3 -7
  25. package/dist/context/verb.js +1 -5
  26. package/dist/host/agent-host.js +2 -2
  27. package/dist/host/handlers.js +1 -2
  28. package/dist/host/wiring.js +1 -1
  29. package/dist/lineage.js +2 -3
  30. package/dist/messages/deliver-message.js +6 -11
  31. package/dist/messages/deliver-notice.js +8 -12
  32. package/dist/messages/notice-store.js +3 -5
  33. package/dist/outbox/outbox-worker.js +5 -2
  34. package/dist/providers/accounts/service.d.ts +2 -1
  35. package/dist/providers/accounts/service.js +11 -13
  36. package/dist/providers/claude.js +0 -1
  37. package/dist/providers/detect.js +2 -10
  38. package/dist/record-directory.js +0 -1
  39. package/dist/tasks/task-coordinator.d.ts +1 -0
  40. package/dist/tasks/task-coordinator.js +40 -36
  41. package/dist/tasks/task-store.js +1 -1
  42. package/dist/tasks/task-work.js +2 -2
  43. package/dist/tasks/waiting-child.js +5 -3
  44. package/dist/tasks/wake-parent.js +15 -10
  45. package/dist/tasks/wiring.js +32 -37
  46. package/dist/usage/aggregate.js +62 -46
  47. package/dist/usage/exchange.js +1 -1
  48. package/dist/usage/limits/monitor.d.ts +2 -2
  49. package/dist/usage/limits/monitor.js +2 -2
  50. package/dist/usage/limits/probe.js +1 -1
  51. package/dist/usage/pricing.js +1 -1
  52. package/dist/usage/roots.js +1 -2
  53. package/dist/usage/scanner.js +7 -9
  54. package/dist/usage/usage-service.d.ts +1 -2
  55. package/dist/usage/usage-service.js +3 -6
  56. package/package.json +3 -3
  57. package/src/async.ts +3 -9
  58. package/src/chat/chat-core.ts +29 -9
  59. package/src/chat/chat-log.ts +2 -2
  60. package/src/chat/chat-session.ts +85 -5
  61. package/src/chat/chat-store.ts +54 -16
  62. package/src/chat/chat-title.ts +4 -8
  63. package/src/chat/claude-backend.ts +9 -6
  64. package/src/chat/claude-title.ts +1 -1
  65. package/src/chat/claude-transcript.ts +1 -1
  66. package/src/chat/codex-backend.ts +5 -8
  67. package/src/chat/codex-protocol.ts +1 -1
  68. package/src/chat/codex-thread.ts +6 -5
  69. package/src/chat/composer-preferences.ts +2 -3
  70. package/src/chat/limit-resume.ts +2 -7
  71. package/src/chat/readable-text.ts +18 -0
  72. package/src/chat/thread.ts +1 -1
  73. package/src/chat/ui-queries.ts +120 -64
  74. package/src/chat/visual-store.ts +9 -6
  75. package/src/chat/wake-chat.ts +4 -1
  76. package/src/context/refusal.ts +3 -7
  77. package/src/context/verb.ts +1 -5
  78. package/src/host/agent-host.ts +2 -2
  79. package/src/host/handlers.ts +1 -2
  80. package/src/host/wiring.ts +1 -1
  81. package/src/lineage.ts +2 -3
  82. package/src/messages/deliver-message.ts +6 -11
  83. package/src/messages/deliver-notice.ts +8 -12
  84. package/src/messages/notice-store.ts +3 -5
  85. package/src/outbox/outbox-worker.ts +5 -2
  86. package/src/providers/accounts/service.ts +12 -13
  87. package/src/providers/accounts/variables.ts +1 -0
  88. package/src/providers/claude.ts +0 -1
  89. package/src/providers/detect.ts +2 -11
  90. package/src/record-directory.ts +0 -1
  91. package/src/skills.ts +0 -1
  92. package/src/tasks/task-coordinator.ts +41 -35
  93. package/src/tasks/task-store.ts +1 -1
  94. package/src/tasks/task-work.ts +2 -2
  95. package/src/tasks/waiting-child.ts +6 -3
  96. package/src/tasks/wake-parent.ts +21 -16
  97. package/src/tasks/wiring.ts +33 -41
  98. package/src/usage/aggregate.ts +80 -50
  99. package/src/usage/exchange.ts +1 -1
  100. package/src/usage/limits/monitor.ts +4 -4
  101. package/src/usage/limits/probe.ts +2 -2
  102. package/src/usage/pricing.ts +1 -1
  103. package/src/usage/roots.ts +1 -2
  104. package/src/usage/scanner.ts +8 -10
  105. package/src/usage/usage-service.ts +5 -9
@@ -6,10 +6,10 @@ import { suggestChatTitle } from '../chat/chat-title.js';
6
6
  import { VisualStore } from '../chat/visual-store.js';
7
7
  import { ClaudeTitleReader } from '../chat/claude-title.js';
8
8
  import { DEFAULT_CODEX_CLIENT } from '../chat/codex-transport.js';
9
+ import { errorText } from '../error-text.js';
9
10
  import { definedEnv } from '../providers/accounts/launch.js';
10
11
  import { ProviderAccountsService } from '../providers/accounts/service.js';
11
12
  import { createClaudeProvider } from '../providers/claude-provider.js';
12
- import { errorText } from '../error-text.js';
13
13
  import { createCodexProvider } from '../providers/codex-provider.js';
14
14
  import { codexRulesPathIn, defaultCodexHome, installCodexRules } from '../providers/codex-rules.js';
15
15
  import { ProviderRegistry } from '../providers/registry.js';
package/dist/lineage.js CHANGED
@@ -25,8 +25,8 @@ function openedByAgent(entry) {
25
25
  return entry.agent && entry.relation !== 'fork';
26
26
  }
27
27
  /*
28
- * Lineage lives in `lineage` under the host's data folder, outside the agent-writable project, so agents
29
- * cannot reset their own depth. Persisting it also prevents a restart of the host from resetting recursion limits.
28
+ * Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
29
+ * own depth; on disk, so a restart of the host does not reset it either.
30
30
  */
31
31
  export class AgentLineageStore {
32
32
  dir;
@@ -117,7 +117,6 @@ export class AgentLineageStore {
117
117
  const entry = this.opened.get(nodeId);
118
118
  return entry?.relation === 'fork' ? entry.openedBy : null;
119
119
  }
120
- /* The forks made from a chat. */
121
120
  forksOf(nodeId) {
122
121
  return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
123
122
  }
@@ -5,13 +5,11 @@ export const DeliverMessageWorkSchema = z.object({
5
5
  payload: z.object({ from: z.string().min(1) })
6
6
  });
7
7
  /*
8
- * Opens the turn a message earns a chat. The messages themselves are not carried here: they wait in
9
- * the notice store and the turn's preamble takes them (`NoticeNotes`), the same channel a person's
10
- * own prompt reads them through, so one message is heard once whichever of the two opens the turn.
8
+ * Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
9
+ * takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
11
10
  *
12
- * One attempt, never `wait`. A chat that turned out to be in a turn reads the message in front of its
13
- * next one, which is what a message to a busy chat has always done; holding the entry instead would
14
- * make the host start turns nobody asked for long after the news was worth anything.
11
+ * One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
12
+ * holding the entry would start turns nobody asked for long after the news mattered.
15
13
  */
16
14
  export function deliverMessageHandler(deps) {
17
15
  return async (entry) => {
@@ -27,11 +25,8 @@ export function deliverMessageHandler(deps) {
27
25
  if (chat === null) {
28
26
  return;
29
27
  }
30
- /*
31
- * No note of its own, and the preamble stays off the thread. A person read the message as it
32
- * landed (`showNotices`), so anything the turn adds about it only says the same thing again;
33
- * the label on the turn already names who it came from.
34
- */
28
+ // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
29
+ // and the label on the turn names who it came from.
35
30
  chat.wake({
36
31
  text: deps.words.prompt(waiting.length),
37
32
  label: deps.words.label(waiting),
@@ -1,9 +1,8 @@
1
1
  /*
2
- * Whether the turn a chat is running was itself opened by a message. This is the whole of the stop:
3
- * two agents that read each other would otherwise wake each other for as long as they kept writing,
4
- * and nothing about that started with a person. It rides on the turn, so it survives a restart.
2
+ * Whether the turn a chat is running was itself opened by a message, which stops two agents that
3
+ * read each other from waking each other forever. It rides on the turn, so it survives a restart.
5
4
  * Between turns the last one counts, since work it left running (a background subagent) may still
6
- * write, and a turn the CLI opened by itself goes on with the step of the turn before it.
5
+ * write, and a turn the CLI opened by itself takes the step of the turn before it.
7
6
  */
8
7
  export function turnFromMessage(items, activeTurnId) {
9
8
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
@@ -40,10 +39,9 @@ export function chatNoticeTargets(chats) {
40
39
  }
41
40
  /*
42
41
  * One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
43
- * one, the way a settled task gives one: a person watching two agents cannot tell a message from an
44
- * assignment, and a message nobody starts a turn for sits there until someone happens to prompt that
45
- * chat. One step deep, so the turn a message opened wakes nobody with a message of its own. A node the
46
- * host runs something else in, such as a terminal, is the host's to deliver to before this.
42
+ * one, as for a settled task, or the message sits there until someone happens to prompt that chat.
43
+ * One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
44
+ * something else, such as a terminal, is the host's to deliver to before this.
47
45
  */
48
46
  export async function deliverToChat(store, targets, notice) {
49
47
  const waiting = await store.put(notice);
@@ -60,10 +58,8 @@ export async function deliverToChat(store, targets, notice) {
60
58
  return { outcome: 'wake', waiting };
61
59
  }
62
60
  /*
63
- * What a chat has to show a person, in its thread, the moment a message lands and not when the model
64
- * gets round to it: without this a message left for a busy node is a file on disk and nothing else.
65
- * An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
66
- * has all of it.
61
+ * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
62
+ * to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
67
63
  */
68
64
  export async function showNotices(store, chat, words, targetId) {
69
65
  if (!(await chat.has(targetId))) {
@@ -5,10 +5,8 @@ import { RecordDirectory } from '../record-directory.js';
5
5
  // Per receiver. Past this the oldest goes: an agent in a loop must not fill a queue nobody reads.
6
6
  export const MAX_NOTICES = 10;
7
7
  /*
8
- * A message nobody picked up in this long is dropped rather than delivered. It is news about work
9
- * happening now ("the build is green"), and a model reading it six hours late would act on
10
- * something that has moved on. The cap alone would not do it: one message in a queue of one waits
11
- * for ever.
8
+ * A message is news about work happening now ("the build is green"), and a model reading it six hours
9
+ * late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
12
10
  */
13
11
  export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
14
12
  const NoticeSchema = z.object({
@@ -109,7 +107,7 @@ export class NoticeStore {
109
107
  fresh(notice) {
110
108
  return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
111
109
  }
112
- /* The queue of one node, emptied here and on disk both; an empty one leaves no file behind. */
110
+ /* In memory and on disk both; an empty queue leaves no file behind. */
113
111
  async persist(targetId, queue) {
114
112
  if (queue.length === 0) {
115
113
  await this.queues.remove(targetId);
@@ -80,6 +80,9 @@ export class OutboxWorker {
80
80
  const now = this.clock.now();
81
81
  const busy = new Set(this.running);
82
82
  let nextDue = null;
83
+ const dueAt = (at) => {
84
+ nextDue = nextDue === null ? at : Math.min(nextDue, at);
85
+ };
83
86
  for (const entry of this.store.list()) {
84
87
  // A waiting entry holds no lane. The resume of a chat must not queue behind a wake that waits for it.
85
88
  if (busy.has(entry.target) || this.waiting.has(entry.id)) {
@@ -87,7 +90,7 @@ export class OutboxWorker {
87
90
  }
88
91
  // Nor does work due at a later time, which nothing owed before it has to wait for.
89
92
  if (entry.attempts === 0 && entry.notBefore > now) {
90
- nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
93
+ dueAt(entry.notBefore);
91
94
  continue;
92
95
  }
93
96
  const lanes = this.store.lanesOf(entry);
@@ -100,7 +103,7 @@ export class OutboxWorker {
100
103
  continue;
101
104
  }
102
105
  if (entry.notBefore > now) {
103
- nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
106
+ dueAt(entry.notBefore);
104
107
  continue;
105
108
  }
106
109
  void this.run(entry);
@@ -1,4 +1,5 @@
1
1
  import { type AgentKind, type ProviderAccount, type ProviderAccountCreatePayload, type ProviderAccountCreateResult, type ProviderAccountMap, type ProviderAccounts, type ProviderInfo } from '@adecore/agent-contracts';
2
+ import { type CodexClientInfo } from '../../chat/codex-transport.ts';
2
3
  import type { AgentSink } from '../../events.ts';
3
4
  import type { ChatProvider } from '../provider.ts';
4
5
  import { type Env } from './accounts.ts';
@@ -6,7 +7,6 @@ import { type AccountLaunches } from './launch.ts';
6
7
  import { type ShadowHomeReport } from './shadow-home.ts';
7
8
  import { type AskAccount } from './status.ts';
8
9
  import { type AccountsHost, type SecretStore } from './variables.ts';
9
- import { type CodexClientInfo } from '../../chat/codex-transport.ts';
10
10
  export interface ProviderAccountsOptions {
11
11
  home: string;
12
12
  host?: AccountsHost;
@@ -68,6 +68,7 @@ export declare class ProviderAccountsService implements AccountLaunches {
68
68
  create(payload: ProviderAccountCreatePayload): Promise<ProviderAccountCreateResult>;
69
69
  watchLogin(id: string): Promise<void>;
70
70
  refresh(): Promise<ProviderAccounts>;
71
+ private folderOf;
71
72
  private runWatch;
72
73
  private serial;
73
74
  private commit;
@@ -2,6 +2,7 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { AgentKindSchema } from '@adecore/agent-contracts';
5
+ import { DEFAULT_CODEX_CLIENT } from '../../chat/codex-transport.js';
5
6
  import { ClientSinks } from '../../client-sinks.js';
6
7
  import { errorText } from '../../error-text.js';
7
8
  import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDefaultAccount, isKnownKind, transcriptFolder } from './accounts.js';
@@ -10,7 +11,6 @@ import { prepareShadowHome } from './shadow-home.js';
10
11
  import { askAccountAs } from './status.js';
11
12
  import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts } from './store.js';
12
13
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError } from './variables.js';
13
- import { DEFAULT_CODEX_CLIENT } from '../../chat/codex-transport.js';
14
14
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
15
15
  const CHECK_INTERVAL_MS = 15 * 60_000;
16
16
  const MAX_BACKOFF_MS = 60 * 60_000;
@@ -161,20 +161,10 @@ export class ProviderAccountsService {
161
161
  return accountEnv(key, account, provider, baseEnv, variables(account));
162
162
  }
163
163
  transcriptFolder(kind, id) {
164
- const key = id ?? kind;
165
- const account = this.accounts[key];
166
- if (account === undefined || account.kind !== kind) {
167
- return null;
168
- }
169
- return transcriptFolder(key, account, this.providers.get(kind), this.env);
164
+ return this.folderOf(kind, id, transcriptFolder);
170
165
  }
171
166
  homeFolder(kind, id) {
172
- const key = id ?? kind;
173
- const account = this.accounts[key];
174
- if (account === undefined || account.kind !== kind) {
175
- return null;
176
- }
177
- return accountFolder(key, account, this.providers.get(kind), this.env);
167
+ return this.folderOf(kind, id, accountFolder);
178
168
  }
179
169
  canContinue(kind, from, to) {
180
170
  const fromId = from ?? kind;
@@ -268,6 +258,14 @@ export class ProviderAccountsService {
268
258
  await this.queue(true);
269
259
  return this.snapshot();
270
260
  }
261
+ folderOf(kind, id, folder) {
262
+ const key = id ?? kind;
263
+ const account = this.accounts[key];
264
+ if (account === undefined || account.kind !== kind) {
265
+ return null;
266
+ }
267
+ return folder(key, account, this.providers.get(kind), this.env);
268
+ }
271
269
  async runWatch(id, watch) {
272
270
  while (this.now() < watch.deadline) {
273
271
  await this.sleep(LOGIN_POLL_MS);
@@ -25,7 +25,6 @@ export function claudeRuntimeMode(mode) {
25
25
  }
26
26
  return Object.keys(PERMISSION_MODE).find((key) => PERMISSION_MODE[key] === mode);
27
27
  }
28
- /* The argument list for one Claude Code chat process. */
29
28
  export function claudeArgs(launch) {
30
29
  // The `=` form matters, since the flag is variadic and would swallow a prompt argument after it (measured on Claude Code 2.1.274).
31
30
  const args = [
@@ -1,13 +1,5 @@
1
1
  import { spawnChatProcess } from '../chat/chat-process.js';
2
- function definedOnly(env) {
3
- const defined = {};
4
- for (const [key, value] of Object.entries(env)) {
5
- if (value !== undefined) {
6
- defined[key] = value;
7
- }
8
- }
9
- return defined;
10
- }
2
+ import { definedEnv } from './accounts/launch.js';
11
3
  /* Asks a CLI for its version; a missing binary is a plain "not installed", never an error. */
12
4
  export async function detectCli(command, env = process.env, spawn = spawnChatProcess) {
13
5
  try {
@@ -15,7 +7,7 @@ export async function detectCli(command, env = process.env, spawn = spawnChatPro
15
7
  const code = new Promise((resolve) => {
16
8
  exited = resolve;
17
9
  });
18
- const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedOnly(env), onExit: (exitCode) => exited(exitCode) });
10
+ const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedEnv(env), onExit: (exitCode) => exited(exitCode) });
19
11
  proc.stdin.end();
20
12
  const output = await new Response(proc.stdout).text();
21
13
  if ((await code) !== 0) {
@@ -100,7 +100,6 @@ export class RecordDirectory {
100
100
  this.records.delete(id);
101
101
  return rm(this.pathOf(id), { force: true });
102
102
  }
103
- /* Drops every record the predicate says is gone. */
104
103
  async prune(drop) {
105
104
  for (const record of this.all()) {
106
105
  if (drop(record)) {
@@ -40,6 +40,7 @@ export declare class TaskCoordinator {
40
40
  private failed;
41
41
  private answers;
42
42
  private turnEnded;
43
+ private settleGone;
43
44
  private heldInBackground;
44
45
  private pauseFor;
45
46
  private turnStarted;
@@ -1,12 +1,16 @@
1
1
  import { abortedByMachine, clipText } from '@adecore/agent-contracts';
2
2
  import { BACKGROUND_COMMAND_LIMIT_MS, commandLabel, isBackgroundWork, runningInBackground, runsInBackground } from '../chat/background-work.js';
3
+ import { readableAssistantText } from '../chat/readable-text.js';
3
4
  import { errorText } from '../error-text.js';
4
5
  // A child's answer is its last word of the turn; the thinking and the tools before it are not the result.
5
6
  function answerOf(items, turnId) {
6
7
  for (let i = items.length - 1; i >= 0; i--) {
7
8
  const item = items[i];
8
- if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId && item.text.trim() !== '') {
9
- return item.text.trim();
9
+ if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId) {
10
+ const text = readableAssistantText(item).trim();
11
+ if (text !== '') {
12
+ return text;
13
+ }
10
14
  }
11
15
  }
12
16
  return null;
@@ -67,7 +71,6 @@ export class TaskCoordinator {
67
71
  }
68
72
  await this.deps.tasks.settled();
69
73
  }
70
- /* For `chats.observe()`. */
71
74
  chatEvent(event) {
72
75
  if (this.stopped || event.event !== 'chat.event') {
73
76
  return;
@@ -197,38 +200,7 @@ export class TaskCoordinator {
197
200
  return;
198
201
  }
199
202
  if (after === 'gone') {
200
- if (task.background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
201
- return;
202
- }
203
- const lost = task.background.itemIds.filter((id) => {
204
- const item = items.find((item) => item.id === id);
205
- return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
206
- });
207
- if (lost.length > 0 || task.background.commands.length > 0) {
208
- this.settle(task, 'failed', {
209
- text: `The agent ended before its background work completed: ${[
210
- ...lost.map((id) => {
211
- const row = items.find((item) => item.id === id);
212
- return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
213
- }),
214
- ...task.background.commands
215
- ].join(', ')}. This work will not send a result.`,
216
- source: 'exit',
217
- at: this.deps.now()
218
- });
219
- }
220
- else {
221
- const reports = task.background.itemIds.flatMap((id) => {
222
- const row = items.find((item) => item.id === id);
223
- const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
224
- return report ? [report] : [];
225
- });
226
- this.settle(task, 'done', {
227
- text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
228
- source: 'turn',
229
- at: this.deps.now()
230
- });
231
- }
203
+ this.settleGone(task, task.background, turn, items);
232
204
  return;
233
205
  }
234
206
  }
@@ -239,6 +211,38 @@ export class TaskCoordinator {
239
211
  const { status, result } = resultOfTurn(turn, items, this.deps.now());
240
212
  this.settle(task, status, result);
241
213
  }
214
+ /* The child's process went after its turn was held for background work; what that work left decides the task. */
215
+ settleGone(task, background, turn, items) {
216
+ if (background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
217
+ return;
218
+ }
219
+ const lost = background.itemIds.filter((id) => {
220
+ const item = items.find((item) => item.id === id);
221
+ return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
222
+ });
223
+ if (lost.length > 0 || background.commands.length > 0) {
224
+ const names = lost.map((id) => {
225
+ const row = items.find((item) => item.id === id);
226
+ return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
227
+ });
228
+ this.settle(task, 'failed', {
229
+ text: `The agent ended before its background work completed: ${[...names, ...background.commands].join(', ')}. This work will not send a result.`,
230
+ source: 'exit',
231
+ at: this.deps.now()
232
+ });
233
+ return;
234
+ }
235
+ const reports = background.itemIds.flatMap((id) => {
236
+ const row = items.find((item) => item.id === id);
237
+ const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
238
+ return report ? [report] : [];
239
+ });
240
+ this.settle(task, 'done', {
241
+ text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
242
+ source: 'turn',
243
+ at: this.deps.now()
244
+ });
245
+ }
242
246
  /*
243
247
  * Whether work in the background keeps this task open past the turn. Commands and monitors alone hold
244
248
  * it only until the limit, owed from the moment they are all that is left; a subagent or a workflow
@@ -293,7 +297,7 @@ export class TaskCoordinator {
293
297
  if (!task || items === null || this.delegating(chatId)) {
294
298
  return;
295
299
  }
296
- const last = [...items].reverse().find((item) => item.kind === 'turn');
300
+ const last = items.findLast((item) => item.kind === 'turn');
297
301
  if (last && last.state !== 'running' && last.createdAt >= task.createdAt) {
298
302
  this.turnEnded(chatId, last);
299
303
  }
@@ -80,7 +80,7 @@ export class TaskStore {
80
80
  return this.sorted().filter((task) => task.projectId === projectId);
81
81
  }
82
82
  /*
83
- * Ends an open task with its result; false when it was not open any more, which is how the first of
83
+ * Ends an open task with its result; null when it was not open any more, which is how the first of
84
84
  * `done`, the end of a turn and an exit wins. A cancelled task wakes nobody.
85
85
  */
86
86
  async settle(id, status, result, now) {
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  export const BackgroundLimitWorkSchema = z.object({
3
3
  kind: z.literal('background-limit'),
4
- // The target is the child; the task its background commands hold open, and what they were when the limit started, for a child whose process went since.
5
- // `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
4
+ // The target is the child. `commands` are what held its task open when the limit started, for a child whose
5
+ // process went since; `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
6
6
  payload: z.object({ taskId: z.string().min(1), commands: z.array(z.string()), restarted: z.literal(true).optional() })
7
7
  });
8
8
  export const WakeParentWorkSchema = z.object({
@@ -51,7 +51,6 @@ export class WaitingObserver {
51
51
  stop() {
52
52
  this.stopped = true;
53
53
  }
54
- /* For `chats.observe()`. */
55
54
  chatEvent(event) {
56
55
  if (this.stopped || event.event !== 'chat.event') {
57
56
  return;
@@ -71,13 +70,16 @@ export class WaitingObserver {
71
70
  .catch((e) => (this.deps.log ?? console.error)(`Owing a note about ${chatId} failed: ${errorText(e)}`));
72
71
  }
73
72
  }
73
+ function oneLine(text) {
74
+ return text.replace(/\s+/g, ' ').trim();
75
+ }
74
76
  function quoted(text) {
75
- return JSON.stringify(text.replace(/\s+/g, ' ').trim());
77
+ return JSON.stringify(oneLine(text));
76
78
  }
77
79
  /* The one line a note shows before "Show more": a question longer than that is read in the node. */
78
80
  const NOTE_QUESTION_LENGTH = 200;
79
81
  function quotedShort(text) {
80
- const line = text.replace(/\s+/g, ' ').trim();
82
+ const line = oneLine(text);
81
83
  return JSON.stringify(line.length > NOTE_QUESTION_LENGTH ? `${clipText(line, NOTE_QUESTION_LENGTH - 1)}…` : line);
82
84
  }
83
85
  /* One question as the parent's CLI reads it, with the choices it may pick from. */
@@ -43,6 +43,19 @@ export function wakeLabel(tasks) {
43
43
  export function wakeNote(tasks) {
44
44
  return `Woken by ${tasks.length} finished ${tasks.length === 1 ? 'task' : 'tasks'}`;
45
45
  }
46
+ /* An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event. */
47
+ function deliveryBlocked(items, tasks) {
48
+ return items.some((item, index) => item.kind === 'turn' &&
49
+ item.deliveryPending &&
50
+ item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
51
+ (item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent')));
52
+ }
53
+ function deliveryPending(items, tasks) {
54
+ return tasks.some((task) => {
55
+ const turn = items.findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
56
+ return turn?.kind === 'turn' && turn.deliveryPending;
57
+ });
58
+ }
46
59
  /*
47
60
  * CLI acceptance lives in the saved turn before the task store consumes its result, so a restart
48
61
  * between those writes can finish delivery without opening another turn.
@@ -75,12 +88,7 @@ export function wakeParentHandler(deps) {
75
88
  if (tasks.length === 0) {
76
89
  return held();
77
90
  }
78
- // An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event.
79
- const blocked = items.some((item, index) => item.kind === 'turn' &&
80
- item.deliveryPending &&
81
- item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
82
- (item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent')));
83
- if (blocked) {
91
+ if (deliveryBlocked(items, tasks)) {
84
92
  return 'wait';
85
93
  }
86
94
  const teamsOut = deps.tasks.openBatches(parentId).length;
@@ -93,10 +101,7 @@ export function wakeParentHandler(deps) {
93
101
  return 'wait';
94
102
  }
95
103
  await chat.persist?.();
96
- if (tasks.some((task) => {
97
- const turn = chat.items().findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
98
- return turn?.kind === 'turn' && turn.deliveryPending;
99
- })) {
104
+ if (deliveryPending(chat.items(), tasks)) {
100
105
  return 'wait';
101
106
  }
102
107
  await deps.tasks.markWoken(tasks.map((task) => task.id));
@@ -1,6 +1,7 @@
1
1
  import { reportsOnBackgroundWork } from '../chat/background-work.js';
2
2
  import { chatOpener, loadChat } from '../chat/wake-chat.js';
3
3
  import { errorText } from '../error-text.js';
4
+ import { chatNoticeTargets } from '../messages/deliver-notice.js';
4
5
  import { backgroundLimitHandler, backgroundLimits } from './background-limit.js';
5
6
  import { giveTaskHandler } from './give-task.js';
6
7
  import { TaskCoordinator } from './task-coordinator.js';
@@ -16,6 +17,7 @@ export function wireTasks(deps) {
16
17
  const { chats, outbox } = deps;
17
18
  const enqueue = (projectId, target, work, notBefore) => outbox.enqueue(projectId, target, work, notBefore);
18
19
  const limit = backgroundLimits(outbox);
20
+ const owesGiveTask = (taskId) => outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === taskId);
19
21
  const coordinator = new TaskCoordinator({
20
22
  tasks: deps.tasks,
21
23
  now,
@@ -23,7 +25,7 @@ export function wireTasks(deps) {
23
25
  placed: (nodeId) => deps.placed(nodeId),
24
26
  loading: (chatId) => chats.isLoading(chatId),
25
27
  // Until the turn that carries a task ran, no turn of the child is its answer.
26
- owedTurn: (taskId) => outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === taskId),
28
+ owedTurn: owesGiveTask,
27
29
  oweWake: (task) => oweWake({ enqueue }, task),
28
30
  alert: (nodeId, title, body) => deps.alert(nodeId, title, body),
29
31
  ...(deps.retryAt ? { retryAt: deps.retryAt } : {}),
@@ -58,38 +60,38 @@ export function wireTasks(deps) {
58
60
  }
59
61
  });
60
62
  const chatFor = chatOpener({ chats, placed: deps.placed });
63
+ const targets = chatNoticeTargets(chats);
64
+ /* Owes again what an earlier run left of an open task, and lets its child's last turn settle it. */
65
+ const recoverOpen = async (task) => {
66
+ if (task.requiresTaskTurn === true && deps.placed(task.childId) && !owesGiveTask(task.id)) {
67
+ await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
68
+ }
69
+ const stored = await chats.storedBackground(task.childId);
70
+ const storedWork = stored.items.length > 0 || stored.commands.length > 0;
71
+ if (!deps.placed(task.childId) || (task.background === undefined && task.requiresTaskTurn !== true && !storedWork)) {
72
+ return;
73
+ }
74
+ if (task.background === undefined && stored.turnId !== null && storedWork) {
75
+ await deps.tasks.holdBackground(task.id, {
76
+ turnId: stored.turnId,
77
+ itemIds: stored.items.map((item) => item.id),
78
+ commands: stored.commands
79
+ });
80
+ }
81
+ const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
82
+ if (chat && chat.info.activeTurnId === null) {
83
+ const last = chat.thread.list().findLast((item) => item.kind === 'turn');
84
+ if (last?.kind === 'turn') {
85
+ coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
86
+ }
87
+ }
88
+ };
61
89
  return {
62
90
  coordinator,
63
91
  recover: async () => {
64
92
  for (const task of deps.tasks.all()) {
65
- if (task.status === 'open' && task.requiresTaskTurn === true && deps.placed(task.childId)) {
66
- if (!outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === task.id)) {
67
- await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
68
- }
69
- }
70
- const storedBackground = task.status === 'open' ? await chats.storedBackground(task.childId) : { turnId: null, items: [], commands: [] };
71
- if (task.status === 'open' &&
72
- deps.placed(task.childId) &&
73
- (task.background !== undefined ||
74
- task.requiresTaskTurn === true ||
75
- storedBackground.items.length > 0 ||
76
- storedBackground.commands.length > 0)) {
77
- if (task.background === undefined &&
78
- storedBackground.turnId !== null &&
79
- (storedBackground.items.length > 0 || storedBackground.commands.length > 0)) {
80
- await deps.tasks.holdBackground(task.id, {
81
- turnId: storedBackground.turnId,
82
- itemIds: storedBackground.items.map((item) => item.id),
83
- commands: storedBackground.commands
84
- });
85
- }
86
- const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
87
- if (chat && chat.info.activeTurnId === null) {
88
- const last = chat.thread.list().findLast((item) => item.kind === 'turn');
89
- if (last?.kind === 'turn') {
90
- coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
91
- }
92
- }
93
+ if (task.status === 'open') {
94
+ await recoverOpen(task);
93
95
  }
94
96
  }
95
97
  await coordinator.settled();
@@ -110,14 +112,7 @@ export function wireTasks(deps) {
110
112
  await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
111
113
  return task;
112
114
  },
113
- chatState: async (nodeId) => {
114
- const session = chats.get(nodeId);
115
- if (session) {
116
- return session.info.activeTurnId === null ? 'idle' : 'running';
117
- }
118
- // A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
119
- return (await chats.hasStored(nodeId)) ? 'idle' : 'none';
120
- },
115
+ chatState: (nodeId) => targets.chat(nodeId),
121
116
  done: (childId, text) => coordinator.done(childId, text),
122
117
  involving: (nodeId) => deps.tasks.involving(nodeId)
123
118
  },