@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
@@ -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);
@@ -33,7 +33,8 @@ export class ModelCatalog {
33
33
  badge: model.badge,
34
34
  legacy: model.legacy === true,
35
35
  isDefault: model.slug === this.data.defaultModel,
36
- options: this.profileOf(model.slug)?.options ?? []
36
+ options: this.profileOf(model.slug)?.options ?? [],
37
+ ...(model.minCliVersion === undefined ? {} : { minCliVersion: model.minCliVersion })
37
38
  }));
38
39
  }
39
40
  /* What a person reads for a model, so an error in a thread names it the way the picker does. */
@@ -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;
@@ -71,7 +71,6 @@ export class TaskCoordinator {
71
71
  }
72
72
  await this.deps.tasks.settled();
73
73
  }
74
- /* For `chats.observe()`. */
75
74
  chatEvent(event) {
76
75
  if (this.stopped || event.event !== 'chat.event') {
77
76
  return;
@@ -201,38 +200,7 @@ export class TaskCoordinator {
201
200
  return;
202
201
  }
203
202
  if (after === 'gone') {
204
- if (task.background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
205
- return;
206
- }
207
- const lost = task.background.itemIds.filter((id) => {
208
- const item = items.find((item) => item.id === id);
209
- return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
210
- });
211
- if (lost.length > 0 || task.background.commands.length > 0) {
212
- this.settle(task, 'failed', {
213
- text: `The agent ended before its background work completed: ${[
214
- ...lost.map((id) => {
215
- const row = items.find((item) => item.id === id);
216
- return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
217
- }),
218
- ...task.background.commands
219
- ].join(', ')}. This work will not send a result.`,
220
- source: 'exit',
221
- at: this.deps.now()
222
- });
223
- }
224
- else {
225
- const reports = task.background.itemIds.flatMap((id) => {
226
- const row = items.find((item) => item.id === id);
227
- const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
228
- return report ? [report] : [];
229
- });
230
- this.settle(task, 'done', {
231
- text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
232
- source: 'turn',
233
- at: this.deps.now()
234
- });
235
- }
203
+ this.settleGone(task, task.background, turn, items);
236
204
  return;
237
205
  }
238
206
  }
@@ -243,6 +211,38 @@ export class TaskCoordinator {
243
211
  const { status, result } = resultOfTurn(turn, items, this.deps.now());
244
212
  this.settle(task, status, result);
245
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
+ }
246
246
  /*
247
247
  * Whether work in the background keeps this task open past the turn. Commands and monitors alone hold
248
248
  * it only until the limit, owed from the moment they are all that is left; a subagent or a workflow
@@ -297,7 +297,7 @@ export class TaskCoordinator {
297
297
  if (!task || items === null || this.delegating(chatId)) {
298
298
  return;
299
299
  }
300
- const last = [...items].reverse().find((item) => item.kind === 'turn');
300
+ const last = items.findLast((item) => item.kind === 'turn');
301
301
  if (last && last.state !== 'running' && last.createdAt >= task.createdAt) {
302
302
  this.turnEnded(chatId, last);
303
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({
@@ -44,6 +44,7 @@ export interface DeliverWaitingDeps {
44
44
  deliver(chatId: string, delivery: {
45
45
  noteId: string;
46
46
  note: string;
47
+ from?: string;
47
48
  preamble: string;
48
49
  }): Promise<boolean>;
49
50
  words: WaitingWords;
@@ -1,4 +1,4 @@
1
- import { clipText } from '@adecore/agent-contracts';
1
+ import { clipText, WAITING_NOTE_PREFIX } from '@adecore/agent-contracts';
2
2
  import { errorText } from '../error-text.js';
3
3
  export function isPendingRequest(item) {
4
4
  return (item?.kind === 'question' && item.state === 'pending') || (item?.kind === 'approval' && item.decision === 'pending');
@@ -26,7 +26,7 @@ export function chatRequests(chats) {
26
26
  }
27
27
  /* The id of the note one request of one child leaves in its parent, so delivering it twice leaves one. */
28
28
  export function waitingNoteId(childId, requestId) {
29
- return `waiting-${childId}-${requestId}`;
29
+ return `${WAITING_NOTE_PREFIX}${childId}-${requestId}`;
30
30
  }
31
31
  /*
32
32
  * How long a request may wait before its parent hears of it. A person watching the child settles one
@@ -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. */
@@ -138,6 +140,6 @@ export function deliverWaitingHandler(deps) {
138
140
  return;
139
141
  }
140
142
  const texts = waitingTexts({ id: childId, title: deps.titleFor(childId) ?? childId }, item, deps.words);
141
- await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), ...texts });
143
+ await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), from: childId, ...texts });
142
144
  };
143
145
  }
@@ -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
  },