@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
@@ -7,12 +7,12 @@ export interface CodexBackendOptions {
7
7
  }
8
8
  export declare class CodexBackend implements ChatBackend {
9
9
  readonly acknowledgesTurns = true;
10
- private promptId;
11
10
  private readonly launch;
12
- private imageWrites;
13
11
  private readonly host;
14
12
  private readonly protocol;
15
13
  private readonly client;
14
+ private promptId;
15
+ private imageWrites;
16
16
  private transport;
17
17
  private threadId;
18
18
  private contextPending;
@@ -1,10 +1,10 @@
1
1
  import { accessSync, constants } from 'node:fs';
2
2
  import { attachmentImageMime } from '@adecore/agent-contracts';
3
+ import { errorText } from '../error-text.js';
3
4
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.js';
4
5
  import { CodexProtocol } from './codex-protocol.js';
5
- import { attachmentNote } from './input.js';
6
6
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
7
- import { errorText } from '../error-text.js';
7
+ import { attachmentNote } from './input.js';
8
8
  function textInput(text) {
9
9
  return [{ type: 'text', text, text_elements: [] }];
10
10
  }
@@ -37,18 +37,15 @@ export function parseSkillsList(result) {
37
37
  */
38
38
  export class CodexBackend {
39
39
  acknowledgesTurns = true;
40
- promptId = null;
41
40
  launch;
42
- imageWrites = null;
43
41
  host;
44
42
  protocol;
45
43
  client;
44
+ promptId = null;
45
+ imageWrites = null;
46
46
  transport = null;
47
47
  threadId = '';
48
- /*
49
- * Codex 0.154 ignores developer instructions on `thread/resume` (measured), so a thread started
50
- * before anything was linked would never hear of its links; the first prompt of a resumed process says so instead.
51
- */
48
+ // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
52
49
  contextPending = null;
53
50
  imageInputSupported = null;
54
51
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -130,7 +130,7 @@ function elicitationInput(meta) {
130
130
  }
131
131
  return input;
132
132
  }
133
- // Every spent window must reset; one unknown reset keeps resuming a person's choice.
133
+ // The latest reset of every spent window; one without a known reset leaves resuming to a person.
134
134
  function spentUntil(windows) {
135
135
  const spent = windows.filter((window) => window.used >= 1);
136
136
  if (spent.length === 0 || spent.some((window) => window.resetsAt === null)) {
@@ -1,8 +1,8 @@
1
+ import { errorText } from '../error-text.js';
1
2
  import { CodexProtocol } from './codex-protocol.js';
2
3
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
3
4
  import { ChatError } from './errors.js';
4
5
  import { readingThread, settledReading } from './subagent-projection.js';
5
- import { errorText } from '../error-text.js';
6
6
  function isRecord(value) {
7
7
  return typeof value === 'object' && value !== null && !Array.isArray(value);
8
8
  }
@@ -85,14 +85,13 @@ async function nthTurnId(transport, threadId, turns) {
85
85
  let seen = 0;
86
86
  let cursor = null;
87
87
  do {
88
- const page = await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) });
89
- const data = isRecord(page) && Array.isArray(page.data) ? page.data.filter(isRecord) : [];
90
- for (const turn of data) {
88
+ const page = parseThreadItemsPage(await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) }));
89
+ for (const turn of page.entries) {
91
90
  if (++seen === turns) {
92
91
  return typeof turn.id === 'string' ? turn.id : null;
93
92
  }
94
93
  }
95
- cursor = isRecord(page) && typeof page.nextCursor === 'string' && page.nextCursor !== '' ? page.nextCursor : null;
94
+ cursor = page.nextCursor;
96
95
  } while (cursor !== null);
97
96
  return null;
98
97
  }
