@adecore/agents 0.0.1 → 0.17.0-beta.1
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 +33 -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,1228 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
ChatAttachmentUploadsSchema,
|
|
5
|
+
type AgentKind,
|
|
6
|
+
type ChatAttachment,
|
|
7
|
+
type ChatAttachResult,
|
|
8
|
+
type ChatAttachmentUpload,
|
|
9
|
+
type ChatBookmark,
|
|
10
|
+
type ChatCheckpointDiff,
|
|
11
|
+
type ChatConfigurePayload,
|
|
12
|
+
type ChatCreatePayload,
|
|
13
|
+
type ChatEvent,
|
|
14
|
+
type ChatHistoryResult,
|
|
15
|
+
type ChatInfo,
|
|
16
|
+
type ChatItem,
|
|
17
|
+
type ChatQueuedMessage,
|
|
18
|
+
type ChatSkill,
|
|
19
|
+
type ChatSubagentPayload,
|
|
20
|
+
type ChatSubagentResult,
|
|
21
|
+
type ChatTurnItem,
|
|
22
|
+
type ModelSelection,
|
|
23
|
+
type RuntimeMode
|
|
24
|
+
} from '@adecore/agent-contracts';
|
|
25
|
+
import { ClientSinks } from '../client-sinks.ts';
|
|
26
|
+
import { errorText } from '../error-text.ts';
|
|
27
|
+
import type { AgentEvent, AgentSink } from '../events.ts';
|
|
28
|
+
import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
|
|
29
|
+
import { AccountError, definedEnv, isDefaultAccountOf, launchEnv, storedAccount, type AccountLaunches } from '../providers/accounts/launch.ts';
|
|
30
|
+
import type { ProviderRegistry } from '../providers/registry.ts';
|
|
31
|
+
import { SkillIndex } from '../skills.ts';
|
|
32
|
+
import type { LimitsUpdate } from '../usage/limits/normalize.ts';
|
|
33
|
+
import { usageRoots } from '../usage/roots.ts';
|
|
34
|
+
import type { AttachmentStore } from './attachment-store.ts';
|
|
35
|
+
import type { BookmarkStore } from './bookmark-store.ts';
|
|
36
|
+
import { ChatLog, COMPACT_ABOVE_BYTES } from './chat-log.ts';
|
|
37
|
+
import type { SpawnChatProcess } from './chat-process.ts';
|
|
38
|
+
import {
|
|
39
|
+
ChatSession,
|
|
40
|
+
type ChatReferences,
|
|
41
|
+
type ChatSendExtras,
|
|
42
|
+
type LimitResumeHooks,
|
|
43
|
+
type PromptNotes,
|
|
44
|
+
type ResumeDecision,
|
|
45
|
+
type ResumeWords,
|
|
46
|
+
type TurnCheckpoints
|
|
47
|
+
} from './chat-session.ts';
|
|
48
|
+
import type { ChatRecord, ChatRecordExtras, ChatStore } from './chat-store.ts';
|
|
49
|
+
import type { ChatTitleInput } from './chat-title.ts';
|
|
50
|
+
import type { CodexProcessSpec } from './codex-thread.ts';
|
|
51
|
+
import type { CodexClientInfo } from './codex-transport.ts';
|
|
52
|
+
import { ComposerPreferences } from './composer-preferences.ts';
|
|
53
|
+
import { DeltaCoalescer } from './delta-coalescer.ts';
|
|
54
|
+
import { ChatError } from './errors.ts';
|
|
55
|
+
import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
|
|
56
|
+
import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
|
|
57
|
+
|
|
58
|
+
/* A turn that was running when the host went down, and the attempt that would take it up again. */
|
|
59
|
+
export interface InterruptedRun {
|
|
60
|
+
chatId: string;
|
|
61
|
+
turnId: string;
|
|
62
|
+
attempt: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type BookmarkableItem = Extract<ChatItem, { kind: 'user' | 'assistant' }>;
|
|
66
|
+
|
|
67
|
+
/* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
|
|
68
|
+
function withoutHidden(info: ChatInfo): ChatInfo {
|
|
69
|
+
const { hidden: _hidden, ...rest } = info;
|
|
70
|
+
return rest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isBookmarkable(item: ChatItem): item is BookmarkableItem {
|
|
74
|
+
return item.kind === 'user' || (item.kind === 'assistant' && (item.parentToolUseId ?? null) === null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The start of a message on one line; a message of only attachments is its file names. */
|
|
78
|
+
function excerptOf(item: BookmarkableItem): string {
|
|
79
|
+
const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
|
|
80
|
+
return (item.text.trim() === '' ? files : item.text).replace(/\s+/g, ' ').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One process worked on the turn and one more may take it up after a restart; a loop of resumes could redo a command forever.
|
|
84
|
+
const MAX_ATTEMPTS = 2;
|
|
85
|
+
|
|
86
|
+
// Above this the record is big enough that rewriting it for every small change costs more than it saves.
|
|
87
|
+
const DEBOUNCE_ABOVE_BYTES = 256 * 1024;
|
|
88
|
+
const DEBOUNCE_MS = 500;
|
|
89
|
+
|
|
90
|
+
const DEFAULT_RESUME_WORDS: ResumeWords = {
|
|
91
|
+
prompt: 'The app restarted while you were working on the previous message. Continue where you left off.',
|
|
92
|
+
note: 'Resumed after a restart',
|
|
93
|
+
preamble: null
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export interface ChatCoreOptions {
|
|
97
|
+
providers: ProviderRegistry;
|
|
98
|
+
// Where the threads are kept; without it a chat lives as long as the process.
|
|
99
|
+
store?: ChatStore;
|
|
100
|
+
// Takes a tree per turn; without it a turn has no checkpoint and the card falls back to the CLI's own changes.
|
|
101
|
+
checkpoints?: TurnCheckpoints;
|
|
102
|
+
// The environment every CLI starts in, `cliEnvironment` for one that holds nothing of another host.
|
|
103
|
+
env?: Record<string, string | undefined>;
|
|
104
|
+
// What the agent is told once, at the start of every process, when the host has one note for every chat.
|
|
105
|
+
instructions?: string;
|
|
106
|
+
// The CLIs to run, when they are not the ones the providers name; a test points these at fakes.
|
|
107
|
+
command?: string[];
|
|
108
|
+
codexCommand?: string[];
|
|
109
|
+
// How a CLI is started; a test runs a fake in the same process instead of spawning one.
|
|
110
|
+
spawn?: SpawnChatProcess;
|
|
111
|
+
// How the host names itself to an app-server it opens for a question about a thread.
|
|
112
|
+
codexClient?: CodexClientInfo;
|
|
113
|
+
// Where the skill folders are looked for; a test points it at a temporary tree.
|
|
114
|
+
skills?: SkillIndex;
|
|
115
|
+
// Where the files people attach are written.
|
|
116
|
+
attachments: AttachmentStore;
|
|
117
|
+
bookmarks?: BookmarkStore;
|
|
118
|
+
// What a running turn says about the plan its CLI runs on; the usage monitor takes it from here.
|
|
119
|
+
onLimits?: (update: LimitsUpdate) => void;
|
|
120
|
+
// Where Claude Code's own name for a session is read, in the projects folder of the chat's account; the other CLIs write none down.
|
|
121
|
+
claudeTitles?: { forSession(agentSessionId: string, projectsDir: string): Promise<string | null> };
|
|
122
|
+
// A name for a Codex chat, asked of a one-shot CLI: its app-server names no thread on its own.
|
|
123
|
+
nameChat?: (provider: AgentKind, input: ChatTitleInput) => Promise<string | null>;
|
|
124
|
+
// Where a subagent's whole conversation is read and how its growth is noticed; a test hands in fakes.
|
|
125
|
+
subagents?: Partial<Pick<SubagentReaderOptions, 'claudeProjectsDir' | 'seams' | 'now' | 'listOnce'>>;
|
|
126
|
+
/*
|
|
127
|
+
* Asked when a chat is loaded with a turn that could be resumed: true once the resume is owed,
|
|
128
|
+
* and the turn stays running. False, or without it, the turn ends aborted with a note that says why.
|
|
129
|
+
*/
|
|
130
|
+
onInterruptedRun?: (run: InterruptedRun) => Promise<boolean>;
|
|
131
|
+
// Where a turn that stopped on a limit is owed a resume on a clock, when the host allows it.
|
|
132
|
+
limitResume?: {
|
|
133
|
+
allowed(): boolean;
|
|
134
|
+
now(): number;
|
|
135
|
+
owe(chatId: string, turnId: string, at: number): Promise<void>;
|
|
136
|
+
lapse(chatId: string): Promise<void>;
|
|
137
|
+
// Whether an entry is owed for this chat now.
|
|
138
|
+
owed(chatId: string): boolean;
|
|
139
|
+
};
|
|
140
|
+
// The accounts a chat may run under; without them every chat runs under its CLI's default account.
|
|
141
|
+
accounts?: AccountLaunches;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* The chats of one host: a `ChatSession` per chat, its record and its log, and the clients that read
|
|
146
|
+
* them. Everything here holds for any host. What a host adds (notes for the agent, rows of its own,
|
|
147
|
+
* what a clear or a removal takes along) it adds by overriding the protected methods below, which
|
|
148
|
+
* do nothing of their own.
|
|
149
|
+
*/
|
|
150
|
+
export class ChatCore {
|
|
151
|
+
protected readonly providers: ProviderRegistry;
|
|
152
|
+
protected readonly store: ChatStore | null;
|
|
153
|
+
protected readonly bookmarks: BookmarkStore | null;
|
|
154
|
+
protected readonly accounts: AccountLaunches | null;
|
|
155
|
+
protected readonly attachments: AttachmentStore;
|
|
156
|
+
protected readonly env: Record<string, string>;
|
|
157
|
+
protected readonly chats = new Map<string, ChatSession>();
|
|
158
|
+
protected readonly creating = new Map<string, Promise<ChatInfo>>();
|
|
159
|
+
protected readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.composerPreferences.forget(clientId));
|
|
160
|
+
protected readonly attached = new Map<string, Set<string>>();
|
|
161
|
+
protected readonly coalescers = new Map<string, DeltaCoalescer>();
|
|
162
|
+
protected readonly subagents: SubagentReader;
|
|
163
|
+
/* Where Claude Code keeps the projects of its default account on this machine; empty when it has none. */
|
|
164
|
+
readonly claudeProjectsDir: string;
|
|
165
|
+
readonly composerPreferences = new ComposerPreferences();
|
|
166
|
+
private readonly checkpoints: TurnCheckpoints | null;
|
|
167
|
+
private readonly instructions: string | null;
|
|
168
|
+
private readonly skillIndex: SkillIndex;
|
|
169
|
+
private readonly onLimits: ((update: LimitsUpdate) => void) | null;
|
|
170
|
+
private readonly commands: Partial<Record<AgentKind, string[]>>;
|
|
171
|
+
private readonly spawn: SpawnChatProcess | null;
|
|
172
|
+
private readonly codexClient: CodexClientInfo | null;
|
|
173
|
+
// What `chat.status` last said about each chat, so the broadcast is one per change and not one per info event.
|
|
174
|
+
private readonly announced = new Map<string, string>();
|
|
175
|
+
private readonly logs = new Map<string, ChatLog>();
|
|
176
|
+
// How big each record was the last time it went to disk, and the writes waiting for a big one.
|
|
177
|
+
private readonly sizes = new Map<string, number>();
|
|
178
|
+
private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
|
|
179
|
+
// What the last record held that no event carries (preambles, the host's extras), as its JSON.
|
|
180
|
+
private readonly unlogged = new Map<string, string>();
|
|
181
|
+
// The write in flight per chat, so the next one queues behind it instead of racing it.
|
|
182
|
+
private readonly writes = new Map<string, Promise<void>>();
|
|
183
|
+
// When each chat last said anything, which is what a chat has instead of a hook event.
|
|
184
|
+
private readonly activity = new Map<string, number>();
|
|
185
|
+
private readonly observers = new Set<AgentSink>();
|
|
186
|
+
private readonly claudeTitles: ChatCoreOptions['claudeTitles'] | null;
|
|
187
|
+
private readonly nameChat: ChatCoreOptions['nameChat'] | null;
|
|
188
|
+
private readonly onInterruptedRun: ChatCoreOptions['onInterruptedRun'] | null;
|
|
189
|
+
private readonly limitResume: ChatCoreOptions['limitResume'] | null;
|
|
190
|
+
|
|
191
|
+
constructor(options: ChatCoreOptions) {
|
|
192
|
+
this.providers = options.providers;
|
|
193
|
+
this.store = options.store ?? null;
|
|
194
|
+
this.bookmarks = options.bookmarks ?? null;
|
|
195
|
+
this.accounts = options.accounts ?? null;
|
|
196
|
+
this.attachments = options.attachments;
|
|
197
|
+
this.checkpoints = options.checkpoints ?? null;
|
|
198
|
+
this.instructions = options.instructions ?? null;
|
|
199
|
+
this.skillIndex = options.skills ?? new SkillIndex();
|
|
200
|
+
this.onLimits = options.onLimits ?? null;
|
|
201
|
+
this.claudeTitles = options.claudeTitles ?? null;
|
|
202
|
+
this.nameChat = options.nameChat ?? null;
|
|
203
|
+
this.onInterruptedRun = options.onInterruptedRun ?? null;
|
|
204
|
+
this.limitResume = options.limitResume ?? null;
|
|
205
|
+
this.commands = {
|
|
206
|
+
...(options.command ? { claude: options.command } : {}),
|
|
207
|
+
...(options.codexCommand ? { codex: options.codexCommand } : {})
|
|
208
|
+
};
|
|
209
|
+
this.spawn = options.spawn ?? null;
|
|
210
|
+
this.codexClient = options.codexClient ?? null;
|
|
211
|
+
this.env = definedEnv(options.env ?? process.env);
|
|
212
|
+
// Only whoever reads the thread has anything to point a bookmark at, so the list goes where the thread goes.
|
|
213
|
+
this.bookmarks?.listen((chatId, bookmarks) => {
|
|
214
|
+
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
215
|
+
this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
this.claudeProjectsDir =
|
|
219
|
+
options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
|
|
220
|
+
this.subagents = new SubagentReader({
|
|
221
|
+
...options.subagents,
|
|
222
|
+
claudeProjectsDir: this.claudeProjectsDir,
|
|
223
|
+
claudeProjectsDirOf: (info) => this.claudeProjectsDirOf(info),
|
|
224
|
+
chatInfo: async (chatId) => (await this.readChat(chatId))?.info ?? null,
|
|
225
|
+
chat: (chatId) => {
|
|
226
|
+
const session = this.chats.get(chatId);
|
|
227
|
+
return session
|
|
228
|
+
? {
|
|
229
|
+
info: session.info,
|
|
230
|
+
running: session.running,
|
|
231
|
+
items: () => session.thread.list(),
|
|
232
|
+
listThreadItems: (params) => session.listThreadItems(params),
|
|
233
|
+
noteNative: (toolUseId, native) => session.noteSubagentNative(toolUseId, native)
|
|
234
|
+
}
|
|
235
|
+
: null;
|
|
236
|
+
},
|
|
237
|
+
codexProcess: (info) => this.codexProcess(info),
|
|
238
|
+
notify: (clientId, event) => this.sinks.to(clientId, { event: 'chat.subagentChanged', payload: event })
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* How an app-server is started for a question about a thread of this chat, under its account, apart from any chat's own process. */
|
|
243
|
+
codexProcess(info: Pick<ChatInfo, 'cwd' | 'account'>): CodexProcessSpec {
|
|
244
|
+
return {
|
|
245
|
+
command: this.commands.codex ?? this.providers.get('codex').command,
|
|
246
|
+
cwd: info.cwd,
|
|
247
|
+
env: definedEnv(launchEnv(this.accounts, 'codex', info.account, this.env)),
|
|
248
|
+
...(this.codexClient ? { client: this.codexClient } : {}),
|
|
249
|
+
...(this.spawn ? { spawn: this.spawn } : {})
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Where Claude Code keeps the projects of this chat's account; empty for an account this machine does not have. */
|
|
254
|
+
claudeProjectsDirOf(info: Pick<ChatInfo, 'account'>): string {
|
|
255
|
+
if (isDefaultAccountOf('claude', info.account)) {
|
|
256
|
+
return this.claudeProjectsDir;
|
|
257
|
+
}
|
|
258
|
+
const folder = this.accounts?.transcriptFolder('claude', info.account) ?? null;
|
|
259
|
+
return folder === null ? '' : join(folder, 'projects');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* Whether a conversation of one account of this CLI can go on under the other. */
|
|
263
|
+
canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean {
|
|
264
|
+
if ((from ?? kind) === (to ?? kind)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return this.accounts?.canContinue(kind, from, to) ?? false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Throws for an account a CLI of this kind cannot start under on this machine. */
|
|
271
|
+
requireAccount(kind: AgentKind, account: string | undefined): void {
|
|
272
|
+
launchEnv(this.accounts, kind, account, this.env);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* What a person calls an account of this CLI. */
|
|
276
|
+
accountLabel(kind: AgentKind, account: string | undefined): string {
|
|
277
|
+
return isDefaultAccountOf(kind, account) || account === undefined ? this.providers.get(kind).name : (this.accounts?.labelOf(account) ?? account);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* A chat as it stands: the thread in memory, else the record on disk; null when this host has no such chat. */
|
|
281
|
+
async readChat(chatId: string): Promise<{ info: ChatInfo; items: ChatItem[] } | null> {
|
|
282
|
+
await this.loaded(chatId);
|
|
283
|
+
const session = this.chats.get(chatId);
|
|
284
|
+
if (session) {
|
|
285
|
+
return session.thread.snapshot();
|
|
286
|
+
}
|
|
287
|
+
const stored = await this.store?.read(chatId);
|
|
288
|
+
return stored ? { info: stored.info, items: stored.items } : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* What a chat of this CLI starts with when nobody else says: the model named, else the newest composer pick, in that CLI's catalog. */
|
|
292
|
+
startingPoint(provider: AgentKind, selection?: ModelSelection): { selection: ModelSelection; runtimeMode?: RuntimeMode; contextWindow: number | null } {
|
|
293
|
+
const catalog = this.providers.get(provider).catalog;
|
|
294
|
+
const preference = this.composerPreferences.for(provider);
|
|
295
|
+
const normalized = catalog.normalize(selection ?? preference.selection);
|
|
296
|
+
return {
|
|
297
|
+
selection: normalized,
|
|
298
|
+
...(preference.runtimeMode ? { runtimeMode: preference.runtimeMode } : {}),
|
|
299
|
+
contextWindow: catalog.contextWindowFor(normalized)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
* An observer only notes things (a record, a file) and never acts: a turn sent from inside an
|
|
305
|
+
* event runs in the first chat's call stack and is gone in a crash.
|
|
306
|
+
*/
|
|
307
|
+
observe(sink: AgentSink): () => void {
|
|
308
|
+
this.observers.add(sink);
|
|
309
|
+
return () => {
|
|
310
|
+
this.observers.delete(sink);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
subscribe(clientId: string, sink: AgentSink): () => void {
|
|
315
|
+
return this.sinks.subscribe(clientId, sink);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* Registers a chat; nothing is spawned until the first message. An existing chat answers its current info. */
|
|
319
|
+
create(payload: ChatCreatePayload): Promise<ChatInfo> {
|
|
320
|
+
// A create in flight has the chat in the map before its first prompt is sent, so a second caller waits for all of it.
|
|
321
|
+
const inFlight = this.creating.get(payload.chatId);
|
|
322
|
+
if (inFlight) {
|
|
323
|
+
return inFlight;
|
|
324
|
+
}
|
|
325
|
+
const existing = this.chats.get(payload.chatId);
|
|
326
|
+
if (existing) {
|
|
327
|
+
return Promise.resolve(existing.info);
|
|
328
|
+
}
|
|
329
|
+
const created = this.createNow(payload).finally(() => this.creating.delete(payload.chatId));
|
|
330
|
+
this.creating.set(payload.chatId, created);
|
|
331
|
+
return created;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async createNow(payload: ChatCreatePayload): Promise<ChatInfo> {
|
|
335
|
+
const stored = await this.store?.read(payload.chatId);
|
|
336
|
+
if (!stored && (await this.store?.unreadable(payload.chatId))) {
|
|
337
|
+
throw new ChatError('chat-unreadable', `The record of chat ${payload.chatId} cannot be read by this version, so it is left as it is`);
|
|
338
|
+
}
|
|
339
|
+
// A thread on disk keeps its provider; the selection it stored only makes sense in that catalog.
|
|
340
|
+
const kind = stored?.info.provider ?? payload.provider ?? 'claude';
|
|
341
|
+
const provider = this.providers.get(kind);
|
|
342
|
+
const catalog = provider.catalog;
|
|
343
|
+
const selection = catalog.normalize(stored?.info.selection ?? this.openingSelection(payload.chatId, kind) ?? payload.selection);
|
|
344
|
+
const cwd = stored?.info.cwd ?? payload.cwd ?? this.env.HOME ?? homedir();
|
|
345
|
+
await this.admit(payload.chatId, cwd);
|
|
346
|
+
// A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
|
|
347
|
+
// A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
|
|
348
|
+
const account = stored ? stored.info.account : storedAccount(kind, payload.account ?? this.composerPreferences.for(kind).account);
|
|
349
|
+
if (!stored) {
|
|
350
|
+
this.requireAccount(kind, account);
|
|
351
|
+
}
|
|
352
|
+
// Asked again for a chat on disk, since the host may have listed it since.
|
|
353
|
+
const hidden = this.hiddenFor(payload.chatId) ? { hidden: true } : {};
|
|
354
|
+
const info: ChatInfo = stored?.info
|
|
355
|
+
? { ...withoutHidden(stored.info), runtimeMode: this.runtimeModeFor(payload.chatId, stored.info.runtimeMode), ...hidden }
|
|
356
|
+
: {
|
|
357
|
+
...hidden,
|
|
358
|
+
chatId: payload.chatId,
|
|
359
|
+
provider: kind,
|
|
360
|
+
...(account === undefined ? {} : { account }),
|
|
361
|
+
cwd,
|
|
362
|
+
agentSessionId: payload.resume ?? null,
|
|
363
|
+
model: null,
|
|
364
|
+
selection,
|
|
365
|
+
runtimeMode: this.runtimeModeFor(payload.chatId, payload.runtimeMode ?? 'full-access'),
|
|
366
|
+
status: 'idle',
|
|
367
|
+
running: false,
|
|
368
|
+
activeTurnId: null,
|
|
369
|
+
slashCommands: [],
|
|
370
|
+
usage: { contextTokens: 0, contextWindow: catalog.contextWindowFor(selection), costUsd: 0, turns: 0 },
|
|
371
|
+
createdAt: Date.now()
|
|
372
|
+
};
|
|
373
|
+
const resume: ResumeDecision = stored ? await this.owedResume(payload.chatId, stored) : { resumeTurnId: null, reason: null };
|
|
374
|
+
const claudeTitles = this.claudeTitles;
|
|
375
|
+
const nameChat = this.nameChat;
|
|
376
|
+
const base = this.envFor(payload.chatId, this.env);
|
|
377
|
+
this.logs.set(payload.chatId, await this.openLog(payload.chatId, stored ?? null));
|
|
378
|
+
const promptNotes = this.promptNotesFor(payload.chatId);
|
|
379
|
+
const references = this.referencesFor(payload.chatId);
|
|
380
|
+
const session = new ChatSession({
|
|
381
|
+
info,
|
|
382
|
+
items: stored?.items ?? [],
|
|
383
|
+
preambles: stored?.preambles ?? [],
|
|
384
|
+
provider,
|
|
385
|
+
command: this.commands[kind] ?? provider.command,
|
|
386
|
+
...(this.spawn ? { spawn: this.spawn } : {}),
|
|
387
|
+
env: (chatAccount) => {
|
|
388
|
+
const env = definedEnv(launchEnv(this.accounts, kind, chatAccount, base));
|
|
389
|
+
this.accounts?.launched?.(kind, chatAccount);
|
|
390
|
+
return env;
|
|
391
|
+
},
|
|
392
|
+
instructions: () => this.instructionsFor(payload.chatId),
|
|
393
|
+
resumeNote: () => this.resumeNoteFor(payload.chatId),
|
|
394
|
+
folders: () => this.foldersFor(payload.chatId),
|
|
395
|
+
...(promptNotes ? { promptNotes } : {}),
|
|
396
|
+
...(references ? { references } : {}),
|
|
397
|
+
...(this.checkpoints ? { checkpoints: this.checkpoints } : {}),
|
|
398
|
+
emit: (event: ChatEvent) => this.emit(payload.chatId, event),
|
|
399
|
+
...(this.onLimits ? { onLimits: this.onLimits } : {}),
|
|
400
|
+
persist: () => this.persist(payload.chatId),
|
|
401
|
+
save: () => this.persistNow(payload.chatId),
|
|
402
|
+
persistSoon: () => this.persistSoon(payload.chatId),
|
|
403
|
+
...(kind === 'claude' && claudeTitles
|
|
404
|
+
? { readTitle: (agentSessionId: string) => claudeTitles.forSession(agentSessionId, this.claudeProjectsDirOf(session.info)) }
|
|
405
|
+
: {}),
|
|
406
|
+
...(kind === 'codex' && nameChat ? { nameThread: (input: ChatTitleInput) => nameChat(kind, input) } : {}),
|
|
407
|
+
...(kind === 'claude' ? { subagentSettlement: (toolUseId: string) => this.subagents.claudeSettlement(payload.chatId, toolUseId) } : {}),
|
|
408
|
+
...(this.limitResume ? { limitResume: limitHooks(this.limitResume, payload.chatId) } : {})
|
|
409
|
+
});
|
|
410
|
+
this.chats.set(session.id, session);
|
|
411
|
+
if (stored) {
|
|
412
|
+
session.settleStored(selection, resume);
|
|
413
|
+
session.settleOwedResume();
|
|
414
|
+
await session.settleOrphanedSubagents();
|
|
415
|
+
}
|
|
416
|
+
await this.opened(session, stored ?? null);
|
|
417
|
+
return session.info;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async storedBackground(chatId: string): Promise<{ turnId: string | null; items: BackgroundWork[]; commands: string[] }> {
|
|
421
|
+
const record = await this.store?.read(chatId);
|
|
422
|
+
const items = record?.items ?? [];
|
|
423
|
+
const background = items.filter(
|
|
424
|
+
(item): item is BackgroundWork =>
|
|
425
|
+
isBackgroundWork(item) && (runsInBackground(item) || items.some((note) => note.id === `lost-background-${item.id}`))
|
|
426
|
+
);
|
|
427
|
+
const last = items.findLast((item) => item.kind === 'turn');
|
|
428
|
+
return { turnId: background.at(-1)?.turnId ?? last?.id ?? null, items: background, commands: (record?.info.background ?? []).map(commandLabel) };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
save(chatId: string): Promise<void> {
|
|
432
|
+
return this.persistNow(chatId, true);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
isLoading(chatId: string): boolean {
|
|
436
|
+
return this.creating.has(chatId);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
configure(payload: ChatConfigurePayload): ChatInfo {
|
|
440
|
+
const session = this.require(payload.chatId);
|
|
441
|
+
if (payload.account !== undefined) {
|
|
442
|
+
this.switchAccount(session, payload.account);
|
|
443
|
+
}
|
|
444
|
+
return session.configure(payload);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/*
|
|
448
|
+
* Any account of the chat's CLI before its first turn. After it only one that reads the same
|
|
449
|
+
* transcripts, since the CLI would not find the conversation in another folder.
|
|
450
|
+
*/
|
|
451
|
+
protected switchAccount(session: ChatSession, requested: string): void {
|
|
452
|
+
const { provider: kind, account: from } = session.info;
|
|
453
|
+
const to = storedAccount(kind, requested);
|
|
454
|
+
if (to === from) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.requireAccount(kind, to);
|
|
458
|
+
const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
|
|
459
|
+
if (spoke && !this.canContinue(kind, from, to)) {
|
|
460
|
+
const label = this.accountLabel(kind, to);
|
|
461
|
+
throw new AccountError(
|
|
462
|
+
'account-incompatible',
|
|
463
|
+
`This conversation is kept by the account '${this.accountLabel(kind, from)}', which '${label}' cannot read. Fork the chat to go on under '${label}'.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
session.setAccount(to);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/*
|
|
470
|
+
* With `since` a client that was here a moment ago gets only what came after it, when the log still
|
|
471
|
+
* holds all of that; otherwise the whole thread, or its newest page with `historyLimit`.
|
|
472
|
+
*/
|
|
473
|
+
attach(chatId: string, clientId: string, historyLimit?: number, since?: number): ChatAttachResult {
|
|
474
|
+
const session = this.require(chatId);
|
|
475
|
+
let clients = this.attached.get(chatId);
|
|
476
|
+
if (!clients) {
|
|
477
|
+
clients = new Set();
|
|
478
|
+
this.attached.set(chatId, clients);
|
|
479
|
+
}
|
|
480
|
+
// The snapshot already holds the text of a delta that is waiting, so it goes out before this
|
|
481
|
+
// client is on the list: the text is either in the snapshot or in the stream, never both.
|
|
482
|
+
this.coalescers.get(chatId)?.flush();
|
|
483
|
+
clients.add(clientId);
|
|
484
|
+
const log = this.logs.get(chatId)!;
|
|
485
|
+
const events = since === undefined ? null : log.after(since);
|
|
486
|
+
if (events !== null) {
|
|
487
|
+
return { info: session.info, items: [], events, seq: log.seq };
|
|
488
|
+
}
|
|
489
|
+
return historyLimit === undefined
|
|
490
|
+
? { ...session.thread.snapshot(), seq: log.seq }
|
|
491
|
+
: { info: session.info, ...session.thread.history(historyLimit), pending: session.thread.pending(), seq: log.seq };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/*
|
|
495
|
+
* `attach` with the chat's bookmarks. They are read before the client joins, so a change written
|
|
496
|
+
* after the read reaches it as `chat.bookmarks` and never falls between the two. A file that
|
|
497
|
+
* cannot be read leaves the list out rather than the thread.
|
|
498
|
+
*/
|
|
499
|
+
async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
|
|
500
|
+
this.require(chatId);
|
|
501
|
+
const bookmarks = await this.bookmarks?.read(chatId).catch((e: unknown) => {
|
|
502
|
+
console.error(`The bookmarks of chat ${chatId} could not be read:`, errorText(e));
|
|
503
|
+
return undefined;
|
|
504
|
+
});
|
|
505
|
+
const result = this.attach(chatId, clientId, historyLimit, since);
|
|
506
|
+
return bookmarks === undefined ? result : { ...result, bookmarks };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* Marks a message of the chat's own thread; a message that already has one keeps it. */
|
|
510
|
+
addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]> {
|
|
511
|
+
const item = this.require(chatId).thread.get(itemId);
|
|
512
|
+
if (!item || !isBookmarkable(item)) {
|
|
513
|
+
throw new ChatError('item-not-found', `Chat ${chatId} has no message ${itemId} to bookmark`);
|
|
514
|
+
}
|
|
515
|
+
return this.requireBookmarks().add(chatId, { itemId, excerpt: excerptOf(item), ...(name === undefined ? {} : { name }) }, Date.now());
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]> {
|
|
519
|
+
this.require(chatId);
|
|
520
|
+
return this.requireBookmarks().rename(chatId, itemId, name);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]> {
|
|
524
|
+
this.require(chatId);
|
|
525
|
+
return this.requireBookmarks().remove(chatId, itemId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private requireBookmarks(): BookmarkStore {
|
|
529
|
+
if (!this.bookmarks) {
|
|
530
|
+
throw new ChatError('chat-unsupported', 'This host keeps no bookmarks');
|
|
531
|
+
}
|
|
532
|
+
return this.bookmarks;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
history(chatId: string, cursor: string, limit?: number): ChatHistoryResult {
|
|
536
|
+
const session = this.require(chatId);
|
|
537
|
+
this.coalescers.get(chatId)?.flush();
|
|
538
|
+
return session.thread.history(limit, cursor);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
detach(chatId: string, clientId: string): void {
|
|
542
|
+
this.coalescers.get(chatId)?.flush();
|
|
543
|
+
this.attached.get(chatId)?.delete(clientId);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
detachAll(clientId: string): void {
|
|
547
|
+
for (const [chatId, clients] of this.attached) {
|
|
548
|
+
if (clients.has(clientId)) {
|
|
549
|
+
this.coalescers.get(chatId)?.flush();
|
|
550
|
+
clients.delete(clientId);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
this.subagents.releaseClient(clientId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
* A page of a subagent's own conversation. Letting go comes before the read, so a panel that closes
|
|
558
|
+
* on a conversation that is gone still lets go; holding comes after it, so a hold is only ever on
|
|
559
|
+
* something that was found.
|
|
560
|
+
*/
|
|
561
|
+
async subagent(clientId: string, payload: ChatSubagentPayload): Promise<ChatSubagentResult> {
|
|
562
|
+
if (payload.watch === false) {
|
|
563
|
+
this.subagents.release(clientId, payload.chatId, payload.toolUseId);
|
|
564
|
+
}
|
|
565
|
+
const result = await this.subagents.read(payload.chatId, payload.toolUseId, payload.cursor, payload.limit);
|
|
566
|
+
await this.chats.get(payload.chatId)?.recheckOrphan(payload.toolUseId);
|
|
567
|
+
if (payload.watch === true) {
|
|
568
|
+
this.subagents.hold(clientId, payload.chatId, payload.toolUseId);
|
|
569
|
+
}
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/*
|
|
574
|
+
* Stops one running sub-agent of the CLI's own. It cannot be stopped apart from its CLI, so its row
|
|
575
|
+
* is only marked, and only while no turn runs: stopping that turn is the composer's Stop.
|
|
576
|
+
*/
|
|
577
|
+
async stopSubagent(chatId: string, toolUseId: string): Promise<void> {
|
|
578
|
+
await this.loaded(chatId);
|
|
579
|
+
const session = this.require(chatId);
|
|
580
|
+
const row = session.thread.find('subagent', (item) => item.toolUseId === toolUseId);
|
|
581
|
+
if (!row) {
|
|
582
|
+
throw new ChatError('subagent-not-found', 'This chat has no such sub-agent');
|
|
583
|
+
}
|
|
584
|
+
if (row.status !== 'running') {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (row.origin === 'ruimte') {
|
|
588
|
+
throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
|
|
589
|
+
}
|
|
590
|
+
session.markSubagentStopped(toolUseId);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* Ends a command or a monitor a chat's CLI runs in the background, for a person who pressed Stop on it. */
|
|
594
|
+
async stopTask(chatId: string, taskId: string): Promise<void> {
|
|
595
|
+
await this.loaded(chatId);
|
|
596
|
+
this.require(chatId).stopTask(taskId);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* The whole conversation of a subagent of the CLI's own, for an agent that reads it as text. */
|
|
600
|
+
async subagentItems(chatId: string, toolUseId: string): Promise<ChatItem[]> {
|
|
601
|
+
return this.subagents.readAll(chatId, toolUseId);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* Whether a chat has a thread on disk, for work that must not make an empty chat of an id it only remembers. */
|
|
605
|
+
async hasStored(chatId: string): Promise<boolean> {
|
|
606
|
+
return this.chats.has(chatId) || (await this.store?.has(chatId)) === true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
|
|
610
|
+
async lastTurn(chatId: string): Promise<ChatTurnItem | null> {
|
|
611
|
+
const session = this.chats.get(chatId);
|
|
612
|
+
if (session) {
|
|
613
|
+
return session.thread.find('turn', () => true) ?? null;
|
|
614
|
+
}
|
|
615
|
+
const stored = await this.store?.read(chatId);
|
|
616
|
+
return stored?.items.findLast((item): item is ChatTurnItem => item.kind === 'turn') ?? null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
|
|
620
|
+
async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
|
|
621
|
+
await this.loaded(chatId);
|
|
622
|
+
if (!(await this.hasStored(chatId))) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (!this.chats.has(chatId)) {
|
|
626
|
+
await this.create({ chatId });
|
|
627
|
+
}
|
|
628
|
+
this.chats.get(chatId)?.addNote(level, text);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
* Leaves a note and its preamble in a chat, loading that chat when nobody has; false when it was
|
|
633
|
+
* there already, or when the id has no chat at all (a terminal), which never gets one made for it.
|
|
634
|
+
*/
|
|
635
|
+
async deliverNote(chatId: string, delivery: { noteId: string; note: string; from?: string; preamble: string }): Promise<boolean> {
|
|
636
|
+
await this.loaded(chatId);
|
|
637
|
+
if (!(await this.hasStored(chatId))) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
if (!this.chats.has(chatId)) {
|
|
641
|
+
await this.create({ chatId });
|
|
642
|
+
}
|
|
643
|
+
return this.require(chatId).deliverNote(delivery);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/*
|
|
647
|
+
* Answers whether the message went into the chat's queue because a turn was still running. The
|
|
648
|
+
* uploads become files first, so a queued message carries paths and never its own bytes.
|
|
649
|
+
*/
|
|
650
|
+
async send(chatId: string, text: string, extras: ChatSendExtras = {}, uploads: ChatAttachmentUpload[] = []): Promise<{ queued: boolean; turnId: string }> {
|
|
651
|
+
const session = this.require(chatId);
|
|
652
|
+
const checked = ChatAttachmentUploadsSchema.safeParse(uploads);
|
|
653
|
+
if (!checked.success) {
|
|
654
|
+
throw new ChatError('invalid-attachments', checked.error.issues[0]!.message);
|
|
655
|
+
}
|
|
656
|
+
const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
|
|
657
|
+
return session.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/* The file behind an attachment id: what this chat's thread or queue says it is. */
|
|
661
|
+
attachment(chatId: string, id: string): ChatAttachment | null {
|
|
662
|
+
const session = this.chats.get(chatId);
|
|
663
|
+
if (!session) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
for (const item of session.thread.list()) {
|
|
667
|
+
const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : undefined;
|
|
668
|
+
if (found) {
|
|
669
|
+
return found;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
for (const message of session.info.queue ?? []) {
|
|
673
|
+
const found = message.attachments?.find((attachment) => attachment.id === id);
|
|
674
|
+
if (found) {
|
|
675
|
+
return found;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
unqueue(chatId: string, messageId: string): ChatQueuedMessage {
|
|
682
|
+
const message = this.require(chatId).unqueue(messageId);
|
|
683
|
+
if (!message) {
|
|
684
|
+
throw new ChatError('request-not-found', `No queued message ${messageId} in chat ${chatId}`);
|
|
685
|
+
}
|
|
686
|
+
return message;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
sendNow(chatId: string, messageId: string): void {
|
|
690
|
+
if (!this.require(chatId).sendNow(messageId)) {
|
|
691
|
+
throw new ChatError('request-not-found', `No queued message ${messageId} in chat ${chatId}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* What the chat's CLI would run as a skill. */
|
|
696
|
+
skills(chatId: string): Promise<ChatSkill[]> {
|
|
697
|
+
const session = this.require(chatId);
|
|
698
|
+
return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
compact(chatId: string): void {
|
|
702
|
+
const session = this.require(chatId);
|
|
703
|
+
if (session.busy) {
|
|
704
|
+
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
705
|
+
}
|
|
706
|
+
session.compact();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/* Empties the thread and drops the CLI's session and the chat's bookmarks; `force` stops a turn that is in the way. */
|
|
710
|
+
async clear(chatId: string, force = false): Promise<void> {
|
|
711
|
+
const session = this.require(chatId);
|
|
712
|
+
if (session.busy && !force) {
|
|
713
|
+
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
714
|
+
}
|
|
715
|
+
await this.clearing(chatId);
|
|
716
|
+
session.clear(force);
|
|
717
|
+
// Before the record is written, so what it takes along from the record is gone from the write as well.
|
|
718
|
+
const cleared = this.cleared(chatId);
|
|
719
|
+
// A debounced write still waiting holds the old thread and must not land after the empty one.
|
|
720
|
+
this.cancelWaiting(chatId);
|
|
721
|
+
// Folded right away: every line before the reset describes a thread that is gone.
|
|
722
|
+
await Promise.all([this.persistNow(chatId, true), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), cleared]);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/* Stops the running turn; with `subagents` also marks the CLI's own subagents stopped, which that turn no longer waits on. */
|
|
726
|
+
cancel(chatId: string, subagents = false): void {
|
|
727
|
+
const session = this.require(chatId);
|
|
728
|
+
session.cancel();
|
|
729
|
+
if (subagents) {
|
|
730
|
+
session.markSubagentsStopped();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/* What a turn changed against the tree it started from; null when it has no checkpoint. */
|
|
735
|
+
turnDiff(chatId: string, turnId: string): Promise<ChatCheckpointDiff | null> {
|
|
736
|
+
return this.require(chatId).turnDiff(turnId);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
approve(chatId: string, requestId: string, decision: 'allow' | 'allow-always' | 'deny', message?: string): void {
|
|
740
|
+
if (!this.require(chatId).approve(requestId, decision, message)) {
|
|
741
|
+
throw new ChatError('request-not-found', `Nothing waits for approval ${requestId}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
answer(chatId: string, requestId: string, answers: Record<string, string>): void {
|
|
746
|
+
if (!this.require(chatId).answer(requestId, answers)) {
|
|
747
|
+
throw new ChatError('request-not-found', `No question waits under ${requestId}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
dismiss(chatId: string, itemId: string): void {
|
|
752
|
+
if (!this.require(chatId).dismiss(itemId)) {
|
|
753
|
+
throw new ChatError('request-not-found', `No question to dismiss under ${itemId}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* Removes a chat: its CLI, its record, its log, its attachments and its bookmarks. */
|
|
758
|
+
async kill(chatId: string): Promise<void> {
|
|
759
|
+
await this.loaded(chatId);
|
|
760
|
+
const session = this.require(chatId);
|
|
761
|
+
// A write already out would put the record back after the delete.
|
|
762
|
+
const writing = this.writes.get(chatId);
|
|
763
|
+
void session.dispose();
|
|
764
|
+
this.subagents.releaseChat(chatId);
|
|
765
|
+
this.coalescers.get(chatId)?.dispose();
|
|
766
|
+
this.coalescers.delete(chatId);
|
|
767
|
+
this.logs.get(chatId)?.close();
|
|
768
|
+
this.logs.delete(chatId);
|
|
769
|
+
this.cancelWaiting(chatId);
|
|
770
|
+
this.sizes.delete(chatId);
|
|
771
|
+
this.unlogged.delete(chatId);
|
|
772
|
+
this.writes.delete(chatId);
|
|
773
|
+
this.activity.delete(chatId);
|
|
774
|
+
this.chats.delete(chatId);
|
|
775
|
+
this.attached.delete(chatId);
|
|
776
|
+
this.announced.delete(chatId);
|
|
777
|
+
const snapshot = session.thread.snapshot();
|
|
778
|
+
this.forgotten(chatId);
|
|
779
|
+
await writing;
|
|
780
|
+
await Promise.all([this.store?.delete(chatId), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), this.removed(chatId, snapshot)]);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/*
|
|
784
|
+
* Ends the CLI of a chat and keeps its thread. A chat nobody loaded has no CLI to end, and a host
|
|
785
|
+
* that must not resume its turn later says so through `endedAt`.
|
|
786
|
+
*/
|
|
787
|
+
async stop(chatId: string, reason: string): Promise<void> {
|
|
788
|
+
await this.loaded(chatId);
|
|
789
|
+
this.chats.get(chatId)?.end(reason);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* The chats with a CLI process, for a process monitor. */
|
|
793
|
+
processTargets(): { id: string; pid: number; provider: AgentKind; status: ChatInfo['status']; updatedAt: number }[] {
|
|
794
|
+
const targets = [];
|
|
795
|
+
for (const [id, session] of this.chats) {
|
|
796
|
+
const pid = session.pid;
|
|
797
|
+
if (pid !== null) {
|
|
798
|
+
targets.push({ id, pid, provider: session.info.provider, status: session.info.status, updatedAt: this.activity.get(id) ?? 0 });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return targets;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
list(): ChatInfo[] {
|
|
805
|
+
return [...this.chats.values()].map((session) => session.info);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
get(chatId: string): ChatSession | undefined {
|
|
809
|
+
return this.chats.get(chatId);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/*
|
|
813
|
+
* Takes up a turn the host went down in, under the same turn with the next attempt. Loads the
|
|
814
|
+
* chat when nobody has yet, which asks `onInterruptedRun` again and finds the resume already owed.
|
|
815
|
+
* Throws when the CLI will not start, so whoever owes it tries again.
|
|
816
|
+
*/
|
|
817
|
+
async resumeRun(chatId: string, turnId: string, attempt: number): Promise<void> {
|
|
818
|
+
await this.loaded(chatId);
|
|
819
|
+
if (!this.chats.has(chatId)) {
|
|
820
|
+
if (!(await this.store?.has(chatId))) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
await this.create({ chatId });
|
|
824
|
+
}
|
|
825
|
+
await this.chats.get(chatId)?.resume(turnId, attempt, await this.resumeWords(chatId));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/*
|
|
829
|
+
* Takes up a turn that stopped on a limit, once its reset or its retry came. Loads the chat when
|
|
830
|
+
* nobody has; a chat whose opener was stopped since that turn is left as it is.
|
|
831
|
+
*/
|
|
832
|
+
async takeUpAfterLimit(chatId: string, turnId: string): Promise<OutboxOutcome> {
|
|
833
|
+
await this.loaded(chatId);
|
|
834
|
+
if (!this.chats.has(chatId)) {
|
|
835
|
+
if (!(await this.store?.has(chatId))) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
await this.create({ chatId });
|
|
839
|
+
}
|
|
840
|
+
const session = this.chats.get(chatId);
|
|
841
|
+
if (!session) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const turn = session.thread.get(turnId);
|
|
845
|
+
const ended = this.endedAt(chatId);
|
|
846
|
+
if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
|
|
847
|
+
session.lapseResume();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
return session.takeUpAfterLimit(turnId) === 'wait' ? 'wait' : undefined;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* The host's switch for resuming after a limit changed; every chat loaded here looks again. */
|
|
854
|
+
resumeSettingChanged(): void {
|
|
855
|
+
for (const session of this.chats.values()) {
|
|
856
|
+
session.resumeSettingChanged();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* A resume that never came about: the turn ends as aborted, with the reason in the thread. */
|
|
861
|
+
abandonRun(chatId: string, turnId: string, reason: string): void {
|
|
862
|
+
this.chats.get(chatId)?.abandon(turnId, reason);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/*
|
|
866
|
+
* Loads every stored chat whose turn was running when the host went down, or that shows a resume
|
|
867
|
+
* after a limit nobody owes, so `create` decides what becomes of it without waiting for a client to
|
|
868
|
+
* open it. One file at a time: it runs beside the host answering, not in front of it.
|
|
869
|
+
*/
|
|
870
|
+
async recoverInterrupted(): Promise<void> {
|
|
871
|
+
if (!this.store) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
for (const chatId of await this.store.list()) {
|
|
875
|
+
if (this.chats.has(chatId) || this.creating.has(chatId)) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const record = await this.store.read(chatId).catch(() => null);
|
|
879
|
+
const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
|
|
880
|
+
if (record !== null && (interruptedTurn(record) !== null || unowed)) {
|
|
881
|
+
await this.create({ chatId }).catch((e: unknown) => console.error(`Loading chat ${chatId} after a restart failed:`, errorText(e)));
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/*
|
|
887
|
+
* Writes every thread and ends every CLI, waiting for them as long as the grace of a SIGKILL. A
|
|
888
|
+
* turn that was running is taken up after the restart, never finished by a CLI nobody hears.
|
|
889
|
+
*/
|
|
890
|
+
async shutdown(): Promise<void> {
|
|
891
|
+
for (const session of this.chats.values()) {
|
|
892
|
+
session.freeze();
|
|
893
|
+
}
|
|
894
|
+
for (const chatId of this.chats.keys()) {
|
|
895
|
+
this.cancelWaiting(chatId);
|
|
896
|
+
}
|
|
897
|
+
// A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
|
|
898
|
+
const written = await Promise.allSettled([...this.chats.keys()].map((chatId) => this.persistNow(chatId, true)));
|
|
899
|
+
await Promise.allSettled([...this.chats.values()].map((session) => session.dispose()));
|
|
900
|
+
const failed = written.find((result) => result.status === 'rejected');
|
|
901
|
+
if (failed) {
|
|
902
|
+
throw failed.reason;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/*
|
|
907
|
+
* Every thread to disk without awaiting anything. A `bun --watch` reload restarts the module while
|
|
908
|
+
* the signal handler is still on its first await, so the threads go down before that first await.
|
|
909
|
+
*/
|
|
910
|
+
persistAllSync(): void {
|
|
911
|
+
if (!this.store) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
for (const session of this.chats.values()) {
|
|
915
|
+
session.freeze();
|
|
916
|
+
}
|
|
917
|
+
for (const [chatId, session] of this.chats) {
|
|
918
|
+
this.cancelWaiting(chatId);
|
|
919
|
+
this.coalescers.get(chatId)?.flush();
|
|
920
|
+
const { info, items } = session.thread.snapshot();
|
|
921
|
+
const log = this.logs.get(chatId);
|
|
922
|
+
// Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
|
|
923
|
+
try {
|
|
924
|
+
this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId));
|
|
925
|
+
} catch (e) {
|
|
926
|
+
console.error(`Chat record for ${chatId} failed:`, errorText(e));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/* Resolves once every write of the chat's record asked for so far has landed or was found unneeded. */
|
|
932
|
+
async persisted(chatId: string): Promise<void> {
|
|
933
|
+
await this.writes.get(chatId);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/* Waits for a create of this chat that is under way, whatever came of it. */
|
|
937
|
+
protected async loaded(chatId: string): Promise<void> {
|
|
938
|
+
await this.creating.get(chatId)?.catch(() => undefined);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
protected persist(chatId: string): void {
|
|
942
|
+
this.cancelWaiting(chatId);
|
|
943
|
+
void this.persistNow(chatId).catch((e) => console.error(`Chat record for ${chatId} failed:`, errorText(e)));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
protected require(chatId: string): ChatSession {
|
|
947
|
+
const session = this.chats.get(chatId);
|
|
948
|
+
if (!session) {
|
|
949
|
+
throw new ChatError('chat-not-found', `No chat ${chatId}`);
|
|
950
|
+
}
|
|
951
|
+
return session;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/* What the agent is told once at the start of every process of this chat; null for nothing. */
|
|
955
|
+
protected instructionsFor(_chatId: string): string | null {
|
|
956
|
+
return this.instructions;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
|
|
960
|
+
protected resumeNoteFor(_chatId: string): string | null {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/*
|
|
965
|
+
* Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
|
|
966
|
+
* before every turn, so a host can hand one over for as long as it is needed: a change starts the
|
|
967
|
+
* next turn in a new process that resumes the thread, and never cuts a turn in flight.
|
|
968
|
+
*/
|
|
969
|
+
protected foldersFor(_chatId: string): readonly string[] {
|
|
970
|
+
return [];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/* Whether no list of chats shows this one, which `ChatInfo.hidden` says to every client. */
|
|
974
|
+
protected hiddenFor(_chatId: string): boolean {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/* What the host says in front of a chat's next real prompt, beside what the chat keeps itself. */
|
|
979
|
+
protected promptNotesFor(_chatId: string): PromptNotes | undefined {
|
|
980
|
+
return undefined;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* How the chats a person attached to a message are named to the agent; without it they are dropped. */
|
|
984
|
+
protected referencesFor(_chatId: string): ChatReferences | undefined {
|
|
985
|
+
return undefined;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/* The environment of this chat's CLI before its account adds its own. */
|
|
989
|
+
protected envFor(_chatId: string, base: Record<string, string>): Record<string, string | undefined> {
|
|
990
|
+
return base;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/* Throws for a folder this chat may not start in; asked every time a chat is loaded. */
|
|
994
|
+
protected async admit(_chatId: string, _cwd: string): Promise<void> {}
|
|
995
|
+
|
|
996
|
+
/* The model a new chat starts on when the host already knows; the explicit one still wins. */
|
|
997
|
+
protected openingSelection(_chatId: string, _kind: AgentKind): ModelSelection | undefined {
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
|
|
1002
|
+
protected runtimeModeFor(_chatId: string, mode: RuntimeMode): RuntimeMode {
|
|
1003
|
+
return mode;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/* A chat just loaded or made, its stored turns settled; whatever the host lays down in it goes here. */
|
|
1007
|
+
protected async opened(_session: ChatSession, _stored: ChatRecord | null): Promise<void> {}
|
|
1008
|
+
|
|
1009
|
+
/* What the host keeps in the chat's record beside the thread. */
|
|
1010
|
+
protected recordExtras(_chatId: string): ChatRecordExtras {
|
|
1011
|
+
return {};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* The chat was cleared; what runs before the first await is in the record written right after. */
|
|
1015
|
+
protected async cleared(_chatId: string): Promise<void> {}
|
|
1016
|
+
|
|
1017
|
+
protected async clearing(_chatId: string): Promise<void> {}
|
|
1018
|
+
|
|
1019
|
+
/* The chat left memory for good, before its files go. */
|
|
1020
|
+
protected forgotten(_chatId: string): void {}
|
|
1021
|
+
|
|
1022
|
+
/* The chat was removed; what the host keeps of it goes along. */
|
|
1023
|
+
protected async removed(_chatId: string, _chat: { info: ChatInfo; items: ChatItem[] }): Promise<void> {}
|
|
1024
|
+
|
|
1025
|
+
/* An event of a chat went out, after the observers and before the clients reading its thread. */
|
|
1026
|
+
protected broadcasted(_chatId: string, _event: ChatEvent): void {}
|
|
1027
|
+
|
|
1028
|
+
/* When whoever opened this chat stopped it; a turn from before that is never taken up again. */
|
|
1029
|
+
protected endedAt(_chatId: string): number | null {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/* Why a turn the host went down in stays down when `onInterruptedRun` says no, in the words of the note it ends with. */
|
|
1034
|
+
protected unownedReason(_chatId: string): string {
|
|
1035
|
+
return 'nothing holds this chat any more';
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/* How a turn the host went down in is taken up in this chat. */
|
|
1039
|
+
protected async resumeWords(_chatId: string): Promise<ResumeWords> {
|
|
1040
|
+
return DEFAULT_RESUME_WORDS;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/* A write that may wait: a long thread is not rewritten for every small change. */
|
|
1044
|
+
private persistSoon(chatId: string): void {
|
|
1045
|
+
if ((this.sizes.get(chatId) ?? 0) < DEBOUNCE_ABOVE_BYTES) {
|
|
1046
|
+
this.persist(chatId);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (this.waiting.has(chatId)) {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const timer = setTimeout(() => {
|
|
1053
|
+
this.waiting.delete(chatId);
|
|
1054
|
+
this.persist(chatId);
|
|
1055
|
+
}, DEBOUNCE_MS);
|
|
1056
|
+
// A pending write must never be the reason the process stays up.
|
|
1057
|
+
timer.unref?.();
|
|
1058
|
+
this.waiting.set(chatId, timer);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private cancelWaiting(chatId: string): void {
|
|
1062
|
+
const timer = this.waiting.get(chatId);
|
|
1063
|
+
if (timer) {
|
|
1064
|
+
clearTimeout(timer);
|
|
1065
|
+
this.waiting.delete(chatId);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/*
|
|
1070
|
+
* One write at a time per chat. Two records in flight together rename in whichever order the
|
|
1071
|
+
* file system finishes them, so an older snapshot could land last and undo what just happened.
|
|
1072
|
+
*/
|
|
1073
|
+
private persistNow(chatId: string, fold = false): Promise<void> {
|
|
1074
|
+
const next = (this.writes.get(chatId) ?? Promise.resolve()).then(async () => {
|
|
1075
|
+
const session = this.chats.get(chatId);
|
|
1076
|
+
const log = this.logs.get(chatId);
|
|
1077
|
+
if (!session || !this.store || !log) {
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
// A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
|
|
1081
|
+
this.coalescers.get(chatId)?.flush();
|
|
1082
|
+
const extras = this.recordExtras(chatId);
|
|
1083
|
+
const unlogged = JSON.stringify([session.preambles, extras]);
|
|
1084
|
+
const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
|
|
1085
|
+
// The log already holds every change to the thread, so the whole record is rewritten only to fold
|
|
1086
|
+
// the log, on a chat's first write, or for what no event carries.
|
|
1087
|
+
if (!folds && this.sizes.has(chatId) && this.unlogged.get(chatId) === unlogged) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const { info, items } = session.thread.snapshot();
|
|
1091
|
+
const at = { seq: log.seq, resetSeq: log.resetSeq };
|
|
1092
|
+
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
|
|
1093
|
+
this.unlogged.set(chatId, unlogged);
|
|
1094
|
+
// A chat killed while the write was out has no log left to fold.
|
|
1095
|
+
if (this.logs.get(chatId) === log && folds) {
|
|
1096
|
+
log.compact(at.seq);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
this.writes.set(
|
|
1100
|
+
chatId,
|
|
1101
|
+
next.catch(() => undefined)
|
|
1102
|
+
);
|
|
1103
|
+
return next;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private emit(chatId: string, event: ChatEvent): void {
|
|
1107
|
+
this.activity.set(chatId, Date.now());
|
|
1108
|
+
let coalescer = this.coalescers.get(chatId);
|
|
1109
|
+
if (!coalescer) {
|
|
1110
|
+
coalescer = new DeltaCoalescer((coalesced) => this.broadcast(chatId, coalesced));
|
|
1111
|
+
this.coalescers.set(chatId, coalescer);
|
|
1112
|
+
}
|
|
1113
|
+
coalescer.push(event);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/* Written down before anyone hears it, so no client holds a seq the log does not. */
|
|
1117
|
+
private broadcast(chatId: string, event: ChatEvent): void {
|
|
1118
|
+
const seq = this.logs.get(chatId)?.append(event, Date.now());
|
|
1119
|
+
const payload = seq === undefined ? { chatId, event } : { chatId, event, seq };
|
|
1120
|
+
for (const sink of this.observers) {
|
|
1121
|
+
sink({ event: 'chat.event', payload });
|
|
1122
|
+
}
|
|
1123
|
+
this.broadcasted(chatId, event);
|
|
1124
|
+
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
1125
|
+
this.sinks.to(clientId, { event: 'chat.event', payload });
|
|
1126
|
+
}
|
|
1127
|
+
// After the thread, so a client reading it has already had this info and the status is a no-op there.
|
|
1128
|
+
if (event.type === 'info' || event.type === 'reset') {
|
|
1129
|
+
this.announceStatus(chatId, event.info);
|
|
1130
|
+
} else if (event.type === 'item' && (event.item.kind === 'approval' || event.item.kind === 'question')) {
|
|
1131
|
+
const session = this.chats.get(chatId);
|
|
1132
|
+
if (session) {
|
|
1133
|
+
this.announceStatus(chatId, session.info);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/*
|
|
1139
|
+
* What a chat is doing goes to every client of this host, not only to the ones reading its thread:
|
|
1140
|
+
* a chat waiting on a person has to say so where nobody has it open. The thread itself stays with
|
|
1141
|
+
* whoever attached, since it streams word by word. Only a change is sent; `chat.list` hands out the
|
|
1142
|
+
* rest on connect.
|
|
1143
|
+
*/
|
|
1144
|
+
private announceStatus(chatId: string, info: ChatInfo): void {
|
|
1145
|
+
// A limit, the resume owed after it and subagents still at work are what a header shows of an idle chat, so they count as a change too,
|
|
1146
|
+
// and so does a request opening or settling while the chat already waits on another.
|
|
1147
|
+
const said = JSON.stringify([
|
|
1148
|
+
info.status,
|
|
1149
|
+
info.limit ?? null,
|
|
1150
|
+
info.resumeAt ?? null,
|
|
1151
|
+
info.resumeAtReset ?? null,
|
|
1152
|
+
info.delegating ?? false,
|
|
1153
|
+
info.requests?.map((request) => request.requestId) ?? []
|
|
1154
|
+
]);
|
|
1155
|
+
if (this.announced.get(chatId) === said) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this.announced.set(chatId, said);
|
|
1159
|
+
this.sinks.emit({ event: 'chat.status', payload: { chatId, info } });
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/*
|
|
1163
|
+
* The stream of a chat as its files left it. A chat with no record starts its stream over, after
|
|
1164
|
+
* whatever seq a log left behind by a life that never wrote a snapshot handed out, with a reset
|
|
1165
|
+
* marked just past it: a client still holding one of those seqs gets the whole thread.
|
|
1166
|
+
*/
|
|
1167
|
+
private async openLog(chatId: string, stored: ChatRecord | null): Promise<ChatLog> {
|
|
1168
|
+
if (!this.store) {
|
|
1169
|
+
return new ChatLog(null);
|
|
1170
|
+
}
|
|
1171
|
+
const path = this.store.logPath(chatId);
|
|
1172
|
+
if (stored) {
|
|
1173
|
+
return new ChatLog(path, stored);
|
|
1174
|
+
}
|
|
1175
|
+
const left = await this.store.discardLog(chatId);
|
|
1176
|
+
return new ChatLog(path, { seq: left, resetSeq: left === 0 ? 0 : left + 1, lines: [] });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/* Every reason says why in the words of the note the turn ends with, so a person can tell a skip from a failure. */
|
|
1180
|
+
private async owedResume(chatId: string, stored: ChatRecord): Promise<ResumeDecision> {
|
|
1181
|
+
const turn = interruptedTurn(stored);
|
|
1182
|
+
const skip = (reason: string): ResumeDecision => ({ resumeTurnId: null, reason });
|
|
1183
|
+
if (turn === null) {
|
|
1184
|
+
return { resumeTurnId: null, reason: null };
|
|
1185
|
+
}
|
|
1186
|
+
if (turn.deliveryPending) {
|
|
1187
|
+
return skip('the wake prompt was not acknowledged; its result waits for your next message');
|
|
1188
|
+
}
|
|
1189
|
+
if (!this.onInterruptedRun) {
|
|
1190
|
+
return skip('this machine does not resume turns');
|
|
1191
|
+
}
|
|
1192
|
+
if (stored.info.agentSessionId === null) {
|
|
1193
|
+
return skip('the agent had not started a session to resume yet');
|
|
1194
|
+
}
|
|
1195
|
+
const ended = this.endedAt(chatId);
|
|
1196
|
+
if (ended !== null && ended >= turn.createdAt) {
|
|
1197
|
+
return skip('the agent that opened it was stopped');
|
|
1198
|
+
}
|
|
1199
|
+
if ((turn.attempt ?? 1) >= MAX_ATTEMPTS && !turn.resumePending) {
|
|
1200
|
+
return skip('it was already resumed after an earlier restart');
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
|
|
1204
|
+
return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.unownedReason(chatId));
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
|
|
1207
|
+
return skip(errorText(e));
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/* The hooks of one chat, over the core's own. */
|
|
1213
|
+
function limitHooks(hooks: NonNullable<ChatCoreOptions['limitResume']>, chatId: string): LimitResumeHooks {
|
|
1214
|
+
return {
|
|
1215
|
+
allowed: () => hooks.allowed(),
|
|
1216
|
+
now: () => hooks.now(),
|
|
1217
|
+
owe: (turnId, at) => hooks.owe(chatId, turnId, at),
|
|
1218
|
+
lapse: () => hooks.lapse(chatId),
|
|
1219
|
+
owed: () => hooks.owed(chatId)
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/* The running turn of a stored chat, when it may be resumed and the resume is now owed; null otherwise. */
|
|
1224
|
+
function interruptedTurn(record: { info: ChatInfo; items: ChatItem[] }): Extract<ChatItem, { kind: 'turn' }> | null {
|
|
1225
|
+
const turnId = record.info.activeTurnId;
|
|
1226
|
+
const turn = turnId === null ? undefined : record.items.find((item) => item.id === turnId);
|
|
1227
|
+
return turn?.kind === 'turn' && turn.state === 'running' ? turn : null;
|
|
1228
|
+
}
|