@adecore/agents 0.21.1 → 0.22.0

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 (121) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/backend.d.ts +4 -0
  3. package/dist/chat/chat-core.d.ts +5 -2
  4. package/dist/chat/chat-core.js +8 -7
  5. package/dist/chat/chat-log.js +2 -2
  6. package/dist/chat/chat-session.d.ts +11 -2
  7. package/dist/chat/chat-session.js +141 -13
  8. package/dist/chat/chat-store.js +4 -7
  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-protocol.d.ts +2 -0
  13. package/dist/chat/claude-protocol.js +21 -1
  14. package/dist/chat/claude-title.js +1 -1
  15. package/dist/chat/claude-transcript.d.ts +1 -0
  16. package/dist/chat/claude-transcript.js +1 -1
  17. package/dist/chat/codex-backend.d.ts +2 -2
  18. package/dist/chat/codex-backend.js +5 -8
  19. package/dist/chat/codex-protocol.js +1 -1
  20. package/dist/chat/codex-thread.js +4 -5
  21. package/dist/chat/composer-preferences.js +2 -3
  22. package/dist/chat/errors.d.ts +1 -1
  23. package/dist/chat/external-sessions.d.ts +26 -0
  24. package/dist/chat/external-sessions.js +136 -0
  25. package/dist/chat/fake-claude.js +73 -0
  26. package/dist/chat/limit-resume.js +2 -7
  27. package/dist/chat/thread-search.d.ts +14 -0
  28. package/dist/chat/thread-search.js +64 -0
  29. package/dist/chat/visual-store.js +8 -6
  30. package/dist/context/refusal.js +3 -7
  31. package/dist/context/verb.js +1 -5
  32. package/dist/host/agent-host.js +2 -2
  33. package/dist/host/handlers.js +1 -2
  34. package/dist/host/wiring.js +1 -1
  35. package/dist/lineage.js +2 -3
  36. package/dist/messages/deliver-message.js +6 -11
  37. package/dist/messages/deliver-notice.js +8 -12
  38. package/dist/messages/notice-store.js +3 -5
  39. package/dist/outbox/outbox-worker.js +5 -2
  40. package/dist/providers/accounts/service.d.ts +2 -1
  41. package/dist/providers/accounts/service.js +11 -13
  42. package/dist/providers/catalog.js +2 -1
  43. package/dist/providers/claude.js +0 -1
  44. package/dist/providers/detect.js +2 -10
  45. package/dist/record-directory.js +0 -1
  46. package/dist/tasks/task-coordinator.d.ts +1 -0
  47. package/dist/tasks/task-coordinator.js +34 -34
  48. package/dist/tasks/task-store.js +1 -1
  49. package/dist/tasks/task-work.js +2 -2
  50. package/dist/tasks/waiting-child.d.ts +1 -0
  51. package/dist/tasks/waiting-child.js +8 -6
  52. package/dist/tasks/wake-parent.js +15 -10
  53. package/dist/tasks/wiring.js +32 -37
  54. package/dist/usage/aggregate.js +62 -46
  55. package/dist/usage/exchange.js +1 -1
  56. package/dist/usage/limits/monitor.d.ts +2 -2
  57. package/dist/usage/limits/monitor.js +2 -2
  58. package/dist/usage/limits/probe.js +1 -1
  59. package/dist/usage/pricing.js +1 -1
  60. package/dist/usage/readers/codex.d.ts +1 -0
  61. package/dist/usage/readers/codex.js +1 -1
  62. package/dist/usage/roots.js +1 -2
  63. package/dist/usage/scanner.js +7 -9
  64. package/dist/usage/usage-service.d.ts +1 -2
  65. package/dist/usage/usage-service.js +3 -6
  66. package/package.json +3 -3
  67. package/src/async.ts +3 -9
  68. package/src/chat/backend.ts +2 -0
  69. package/src/chat/chat-core.ts +8 -7
  70. package/src/chat/chat-log.ts +2 -2
  71. package/src/chat/chat-session.ts +148 -13
  72. package/src/chat/chat-store.ts +4 -8
  73. package/src/chat/chat-title.ts +4 -8
  74. package/src/chat/claude-backend.ts +9 -6
  75. package/src/chat/claude-protocol.ts +23 -1
  76. package/src/chat/claude-title.ts +1 -1
  77. package/src/chat/claude-transcript.ts +2 -2
  78. package/src/chat/codex-backend.ts +5 -8
  79. package/src/chat/codex-protocol.ts +1 -1
  80. package/src/chat/codex-thread.ts +6 -5
  81. package/src/chat/composer-preferences.ts +2 -3
  82. package/src/chat/errors.ts +2 -0
  83. package/src/chat/external-sessions.ts +177 -0
  84. package/src/chat/fake-claude.ts +75 -1
  85. package/src/chat/limit-resume.ts +2 -7
  86. package/src/chat/thread-search.ts +87 -0
  87. package/src/chat/thread.ts +1 -1
  88. package/src/chat/visual-store.ts +9 -6
  89. package/src/chat/wake-chat.ts +4 -1
  90. package/src/context/refusal.ts +3 -7
  91. package/src/context/verb.ts +1 -5
  92. package/src/host/agent-host.ts +2 -2
  93. package/src/host/handlers.ts +1 -2
  94. package/src/host/wiring.ts +1 -1
  95. package/src/lineage.ts +2 -3
  96. package/src/messages/deliver-message.ts +6 -11
  97. package/src/messages/deliver-notice.ts +8 -12
  98. package/src/messages/notice-store.ts +3 -5
  99. package/src/outbox/outbox-worker.ts +5 -2
  100. package/src/providers/accounts/service.ts +12 -13
  101. package/src/providers/accounts/variables.ts +1 -0
  102. package/src/providers/catalog.ts +2 -1
  103. package/src/providers/claude.ts +0 -1
  104. package/src/providers/detect.ts +2 -11
  105. package/src/record-directory.ts +0 -1
  106. package/src/skills.ts +0 -1
  107. package/src/tasks/task-coordinator.ts +35 -33
  108. package/src/tasks/task-store.ts +1 -1
  109. package/src/tasks/task-work.ts +2 -2
  110. package/src/tasks/waiting-child.ts +10 -7
  111. package/src/tasks/wake-parent.ts +21 -16
  112. package/src/tasks/wiring.ts +33 -41
  113. package/src/usage/aggregate.ts +80 -50
  114. package/src/usage/exchange.ts +1 -1
  115. package/src/usage/limits/monitor.ts +4 -4
  116. package/src/usage/limits/probe.ts +2 -2
  117. package/src/usage/pricing.ts +1 -1
  118. package/src/usage/readers/codex.ts +1 -1
  119. package/src/usage/roots.ts +1 -2
  120. package/src/usage/scanner.ts +8 -10
  121. package/src/usage/usage-service.ts +5 -9
