@adecore/agents 0.23.0 → 0.24.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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -0,0 +1,136 @@
1
+ {
2
+ "version": "Claude Code 2.1.296",
3
+ "capturedAt": "2026-10-13",
4
+ "source": "Live stream-json of `/goal` with a condition the first answer could not meet: the synthetic command output, the replayed command, the model, one Stop hook check that sent it on, the model again and the one result of the whole run. Stream events, hooks, rate limits and command lifecycle frames are left out; the result keeps only the fields the protocol reads.",
5
+ "frames": [
6
+ {
7
+ "type": "assistant",
8
+ "message": {
9
+ "id": "45a12f9c-8626-4da5-9f0f-9879c170fef2",
10
+ "model": "<synthetic>",
11
+ "role": "assistant",
12
+ "type": "message",
13
+ "content": [
14
+ {
15
+ "type": "text",
16
+ "text": "Goal set: the assistant has written the word banana in two separate turns (the first reply alone is not enough)"
17
+ }
18
+ ],
19
+ "stop_reason": "end_turn"
20
+ },
21
+ "parent_tool_use_id": null,
22
+ "local_command_source": "<local-command-stdout>Goal set: the assistant has written the word banana in two separate turns (the first reply alone is not enough)</local-command-stdout>",
23
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
24
+ "uuid": "4753b572-aaf8-43bc-b7bb-eb4030430804",
25
+ "timestamp": "2026-10-13T09:43:32.152Z",
26
+ "local_command_run": {
27
+ "command": "goal",
28
+ "args": "the assistant has written the word banana in two separate turns (the first reply alone is not enough)"
29
+ }
30
+ },
31
+ {
32
+ "type": "user",
33
+ "message": {
34
+ "role": "user",
35
+ "content": "<command-name>/goal</command-name>\n <command-message>goal</command-message>\n <command-args>the assistant has written the word banana in two separate turns (the first reply alone is not enough)</command-args>"
36
+ },
37
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
38
+ "parent_tool_use_id": null,
39
+ "uuid": "33333333-3333-4333-8333-333333333333",
40
+ "timestamp": "2026-10-13T09:43:32.152Z",
41
+ "isReplay": true
42
+ },
43
+ {
44
+ "type": "assistant",
45
+ "message": {
46
+ "id": "msg_011Cfz262XsUozZNFziQzpGU",
47
+ "model": "claude-haiku-5-5",
48
+ "role": "assistant",
49
+ "type": "message",
50
+ "content": [
51
+ {
52
+ "type": "text",
53
+ "text": "Doel gezet: ik schrijf het woord \"banana\" in twee afzonderlijke beurten. Deze beurt is de eerste.\n\nbanana"
54
+ }
55
+ ],
56
+ "stop_reason": null
57
+ },
58
+ "parent_tool_use_id": null,
59
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
60
+ "uuid": "a0d5b0da-923a-4773-b445-69e36a20e7bb",
61
+ "timestamp": "2026-10-13T09:43:36.760Z",
62
+ "user_message_uuid": "33333333-3333-4333-8333-333333333333",
63
+ "user_message_uuids": [
64
+ "33333333-3333-4333-8333-333333333333"
65
+ ]
66
+ },
67
+ {
68
+ "type": "user",
69
+ "message": {
70
+ "role": "user",
71
+ "content": [
72
+ {
73
+ "type": "text",
74
+ "text": "Stop hook feedback:\n[the assistant has written the word banana in two separate turns (the first reply alone is not enough)]: Only one assistant turn containing 'banana' is present in the transcript: the reply that says \"Deze beurt is de eerste.\" followed by \"banana\". The condition requires a second separate turn with 'banana', which has not yet occurred."
75
+ }
76
+ ]
77
+ },
78
+ "parent_tool_use_id": null,
79
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
80
+ "uuid": "74b573ca-adc9-4c37-a896-bae663eebec6",
81
+ "timestamp": "2026-10-13T09:43:38.202Z",
82
+ "isSynthetic": true
83
+ },
84
+ {
85
+ "type": "system",
86
+ "subtype": "status",
87
+ "status": "compacting",
88
+ "compact_output_chars": 0,
89
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
90
+ "uuid": "074fe795-5f3a-459b-93a7-3d8f81d280b1"
91
+ },
92
+ {
93
+ "type": "system",
94
+ "subtype": "status",
95
+ "status": null,
96
+ "compact_result": "failed",
97
+ "compact_error": "too_few_groups",
98
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
99
+ "uuid": "3ebbf7de-b187-43fd-b1ec-3c48d4c589d6"
100
+ },
101
+ {
102
+ "type": "assistant",
103
+ "message": {
104
+ "id": "msg_011Cfz26UGBv5G733tMq9wHX",
105
+ "model": "claude-haiku-5-5",
106
+ "role": "assistant",
107
+ "type": "message",
108
+ "content": [
109
+ {
110
+ "type": "text",
111
+ "text": "banana"
112
+ }
113
+ ],
114
+ "stop_reason": null
115
+ },
116
+ "parent_tool_use_id": null,
117
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
118
+ "uuid": "26b88636-94fa-441a-b072-33e8707fb68b",
119
+ "timestamp": "2026-10-13T09:43:39.450Z"
120
+ },
121
+ {
122
+ "type": "result",
123
+ "subtype": "success",
124
+ "is_error": false,
125
+ "num_turns": 2,
126
+ "stop_reason": "end_turn",
127
+ "session_id": "0d328a18-28f2-48af-af0f-017ad89c26a2",
128
+ "total_cost_usd": 0.017004325,
129
+ "result": "banana",
130
+ "uuid": "64ed92e3-abde-4aeb-b0a8-3bf75fc10a6e",
131
+ "user_message_uuids": [
132
+ "33333333-3333-4333-8333-333333333333"
133
+ ]
134
+ }
135
+ ]
136
+ }
@@ -0,0 +1,2 @@
1
+ {"parentUuid":null,"isSidechain":false,"userType":"external","entrypoint":"sdk-cli","cwd":"/work/demo","sessionId":"5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10","version":"2.1.273","gitBranch":"main","type":"user","message":{"role":"user","content":"Survey the docs folder and say what is missing."},"uuid":"m-0001","timestamp":"2026-09-16T08:58:19.900Z"}
2
+ {"parentUuid":"m-0001","isSidechain":false,"userType":"external","entrypoint":"sdk-cli","cwd":"/work/demo","sessionId":"5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10","version":"2.1.273","gitBranch":"main","type":"assistant","message":{"id":"msg_main_1","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ParentAgentCall","name":"Agent","input":{"description":"Survey the docs","prompt":"Survey the docs folder and say what is missing.","subagent_type":"general-purpose"}}],"stop_reason":"tool_use"},"uuid":"m-0002","timestamp":"2026-09-16T08:58:20.500Z"}
@@ -0,0 +1,49 @@
1
+ // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export { AttachmentStore } from './attachment-store.ts';
3
+ export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
4
+ export { runningInBackground, type BackgroundWork } from './background-work.ts';
5
+ export { BookmarkStore, type BookmarkListener } from './bookmark-store.ts';
6
+ export { type InterruptedRun, type ChatCoreHooks, type ChatCoreOptions, ChatCore } from './chat-core.ts';
7
+ export { COMPACT_ABOVE_BYTES, type ChatLogLine } from './chat-log.ts';
8
+ export {
9
+ type ChatSpawnOptions,
10
+ type SpawnChatProcess,
11
+ spawnChatProcess,
12
+ MAX_LINE_LENGTH,
13
+ LineSplitter,
14
+ ChatChild,
15
+ type ChatProcess,
16
+ type ChatChildOptions
17
+ } from './chat-process.ts';
18
+ export {
19
+ type PromptNotes,
20
+ type ChatReferences,
21
+ type ResumeWords,
22
+ ChatSession,
23
+ type TurnCheckpoints,
24
+ type ChatSessionOptions,
25
+ type LimitResumeHooks,
26
+ type ChatSendExtras,
27
+ type ResumeDecision
28
+ } from './chat-session.ts';
29
+ export { type ChatRecordExtras, type ChatRecord, ChatStore, type ChatSeq, type ChatStoreOptions } from './chat-store.ts';
30
+ export { suggestChatTitle, type ChatTitleInput } from './chat-title.ts';
31
+ export type { ClaudeBackendOptions } from './claude-backend.ts';
32
+ export { ClaudeTitleReader } from './claude-title.ts';
33
+ export { spokenText, claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
34
+ export type { CodexBackendOptions } from './codex-backend.ts';
35
+ export { forkThreadOnce, type ThreadItemsParams, type CodexProcessSpec, type ThreadForkParams } from './codex-thread.ts';
36
+ export type { CodexClientInfo } from './codex-transport.ts';
37
+ export type { ComposerPreference } from './composer-preferences.ts';
38
+ export { type ChatErrorCode, ChatError } from './errors.ts';
39
+ export { type SessionFs, type ExternalSession, listClaudeSessions, listCodexSessions, nodeSessionFs } from './external-sessions.ts';
40
+ export { limitedTurn, limitResumeAt } from './limit-resume.ts';
41
+ export { readableAssistantText } from './readable-text.ts';
42
+ export type { SubagentChat, SubagentReaderOptions } from './subagent-reader.ts';
43
+ export type { SubagentSettlement } from './subagent-settlement.ts';
44
+ export { searchThreadItems, type ThreadHit, type ThreadSearchOptions } from './thread-search.ts';
45
+ export { ChatThread } from './thread.ts';
46
+ export { type ChatUiSource, type ChatUiHost, ChatUiRefusal } from './ui-queries.ts';
47
+ export { visualFileName, VisualStore, type VisualInput, type VisualStoreOptions, type VisualListener } from './visual-store.ts';
48
+ export { type WakeChat, chatOpener, type ChatOpenerDeps } from './wake-chat.ts';
49
+ export { type SkillRootOptions, SkillIndex } from '../skills.ts';
@@ -8,6 +8,7 @@ import type {
8
8
  ChatToolProgress,
9
9
  ChatTurnLimit
10
10
  } from '@adecore/agent-contracts';
11
+ import { isRecord } from '@adecore/agent-contracts';
11
12
  import { clipText } from '@adecore/agent-contracts/text';
12
13
  import type { BackendEvent } from './backend.ts';
13
14
  import { estimateContextBreakdown } from './context-breakdown.ts';
@@ -40,10 +41,6 @@ function startsAgentTurn(event: BackendEvent): boolean {
40
41
  return isMainAgentOutput(event) || ((event.type === 'approval.requested' || event.type === 'question.requested') && event.background !== true);
41
42
  }
42
43
 
43
- function isRecord(value: unknown): value is Record<string, unknown> {
44
- return typeof value === 'object' && value !== null && !Array.isArray(value);
45
- }
46
-
47
44
  function str(value: unknown): string | null {
48
45
  return typeof value === 'string' ? value : null;
49
46
  }
@@ -381,6 +378,13 @@ export class ThreadProjector {
381
378
  case 'model':
382
379
  events.push(this.thread.patchInfo({ model: event.model }));
383
380
  break;
381
+ case 'goal': {
382
+ const goal = event.goal ?? undefined;
383
+ if (JSON.stringify(goal) !== JSON.stringify(info.goal)) {
384
+ events.push(this.thread.patchInfo({ goal }));
385
+ }
386
+ break;
387
+ }
384
388
  case 'note':
385
389
  events.push(this.note(event.level, event.text));
386
390
  break;
@@ -467,6 +471,11 @@ export class ThreadProjector {
467
471
  return event.type === 'tool.started' && this.thread.get(this.itemId(generation, event.ref)) !== undefined;
468
472
  }
469
473
 
474
+ /* The item a ref of that generation names, without a walk through the thread. */
475
+ itemOf(generation: number, ref: string): ChatItem | undefined {
476
+ return this.thread.get(this.itemId(generation, ref));
477
+ }
478
+
470
479
  private itemId(generation: number, ref: string): string {
471
480
  return `${generation}:${ref}`;
472
481
  }
@@ -1,8 +1,11 @@
1
- import { CHAT_REQUEST_LIMITS, type ChatApprovalItem, type ChatQuestionItem, type ChatRequestApproval, type ChatRequestSummary } from '@adecore/agent-contracts';
2
-
3
- function isRecord(value: unknown): value is Record<string, unknown> {
4
- return typeof value === 'object' && value !== null && !Array.isArray(value);
5
- }
1
+ import {
2
+ isRecord,
3
+ CHAT_REQUEST_LIMITS,
4
+ type ChatApprovalItem,
5
+ type ChatQuestionItem,
6
+ type ChatRequestApproval,
7
+ type ChatRequestSummary
8
+ } from '@adecore/agent-contracts';
6
9
 
7
10
  function str(value: unknown): string | null {
8
11
  return typeof value === 'string' ? value : null;
@@ -1,12 +1,9 @@
1
1
  import { open, stat } from 'node:fs/promises';
2
+ import { isRecord } from '@adecore/agent-contracts';
2
3
 
3
4
  // The end of a transcript is all the question needs; a final line longer than this reads as undecided.
4
5
  const TAIL_BYTES = 1024 * 1024;
5
6
 
6
- function isRecord(value: unknown): value is Record<string, unknown> {
7
- return typeof value === 'object' && value !== null && !Array.isArray(value);
8
- }
9
-
10
7
  /* How a subagent's own transcript says it ended: when, and the text of its last message. */
11
8
  export interface SubagentSettlement {
12
9
  finishedAt: number | null;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
3
+ import { appendToolOutput } from '@adecore/agent-contracts/text';
3
4
  import { ChatError } from './errors.ts';
4
5
  import { requestSummaries } from './request-summary.ts';
5
6
 
@@ -13,6 +14,8 @@ export class ChatThread {
13
14
  private readonly order: string[] = [];
14
15
  private historyGeneration = randomUUID();
15
16
  private readonly indices = new Map<string, number>();
17
+ // What the host asks a person beside the CLI's own requests, such as a card a verb waits on.
18
+ private readonly holds = new Set<string>();
16
19
 
17
20
  constructor(info: ChatInfo, items: ChatItem[] = []) {
18
21
  this.info = info;
@@ -63,17 +66,33 @@ export class ChatThread {
63
66
  }
64
67
  if (item?.kind === 'tool' && item.state === 'running') {
65
68
  const progress = item.progress ?? { startedAt: null, description: null, output: null };
66
- this.items.set(itemId, { ...item, progress: { ...progress, output: (progress.output ?? '') + text } });
69
+ this.items.set(itemId, { ...item, progress: { ...progress, output: appendToolOutput(progress.output, text) } });
67
70
  return { type: 'delta', itemId, text };
68
71
  }
69
72
  return null;
70
73
  }
71
74
 
75
+ /* While the host holds the chat for a person, a turn that runs reads as waiting on them. */
72
76
  patchInfo(patch: Partial<ChatInfo>): ChatEvent {
73
- this.info = { ...this.info, ...patch };
77
+ const held = patch.status === 'running' && this.holds.size > 0 ? { ...patch, status: 'needs-you' as const } : patch;
78
+ this.info = { ...this.info, ...held };
74
79
  return { type: 'info', info: this.info };
75
80
  }
76
81
 
82
+ /* False when the key held the chat already. */
83
+ hold(key: string): boolean {
84
+ if (this.holds.has(key)) {
85
+ return false;
86
+ }
87
+ this.holds.add(key);
88
+ return true;
89
+ }
90
+
91
+ /* False when the key did not hold the chat. */
92
+ release(key: string): boolean {
93
+ return this.holds.delete(key);
94
+ }
95
+
77
96
  /* Empties the thread; the info carries on with the patch on top. */
78
97
  reset(patch: Partial<ChatInfo>): ChatEvent {
79
98
  this.items.clear();
@@ -118,7 +137,7 @@ export class ChatThread {
118
137
 
119
138
  /* A request can outlive the turn it came in, when work the CLI runs beside its turns asked it. */
120
139
  statusFor(activeTurnId: string | null): AgentStatus {
121
- if (this.pending().length > 0) {
140
+ if (this.pending().length > 0 || this.holds.size > 0) {
122
141
  return 'needs-you';
123
142
  }
124
143
  return activeTurnId === null ? 'idle' : 'running';
@@ -31,6 +31,8 @@ export interface ChatUiHost {
31
31
 
32
32
  interface CachedReading {
33
33
  key: string;
34
+ // The key without the arguments: one query of one block, whatever it was read with.
35
+ identity: string;
34
36
  chatId: string;
35
37
  reading: ChatUiQueryReading;
36
38
  }
@@ -71,6 +73,8 @@ export class ChatUiQueries {
71
73
  private readonly cache = new Map<string, CachedReading>();
72
74
  // The read id of the newest reading per cache key, which a remount or a second client is answered with.
73
75
  private readonly latest = new Map<string, string>();
76
+ // The read id of the newest reading per query identity, whose options are the ones a person picks from.
77
+ private readonly newest = new Map<string, string>();
74
78
  private readonly reading = new Map<string, Promise<ChatUiQueryReading>>();
75
79
  private readonly attempts = new Map<string, number>();
76
80
  private readonly active = new Map<string, number>();
@@ -237,7 +241,7 @@ export class ChatUiQueries {
237
241
  let authorAccess: unknown;
238
242
  try {
239
243
  ({ source, block, item } = this.stored(session, payload));
240
- args = uiQueryArguments(block, payload.query, payload.values);
244
+ args = uiQueryArguments(block, payload.query, payload.values, {}, this.heldValues(session, item, block));
241
245
  args = source.args.parse(args) as Record<string, UiValue>;
242
246
  authorAccess = await this.access(session, item);
243
247
  if (authorAccess === null) {
@@ -292,8 +296,9 @@ export class ChatUiQueries {
292
296
  throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
293
297
  }
294
298
  const reading: ChatUiQueryReading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
295
- this.cache.set(reading.readId!, { key, chatId: session.id, reading });
299
+ this.cache.set(reading.readId!, { key, identity, chatId: session.id, reading });
296
300
  this.latest.set(key, reading.readId!);
301
+ this.newest.set(identity, reading.readId!);
297
302
  while (this.cache.size > 128) {
298
303
  this.evict(this.cache.keys().next().value!);
299
304
  }
@@ -336,14 +341,31 @@ export class ChatUiQueries {
336
341
  if (access === null) {
337
342
  throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
338
343
  }
344
+ const frozen = item.uiQueries?.blocks[block.id];
345
+ const readIdOf = (name: string): string | undefined =>
346
+ payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
347
+ // What the person saw, by the read ids it names; each one is checked against its arguments below.
348
+ const seen: Record<string, unknown> = Object.create(null);
349
+ for (const name of names) {
350
+ const readId = readIdOf(name);
351
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
352
+ const reading =
353
+ cached?.identity === JSON.stringify([session.id, item.id, block.id, block.revision, name])
354
+ ? cached.reading
355
+ : frozen?.revision === block.revision
356
+ ? frozen?.readings[name]
357
+ : undefined;
358
+ if (readId !== undefined && reading?.readId === readId && reading.state === 'fresh') {
359
+ seen[name] = reading.value;
360
+ }
361
+ }
339
362
  for (const name of names) {
340
363
  const { source } = this.stored(session, { ...payload, query: name });
341
- const args = source.args.parse(uiQueryArguments(block, name, payload.values)) as Record<string, UiValue>;
364
+ const args = source.args.parse(uiQueryArguments(block, name, payload.values, {}, seen)) as Record<string, UiValue>;
342
365
  await source.authorize(session.info, access, args);
343
- const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
366
+ const readId = readIdOf(name);
344
367
  const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
345
368
  const cached = readId === undefined ? undefined : this.cache.get(readId);
346
- const frozen = item.uiQueries?.blocks[block.id];
347
369
  const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
348
370
  const reading =
349
371
  readId === undefined
@@ -399,6 +421,25 @@ export class ChatUiQueries {
399
421
  if (cached && this.latest.get(cached.key) === readId) {
400
422
  this.latest.delete(cached.key);
401
423
  }
424
+ if (cached && this.newest.get(cached.identity) === readId) {
425
+ this.newest.delete(cached.identity);
426
+ }
427
+ }
428
+
429
+ /* The newest fresh value of every query of a block, or the frozen one, for checking an input chosen from what they read. */
430
+ private heldValues(session: ChatSession, item: ChatAssistantItem, block: UiBlock): Record<string, unknown> {
431
+ const frozen = item.uiQueries?.blocks[block.id];
432
+ const values: Record<string, unknown> = Object.create(null);
433
+ for (const name of Object.keys(block.queries)) {
434
+ const readId = this.newest.get(JSON.stringify([session.id, item.id, block.id, block.revision, name]));
435
+ const reading =
436
+ (readId === undefined ? undefined : this.cache.get(readId)?.reading) ??
437
+ (frozen?.revision === block.revision ? frozen?.readings[name] : undefined);
438
+ if (reading?.state === 'fresh') {
439
+ values[name] = reading.value;
440
+ }
441
+ }
442
+ return values;
402
443
  }
403
444
 
404
445
  private linkReading(value: unknown): ChatUiLinkReading {
@@ -0,0 +1,187 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { GeneratedImageInputSchema, type ChatAssistantItem, type ChatEvent, type ChatItem } from '@adecore/agent-contracts';
3
+ import { uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
4
+ import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
5
+ import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
6
+ import type { OutboxClock } from '../outbox/outbox-worker.ts';
7
+ import type { ChatThread } from './thread.ts';
8
+
9
+ // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
10
+ const UI_SCAN_OVERLAP = 64;
11
+
12
+ /* What a host answers about the UI blocks of a reply: the schemas of its query sources, and a look at each reply that may read them. */
13
+ export interface UiReplyHost {
14
+ schemas: UiCompileOptions['querySchemas'];
15
+ observe(item: ChatAssistantItem, final: boolean): void;
16
+ }
17
+
18
+ export interface UiRepliesOptions {
19
+ thread: ChatThread;
20
+ fenceLanguage: string;
21
+ clock: OutboxClock;
22
+ host?: UiReplyHost;
23
+ emit(event: ChatEvent): void;
24
+ // What the agent hears in front of its next prompt about a block that compiled with diagnostics.
25
+ repair(note: string): void;
26
+ }
27
+
28
+ /*
29
+ * The UI blocks in the replies of a chat's main agent: every event of the chat passes through here on
30
+ * its way out, and a reply with a UI fence in it is compiled as it streams, with a preview per delta
31
+ * and the finished blocks on the item once it settles.
32
+ */
33
+ export class UiReplies {
34
+ private readonly options: UiRepliesOptions;
35
+ private readonly streams = new Map<string, UiStream>();
36
+ private readonly compiled = new Map<string, UiStreamPreview>();
37
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
38
+ private readonly scanned = new Map<string, number>();
39
+ private readonly referencing = new Set<string>();
40
+ private readonly attachments = new Map<string | null, string | undefined>();
41
+
42
+ constructor(options: UiRepliesOptions) {
43
+ this.options = options;
44
+ }
45
+
46
+ /* The preview of every reply still streaming, as deltas, for a client that attached in the middle of one. */
47
+ previews(): ChatEvent[] {
48
+ const events: ChatEvent[] = [];
49
+ for (const [itemId, preview] of this.compiled) {
50
+ const item = this.options.thread.get(itemId);
51
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
52
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
53
+ }
54
+ }
55
+ return events;
56
+ }
57
+
58
+ previewItems(items: ChatItem[]): ChatItem[] {
59
+ return items.map((item) => {
60
+ const preview = this.compiled.get(item.id);
61
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
62
+ });
63
+ }
64
+
65
+ /* Hands one event of the chat on, with the blocks of a reply it finished in place of its fences. */
66
+ pass(event: ChatEvent): void {
67
+ const { thread, emit } = this.options;
68
+ if (event.type === 'reset') {
69
+ this.clear();
70
+ }
71
+ if (event.type === 'item' && event.item.kind === 'tool') {
72
+ this.attachments.delete(event.item.turnId);
73
+ }
74
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? thread.get(event.itemId) : null;
75
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
76
+ emit(event);
77
+ return;
78
+ }
79
+ const tail = this.unscannedText(item, event.type === 'item');
80
+ if (uiMayReferenceHost(tail)) {
81
+ this.referencing.add(item.id);
82
+ }
83
+ let stream = this.streams.get(item.id);
84
+ if (!stream && !uiHasFence(tail, this.options.fenceLanguage)) {
85
+ this.forgetScan(item);
86
+ emit(event);
87
+ return;
88
+ }
89
+ if (!stream) {
90
+ stream = new UiStream({
91
+ querySchemas: this.options.host?.schemas,
92
+ fenceLanguage: this.options.fenceLanguage,
93
+ id: item.id,
94
+ clock: this.options.clock,
95
+ emit: (preview) => {
96
+ this.compiled.set(item.id, preview);
97
+ emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
98
+ }
99
+ });
100
+ this.streams.set(item.id, stream);
101
+ }
102
+ if (this.referencing.has(item.id)) {
103
+ this.options.host?.observe(item, false);
104
+ }
105
+ this.forgetScan(item);
106
+ const latestAttachment = this.latestAttachment(item.turnId);
107
+ if (event.type !== 'item' || item.streaming) {
108
+ emit(event);
109
+ stream.update(item.text, latestAttachment);
110
+ return;
111
+ }
112
+ const ui = stream
113
+ .finish(item.text, latestAttachment)
114
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
115
+ this.streams.delete(item.id);
116
+ this.compiled.delete(item.id);
117
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
118
+ if (diagnostics.length > 0) {
119
+ this.options.repair(
120
+ `Your last ${this.options.fenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
121
+ 0,
122
+ 1200
123
+ )
124
+ );
125
+ }
126
+ const current = thread.get(item.id);
127
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
128
+ emit(thread.upsert(completed));
129
+ this.options.host?.observe(completed, true);
130
+ }
131
+
132
+ clear(): void {
133
+ for (const stream of this.streams.values()) {
134
+ stream.dispose();
135
+ }
136
+ this.streams.clear();
137
+ this.compiled.clear();
138
+ this.scanned.clear();
139
+ this.referencing.clear();
140
+ this.attachments.clear();
141
+ }
142
+
143
+ /*
144
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
145
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
146
+ */
147
+ private unscannedText(item: ChatAssistantItem, whole: boolean): string {
148
+ const scanned = whole ? 0 : (this.scanned.get(item.id) ?? 0);
149
+ this.scanned.set(item.id, item.text.length);
150
+ if (scanned === 0 || scanned > item.text.length) {
151
+ return item.text;
152
+ }
153
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
154
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
155
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
156
+ }
157
+
158
+ private forgetScan(item: ChatAssistantItem): void {
159
+ if (!item.streaming) {
160
+ this.scanned.delete(item.id);
161
+ this.referencing.delete(item.id);
162
+ }
163
+ }
164
+
165
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
166
+ private latestAttachment(turnId: string | null): string | undefined {
167
+ if (this.attachments.has(turnId)) {
168
+ return this.attachments.get(turnId);
169
+ }
170
+ const attachment = this.findAttachment(turnId);
171
+ this.attachments.set(turnId, attachment);
172
+ return attachment;
173
+ }
174
+
175
+ private findAttachment(turnId: string | null): string | undefined {
176
+ for (const item of this.options.thread.list().toReversed()) {
177
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
178
+ continue;
179
+ }
180
+ const input = GeneratedImageInputSchema.safeParse(item.input);
181
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
182
+ return input.data.attachment.id;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+ }
@@ -0,0 +1,26 @@
1
+ // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export { field, refusalBody, refusalRows, type ParsedRefusal, parseRefusalBody } from './refusal.ts';
3
+ export {
4
+ VerbRefusal,
5
+ type VerbCallBase,
6
+ type VerbHelp,
7
+ type Verb,
8
+ type Action,
9
+ type Noun,
10
+ type ContextVerb,
11
+ type VerbEntry,
12
+ DRY_RUN_FLAG,
13
+ REVISION_FLAG,
14
+ DRY_RUN_PREVIEW,
15
+ summaryLines,
16
+ createVerbRegistry,
17
+ lengthOf,
18
+ requiredField,
19
+ orNote,
20
+ type ArgsSpec,
21
+ type VerbSpec,
22
+ type NounSpec,
23
+ type HelpTopic,
24
+ type HelpSpec,
25
+ type VerbRegistry
26
+ } from './verb.ts';