@adecore/agents 0.21.1 → 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 (95) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/chat-core.d.ts +2 -2
  3. package/dist/chat/chat-core.js +4 -4
  4. package/dist/chat/chat-log.js +2 -2
  5. package/dist/chat/chat-store.js +4 -7
  6. package/dist/chat/chat-title.js +4 -8
  7. package/dist/chat/claude-backend.d.ts +1 -0
  8. package/dist/chat/claude-backend.js +8 -6
  9. package/dist/chat/claude-title.js +1 -1
  10. package/dist/chat/codex-backend.d.ts +2 -2
  11. package/dist/chat/codex-backend.js +5 -8
  12. package/dist/chat/codex-protocol.js +1 -1
  13. package/dist/chat/codex-thread.js +4 -5
  14. package/dist/chat/composer-preferences.js +2 -3
  15. package/dist/chat/limit-resume.js +2 -7
  16. package/dist/chat/visual-store.js +8 -6
  17. package/dist/context/refusal.js +3 -7
  18. package/dist/context/verb.js +1 -5
  19. package/dist/host/agent-host.js +2 -2
  20. package/dist/host/handlers.js +0 -1
  21. package/dist/host/wiring.js +1 -1
  22. package/dist/lineage.js +2 -3
  23. package/dist/messages/deliver-message.js +6 -11
  24. package/dist/messages/deliver-notice.js +8 -12
  25. package/dist/messages/notice-store.js +3 -5
  26. package/dist/outbox/outbox-worker.js +5 -2
  27. package/dist/providers/accounts/service.d.ts +2 -1
  28. package/dist/providers/accounts/service.js +11 -13
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/detect.js +2 -10
  31. package/dist/record-directory.js +0 -1
  32. package/dist/tasks/task-coordinator.d.ts +1 -0
  33. package/dist/tasks/task-coordinator.js +34 -34
  34. package/dist/tasks/task-store.js +1 -1
  35. package/dist/tasks/task-work.js +2 -2
  36. package/dist/tasks/waiting-child.js +5 -3
  37. package/dist/tasks/wake-parent.js +15 -10
  38. package/dist/tasks/wiring.js +32 -37
  39. package/dist/usage/aggregate.js +62 -46
  40. package/dist/usage/exchange.js +1 -1
  41. package/dist/usage/limits/monitor.d.ts +2 -2
  42. package/dist/usage/limits/monitor.js +2 -2
  43. package/dist/usage/limits/probe.js +1 -1
  44. package/dist/usage/pricing.js +1 -1
  45. package/dist/usage/roots.js +1 -2
  46. package/dist/usage/scanner.js +7 -9
  47. package/dist/usage/usage-service.d.ts +1 -2
  48. package/dist/usage/usage-service.js +3 -6
  49. package/package.json +3 -3
  50. package/src/async.ts +3 -9
  51. package/src/chat/chat-core.ts +4 -4
  52. package/src/chat/chat-log.ts +2 -2
  53. package/src/chat/chat-store.ts +4 -8
  54. package/src/chat/chat-title.ts +4 -8
  55. package/src/chat/claude-backend.ts +9 -6
  56. package/src/chat/claude-title.ts +1 -1
  57. package/src/chat/claude-transcript.ts +1 -1
  58. package/src/chat/codex-backend.ts +5 -8
  59. package/src/chat/codex-protocol.ts +1 -1
  60. package/src/chat/codex-thread.ts +6 -5
  61. package/src/chat/composer-preferences.ts +2 -3
  62. package/src/chat/limit-resume.ts +2 -7
  63. package/src/chat/thread.ts +1 -1
  64. package/src/chat/visual-store.ts +9 -6
  65. package/src/chat/wake-chat.ts +4 -1
  66. package/src/context/refusal.ts +3 -7
  67. package/src/context/verb.ts +1 -5
  68. package/src/host/agent-host.ts +2 -2
  69. package/src/host/handlers.ts +0 -1
  70. package/src/host/wiring.ts +1 -1
  71. package/src/lineage.ts +2 -3
  72. package/src/messages/deliver-message.ts +6 -11
  73. package/src/messages/deliver-notice.ts +8 -12
  74. package/src/messages/notice-store.ts +3 -5
  75. package/src/outbox/outbox-worker.ts +5 -2
  76. package/src/providers/accounts/service.ts +12 -13
  77. package/src/providers/accounts/variables.ts +1 -0
  78. package/src/providers/claude.ts +0 -1
  79. package/src/providers/detect.ts +2 -11
  80. package/src/record-directory.ts +0 -1
  81. package/src/skills.ts +0 -1
  82. package/src/tasks/task-coordinator.ts +35 -33
  83. package/src/tasks/task-store.ts +1 -1
  84. package/src/tasks/task-work.ts +2 -2
  85. package/src/tasks/waiting-child.ts +6 -3
  86. package/src/tasks/wake-parent.ts +21 -16
  87. package/src/tasks/wiring.ts +33 -41
  88. package/src/usage/aggregate.ts +80 -50
  89. package/src/usage/exchange.ts +1 -1
  90. package/src/usage/limits/monitor.ts +4 -4
  91. package/src/usage/limits/probe.ts +2 -2
  92. package/src/usage/pricing.ts +1 -1
  93. package/src/usage/roots.ts +1 -2
  94. package/src/usage/scanner.ts +8 -10
  95. package/src/usage/usage-service.ts +5 -9