@@ -115,6 +115,9 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
115
115
  const now = this.clock.now();
116
116
  const busy = new Set(this.running);
117
117
  let nextDue: number | null = null;
118
+ const dueAt = (at: number): void => {
119
+ nextDue = nextDue === null ? at : Math.min(nextDue, at);
120
+ };
118
121
  for (const entry of this.store.list()) {
119
122
  // A waiting entry holds no lane. The resume of a chat must not queue behind a wake that waits for it.
120
123
  if (busy.has(entry.target) || this.waiting.has(entry.id)) {
@@ -122,7 +125,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
122
125
  }
123
126
  // Nor does work due at a later time, which nothing owed before it has to wait for.
124
127
  if (entry.attempts === 0 && entry.notBefore > now) {
125
- nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
128
+ dueAt(entry.notBefore);
126
129
  continue;
127
130
  }
128
131
  const lanes = this.store.lanesOf(entry);
@@ -135,7 +138,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
135
138
  continue;
136
139
  }
137
140
  if (entry.notBefore > now) {
138
- nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
141
+ dueAt(entry.notBefore);
139
142
  continue;
140
143
  }
141
144
  void this.run(entry);
@@ -13,6 +13,7 @@ import {
13
13
  type ProviderAccountVariable,
14
14
  type ProviderInfo
15
15
  } from '@adecore/agent-contracts';
16
+ import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../../chat/codex-transport.ts';
16
17
  import { ClientSinks } from '../../client-sinks.ts';
17
18
  import { errorText } from '../../error-text.ts';
18
19
  import type { AgentEvent, AgentSink } from '../../events.ts';
@@ -23,7 +24,6 @@ import { prepareShadowHome, type ShadowHomeReport } from './shadow-home.ts';
23
24
  import { askAccountAs, type AskAccount } from './status.ts';
24
25
  import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts, type StoredAccounts } from './store.ts';
