@adecore/agents 0.21.2 → 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.
@@ -176,6 +176,10 @@ export type BackendEvent = {
176
176
  type: 'note';
177
177
  level: 'info' | 'warning' | 'error';
178
178
  text: string;
179
+ } | {
180
+ type: 'stop.refused';
181
+ taskId: string;
182
+ message: string;
179
183
  } | {
180
184
  type: 'turn.done';
181
185
  promptId?: string;
@@ -113,6 +113,9 @@ export declare class ChatCore {
113
113
  values?: Record<string, unknown> | undefined;
114
114
  at: number;
115
115
  } | undefined;
116
+ quoteSource?: {
117
+ itemId: string;
118
+ } | undefined;
116
119
  }[] | undefined;
117
120
  queuePaused?: boolean | undefined;
118
121
  background?: {
@@ -486,8 +486,9 @@ export class ChatCore {
486
486
  return result;
487
487
  }
488
488
  /*
489
- * Stops one running sub-agent of the CLI's own. It cannot be stopped apart from its CLI, so its row
490
- * is only marked, and only while no turn runs: stopping that turn is the composer's Stop.
489
+ * Stops one running sub-agent of the CLI's own. The process that started it ends it with `stop_task`
490
+ * and the parent keeps working. Without such a process the row is only marked, and only while no
491
+ * turn runs: stopping that turn is the composer's Stop.
491
492
  */
492
493
  async stopSubagent(chatId, toolUseId) {
493
494
  await this.loaded(chatId);
@@ -502,7 +503,7 @@ export class ChatCore {
502
503
  if (row.origin === 'ruimte') {
503
504
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
504
505
  }
505
- session.markSubagentStopped(toolUseId);
506
+ session.stopSubagent(toolUseId);
506
507
  }
507
508
  /* Ends a command or a monitor a chat's CLI runs in the background, for a person who pressed Stop on it. */
508
509
  async stopTask(chatId, taskId) {
@@ -1,4 +1,4 @@
1
- import type { ChatAttachment, ChatAssistantItem, ChatCheckpointDiff, ChatEvent, ChatInfo, ChatItem, ChatQueuedMessage, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { ChatAttachment, ChatAssistantItem, ChatCheckpointDiff, ChatEvent, ChatInfo, ChatItem, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
3
3
  import type { ChatProvider } from '../providers/provider.ts';
4
4
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
@@ -15,6 +15,7 @@ export interface TurnCheckpoints {
15
15
  diff: ChatCheckpointDiff;
16
16
  after: string;
17
17
  } | null>;
18
+ head?(cwd: string): Promise<string | null>;
18
19
  }
19
20
  export interface PromptNotes {
20
21
  next(): {
@@ -72,6 +73,7 @@ export interface ChatSendExtras {
72
73
  mentions?: string[];
73
74
  skills?: string[];
74
75
  chats?: string[];
76
+ quoteSource?: ChatQuoteSource;
75
77
  attachments?: ChatAttachment[];
76
78
  }
77
79
  export interface ResumeWords {
@@ -101,6 +103,8 @@ export declare class ChatSession {
101
103
  private titleTimer;
102
104
  private naming;
103
105
  private frozen;
106
+ private readonly subagentOwners;
107
+ private readonly stopping;
104
108
  private readonly orphans;
105
109
  private pendingPreambles;
106
110
  private submission;
@@ -127,7 +131,7 @@ export declare class ChatSession {
127
131
  resumeAtReset?: boolean;
128
132
  }): ChatInfo;
129
133
  setAccount(account: string | undefined): ChatInfo;
130
- send(text: string, extras?: ChatSendExtras): {
134
+ send(text: string, sent?: ChatSendExtras): {
131
135
  queued: boolean;
132
136
  turnId: string;
133
137
  };
@@ -140,6 +144,7 @@ export declare class ChatSession {
140
144
  turnId: string;
141
145
  };
142
146
  private recordUiAnswer;
147
+ private vetted;
143
148
  sendInput(text: string, extras?: ChatSendExtras): Promise<{
144
149
  queued: boolean;
145
150
  turnId: string;
@@ -199,6 +204,10 @@ export declare class ChatSession {
199
204
  }): Promise<unknown> | null;
200
205
  settleOrphanedSubagents(): Promise<void>;
201
206
  private recordLostBackground;
207
+ stopSubagent(toolUseId: string): 'stopping' | 'marked';
208
+ private trackSubagentOwner;
209
+ private refuseStop;
210
+ private noteStopped;
202
211
  markSubagentStopped(toolUseId: string): void;
203
212
  markSubagentsStopped(): void;
204
213
  private markStopped;
@@ -70,6 +70,10 @@ export class ChatSession {
70
70
  naming = false;
71
71
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
72
72
  frozen = false;
73
+ // Which CLI process (generation) announced each subagent of its own, by call id; only that one can stop it.
74
+ subagentOwners = new Map();
75
+ // Subagents a stop_task went out for, as task id to call id, until their notification settles them.
76
+ stopping = new Map();
73
77
  // Background subagents a load found running whose transcript did not show an end yet; no CLI here will report them.
74
78
  orphans = new Set();
75
79
  pendingPreambles;
@@ -167,7 +171,8 @@ export class ChatSession {
167
171
  /*
168
172
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
169
173
  */
170
- send(text, extras = {}) {
174
+ send(text, sent = {}) {
175
+ const extras = this.vetted(sent);
171
176
  const turnId = newId('turn');
172
177
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
173
178
  if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
@@ -183,6 +188,7 @@ export class ChatSession {
183
188
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
184
189
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
185
190
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
191
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {}),
186
192
  ...(extras.attachments?.length ? { attachments: extras.attachments } : {})
187
193
  };
188
194
  this.setQueue([...this.queue, message]);
@@ -278,10 +284,20 @@ export class ChatSession {
278
284
  this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
279
285
  this.options.persist();
280
286
  }
287
+ /* A quote whose source is no reply of this thread is sent as the plain text it is; the message itself is never refused for it. */
288
+ vetted(extras) {
289
+ if (extras.quoteSource === undefined || this.thread.get(extras.quoteSource.itemId)?.kind === 'assistant') {
290
+ return extras;
291
+ }
292
+ const plain = { ...extras };
293
+ delete plain.quoteSource;
294
+ return plain;
295
+ }
281
296
  sendInput(text, extras = {}) {
282
297
  return this.submitInput(text, extras);
283
298
  }
284
- async submitInput(text, extras) {
299
+ async submitInput(text, sent) {
300
+ const extras = this.vetted(sent);
285
301
  const backend = this.backend;
286
302
  const turnId = this.info.activeTurnId;
287
303
  if (extras.delivery !== 'steer' ||
@@ -323,7 +339,8 @@ export class ChatSession {
323
339
  mentions: extras.mentions,
324
340
  skills: extras.skills,
325
341
  chats: references.ids,
326
- attachments: extras.attachments
342
+ attachments: extras.attachments,
343
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
327
344
  }),
328
345
  this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
329
346
  ]);
@@ -406,7 +423,14 @@ export class ChatSession {
406
423
  this.sendAfterReplacement = false;
407
424
  this.pauseQueue(false);
408
425
  this.setQueue(rest);
409
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
426
+ this.dispatch(next.text, {
427
+ mentions: next.mentions,
428
+ skills: next.skills,
429
+ chats: next.chats,
430
+ attachments: next.attachments,
431
+ uiChoice: next.uiChoice,
432
+ quoteSource: next.quoteSource
433
+ }, next.turnId);
410
434
  }
411
435
  dispatch(text, extras, requestedTurnId) {
412
436
  this.pauseQueue(false);
@@ -1016,6 +1040,96 @@ export class ChatSession {
1016
1040
  const text = `The machine restarted before background work "${label}" completed. It will not send a result. Do not wait for it.`;
1017
1041
  this.deliverNote({ noteId: `lost-background-${item.id}`, note: text, preamble: text });
1018
1042
  }
1043
+ /*
1044
+ * Stops one subagent of the CLI's own. The process that announced it ends it with `stop_task` and the
1045
+ * parent goes on; a process that did not start it has nothing to address, so while a turn runs that
1046
+ * is refused, and otherwise the row is only marked.
1047
+ */
1048
+ stopSubagent(toolUseId) {
1049
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1050
+ const agentId = row?.native?.agentId;
1051
+ const owned = this.subagentOwners.get(toolUseId) === this.generation;
1052
+ if (agentId !== undefined && owned && this.backend?.stopTask) {
1053
+ this.stopping.set(agentId, toolUseId);
1054
+ this.backend.stopTask(agentId);
1055
+ return 'stopping';
1056
+ }
1057
+ if (agentId !== undefined && !owned && this.thread.info.activeTurnId !== null) {
1058
+ throw new ChatError('subagent-stale', 'This sub-agent belongs to a process that has ended');
1059
+ }
1060
+ this.markSubagentStopped(toolUseId);
1061
+ return 'marked';
1062
+ }
1063
+ trackSubagentOwner(generation, event) {
1064
+ if (event.type === 'task.started' && event.taskId) {
1065
+ this.subagentOwners.set(event.ref, generation);
1066
+ }
1067
+ else if (event.type === 'task.done') {
1068
+ this.noteStopped(event);
1069
+ if (event.ref !== null) {
1070
+ this.subagentOwners.delete(event.ref);
1071
+ }
1072
+ }
1073
+ else if (event.type === 'exit') {
1074
+ this.stopping.clear();
1075
+ }
1076
+ }
1077
+ /* The CLI would not stop it: with no turn running the row is only marked, and while one runs a note says it still goes on. */
1078
+ refuseStop(agentId, message) {
1079
+ const toolUseId = this.stopping.get(agentId);
1080
+ if (toolUseId === undefined) {
1081
+ return;
1082
+ }
1083
+ this.stopping.delete(agentId);
1084
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1085
+ if (row?.status !== 'running') {
1086
+ return;
1087
+ }
1088
+ if (this.thread.info.activeTurnId === null) {
1089
+ this.markSubagentStopped(toolUseId);
1090
+ return;
1091
+ }
1092
+ this.emit([
1093
+ this.thread.upsert({
1094
+ id: `stop-refused-${toolUseId}-${Date.now()}`,
1095
+ kind: 'note',
1096
+ createdAt: Date.now(),
1097
+ turnId: null,
1098
+ level: 'warning',
1099
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" could not be stopped: ${message}`
1100
+ })
1101
+ ]);
1102
+ this.options.persist();
1103
+ }
1104
+ /* The line under a subagent that `stop_task` ended, once the CLI reported it settled. */
1105
+ noteStopped(event) {
1106
+ const stopped = [...this.stopping].find(([agentId, toolUseId]) => agentId === event.taskId || toolUseId === event.ref);
1107
+ if (stopped === undefined) {
1108
+ return;
1109
+ }
1110
+ this.stopping.delete(stopped[0]);
1111
+ // It finished on its own just before the stop arrived; the row already says so.
1112
+ if (event.ok) {
1113
+ return;
1114
+ }
1115
+ const row = this.thread.find('subagent', (item) => item.toolUseId === stopped[1]);
1116
+ if (!row) {
1117
+ return;
1118
+ }
1119
+ const now = Date.now();
1120
+ this.emit([
1121
+ ...(row.status === 'running' ? [this.thread.upsert({ ...row, status: 'failed', finishedAt: now })] : []),
1122
+ this.thread.upsert({
1123
+ id: `stopped-${row.toolUseId}`,
1124
+ kind: 'note',
1125
+ createdAt: now,
1126
+ turnId: null,
1127
+ level: 'info',
1128
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" was stopped.`
1129
+ })
1130
+ ]);
1131
+ this.options.persist();
1132
+ }
1019
1133
  /*
1020
1134
  * Marks a subagent of the CLI's own as stopped: `failed` with a note that says what it means, since
1021
1135
  * no CLI stops one subagent on its own. Refused while a turn runs, which may still be waiting on it.
@@ -1121,7 +1235,8 @@ export class ChatSession {
1121
1235
  skills,
1122
1236
  chats,
1123
1237
  attachments,
1124
- ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1238
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
1239
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
1125
1240
  }));
1126
1241
  }
1127
1242
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
@@ -1155,14 +1270,14 @@ export class ChatSession {
1155
1270
  if (!checkpoints) {
1156
1271
  return Promise.resolve();
1157
1272
  }
1158
- return checkpoints
1159
- .take(this.thread.info.cwd)
1160
- .then((tree) => {
1273
+ const cwd = this.thread.info.cwd;
1274
+ return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1275
+ .then(([tree, head]) => {
1161
1276
  const turn = this.thread.get(turnId);
1162
1277
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1163
1278
  return;
1164
1279
  }
1165
- this.emit([this.thread.upsert({ ...turn, checkpoint: tree })]);
1280
+ this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1166
1281
  })
1167
1282
  .catch(() => undefined);
1168
1283
  }
@@ -1172,14 +1287,22 @@ export class ChatSession {
1172
1287
  if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
1173
1288
  return;
1174
1289
  }
1175
- void this.options.checkpoints
1176
- .settle(this.thread.info.cwd, turn.checkpoint)
1177
- .then((answer) => {
1290
+ const { checkpoints } = this.options;
1291
+ const cwd = this.thread.info.cwd;
1292
+ void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1293
+ .then(([answer, head]) => {
1178
1294
  const settled = this.thread.get(turnId);
1179
1295
  if (answer === null || settled?.kind !== 'turn') {
1180
1296
  return;
1181
1297
  }
1182
- this.emit([this.thread.upsert({ ...settled, checkpointDiff: answer.diff, checkpointAfter: answer.after })]);
1298
+ this.emit([
1299
+ this.thread.upsert({
1300
+ ...settled,
1301
+ checkpointDiff: answer.diff,
1302
+ checkpointAfter: answer.after,
1303
+ ...(head === null ? {} : { checkpointHeadAfter: head })
1304
+ })
1305
+ ]);
1183
1306
  this.options.persist();
1184
1307
  })
1185
1308
  .catch(() => undefined);
@@ -1409,6 +1532,10 @@ export class ChatSession {
1409
1532
  this.applyTitle(event.title);
1410
1533
  return;
1411
1534
  }
1535
+ if (event.type === 'stop.refused') {
1536
+ this.refuseStop(event.taskId, event.message);
1537
+ return;
1538
+ }
1412
1539
  /*
1413
1540
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1414
1541
  * send does, so its report may not put the old size back under a meter that already reads the new one.
@@ -1466,6 +1593,7 @@ export class ChatSession {
1466
1593
  this.awaitingBackgroundReport = true;
1467
1594
  }
1468
1595
  this.emit(projected);
1596
+ this.trackSubagentOwner(generation, event);
1469
1597
  this.watchWorkflows(event);
1470
1598
  if (isMainAgentOutput(event)) {
1471
1599
  this.spokenTurnId = this.thread.info.activeTurnId;
@@ -15,6 +15,7 @@ export declare class ClaudeProtocol {
15
15
  private readonly subagentModels;
16
16
  private apiError;
17
17
  private readonly refusedWindows;
18
+ private readonly stopRequests;
18
19
  private readonly steerIds;
19
20
  beginSteer(promptId: string): void;
20
21
  beginPrompt(promptId: string): void;
@@ -30,6 +31,7 @@ export declare class ClaudeProtocol {
30
31
  private endBackground;
31
32
  private endWorkflow;
32
33
  stopTaskRequest(taskId: string): unknown;
34
+ private handleControlResponse;
33
35
  private handleStreamEvent;
34
36
  private handleAssistant;
35
37
  private handleToolProgress;
@@ -117,6 +117,8 @@ export class ClaudeProtocol {
117
117
  // Only main-chain errors classify the turn; each plan window can refuse it independently.
118
118
  apiError = null;
119
119
  refusedWindows = new Map();
120
+ // The task each `stop_task` request named, so an error answer finds it again.
121
+ stopRequests = new Map();
120
122
  steerIds = new Set();
121
123
  beginSteer(promptId) {
122
124
  this.steerIds.add(promptId);
@@ -152,6 +154,9 @@ export class ClaudeProtocol {
152
154
  case 'control_request':
153
155
  this.handleControlRequest(frame, events);
154
156
  break;
157
+ case 'control_response':
158
+ this.handleControlResponse(frame, events);
159
+ break;
155
160
  case 'control_cancel_request': {
156
161
  const requestId = str(frame.request_id);
157
162
  if (requestId) {
@@ -408,7 +413,22 @@ export class ClaudeProtocol {
408
413
  }
409
414
  /* The control request that ends one task the CLI runs beside its turns. */
410
415
  stopTaskRequest(taskId) {
411
- return { type: 'control_request', request_id: `stop-task-${taskId}-${Date.now()}`, request: { subtype: 'stop_task', task_id: taskId } };
416
+ const requestId = `stop-task-${taskId}-${Date.now()}`;
417
+ this.stopRequests.set(requestId, taskId);
418
+ return { type: 'control_request', request_id: requestId, request: { subtype: 'stop_task', task_id: taskId } };
419
+ }
420
+ /* Only a refused `stop_task` matters; every other answer is the CLI saying it took the request. */
421
+ handleControlResponse(frame, events) {
422
+ const response = isRecord(frame.response) ? frame.response : null;
423
+ const requestId = str(response?.request_id);
424
+ const taskId = requestId === null ? undefined : this.stopRequests.get(requestId);
425
+ if (response === null || requestId === null || taskId === undefined) {
426
+ return;
427
+ }
428
+ this.stopRequests.delete(requestId);
429
+ if (response.subtype === 'error') {
430
+ events.push({ type: 'stop.refused', taskId, message: str(response.error) ?? 'The CLI refused to stop it' });
431
+ }
412
432
  }
413
433
  handleStreamEvent(frame, events) {
414
434
  const event = frame.event;
@@ -1,4 +1,5 @@
1
1
  import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
2
+ export declare function spokenText(content: unknown): string | null;
2
3
  export interface SubagentMeta {
3
4
  agentId: string;
4
5
  toolUseId: string | null;
@@ -12,7 +12,7 @@ function str(value) {
12
12
  return typeof value === 'string' && value !== '' ? value : null;
13
13
  }
14
14
  /* The words of a user line that somebody said, or null for a line that carries tool results. */
15
- function spokenText(content) {
15
+ export function spokenText(content) {
16
16
  if (typeof content === 'string') {
17
17
  return content;
18
18
  }
@@ -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
+ }