@@ -24,13 +24,11 @@ export interface DeliverMessageDeps {
24
24
  }
25
25
 
26
26
  /*
27
- * Opens the turn a message earns a chat. The messages themselves are not carried here: they wait in
28
- * the notice store and the turn's preamble takes them (`NoticeNotes`), the same channel a person's
29
- * own prompt reads them through, so one message is heard once whichever of the two opens the turn.
27
+ * Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
28
+ * takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
30
29
  *
31
- * One attempt, never `wait`. A chat that turned out to be in a turn reads the message in front of its
32
- * next one, which is what a message to a busy chat has always done; holding the entry instead would
33
- * make the host start turns nobody asked for long after the news was worth anything.
30
+ * One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
31
+ * holding the entry would start turns nobody asked for long after the news mattered.
34
32
  */
35
33
  export function deliverMessageHandler(deps: DeliverMessageDeps) {
36
34
  return async (entry: DeliverMessageEntry): Promise<OutboxOutcome> => {
@@ -46,11 +44,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
46
44
  if (chat === null) {
47
45
  return;
48
46
  }
49
- /*
50
- * No note of its own, and the preamble stays off the thread. A person read the message as it
51
- * landed (`showNotices`), so anything the turn adds about it only says the same thing again;
52
- * the label on the turn already names who it came from.
53
- */
47
+ // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
48
+ // and the label on the turn names who it came from.
54
49
  chat.wake({
55
50
  text: deps.words.prompt(waiting.length),
56
51
  label: deps.words.label(waiting),
@@ -33,11 +33,10 @@ export interface ChatDelivery {
33
33
  }
34
34
 
35
35
  /*
36
- * Whether the turn a chat is running was itself opened by a message. This is the whole of the stop:
37
- * two agents that read each other would otherwise wake each other for as long as they kept writing,
38
- * and nothing about that started with a person. It rides on the turn, so it survives a restart.
36
+ * Whether the turn a chat is running was itself opened by a message, which stops two agents that
37
+ * read each other from waking each other forever. It rides on the turn, so it survives a restart.
39
38
  * Between turns the last one counts, since work it left running (a background subagent) may still
40
- * write, and a turn the CLI opened by itself goes on with the step of the turn before it.
39
+ * write, and a turn the CLI opened by itself takes the step of the turn before it.
41
40
  */
42
41
  export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
43
42
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
@@ -76,10 +75,9 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
76
75
 
77
76
  /*
78
77
  * One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
79
- * one, the way a settled task gives one: a person watching two agents cannot tell a message from an
80
- * assignment, and a message nobody starts a turn for sits there until someone happens to prompt that
81
- * chat. One step deep, so the turn a message opened wakes nobody with a message of its own. A node the
82
- * host runs something else in, such as a terminal, is the host's to deliver to before this.
78
+ * one, as for a settled task, or the message sits there until someone happens to prompt that chat.
79
+ * One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
80
+ * something else, such as a terminal, is the host's to deliver to before this.
83
81
  */
84
82
  export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
85
83
  const waiting = await store.put(notice);
@@ -105,10 +103,8 @@ export interface NoticeChat {
105
103
  }
106
104
 
107
105
  /*
108
- * What a chat has to show a person, in its thread, the moment a message lands and not when the model
109
- * gets round to it: without this a message left for a busy node is a file on disk and nothing else.
110
- * An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
111
- * has all of it.
106
+ * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
107
+ * to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
112
108
  */
113
109
  export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
114
110
  if (!(await chat.has(targetId))) {
@@ -7,10 +7,8 @@ import { RecordDirectory } from '../record-directory.ts';
7
7
  export const MAX_NOTICES = 10;
8
8
 
9
9
  /*
10
- * A message nobody picked up in this long is dropped rather than delivered. It is news about work
11
- * happening now ("the build is green"), and a model reading it six hours late would act on
12
- * something that has moved on. The cap alone would not do it: one message in a queue of one waits
13
- * for ever.
10
+ * A message is news about work happening now ("the build is green"), and a model reading it six hours
11
+ * late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
14
12
  */
15
13
  export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
16
14
 
@@ -131,7 +129,7 @@ export class NoticeStore {
131
129
  return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
132
130
  }
133
131
 
134
- /* The queue of one node, emptied here and on disk both; an empty one leaves no file behind. */
132
+ /* In memory and on disk both; an empty queue leaves no file behind. */
135
133
  private async persist(targetId: string, queue: Notice[]): Promise<void> {
136
134
  if (queue.length === 0) {
137
135
  await this.queues.remove(targetId);
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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
  },