25
26
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError, type AccountsHost, type SecretStore } from './variables.ts';
26
- import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../../chat/codex-transport.ts';
27
27
 
28
28
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
29
29
  const CHECK_INTERVAL_MS = 15 * 60_000;
@@ -223,21 +223,11 @@ export class ProviderAccountsService implements AccountLaunches {
223
223
  }
224
224
 
225
225
  transcriptFolder(kind: AgentKind, id: string | undefined): string | null {
226
- const key = id ?? kind;
227
- const account = this.accounts[key];
228
- if (account === undefined || account.kind !== kind) {
229
- return null;
230
- }
231
- return transcriptFolder(key, account, this.providers.get(kind), this.env);
226
+ return this.folderOf(kind, id, transcriptFolder);
232
227
  }
233
228
 
234
229
  homeFolder(kind: AgentKind, id: string | undefined): string | null {
235
- const key = id ?? kind;
236
- const account = this.accounts[key];
237
- if (account === undefined || account.kind !== kind) {
238
- return null;
239
- }
240
- return accountFolder(key, account, this.providers.get(kind), this.env);
230
+ return this.folderOf(kind, id, accountFolder);
241
231
  }
242
232
 
243
233
  canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean {
@@ -343,6 +333,15 @@ export class ProviderAccountsService implements AccountLaunches {
343
333
  return this.snapshot();
344
334
  }
345
335
 
336
+ private folderOf(kind: AgentKind, id: string | undefined, folder: typeof accountFolder): string | null {
337
+ const key = id ?? kind;
338
+ const account = this.accounts[key];
339
+ if (account === undefined || account.kind !== kind) {
340
+ return null;
341
+ }
342
+ return folder(key, account, this.providers.get(kind), this.env);
343
+ }
344
+
346
345
  private async runWatch(id: string, watch: LoginWatch): Promise<void> {
347
346
  while (this.now() < watch.deadline) {
348
347
  await this.sleep(LOGIN_POLL_MS);
@@ -84,6 +84,7 @@ export function redactVariables(variables: readonly ProviderAccountVariable[]):
84
84
  export function keychainService(host: AccountsHost, home: string): string {
85
85
  return `${host.keychainPrefix}-provider-env-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`;
86
86
  }
87
+
87
88
  // `security` exits with this when no item matches.
88
89
  const NOT_FOUND = 44;
89
90
 
@@ -43,7 +43,8 @@ export class ModelCatalog {
43
43
  badge: model.badge,
44
44
  legacy: model.legacy === true,
45
45
  isDefault: model.slug === this.data.defaultModel,
46
- options: this.profileOf(model.slug)?.options ?? []
46
+ options: this.profileOf(model.slug)?.options ?? [],
47
+ ...(model.minCliVersion === undefined ? {} : { minCliVersion: model.minCliVersion })
47
48
  }));
48
49
  }
49
50
 
@@ -39,7 +39,6 @@ interface ClaudeLaunch {
39
39
  folders?: readonly string[];
40
40
  }
41
41
 
42
- /* The argument list for one Claude Code chat process. */
43
42
  export function claudeArgs(launch: ClaudeLaunch): string[] {
44
43
  // The `=` form matters, since the flag is variadic and would swallow a prompt argument after it (measured on Claude Code 2.1.274).
45
44
  const args = [
@@ -1,20 +1,11 @@
1
1
  import { spawnChatProcess, type SpawnChatProcess } from '../chat/chat-process.ts';
2
+ import { definedEnv } from './accounts/launch.ts';
2
3
 
3
4
  export interface CliDetection {
4
5
  installed: boolean;
5
6
  version: string | null;
6
7
  }
7
8
 
8
- function definedOnly(env: Record<string, string | undefined>): Record<string, string> {
9
- const defined: Record<string, string> = {};
10
- for (const [key, value] of Object.entries(env)) {
11
- if (value !== undefined) {
12
- defined[key] = value;
13
- }
14
- }
15
- return defined;
16
- }
17
-
18
9
  /* Asks a CLI for its version; a missing binary is a plain "not installed", never an error. */
19
10
  export async function detectCli(
20
11
  command: string,
@@ -26,7 +17,7 @@ export async function detectCli(
26
17
  const code = new Promise<number | null>((resolve) => {
27
18
  exited = resolve;
28
19
  });
29
- const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedOnly(env), onExit: (exitCode) => exited(exitCode) });
20
+ const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedEnv(env), onExit: (exitCode) => exited(exitCode) });
30
21
  proc.stdin.end();