@@ -1,8 +1,7 @@
1
1
  /*
2
2
  * What each connected client says a new chat starts with, for the chats the host starts with no
3
- * client mounting them. Held per socket like the approval switch: a machine never remembers a
4
- * person's pick after that person's client is gone, so with nobody connected a chat gets the
5
- * host's own defaults.
3
+ * client mounting them. Held per client only, so with nobody connected a chat gets the host's own
4
+ * defaults rather than the pick of a person who left.
6
5
  */
7
6
  export class ComposerPreferences {
8
7
  byClient = new Map();
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
2
+ export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }
@@ -0,0 +1,26 @@
1
+ export interface SessionFs {
2
+ readdir(path: string): Promise<string[]>;
3
+ stat(path: string): Promise<{
4
+ mtimeMs: number;
5
+ } | null>;
6
+ readHead(path: string, maxBytes: number): Promise<string>;
7
+ }
8
+ export interface ExternalSession {
9
+ provider: 'claude' | 'codex';
10
+ sessionId: string;
11
+ title: string;
12
+ updatedAt: number;
13
+ cwd: string;
14
+ }
15
+ export declare function listClaudeSessions({ projectsDir, folder, fs }: {
16
+ projectsDir: string;
17
+ folder: string;
18
+ fs: SessionFs;
19
+ }): Promise<ExternalSession[]>;
20
+ export declare function listCodexSessions({ sessionsDir, folder, fs, maxFiles }: {
21
+ sessionsDir: string;
22
+ folder: string;
23
+ fs: SessionFs;
24
+ maxFiles?: number;
25
+ }): Promise<ExternalSession[]>;
26
+ export declare function nodeSessionFs(): Promise<SessionFs>;
@@ -0,0 +1,136 @@
1
+ import { join } from 'node:path';
2
+ import { clipText } from '@adecore/agent-contracts';
3
+ import { isForkOrSubagent } from '../usage/readers/codex.js';
4
+ import { claudeProjectSlug, spokenText } from './claude-transcript.js';
5
+ const TITLE_LENGTH = 120;
6
+ // Claude writes hook output and attachments ahead of the first prompt, and Codex its instructions.
7
+ const HEAD_BYTES = 256 * 1024;
8
+ const CLAUDE_MAX_FILES = 200;
9
+ function titleOf(text) {
10
+ return clipText(text.replace(/\s+/g, ' ').trim(), TITLE_LENGTH);
11
+ }
12
+ function records(head) {
13
+ const lines = head.split('\n');
14
+ // The head ends wherever the byte limit fell, so its last line may not parse.
15
+ return lines.flatMap((line) => {
16
+ if (line === '') {
17
+ return [];
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(line);
21
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed] : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ });
27
+ }
28
+ const CLAUDE_BOOKKEEPING = new Set(['summary', 'file-history-snapshot', 'queue-operation']);
29
+ /* A reminder or the wrapper of a slash command is written as a user line too, but nobody said it. */
30
+ function claudePrompt(entry) {
31
+ if (CLAUDE_BOOKKEEPING.has(String(entry.type)) || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) {
32
+ return null;
33
+ }
34
+ const message = typeof entry.message === 'object' && entry.message !== null ? entry.message : {};
35
+ const said = spokenText(message.content);
36
+ if (said === null || said.trim() === '' || said.startsWith('<command-') || said.startsWith('<local-command-')) {
37
+ return null;
38
+ }
39
+ return said;
40
+ }
41
+ /*
42
+ * The conversations Claude Code kept for a folder, newest first. Only the folder's own directory is
43
+ * read, and only the `.jsonl` files in it, so the transcripts of subagents in `<session>/subagents` stay out.
44
+ * A file without a prompt somebody said is no conversation.
45
+ */
46
+ export async function listClaudeSessions({ projectsDir, folder, fs }) {
47
+ const dir = join(projectsDir, claudeProjectSlug(folder));
48
+ const names = (await fs.readdir(dir)).filter((name) => name.endsWith('.jsonl'));
49
+ const stats = await Promise.all(names.map(async (name) => ({ name, stat: await fs.stat(join(dir, name)) })));
50
+ const newest = stats
51
+ .flatMap(({ name, stat }) => (stat === null ? [] : [{ name, mtimeMs: stat.mtimeMs }]))
52
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
53
+ .slice(0, CLAUDE_MAX_FILES);
54
+ const sessions = [];
55
+ for (const { name, mtimeMs } of newest) {
56
+ const prompt = records(await fs.readHead(join(dir, name), HEAD_BYTES))
57
+ .map(claudePrompt)
58
+ .find((said) => said !== null);
59
+ if (prompt !== undefined && prompt !== null) {
60
+ sessions.push({ provider: 'claude', sessionId: name.slice(0, -'.jsonl'.length), title: titleOf(prompt), updatedAt: mtimeMs, cwd: folder });
61
+ }
62
+ }
63
+ return sessions;
64
+ }
65
+ function codexPrompt(entry) {
66
+ const payload = typeof entry.payload === 'object' && entry.payload !== null ? entry.payload : null;
67
+ if (payload === null) {
68
+ return null;
69
+ }
70
+ if (entry.type === 'event_msg' && payload.type === 'user_message' && typeof payload.message === 'string') {
71
+ return payload.message.trim() === '' ? null : payload.message;
72
+ }
73
+ return null;
74
+ }
75
+ /* The newest names first; the folders are named by year, month and day, and the files by when they began. */
76
+ function newestFirst(names) {
77
+ return [...names].sort().reverse();
78
+ }
79
+ async function rolloutFiles(sessionsDir, fs, max) {
80
+ const files = [];
81
+ for (const year of newestFirst(await fs.readdir(sessionsDir))) {
82
+ for (const month of newestFirst(await fs.readdir(join(sessionsDir, year)))) {
83
+ for (const day of newestFirst(await fs.readdir(join(sessionsDir, year, month)))) {
84
+ const names = newestFirst((await fs.readdir(join(sessionsDir, year, month, day))).filter((name) => name.startsWith('rollout-') && name.endsWith('.jsonl')));
85
+ for (const name of names) {
86
+ files.push(join(sessionsDir, year, month, day, name));
87
+ if (files.length >= max) {
88
+ return files;
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return files;
95
+ }
96
+ /*
97
+ * The conversations Codex kept for a folder, newest first, out of the newest `maxFiles` rollouts. A
98
+ * rollout counts when its first line, the session's metadata, names this folder and is no fork or subagent.
99
+ */
100
+ export async function listCodexSessions({ sessionsDir, folder, fs, maxFiles = 400 }) {
101
+ const sessions = [];
102
+ for (const path of await rolloutFiles(sessionsDir, fs, maxFiles)) {
103
+ const entries = records(await fs.readHead(path, HEAD_BYTES));
104
+ const meta = entries[0];
105
+ const payload = meta?.type === 'session_meta' && typeof meta.payload === 'object' && meta.payload !== null ? meta.payload : null;
106
+ if (payload === null || payload.cwd !== folder || typeof payload.id !== 'string' || isForkOrSubagent(payload)) {
107
+ continue;
108
+ }
109
+ const prompt = entries.map(codexPrompt).find((said) => said !== null);
110
+ if (prompt === undefined || prompt === null) {
111
+ continue;
112
+ }
113
+ const stat = await fs.stat(path);
114
+ sessions.push({ provider: 'codex', sessionId: payload.id, title: titleOf(prompt), updatedAt: stat?.mtimeMs ?? 0, cwd: folder });
115
+ }
116
+ return sessions;
117
+ }
118
+ /* A `node:fs` behind the seam, for a host that lists the real thing. */
119
+ export async function nodeSessionFs() {
120
+ const { readdir, stat, open } = await import('node:fs/promises');
121
+ return {
122
+ readdir: async (path) => readdir(path).catch(() => []),
123
+ stat: async (path) => stat(path).catch(() => null),
124
+ readHead: async (path, maxBytes) => {
125
+ const handle = await open(path, 'r');
126
+ try {
127
+ const buffer = Buffer.alloc(maxBytes);
128
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
129
+ return buffer.subarray(0, bytesRead).toString('utf8');
130
+ }
131
+ finally {
132
+ await handle.close();
133
+ }
134
+ }
135
+ };
136
+ }
@@ -137,6 +137,39 @@ export function fakeClaude(io) {
137
137
  // What a background agent asked; its answer comes in after the turn that launched it, whenever a person gives one.
138
138
  let backgroundAsk = null;
139
139
  let slow = false;
140
+ // The two foreground subagents of `two subagents` that have not settled; the turn stays open until both have.
141
+ const openTwo = new Set();
142
+ const settleTwo = (name, stopped) => {
143
+ const toolUseId = name === 'a-one' ? 'toolu_two_a' : 'toolu_two_b';
144
+ if (!openTwo.delete(name)) {
145
+ return;
146
+ }
147
+ if (stopped) {
148
+ out({ type: 'system', subtype: 'task_updated', task_id: name, patch: { status: 'killed', end_time: 0 }, session_id: sessionId });
149
+ out({
150
+ type: 'system',
151
+ subtype: 'task_notification',
152
+ task_id: name,
153
+ tool_use_id: toolUseId,
154
+ status: 'stopped',
155
+ output_file: '',
156
+ summary: 'Stopped',
157
+ session_id: sessionId
158
+ });
159
+ }
160
+ out({
161
+ type: 'user',
162
+ message: {
163
+ role: 'user',
164
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content: stopped ? 'Stopped' : `${name} done`, is_error: stopped }]
165
+ },
166
+ session_id: sessionId
167
+ });
168
+ if (openTwo.size === 0) {
169
+ assistantText('summarized B');
170
+ result();
171
+ }
172
+ };
140
173
  // 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.
141
174
  const finishBackgroundAgent = (command, allowed, message) => {
142
175
  const toolUseId = 'toolu_bgask';
@@ -182,6 +215,43 @@ export function fakeClaude(io) {
182
215
  io.exit(1);
183
216
  return;
184
217
  }
218
+ // 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`.
219
+ if (text === 'two subagents') {
220
+ openTwo.add('a-one').add('a-two');
221
+ for (const [name, toolUseId] of [
222
+ ['a-one', 'toolu_two_a'],
223
+ ['a-two', 'toolu_two_b']
224
+ ]) {
225
+ out({
226
+ type: 'assistant',
227
+ message: {
228
+ id: `msg_${nonce}_${++messageCounter}`,
229
+ model,
230
+ role: 'assistant',
231
+ content: [
232
+ { type: 'tool_use', id: toolUseId, name: 'Agent', input: { description: name, subagent_type: 'general-purpose', prompt: name } }
233
+ ],
234
+ usage
235
+ },
236
+ session_id: sessionId
237
+ });
238
+ out({
239
+ type: 'system',
240
+ subtype: 'task_started',
241
+ task_id: name,
242
+ tool_use_id: toolUseId,
243
+ description: name,
244
+ subagent_type: 'general-purpose',
245
+ is_backgrounded: false,
246
+ spawn_depth: 1,
247
+ task_type: 'local_agent',
248
+ prompt: name,
249
+ session_id: sessionId
250
+ });
251
+ }
252
+ io.later(() => settleTwo('a-two', false));
253
+ return;
254
+ }
185
255
  if (text === 'crash loudly') {
186
256
  io.err(`${'warming up\n'.repeat(2000)}Error: the fake lost its key\n at handleUser (fake-claude.ts)\n`);
187
257
  io.exit(1);
@@ -932,6 +1002,9 @@ export function fakeClaude(io) {
932
1002
  else if (frame.type === 'control_request') {
933
1003
  const request = frame.request;
934
1004
  out({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response: {} } });
1005
+ if (request.subtype === 'stop_task' && (request.task_id === 'a-one' || request.task_id === 'a-two')) {
1006
+ settleTwo(request.task_id, true);
1007
+ }
935
1008
  if (request.subtype === 'interrupt' && slow) {
936
1009
  slow = false;
937
1010
  assistantText('[interrupted]');
@@ -2,13 +2,8 @@
2
2
  export const LIMIT_RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
3
3
  /* The last turn of a thread, when it stopped on a limit. */
4
4
  export function limitedTurn(items) {
5
- for (let i = items.length - 1; i >= 0; i--) {
6
- const item = items[i];
7
- if (item.kind === 'turn') {
8
- return item.state === 'error' && item.limit !== undefined ? item : null;
9
- }
10
- }
11
- return null;
5
+ const last = items.findLast((item) => item.kind === 'turn');
6
+ return last?.kind === 'turn' && last.state === 'error' && last.limit !== undefined ? last : null;
12
7
  }
13
8
  /* 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. */
14
9
  function limitedInARow(items, turn) {
@@ -0,0 +1,14 @@
1
+ import type { ChatItem } from '@adecore/agent-contracts';
2
+ export interface ThreadHit {
3
+ itemId: string;
4
+ author: 'person' | 'agent';
5
+ snippet: string;
6
+ matchStart: number;
7
+ matchLength: number;
8
+ createdAt: number;
9
+ turnId: string | null;
10
+ }
11
+ export interface ThreadSearchOptions {
12
+ limit: number;
13
+ }
14
+ export declare function searchThreadItems(items: readonly ChatItem[], query: string, { limit }: ThreadSearchOptions): ThreadHit[];
@@ -0,0 +1,64 @@
1
+ // Characters kept on each side of the match.
2
+ const CONTEXT = 60;
3
+ // Emphasis and code markers carry no meaning in a one-line snippet, where they only read as noise.
4
+ function plain(text) {
5
+ return text.replace(/\*{1,3}|_{2,}|`+/g, '').replace(/\s+/g, ' ');
6
+ }
7
+ // Moves a cut forward to the start of the next word, never past `limit`, so a snippet does not open on half a word.
8
+ function wordStart(text, from, limit) {
9
+ if (from === 0 || /\s/.test(text[from - 1]) || /\s/.test(text[from])) {
10
+ return from;
11
+ }
12
+ const space = text.slice(from, limit).search(/\s/);
13
+ return space === -1 ? from : from + space + 1;
14
+ }
15
+ // Moves a cut back to the end of the previous word, never before `limit`.
16
+ function wordEnd(text, to, limit) {
17
+ if (to === text.length || /\s/.test(text[to - 1]) || /\s/.test(text[to])) {
18
+ return to;
19
+ }
20
+ const space = text.slice(limit, to).search(/\s\S*$/);
21
+ return space === -1 ? to : limit + space;
22
+ }
23
+ function snippetOf(text, index, length) {
24
+ const from = wordStart(text, Math.max(0, index - CONTEXT), index);
25
+ const to = wordEnd(text, Math.min(text.length, index + length + CONTEXT), index + length);
26
+ const before = `${from > 0 ? '…' : ''}${plain(text.slice(from, index))}`;
27
+ const match = plain(text.slice(index, index + length));
28
+ const after = `${plain(text.slice(index + length, to))}${to < text.length ? '…' : ''}`;
29
+ return { snippet: `${before}${match}${after}`, matchStart: before.length, matchLength: match.length };
30
+ }
31
+ /*
32
+ * The messages of a thread that hold `query`, oldest first, one hit per message. Only what a person
33
+ * and the agent said counts: what a subagent said (`parentToolUseId`) is not part of the conversation.
34
+ */
35
+ export function searchThreadItems(items, query, { limit }) {
36
+ if (query === '' || limit <= 0) {
37
+ return [];
38
+ }
39
+ const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
40
+ const hits = [];
41
+ for (const item of items) {
42
+ if (item.kind !== 'user' && item.kind !== 'assistant') {
43
+ continue;
44
+ }
45
+ if (item.kind === 'assistant' && item.parentToolUseId) {
46
+ continue;
47
+ }
48
+ const found = pattern.exec(item.text);
49
+ if (found === null) {
50
+ continue;
51
+ }
52
+ hits.push({
53
+ itemId: item.id,
54
+ author: item.kind === 'user' ? 'person' : 'agent',
55
+ ...snippetOf(item.text, found.index, found[0].length),
56
+ createdAt: item.createdAt,
57
+ turnId: item.turnId
58
+ });
59
+ if (hits.length >= limit) {
60
+ break;
61
+ }
62
+ }
63
+ return hits;
64
+ }
@@ -59,6 +59,12 @@ function tooLarge(bytes) {
59
59
  return refuse('visual-too-large', `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
60
60
  'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them');
61
61
  }
62
+ function refuseOversized(html) {
63
+ const bytes = Buffer.byteLength(html, 'utf8');
64
+ if (bytes > VISUAL_LIMITS.bytes) {
65
+ tooLarge(bytes);
66
+ }
67
+ }
62
68
  /*
63
69
  * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
64
70
  * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
@@ -104,9 +110,7 @@ export class VisualStore {
104
110
  if (html.trim() === '') {
105
111
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
106
112
  }
107
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
108
- tooLarge(Buffer.byteLength(html, 'utf8'));
109
- }
113
+ refuseOversized(html);
110
114
  return this.writeWorkspaceFile(chatId, name, html);
111
115
  }
112
116
  writePreview(chatId, png) {
@@ -116,9 +120,7 @@ export class VisualStore {
116
120
  async publish(chatId, input) {
117
121
  const { title, html, maxHeight, layout, heights } = checked(input);
118
122
  // Before the bootstrap is added, so a page far too large is never copied first.
119
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
120
- tooLarge(Buffer.byteLength(html, 'utf8'));
121
- }
123
+ refuseOversized(html);
122
124
  const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
123
125
  if (page.byteLength > VISUAL_LIMITS.bytes) {
124
126
  tooLarge(page.byteLength);
@@ -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
  /* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
8
7
  export function field(value) {
@@ -19,10 +18,7 @@ export function refusalBody(code, message, lines = []) {
19
18
  const [first, ...rest] = rows(code, message, lines);
20
19
  return [`refused\t${first}`, ...rest].join('\n');
21
20
  }
22
- /*
23
- * The same without the word that names it, for a host that adds the prefix in its CLI:
24
- * The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
25
- */
21
+ /* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
26
22
  export function refusalRows(code, message, lines = []) {
27
23
  return rows(code, message, lines).join('\n');
28
24
  }
@@ -223,11 +223,7 @@ export function lengthOf(input) {
223
223
  export function requiredField(needs) {
224
224
  return z.string({ error: needs }).min(1, needs);
225
225
  }
226
- /*
227
- * What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
228
- * canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
229
- * none, which is a different thing to do something about.
230
- */
226
+ /* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
231
227
  export function orNote(lines, note) {
232
228
  return lines.length === 0 ? [`note\t${note}`] : lines;
233
229
  }
@@ -10,8 +10,8 @@ function isAgentRequest(type) {
10
10
  }
11
11
  /*
12
12
  * The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
13
- * 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
14
- * goes on at the next start through its CLI's own session.
13
+ * sends the agent events back on it, frames checked on arrival. One port is one client. A chat
14
+ * closed with the host goes on at the next start through its CLI's own session.
15
15
  */
16
16
  export class AgentHost {
17
17
  chats;
@@ -31,7 +31,7 @@ export function chatHandlers(chats, providers) {
31
31
  chats.detach(payload.chatId, clientId);
32
32
  return {};
33
33
  },
34
- 'chat.send': (payload) => chats.send(payload.chatId, payload.text, { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
34
+ 'chat.send': (payload) => chats.send(payload.chatId, payload.text, { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats, quoteSource: payload.quoteSource }, payload.attachments),
35
35
  'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
36
36
  'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
37
37
  'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
@@ -81,7 +81,6 @@ export function chatHandlers(chats, providers) {
81
81
  'chat.list': () => ({ chats: chats.list() })
82
82
  };
83
83
  }
84
- /* The accounts are the person's settings of this host. */
85
84
  export function accountHandlers(accounts) {
86
85
  return {
87
86
  'accounts.list': () => accounts.snapshot(),
@@ -6,10 +6,10 @@ import { suggestChatTitle } from '../chat/chat-title.js';
6
6
  import { VisualStore } from '../chat/visual-store.js';
7
7
  import { ClaudeTitleReader } from '../chat/claude-title.js';
8
8
  import { DEFAULT_CODEX_CLIENT } from '../chat/codex-transport.js';
9
+ import { errorText } from '../error-text.js';
9
10
  import { definedEnv } from '../providers/accounts/launch.js';
10
11
  import { ProviderAccountsService } from '../providers/accounts/service.js';
11
12
  import { createClaudeProvider } from '../providers/claude-provider.js';
12
- import { errorText } from '../error-text.js';
13
13
  import { createCodexProvider } from '../providers/codex-provider.js';
14
14
  import { codexRulesPathIn, defaultCodexHome, installCodexRules } from '../providers/codex-rules.js';
15
15
  import { ProviderRegistry } from '../providers/registry.js';
package/dist/lineage.js CHANGED
@@ -25,8 +25,8 @@ function openedByAgent(entry) {
25
25
  return entry.agent && entry.relation !== 'fork';
26
26
  }
27
27
  /*
28
- * Lineage lives in `lineage` under the host's data folder, outside the agent-writable project, so agents
29
- * cannot reset their own depth. Persisting it also prevents a restart of the host from resetting recursion limits.
28
+ * Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
29
+ * own depth; on disk, so a restart of the host does not reset it either.
30
30
  */
31
31
  export class AgentLineageStore {
32
32
  dir;
@@ -117,7 +117,6 @@ export class AgentLineageStore {
117
117
  const entry = this.opened.get(nodeId);
118
118
  return entry?.relation === 'fork' ? entry.openedBy : null;
119
119
  }
120
- /* The forks made from a chat. */
121
120
  forksOf(nodeId) {
122
121
  return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
123
122
  }
@@ -5,13 +5,11 @@ export const DeliverMessageWorkSchema = z.object({
5
5
  payload: z.object({ from: z.string().min(1) })
6
6
  });
7
7
  /*
8
- * Opens the turn a message earns a chat. The messages themselves are not carried here: they wait in
9
- * the notice store and the turn's preamble takes them (`NoticeNotes`), the same channel a person's
10
- * own prompt reads them through, so one message is heard once whichever of the two opens the turn.
8
+ * Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
9
+ * takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
11
10
  *
12
- * One attempt, never `wait`. A chat that turned out to be in a turn reads the message in front of its
13
- * next one, which is what a message to a busy chat has always done; holding the entry instead would
14
- * make the host start turns nobody asked for long after the news was worth anything.
11
+ * One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
12
+ * holding the entry would start turns nobody asked for long after the news mattered.
15
13
  */
16
14
  export function deliverMessageHandler(deps) {
17
15
  return async (entry) => {
@@ -27,11 +25,8 @@ export function deliverMessageHandler(deps) {
27
25
  if (chat === null) {
28
26
  return;
29
27
  }
30
- /*
31
- * No note of its own, and the preamble stays off the thread. A person read the message as it
32
- * landed (`showNotices`), so anything the turn adds about it only says the same thing again;
33
- * the label on the turn already names who it came from.
34
- */
28
+ // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
29
+ // and the label on the turn names who it came from.
35
30
  chat.wake({
36
31
  text: deps.words.prompt(waiting.length),
37
32
  label: deps.words.label(waiting),