@sayknow-cli/coding-agent 0.3.12 → 0.3.15
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/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commands/web-search.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
- package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
- package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +3 -3
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/commit/shared-llm.d.ts +9 -9
- package/dist/types/config/keybindings.d.ts +134 -129
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +35 -35
- package/dist/types/config/models-config-schema.d.ts +111 -111
- package/dist/types/config/settings-schema.d.ts +56 -16
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/edit/modes/patch.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/goals/tools/goal-tool.d.ts +6 -6
- package/dist/types/i18n/messages/en.d.ts +8 -8
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/lsp/index.d.ts +9 -9
- package/dist/types/lsp/types.d.ts +9 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/skc-runtime/state-schema.d.ts +19 -19
- package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/task/agents.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -27
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/browser.d.ts +28 -28
- package/dist/types/tools/computer.d.ts +20 -20
- package/dist/types/tools/cron.d.ts +2 -2
- package/dist/types/tools/debug.d.ts +40 -40
- package/dist/types/tools/gh.d.ts +10 -10
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/monitor.d.ts +6 -6
- package/dist/types/tools/skill-discovery.d.ts +4 -4
- package/dist/types/tools/subagent.d.ts +12 -12
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/tools/todo-write.d.ts +8 -8
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/dist/types/web/insane/bridge.d.ts +9 -9
- package/dist/types/web/search/index.d.ts +4 -4
- package/package.json +8 -8
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +51 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2958 -430
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/sayknow-pet-widget.ts +360 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/status-line/segments.ts +14 -7
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/skc-runtime/session-state-sidecar.ts +756 -135
- package/src/skc-runtime/tmux-common.ts +26 -7
- package/src/skc-runtime/tmux-gc.ts +52 -9
- package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/skc-runtime/tmux-sessions.ts +871 -68
- package/src/slash-commands/builtin-registry.ts +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- package/src/utils/external-editor.ts +6 -1
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type CloseSessionRequest,
|
|
11
11
|
type CloseSessionResponse,
|
|
12
12
|
type CreateElicitationResponse,
|
|
13
|
+
type DeleteSessionRequest,
|
|
14
|
+
type DeleteSessionResponse,
|
|
13
15
|
type ElicitationContentValue,
|
|
14
16
|
type ElicitationPropertySchema,
|
|
15
17
|
type ForkSessionRequest,
|
|
@@ -30,14 +32,11 @@ import {
|
|
|
30
32
|
type ResumeSessionResponse,
|
|
31
33
|
type SessionConfigOption,
|
|
32
34
|
type SessionInfo,
|
|
33
|
-
type SessionModelState,
|
|
34
35
|
type SessionModeState,
|
|
35
36
|
type SessionNotification,
|
|
36
37
|
type SessionUpdate,
|
|
37
38
|
type SetSessionConfigOptionRequest,
|
|
38
39
|
type SetSessionConfigOptionResponse,
|
|
39
|
-
type SetSessionModelRequest,
|
|
40
|
-
type SetSessionModelResponse,
|
|
41
40
|
type SetSessionModeRequest,
|
|
42
41
|
type SetSessionModeResponse,
|
|
43
42
|
type Usage,
|
|
@@ -66,11 +65,19 @@ import { MCPManager } from "../../runtime-mcp/manager";
|
|
|
66
65
|
import type { MCPServerConfig } from "../../runtime-mcp/types";
|
|
67
66
|
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
|
|
68
67
|
import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
|
|
68
|
+
import { isLegacyProviderSafetyStopMessage } from "../../session/provider-safety-stop";
|
|
69
69
|
import {
|
|
70
70
|
SessionManager,
|
|
71
71
|
type SessionInfo as StoredSessionInfo,
|
|
72
|
+
type StrictInventoryCandidate,
|
|
72
73
|
type UsageStatistics,
|
|
73
74
|
} from "../../session/session-manager";
|
|
75
|
+
import {
|
|
76
|
+
FileSessionStorage,
|
|
77
|
+
type SessionStorageFileIdentity,
|
|
78
|
+
type VerifiedSessionDeleteResult,
|
|
79
|
+
type VerifiedSessionDeleteTarget,
|
|
80
|
+
} from "../../session/session-storage";
|
|
74
81
|
import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
|
|
75
82
|
import { parseThinkingLevel } from "../../thinking";
|
|
76
83
|
import { toAgentWireEventPayload } from "../shared/agent-wire/event-envelope";
|
|
@@ -90,6 +97,36 @@ const MODEL_CONFIG_ID = "model";
|
|
|
90
97
|
const THINKING_CONFIG_ID = "thinking";
|
|
91
98
|
const THINKING_OFF = "off";
|
|
92
99
|
const SESSION_PAGE_SIZE = 50;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One immutable authorization snapshot built from a complete strict scoped inventory.
|
|
103
|
+
* `entries` maps every discovered session id to either the single exact-identity
|
|
104
|
+
* candidate (authoritative) or a conflict tombstone (duplicate/unsafe). The snapshot
|
|
105
|
+
* is built once, before pagination, so a duplicate beyond page 1 is known before the
|
|
106
|
+
* first page response. Lifecycle changes bump {@link #authorityGeneration} and
|
|
107
|
+
* invalidate the snapshot.
|
|
108
|
+
*/
|
|
109
|
+
type AcpAuthorityEntry =
|
|
110
|
+
| { kind: "candidate"; candidate: StrictInventoryCandidate }
|
|
111
|
+
| { kind: "conflict"; reason: string };
|
|
112
|
+
/**
|
|
113
|
+
* Phase-aware same-connection retry evidence for an incomplete verified delete.
|
|
114
|
+
* The transcript identity is always recorded and re-verified on retry; the
|
|
115
|
+
* artifacts identity is recorded only when the failure occurred at the
|
|
116
|
+
* artifacts phase (the artifact directory still existed at failure time).
|
|
117
|
+
*/
|
|
118
|
+
type PendingDeleteEvidence = {
|
|
119
|
+
transcriptIdentity: SessionStorageFileIdentity;
|
|
120
|
+
artifactsIdentity?: SessionStorageFileIdentity;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type AcpAuthoritySnapshot = {
|
|
124
|
+
scope: string;
|
|
125
|
+
entries: Map<string, AcpAuthorityEntry>;
|
|
126
|
+
/** Ordered unique session ids (conflicts appear once at first sight) for paging. */
|
|
127
|
+
orderedIds: string[];
|
|
128
|
+
generation: number;
|
|
129
|
+
};
|
|
93
130
|
/**
|
|
94
131
|
* Delay between `session/new` (or `session/load` / `session/resume` /
|
|
95
132
|
* `unstable_session/fork`) returning and the agent firing the first
|
|
@@ -143,6 +180,28 @@ function isPromptTurnInFlight(turn: PromptTurnState | undefined): turn is Prompt
|
|
|
143
180
|
return turn !== undefined && (!turn.settled || turn.cleanup !== undefined);
|
|
144
181
|
}
|
|
145
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Per-record terminal state machine for session deletion.
|
|
185
|
+
* - `active`: normal operation; all lifecycle methods allowed.
|
|
186
|
+
* - `deleting`: a terminal delete has reserved the record; prompt/config/close/
|
|
187
|
+
* load/resume/fork reject, concurrent deletes join the terminal promise.
|
|
188
|
+
* - `pre_dispatch_retryable`: close_failed_retryable before verified-delete
|
|
189
|
+
* dispatch; normal ops rejected but a subsequent delete may retry.
|
|
190
|
+
* - `cleanup_pending`: verified delete returned cleanup_pending with identity
|
|
191
|
+
* evidence; normal ops rejected but a subsequent delete may retry.
|
|
192
|
+
* - `terminal_failure`: close_unknown or quiescence failure (or any unexpected
|
|
193
|
+
* failure); normal ops rejected and delete NEVER reopens — subsequent deletes
|
|
194
|
+
* share the settled terminal failure error.
|
|
195
|
+
* - `deleted`: verified delete succeeded; record removed from the active map.
|
|
196
|
+
*/
|
|
197
|
+
type AcpSessionTerminalState =
|
|
198
|
+
| "active"
|
|
199
|
+
| "deleting"
|
|
200
|
+
| "pre_dispatch_retryable"
|
|
201
|
+
| "cleanup_pending"
|
|
202
|
+
| "terminal_failure"
|
|
203
|
+
| "deleted";
|
|
204
|
+
|
|
146
205
|
type ManagedSessionRecord = {
|
|
147
206
|
session: AgentSession;
|
|
148
207
|
mcpManager: MCPManager | undefined;
|
|
@@ -155,6 +214,17 @@ type ManagedSessionRecord = {
|
|
|
155
214
|
// Installed after the bootstrap race guard or eagerly when the client starts a prompt;
|
|
156
215
|
// released in `#disposeSessionRecord`. Lives independent of any prompt turn.
|
|
157
216
|
lifetimeUnsubscribe: (() => void) | undefined;
|
|
217
|
+
// Per-record terminal state machine (see AcpSessionTerminalState). After ANY
|
|
218
|
+
// delete failure the record stays non-`active` so prompt/config/close/load/
|
|
219
|
+
// resume/fork remain rejected. Only `pre_dispatch_retryable` and
|
|
220
|
+
// `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
|
|
221
|
+
terminalState: AcpSessionTerminalState;
|
|
222
|
+
// The settled terminal failure error, shared with subsequent delete callers
|
|
223
|
+
// so a terminal_failure is observable without re-running the terminal op.
|
|
224
|
+
terminalFailure: Error | undefined;
|
|
225
|
+
// Resolves when an in-progress terminal delete settles. Concurrent delete
|
|
226
|
+
// callers and abort/shutdown join this promise.
|
|
227
|
+
terminalPromise: Promise<DeleteSessionResponse> | undefined;
|
|
158
228
|
};
|
|
159
229
|
|
|
160
230
|
type ReplayableMessage = {
|
|
@@ -276,10 +346,24 @@ async function elicitFromAcpClient(
|
|
|
276
346
|
finish(undefined);
|
|
277
347
|
});
|
|
278
348
|
const response = await promise;
|
|
279
|
-
if (
|
|
349
|
+
if (
|
|
350
|
+
response?.action !== "accept" ||
|
|
351
|
+
typeof response.content !== "object" ||
|
|
352
|
+
response.content === null ||
|
|
353
|
+
!("value" in response.content)
|
|
354
|
+
) {
|
|
280
355
|
return undefined;
|
|
281
356
|
}
|
|
282
|
-
|
|
357
|
+
const value = response.content.value;
|
|
358
|
+
if (
|
|
359
|
+
typeof value === "string" ||
|
|
360
|
+
typeof value === "number" ||
|
|
361
|
+
typeof value === "boolean" ||
|
|
362
|
+
(Array.isArray(value) && value.every(item => typeof item === "string"))
|
|
363
|
+
) {
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
283
367
|
}
|
|
284
368
|
|
|
285
369
|
/**
|
|
@@ -381,6 +465,23 @@ export class AcpAgent implements Agent {
|
|
|
381
465
|
#cleanupRegistered = false;
|
|
382
466
|
#clientCapabilities: ClientCapabilities | undefined;
|
|
383
467
|
#cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
|
|
468
|
+
/** Immutable canonical cwd locked by the first successful explicit-cwd lifecycle/list call. */
|
|
469
|
+
#canonicalCwdScope: string | undefined;
|
|
470
|
+
/** Authority snapshot from the last complete strict scoped inventory. Invalidation bumps this. */
|
|
471
|
+
#authorityGeneration = 0;
|
|
472
|
+
#authoritySnapshot: AcpAuthoritySnapshot | undefined;
|
|
473
|
+
/**
|
|
474
|
+
* Same-connection retry evidence for an incomplete verified delete. Keyed by
|
|
475
|
+
* session id. Phase-aware: every cleanup_pending carries the transcript
|
|
476
|
+
* identity at failure time, which is re-verified against the fresh candidate
|
|
477
|
+
* on retry so a replaced transcript cannot authorize a replacement; the
|
|
478
|
+
* artifacts-phase result additionally carries the artifact directory
|
|
479
|
+
* identity to re-accept. Cleared once a delete reaches `kind: "deleted"`.
|
|
480
|
+
*/
|
|
481
|
+
#pendingDeleteEvidence = new Map<string, PendingDeleteEvidence>();
|
|
482
|
+
#shutdownPromise: Promise<void> | undefined;
|
|
483
|
+
#lifecycleOperations = new Set<Promise<unknown>>();
|
|
484
|
+
#shuttingDown = false;
|
|
384
485
|
|
|
385
486
|
constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession) {
|
|
386
487
|
this.#connection = connection;
|
|
@@ -434,6 +535,7 @@ export class AcpAgent implements Agent {
|
|
|
434
535
|
fork: {},
|
|
435
536
|
resume: {},
|
|
436
537
|
close: {},
|
|
538
|
+
delete: {},
|
|
437
539
|
},
|
|
438
540
|
},
|
|
439
541
|
};
|
|
@@ -452,12 +554,17 @@ export class AcpAgent implements Agent {
|
|
|
452
554
|
}
|
|
453
555
|
|
|
454
556
|
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
|
455
|
-
this.#
|
|
456
|
-
|
|
557
|
+
this.#rejectIfShuttingDown();
|
|
558
|
+
// Stage scope before the lifecycle op; commit only after it succeeds so a
|
|
559
|
+
// failed first call does not pin the connection to a cwd it never served.
|
|
560
|
+
const canonical = this.#stageCanonicalScope(params.cwd);
|
|
561
|
+
const record = await this.#trackLifecycle(() => this.#createNewSessionRecord(params.cwd, params.mcpServers));
|
|
562
|
+
this.#rejectIfShuttingDown();
|
|
563
|
+
this.#commitCanonicalScope(canonical);
|
|
564
|
+
this.#invalidateAuthority();
|
|
457
565
|
const response: NewSessionResponse = {
|
|
458
566
|
sessionId: record.session.sessionId,
|
|
459
567
|
configOptions: this.#buildConfigOptions(record.session),
|
|
460
|
-
models: this.#buildModelState(record.session),
|
|
461
568
|
modes: this.#buildModeState(record.session),
|
|
462
569
|
};
|
|
463
570
|
this.#scheduleBootstrapUpdates(record.session.sessionId);
|
|
@@ -465,41 +572,103 @@ export class AcpAgent implements Agent {
|
|
|
465
572
|
}
|
|
466
573
|
|
|
467
574
|
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
|
468
|
-
this.#
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
575
|
+
this.#rejectIfShuttingDown();
|
|
576
|
+
const canonical = this.#stageCanonicalScope(params.cwd);
|
|
577
|
+
const existingRecord = this.#sessions.get(params.sessionId);
|
|
578
|
+
const record = await this.#trackLifecycle(() =>
|
|
579
|
+
this.#loadManagedSession(params.sessionId, params.cwd, params.mcpServers),
|
|
580
|
+
);
|
|
581
|
+
try {
|
|
582
|
+
this.#rejectIfShuttingDown();
|
|
583
|
+
await this.#replaySessionHistory(record);
|
|
584
|
+
this.#rejectIfShuttingDown();
|
|
585
|
+
// Construct every fallible response/bootstrap value inside the
|
|
586
|
+
// transaction so a build/schedule failure rolls back the prepared
|
|
587
|
+
// record and never pins the connection.
|
|
588
|
+
const response: LoadSessionResponse = {
|
|
589
|
+
configOptions: this.#buildConfigOptions(record.session),
|
|
590
|
+
modes: this.#buildModeState(record.session),
|
|
591
|
+
};
|
|
592
|
+
this.#scheduleBootstrapUpdates(record.session.sessionId);
|
|
593
|
+
// Commit the cwd scope only after replay, response construction, and
|
|
594
|
+
// bootstrap scheduling all succeed — a failed first load leaves no
|
|
595
|
+
// half-loaded session and does not pin the connection.
|
|
596
|
+
this.#commitCanonicalScope(canonical);
|
|
597
|
+
this.#invalidateAuthority();
|
|
598
|
+
return response;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
// Any pre-return failure: roll back the prepared record so a failed
|
|
601
|
+
// first load leaves no half-loaded session and does not pin the
|
|
602
|
+
// connection, and commit no cwd scope.
|
|
603
|
+
if (record !== existingRecord) {
|
|
604
|
+
this.#sessions.delete(record.session.sessionId);
|
|
605
|
+
await this.#disposeSessionRecord(record);
|
|
606
|
+
}
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
478
609
|
}
|
|
479
610
|
|
|
480
611
|
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
612
|
+
this.#rejectIfShuttingDown();
|
|
613
|
+
const scope = params.cwd ? this.#stageCanonicalScope(params.cwd) : undefined;
|
|
484
614
|
for (const record of this.#sessions.values()) {
|
|
485
615
|
await record.session.sessionManager.flush();
|
|
486
616
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
617
|
+
this.#rejectIfShuttingDown();
|
|
618
|
+
// Cwd-less / global listing stays display-only and non-authorizing.
|
|
619
|
+
if (!scope) {
|
|
620
|
+
const sessions = await this.#listStoredSessions(undefined);
|
|
621
|
+
const offset = this.#parseCursor(params.cursor ?? undefined);
|
|
622
|
+
const paged = sessions.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
623
|
+
const nextOffset = offset + paged.length;
|
|
624
|
+
return {
|
|
625
|
+
sessions: paged.map(session => this.#toSessionInfo(session)),
|
|
626
|
+
nextCursor: nextOffset < sessions.length ? String(nextOffset) : undefined,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
// Explicit scoped list: stage scope, strict raw inventory, build the full
|
|
630
|
+
// authorization/conflict snapshot BEFORE page slicing so duplicate ids
|
|
631
|
+
// beyond page 1 are known before the first page response. Scope commits
|
|
632
|
+
// only after cursor validation, stored-session read, and full response
|
|
633
|
+
// construction succeed — a failed first list never pins the connection.
|
|
634
|
+
if (params.cursor === undefined) {
|
|
635
|
+
// A new first-page request is a new issuance event. Refresh the complete
|
|
636
|
+
// snapshot and invalidate cursors minted by the previous issuance.
|
|
637
|
+
this.#invalidateAuthority();
|
|
638
|
+
}
|
|
639
|
+
const snapshot = this.#buildAuthoritySnapshot(scope);
|
|
640
|
+
// Scoped cursors carry the authority generation at which they were minted
|
|
641
|
+
// (form `${generation}:${offset}`). A lifecycle change between page 1 and
|
|
642
|
+
// page 2 bumps the generation, so the old cursor is rejected instead of
|
|
643
|
+
// silently rebuilding at the new generation. The cwd-less display cursor
|
|
644
|
+
// stays a plain offset (see #parseCursor).
|
|
645
|
+
const offset = this.#parseScopedCursor(params.cursor ?? undefined, snapshot.generation);
|
|
646
|
+
const stored = await this.#listStoredSessions(scope);
|
|
647
|
+
const storedById = new Map(stored.map(s => [s.id, s] as const));
|
|
648
|
+
const pageIds = snapshot.orderedIds.slice(offset, offset + SESSION_PAGE_SIZE);
|
|
649
|
+
const nextOffset = offset + pageIds.length;
|
|
650
|
+
const pageSessions = pageIds.map(id => storedById.get(id)).filter((s): s is StoredSessionInfo => s !== undefined);
|
|
651
|
+
const response: ListSessionsResponse = {
|
|
652
|
+
sessions: pageSessions.map(session => this.#toSessionInfo(session)),
|
|
653
|
+
nextCursor: nextOffset < snapshot.orderedIds.length ? `${snapshot.generation}:${nextOffset}` : undefined,
|
|
494
654
|
};
|
|
655
|
+
// Commit the scope only after cursor validation, stored-session read, and
|
|
656
|
+
// full response construction succeed.
|
|
657
|
+
this.#commitCanonicalScope(scope);
|
|
658
|
+
return response;
|
|
495
659
|
}
|
|
496
660
|
|
|
497
661
|
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
|
498
|
-
this.#
|
|
499
|
-
const
|
|
662
|
+
this.#rejectIfShuttingDown();
|
|
663
|
+
const canonical = this.#stageCanonicalScope(params.cwd);
|
|
664
|
+
const record = await this.#trackLifecycle(() =>
|
|
665
|
+
this.#resumeManagedSession(params.sessionId, params.cwd, params.mcpServers ?? []),
|
|
666
|
+
);
|
|
667
|
+
this.#rejectIfShuttingDown();
|
|
668
|
+
this.#commitCanonicalScope(canonical);
|
|
669
|
+
this.#invalidateAuthority();
|
|
500
670
|
const response: ResumeSessionResponse = {
|
|
501
671
|
configOptions: this.#buildConfigOptions(record.session),
|
|
502
|
-
models: this.#buildModelState(record.session),
|
|
503
672
|
modes: this.#buildModeState(record.session),
|
|
504
673
|
};
|
|
505
674
|
this.#scheduleBootstrapUpdates(record.session.sessionId);
|
|
@@ -507,12 +676,15 @@ export class AcpAgent implements Agent {
|
|
|
507
676
|
}
|
|
508
677
|
|
|
509
678
|
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
|
510
|
-
this.#
|
|
511
|
-
const
|
|
679
|
+
this.#rejectIfShuttingDown();
|
|
680
|
+
const canonical = this.#stageCanonicalScope(params.cwd);
|
|
681
|
+
const record = await this.#trackLifecycle(() => this.#forkManagedSession(params));
|
|
682
|
+
this.#rejectIfShuttingDown();
|
|
683
|
+
this.#commitCanonicalScope(canonical);
|
|
684
|
+
this.#invalidateAuthority();
|
|
512
685
|
const response: ForkSessionResponse = {
|
|
513
686
|
sessionId: record.session.sessionId,
|
|
514
687
|
configOptions: this.#buildConfigOptions(record.session),
|
|
515
|
-
models: this.#buildModelState(record.session),
|
|
516
688
|
modes: this.#buildModeState(record.session),
|
|
517
689
|
};
|
|
518
690
|
this.#scheduleBootstrapUpdates(record.session.sessionId);
|
|
@@ -524,12 +696,42 @@ export class AcpAgent implements Agent {
|
|
|
524
696
|
if (!record) {
|
|
525
697
|
return {};
|
|
526
698
|
}
|
|
699
|
+
this.#rejectIfTerminal(record);
|
|
527
700
|
await this.#closeManagedSession(params.sessionId, record);
|
|
701
|
+
this.#invalidateAuthority();
|
|
528
702
|
return {};
|
|
529
703
|
}
|
|
530
704
|
|
|
705
|
+
/**
|
|
706
|
+
* Hard delete an ACP session. `DeleteSessionRequest` carries only `sessionId`;
|
|
707
|
+
* the server owns authorization through the immutable canonical cwd scope and
|
|
708
|
+
* the complete strict scoped inventory snapshot.
|
|
709
|
+
*
|
|
710
|
+
* - No scope / unknown / already-deleted id → lookup-free `{}`.
|
|
711
|
+
* - Duplicate/conflict id → visible error; neither transcript nor artifacts change.
|
|
712
|
+
* - Active session → reserve `deleting`, drain/cancel prompt, flush, strict
|
|
713
|
+
* dispose/close, recheck identity/state, then verified artifact-first deletion.
|
|
714
|
+
* - Concurrent delete callers join the terminal promise.
|
|
715
|
+
*/
|
|
716
|
+
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
|
717
|
+
// Before scope: return {} without scanning/listing storage.
|
|
718
|
+
if (this.#canonicalCwdScope === undefined) {
|
|
719
|
+
return {};
|
|
720
|
+
}
|
|
721
|
+
if (this.#shuttingDown) {
|
|
722
|
+
throw new Error("ACP session delete is unavailable during shutdown");
|
|
723
|
+
}
|
|
724
|
+
const record = this.#sessions.get(params.sessionId);
|
|
725
|
+
if (record) {
|
|
726
|
+
return await this.#deleteActiveSession(params.sessionId, record);
|
|
727
|
+
}
|
|
728
|
+
// Inactive: read authority from the strict inventory snapshot.
|
|
729
|
+
return await this.#deleteInactiveSession(params.sessionId);
|
|
730
|
+
}
|
|
731
|
+
|
|
531
732
|
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
|
532
733
|
const record = this.#getSessionRecord(params.sessionId);
|
|
734
|
+
this.#rejectIfTerminal(record);
|
|
533
735
|
this.#applyModeChange(record.session, params.modeId);
|
|
534
736
|
await this.#connection.sessionUpdate({
|
|
535
737
|
sessionId: record.session.sessionId,
|
|
@@ -541,6 +743,7 @@ export class AcpAgent implements Agent {
|
|
|
541
743
|
|
|
542
744
|
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
|
543
745
|
const record = this.#getSessionRecord(params.sessionId);
|
|
746
|
+
this.#rejectIfTerminal(record);
|
|
544
747
|
if (typeof params.value === "boolean") {
|
|
545
748
|
throw new Error(`Unsupported boolean ACP config option: ${params.configId}`);
|
|
546
749
|
}
|
|
@@ -580,15 +783,9 @@ export class AcpAgent implements Agent {
|
|
|
580
783
|
return { configOptions: this.#buildConfigOptions(record.session) };
|
|
581
784
|
}
|
|
582
785
|
|
|
583
|
-
async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
|
|
584
|
-
const record = this.#getSessionRecord(params.sessionId);
|
|
585
|
-
await this.#setModelById(record.session, params.modelId);
|
|
586
|
-
await this.#pushConfigOptionUpdate(record);
|
|
587
|
-
return {};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
786
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
591
787
|
const record = this.#getSessionRecord(params.sessionId);
|
|
788
|
+
this.#rejectIfTerminal(record);
|
|
592
789
|
const activeTurn = record.promptTurn;
|
|
593
790
|
if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
|
|
594
791
|
throw new Error("ACP prompt already in progress for this session");
|
|
@@ -607,8 +804,9 @@ export class AcpAgent implements Agent {
|
|
|
607
804
|
|
|
608
805
|
const converted = this.#convertPromptBlocks(params.prompt);
|
|
609
806
|
const pendingPrompt = Promise.withResolvers<PromptResponse>();
|
|
807
|
+
const metaUserMessageId = params._meta?.userMessageId;
|
|
610
808
|
record.promptTurn = {
|
|
611
|
-
userMessageId:
|
|
809
|
+
userMessageId: typeof metaUserMessageId === "string" ? metaUserMessageId : crypto.randomUUID(),
|
|
612
810
|
cancelRequested: false,
|
|
613
811
|
settled: false,
|
|
614
812
|
cleanup: undefined,
|
|
@@ -694,7 +892,7 @@ export class AcpAgent implements Agent {
|
|
|
694
892
|
this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
|
|
695
893
|
record.session.sessionManager.getUsageStatistics(),
|
|
696
894
|
),
|
|
697
|
-
userMessageId: promptTurn
|
|
895
|
+
_meta: promptTurn ? { userMessageId: promptTurn.userMessageId } : undefined,
|
|
698
896
|
});
|
|
699
897
|
return;
|
|
700
898
|
}
|
|
@@ -814,6 +1012,11 @@ export class AcpAgent implements Agent {
|
|
|
814
1012
|
try {
|
|
815
1013
|
await cleanup;
|
|
816
1014
|
} catch (error: unknown) {
|
|
1015
|
+
// A terminal delete owns the record; let the delete handle the failure
|
|
1016
|
+
// rather than racing it with an independent close.
|
|
1017
|
+
if (record.terminalState !== "active") {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
817
1020
|
logger.warn("ACP cancel cleanup timed out; closing session", { sessionId: record.session.sessionId, error });
|
|
818
1021
|
await this.#closeManagedSession(record.session.sessionId, record);
|
|
819
1022
|
}
|
|
@@ -838,7 +1041,7 @@ export class AcpAgent implements Agent {
|
|
|
838
1041
|
this.#finishPrompt(record, {
|
|
839
1042
|
stopReason: "cancelled",
|
|
840
1043
|
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
|
841
|
-
userMessageId: promptTurn.userMessageId,
|
|
1044
|
+
_meta: { userMessageId: promptTurn.userMessageId },
|
|
842
1045
|
});
|
|
843
1046
|
return cleanup;
|
|
844
1047
|
}
|
|
@@ -957,12 +1160,49 @@ export class AcpAgent implements Agent {
|
|
|
957
1160
|
this.#connection.signal.addEventListener(
|
|
958
1161
|
"abort",
|
|
959
1162
|
() => {
|
|
960
|
-
|
|
1163
|
+
this.#shuttingDown = true;
|
|
1164
|
+
// Mark shutdown under mutex (no actual mutex needed — single-threaded),
|
|
1165
|
+
// reject new scope/inventory/lifecycle work, then await terminal work.
|
|
1166
|
+
this.#shutdownPromise = (async () => {
|
|
1167
|
+
// Join lifecycle preparation that has not registered yet. Each tracked
|
|
1168
|
+
// operation rechecks shutdown and disposes its prepared session before settling.
|
|
1169
|
+
await Promise.allSettled(Array.from(this.#lifecycleOperations));
|
|
1170
|
+
const terminalPromises = Array.from(this.#sessions.values())
|
|
1171
|
+
.map(r => r.terminalPromise)
|
|
1172
|
+
.filter((p): p is Promise<DeleteSessionResponse> => p !== undefined);
|
|
1173
|
+
await Promise.allSettled(terminalPromises);
|
|
1174
|
+
await this.#disposeAllSessions();
|
|
1175
|
+
// Clear connection-local authority.
|
|
1176
|
+
this.#authoritySnapshot = undefined;
|
|
1177
|
+
this.#canonicalCwdScope = undefined;
|
|
1178
|
+
})();
|
|
961
1179
|
},
|
|
962
1180
|
{ once: true },
|
|
963
1181
|
);
|
|
964
1182
|
}
|
|
965
1183
|
|
|
1184
|
+
/**
|
|
1185
|
+
* Connection-local shutdown promise. Resolves once all terminal work (dispose,
|
|
1186
|
+
* active deletes, abort cleanup) has settled. `acp-mode.ts` awaits this after
|
|
1187
|
+
* transport closure so `process.exit` never kills in-flight deletion/disposal.
|
|
1188
|
+
*/
|
|
1189
|
+
get shutdownPromise(): Promise<void> {
|
|
1190
|
+
return this.#shutdownPromise ?? Promise.resolve();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async #trackLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
1194
|
+
this.#rejectIfShuttingDown();
|
|
1195
|
+
const start = Promise.withResolvers<void>();
|
|
1196
|
+
const promise = start.promise.then(operation);
|
|
1197
|
+
this.#lifecycleOperations.add(promise);
|
|
1198
|
+
start.resolve();
|
|
1199
|
+
try {
|
|
1200
|
+
return await promise;
|
|
1201
|
+
} finally {
|
|
1202
|
+
this.#lifecycleOperations.delete(promise);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
966
1206
|
async #createNewSessionRecord(cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
|
|
967
1207
|
const session = await this.#createSession(path.resolve(cwd));
|
|
968
1208
|
try {
|
|
@@ -977,31 +1217,45 @@ export class AcpAgent implements Agent {
|
|
|
977
1217
|
async #loadManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
|
|
978
1218
|
const existing = this.#sessions.get(sessionId);
|
|
979
1219
|
if (existing) {
|
|
1220
|
+
this.#rejectIfTerminal(existing);
|
|
980
1221
|
this.#assertMatchingCwd(existing.session, cwd);
|
|
981
1222
|
await this.#configureMcpServers(existing, mcpServers);
|
|
982
1223
|
return existing;
|
|
983
1224
|
}
|
|
984
1225
|
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
throw new Error(`ACP session not found: ${sessionId}`);
|
|
988
|
-
}
|
|
989
|
-
return await this.#openStoredSession(storedSession.path, cwd, mcpServers, sessionId);
|
|
1226
|
+
const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
|
|
1227
|
+
return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
|
|
990
1228
|
}
|
|
991
1229
|
|
|
992
1230
|
async #resumeManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
|
|
993
1231
|
const existing = this.#sessions.get(sessionId);
|
|
994
1232
|
if (existing) {
|
|
1233
|
+
this.#rejectIfTerminal(existing);
|
|
995
1234
|
this.#assertMatchingCwd(existing.session, cwd);
|
|
996
1235
|
await this.#configureMcpServers(existing, mcpServers);
|
|
997
1236
|
return existing;
|
|
998
1237
|
}
|
|
999
1238
|
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1239
|
+
const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
|
|
1240
|
+
return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Resolve a single exact-one scoped candidate via a fresh strict inventory.
|
|
1245
|
+
* Rejects duplicates and inventory failures — never falls back to a forgiving
|
|
1246
|
+
* first-match lookup. Used by load/resume so authoritative lifecycle paths bind
|
|
1247
|
+
* exact identity before opening a stored session.
|
|
1248
|
+
*/
|
|
1249
|
+
#resolveStrictLifecycleCandidate(sessionId: string, scope: string): StrictInventoryCandidate {
|
|
1250
|
+
const snapshot = this.#buildFreshAuthoritySnapshot(scope);
|
|
1251
|
+
const entry = snapshot.entries.get(sessionId);
|
|
1252
|
+
if (!entry) {
|
|
1002
1253
|
throw new Error(`ACP session not found: ${sessionId}`);
|
|
1003
1254
|
}
|
|
1004
|
-
|
|
1255
|
+
if (entry.kind !== "candidate") {
|
|
1256
|
+
throw new Error(`ACP session ${sessionId} load failed: ${entry.reason}`);
|
|
1257
|
+
}
|
|
1258
|
+
return entry.candidate;
|
|
1005
1259
|
}
|
|
1006
1260
|
|
|
1007
1261
|
async #forkManagedSession(params: ForkSessionRequest): Promise<ManagedSessionRecord> {
|
|
@@ -1024,14 +1278,23 @@ export class AcpAgent implements Agent {
|
|
|
1024
1278
|
}
|
|
1025
1279
|
|
|
1026
1280
|
async #openStoredSession(
|
|
1027
|
-
|
|
1281
|
+
issued: StrictInventoryCandidate,
|
|
1028
1282
|
cwd: string,
|
|
1029
1283
|
mcpServers: McpServer[],
|
|
1030
1284
|
sessionId: string,
|
|
1031
1285
|
): Promise<ManagedSessionRecord> {
|
|
1032
1286
|
const session = await this.#createSession(path.resolve(cwd));
|
|
1033
1287
|
try {
|
|
1034
|
-
const
|
|
1288
|
+
const current = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
|
|
1289
|
+
if (
|
|
1290
|
+
current.path !== issued.path ||
|
|
1291
|
+
current.cwd !== issued.cwd ||
|
|
1292
|
+
current.identity.dev !== issued.identity.dev ||
|
|
1293
|
+
current.identity.ino !== issued.identity.ino
|
|
1294
|
+
) {
|
|
1295
|
+
throw new Error(`ACP session ${sessionId} changed while opening`);
|
|
1296
|
+
}
|
|
1297
|
+
const success = await session.switchSession(current.path);
|
|
1035
1298
|
if (!success) {
|
|
1036
1299
|
throw new Error(`ACP session load was cancelled: ${sessionId}`);
|
|
1037
1300
|
}
|
|
@@ -1050,6 +1313,10 @@ export class AcpAgent implements Agent {
|
|
|
1050
1313
|
try {
|
|
1051
1314
|
await this.#configureExtensions(record);
|
|
1052
1315
|
await this.#configureMcpServers(record, mcpServers);
|
|
1316
|
+
if (this.#shuttingDown) {
|
|
1317
|
+
await this.#disposeSessionRecord(record);
|
|
1318
|
+
throw new Error("ACP session lifecycle is unavailable during shutdown");
|
|
1319
|
+
}
|
|
1053
1320
|
this.#sessions.set(session.sessionId, record);
|
|
1054
1321
|
return record;
|
|
1055
1322
|
} catch (error) {
|
|
@@ -1069,6 +1336,9 @@ export class AcpAgent implements Agent {
|
|
|
1069
1336
|
toolArgsById: new Map(),
|
|
1070
1337
|
extensionsConfigured: false,
|
|
1071
1338
|
lifetimeUnsubscribe: undefined,
|
|
1339
|
+
terminalState: "active",
|
|
1340
|
+
terminalFailure: undefined,
|
|
1341
|
+
terminalPromise: undefined,
|
|
1072
1342
|
};
|
|
1073
1343
|
}
|
|
1074
1344
|
|
|
@@ -1136,6 +1406,9 @@ export class AcpAgent implements Agent {
|
|
|
1136
1406
|
async #resolveForkSourceSessionPath(sessionId: string): Promise<string> {
|
|
1137
1407
|
const loaded = this.#sessions.get(sessionId);
|
|
1138
1408
|
if (loaded) {
|
|
1409
|
+
if (loaded.terminalState !== "active") {
|
|
1410
|
+
throw new Error(`ACP session fork is unavailable while it is in terminal state: ${sessionId}`);
|
|
1411
|
+
}
|
|
1139
1412
|
if (isPromptTurnInFlight(loaded.promptTurn)) {
|
|
1140
1413
|
throw new Error(`ACP session fork is unavailable while a prompt is in progress: ${sessionId}`);
|
|
1141
1414
|
}
|
|
@@ -1147,11 +1420,21 @@ export class AcpAgent implements Agent {
|
|
|
1147
1420
|
return sessionPath;
|
|
1148
1421
|
}
|
|
1149
1422
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1423
|
+
// Inactive fork source: exact-one scoped lookup only. Never use global
|
|
1424
|
+
// listAll/first-match — that was the P1 data-loss path.
|
|
1425
|
+
const scope = this.#canonicalCwdScope;
|
|
1426
|
+
if (!scope) {
|
|
1427
|
+
throw new Error(`ACP session not found (no scope established): ${sessionId}`);
|
|
1428
|
+
}
|
|
1429
|
+
const snapshot = this.#buildFreshAuthoritySnapshot(scope);
|
|
1430
|
+
const entry = snapshot.entries.get(sessionId);
|
|
1431
|
+
if (!entry) {
|
|
1152
1432
|
throw new Error(`ACP session not found: ${sessionId}`);
|
|
1153
1433
|
}
|
|
1154
|
-
|
|
1434
|
+
if (entry.kind !== "candidate") {
|
|
1435
|
+
throw new Error(`ACP session fork failed: ${entry.reason}`);
|
|
1436
|
+
}
|
|
1437
|
+
return entry.candidate.path;
|
|
1155
1438
|
}
|
|
1156
1439
|
|
|
1157
1440
|
async #handlePromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
|
|
@@ -1189,7 +1472,7 @@ export class AcpAgent implements Agent {
|
|
|
1189
1472
|
this.#finishPrompt(record, {
|
|
1190
1473
|
stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
|
|
1191
1474
|
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
|
1192
|
-
userMessageId: promptTurn.userMessageId,
|
|
1475
|
+
_meta: { userMessageId: promptTurn.userMessageId },
|
|
1193
1476
|
});
|
|
1194
1477
|
}
|
|
1195
1478
|
}
|
|
@@ -1281,8 +1564,10 @@ export class AcpAgent implements Agent {
|
|
|
1281
1564
|
case "length":
|
|
1282
1565
|
return "max_tokens";
|
|
1283
1566
|
case "error": {
|
|
1284
|
-
|
|
1285
|
-
|
|
1567
|
+
if (lastAssistant?.errorKind === "provider_safety_stop") {
|
|
1568
|
+
return "refusal";
|
|
1569
|
+
}
|
|
1570
|
+
if (isLegacyProviderSafetyStopMessage(lastAssistant?.errorMessage ?? "")) {
|
|
1286
1571
|
return "refusal";
|
|
1287
1572
|
}
|
|
1288
1573
|
return "end_turn";
|
|
@@ -1312,6 +1597,281 @@ export class AcpAgent implements Agent {
|
|
|
1312
1597
|
}
|
|
1313
1598
|
}
|
|
1314
1599
|
|
|
1600
|
+
/**
|
|
1601
|
+
* Validate the cwd and reject cross-cwd calls against an already-committed scope,
|
|
1602
|
+
* WITHOUT committing. Returns the canonical (resolved) cwd. Pair with
|
|
1603
|
+
* {@link #commitCanonicalScope} after the lifecycle operation succeeds so a failed
|
|
1604
|
+
* first new/load/resume/fork/list never pins the connection to a cwd it never served.
|
|
1605
|
+
*/
|
|
1606
|
+
#stageCanonicalScope(cwd: string): string {
|
|
1607
|
+
this.#assertAbsoluteCwd(cwd);
|
|
1608
|
+
const canonical = path.resolve(cwd);
|
|
1609
|
+
if (this.#canonicalCwdScope !== undefined && this.#canonicalCwdScope !== canonical) {
|
|
1610
|
+
throw new Error(`ACP connection is scoped to ${this.#canonicalCwdScope}, not ${canonical}`);
|
|
1611
|
+
}
|
|
1612
|
+
return canonical;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** Commit the canonical cwd scope on the first successful explicit-cwd call. */
|
|
1616
|
+
#commitCanonicalScope(canonical: string): void {
|
|
1617
|
+
if (this.#canonicalCwdScope === undefined) {
|
|
1618
|
+
this.#canonicalCwdScope = canonical;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** Bump the generation and discard the authority snapshot on lifecycle changes. */
|
|
1623
|
+
#invalidateAuthority(): void {
|
|
1624
|
+
this.#authorityGeneration += 1;
|
|
1625
|
+
this.#authoritySnapshot = undefined;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
#rejectIfTerminal(record: ManagedSessionRecord): void {
|
|
1629
|
+
if (record.terminalState !== "active") {
|
|
1630
|
+
throw new Error(`ACP session ${record.session.sessionId} is in terminal state: ${record.terminalState}`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
#rejectIfShuttingDown(): void {
|
|
1635
|
+
if (this.#shuttingDown) {
|
|
1636
|
+
throw new Error("ACP session lifecycle is unavailable during shutdown");
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Build (or reuse) the complete authorization snapshot from a strict raw scoped
|
|
1642
|
+
* inventory. The cached pagination snapshot is reused for display/list paging.
|
|
1643
|
+
* Any inventory failure throws a sanitized error — it grants zero authority.
|
|
1644
|
+
* The snapshot binds every discovered id to its exact-identity candidate or a
|
|
1645
|
+
* conflict tombstone BEFORE pagination, so duplicates beyond page 1 are known
|
|
1646
|
+
* before the first page response.
|
|
1647
|
+
*/
|
|
1648
|
+
#buildAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
|
|
1649
|
+
if (this.#authoritySnapshot?.scope === scope) {
|
|
1650
|
+
return this.#authoritySnapshot;
|
|
1651
|
+
}
|
|
1652
|
+
return this.#rebuildAuthoritySnapshot(scope, true);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Build a FRESH authorization snapshot, ignoring the cached pagination snapshot.
|
|
1657
|
+
* Destructive authority (delete, inactive fork) must read storage at mutation
|
|
1658
|
+
* time so an external create/remove/replace/duplicate between list and delete
|
|
1659
|
+
* cannot yield a stale no-op or wrong mutation. The result is NOT cached: it
|
|
1660
|
+
* must not poison the generation-aware pagination cursor contract.
|
|
1661
|
+
*/
|
|
1662
|
+
#buildFreshAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
|
|
1663
|
+
return this.#rebuildAuthoritySnapshot(scope, false);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
#rebuildAuthoritySnapshot(scope: string, cache: boolean): AcpAuthoritySnapshot {
|
|
1667
|
+
const inventory = SessionManager.inventorySessionsStrict(scope);
|
|
1668
|
+
if (inventory.kind === "failure") {
|
|
1669
|
+
const messages = inventory.failures.map(f => `${f.kind}: ${f.message}`);
|
|
1670
|
+
throw new Error(`ACP scoped session inventory is incomplete: ${messages.join("; ")}`);
|
|
1671
|
+
}
|
|
1672
|
+
const entries = new Map<string, AcpAuthorityEntry>();
|
|
1673
|
+
const orderedIds: string[] = [];
|
|
1674
|
+
for (const candidate of inventory.candidates) {
|
|
1675
|
+
const existing = entries.get(candidate.id);
|
|
1676
|
+
if (existing) {
|
|
1677
|
+
entries.set(candidate.id, { kind: "conflict", reason: "Duplicate session id in scoped inventory" });
|
|
1678
|
+
} else {
|
|
1679
|
+
entries.set(candidate.id, { kind: "candidate", candidate });
|
|
1680
|
+
orderedIds.push(candidate.id);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const snapshot: AcpAuthoritySnapshot = {
|
|
1684
|
+
scope,
|
|
1685
|
+
entries,
|
|
1686
|
+
orderedIds,
|
|
1687
|
+
generation: this.#authorityGeneration,
|
|
1688
|
+
};
|
|
1689
|
+
if (cache) {
|
|
1690
|
+
this.#authoritySnapshot = snapshot;
|
|
1691
|
+
}
|
|
1692
|
+
return snapshot;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Delete an active (loaded) session via the per-record terminal state machine.
|
|
1697
|
+
* Reserves `deleting` before the first await; concurrent deletes join the
|
|
1698
|
+
* terminal promise. On failure the state transitions to `pre_dispatch_retryable`
|
|
1699
|
+
* (close_failed_retryable), `cleanup_pending` (verified-delete partial), or
|
|
1700
|
+
* `terminal_failure` (close_unknown / quiescence / unexpected) — all non-`active`
|
|
1701
|
+
* states reject prompt/config/close/load/resume/fork. Only `pre_dispatch_retryable`
|
|
1702
|
+
* and `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
|
|
1703
|
+
*/
|
|
1704
|
+
async #deleteActiveSession(sessionId: string, record: ManagedSessionRecord): Promise<DeleteSessionResponse> {
|
|
1705
|
+
// Concurrent delete joins the in-progress terminal promise.
|
|
1706
|
+
if (record.terminalPromise) {
|
|
1707
|
+
return await record.terminalPromise;
|
|
1708
|
+
}
|
|
1709
|
+
// terminal_failure: never reopen — share the settled error.
|
|
1710
|
+
if (record.terminalState === "terminal_failure") {
|
|
1711
|
+
throw record.terminalFailure ?? new Error(`ACP session ${sessionId} has a settled terminal failure`);
|
|
1712
|
+
}
|
|
1713
|
+
// Only active / pre_dispatch_retryable / cleanup_pending may start a delete.
|
|
1714
|
+
if (record.terminalState === "deleting") {
|
|
1715
|
+
throw new Error(`ACP session ${sessionId} is being deleted`);
|
|
1716
|
+
}
|
|
1717
|
+
record.terminalState = "deleting";
|
|
1718
|
+
const scope = this.#canonicalCwdScope!;
|
|
1719
|
+
const terminalPromise = (async (): Promise<DeleteSessionResponse> => {
|
|
1720
|
+
try {
|
|
1721
|
+
// Quiesce — failure is terminal (never swallow).
|
|
1722
|
+
try {
|
|
1723
|
+
await this.#quiesceForDelete(sessionId, record);
|
|
1724
|
+
} catch (quiesceError) {
|
|
1725
|
+
record.terminalState = "terminal_failure";
|
|
1726
|
+
record.terminalFailure = quiesceError instanceof Error ? quiesceError : new Error(String(quiesceError));
|
|
1727
|
+
throw quiesceError;
|
|
1728
|
+
}
|
|
1729
|
+
const manager = record.session.sessionManager;
|
|
1730
|
+
// Strict close — outcome determines retryability.
|
|
1731
|
+
const closeOutcome = await record.session.closeWriterStrict();
|
|
1732
|
+
if (closeOutcome.kind !== "closed") {
|
|
1733
|
+
if (closeOutcome.kind === "close_failed_retryable") {
|
|
1734
|
+
record.terminalState = "pre_dispatch_retryable";
|
|
1735
|
+
} else {
|
|
1736
|
+
record.terminalState = "terminal_failure";
|
|
1737
|
+
record.terminalFailure = closeOutcome.error;
|
|
1738
|
+
}
|
|
1739
|
+
const reason =
|
|
1740
|
+
closeOutcome.kind === "close_unknown"
|
|
1741
|
+
? "writer close outcome is unknown"
|
|
1742
|
+
: "writer close failed before dispatch";
|
|
1743
|
+
throw new Error(`ACP session ${sessionId} cannot be deleted: ${reason}`);
|
|
1744
|
+
}
|
|
1745
|
+
// Recheck: the record must still own this exact session.
|
|
1746
|
+
if (this.#sessions.get(sessionId) !== record || record.terminalState !== "deleting") {
|
|
1747
|
+
record.terminalState = "terminal_failure";
|
|
1748
|
+
record.terminalFailure = new Error(`ACP session ${sessionId} state changed during deletion`);
|
|
1749
|
+
throw new Error(`ACP session ${sessionId} state changed during deletion`);
|
|
1750
|
+
}
|
|
1751
|
+
const sessionFile = manager.getSessionFile();
|
|
1752
|
+
const sessionDir = manager.getSessionDir();
|
|
1753
|
+
if (!sessionFile) {
|
|
1754
|
+
record.terminalState = "terminal_failure";
|
|
1755
|
+
record.terminalFailure = new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
|
|
1756
|
+
throw new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
|
|
1757
|
+
}
|
|
1758
|
+
const snapshot = this.#buildFreshAuthoritySnapshot(scope);
|
|
1759
|
+
const entry = snapshot.entries.get(sessionId);
|
|
1760
|
+
if (entry?.kind !== "candidate") {
|
|
1761
|
+
record.terminalState = "terminal_failure";
|
|
1762
|
+
record.terminalFailure = new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
|
|
1763
|
+
throw new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
|
|
1764
|
+
}
|
|
1765
|
+
if (entry.candidate.path !== sessionFile) {
|
|
1766
|
+
record.terminalState = "terminal_failure";
|
|
1767
|
+
record.terminalFailure = new Error(`ACP session ${sessionId} transcript path changed during deletion`);
|
|
1768
|
+
throw new Error(`ACP session ${sessionId} transcript path changed during deletion`);
|
|
1769
|
+
}
|
|
1770
|
+
this.#assertPendingTranscriptIdentity(sessionId, entry.candidate.identity);
|
|
1771
|
+
const sessionsRoot = path.dirname(sessionDir);
|
|
1772
|
+
const target: VerifiedSessionDeleteTarget = {
|
|
1773
|
+
sessionsRoot,
|
|
1774
|
+
transcriptPath: sessionFile,
|
|
1775
|
+
sessionId,
|
|
1776
|
+
cwd: manager.getCwd(),
|
|
1777
|
+
transcriptIdentity: {
|
|
1778
|
+
dev: entry.candidate.identity.dev,
|
|
1779
|
+
ino: entry.candidate.identity.ino,
|
|
1780
|
+
},
|
|
1781
|
+
...this.#pendingArtifactsIdentity(sessionId),
|
|
1782
|
+
};
|
|
1783
|
+
const outcome = await manager.deleteSessionVerified(target);
|
|
1784
|
+
if (outcome.kind !== "deleted") {
|
|
1785
|
+
record.terminalState = "cleanup_pending";
|
|
1786
|
+
this.#recordPendingDeleteEvidence(sessionId, outcome);
|
|
1787
|
+
throw new Error(
|
|
1788
|
+
`ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
// Fully deleted: clear retry evidence, remove from active map, dispose.
|
|
1792
|
+
record.terminalState = "deleted";
|
|
1793
|
+
this.#pendingDeleteEvidence.delete(sessionId);
|
|
1794
|
+
this.#sessions.delete(sessionId);
|
|
1795
|
+
this.#invalidateAuthority();
|
|
1796
|
+
await this.#disposeSessionRecord(record);
|
|
1797
|
+
return {};
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
// terminalState was set at the failure point above. For any
|
|
1800
|
+
// unexpected error that left the state as `deleting`, treat it
|
|
1801
|
+
// as terminal_failure so the record never silently reopens.
|
|
1802
|
+
if (record.terminalState === "deleting") {
|
|
1803
|
+
record.terminalState = "terminal_failure";
|
|
1804
|
+
record.terminalFailure = error instanceof Error ? error : new Error(String(error));
|
|
1805
|
+
}
|
|
1806
|
+
record.terminalPromise = undefined;
|
|
1807
|
+
this.#invalidateAuthority();
|
|
1808
|
+
throw error;
|
|
1809
|
+
}
|
|
1810
|
+
})();
|
|
1811
|
+
record.terminalPromise = terminalPromise;
|
|
1812
|
+
return await terminalPromise;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Delete an inactive (not currently loaded) session. The ID must have been
|
|
1817
|
+
* issued by the last complete scoped authority snapshot; an absent/unissued ID
|
|
1818
|
+
* returns lookup-free `{}` with NO inventory scan. Only after issuance may a
|
|
1819
|
+
* fresh strict inventory revalidate the same exact candidate before mutation.
|
|
1820
|
+
* A repeat-unknown (issued but gone from the fresh inventory) is also lookup-free `{}`.
|
|
1821
|
+
*/
|
|
1822
|
+
async #deleteInactiveSession(sessionId: string): Promise<DeleteSessionResponse> {
|
|
1823
|
+
const scope = this.#canonicalCwdScope!;
|
|
1824
|
+
// Gate: only IDs issued by the last complete scoped authority snapshot
|
|
1825
|
+
// may trigger a fresh inventory scan. Absent/unissued → lookup-free {}.
|
|
1826
|
+
const issued = this.#authoritySnapshot?.entries.get(sessionId);
|
|
1827
|
+
if (!issued) {
|
|
1828
|
+
return {};
|
|
1829
|
+
}
|
|
1830
|
+
if (issued.kind === "conflict") {
|
|
1831
|
+
throw new Error(`ACP session delete failed: ${issued.reason}`);
|
|
1832
|
+
}
|
|
1833
|
+
// Fresh strict inventory to revalidate the same exact candidate.
|
|
1834
|
+
const snapshot = this.#buildFreshAuthoritySnapshot(scope);
|
|
1835
|
+
const entry = snapshot.entries.get(sessionId);
|
|
1836
|
+
if (!entry) {
|
|
1837
|
+
// Repeat unknown: issued but no longer present in the fresh inventory.
|
|
1838
|
+
return {};
|
|
1839
|
+
}
|
|
1840
|
+
if (entry.kind === "conflict") {
|
|
1841
|
+
throw new Error(`ACP session delete failed: ${entry.reason}`);
|
|
1842
|
+
}
|
|
1843
|
+
const candidate = entry.candidate;
|
|
1844
|
+
if (
|
|
1845
|
+
candidate.path !== issued.candidate.path ||
|
|
1846
|
+
candidate.cwd !== issued.candidate.cwd ||
|
|
1847
|
+
candidate.identity.dev !== issued.candidate.identity.dev ||
|
|
1848
|
+
candidate.identity.ino !== issued.candidate.identity.ino
|
|
1849
|
+
) {
|
|
1850
|
+
throw new Error(`ACP session ${sessionId} changed since authority was issued`);
|
|
1851
|
+
}
|
|
1852
|
+
this.#assertPendingTranscriptIdentity(sessionId, candidate.identity);
|
|
1853
|
+
const sessionsRoot = path.dirname(candidate.path);
|
|
1854
|
+
const target: VerifiedSessionDeleteTarget = {
|
|
1855
|
+
sessionsRoot,
|
|
1856
|
+
transcriptPath: candidate.path,
|
|
1857
|
+
sessionId,
|
|
1858
|
+
cwd: candidate.cwd,
|
|
1859
|
+
transcriptIdentity: { dev: candidate.identity.dev, ino: candidate.identity.ino },
|
|
1860
|
+
...this.#pendingArtifactsIdentity(sessionId),
|
|
1861
|
+
};
|
|
1862
|
+
const storage = new FileSessionStorage();
|
|
1863
|
+
const outcome = await storage.deleteSessionVerified(target);
|
|
1864
|
+
if (outcome.kind !== "deleted") {
|
|
1865
|
+
this.#recordPendingDeleteEvidence(sessionId, outcome);
|
|
1866
|
+
throw new Error(
|
|
1867
|
+
`ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
this.#pendingDeleteEvidence.delete(sessionId);
|
|
1871
|
+
this.#invalidateAuthority();
|
|
1872
|
+
return {};
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1315
1875
|
#convertPromptBlocks(blocks: PromptRequest["prompt"]): { text: string; images: AgentImageContent[] } {
|
|
1316
1876
|
const textParts: string[] = [];
|
|
1317
1877
|
const images: AgentImageContent[] = [];
|
|
@@ -1406,28 +1966,6 @@ export class AcpAgent implements Agent {
|
|
|
1406
1966
|
return configOptions;
|
|
1407
1967
|
}
|
|
1408
1968
|
|
|
1409
|
-
#buildModelState(session: AgentSession): SessionModelState | undefined {
|
|
1410
|
-
const models = session.getAvailableModels();
|
|
1411
|
-
if (models.length === 0) {
|
|
1412
|
-
return undefined;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
const availableModels = models.map(model => ({
|
|
1416
|
-
modelId: this.#toModelId(model),
|
|
1417
|
-
name: model.name,
|
|
1418
|
-
description: `${model.provider}/${model.id}`,
|
|
1419
|
-
}));
|
|
1420
|
-
const currentModelId = session.model ? this.#toModelId(session.model) : availableModels[0]?.modelId;
|
|
1421
|
-
if (!currentModelId) {
|
|
1422
|
-
return undefined;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
return {
|
|
1426
|
-
availableModels,
|
|
1427
|
-
currentModelId,
|
|
1428
|
-
};
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
1969
|
#buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> {
|
|
1432
1970
|
return [
|
|
1433
1971
|
{ value: THINKING_OFF, name: "Off" },
|
|
@@ -1679,14 +2217,14 @@ export class AcpAgent implements Agent {
|
|
|
1679
2217
|
const sessionId = record.session.sessionId;
|
|
1680
2218
|
|
|
1681
2219
|
const contextUsage = record.session.getContextUsage();
|
|
1682
|
-
if (contextUsage) {
|
|
2220
|
+
if (contextUsage?.tokens != null) {
|
|
1683
2221
|
const usageStats = record.session.sessionManager.getUsageStatistics();
|
|
1684
2222
|
await this.#connection.sessionUpdate({
|
|
1685
2223
|
sessionId,
|
|
1686
2224
|
update: {
|
|
1687
2225
|
sessionUpdate: "usage_update",
|
|
1688
2226
|
size: contextUsage.contextWindow,
|
|
1689
|
-
used: contextUsage.tokens
|
|
2227
|
+
used: contextUsage.tokens,
|
|
1690
2228
|
cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
|
|
1691
2229
|
},
|
|
1692
2230
|
});
|
|
@@ -1748,16 +2286,6 @@ export class AcpAgent implements Agent {
|
|
|
1748
2286
|
return sessions.sort((left, right) => right.modified.getTime() - left.modified.getTime());
|
|
1749
2287
|
}
|
|
1750
2288
|
|
|
1751
|
-
async #findStoredSession(sessionId: string, cwd: string): Promise<StoredSessionInfo | undefined> {
|
|
1752
|
-
const sessions = await this.#listStoredSessions(cwd);
|
|
1753
|
-
return sessions.find(session => session.id === sessionId);
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
async #findStoredSessionById(sessionId: string): Promise<StoredSessionInfo | undefined> {
|
|
1757
|
-
const sessions = await this.#listStoredSessions();
|
|
1758
|
-
return sessions.find(session => session.id === sessionId);
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
2289
|
#parseCursor(cursor: string | undefined): number {
|
|
1762
2290
|
if (!cursor) {
|
|
1763
2291
|
return 0;
|
|
@@ -1769,6 +2297,32 @@ export class AcpAgent implements Agent {
|
|
|
1769
2297
|
return parsed;
|
|
1770
2298
|
}
|
|
1771
2299
|
|
|
2300
|
+
/**
|
|
2301
|
+
* Parse a scoped cursor of the form `${generation}:${offset}` and reject it when
|
|
2302
|
+
* its embedded generation no longer matches the current authority generation.
|
|
2303
|
+
* Unlike the cwd-less display cursor, the scoped cursor binds the page offset to
|
|
2304
|
+
* the exact authority snapshot that produced it, so a lifecycle invalidation
|
|
2305
|
+
* between pages is observable instead of silently rebuilt.
|
|
2306
|
+
*/
|
|
2307
|
+
#parseScopedCursor(cursor: string | undefined, currentGeneration: number): number {
|
|
2308
|
+
if (!cursor) {
|
|
2309
|
+
return 0;
|
|
2310
|
+
}
|
|
2311
|
+
const separator = cursor.indexOf(":");
|
|
2312
|
+
if (separator <= 0) {
|
|
2313
|
+
throw new Error(`Invalid ACP session cursor: ${cursor}`);
|
|
2314
|
+
}
|
|
2315
|
+
const generation = Number.parseInt(cursor.slice(0, separator), 10);
|
|
2316
|
+
const offset = Number.parseInt(cursor.slice(separator + 1), 10);
|
|
2317
|
+
if (!Number.isFinite(generation) || generation < 0 || !Number.isFinite(offset) || offset < 0) {
|
|
2318
|
+
throw new Error(`Invalid ACP session cursor: ${cursor}`);
|
|
2319
|
+
}
|
|
2320
|
+
if (generation !== currentGeneration) {
|
|
2321
|
+
throw new Error("ACP session list cursor is stale; the scoped session inventory changed");
|
|
2322
|
+
}
|
|
2323
|
+
return offset;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
1772
2326
|
async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
|
|
1773
2327
|
const cwd = record.session.sessionManager.getCwd();
|
|
1774
2328
|
const replayedToolCallIds = new Set<string>();
|
|
@@ -2169,11 +2723,14 @@ export class AcpAgent implements Agent {
|
|
|
2169
2723
|
headers: this.#toNameValueMap(server.headers),
|
|
2170
2724
|
};
|
|
2171
2725
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2726
|
+
if (server.type === "sse") {
|
|
2727
|
+
return {
|
|
2728
|
+
type: "sse",
|
|
2729
|
+
url: server.url,
|
|
2730
|
+
headers: this.#toNameValueMap(server.headers),
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
throw new Error(`Unsupported ACP MCP transport: ${server.type}`);
|
|
2177
2734
|
}
|
|
2178
2735
|
|
|
2179
2736
|
#toNameValueMap(values: Array<{ name: string; value: string }>): { [name: string]: string } {
|
|
@@ -2202,6 +2759,79 @@ export class AcpAgent implements Agent {
|
|
|
2202
2759
|
logger.warn("Failed to abort ACP prompt during session close", { error });
|
|
2203
2760
|
}
|
|
2204
2761
|
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Delete-specific strict prompt quiescence barrier. Mirrors the cancel setup of
|
|
2764
|
+
* {@link #cancelPromptForClose} but NEVER swallows abort/timeout failures, then
|
|
2765
|
+
* waits for full idle + async-delivery drain. A quiescence failure blocks the
|
|
2766
|
+
* destructive mutation that follows — it is not catch-and-logged. The best-effort
|
|
2767
|
+
* close path is preserved separately for nondestructive shutdown.
|
|
2768
|
+
*/
|
|
2769
|
+
async #quiesceForDelete(sessionId: string, record: ManagedSessionRecord): Promise<void> {
|
|
2770
|
+
const promptTurn = record.promptTurn;
|
|
2771
|
+
if (isPromptTurnInFlight(promptTurn)) {
|
|
2772
|
+
const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
|
|
2773
|
+
await cleanup;
|
|
2774
|
+
}
|
|
2775
|
+
await this.#waitForAcpPromptIdle(record);
|
|
2776
|
+
// Explicit post-drain status check: the drain loop's boolean return is
|
|
2777
|
+
// not trusted as proof. Treat a drain false/timeout/no-progress/max-pass
|
|
2778
|
+
// as unproven and verify the owner-scoped delivery state directly. Any
|
|
2779
|
+
// remaining queued/delivering work blocks the destructive mutation.
|
|
2780
|
+
const delivery = record.session.getAsyncDeliveryStateForAcp();
|
|
2781
|
+
if (delivery.queued > 0 || delivery.delivering) {
|
|
2782
|
+
throw new Error(`ACP session ${sessionId} cannot be deleted: async delivery is not quiesced`);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Record phase-aware same-connection retry evidence for an incomplete verified
|
|
2788
|
+
* delete. The transcript identity (carried by every cleanup_pending) is always
|
|
2789
|
+
* recorded so a retry can reject a replaced transcript; the artifacts identity
|
|
2790
|
+
* is recorded only at the artifacts phase.
|
|
2791
|
+
*/
|
|
2792
|
+
#recordPendingDeleteEvidence(sessionId: string, result: VerifiedSessionDeleteResult): void {
|
|
2793
|
+
if (result.kind !== "cleanup_pending") {
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (result.phase === "artifacts") {
|
|
2797
|
+
this.#pendingDeleteEvidence.set(sessionId, {
|
|
2798
|
+
transcriptIdentity: result.transcriptIdentity,
|
|
2799
|
+
artifactsIdentity: result.artifactsIdentity,
|
|
2800
|
+
});
|
|
2801
|
+
} else {
|
|
2802
|
+
// Transcript phase: artifacts were already removed successfully, so only
|
|
2803
|
+
// the transcript identity remains for retry binding.
|
|
2804
|
+
this.#pendingDeleteEvidence.set(sessionId, {
|
|
2805
|
+
transcriptIdentity: result.transcriptIdentity,
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/** Build the optional `expectedArtifactsIdentity` field for a retry target. */
|
|
2811
|
+
#pendingArtifactsIdentity(sessionId: string): { expectedArtifactsIdentity?: SessionStorageFileIdentity } {
|
|
2812
|
+
const pending = this.#pendingDeleteEvidence.get(sessionId);
|
|
2813
|
+
return pending?.artifactsIdentity ? { expectedArtifactsIdentity: pending.artifactsIdentity } : {};
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* Phase-aware retry gate: a preserved transcript identity must match the fresh
|
|
2818
|
+
* candidate's transcript identity before that candidate can authorize a
|
|
2819
|
+
* replacement. A mismatch (the transcript was externally replaced between the
|
|
2820
|
+
* cleanup_pending failure and the retry) fails closed — it never authorizes a
|
|
2821
|
+
* wrong mutation.
|
|
2822
|
+
*/
|
|
2823
|
+
#assertPendingTranscriptIdentity(sessionId: string, candidateIdentity: { dev: bigint; ino: bigint }): void {
|
|
2824
|
+
const pending = this.#pendingDeleteEvidence.get(sessionId);
|
|
2825
|
+
if (
|
|
2826
|
+
pending &&
|
|
2827
|
+
(pending.transcriptIdentity.dev !== candidateIdentity.dev ||
|
|
2828
|
+
pending.transcriptIdentity.ino !== candidateIdentity.ino)
|
|
2829
|
+
) {
|
|
2830
|
+
throw new Error(
|
|
2831
|
+
`ACP session ${sessionId} transcript identity changed since pending cleanup; replacement rejected`,
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2205
2835
|
|
|
2206
2836
|
async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
|
|
2207
2837
|
record.lifetimeUnsubscribe?.();
|