@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
@@ -0,0 +1,177 @@
1
+ import { join } from 'node:path';
2
+ import { clipText } from '@adecore/agent-contracts';
3
+ import { isForkOrSubagent } from '../usage/readers/codex.ts';
4
+ import { claudeProjectSlug, spokenText } from './claude-transcript.ts';
5
+
6
+ /* What the listing may do to the file system; a fake in tests, `node:fs` in a host. */
7
+ export interface SessionFs {
8
+ /* The names in a folder; empty when it is not there. */
9
+ readdir(path: string): Promise<string[]>;
10
+ stat(path: string): Promise<{ mtimeMs: number } | null>;
11
+ /* The first `maxBytes` of a file as text; the last line may be cut short. */
12
+ readHead(path: string, maxBytes: number): Promise<string>;
13
+ }
14
+
15
+ /* A conversation a CLI keeps on disk, started outside the app that lists it. */
16
+ export interface ExternalSession {
17
+ provider: 'claude' | 'codex';
18
+ sessionId: string;
19
+ /* The first thing the person said, on one line. */
20
+ title: string;
21
+ updatedAt: number;
22
+ cwd: string;
23
+ }
24
+
25
+ const TITLE_LENGTH = 120;
26
+ // Claude writes hook output and attachments ahead of the first prompt, and Codex its instructions.
27
+ const HEAD_BYTES = 256 * 1024;
28
+ const CLAUDE_MAX_FILES = 200;
29
+
30
+ function titleOf(text: string): string {
31
+ return clipText(text.replace(/\s+/g, ' ').trim(), TITLE_LENGTH);
32
+ }
33
+
34
+ function records(head: string): Array<Record<string, unknown>> {
35
+ const lines = head.split('\n');
36
+ // The head ends wherever the byte limit fell, so its last line may not parse.
37
+ return lines.flatMap((line) => {
38
+ if (line === '') {
39
+ return [];
40
+ }
41
+ try {
42
+ const parsed: unknown = JSON.parse(line);
43
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed as Record<string, unknown>] : [];
44
+ } catch {
45
+ return [];
46
+ }
47
+ });
48
+ }
49
+
50
+ const CLAUDE_BOOKKEEPING = new Set(['summary', 'file-history-snapshot', 'queue-operation']);
51
+
52
+ /* A reminder or the wrapper of a slash command is written as a user line too, but nobody said it. */
53
+ function claudePrompt(entry: Record<string, unknown>): string | null {
54
+ if (CLAUDE_BOOKKEEPING.has(String(entry.type)) || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) {
55
+ return null;
56
+ }
57
+ const message = typeof entry.message === 'object' && entry.message !== null ? (entry.message as Record<string, unknown>) : {};
58
+ const said = spokenText(message.content);
59
+ if (said === null || said.trim() === '' || said.startsWith('<command-') || said.startsWith('<local-command-')) {
60
+ return null;
61
+ }
62
+ return said;
63
+ }
64
+
65
+ /*
66
+ * The conversations Claude Code kept for a folder, newest first. Only the folder's own directory is
67
+ * read, and only the `.jsonl` files in it, so the transcripts of subagents in `<session>/subagents` stay out.
68
+ * A file without a prompt somebody said is no conversation.
69
+ */
70
+ export async function listClaudeSessions({ projectsDir, folder, fs }: { projectsDir: string; folder: string; fs: SessionFs }): Promise<ExternalSession[]> {
71
+ const dir = join(projectsDir, claudeProjectSlug(folder));
72
+ const names = (await fs.readdir(dir)).filter((name) => name.endsWith('.jsonl'));
73
+ const stats = await Promise.all(names.map(async (name) => ({ name, stat: await fs.stat(join(dir, name)) })));
74
+ const newest = stats
75
+ .flatMap(({ name, stat }) => (stat === null ? [] : [{ name, mtimeMs: stat.mtimeMs }]))
76
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
77
+ .slice(0, CLAUDE_MAX_FILES);
78
+ const sessions: ExternalSession[] = [];
79
+ for (const { name, mtimeMs } of newest) {
80
+ const prompt = records(await fs.readHead(join(dir, name), HEAD_BYTES))
81
+ .map(claudePrompt)
82
+ .find((said) => said !== null);
83
+ if (prompt !== undefined && prompt !== null) {
84
+ sessions.push({ provider: 'claude', sessionId: name.slice(0, -'.jsonl'.length), title: titleOf(prompt), updatedAt: mtimeMs, cwd: folder });
85
+ }
86
+ }
87
+ return sessions;
88
+ }
89
+
90
+ function codexPrompt(entry: Record<string, unknown>): string | null {
91
+ const payload = typeof entry.payload === 'object' && entry.payload !== null ? (entry.payload as Record<string, unknown>) : null;
92
+ if (payload === null) {
93
+ return null;
94
+ }
95
+ if (entry.type === 'event_msg' && payload.type === 'user_message' && typeof payload.message === 'string') {
96
+ return payload.message.trim() === '' ? null : payload.message;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /* The newest names first; the folders are named by year, month and day, and the files by when they began. */
102
+ function newestFirst(names: string[]): string[] {
103
+ return [...names].sort().reverse();
104
+ }
105
+
106
+ async function rolloutFiles(sessionsDir: string, fs: SessionFs, max: number): Promise<string[]> {
107
+ const files: string[] = [];
108
+ for (const year of newestFirst(await fs.readdir(sessionsDir))) {
109
+ for (const month of newestFirst(await fs.readdir(join(sessionsDir, year)))) {
110
+ for (const day of newestFirst(await fs.readdir(join(sessionsDir, year, month)))) {
111
+ const names = newestFirst(
112
+ (await fs.readdir(join(sessionsDir, year, month, day))).filter((name) => name.startsWith('rollout-') && name.endsWith('.jsonl'))
113
+ );
114
+ for (const name of names) {
115
+ files.push(join(sessionsDir, year, month, day, name));
116
+ if (files.length >= max) {
117
+ return files;
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return files;
124
+ }
125
+
126
+ /*
127
+ * The conversations Codex kept for a folder, newest first, out of the newest `maxFiles` rollouts. A
128
+ * rollout counts when its first line, the session's metadata, names this folder and is no fork or subagent.
129
+ */
130
+ export async function listCodexSessions({
131
+ sessionsDir,
132
+ folder,
133
+ fs,
134
+ maxFiles = 400
135
+ }: {
136
+ sessionsDir: string;
137
+ folder: string;
138
+ fs: SessionFs;
139
+ maxFiles?: number;
140
+ }): Promise<ExternalSession[]> {
141
+ const sessions: ExternalSession[] = [];
142
+ for (const path of await rolloutFiles(sessionsDir, fs, maxFiles)) {
143
+ const entries = records(await fs.readHead(path, HEAD_BYTES));
144
+ const meta = entries[0];
145
+ const payload =
146
+ meta?.type === 'session_meta' && typeof meta.payload === 'object' && meta.payload !== null ? (meta.payload as Record<string, unknown>) : null;
147
+ if (payload === null || payload.cwd !== folder || typeof payload.id !== 'string' || isForkOrSubagent(payload)) {
148
+ continue;
149
+ }
150
+ const prompt = entries.map(codexPrompt).find((said) => said !== null);
151
+ if (prompt === undefined || prompt === null) {
152
+ continue;
153
+ }
154
+ const stat = await fs.stat(path);
155
+ sessions.push({ provider: 'codex', sessionId: payload.id, title: titleOf(prompt), updatedAt: stat?.mtimeMs ?? 0, cwd: folder });
156
+ }
157
+ return sessions;
158
+ }
159
+
160
+ /* A `node:fs` behind the seam, for a host that lists the real thing. */
161
+ export async function nodeSessionFs(): Promise<SessionFs> {
162
+ const { readdir, stat, open } = await import('node:fs/promises');
163
+ return {
164
+ readdir: async (path) => readdir(path).catch(() => []),
165
+ stat: async (path) => stat(path).catch(() => null),
166
+ readHead: async (path, maxBytes) => {
167
+ const handle = await open(path, 'r');
168
+ try {
169
+ const buffer = Buffer.alloc(maxBytes);
170
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
171
+ return buffer.subarray(0, bytesRead).toString('utf8');
172
+ } finally {
173
+ await handle.close();
174
+ }
175
+ }
176
+ };
177
+ }
@@ -146,6 +146,40 @@ export function fakeClaude(io: FakeIo): FakeProgram {
146
146
  // What a background agent asked; its answer comes in after the turn that launched it, whenever a person gives one.
147
147
  let backgroundAsk: { requestId: string; command: string } | null = null;
148
148
  let slow = false;
149
+ // The two foreground subagents of `two subagents` that have not settled; the turn stays open until both have.
150
+ const openTwo = new Set<string>();
151
+
152
+ const settleTwo = (name: 'a-one' | 'a-two', stopped: boolean): void => {
153
+ const toolUseId = name === 'a-one' ? 'toolu_two_a' : 'toolu_two_b';
154
+ if (!openTwo.delete(name)) {
155
+ return;
156
+ }
157
+ if (stopped) {
158
+ out({ type: 'system', subtype: 'task_updated', task_id: name, patch: { status: 'killed', end_time: 0 }, session_id: sessionId });
159
+ out({
160
+ type: 'system',
161
+ subtype: 'task_notification',
162
+ task_id: name,
163
+ tool_use_id: toolUseId,
164
+ status: 'stopped',
165
+ output_file: '',
166
+ summary: 'Stopped',
167
+ session_id: sessionId
168
+ });
169
+ }
170
+ out({
171
+ type: 'user',
172
+ message: {
173
+ role: 'user',
174
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content: stopped ? 'Stopped' : `${name} done`, is_error: stopped }]
175
+ },
176
+ session_id: sessionId
177
+ });
178
+ if (openTwo.size === 0) {
179
+ assistantText('summarized B');
180
+ result();
181
+ }
182
+ };
149
183
 
150
184
  // The rest of a background agent once its request was answered: its call's result, its report, and the turn the CLI opens about it.
151
185
  const finishBackgroundAgent = (command: string, allowed: boolean, message: string | undefined): void => {
@@ -193,6 +227,43 @@ export function fakeClaude(io: FakeIo): FakeProgram {
193
227
  io.exit(1);
194
228
  return;
195
229
  }
230
+ // Two foreground subagents beside each other; the turn ends once both settled: a-two when the test runs what is put off, a-one by a `stop_task`.
231
+ if (text === 'two subagents') {
232
+ openTwo.add('a-one').add('a-two');
233
+ for (const [name, toolUseId] of [
234
+ ['a-one', 'toolu_two_a'],
235
+ ['a-two', 'toolu_two_b']
236
+ ] as const) {
237
+ out({
238
+ type: 'assistant',
239
+ message: {
240
+ id: `msg_${nonce}_${++messageCounter}`,
241
+ model,
242
+ role: 'assistant',
243
+ content: [
244
+ { type: 'tool_use', id: toolUseId, name: 'Agent', input: { description: name, subagent_type: 'general-purpose', prompt: name } }
245
+ ],
246
+ usage
247
+ },
248
+ session_id: sessionId
249
+ });
250
+ out({
251
+ type: 'system',
252
+ subtype: 'task_started',
253
+ task_id: name,
254
+ tool_use_id: toolUseId,
255
+ description: name,
256
+ subagent_type: 'general-purpose',
257
+ is_backgrounded: false,
258
+ spawn_depth: 1,
259
+ task_type: 'local_agent',
260
+ prompt: name,
261
+ session_id: sessionId
262
+ });
263
+ }
264
+ io.later(() => settleTwo('a-two', false));
265
+ return;
266
+ }
196
267
  if (text === 'crash loudly') {
197
268
  io.err(`${'warming up\n'.repeat(2000)}Error: the fake lost its key\n at handleUser (fake-claude.ts)\n`);
198
269
  io.exit(1);
@@ -952,8 +1023,11 @@ export function fakeClaude(io: FakeIo): FakeProgram {
952
1023
  } else if (frame.type === 'control_response') {
953
1024
  handleControlResponse(frame.response as Record<string, unknown>);
954
1025
  } else if (frame.type === 'control_request') {
955
- const request = frame.request as { subtype: string };
1026
+ const request = frame.request as { subtype: string; task_id?: string };
956
1027
  out({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response: {} } });
1028
+ if (request.subtype === 'stop_task' && (request.task_id === 'a-one' || request.task_id === 'a-two')) {
1029
+ settleTwo(request.task_id, true);
1030
+ }
957
1031
  if (request.subtype === 'interrupt' && slow) {
958
1032
  slow = false;
959
1033
  assistantText('[interrupted]');
@@ -5,13 +5,8 @@ export const LIMIT_RETRY_DELAYS_MS: readonly number[] = [60_000, 5 * 60_000, 15
5
5
 
6
6
  /* The last turn of a thread, when it stopped on a limit. */
7
7
  export function limitedTurn(items: readonly ChatItem[]): (ChatTurnItem & { limit: ChatTurnLimit }) | null {
8
- for (let i = items.length - 1; i >= 0; i--) {
9
- const item = items[i]!;
10
- if (item.kind === 'turn') {
11
- return item.state === 'error' && item.limit !== undefined ? (item as ChatTurnItem & { limit: ChatTurnLimit }) : null;
12
- }
13
- }
14
- return null;
8
+ const last = items.findLast((item) => item.kind === 'turn');
9
+ return last?.kind === 'turn' && last.state === 'error' && last.limit !== undefined ? (last as ChatTurnItem & { limit: ChatTurnLimit }) : null;
15
10
  }
16
11
 
17
12
  /* How many turns in a row, up to and including this one, stopped on a limit: the tries a person did not step in between. */
@@ -0,0 +1,87 @@
1
+ import type { ChatItem } from '@adecore/agent-contracts';
2
+
3
+ /* One message of a thread that holds the query, cut to a line around the match. */
4
+ export interface ThreadHit {
5
+ itemId: string;
6
+ author: 'person' | 'agent';
7
+ snippet: string;
8
+ /* Where the match sits in `snippet`, in UTF-16 units. */
9
+ matchStart: number;
10
+ matchLength: number;
11
+ createdAt: number;
12
+ turnId: string | null;
13
+ }
14
+
15
+ export interface ThreadSearchOptions {
16
+ limit: number;
17
+ }
18
+
19
+ // Characters kept on each side of the match.
20
+ const CONTEXT = 60;
21
+
22
+ // Emphasis and code markers carry no meaning in a one-line snippet, where they only read as noise.
23
+ function plain(text: string): string {
24
+ return text.replace(/\*{1,3}|_{2,}|`+/g, '').replace(/\s+/g, ' ');
25
+ }
26
+
27
+ // Moves a cut forward to the start of the next word, never past `limit`, so a snippet does not open on half a word.
28
+ function wordStart(text: string, from: number, limit: number): number {
29
+ if (from === 0 || /\s/.test(text[from - 1]!) || /\s/.test(text[from]!)) {
30
+ return from;
31
+ }
32
+ const space = text.slice(from, limit).search(/\s/);
33
+ return space === -1 ? from : from + space + 1;
34
+ }
35
+
36
+ // Moves a cut back to the end of the previous word, never before `limit`.
37
+ function wordEnd(text: string, to: number, limit: number): number {
38
+ if (to === text.length || /\s/.test(text[to - 1]!) || /\s/.test(text[to]!)) {
39
+ return to;
40
+ }
41
+ const space = text.slice(limit, to).search(/\s\S*$/);
42
+ return space === -1 ? to : limit + space;
43
+ }
44
+
45
+ function snippetOf(text: string, index: number, length: number): { snippet: string; matchStart: number; matchLength: number } {
46
+ const from = wordStart(text, Math.max(0, index - CONTEXT), index);
47
+ const to = wordEnd(text, Math.min(text.length, index + length + CONTEXT), index + length);
48
+ const before = `${from > 0 ? '…' : ''}${plain(text.slice(from, index))}`;
49
+ const match = plain(text.slice(index, index + length));
50
+ const after = `${plain(text.slice(index + length, to))}${to < text.length ? '…' : ''}`;
51
+ return { snippet: `${before}${match}${after}`, matchStart: before.length, matchLength: match.length };
52
+ }
53
+
54
+ /*
55
+ * The messages of a thread that hold `query`, oldest first, one hit per message. Only what a person
56
+ * and the agent said counts: what a subagent said (`parentToolUseId`) is not part of the conversation.
57
+ */
58
+ export function searchThreadItems(items: readonly ChatItem[], query: string, { limit }: ThreadSearchOptions): ThreadHit[] {
59
+ if (query === '' || limit <= 0) {
60
+ return [];
61
+ }
62
+ const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
63
+ const hits: ThreadHit[] = [];
64
+ for (const item of items) {
65
+ if (item.kind !== 'user' && item.kind !== 'assistant') {
66
+ continue;
67
+ }
68
+ if (item.kind === 'assistant' && item.parentToolUseId) {
69
+ continue;
70
+ }
71
+ const found = pattern.exec(item.text);
72
+ if (found === null) {
73
+ continue;
74
+ }
75
+ hits.push({
76
+ itemId: item.id,
77
+ author: item.kind === 'user' ? 'person' : 'agent',
78
+ ...snippetOf(item.text, found.index, found[0].length),
79
+ createdAt: item.createdAt,
80
+ turnId: item.turnId
81
+ });
82
+ if (hits.length >= limit) {
83
+ break;
84
+ }
85
+ }
86
+ return hits;
87
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
2
3
  import { ChatError } from './errors.ts';
3
4
  import { requestSummaries } from './request-summary.ts';
4
- import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
5
5
 
6
6
  /*
7
7
  * The state of one chat as the client sees it. Every mutation answers the event that describes
@@ -113,6 +113,13 @@ function tooLarge(bytes: number): never {
113
113
  );
114
114
  }
115
115
 
116
+ function refuseOversized(html: string): void {
117
+ const bytes = Buffer.byteLength(html, 'utf8');
118
+ if (bytes > VISUAL_LIMITS.bytes) {
119
+ tooLarge(bytes);
120
+ }
121
+ }
122
+
116
123
  /*
117
124
  * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
118
125
  * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
@@ -164,9 +171,7 @@ export class VisualStore {
164
171
  if (html.trim() === '') {
165
172
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
166
173
  }
167
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
168
- tooLarge(Buffer.byteLength(html, 'utf8'));
169
- }
174
+ refuseOversized(html);
170
175
  return this.writeWorkspaceFile(chatId, name, html);
171
176
  }
172
177
 
@@ -178,9 +183,7 @@ export class VisualStore {
178
183
  async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
179
184
  const { title, html, maxHeight, layout, heights } = checked(input);
180
185
  // Before the bootstrap is added, so a page far too large is never copied first.
181
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
182
- tooLarge(Buffer.byteLength(html, 'utf8'));
183
- }
186
+ refuseOversized(html);
184
187
  const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
185
188
  if (page.byteLength > VISUAL_LIMITS.bytes) {
186
189
  tooLarge(page.byteLength);
@@ -2,7 +2,10 @@ import type { ChatItem } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from './chat-core.ts';
3
3
  import type { ChatSession } from './chat-session.ts';
4
4
 
5
- /* A chat the host may open a turn in, as the outbox handlers see it: what its thread holds, and the one call that opens one, false while a turn or an owed resume is in the way. */
5
+ /*
6
+ * A chat the host may open a turn in, as the outbox handlers see it. `wake` answers false while a
7
+ * turn or an owed resume is in the way.
8
+ */
6
9
  export interface WakeChat {
7
10
  items(): ChatItem[];
8
11
  persist?(): Promise<void>;
@@ -1,8 +1,7 @@
1
1
  /*
2
2
  * How every no reads: `refused`, the code a script branches on and the sentence a model reads, and
3
- * under it a line per thing the actor can do instead. One row per line, and a line is a row of
4
- * tab-separated fields of its own, so only a newline is taken out of it: that would split the row in
5
- * two, and everything under the first row is read as advice.
3
+ * under it a line per thing the actor can do instead. A line keeps its tabs but loses its newlines,
4
+ * since everything under the first row is read as advice.
6
5
  */
7
6
 
8
7
  /* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
@@ -24,10 +23,7 @@ export function refusalBody(code: string, message: string, lines: readonly strin
24
23
  return [`refused\t${first}`, ...rest].join('\n');
25
24
  }
26
25
 
27
- /*
28
- * The same without the word that names it, for a host that adds the prefix in its CLI:
29
- * The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
30
- */
26
+ /* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
31
27
  export function refusalRows(code: string, message: string, lines: readonly string[] = []): string {
32
28
  return rows(code, message, lines).join('\n');
33
29
  }
@@ -382,11 +382,7 @@ export function requiredField(needs: string): z.ZodString {
382
382
  return z.string({ error: needs }).min(1, needs);
383
383
  }
384
384
 
385
- /*
386
- * What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
387
- * canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
388
- * none, which is a different thing to do something about.
389
- */
385
+ /* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
390
386
  export function orNote(lines: string[], note: string): string[] {
391
387
  return lines.length === 0 ? [`note\t${note}`] : lines;
392
388
  }
@@ -24,8 +24,8 @@ function isAgentRequest(type: string): type is AgentRequestType {
24
24
 
25
25
  /*
26
26
  * The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
27
- * sends the agent events back on it, frames checked on arrival. One port is one client. Closing the host writes every thread and ends every CLI; a chat
28
- * goes on at the next start through its CLI's own session.
27
+ * sends the agent events back on it, frames checked on arrival. One port is one client. A chat
28
+ * closed with the host goes on at the next start through its CLI's own session.
29
29
  */
30
30
  export class AgentHost<Core extends ChatCore = ChatCore> {
31
31
  readonly chats: Core;
@@ -63,7 +63,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
63
63
  chats.send(
64
64
  payload.chatId,
65
65
  payload.text,
66
- { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
66
+ { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats, quoteSource: payload.quoteSource },
67
67
  payload.attachments
68
68
  ),
69
69
  'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
@@ -116,7 +116,6 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
116
116
  };
117
117
  }
118
118
 
119
- /* The accounts are the person's settings of this host. */
120
119
  export function accountHandlers(accounts: ProviderAccountsService): { [T in AccountRequestType]: AgentHandler<T> } {
121
120
  return {
122
121
  'accounts.list': () => accounts.snapshot(),
@@ -9,12 +9,12 @@ import { VisualStore } from '../chat/visual-store.ts';
9
9
  import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
10
10
  import { ClaudeTitleReader } from '../chat/claude-title.ts';
11
11
  import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
12
+ import { errorText } from '../error-text.ts';
12
13
  import type { AgentEvent } from '../events.ts';
13
14
  import { definedEnv } from '../providers/accounts/launch.ts';
14
15
  import { ProviderAccountsService } from '../providers/accounts/service.ts';
15
16
  import type { AccountsHost } from '../providers/accounts/variables.ts';
16
17
  import { createClaudeProvider } from '../providers/claude-provider.ts';
17
- import { errorText } from '../error-text.ts';
18
18
  import { createCodexProvider } from '../providers/codex-provider.ts';
19
19
  import { codexRulesPathIn, defaultCodexHome, installCodexRules, type CodexRules } from '../providers/codex-rules.ts';
20
20
  import { ProviderRegistry, type ProviderRegistryOptions } from '../providers/registry.ts';
package/src/lineage.ts CHANGED
@@ -30,8 +30,8 @@ function openedByAgent(entry: Lineage): boolean {
30
30
  }
31
31
 
32
32
  /*
33
- * Lineage lives in `lineage` under the host's data folder, outside the agent-writable project, so agents
34
- * cannot reset their own depth. Persisting it also prevents a restart of the host from resetting recursion limits.
33
+ * Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
34
+ * own depth; on disk, so a restart of the host does not reset it either.
35
35
  */
36
36
  export class AgentLineageStore {
37
37
  readonly dir: string;
@@ -140,7 +140,6 @@ export class AgentLineageStore {
140
140
  return entry?.relation === 'fork' ? entry.openedBy : null;
141
141
  }
142
142
 
143
- /* The forks made from a chat. */
144
143
  forksOf(nodeId: string): string[] {
145
144
  return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
146
145
  }
@@ -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);