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