@adecore/agents 0.0.1 → 0.17.0-beta.2
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.
- package/LICENSE +110 -0
- package/README.md +48 -0
- package/dist/async.d.ts +2 -0
- package/dist/async.js +31 -0
- package/dist/chat/attachment-store.d.ts +10 -0
- package/dist/chat/attachment-store.js +93 -0
- package/dist/chat/backend.d.ts +214 -0
- package/dist/chat/backend.js +1 -0
- package/dist/chat/background-work.d.ts +8 -0
- package/dist/chat/background-work.js +33 -0
- package/dist/chat/bookmark-store.d.ts +25 -0
- package/dist/chat/bookmark-store.js +141 -0
- package/dist/chat/chat-core.d.ts +311 -0
- package/dist/chat/chat-core.js +1025 -0
- package/dist/chat/chat-log.d.ts +33 -0
- package/dist/chat/chat-log.js +163 -0
- package/dist/chat/chat-process.d.ts +46 -0
- package/dist/chat/chat-process.js +190 -0
- package/dist/chat/chat-session.d.ts +209 -0
- package/dist/chat/chat-session.js +1511 -0
- package/dist/chat/chat-store.d.ts +37 -0
- package/dist/chat/chat-store.js +202 -0
- package/dist/chat/chat-title.d.ts +10 -0
- package/dist/chat/chat-title.js +74 -0
- package/dist/chat/claude-backend.d.ts +35 -0
- package/dist/chat/claude-backend.js +214 -0
- package/dist/chat/claude-protocol.d.ts +41 -0
- package/dist/chat/claude-protocol.js +642 -0
- package/dist/chat/claude-title.d.ts +11 -0
- package/dist/chat/claude-title.js +97 -0
- package/dist/chat/claude-transcript.d.ts +28 -0
- package/dist/chat/claude-transcript.js +211 -0
- package/dist/chat/codex-backend.d.ts +51 -0
- package/dist/chat/codex-backend.js +364 -0
- package/dist/chat/codex-protocol.d.ts +56 -0
- package/dist/chat/codex-protocol.js +767 -0
- package/dist/chat/codex-thread.d.ts +32 -0
- package/dist/chat/codex-thread.js +131 -0
- package/dist/chat/codex-transport.d.ts +42 -0
- package/dist/chat/codex-transport.js +188 -0
- package/dist/chat/composer-preferences.d.ts +15 -0
- package/dist/chat/composer-preferences.js +46 -0
- package/dist/chat/context-breakdown.d.ts +2 -0
- package/dist/chat/context-breakdown.js +74 -0
- package/dist/chat/delta-coalescer.d.ts +12 -0
- package/dist/chat/delta-coalescer.js +55 -0
- package/dist/chat/errors.d.ts +4 -0
- package/dist/chat/errors.js +4 -0
- package/dist/chat/fake-claude.d.ts +3 -0
- package/dist/chat/fake-claude.js +982 -0
- package/dist/chat/fake-cli.d.ts +25 -0
- package/dist/chat/fake-cli.js +157 -0
- package/dist/chat/fake-codex.d.ts +10 -0
- package/dist/chat/fake-codex.js +502 -0
- package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/dist/chat/input.d.ts +19 -0
- package/dist/chat/input.js +71 -0
- package/dist/chat/limit-resume.d.ts +11 -0
- package/dist/chat/limit-resume.js +68 -0
- package/dist/chat/projector.d.ts +60 -0
- package/dist/chat/projector.js +860 -0
- package/dist/chat/request-summary.d.ts +3 -0
- package/dist/chat/request-summary.js +143 -0
- package/dist/chat/subagent-projection.d.ts +8 -0
- package/dist/chat/subagent-projection.js +40 -0
- package/dist/chat/subagent-reader.d.ts +66 -0
- package/dist/chat/subagent-reader.js +396 -0
- package/dist/chat/subagent-settlement.d.ts +6 -0
- package/dist/chat/subagent-settlement.js +103 -0
- package/dist/chat/thread.d.ts +30 -0
- package/dist/chat/thread.js +147 -0
- package/dist/chat/wake-chat.d.ts +20 -0
- package/dist/chat/wake-chat.js +27 -0
- package/dist/client-sinks.d.ts +9 -0
- package/dist/client-sinks.js +34 -0
- package/dist/coded-error.d.ts +5 -0
- package/dist/coded-error.js +17 -0
- package/dist/context/argv.d.ts +11 -0
- package/dist/context/argv.js +58 -0
- package/dist/context/refusal.d.ts +9 -0
- package/dist/context/refusal.js +38 -0
- package/dist/context/verb.d.ts +85 -0
- package/dist/context/verb.js +233 -0
- package/dist/error-text.d.ts +5 -0
- package/dist/error-text.js +24 -0
- package/dist/events.d.ts +12 -0
- package/dist/events.js +1 -0
- package/dist/fs.d.ts +9 -0
- package/dist/fs.js +107 -0
- package/dist/host/agent-host.d.ts +28 -0
- package/dist/host/agent-host.js +105 -0
- package/dist/host/environment.d.ts +7 -0
- package/dist/host/environment.js +22 -0
- package/dist/host/handlers.d.ts +30 -0
- package/dist/host/handlers.js +111 -0
- package/dist/host/memory-port.d.ts +2 -0
- package/dist/host/memory-port.js +25 -0
- package/dist/host/wiring.d.ts +40 -0
- package/dist/host/wiring.js +120 -0
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.js +134 -0
- package/dist/messages/deliver-message.d.ts +21 -0
- package/dist/messages/deliver-message.js +42 -0
- package/dist/messages/deliver-notice.d.ts +25 -0
- package/dist/messages/deliver-notice.js +75 -0
- package/dist/messages/notice-notes.d.ts +15 -0
- package/dist/messages/notice-notes.js +26 -0
- package/dist/messages/notice-store.d.ts +30 -0
- package/dist/messages/notice-store.js +120 -0
- package/dist/modes.d.ts +11 -0
- package/dist/modes.js +28 -0
- package/dist/outbox/manual-clock.d.ts +10 -0
- package/dist/outbox/manual-clock.js +25 -0
- package/dist/outbox/outbox-worker.d.ts +44 -0
- package/dist/outbox/outbox-worker.js +173 -0
- package/dist/outbox/outbox.d.ts +37 -0
- package/dist/outbox/outbox.js +76 -0
- package/dist/providers/accounts/accounts.d.ts +20 -0
- package/dist/providers/accounts/accounts.js +158 -0
- package/dist/providers/accounts/launch.d.ts +17 -0
- package/dist/providers/accounts/launch.js +30 -0
- package/dist/providers/accounts/service.d.ts +87 -0
- package/dist/providers/accounts/service.js +538 -0
- package/dist/providers/accounts/shadow-home.d.ts +8 -0
- package/dist/providers/accounts/shadow-home.js +70 -0
- package/dist/providers/accounts/status.d.ts +12 -0
- package/dist/providers/accounts/status.js +85 -0
- package/dist/providers/accounts/store.d.ts +14 -0
- package/dist/providers/accounts/store.js +112 -0
- package/dist/providers/accounts/test-accounts.d.ts +12 -0
- package/dist/providers/accounts/test-accounts.js +38 -0
- package/dist/providers/accounts/variables.d.ts +27 -0
- package/dist/providers/accounts/variables.js +94 -0
- package/dist/providers/catalog.d.ts +17 -0
- package/dist/providers/catalog.js +76 -0
- package/dist/providers/claude-models.json +114 -0
- package/dist/providers/claude-provider.d.ts +4 -0
- package/dist/providers/claude-provider.js +29 -0
- package/dist/providers/claude.d.ts +15 -0
- package/dist/providers/claude.js +105 -0
- package/dist/providers/codex-models.json +135 -0
- package/dist/providers/codex-provider.d.ts +4 -0
- package/dist/providers/codex-provider.js +27 -0
- package/dist/providers/codex-rules.d.ts +9 -0
- package/dist/providers/codex-rules.js +35 -0
- package/dist/providers/codex.d.ts +14 -0
- package/dist/providers/codex.js +52 -0
- package/dist/providers/detect.d.ts +6 -0
- package/dist/providers/detect.js +30 -0
- package/dist/providers/provider.d.ts +25 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/registry.d.ts +28 -0
- package/dist/providers/registry.js +118 -0
- package/dist/record-directory.d.ts +27 -0
- package/dist/record-directory.js +114 -0
- package/dist/run-process.d.ts +13 -0
- package/dist/run-process.js +42 -0
- package/dist/serializer.d.ts +9 -0
- package/dist/serializer.js +34 -0
- package/dist/skills.d.ts +21 -0
- package/dist/skills.js +174 -0
- package/dist/tasks/background-limit.d.ts +16 -0
- package/dist/tasks/background-limit.js +55 -0
- package/dist/tasks/end-children.d.ts +34 -0
- package/dist/tasks/end-children.js +53 -0
- package/dist/tasks/give-task.d.ts +14 -0
- package/dist/tasks/give-task.js +33 -0
- package/dist/tasks/task-coordinator.d.ts +52 -0
- package/dist/tasks/task-coordinator.js +323 -0
- package/dist/tasks/task-store.d.ts +49 -0
- package/dist/tasks/task-store.js +197 -0
- package/dist/tasks/task-work.d.ts +47 -0
- package/dist/tasks/task-work.js +28 -0
- package/dist/tasks/waiting-child.d.ts +51 -0
- package/dist/tasks/waiting-child.js +143 -0
- package/dist/tasks/wake-parent.d.ts +19 -0
- package/dist/tasks/wake-parent.js +109 -0
- package/dist/tasks/wiring.d.ts +51 -0
- package/dist/tasks/wiring.js +151 -0
- package/dist/title-file.d.ts +2 -0
- package/dist/title-file.js +55 -0
- package/dist/usage/accounts.d.ts +15 -0
- package/dist/usage/accounts.js +64 -0
- package/dist/usage/aggregate.d.ts +12 -0
- package/dist/usage/aggregate.js +154 -0
- package/dist/usage/exchange.d.ts +16 -0
- package/dist/usage/exchange.js +84 -0
- package/dist/usage/index-file.d.ts +15 -0
- package/dist/usage/index-file.js +123 -0
- package/dist/usage/limits/monitor.d.ts +50 -0
- package/dist/usage/limits/monitor.js +179 -0
- package/dist/usage/limits/normalize.d.ts +21 -0
- package/dist/usage/limits/normalize.js +160 -0
- package/dist/usage/limits/probe.d.ts +9 -0
- package/dist/usage/limits/probe.js +113 -0
- package/dist/usage/prices-snapshot.json +1071 -0
- package/dist/usage/pricing.d.ts +34 -0
- package/dist/usage/pricing.js +186 -0
- package/dist/usage/projects.d.ts +17 -0
- package/dist/usage/projects.js +49 -0
- package/dist/usage/readers/claude.d.ts +3 -0
- package/dist/usage/readers/claude.js +59 -0
- package/dist/usage/readers/codex.d.ts +23 -0
- package/dist/usage/readers/codex.js +155 -0
- package/dist/usage/record.d.ts +15 -0
- package/dist/usage/record.js +39 -0
- package/dist/usage/roots.d.ts +12 -0
- package/dist/usage/roots.js +29 -0
- package/dist/usage/scanner.d.ts +30 -0
- package/dist/usage/scanner.js +234 -0
- package/dist/usage/usage-service.d.ts +35 -0
- package/dist/usage/usage-service.js +130 -0
- package/dist/watch-seam.d.ts +29 -0
- package/dist/watch-seam.js +78 -0
- package/dist/watch-test-helpers.d.ts +28 -0
- package/dist/watch-test-helpers.js +86 -0
- package/package.json +42 -3
- package/src/async.ts +40 -0
- package/src/chat/attachment-store.ts +108 -0
- package/src/chat/backend.ts +199 -0
- package/src/chat/background-work.ts +42 -0
- package/src/chat/bookmark-store.ts +166 -0
- package/src/chat/chat-core.ts +1228 -0
- package/src/chat/chat-log.ts +187 -0
- package/src/chat/chat-process.ts +237 -0
- package/src/chat/chat-session.ts +1723 -0
- package/src/chat/chat-store.ts +261 -0
- package/src/chat/chat-title.ts +90 -0
- package/src/chat/claude-backend.ts +235 -0
- package/src/chat/claude-protocol.ts +684 -0
- package/src/chat/claude-title.ts +106 -0
- package/src/chat/claude-transcript.ts +233 -0
- package/src/chat/codex-backend.ts +397 -0
- package/src/chat/codex-protocol.ts +872 -0
- package/src/chat/codex-thread.ts +162 -0
- package/src/chat/codex-transport.ts +224 -0
- package/src/chat/composer-preferences.ts +67 -0
- package/src/chat/context-breakdown.ts +80 -0
- package/src/chat/delta-coalescer.ts +62 -0
- package/src/chat/errors.ts +25 -0
- package/src/chat/fake-claude.ts +1006 -0
- package/src/chat/fake-cli.ts +199 -0
- package/src/chat/fake-codex.ts +534 -0
- package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/src/chat/input.ts +89 -0
- package/src/chat/limit-resume.ts +74 -0
- package/src/chat/projector.ts +969 -0
- package/src/chat/request-summary.ts +168 -0
- package/src/chat/subagent-projection.ts +45 -0
- package/src/chat/subagent-reader.ts +469 -0
- package/src/chat/subagent-settlement.ts +111 -0
- package/src/chat/thread.ts +167 -0
- package/src/chat/wake-chat.ts +45 -0
- package/src/client-sinks.ts +39 -0
- package/src/coded-error.ts +18 -0
- package/src/context/argv.ts +61 -0
- package/src/context/refusal.ts +50 -0
- package/src/context/verb.ts +392 -0
- package/src/error-text.ts +28 -0
- package/src/events.ts +9 -0
- package/src/fs.ts +118 -0
- package/src/host/agent-host.ts +124 -0
- package/src/host/environment.ts +31 -0
- package/src/host/handlers.ts +142 -0
- package/src/host/memory-port.ts +27 -0
- package/src/host/wiring.ts +164 -0
- package/src/lineage.ts +159 -0
- package/src/messages/deliver-message.ts +61 -0
- package/src/messages/deliver-notice.ts +120 -0
- package/src/messages/notice-notes.ts +34 -0
- package/src/messages/notice-store.ts +142 -0
- package/src/modes.ts +38 -0
- package/src/outbox/manual-clock.ts +31 -0
- package/src/outbox/outbox-worker.ts +214 -0
- package/src/outbox/outbox.ts +106 -0
- package/src/providers/accounts/accounts.ts +192 -0
- package/src/providers/accounts/launch.ts +55 -0
- package/src/providers/accounts/service.ts +632 -0
- package/src/providers/accounts/shadow-home.ts +85 -0
- package/src/providers/accounts/status.ts +106 -0
- package/src/providers/accounts/store.ts +158 -0
- package/src/providers/accounts/test-accounts.ts +48 -0
- package/src/providers/accounts/variables.ts +132 -0
- package/src/providers/catalog.ts +90 -0
- package/src/providers/claude-models.json +114 -0
- package/src/providers/claude-provider.ts +32 -0
- package/src/providers/claude.ts +123 -0
- package/src/providers/codex-models.json +135 -0
- package/src/providers/codex-provider.ts +30 -0
- package/src/providers/codex-rules.ts +54 -0
- package/src/providers/codex.ts +69 -0
- package/src/providers/detect.ts +40 -0
- package/src/providers/provider.ts +46 -0
- package/src/providers/registry.ts +150 -0
- package/src/record-directory.ts +136 -0
- package/src/run-process.ts +61 -0
- package/src/serializer.ts +41 -0
- package/src/skills.ts +199 -0
- package/src/tasks/background-limit.ts +72 -0
- package/src/tasks/end-children.ts +100 -0
- package/src/tasks/give-task.ts +52 -0
- package/src/tasks/task-coordinator.ts +384 -0
- package/src/tasks/task-store.ts +236 -0
- package/src/tasks/task-work.ts +62 -0
- package/src/tasks/waiting-child.ts +203 -0
- package/src/tasks/wake-parent.ts +142 -0
- package/src/tasks/wiring.ts +231 -0
- package/src/title-file.ts +57 -0
- package/src/usage/accounts.ts +86 -0
- package/src/usage/aggregate.ts +220 -0
- package/src/usage/exchange.ts +93 -0
- package/src/usage/index-file.ts +178 -0
- package/src/usage/limits/monitor.ts +239 -0
- package/src/usage/limits/normalize.ts +188 -0
- package/src/usage/limits/probe.ts +120 -0
- package/src/usage/prices-snapshot.json +1071 -0
- package/src/usage/pricing.ts +236 -0
- package/src/usage/projects.ts +65 -0
- package/src/usage/readers/claude.ts +60 -0
- package/src/usage/readers/codex.ts +196 -0
- package/src/usage/record.ts +60 -0
- package/src/usage/roots.ts +39 -0
- package/src/usage/scanner.ts +265 -0
- package/src/usage/usage-service.ts +161 -0
- package/src/watch-seam.ts +111 -0
- package/src/watch-test-helpers.ts +102 -0
|
@@ -0,0 +1,1723 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
ChatAttachment,
|
|
4
|
+
ChatCheckpointDiff,
|
|
5
|
+
ChatEvent,
|
|
6
|
+
ChatInfo,
|
|
7
|
+
ChatItem,
|
|
8
|
+
ChatQueuedMessage,
|
|
9
|
+
ChatSkill,
|
|
10
|
+
ChatSubagentItem,
|
|
11
|
+
ChatTurnItem,
|
|
12
|
+
ModelSelection,
|
|
13
|
+
RuntimeMode
|
|
14
|
+
} from '@adecore/agent-contracts';
|
|
15
|
+
import { notResumedNote } from '@adecore/agent-contracts';
|
|
16
|
+
import type { ChatProvider } from '../providers/provider.ts';
|
|
17
|
+
import type { LimitsUpdate } from '../usage/limits/normalize.ts';
|
|
18
|
+
import type { BackendEvent, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
19
|
+
import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground, type BackgroundWork } from './background-work.ts';
|
|
20
|
+
import type { SpawnChatProcess } from './chat-process.ts';
|
|
21
|
+
import type { ChatTitleInput } from './chat-title.ts';
|
|
22
|
+
import { ChatError } from './errors.ts';
|
|
23
|
+
import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.ts';
|
|
24
|
+
import { isMainAgentOutput, ThreadProjector } from './projector.ts';
|
|
25
|
+
import type { SubagentSettlement } from './subagent-settlement.ts';
|
|
26
|
+
import { ChatThread } from './thread.ts';
|
|
27
|
+
import { errorText } from '../error-text.ts';
|
|
28
|
+
import { systemClock, type OutboxClock } from '../outbox/outbox-worker.ts';
|
|
29
|
+
|
|
30
|
+
/* What a host keeps of a chat's turns in git (or anything like it), so a turn can show what it changed. */
|
|
31
|
+
export interface TurnCheckpoints {
|
|
32
|
+
take(cwd: string): Promise<string | null>;
|
|
33
|
+
diff(cwd: string, tree: string): Promise<ChatCheckpointDiff | null>;
|
|
34
|
+
/* The diff of a turn that ended, with the tree of the folder it was taken against: where that turn left the files. */
|
|
35
|
+
settle(cwd: string, tree: string): Promise<{ diff: ChatCheckpointDiff; after: string } | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* What a host says in front of the next real prompt, beside the preambles a chat keeps itself. */
|
|
39
|
+
export interface PromptNotes {
|
|
40
|
+
/* `shown` also lands in the thread as a note a person reads; `heard` only reaches the CLI. */
|
|
41
|
+
next(): { shown: string[]; heard: string[] };
|
|
42
|
+
/* The chat started over, so the next prompt is its first again. */
|
|
43
|
+
reset(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Chats a person attached to a message: the ones the host knows, and what the agent is told about them. */
|
|
47
|
+
export type ChatReferences = (ids: readonly string[] | undefined) => { ids: string[]; note: string | null };
|
|
48
|
+
|
|
49
|
+
export interface ChatSessionOptions {
|
|
50
|
+
info: ChatInfo;
|
|
51
|
+
clock?: OutboxClock;
|
|
52
|
+
items?: ChatItem[];
|
|
53
|
+
// Said once, in front of the next real prompt, and kept in the record until then (a fork's note for its agent).
|
|
54
|
+
preambles?: string[];
|
|
55
|
+
provider: ChatProvider;
|
|
56
|
+
// The executable and leading arguments; a test points this at a fake CLI.
|
|
57
|
+
command: string[];
|
|
58
|
+
/* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
|
|
59
|
+
env(account: string | undefined): Record<string, string>;
|
|
60
|
+
spawn?: SpawnChatProcess;
|
|
61
|
+
// What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
|
|
62
|
+
instructions?(): string | null;
|
|
63
|
+
resumeNote?(): string | null;
|
|
64
|
+
// The folders beside the working directory, asked before every turn; a change starts the next turn in a new process.
|
|
65
|
+
folders?(): readonly string[];
|
|
66
|
+
promptNotes?: PromptNotes;
|
|
67
|
+
references?: ChatReferences;
|
|
68
|
+
// Trees per turn, so a settled turn can show what the working tree holds against its start.
|
|
69
|
+
checkpoints?: TurnCheckpoints;
|
|
70
|
+
emit(event: ChatEvent): void;
|
|
71
|
+
/* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
|
|
72
|
+
onLimits?(update: LimitsUpdate): void;
|
|
73
|
+
persist(): void;
|
|
74
|
+
save?(): Promise<void>;
|
|
75
|
+
// A write that may wait a moment, for a small change the log already holds.
|
|
76
|
+
persistSoon(): void;
|
|
77
|
+
// The name the CLI gave its session, where it writes one down; absent for a CLI that does not.
|
|
78
|
+
readTitle?(agentSessionId: string): Promise<string | null>;
|
|
79
|
+
// A name asked of a one-shot CLI, for a CLI that names nothing itself; null when none came.
|
|
80
|
+
nameThread?(input: ChatTitleInput): Promise<string | null>;
|
|
81
|
+
// How a subagent's own transcript says it ended, for a row whose CLI is gone; absent for a CLI that keeps none.
|
|
82
|
+
subagentSettlement?(toolUseId: string): Promise<SubagentSettlement | null>;
|
|
83
|
+
// What the host owes a turn that stopped on a limit; absent, nothing takes one up on its own.
|
|
84
|
+
limitResume?: LimitResumeHooks;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface LimitResumeHooks {
|
|
88
|
+
// The host's switch; the chat's own `resumeAtReset` can still say no.
|
|
89
|
+
allowed(): boolean;
|
|
90
|
+
now(): number;
|
|
91
|
+
// Owes what takes the turn up at `at`, in place of anything this chat was owed before.
|
|
92
|
+
owe(turnId: string, at: number): Promise<void>;
|
|
93
|
+
lapse(): Promise<void>;
|
|
94
|
+
owed(): boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
|
|
98
|
+
const TITLE_RECHECK_MS = 10_000;
|
|
99
|
+
|
|
100
|
+
export interface ChatSendExtras {
|
|
101
|
+
mentions?: string[];
|
|
102
|
+
skills?: string[];
|
|
103
|
+
chats?: string[];
|
|
104
|
+
attachments?: ChatAttachment[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* How a turn the host went down in is taken up: what the CLI is told, the note a person reads, and what goes in front. */
|
|
108
|
+
export interface ResumeWords {
|
|
109
|
+
prompt: string;
|
|
110
|
+
note: string;
|
|
111
|
+
preamble: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* One per limited turn, so a second fork of the same turn writes no second note. */
|
|
115
|
+
function continuedNoteId(turnId: string): string {
|
|
116
|
+
return `continued-${turnId}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function newId(prefix: string): string {
|
|
120
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* Item and request ids carry the generation of the CLI that made them, and a restart of the host
|
|
125
|
+
* must not hand out one a stored thread already holds. Starting from the clock puts every run above
|
|
126
|
+
* the ones before it, as long as it handed out fewer than one per millisecond it ran.
|
|
127
|
+
*/
|
|
128
|
+
let lastGeneration = Date.now();
|
|
129
|
+
function nextGeneration(): number {
|
|
130
|
+
lastGeneration = Math.max(lastGeneration + 1, Date.now());
|
|
131
|
+
return lastGeneration;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sameFolders(first: readonly string[], second: readonly string[]): boolean {
|
|
135
|
+
return first.length === second.length && first.every((folder, i) => folder === second[i]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
* Provider-neutral chat state. Backends live between turns and resume by CLI session id after a
|
|
140
|
+
* crash, host restart, or model and mode change.
|
|
141
|
+
*/
|
|
142
|
+
export class ChatSession {
|
|
143
|
+
readonly thread: ChatThread;
|
|
144
|
+
private readonly options: ChatSessionOptions;
|
|
145
|
+
private readonly projector: ThreadProjector;
|
|
146
|
+
private backend: ChatBackend | null = null;
|
|
147
|
+
private starting: Promise<ChatBackend> | null = null;
|
|
148
|
+
private pendingStart: { turnId: string; warning: unknown } | null = null;
|
|
149
|
+
private sendAfterStop = false;
|
|
150
|
+
// A message the person sent while a settings change waited goes out once it applies, paused queue or not.
|
|
151
|
+
private sendAfterReplacement = false;
|
|
152
|
+
private readonly workflowWarnings = new Map<string, unknown>();
|
|
153
|
+
// New per backend, so thread items of a resumed CLI never overwrite older ones.
|
|
154
|
+
private generation = nextGeneration();
|
|
155
|
+
// Claude's task notification precedes the turn that reports its result.
|
|
156
|
+
private awaitingBackgroundReport = false;
|
|
157
|
+
// The selection the running CLI was started on; a window it reports is that one's, not a newer pick's.
|
|
158
|
+
private launchedSelection: ModelSelection | null = null;
|
|
159
|
+
// The account the running CLI was started under, which what it reports about a plan belongs to.
|
|
160
|
+
private launchedAccount: string | undefined = undefined;
|
|
161
|
+
private launchedRuntimeMode: RuntimeMode | null = null;
|
|
162
|
+
private launchedFolders: readonly string[] = [];
|
|
163
|
+
// The checkpoint of the turn in flight; everything queued for that turn waits for it.
|
|
164
|
+
private turnReady: Promise<void> = Promise.resolve();
|
|
165
|
+
// Turns we settled ourselves whose `result` is still on its way; it may not close the turn after them.
|
|
166
|
+
private staleResults = 0;
|
|
167
|
+
// The last turn the main agent spoke in; a turn opened only for a subagent's request gets no `result`.
|
|
168
|
+
private spokenTurnId: string | null = null;
|
|
169
|
+
private titleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
170
|
+
// A name is asked for once per chat this host holds; the turn count keeps it once across restarts.
|
|
171
|
+
private naming = false;
|
|
172
|
+
// Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
|
|
173
|
+
private frozen = false;
|
|
174
|
+
// Background subagents a load found running whose transcript did not show an end yet; no CLI here will report them.
|
|
175
|
+
private readonly orphans = new Set<string>();
|
|
176
|
+
private pendingPreambles: readonly string[];
|
|
177
|
+
private submission: { turnId: string; input: TurnInput } | null = null;
|
|
178
|
+
|
|
179
|
+
constructor(options: ChatSessionOptions) {
|
|
180
|
+
this.options = options;
|
|
181
|
+
this.pendingPreambles = options.preambles ?? [];
|
|
182
|
+
this.thread = new ChatThread(options.info, options.items);
|
|
183
|
+
this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get id(): string {
|
|
187
|
+
return this.thread.info.chatId;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get info(): ChatInfo {
|
|
191
|
+
return this.thread.info;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get running(): boolean {
|
|
195
|
+
return this.backend?.running === true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* What still waits for the next real prompt, for the record. */
|
|
199
|
+
get preambles(): readonly string[] {
|
|
200
|
+
return [...this.pendingPreambles, ...(this.submission?.input.preamble ? [this.submission.input.preamble] : [])];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get pid(): number | null {
|
|
204
|
+
return this.backend?.pid ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Whether the person has to wait. A turn the CLI opened itself is stepped on by the next message. */
|
|
208
|
+
get busy(): boolean {
|
|
209
|
+
const turnId = this.thread.info.activeTurnId;
|
|
210
|
+
const turn = turnId ? this.thread.get(turnId) : undefined;
|
|
211
|
+
return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* The next process uses these settings; live work keeps the process it started in. */
|
|
215
|
+
configure(patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; resumeAtReset?: boolean }): ChatInfo {
|
|
216
|
+
if (patch.resumeAtReset !== undefined && patch.resumeAtReset !== this.thread.info.resumeAtReset) {
|
|
217
|
+
this.emit([this.thread.patchInfo({ resumeAtReset: patch.resumeAtReset })]);
|
|
218
|
+
this.options.persist();
|
|
219
|
+
this.resumeSettingChanged();
|
|
220
|
+
}
|
|
221
|
+
const catalog = this.options.provider.catalog;
|
|
222
|
+
const selection = patch.selection ? catalog.normalize(patch.selection) : this.thread.info.selection;
|
|
223
|
+
const next: Partial<ChatInfo> = {
|
|
224
|
+
selection,
|
|
225
|
+
runtimeMode: patch.runtimeMode ?? this.thread.info.runtimeMode
|
|
226
|
+
};
|
|
227
|
+
const changed = JSON.stringify(next.selection) !== JSON.stringify(this.thread.info.selection) || next.runtimeMode !== this.thread.info.runtimeMode;
|
|
228
|
+
if (!changed) {
|
|
229
|
+
return this.thread.info;
|
|
230
|
+
}
|
|
231
|
+
if (patch.selection) {
|
|
232
|
+
next.usage = { ...this.thread.info.usage, contextWindow: catalog.contextWindowFor(selection) };
|
|
233
|
+
}
|
|
234
|
+
this.emit([this.thread.patchInfo(next)]);
|
|
235
|
+
this.options.persist();
|
|
236
|
+
this.drainQueue();
|
|
237
|
+
return this.thread.info;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* The next process uses this account; current usage still belongs to the launched account. */
|
|
241
|
+
setAccount(account: string | undefined): ChatInfo {
|
|
242
|
+
if (account === this.thread.info.account) {
|
|
243
|
+
return this.thread.info;
|
|
244
|
+
}
|
|
245
|
+
this.emit([this.thread.patchInfo({ account })]);
|
|
246
|
+
this.options.persist();
|
|
247
|
+
this.drainQueue();
|
|
248
|
+
return this.thread.info;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* A message while a turn runs joins the queue instead of being refused; the host sends it when
|
|
253
|
+
* that turn settles. One queue for both providers: Claude's steer and Codex's own queue have
|
|
254
|
+
* different semantics, and one rule is easier to reason about than a rule per CLI.
|
|
255
|
+
*/
|
|
256
|
+
send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
|
|
257
|
+
const turnId = newId('turn');
|
|
258
|
+
// Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
|
|
259
|
+
if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
|
|
260
|
+
if (this.busy && this.info.queuePaused) {
|
|
261
|
+
this.sendAfterStop = true;
|
|
262
|
+
}
|
|
263
|
+
const message: ChatQueuedMessage = {
|
|
264
|
+
id: newId('queued'),
|
|
265
|
+
turnId,
|
|
266
|
+
text,
|
|
267
|
+
createdAt: Date.now(),
|
|
268
|
+
...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
|
|
269
|
+
...(extras.skills?.length ? { skills: extras.skills } : {}),
|
|
270
|
+
...(extras.chats?.length ? { chats: extras.chats } : {}),
|
|
271
|
+
...(extras.attachments?.length ? { attachments: extras.attachments } : {})
|
|
272
|
+
};
|
|
273
|
+
this.setQueue([...this.queue, message]);
|
|
274
|
+
this.lapseResume();
|
|
275
|
+
this.drainQueue(true);
|
|
276
|
+
return { queued: true, turnId };
|
|
277
|
+
}
|
|
278
|
+
this.dispatch(text, extras, turnId);
|
|
279
|
+
return { queued: false, turnId };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
|
|
283
|
+
unqueue(messageId: string): ChatQueuedMessage | null {
|
|
284
|
+
const queue = this.queue;
|
|
285
|
+
const message = queue.find((entry) => entry.id === messageId);
|
|
286
|
+
if (!message) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
this.setQueue(queue.filter((entry) => entry.id !== messageId));
|
|
290
|
+
return message;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Puts a queued message first and stops the turn in its way; the settle sends it. */
|
|
294
|
+
sendNow(messageId: string): boolean {
|
|
295
|
+
const queue = this.queue;
|
|
296
|
+
const message = queue.find((entry) => entry.id === messageId);
|
|
297
|
+
if (!message) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
|
|
301
|
+
if (this.busy) {
|
|
302
|
+
this.cancel(true);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
this.lapseResume();
|
|
306
|
+
this.drainQueue(true);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private get queue(): ChatQueuedMessage[] {
|
|
311
|
+
return this.thread.info.queue ?? [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private setQueue(queue: ChatQueuedMessage[]): void {
|
|
315
|
+
this.emit([this.thread.patchInfo({ queue })]);
|
|
316
|
+
this.options.persist();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* The next queued message, once nothing is in its way. */
|
|
320
|
+
private drainQueue(asked = false): void {
|
|
321
|
+
const [next, ...rest] = this.queue;
|
|
322
|
+
const limited = limitedTurn(this.thread.list());
|
|
323
|
+
const explicit = asked || this.sendAfterReplacement;
|
|
324
|
+
if (
|
|
325
|
+
!next ||
|
|
326
|
+
this.busy ||
|
|
327
|
+
(!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
|
|
328
|
+
) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (this.replacementWaits()) {
|
|
332
|
+
this.sendAfterReplacement = explicit;
|
|
333
|
+
const id = `settings-wait-${this.generation}`;
|
|
334
|
+
if (!this.thread.get(id)) {
|
|
335
|
+
this.emit([
|
|
336
|
+
this.thread.upsert({
|
|
337
|
+
id,
|
|
338
|
+
kind: 'note',
|
|
339
|
+
createdAt: Date.now(),
|
|
340
|
+
turnId: null,
|
|
341
|
+
level: 'info',
|
|
342
|
+
text: 'Your message is queued until the current work and open requests finish. The changes to this chat will apply then.'
|
|
343
|
+
})
|
|
344
|
+
]);
|
|
345
|
+
this.options.persist();
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.sendAfterReplacement = false;
|
|
350
|
+
this.pauseQueue(false);
|
|
351
|
+
this.setQueue(rest);
|
|
352
|
+
this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
|
|
356
|
+
this.pauseQueue(false);
|
|
357
|
+
this.settleAgentTurn();
|
|
358
|
+
const { preamble, note } = this.contextNote(text);
|
|
359
|
+
// A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
|
|
360
|
+
const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
|
|
361
|
+
const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
|
|
362
|
+
const said = [preamble, references.note].filter((part): part is string => part !== null);
|
|
363
|
+
const input = {
|
|
364
|
+
text,
|
|
365
|
+
preamble: said.length === 0 ? null : said.join('\n\n'),
|
|
366
|
+
attachments: extras.attachments ?? [],
|
|
367
|
+
mentions: extras.mentions ?? [],
|
|
368
|
+
skills: extras.skills ?? []
|
|
369
|
+
};
|
|
370
|
+
// The prompt waits for the checkpoint, so the tree is the folder as it was before the agent edited it.
|
|
371
|
+
this.turnReady = this.checkpoint(turnId);
|
|
372
|
+
this.submit(turnId, input);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* Asks the CLI to fold its context: a call of its own, or the slash command as a turn. */
|
|
376
|
+
compact(): void {
|
|
377
|
+
const compaction = this.options.provider.capabilities.compaction;
|
|
378
|
+
if (compaction === 'none') {
|
|
379
|
+
throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot fold its context`);
|
|
380
|
+
}
|
|
381
|
+
if (compaction === 'prompt') {
|
|
382
|
+
this.send('/compact');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (this.replacementWaits()) {
|
|
386
|
+
throw new ChatError('chat-busy', 'The current work and open requests must finish before the changes to this chat can apply');
|
|
387
|
+
}
|
|
388
|
+
// A native compaction has no message of the person in front of it, only a turn to fold behind.
|
|
389
|
+
this.openTurn(null, null, {});
|
|
390
|
+
// Folding the context changes no file, so this turn needs no checkpoint.
|
|
391
|
+
this.turnReady = Promise.resolve();
|
|
392
|
+
this.run((backend) => backend.compact());
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
cancel(advanceQueue = false): void {
|
|
396
|
+
this.sendAfterStop = advanceQueue;
|
|
397
|
+
this.sendAfterReplacement = false;
|
|
398
|
+
this.pauseQueue(true);
|
|
399
|
+
this.lapseResume();
|
|
400
|
+
const turnId = this.thread.info.activeTurnId;
|
|
401
|
+
// A turn still waiting to be resumed has no process to interrupt; stopping it is ending it here.
|
|
402
|
+
if (!this.backend && turnId !== null) {
|
|
403
|
+
this.clearStartWarning();
|
|
404
|
+
this.abandon(turnId, null);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!this.backend || turnId === null) {
|
|
408
|
+
if (turnId === null && this.backend) {
|
|
409
|
+
for (const item of this.thread.pending()) {
|
|
410
|
+
if (this.backend.declineRequest?.(item.requestId, 'The user stopped the turn')) {
|
|
411
|
+
this.receive(this.generation, { type: 'request.withdrawn', requestId: item.requestId });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.options.persist();
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (this.pendingStart?.turnId === turnId) {
|
|
419
|
+
this.clearStartWarning();
|
|
420
|
+
const backend = this.backend;
|
|
421
|
+
this.backend = null;
|
|
422
|
+
this.starting = null;
|
|
423
|
+
backend.stop();
|
|
424
|
+
this.emit([this.thread.patchInfo({ running: false })]);
|
|
425
|
+
this.receive(this.generation, { type: 'turn.done', state: 'aborted', costUsd: 0 });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
this.backend.interrupt();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* Ends a command or a monitor the CLI runs in the background; the CLI's own report takes it off the list. */
|
|
432
|
+
stopTask(taskId: string): void {
|
|
433
|
+
const workflow = this.thread
|
|
434
|
+
.list()
|
|
435
|
+
.some((item) => item.kind === 'tool' && item.name === 'Workflow' && item.state === 'running' && item.workflow?.taskId === taskId);
|
|
436
|
+
if (!workflow && !this.thread.info.background?.some((task) => task.id === taskId)) {
|
|
437
|
+
throw new ChatError('task-not-found', 'This chat runs no such task');
|
|
438
|
+
}
|
|
439
|
+
if (!this.backend?.stopTask) {
|
|
440
|
+
throw new ChatError('chat-unsupported', 'This CLI cannot stop a task on its own');
|
|
441
|
+
}
|
|
442
|
+
this.backend.stopTask(taskId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* Answers a pending approval; false when nothing waits under that id. */
|
|
446
|
+
approve(requestId: string, decision: 'allow' | 'allow-always' | 'deny', message?: string): boolean {
|
|
447
|
+
const item = this.thread.get(`approval-${requestId}`);
|
|
448
|
+
if (item?.kind !== 'approval' || item.decision !== 'pending' || this.backend?.respondApproval(requestId, decision, message) !== true) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
this.emit([this.thread.upsert({ ...item, decision }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* Answers a pending question; false when nothing waits under that id. */
|
|
456
|
+
answer(requestId: string, answers: Record<string, string>): boolean {
|
|
457
|
+
const item = this.thread.get(`question-${requestId}`);
|
|
458
|
+
if (item?.kind !== 'question' || item.state !== 'pending' || this.backend?.respondQuestion(requestId, answers) !== true) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
this.emit([this.thread.upsert({ ...item, answers, state: 'answered' }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/*
|
|
466
|
+
* Leaves an asynchronous question alone. The CLI is not told: it asked beside its turn and
|
|
467
|
+
* carries on either way, so the item only has to stop waiting for the person.
|
|
468
|
+
*/
|
|
469
|
+
dismiss(itemId: string): boolean {
|
|
470
|
+
const item = this.thread.get(itemId);
|
|
471
|
+
if (item?.kind !== 'question' || item.state !== 'pending' || item.async !== true) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
this.backend?.dismissRequest?.(item.requestId);
|
|
475
|
+
this.emit([this.thread.upsert({ ...item, state: 'dismissed' }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/*
|
|
480
|
+
* Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
|
|
481
|
+
* A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
|
|
482
|
+
* since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
|
|
483
|
+
*/
|
|
484
|
+
clear(force: boolean): void {
|
|
485
|
+
if (this.busy && !force) {
|
|
486
|
+
throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
|
|
487
|
+
}
|
|
488
|
+
void this.dispose();
|
|
489
|
+
this.generation = nextGeneration();
|
|
490
|
+
this.projector.reset();
|
|
491
|
+
// The fresh CLI hears what the host tells it at launch, as it would on a first turn.
|
|
492
|
+
this.options.promptNotes?.reset();
|
|
493
|
+
// A cleared chat starts a new conversation, which what was waiting to be said no longer describes.
|
|
494
|
+
this.pendingPreambles = [];
|
|
495
|
+
this.submission = null;
|
|
496
|
+
this.staleResults = 0;
|
|
497
|
+
this.sendAfterStop = false;
|
|
498
|
+
this.sendAfterReplacement = false;
|
|
499
|
+
this.awaitingBackgroundReport = false;
|
|
500
|
+
this.launchedSelection = null;
|
|
501
|
+
this.turnReady = Promise.resolve();
|
|
502
|
+
this.lapseResume();
|
|
503
|
+
const usage = this.thread.info.usage;
|
|
504
|
+
this.emit([
|
|
505
|
+
this.thread.reset({
|
|
506
|
+
agentSessionId: null,
|
|
507
|
+
limit: undefined,
|
|
508
|
+
running: false,
|
|
509
|
+
status: 'idle',
|
|
510
|
+
activeTurnId: null,
|
|
511
|
+
queue: [],
|
|
512
|
+
queuePaused: false,
|
|
513
|
+
background: [],
|
|
514
|
+
delegating: undefined,
|
|
515
|
+
effectiveRuntimeMode: undefined,
|
|
516
|
+
permissionMode: undefined,
|
|
517
|
+
slashCommands: [],
|
|
518
|
+
// The next CLI starts on the current pick, so the window the one that just went reported is not its.
|
|
519
|
+
usage: {
|
|
520
|
+
...usage,
|
|
521
|
+
contextTokens: 0,
|
|
522
|
+
contextWindow: this.options.provider.catalog.contextWindowFor(this.thread.info.selection),
|
|
523
|
+
breakdown: undefined
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
]);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/*
|
|
530
|
+
* A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
|
|
531
|
+
* except the turn a restart takes up again. Said as events, so a client that comes back with a
|
|
532
|
+
* seq from before the restart hears it as well.
|
|
533
|
+
*/
|
|
534
|
+
settleStored(selection: ModelSelection, resume: ResumeDecision): void {
|
|
535
|
+
const events: ChatEvent[] = [];
|
|
536
|
+
const resumeTurnId = resume.resumeTurnId;
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
for (const item of this.thread.list()) {
|
|
539
|
+
const settled = settleStoredItem(item, resumeTurnId);
|
|
540
|
+
if (settled === item) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (isBackgroundWork(item)) {
|
|
544
|
+
this.recordLostBackground(item);
|
|
545
|
+
}
|
|
546
|
+
events.push(this.thread.upsert(settled));
|
|
547
|
+
if (item.kind === 'turn' && settled.kind === 'turn' && settled.state === 'aborted') {
|
|
548
|
+
const reason = item.id === this.thread.info.activeTurnId && resume.reason !== null ? resume.reason : 'it was not the turn the chat was running';
|
|
549
|
+
events.push(
|
|
550
|
+
this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId: item.id, level: 'warning', text: notResumedNote(reason) })
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const info = this.thread.info;
|
|
555
|
+
const patch: Partial<ChatInfo> = {
|
|
556
|
+
selection,
|
|
557
|
+
running: false,
|
|
558
|
+
status: resumeTurnId === null ? 'idle' : 'running',
|
|
559
|
+
activeTurnId: resumeTurnId,
|
|
560
|
+
effectiveRuntimeMode: undefined,
|
|
561
|
+
permissionMode: undefined
|
|
562
|
+
};
|
|
563
|
+
// No process of the host that went down is left to run what it kept in the background.
|
|
564
|
+
if (info.background?.length) {
|
|
565
|
+
for (const command of info.background) {
|
|
566
|
+
const text = `The machine restarted while background command "${commandLabel(command)}" was running. It will not send a result. Do not wait for it.`;
|
|
567
|
+
this.deliverNote({ noteId: `lost-command-${command.id}`, note: text, preamble: text });
|
|
568
|
+
}
|
|
569
|
+
patch.background = [];
|
|
570
|
+
}
|
|
571
|
+
// The window stored is whatever the session that went left behind; the next CLI starts on the pick.
|
|
572
|
+
const contextWindow = this.options.provider.catalog.contextWindowFor(selection);
|
|
573
|
+
if (contextWindow !== info.usage.contextWindow) {
|
|
574
|
+
patch.usage = { ...info.usage, contextWindow };
|
|
575
|
+
}
|
|
576
|
+
if (
|
|
577
|
+
JSON.stringify(selection) !== JSON.stringify(info.selection) ||
|
|
578
|
+
patch.usage !== undefined ||
|
|
579
|
+
info.running ||
|
|
580
|
+
info.effectiveRuntimeMode !== undefined ||
|
|
581
|
+
info.permissionMode !== undefined ||
|
|
582
|
+
patch.background !== undefined ||
|
|
583
|
+
info.status !== patch.status ||
|
|
584
|
+
info.activeTurnId !== resumeTurnId
|
|
585
|
+
) {
|
|
586
|
+
events.push(this.thread.patchInfo(patch));
|
|
587
|
+
}
|
|
588
|
+
this.emit(events);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/*
|
|
592
|
+
* Takes up a turn the host went down in: a new process on the CLI's own session, the same turn
|
|
593
|
+
* with the next attempt, and a prompt that says what happened. Nothing happens when the turn has
|
|
594
|
+
* ended or already has that attempt, so the host may run it twice. A CLI that will not start
|
|
595
|
+
* throws and leaves the turn waiting for the next try.
|
|
596
|
+
*/
|
|
597
|
+
async resume(turnId: string, attempt: number, words: ResumeWords): Promise<void> {
|
|
598
|
+
if (!this.awaitsResume(turnId, attempt)) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const original = this.thread.get(turnId);
|
|
602
|
+
if (original?.kind !== 'turn') {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const now = Date.now();
|
|
606
|
+
this.emit([
|
|
607
|
+
this.thread.upsert({ ...original, attempt, resumePending: true }),
|
|
608
|
+
this.thread.upsert({ id: `resume-${turnId}-${attempt}`, kind: 'note', createdAt: now, turnId, level: 'info', text: words.note })
|
|
609
|
+
]);
|
|
610
|
+
this.options.persist();
|
|
611
|
+
const pending = this.watchStart(turnId);
|
|
612
|
+
let backend: ChatBackend;
|
|
613
|
+
try {
|
|
614
|
+
await this.options.save?.();
|
|
615
|
+
if (this.pendingStart !== pending || !this.awaitsResume(turnId, attempt)) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
backend = await this.ensureBackend();
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (this.pendingStart !== pending) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this.clearStartWarning();
|
|
624
|
+
if (this.backend === null) {
|
|
625
|
+
this.emit([this.thread.patchInfo({ running: false })]);
|
|
626
|
+
}
|
|
627
|
+
this.addNote('warning', `The resume attempt could not start: ${errorText(e)}`);
|
|
628
|
+
throw e;
|
|
629
|
+
}
|
|
630
|
+
const turn = this.thread.get(turnId);
|
|
631
|
+
if (this.pendingStart !== pending || !this.awaitsResume(turnId, attempt) || this.backend !== backend || turn?.kind !== 'turn') {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
this.clearStartWarning();
|
|
635
|
+
// The turn keeps the checkpoint it started with, so its card still shows everything it changed.
|
|
636
|
+
this.turnReady = Promise.resolve();
|
|
637
|
+
const context = this.contextNote(words.prompt);
|
|
638
|
+
const preamble = [words.preamble, context.preamble].filter(Boolean).join('\n\n') || null;
|
|
639
|
+
if (context.note) {
|
|
640
|
+
this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: context.note })]);
|
|
641
|
+
}
|
|
642
|
+
const input: TurnInput = { promptId: randomUUID(), text: words.prompt, preamble, attachments: [], mentions: [], skills: [] };
|
|
643
|
+
this.submission = { turnId, input };
|
|
644
|
+
this.options.persist();
|
|
645
|
+
await this.options.save?.();
|
|
646
|
+
if (this.backend !== backend || this.info.activeTurnId !== turnId || this.frozen) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
backend.sendTurn(input);
|
|
650
|
+
if (!backend.acknowledgesTurns) {
|
|
651
|
+
this.acceptPrompt(input.promptId!);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/* Ends a running turn nobody is working on and keeps queued messages for a person's next send. */
|
|
656
|
+
abandon(turnId: string, reason: string | null): void {
|
|
657
|
+
const turn = this.thread.get(turnId);
|
|
658
|
+
if (turn?.kind !== 'turn' || turn.state !== 'running' || this.thread.info.activeTurnId !== turnId || this.running) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
this.pauseQueue(true);
|
|
663
|
+
this.emit([
|
|
664
|
+
this.thread.upsert({ ...turn, state: 'aborted', endedAt: now }),
|
|
665
|
+
...(reason === null ? [] : [this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason })]),
|
|
666
|
+
this.thread.patchInfo({ status: 'idle', activeTurnId: null })
|
|
667
|
+
]);
|
|
668
|
+
this.options.persist();
|
|
669
|
+
this.drainQueue(this.sendAfterStop);
|
|
670
|
+
this.sendAfterStop = false;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/*
|
|
674
|
+
* Opens a turn nobody typed a prompt for (a settled task, a message left by another node), with the
|
|
675
|
+
* turn carrying what woke it, so a turn it opens cannot itself wake anyone. A caller passes `note`
|
|
676
|
+
* only when its news is not already written in the thread. Checked and opened in one step, so null
|
|
677
|
+
* means a turn (or a shutdown) was in the way. Never through `send`, which would step on a turn
|
|
678
|
+
* with `origin: 'agent'` and close the wake it just made.
|
|
679
|
+
*/
|
|
680
|
+
wake(wake: { text: string; label: string; note?: string; taskIds: string[]; summaryFor?: string; messageFrom?: string[] }): string | null {
|
|
681
|
+
if (this.frozen || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
this.turnOpened();
|
|
685
|
+
const { preamble, note } = this.contextNote(wake.text);
|
|
686
|
+
const turnId = newId('turn');
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
this.emit([
|
|
689
|
+
this.thread.upsert({
|
|
690
|
+
id: turnId,
|
|
691
|
+
kind: 'turn',
|
|
692
|
+
createdAt: now,
|
|
693
|
+
turnId,
|
|
694
|
+
state: 'running',
|
|
695
|
+
origin: 'agent',
|
|
696
|
+
deliveryPending: true,
|
|
697
|
+
label: wake.label,
|
|
698
|
+
...(wake.taskIds.length === 0 ? {} : { taskIds: wake.taskIds }),
|
|
699
|
+
...(wake.messageFrom === undefined || wake.messageFrom.length === 0 ? {} : { messageFrom: wake.messageFrom }),
|
|
700
|
+
...(wake.summaryFor === undefined ? {} : { summaryFor: wake.summaryFor }),
|
|
701
|
+
endedAt: null,
|
|
702
|
+
costUsd: 0
|
|
703
|
+
}),
|
|
704
|
+
...(wake.note === undefined
|
|
705
|
+
? []
|
|
706
|
+
: [this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: wake.note })]),
|
|
707
|
+
...(note === null ? [] : [this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: note })]),
|
|
708
|
+
this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId })
|
|
709
|
+
]);
|
|
710
|
+
this.options.persist();
|
|
711
|
+
this.turnReady = this.checkpoint(turnId);
|
|
712
|
+
this.submit(turnId, { text: wake.text, preamble, attachments: [], mentions: [], skills: [] });
|
|
713
|
+
return turnId;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* The host's switch or this chat's own changed: a limit still ahead is owed a resume now, or what was owed lapses. */
|
|
717
|
+
resumeSettingChanged(): void {
|
|
718
|
+
if (this.resumeAllowed()) {
|
|
719
|
+
this.oweResume(false);
|
|
720
|
+
} else {
|
|
721
|
+
this.lapseResume();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/*
|
|
726
|
+
* Opens the turn that takes up the limited turn `turnId` again, carrying the tasks and messages that
|
|
727
|
+
* one answered. `wait` while a settings change waits on the CLI's work, which keeps the owed entry
|
|
728
|
+
* until that work ends; `lapsed` when anything stepped in since (a person wrote, the switch went off,
|
|
729
|
+
* the chat stopped or was cleared), which takes the resume time off with the entry.
|
|
730
|
+
*/
|
|
731
|
+
takeUpAfterLimit(turnId: string): 'opened' | 'wait' | 'lapsed' {
|
|
732
|
+
const turn = limitedTurn(this.thread.list());
|
|
733
|
+
if (turn === null || turn.id !== turnId || !this.resumeAllowed()) {
|
|
734
|
+
this.lapseResume();
|
|
735
|
+
return 'lapsed';
|
|
736
|
+
}
|
|
737
|
+
const wake = limitResumeWake(turn.limit.kind);
|
|
738
|
+
const opened = this.wake({
|
|
739
|
+
...wake,
|
|
740
|
+
taskIds: turn.taskIds ?? [],
|
|
741
|
+
...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom })
|
|
742
|
+
});
|
|
743
|
+
if (opened === null) {
|
|
744
|
+
return 'wait';
|
|
745
|
+
}
|
|
746
|
+
this.pauseQueue(false);
|
|
747
|
+
return 'opened';
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/* Whether a change that needs a new CLI waits now: a turn, work beside it or an open request holds the one there is. */
|
|
751
|
+
get replacementWouldWait(): boolean {
|
|
752
|
+
return this.backend !== null && (this.info.activeTurnId !== null || this.processInUse());
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* A limited turn a fork went on with is that fork's to take up, never this chat's. */
|
|
756
|
+
private wentOn(turn: ChatTurnItem): boolean {
|
|
757
|
+
return this.thread.get(continuedNoteId(turn.id)) !== undefined;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private resumeAllowed(): boolean {
|
|
761
|
+
return this.options.limitResume?.allowed() === true && this.thread.info.resumeAtReset !== false;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/*
|
|
765
|
+
* Owes a resume of the last turn when it stopped on a limit. Right after that turn every limit is
|
|
766
|
+
* owed; a switch turned on later only owes a usage limit whose reset is still ahead, so an old
|
|
767
|
+
* error is never taken up out of the blue.
|
|
768
|
+
*/
|
|
769
|
+
private oweResume(fresh: boolean): void {
|
|
770
|
+
const hooks = this.options.limitResume;
|
|
771
|
+
const turn = limitedTurn(this.thread.list());
|
|
772
|
+
if (!hooks || turn === null || this.thread.info.activeTurnId !== null || this.frozen || !this.resumeAllowed() || this.wentOn(turn)) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const now = hooks.now();
|
|
776
|
+
if (!fresh && (turn.limit.kind !== 'usage' || (turn.limit.resetsAt ?? 0) <= now)) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const at = limitResumeAt(this.thread.list(), turn, now);
|
|
780
|
+
if (at === null || at === this.thread.info.resumeAt) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.emit([this.thread.patchInfo({ resumeAt: at })]);
|
|
784
|
+
this.options.persist();
|
|
785
|
+
void hooks.owe(turn.id, at).catch((e: unknown) => console.error(`Owing a resume of chat ${this.id} failed:`, errorText(e)));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/*
|
|
789
|
+
* A resume time the record shows while the host holds no entry for it, which a host that went
|
|
790
|
+
* down between the two leaves: owed again at that time, or taken off when nothing may owe it now.
|
|
791
|
+
*/
|
|
792
|
+
settleOwedResume(): void {
|
|
793
|
+
const hooks = this.options.limitResume;
|
|
794
|
+
const at = this.thread.info.resumeAt;
|
|
795
|
+
if (at === undefined || hooks?.owed() === true) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const turn = limitedTurn(this.thread.list());
|
|
799
|
+
if (!hooks || turn === null || this.thread.info.activeTurnId !== null || !this.resumeAllowed()) {
|
|
800
|
+
this.emit([this.thread.patchInfo({ resumeAt: undefined })]);
|
|
801
|
+
this.options.persist();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
void hooks.owe(turn.id, at).catch((e: unknown) => console.error(`Owing a resume of chat ${this.id} failed:`, errorText(e)));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/* Whatever the last turn stopped on is behind the chat once another turn opens. */
|
|
808
|
+
private turnOpened(): void {
|
|
809
|
+
if (this.thread.info.limit !== undefined) {
|
|
810
|
+
this.emit([this.thread.patchInfo({ limit: undefined })]);
|
|
811
|
+
}
|
|
812
|
+
this.lapseResume();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/*
|
|
816
|
+
* The limited turn went on elsewhere (a fork), so what the host owes for it lapses, and the note says where
|
|
817
|
+
* it went. The note is also the mark that keeps a switch turned on later from owing that turn again.
|
|
818
|
+
*/
|
|
819
|
+
continuedElsewhere(note: string): void {
|
|
820
|
+
this.lapseResume();
|
|
821
|
+
const turn = limitedTurn(this.thread.list());
|
|
822
|
+
if (turn === null || this.wentOn(turn)) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.emit([this.thread.upsert({ id: continuedNoteId(turn.id), kind: 'note', createdAt: Date.now(), turnId: turn.id, level: 'info', text: note })]);
|
|
826
|
+
this.options.persist();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/* Drops the resume owed after a limit, the time the thread shows for it included. */
|
|
830
|
+
lapseResume(): void {
|
|
831
|
+
const hooks = this.options.limitResume;
|
|
832
|
+
if (this.thread.info.resumeAt === undefined || !hooks) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
this.emit([this.thread.patchInfo({ resumeAt: undefined })]);
|
|
836
|
+
this.options.persist();
|
|
837
|
+
void hooks.lapse().catch((e: unknown) => console.error(`Dropping the resume of chat ${this.id} failed:`, errorText(e)));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/*
|
|
841
|
+
* A note a person reads and a preamble the CLI hears in front of the next real prompt, whether or
|
|
842
|
+
* not a turn runs now: a fork's summary, a child that waits. Written under the id the delivery
|
|
843
|
+
* names, so a delivery that runs twice leaves one of each; false when it was here already.
|
|
844
|
+
*/
|
|
845
|
+
deliverNote(delivery: { noteId: string; note: string; from?: string; preamble: string }): boolean {
|
|
846
|
+
if (this.thread.get(delivery.noteId) !== undefined) {
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
this.pendingPreambles = [...this.pendingPreambles, delivery.preamble];
|
|
850
|
+
this.emit([
|
|
851
|
+
this.thread.upsert({
|
|
852
|
+
id: delivery.noteId,
|
|
853
|
+
kind: 'note',
|
|
854
|
+
createdAt: Date.now(),
|
|
855
|
+
turnId: null,
|
|
856
|
+
level: 'info',
|
|
857
|
+
text: delivery.note,
|
|
858
|
+
...(delivery.from === undefined ? {} : { from: delivery.from })
|
|
859
|
+
})
|
|
860
|
+
]);
|
|
861
|
+
this.options.persist();
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/* Writes items a host draws in the thread itself (a row for work it delegated), leaving the ones that did not change alone. */
|
|
866
|
+
upsertItems(items: readonly ChatItem[]): void {
|
|
867
|
+
const changed = items.filter((item) => JSON.stringify(this.thread.get(item.id)) !== JSON.stringify(item));
|
|
868
|
+
if (changed.length === 0) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
this.emit(changed.map((item) => this.thread.upsert(item)));
|
|
872
|
+
this.options.persistSoon();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/* A line in the thread outside any turn, for something the host has to say about work it gave up on. */
|
|
876
|
+
addNote(level: 'info' | 'warning' | 'error', text: string): void {
|
|
877
|
+
this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: Date.now(), turnId: null, level, text })]);
|
|
878
|
+
this.options.persist();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/*
|
|
882
|
+
* Ends the CLI because the agent that opened this chat was stopped: whatever ran or waited is
|
|
883
|
+
* closed with the reason in the thread, the queue goes, and the thread stays for a person to read
|
|
884
|
+
* or to carry on in, which starts the CLI again like any send.
|
|
885
|
+
*/
|
|
886
|
+
end(reason: string): void {
|
|
887
|
+
this.clearStartWarning();
|
|
888
|
+
this.clearWorkflowWarnings();
|
|
889
|
+
this.sendAfterStop = false;
|
|
890
|
+
this.sendAfterReplacement = false;
|
|
891
|
+
this.lapseResume();
|
|
892
|
+
const backend = this.backend;
|
|
893
|
+
this.backend = null;
|
|
894
|
+
this.starting = null;
|
|
895
|
+
this.awaitingBackgroundReport = false;
|
|
896
|
+
void backend?.dispose();
|
|
897
|
+
const now = Date.now();
|
|
898
|
+
const turnId = this.thread.info.activeTurnId;
|
|
899
|
+
const events: ChatEvent[] = [];
|
|
900
|
+
for (const item of this.thread.list()) {
|
|
901
|
+
const settled =
|
|
902
|
+
item.kind === 'turn' && item.state === 'running'
|
|
903
|
+
? { ...item, state: 'aborted' as const, endedAt: now }
|
|
904
|
+
: item.kind === 'subagent' && item.origin !== 'ruimte' && item.status === 'running'
|
|
905
|
+
? { ...item, status: 'failed' as const, finishedAt: now }
|
|
906
|
+
: settleStoredItem(item, null);
|
|
907
|
+
if (settled !== item) {
|
|
908
|
+
events.push(this.thread.upsert(settled));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (turnId !== null) {
|
|
912
|
+
events.push(this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
|
|
913
|
+
}
|
|
914
|
+
events.push(
|
|
915
|
+
this.thread.patchInfo({
|
|
916
|
+
running: false,
|
|
917
|
+
effectiveRuntimeMode: undefined,
|
|
918
|
+
permissionMode: undefined,
|
|
919
|
+
status: 'idle',
|
|
920
|
+
activeTurnId: null,
|
|
921
|
+
...(this.queue.length > 0 ? { queue: [] } : {}),
|
|
922
|
+
...(this.thread.info.background?.length ? { background: [] } : {})
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
this.emit(events);
|
|
926
|
+
this.options.persist();
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
|
|
930
|
+
freeze(): void {
|
|
931
|
+
this.clearStartWarning();
|
|
932
|
+
this.clearWorkflowWarnings();
|
|
933
|
+
this.frozen = true;
|
|
934
|
+
this.backend?.stop();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
|
|
938
|
+
dispose(): Promise<void> {
|
|
939
|
+
this.clearStartWarning();
|
|
940
|
+
this.clearWorkflowWarnings();
|
|
941
|
+
if (this.titleTimer !== null) {
|
|
942
|
+
clearTimeout(this.titleTimer);
|
|
943
|
+
this.titleTimer = null;
|
|
944
|
+
}
|
|
945
|
+
const ended = this.backend?.dispose() ?? Promise.resolve();
|
|
946
|
+
this.backend = null;
|
|
947
|
+
this.starting = null;
|
|
948
|
+
return ended;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/* What a turn changed against its checkpoint: the stored answer, or one taken now while it runs. */
|
|
952
|
+
async turnDiff(turnId: string): Promise<ChatCheckpointDiff | null> {
|
|
953
|
+
const turn = this.thread.get(turnId);
|
|
954
|
+
if (turn?.kind !== 'turn') {
|
|
955
|
+
throw new ChatError('request-not-found', `No turn ${turnId} in chat ${this.id}`);
|
|
956
|
+
}
|
|
957
|
+
if (turn.checkpointDiff) {
|
|
958
|
+
return turn.checkpointDiff;
|
|
959
|
+
}
|
|
960
|
+
if (turn.checkpoint === undefined || !this.options.checkpoints) {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
return await this.options.checkpoints.diff(this.thread.info.cwd, turn.checkpoint);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* A page of a thread the CLI keeps, through the process that runs now; null when none runs that can answer. */
|
|
967
|
+
listThreadItems(params: { threadId: string; cursor?: string; limit: number; sortDirection: 'asc' | 'desc' }): Promise<unknown> | null {
|
|
968
|
+
const backend = this.backend;
|
|
969
|
+
return backend?.running === true && backend.listThreadItems ? backend.listThreadItems(params) : null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/* A completed transcript survives the process; unfinished work cannot send a result after a restart. */
|
|
973
|
+
async settleOrphanedSubagents(): Promise<void> {
|
|
974
|
+
const rows = this.runningBackgroundRows();
|
|
975
|
+
if (this.info.provider === 'claude') {
|
|
976
|
+
for (const row of rows) {
|
|
977
|
+
this.orphans.add(row.toolUseId);
|
|
978
|
+
}
|
|
979
|
+
await this.settleFromTranscripts(rows);
|
|
980
|
+
}
|
|
981
|
+
const lost = this.runningBackgroundRows();
|
|
982
|
+
for (const row of lost) {
|
|
983
|
+
this.recordLostBackground(row);
|
|
984
|
+
}
|
|
985
|
+
this.settleRows(lost.map((row) => ({ ...row, status: 'failed', finishedAt: Date.now() })));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private recordLostBackground(item: BackgroundWork): void {
|
|
989
|
+
const label = item.kind === 'subagent' ? item.description || item.subagentType || item.toolUseId : item.name;
|
|
990
|
+
const text = `The machine restarted before background work "${label}" completed. It will not send a result. Do not wait for it.`;
|
|
991
|
+
this.deliverNote({ noteId: `lost-background-${item.id}`, note: text, preamble: text });
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/*
|
|
995
|
+
* Marks a subagent of the CLI's own as stopped: `failed` with a note that says what it means, since
|
|
996
|
+
* no CLI stops one subagent on its own. Refused while a turn runs, which may still be waiting on it.
|
|
997
|
+
*/
|
|
998
|
+
markSubagentStopped(toolUseId: string): void {
|
|
999
|
+
if (this.thread.info.activeTurnId !== null) {
|
|
1000
|
+
throw new ChatError('chat-busy', 'A turn is running; stop the turn instead');
|
|
1001
|
+
}
|
|
1002
|
+
const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
|
|
1003
|
+
if (row?.status !== 'running') {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
this.markStopped([row]);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/* Marks every running subagent of the CLI's own as stopped, for a person stopping the turn together with them. */
|
|
1010
|
+
markSubagentsStopped(): void {
|
|
1011
|
+
this.markStopped(
|
|
1012
|
+
this.thread.list().filter((item): item is ChatSubagentItem => item.kind === 'subagent' && item.status === 'running' && item.origin !== 'ruimte')
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private markStopped(rows: readonly ChatSubagentItem[]): void {
|
|
1017
|
+
if (rows.length === 0) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const now = Date.now();
|
|
1021
|
+
for (const row of rows) {
|
|
1022
|
+
this.orphans.delete(row.toolUseId);
|
|
1023
|
+
}
|
|
1024
|
+
const names = rows.map((row) => `"${row.description || row.subagentType || 'Sub-agent'}"`).join(', ');
|
|
1025
|
+
this.emit([
|
|
1026
|
+
...rows.map((row) => this.thread.upsert({ ...row, status: 'failed', finishedAt: now })),
|
|
1027
|
+
this.thread.upsert({
|
|
1028
|
+
id: newId('note'),
|
|
1029
|
+
kind: 'note',
|
|
1030
|
+
createdAt: now,
|
|
1031
|
+
turnId: null,
|
|
1032
|
+
level: 'info',
|
|
1033
|
+
text: `${names} ${rows.length === 1 ? 'was' : 'were'} marked as stopped. ${this.options.provider.name} cannot stop one sub-agent on its own, so ${rows.length === 1 ? 'it' : 'they'} may keep working until the chat's process ends.`
|
|
1034
|
+
})
|
|
1035
|
+
]);
|
|
1036
|
+
this.options.persist();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/* Asks the transcript again for a row a load left running, when somebody looks at it. */
|
|
1040
|
+
async recheckOrphan(toolUseId: string): Promise<void> {
|
|
1041
|
+
const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
|
|
1042
|
+
if (this.orphans.has(toolUseId) && (row?.status === 'running' || row?.status === 'failed')) {
|
|
1043
|
+
await this.settleFromTranscripts([row]);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/* Writes down where a subagent's conversation was found, so the next question about it reads no folder. */
|
|
1048
|
+
noteSubagentNative(toolUseId: string, native: { agentId?: string; threadId?: string }): void {
|
|
1049
|
+
const item = this.thread.find('subagent', (candidate) => candidate.toolUseId === toolUseId);
|
|
1050
|
+
if (!item || (item.native?.agentId === native.agentId && item.native?.threadId === native.threadId)) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
this.emit([this.thread.upsert({ ...item, native: { ...item.native, ...native } })]);
|
|
1054
|
+
this.options.persistSoon();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/*
|
|
1058
|
+
* What this chat's CLI would run as a skill. A running backend that answers the question itself
|
|
1059
|
+
* (Codex) is the authority; otherwise the host's own scan is, narrowed to what the CLI's init
|
|
1060
|
+
* frame said it has, so a skill turned off in its settings drops out after the first message.
|
|
1061
|
+
*/
|
|
1062
|
+
async skills(scan: () => Promise<ChatSkill[]>): Promise<ChatSkill[]> {
|
|
1063
|
+
const backend = this.backend;
|
|
1064
|
+
if (backend?.running === true && backend.listSkills) {
|
|
1065
|
+
try {
|
|
1066
|
+
const own = await backend.listSkills();
|
|
1067
|
+
if (own.length > 0) {
|
|
1068
|
+
return own;
|
|
1069
|
+
}
|
|
1070
|
+
} catch {
|
|
1071
|
+
// The CLI could not answer; the scan below is what is left.
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const scanned = await scan();
|
|
1075
|
+
const announced = this.thread.info.skills;
|
|
1076
|
+
if (!announced?.length) {
|
|
1077
|
+
return scanned;
|
|
1078
|
+
}
|
|
1079
|
+
const known = new Set(announced);
|
|
1080
|
+
return scanned.filter((skill) => known.has(skill.name));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private openTurn(text: string | null, note: string | null, extras: ChatSendExtras, requestedTurnId?: string): string {
|
|
1084
|
+
this.turnOpened();
|
|
1085
|
+
const turnId = requestedTurnId ?? newId('turn');
|
|
1086
|
+
const now = Date.now();
|
|
1087
|
+
const events = [this.thread.upsert({ id: turnId, kind: 'turn', createdAt: now, turnId, state: 'running', origin: 'user', endedAt: null, costUsd: 0 })];
|
|
1088
|
+
if (note !== null) {
|
|
1089
|
+
events.push(this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'info', text: note }));
|
|
1090
|
+
}
|
|
1091
|
+
if (text !== null) {
|
|
1092
|
+
const mentions = extras.mentions?.length ? extras.mentions : undefined;
|
|
1093
|
+
const skills = extras.skills?.length ? extras.skills : undefined;
|
|
1094
|
+
const chats = extras.chats?.length ? extras.chats : undefined;
|
|
1095
|
+
const attachments = extras.attachments?.length ? extras.attachments : undefined;
|
|
1096
|
+
events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
|
|
1097
|
+
}
|
|
1098
|
+
events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
|
|
1099
|
+
this.emit(events);
|
|
1100
|
+
// On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
|
|
1101
|
+
this.options.persist();
|
|
1102
|
+
return turnId;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/*
|
|
1106
|
+
* A turn the CLI opened itself is closed the moment the person types: they are steering now, and
|
|
1107
|
+
* a `result` still coming for that turn must not settle the one they just started.
|
|
1108
|
+
*/
|
|
1109
|
+
private settleAgentTurn(): void {
|
|
1110
|
+
const turnId = this.thread.info.activeTurnId;
|
|
1111
|
+
const turn = turnId ? this.thread.get(turnId) : undefined;
|
|
1112
|
+
if (turn?.kind !== 'turn' || turn.origin !== 'agent') {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (this.spokenTurnId === turn.id) {
|
|
1116
|
+
this.staleResults += 1;
|
|
1117
|
+
}
|
|
1118
|
+
this.emit([this.thread.upsert({ ...turn, state: 'done', endedAt: Date.now() }), this.thread.patchInfo({ status: 'idle', activeTurnId: null })]);
|
|
1119
|
+
this.settleCheckpoint(turn.id);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/* Records the folder's tree on the turn; a folder without git leaves the turn as it is. */
|
|
1123
|
+
private checkpoint(turnId: string): Promise<void> {
|
|
1124
|
+
const checkpoints = this.options.checkpoints;
|
|
1125
|
+
if (!checkpoints) {
|
|
1126
|
+
return Promise.resolve();
|
|
1127
|
+
}
|
|
1128
|
+
return checkpoints
|
|
1129
|
+
.take(this.thread.info.cwd)
|
|
1130
|
+
.then((tree) => {
|
|
1131
|
+
const turn = this.thread.get(turnId);
|
|
1132
|
+
if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
this.emit([this.thread.upsert({ ...turn, checkpoint: tree })]);
|
|
1136
|
+
})
|
|
1137
|
+
.catch(() => undefined);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/* The diff of a turn that just ended, so a reload shows it without asking git again, and the tree a fork after it starts from. */
|
|
1141
|
+
private settleCheckpoint(turnId: string): void {
|
|
1142
|
+
const turn = this.thread.get(turnId);
|
|
1143
|
+
if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
void this.options.checkpoints
|
|
1147
|
+
.settle(this.thread.info.cwd, turn.checkpoint)
|
|
1148
|
+
.then((answer) => {
|
|
1149
|
+
const settled = this.thread.get(turnId);
|
|
1150
|
+
if (answer === null || settled?.kind !== 'turn') {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
this.emit([this.thread.upsert({ ...settled, checkpointDiff: answer.diff, checkpointAfter: answer.after })]);
|
|
1154
|
+
this.options.persist();
|
|
1155
|
+
})
|
|
1156
|
+
.catch(() => undefined);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/*
|
|
1160
|
+
* What the agent has to hear before this prompt: what the chat kept for it and what the host says.
|
|
1161
|
+
* Each is said once, and only in front of a real prompt. The thread gets only what the host shows.
|
|
1162
|
+
*/
|
|
1163
|
+
private contextNote(text: string): { preamble: string | null; note: string | null } {
|
|
1164
|
+
// A slash command must stay the first thing the CLI reads; the rest waits for a real prompt.
|
|
1165
|
+
if (text.startsWith('/')) {
|
|
1166
|
+
return { preamble: null, note: null };
|
|
1167
|
+
}
|
|
1168
|
+
const host = this.options.promptNotes?.next() ?? { shown: [], heard: [] };
|
|
1169
|
+
// Keep consumed notes in the submission until the CLI acknowledges them.
|
|
1170
|
+
const preambles = this.preambles;
|
|
1171
|
+
this.pendingPreambles = [];
|
|
1172
|
+
this.submission = null;
|
|
1173
|
+
const read = [...preambles, ...host.shown];
|
|
1174
|
+
const joined = (parts: string[]): string | null => (parts.length === 0 ? null : parts.join('\n\n'));
|
|
1175
|
+
return { preamble: joined([...read, ...host.heard]), note: joined(read) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private submit(turnId: string, input: TurnInput): void {
|
|
1179
|
+
this.pendingPreambles = this.preambles;
|
|
1180
|
+
this.submission = { turnId, input: { ...input, promptId: randomUUID() } };
|
|
1181
|
+
const submission = this.submission;
|
|
1182
|
+
this.options.persist();
|
|
1183
|
+
this.run((backend) => {
|
|
1184
|
+
backend.sendTurn(submission.input);
|
|
1185
|
+
if (!backend.acknowledgesTurns) {
|
|
1186
|
+
this.acceptPrompt(submission.input.promptId!);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private acceptPrompt(promptId: string): void {
|
|
1192
|
+
const submission = this.submission;
|
|
1193
|
+
if (submission?.input.promptId !== promptId) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.submission = null;
|
|
1197
|
+
const turn = this.thread.get(submission.turnId);
|
|
1198
|
+
if (turn?.kind === 'turn' && (turn.deliveryPending || turn.resumePending)) {
|
|
1199
|
+
this.emit([this.thread.upsert({ ...turn, deliveryPending: undefined, resumePending: undefined })]);
|
|
1200
|
+
}
|
|
1201
|
+
this.options.persist();
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/* Runs one turn against the backend; a backend that will not start ends the turn with the reason. */
|
|
1205
|
+
private run(work: (backend: ChatBackend) => void): void {
|
|
1206
|
+
const turnId = this.info.activeTurnId;
|
|
1207
|
+
if (turnId === null) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const pending = this.watchStart(turnId);
|
|
1211
|
+
const backend = this.ensureBackend();
|
|
1212
|
+
const generation = this.generation;
|
|
1213
|
+
void Promise.all([backend, this.turnReady, this.options.save?.()])
|
|
1214
|
+
.then(([started]) => {
|
|
1215
|
+
if (this.pendingStart !== pending || this.backend !== started || this.info.activeTurnId !== turnId || this.frozen) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
this.clearStartWarning();
|
|
1219
|
+
work(started);
|
|
1220
|
+
})
|
|
1221
|
+
.catch((error: unknown) => {
|
|
1222
|
+
if (this.pendingStart === pending || (this.backend !== null && this.generation === generation && this.info.activeTurnId === turnId)) {
|
|
1223
|
+
this.clearStartWarning();
|
|
1224
|
+
if (error instanceof ChatError && error.code === 'chat-busy') {
|
|
1225
|
+
this.pendingPreambles = this.preambles;
|
|
1226
|
+
this.submission = null;
|
|
1227
|
+
this.pauseQueue(true);
|
|
1228
|
+
this.emit(this.projector.project(generation, { type: 'turn.done', state: 'error', costUsd: 0, error: error.message }));
|
|
1229
|
+
this.settleCheckpoint(turnId);
|
|
1230
|
+
this.options.persist();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
this.receive(generation, { type: 'failed', message: errorText(error), processAlive: this.running });
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private watchStart(turnId: string): { turnId: string; warning: unknown } {
|
|
1239
|
+
this.clearStartWarning();
|
|
1240
|
+
const clock = this.options.clock ?? systemClock;
|
|
1241
|
+
const pending = {
|
|
1242
|
+
turnId,
|
|
1243
|
+
warning: clock.setTimeout(() => {
|
|
1244
|
+
if (this.pendingStart?.turnId === turnId && this.info.activeTurnId === turnId && !this.frozen) {
|
|
1245
|
+
this.emit([
|
|
1246
|
+
this.thread.upsert({
|
|
1247
|
+
id: newId('note'),
|
|
1248
|
+
kind: 'note',
|
|
1249
|
+
createdAt: clock.now(),
|
|
1250
|
+
turnId,
|
|
1251
|
+
level: 'warning',
|
|
1252
|
+
text: 'The agent is still starting. You can stop this turn and try again.'
|
|
1253
|
+
})
|
|
1254
|
+
]);
|
|
1255
|
+
this.options.persist();
|
|
1256
|
+
}
|
|
1257
|
+
}, 30_000)
|
|
1258
|
+
};
|
|
1259
|
+
this.pendingStart = pending;
|
|
1260
|
+
return pending;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
private clearStartWarning(): void {
|
|
1264
|
+
if (this.pendingStart === null) {
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
(this.options.clock ?? systemClock).clearTimeout(this.pendingStart.warning);
|
|
1268
|
+
this.pendingStart = null;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private requiresReplacement(): boolean {
|
|
1272
|
+
return (
|
|
1273
|
+
this.backend !== null &&
|
|
1274
|
+
(JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
|
|
1275
|
+
this.info.runtimeMode !== this.launchedRuntimeMode ||
|
|
1276
|
+
this.info.account !== this.launchedAccount ||
|
|
1277
|
+
!sameFolders(this.options.folders?.() ?? [], this.launchedFolders))
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
private hasNativeWork(): boolean {
|
|
1282
|
+
return runningInBackground(this.thread.list()).length > 0 || (this.info.background?.length ?? 0) > 0;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
private processInUse(): boolean {
|
|
1286
|
+
return this.hasNativeWork() || this.thread.pending().length > 0 || this.awaitingBackgroundReport;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
private replacementWaits(): boolean {
|
|
1290
|
+
return this.requiresReplacement() && (this.info.activeTurnId !== null || this.processInUse());
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
private ensureBackend(): Promise<ChatBackend> {
|
|
1294
|
+
if (this.backend && this.requiresReplacement()) {
|
|
1295
|
+
if (this.processInUse()) {
|
|
1296
|
+
return Promise.reject(new ChatError('chat-busy', 'The current work and open requests must finish before the changes to this chat can apply'));
|
|
1297
|
+
}
|
|
1298
|
+
// Let the old one go quietly; its events are dropped and its exit must not end the chat.
|
|
1299
|
+
this.backend.stop();
|
|
1300
|
+
this.backend = null;
|
|
1301
|
+
this.starting = null;
|
|
1302
|
+
}
|
|
1303
|
+
if (this.backend && this.starting) {
|
|
1304
|
+
return this.starting;
|
|
1305
|
+
}
|
|
1306
|
+
let env: Record<string, string>;
|
|
1307
|
+
try {
|
|
1308
|
+
env = this.options.env(this.thread.info.account);
|
|
1309
|
+
this.launchedAccount = this.thread.info.account;
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1312
|
+
}
|
|
1313
|
+
this.generation = nextGeneration();
|
|
1314
|
+
const generation = this.generation;
|
|
1315
|
+
const info = this.thread.info;
|
|
1316
|
+
this.launchedSelection = info.selection;
|
|
1317
|
+
this.launchedRuntimeMode = info.runtimeMode;
|
|
1318
|
+
const folders = this.options.folders?.() ?? [];
|
|
1319
|
+
this.launchedFolders = folders;
|
|
1320
|
+
const launch: BackendLaunch = {
|
|
1321
|
+
command: this.options.command,
|
|
1322
|
+
cwd: info.cwd,
|
|
1323
|
+
env,
|
|
1324
|
+
selection: info.selection,
|
|
1325
|
+
modelName: this.options.provider.catalog.nameOf(info.selection.model),
|
|
1326
|
+
runtimeMode: info.runtimeMode,
|
|
1327
|
+
resume: info.agentSessionId,
|
|
1328
|
+
generation,
|
|
1329
|
+
instructions: this.options.instructions?.() ?? null,
|
|
1330
|
+
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1331
|
+
...(folders.length > 0 ? { folders } : {}),
|
|
1332
|
+
...(this.options.spawn ? { spawn: this.options.spawn } : {})
|
|
1333
|
+
};
|
|
1334
|
+
const made: { backend: ChatBackend | null } = { backend: null };
|
|
1335
|
+
try {
|
|
1336
|
+
made.backend = this.options.provider.createBackend(launch, {
|
|
1337
|
+
onEvent: (event) => {
|
|
1338
|
+
if (made.backend === this.backend) {
|
|
1339
|
+
this.receive(generation, event);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1345
|
+
}
|
|
1346
|
+
const backend = made.backend;
|
|
1347
|
+
this.backend = backend;
|
|
1348
|
+
this.emit([this.thread.patchInfo({ running: true, effectiveRuntimeMode: undefined, permissionMode: undefined })]);
|
|
1349
|
+
let started: Promise<void>;
|
|
1350
|
+
try {
|
|
1351
|
+
started = backend.start();
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
// A spawn that throws on the spot (no such executable) is a start that failed like any other.
|
|
1354
|
+
started = Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1355
|
+
}
|
|
1356
|
+
this.starting = started
|
|
1357
|
+
.then(() => backend)
|
|
1358
|
+
.catch((error: unknown) => {
|
|
1359
|
+
// A CLI that will not start leaves nothing to talk to; the next send tries again.
|
|
1360
|
+
if (this.backend === backend) {
|
|
1361
|
+
this.backend = null;
|
|
1362
|
+
this.starting = null;
|
|
1363
|
+
}
|
|
1364
|
+
throw error;
|
|
1365
|
+
});
|
|
1366
|
+
return this.starting;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private awaitsResume(turnId: string, attempt: number): boolean {
|
|
1370
|
+
const turn = this.thread.get(turnId);
|
|
1371
|
+
return (
|
|
1372
|
+
!this.frozen &&
|
|
1373
|
+
turn?.kind === 'turn' &&
|
|
1374
|
+
turn.state === 'running' &&
|
|
1375
|
+
this.thread.info.activeTurnId === turnId &&
|
|
1376
|
+
((turn.attempt ?? 1) < attempt || (turn.attempt === attempt && turn.resumePending === true))
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
private receive(generation: number, event: BackendEvent): void {
|
|
1381
|
+
if (this.frozen) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (event.type === 'turn.accepted') {
|
|
1385
|
+
this.acceptPrompt(event.promptId);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (event.type === 'limits') {
|
|
1389
|
+
this.options.onLimits?.(this.launchedAccount === undefined ? event.update : { ...event.update, account: this.launchedAccount });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (event.type === 'title') {
|
|
1393
|
+
this.applyTitle(event.title);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
/*
|
|
1397
|
+
* A CLI reports the window it was started on. A pick made since waits for the restart the next
|
|
1398
|
+
* send does, so its report may not put the old size back under a meter that already reads the new one.
|
|
1399
|
+
*/
|
|
1400
|
+
if (event.type === 'usage' && event.contextWindow !== undefined && !this.reportsCurrentWindow()) {
|
|
1401
|
+
if (event.contextTokens === undefined) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
this.emit(this.projector.project(generation, { type: 'usage', contextTokens: event.contextTokens }));
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (event.type === 'exit') {
|
|
1408
|
+
this.backend = null;
|
|
1409
|
+
this.starting = null;
|
|
1410
|
+
this.awaitingBackgroundReport = false;
|
|
1411
|
+
}
|
|
1412
|
+
// A turn we settled ourselves still has its own `result` coming; it may not close the turn after it.
|
|
1413
|
+
if (event.type === 'turn.done' && event.promptId === undefined && this.staleResults > 0) {
|
|
1414
|
+
this.staleResults -= 1;
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (event.type === 'exit' || event.type === 'failed') {
|
|
1418
|
+
this.staleResults = 0;
|
|
1419
|
+
}
|
|
1420
|
+
if (event.type === 'turn.done') {
|
|
1421
|
+
if (event.promptId !== undefined && this.submission !== null && this.submission.input.promptId !== event.promptId) {
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (event.promptId !== undefined) {
|
|
1425
|
+
this.staleResults = 0;
|
|
1426
|
+
}
|
|
1427
|
+
const promptId = event.promptId ?? this.submission?.input.promptId;
|
|
1428
|
+
if (promptId !== undefined) {
|
|
1429
|
+
this.acceptPrompt(promptId);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const openTurnId = this.thread.info.activeTurnId;
|
|
1433
|
+
if ((event.type === 'turn.done' && event.state !== 'done') || ((event.type === 'failed' || event.type === 'exit') && openTurnId !== null)) {
|
|
1434
|
+
this.pauseQueue(true);
|
|
1435
|
+
}
|
|
1436
|
+
// Settled as failed by the exit below; the transcript may still show that they finished first.
|
|
1437
|
+
const orphaned = event.type === 'exit' && this.info.provider === 'claude' ? this.runningBackgroundRows() : [];
|
|
1438
|
+
if (event.type === 'turn.done' && event.state === 'aborted') {
|
|
1439
|
+
// A request of work beside the turn outlives the interrupt; turned down, that work goes on instead of waiting forever.
|
|
1440
|
+
for (const item of this.thread.pending()) {
|
|
1441
|
+
this.backend?.declineRequest?.(item.requestId, 'The user stopped the turn');
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const hadNativeWork = (event.type === 'task.done' || event.type === 'tool.done' || event.type === 'background.ended') && this.hasNativeWork();
|
|
1445
|
+
const projected = this.projector.project(generation, event);
|
|
1446
|
+
if (event.type === 'turn.done' || (event.type === 'failed' && event.processAlive !== true)) {
|
|
1447
|
+
this.awaitingBackgroundReport = false;
|
|
1448
|
+
} else if (event.type !== 'exit' && event.type !== 'failed' && hadNativeWork && !this.hasNativeWork() && reportsOnBackgroundWork(this.info.provider)) {
|
|
1449
|
+
this.awaitingBackgroundReport = true;
|
|
1450
|
+
}
|
|
1451
|
+
this.emit(projected);
|
|
1452
|
+
this.watchWorkflows(event);
|
|
1453
|
+
if (isMainAgentOutput(event)) {
|
|
1454
|
+
this.spokenTurnId = this.thread.info.activeTurnId;
|
|
1455
|
+
}
|
|
1456
|
+
if (orphaned.length > 0) {
|
|
1457
|
+
void this.settleFromTranscripts(orphaned.flatMap((row) => this.thread.get(row.id) ?? []).filter((item) => item.kind === 'subagent'));
|
|
1458
|
+
}
|
|
1459
|
+
const activeTurnId = this.thread.info.activeTurnId;
|
|
1460
|
+
if (openTurnId === null && activeTurnId !== null) {
|
|
1461
|
+
// The CLI opened this turn itself; it takes a checkpoint like any other, so the card can
|
|
1462
|
+
// show what the agent changed while nobody was watching.
|
|
1463
|
+
this.turnOpened();
|
|
1464
|
+
void this.checkpoint(activeTurnId);
|
|
1465
|
+
this.options.persist();
|
|
1466
|
+
}
|
|
1467
|
+
if (event.type === 'turn.done' || event.type === 'exit' || event.type === 'failed') {
|
|
1468
|
+
this.options.persist();
|
|
1469
|
+
}
|
|
1470
|
+
if (event.type === 'permissions' || event.type === 'session') {
|
|
1471
|
+
this.options.persist();
|
|
1472
|
+
}
|
|
1473
|
+
if (openTurnId !== null && activeTurnId === null) {
|
|
1474
|
+
this.settleCheckpoint(openTurnId);
|
|
1475
|
+
}
|
|
1476
|
+
if (event.type === 'session' && typeof event.title === 'string') {
|
|
1477
|
+
this.applyTitle(event.title);
|
|
1478
|
+
}
|
|
1479
|
+
if (event.type === 'session' && this.thread.info.agentSessionId !== null) {
|
|
1480
|
+
this.refreshTitle();
|
|
1481
|
+
if (this.titleTimer === null && this.thread.info.suggestedTitle === undefined) {
|
|
1482
|
+
this.titleTimer = setTimeout(() => {
|
|
1483
|
+
this.titleTimer = null;
|
|
1484
|
+
this.refreshTitle();
|
|
1485
|
+
}, TITLE_RECHECK_MS);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// The turn is over and the CLI is still there, so whatever waited behind it can go out now.
|
|
1489
|
+
if (event.type === 'turn.done') {
|
|
1490
|
+
this.refreshTitle();
|
|
1491
|
+
if (event.state === 'done') {
|
|
1492
|
+
this.nameThread();
|
|
1493
|
+
}
|
|
1494
|
+
if (event.limit !== undefined) {
|
|
1495
|
+
this.oweResume(true);
|
|
1496
|
+
}
|
|
1497
|
+
this.drainQueue(event.state === 'aborted' && this.sendAfterStop);
|
|
1498
|
+
this.sendAfterStop = false;
|
|
1499
|
+
} else if (
|
|
1500
|
+
event.type === 'request.withdrawn' ||
|
|
1501
|
+
(!reportsOnBackgroundWork(this.info.provider) && (event.type === 'task.done' || event.type === 'tool.done' || event.type === 'background.ended'))
|
|
1502
|
+
) {
|
|
1503
|
+
this.drainQueue();
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
private pauseQueue(paused: boolean): void {
|
|
1508
|
+
if ((this.info.queuePaused ?? false) !== paused) {
|
|
1509
|
+
this.emit([this.thread.patchInfo({ queuePaused: paused })]);
|
|
1510
|
+
this.options.persist();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private clearWorkflowWarnings(): void {
|
|
1515
|
+
const clock = this.options.clock ?? systemClock;
|
|
1516
|
+
for (const timer of this.workflowWarnings.values()) {
|
|
1517
|
+
clock.clearTimeout(timer);
|
|
1518
|
+
}
|
|
1519
|
+
this.workflowWarnings.clear();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
private watchWorkflows(event: BackendEvent): void {
|
|
1523
|
+
if (
|
|
1524
|
+
!['tool.started', 'tool.progress', 'tool.output', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(
|
|
1525
|
+
event.type
|
|
1526
|
+
)
|
|
1527
|
+
) {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const clock = this.options.clock ?? systemClock;
|
|
1531
|
+
const ref = 'ref' in event ? event.ref : null;
|
|
1532
|
+
for (const item of this.thread.list()) {
|
|
1533
|
+
if (item.kind !== 'tool' || item.name !== 'Workflow') {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
const existing = this.workflowWarnings.get(item.id);
|
|
1537
|
+
const progress =
|
|
1538
|
+
ref === item.toolUseId &&
|
|
1539
|
+
['workflow.progress', 'tool.started', 'tool.progress', 'tool.output', 'tool.done', 'task.started'].includes(event.type);
|
|
1540
|
+
if (item.state !== 'running' || progress) {
|
|
1541
|
+
if (existing !== undefined) {
|
|
1542
|
+
clock.clearTimeout(existing);
|
|
1543
|
+
this.workflowWarnings.delete(item.id);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
if (progress) {
|
|
1550
|
+
this.emit([
|
|
1551
|
+
this.thread.upsert({
|
|
1552
|
+
...item,
|
|
1553
|
+
workflow: { name: null, phases: [], agents: [], ...item.workflow, lastProgressAt: clock.now(), stalledAt: undefined }
|
|
1554
|
+
})
|
|
1555
|
+
]);
|
|
1556
|
+
}
|
|
1557
|
+
this.workflowWarnings.set(
|
|
1558
|
+
item.id,
|
|
1559
|
+
clock.setTimeout(() => {
|
|
1560
|
+
this.workflowWarnings.delete(item.id);
|
|
1561
|
+
const current = this.thread.get(item.id);
|
|
1562
|
+
if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
this.emit([
|
|
1566
|
+
this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })
|
|
1567
|
+
]);
|
|
1568
|
+
this.options.persist();
|
|
1569
|
+
}, 5 * 60_000)
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/* Whether the CLI that reports was started on a pick standing for the same window as the chat's own. */
|
|
1575
|
+
private reportsCurrentWindow(): boolean {
|
|
1576
|
+
const catalog = this.options.provider.catalog;
|
|
1577
|
+
return this.launchedSelection === null || catalog.contextWindowFor(this.launchedSelection) === catalog.contextWindowFor(this.thread.info.selection);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/* Rides on the info patch: a client that has the chat open hears it, and a reload finds it stored. */
|
|
1581
|
+
private refreshTitle(): void {
|
|
1582
|
+
const agentSessionId = this.thread.info.agentSessionId;
|
|
1583
|
+
if (!this.options.readTitle || agentSessionId === null) {
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
void this.options
|
|
1587
|
+
.readTitle(agentSessionId)
|
|
1588
|
+
.then((title) => {
|
|
1589
|
+
// A clear in between started another session, whose name this is not.
|
|
1590
|
+
if (title === null || title === this.thread.info.suggestedTitle || this.thread.info.agentSessionId !== agentSessionId) {
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
this.emit([this.thread.patchInfo({ suggestedTitle: title })]);
|
|
1594
|
+
this.options.persistSoon();
|
|
1595
|
+
})
|
|
1596
|
+
.catch(() => undefined);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private applyTitle(title: string): void {
|
|
1600
|
+
if (title === this.thread.info.suggestedTitle) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
this.emit([this.thread.patchInfo({ suggestedTitle: title })]);
|
|
1604
|
+
this.options.persistSoon();
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/*
|
|
1608
|
+
* Names a chat whose CLI names nothing, after its first turn that went well: the prompt alone is
|
|
1609
|
+
* often too little to name, and the start of the answer says what the conversation became. Once,
|
|
1610
|
+
* and not for a thread that already has a name, as a resumed one may.
|
|
1611
|
+
*/
|
|
1612
|
+
private nameThread(): void {
|
|
1613
|
+
const name = this.options.nameThread;
|
|
1614
|
+
if (!name || this.naming || this.thread.info.suggestedTitle !== undefined) {
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const items = this.thread.list();
|
|
1618
|
+
const done = items.filter((item) => item.kind === 'turn' && item.state === 'done');
|
|
1619
|
+
const prompt = items.find((item) => item.kind === 'user');
|
|
1620
|
+
if (done.length !== 1 || prompt?.kind !== 'user' || prompt.text.trim() === '') {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
this.naming = true;
|
|
1624
|
+
const turnId = done[0]!.id;
|
|
1625
|
+
const answer = items
|
|
1626
|
+
.map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
|
|
1627
|
+
.filter((text) => text !== '')
|
|
1628
|
+
.join('\n\n');
|
|
1629
|
+
const agentSessionId = this.thread.info.agentSessionId;
|
|
1630
|
+
void name({ cwd: this.thread.info.cwd, prompt: prompt.text, answer })
|
|
1631
|
+
.then((title) => {
|
|
1632
|
+
// A clear in between started another conversation, and a name that came meanwhile is the CLI's own.
|
|
1633
|
+
if (title === null || this.thread.info.agentSessionId !== agentSessionId || this.thread.info.suggestedTitle !== undefined) {
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
this.applyTitle(title);
|
|
1637
|
+
this.backend?.setTitle?.(title);
|
|
1638
|
+
})
|
|
1639
|
+
.catch(() => undefined);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
private runningBackgroundRows(): ChatSubagentItem[] {
|
|
1643
|
+
return runningInBackground(this.thread.list()).filter((item) => item.kind === 'subagent');
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/* Done for every row whose transcript shows the end, unless something else settled the row while it was read. */
|
|
1647
|
+
private async settleFromTranscripts(rows: ChatSubagentItem[]): Promise<void> {
|
|
1648
|
+
const lookup = this.options.subagentSettlement;
|
|
1649
|
+
if (!lookup || rows.length === 0) {
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const settled: ChatSubagentItem[] = [];
|
|
1653
|
+
for (const row of rows) {
|
|
1654
|
+
const settlement = await lookup(row.toolUseId);
|
|
1655
|
+
const current = this.thread.get(row.id);
|
|
1656
|
+
if (settlement === null || current?.kind !== 'subagent' || current.status !== row.status || current.finishedAt !== row.finishedAt) {
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
this.orphans.delete(row.toolUseId);
|
|
1660
|
+
settled.push({
|
|
1661
|
+
...current,
|
|
1662
|
+
status: 'done',
|
|
1663
|
+
finishedAt: settlement.finishedAt ?? current.finishedAt ?? Date.now(),
|
|
1664
|
+
result: settlement.report ?? current.result
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
this.settleRows(settled);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private settleRows(rows: ChatSubagentItem[]): void {
|
|
1671
|
+
if (this.frozen || rows.length === 0) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
this.emit(rows.map((row) => this.thread.upsert(row)));
|
|
1675
|
+
this.options.persist();
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
private emit(events: ChatEvent[]): void {
|
|
1679
|
+
const delegating = this.delegatingChange(events);
|
|
1680
|
+
for (const event of delegating === null ? events : [...events, delegating]) {
|
|
1681
|
+
this.options.emit(event);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
|
|
1686
|
+
private delegatingChange(events: ChatEvent[]): ChatEvent | null {
|
|
1687
|
+
if (!events.some((event) => event.type === 'item' && isBackgroundWork(event.item))) {
|
|
1688
|
+
return null;
|
|
1689
|
+
}
|
|
1690
|
+
const delegating = runningInBackground(this.thread.list()).length > 0;
|
|
1691
|
+
if (delegating === (this.thread.info.delegating ?? false)) {
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
return this.thread.patchInfo({ delegating: delegating || undefined });
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/* The turn a restart takes up again, or else why the turn the host went down in ends (null when none was running). */
|
|
1699
|
+
export interface ResumeDecision {
|
|
1700
|
+
resumeTurnId: string | null;
|
|
1701
|
+
reason: string | null;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Whatever was open when the host went down: nobody is going to answer it now.
|
|
1705
|
+
function settleStoredItem(item: ChatItem, resumeTurnId: string | null): ChatItem {
|
|
1706
|
+
if (item.kind === 'assistant' && item.streaming) {
|
|
1707
|
+
return { ...item, streaming: false };
|
|
1708
|
+
}
|
|
1709
|
+
if (item.kind === 'approval' && item.decision === 'pending') {
|
|
1710
|
+
return { ...item, decision: 'cancelled' };
|
|
1711
|
+
}
|
|
1712
|
+
if (item.kind === 'question' && item.state === 'pending') {
|
|
1713
|
+
return { ...item, state: 'cancelled' };
|
|
1714
|
+
}
|
|
1715
|
+
if (item.kind === 'tool' && item.state === 'running') {
|
|
1716
|
+
return { ...item, state: 'error' };
|
|
1717
|
+
}
|
|
1718
|
+
// Aborted rather than error: the turn did not fail, the machine went down under it.
|
|
1719
|
+
if (item.kind === 'turn' && item.state === 'running' && item.id !== resumeTurnId) {
|
|
1720
|
+
return { ...item, state: 'aborted', endedAt: item.endedAt ?? item.createdAt };
|
|
1721
|
+
}
|
|
1722
|
+
return item;
|
|
1723
|
+
}
|