31
22
  const output = await new Response(proc.stdout).text();
32
23
  if ((await code) !== 0) {
@@ -121,7 +121,6 @@ export class RecordDirectory<T> {
121
121
  return rm(this.pathOf(id), { force: true });
122
122
  }
123
123
 
124
- /* Drops every record the predicate says is gone. */
125
124
  async prune(drop: (record: T) => boolean): Promise<void> {
126
125
  for (const record of this.all()) {
127
126
  if (drop(record)) {
package/src/skills.ts CHANGED
@@ -5,7 +5,6 @@ import { dirname, join, parse } from 'node:path';
5
5
  import type { AgentKind, ChatSkill, ChatSkillSource } from '@adecore/agent-contracts';
6
6
  import { isNotFound } from './fs.ts';
7
7
 
8
- // One folder full of skill folders, and what the CLI calls the skills it finds there.
9
8
  export interface SkillRoot {
10
9
  dir: string;
11
10
  source: ChatSkillSource;
@@ -116,7 +116,6 @@ export class TaskCoordinator {
116
116
  await this.deps.tasks.settled();
117
117
  }
118
118
 
119
- /* For `chats.observe()`. */
120
119
  chatEvent(event: AgentEvent): void {
121
120
  if (this.stopped || event.event !== 'chat.event') {
122
121
  return;
@@ -255,37 +254,7 @@ export class TaskCoordinator {
255
254
  return;
256
255
  }
257
256
  if (after === 'gone') {
258
- if (task.background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
259
- return;
260
- }
261
- const lost = task.background.itemIds.filter((id) => {
262
- const item = items.find((item) => item.id === id);
263
- return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
264
- });
265
- if (lost.length > 0 || task.background.commands.length > 0) {
266
- this.settle(task, 'failed', {
267
- text: `The agent ended before its background work completed: ${[
268
- ...lost.map((id) => {
269
- const row = items.find((item) => item.id === id);
270
- return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
271
- }),
272
- ...task.background.commands
273
- ].join(', ')}. This work will not send a result.`,
274
- source: 'exit',
275
- at: this.deps.now()
276
- });
277
- } else {
278
- const reports = task.background.itemIds.flatMap((id) => {
279
- const row = items.find((item) => item.id === id);
280
- const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
281
- return report ? [report] : [];
282
- });
283
- this.settle(task, 'done', {
284
- text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
285
- source: 'turn',
286
- at: this.deps.now()
287
- });
288
- }
257
+ this.settleGone(task, task.background, turn, items);
289
258
  return;
290
259
  }
291
260
  } else if (turn.state === 'done' && this.deps.limit.owed(task.id) && this.deps.afterBackgroundWork(chatId) === 'gone') {
@@ -296,6 +265,39 @@ export class TaskCoordinator {
296
265
  this.settle(task, status, result);
297
266
  }
298
267
 
268
+ /* The child's process went after its turn was held for background work; what that work left decides the task. */
269
+ private settleGone(task: Task, background: NonNullable<Task['background']>, turn: ChatTurnItem, items: readonly ChatItem[]): void {
270
+ if (background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
271
+ return;
272
+ }
273
+ const lost = background.itemIds.filter((id) => {
274
+ const item = items.find((item) => item.id === id);
275
+ return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
276
+ });
277
+ if (lost.length > 0 || background.commands.length > 0) {
278
+ const names = lost.map((id) => {
279
+ const row = items.find((item) => item.id === id);
280
+ return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
281
+ });
282
+ this.settle(task, 'failed', {
283
+ text: `The agent ended before its background work completed: ${[...names, ...background.commands].join(', ')}. This work will not send a result.`,
284
+ source: 'exit',
285
+ at: this.deps.now()
286
+ });
287
+ return;
288
+ }
289
+ const reports = background.itemIds.flatMap((id) => {
290
+ const row = items.find((item) => item.id === id);
291
+ const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
292
+ return report ? [report] : [];
293
+ });
294
+ this.settle(task, 'done', {
295
+ text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
296
+ source: 'turn',
297
+ at: this.deps.now()
298
+ });
299
+ }
300
+
299
301
  /*
300
302
  * Whether work in the background keeps this task open past the turn. Commands and monitors alone hold
301
303
  * it only until the limit, owed from the moment they are all that is left; a subagent or a workflow
@@ -354,7 +356,7 @@ export class TaskCoordinator {
354
356
  if (!task || items === null || this.delegating(chatId)) {
355
357
  return;
356
358
  }
357
- const last = [...items].reverse().find((item): item is ChatTurnItem => item.kind === 'turn');
359
+ const last = items.findLast((item): item is ChatTurnItem => item.kind === 'turn');
358
360
  if (last && last.state !== 'running' && last.createdAt >= task.createdAt) {
359
361
  this.turnEnded(chatId, last);
360
362
  }
@@ -108,7 +108,7 @@ export class TaskStore {
108
108
  }
109
109
 
110
110
  /*
111
- * Ends an open task with its result; false when it was not open any more, which is how the first of
111
+ * Ends an open task with its result; null when it was not open any more, which is how the first of
112
112
  * `done`, the end of a turn and an exit wins. A cancelled task wakes nobody.
113
113
  */
114
114
  async settle(id: string, status: 'done' | 'failed' | 'cancelled', result: TaskResult | null, now: number): Promise<Task | null> {
@@ -3,8 +3,8 @@ import type { OutboxEntryOf, OutboxWorkShape } from '../outbox/outbox.ts';
3
3
 
4
4
  export const BackgroundLimitWorkSchema = z.object({
5
5
  kind: z.literal('background-limit'),
6
- // 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.
7
- // `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
6
+ // The target is the child. `commands` are what held its task open when the limit started, for a child whose
7
+ // process went since; `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
8
8
  payload: z.object({ taskId: z.string().min(1), commands: z.array(z.string()), restarted: z.literal(true).optional() })
9
9
  });
10
10
 
@@ -1,4 +1,4 @@
1
- import { clipText, type ChatApprovalItem, type ChatItem, type ChatQuestionItem, type Task } from '@adecore/agent-contracts';
1
+ import { clipText, WAITING_NOTE_PREFIX, type ChatApprovalItem, type ChatItem, type ChatQuestionItem, type Task } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from '../chat/chat-core.ts';
3
3
  import { errorText } from '../error-text.ts';
4
4
  import type { AgentEvent } from '../events.ts';
@@ -45,7 +45,7 @@ export function chatRequests(chats: Pick<ChatCore, 'get' | 'answer'>): ChatReque
45
45
 
46
46
  /* The id of the note one request of one child leaves in its parent, so delivering it twice leaves one. */
47
47
  export function waitingNoteId(childId: string, requestId: string): string {
48
- return `waiting-${childId}-${requestId}`;
48
+ return `${WAITING_NOTE_PREFIX}${childId}-${requestId}`;
49
49
  }
50
50
 
51
51
  /*
@@ -82,7 +82,6 @@ export class WaitingObserver {
82
82
  this.stopped = true;
83
83
  }
84
84
 
85
- /* For `chats.observe()`. */
86
85
  chatEvent(event: AgentEvent): void {
87
86
  if (this.stopped || event.event !== 'chat.event') {
88
87
  return;
@@ -108,15 +107,19 @@ export class WaitingObserver {
108
107
  }
109
108
  }
110
109
 
110
+ function oneLine(text: string): string {
111
+ return text.replace(/\s+/g, ' ').trim();
112
+ }
113
+
111
114
  function quoted(text: string): string {
112
- return JSON.stringify(text.replace(/\s+/g, ' ').trim());
115
+ return JSON.stringify(oneLine(text));
113
116
  }
114
117
 
115
118
  /* The one line a note shows before "Show more": a question longer than that is read in the node. */
116
119
  const NOTE_QUESTION_LENGTH = 200;
117
120
 
118
121
  function quotedShort(text: string): string {
119
- const line = text.replace(/\s+/g, ' ').trim();
122
+ const line = oneLine(text);
120
123
  return JSON.stringify(line.length > NOTE_QUESTION_LENGTH ? `${clipText(line, NOTE_QUESTION_LENGTH - 1)}…` : line);
121
124
  }
122
125
 
@@ -182,7 +185,7 @@ export function waitingTexts(child: { id: string; title: string }, item: Waiting
182
185
  export interface DeliverWaitingDeps {
183
186
  request: ChatRequests['request'];
184
187
  titleFor(nodeId: string): string | null;
185
- deliver(chatId: string, delivery: { noteId: string; note: string; preamble: string }): Promise<boolean>;
188
+ deliver(chatId: string, delivery: { noteId: string; note: string; from?: string; preamble: string }): Promise<boolean>;
186
189
  words: WaitingWords;
187
190
  }
188
191
 
@@ -198,6 +201,6 @@ export function deliverWaitingHandler(deps: DeliverWaitingDeps) {
198
201
  return;
199
202
  }
200
203
  const texts = waitingTexts({ id: childId, title: deps.titleFor(childId) ?? childId }, item, deps.words);
201
- await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), ...texts });
204
+ await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), from: childId, ...texts });
202
205
  };
203
206
  }
@@ -1,4 +1,4 @@
1
- import type { Task } from '@adecore/agent-contracts';
1
+ import type { ChatItem, Task } from '@adecore/agent-contracts';
2
2
  import type { WakeChat } from '../chat/wake-chat.ts';
3
3
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
4
4
  import type { TaskStore } from './task-store.ts';
@@ -56,6 +56,24 @@ export function wakeNote(tasks: readonly Task[]): string {
56
56
  return `Woken by ${tasks.length} finished ${tasks.length === 1 ? 'task' : 'tasks'}`;
57
57
  }
58
58
 
59
+ /* An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event. */
60
+ function deliveryBlocked(items: readonly ChatItem[], tasks: readonly Task[]): boolean {
61
+ return items.some(
62
+ (item, index) =>
63
+ item.kind === 'turn' &&
64
+ item.deliveryPending &&
65
+ item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
66
+ (item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent'))
67
+ );
68
+ }
69
+
70
+ function deliveryPending(items: readonly ChatItem[], tasks: readonly Task[]): boolean {
71
+ return tasks.some((task) => {
72
+ const turn = items.findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
73
+ return turn?.kind === 'turn' && turn.deliveryPending;
74
+ });
75
+ }
76
+
59
77
  export interface WakeParentDeps {
60
78
  tasks: Pick<TaskStore, 'pendingWake' | 'readyWake' | 'openBatches' | 'markWoken' | 'dropWake'>;
61
79
  /* The chat as it is now, loading it from disk when nobody has; null for a chat that has no thread any more. */
@@ -96,15 +114,7 @@ export function wakeParentHandler(deps: WakeParentDeps) {
96
114
  if (tasks.length === 0) {
97
115
  return held();
98
116
  }
99
- // An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event.
100
- const blocked = items.some(
101
- (item, index) =>
102
- item.kind === 'turn' &&
103
- item.deliveryPending &&
104
- item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
105
- (item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent'))
106
- );
107
- if (blocked) {
117
+ if (deliveryBlocked(items, tasks)) {
108
118
  return 'wait';
109
119
  }
110
120
  const teamsOut = deps.tasks.openBatches(parentId).length;
@@ -119,12 +129,7 @@ export function wakeParentHandler(deps: WakeParentDeps) {
119
129
  return 'wait';
120
130
  }
121
131
  await chat.persist?.();
122
- if (
123
- tasks.some((task) => {
124
- const turn = chat.items().findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
125
- return turn?.kind === 'turn' && turn.deliveryPending;
126
- })
127
- ) {
132
+ if (deliveryPending(chat.items(), tasks)) {
128
133
  return 'wait';
129
134
  }
130
135
  await deps.tasks.markWoken(tasks.map((task) => task.id));
@@ -3,6 +3,7 @@ import { reportsOnBackgroundWork } from '../chat/background-work.ts';
3
3
  import type { ChatCore } from '../chat/chat-core.ts';
4
4
  import { chatOpener, loadChat } from '../chat/wake-chat.ts';
5
5
  import { errorText } from '../error-text.ts';
6
+ import { chatNoticeTargets } from '../messages/deliver-notice.ts';
6
7
  import type { OutboxHandlers } from '../outbox/outbox-worker.ts';
7
8
  import { backgroundLimitHandler, backgroundLimits } from './background-limit.ts';
8
9
  import { giveTaskHandler } from './give-task.ts';
@@ -85,6 +86,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
85
86
  const enqueue = (projectId: string, target: string, work: TaskWork, notBefore?: number): Promise<void> =>
86
87
  outbox.enqueue(projectId, target, work, notBefore);
87
88
  const limit = backgroundLimits(outbox);
89
+ const owesGiveTask = (taskId: string): boolean => outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === taskId);
88
90
  const coordinator = new TaskCoordinator({
89
91
  tasks: deps.tasks,
90
92
  now,
@@ -92,7 +94,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
92
94
  placed: (nodeId) => deps.placed(nodeId),
93
95
  loading: (chatId) => chats.isLoading(chatId),
94
96
  // Until the turn that carries a task ran, no turn of the child is its answer.
95
- owedTurn: (taskId) => outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === taskId),
97
+ owedTurn: owesGiveTask,
96
98
  oweWake: (task) => oweWake({ enqueue }, task),
97
99
  alert: (nodeId, title, body) => deps.alert(nodeId, title, body),
98
100
  ...(deps.retryAt ? { retryAt: deps.retryAt } : {}),
@@ -131,43 +133,40 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
131
133
  });
132
134
 
133
135
  const chatFor = chatOpener({ chats, placed: deps.placed });
136
+ const targets = chatNoticeTargets(chats);
137
+
138
+ /* Owes again what an earlier run left of an open task, and lets its child's last turn settle it. */
139
+ const recoverOpen = async (task: Task): Promise<void> => {
140
+ if (task.requiresTaskTurn === true && deps.placed(task.childId) && !owesGiveTask(task.id)) {
141
+ await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
142
+ }
143
+ const stored = await chats.storedBackground(task.childId);
144
+ const storedWork = stored.items.length > 0 || stored.commands.length > 0;
145
+ if (!deps.placed(task.childId) || (task.background === undefined && task.requiresTaskTurn !== true && !storedWork)) {
146
+ return;
147
+ }
148
+ if (task.background === undefined && stored.turnId !== null && storedWork) {
149
+ await deps.tasks.holdBackground(task.id, {
150
+ turnId: stored.turnId,
151
+ itemIds: stored.items.map((item) => item.id),
152
+ commands: stored.commands
153
+ });
154
+ }
155
+ const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
156
+ if (chat && chat.info.activeTurnId === null) {
157
+ const last = chat.thread.list().findLast((item) => item.kind === 'turn');
158
+ if (last?.kind === 'turn') {
159
+ coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
160
+ }
161
+ }
162
+ };
134
163
 
135
164
  return {
136
165
  coordinator,
137
166
  recover: async () => {
138
167
  for (const task of deps.tasks.all()) {
139
- if (task.status === 'open' && task.requiresTaskTurn === true && deps.placed(task.childId)) {
140
- if (!outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === task.id)) {
141
- await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
142
- }
143
- }
144
- const storedBackground = task.status === 'open' ? await chats.storedBackground(task.childId) : { turnId: null, items: [], commands: [] };
145
- if (
146
- task.status === 'open' &&
147
- deps.placed(task.childId) &&
148
- (task.background !== undefined ||
149
- task.requiresTaskTurn === true ||
150
- storedBackground.items.length > 0 ||
151
- storedBackground.commands.length > 0)
152
- ) {
153
- if (
154
- task.background === undefined &&
155
- storedBackground.turnId !== null &&
156
- (storedBackground.items.length > 0 || storedBackground.commands.length > 0)
157
- ) {
158
- await deps.tasks.holdBackground(task.id, {
159
- turnId: storedBackground.turnId,
160
- itemIds: storedBackground.items.map((item) => item.id),
161
- commands: storedBackground.commands
162
- });
163
- }
164
- const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
165
- if (chat && chat.info.activeTurnId === null) {
166
- const last = chat.thread.list().findLast((item) => item.kind === 'turn');
167
- if (last?.kind === 'turn') {
168
- coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
169
- }
170
- }
168
+ if (task.status === 'open') {
169
+ await recoverOpen(task);
171
170
  }
172
171
  }
173
172
  await coordinator.settled();
@@ -190,14 +189,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
190
189
  await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
191
190
  return task;
192
191
  },
193
- chatState: async (nodeId) => {
194
- const session = chats.get(nodeId);
195
- if (session) {
196
- return session.info.activeTurnId === null ? 'idle' : 'running';
197
- }
198
- // A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
199
- return (await chats.hasStored(nodeId)) ? 'idle' : 'none';
200
- },
192
+ chatState: (nodeId) => targets.chat(nodeId),
201
193
  done: (childId, text) => coordinator.done(childId, text),
202
194
  involving: (nodeId) => deps.tasks.involving(nodeId)
203
195
  },