salmon-loop 0.2.16 → 0.3.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.
Files changed (98) hide show
  1. package/dist/cli/authorization/non-interactive.js +7 -21
  2. package/dist/cli/commands/chat.js +1 -1
  3. package/dist/cli/commands/parallel.js +46 -41
  4. package/dist/cli/commands/run/assistant-message.js +3 -0
  5. package/dist/cli/commands/run/handler.js +2 -1
  6. package/dist/cli/commands/serve.js +112 -156
  7. package/dist/cli/headless/json-protocol.js +1 -1
  8. package/dist/cli/headless/stream-json-protocol.js +3 -2
  9. package/dist/cli/program-bootstrap.js +2 -2
  10. package/dist/cli/slash/runtime.js +5 -1
  11. package/dist/core/adapters/fs/node-fs.js +1 -0
  12. package/dist/core/backends/salmon-loop/task-executor.js +1 -0
  13. package/dist/core/benchmark/patch-artifact.js +1 -1
  14. package/dist/core/context/service.js +5 -2
  15. package/dist/core/extensions/index.js +2 -35
  16. package/dist/core/extensions/merge.js +14 -0
  17. package/dist/core/extensions/redact.js +9 -3
  18. package/dist/core/extensions/schemas.js +2 -51
  19. package/dist/core/facades/cli-authorization-non-interactive.js +1 -1
  20. package/dist/core/facades/cli-program-bootstrap.js +1 -0
  21. package/dist/core/facades/cli-serve.js +2 -1
  22. package/dist/core/grizzco/dsl/strategies.js +1 -3
  23. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +12 -7
  24. package/dist/core/grizzco/engine/transaction/attempt-failure.js +23 -23
  25. package/dist/core/grizzco/engine/transaction/report-mapper.js +3 -0
  26. package/dist/core/grizzco/engine/transaction/transaction-runner.js +14 -0
  27. package/dist/core/grizzco/flows/AutopilotFlow.js +1 -0
  28. package/dist/core/grizzco/flows/SalmonLoopFlow.js +1 -0
  29. package/dist/core/grizzco/steps/apply.js +0 -7
  30. package/dist/core/grizzco/steps/autopilot.js +108 -6
  31. package/dist/core/grizzco/steps/preflight.js +10 -0
  32. package/dist/core/grizzco/steps/tool-runtime.js +1 -0
  33. package/dist/core/interaction/events/bus.js +14 -0
  34. package/dist/core/interaction/orchestration/facade.js +11 -1
  35. package/dist/core/llm/ai-sdk/request-params.js +40 -1
  36. package/dist/core/mcp/bridge/index.js +4 -0
  37. package/dist/core/mcp/bridge/prompt-command-provider.js +261 -0
  38. package/dist/core/mcp/bridge/resource-context-provider.js +259 -0
  39. package/dist/core/mcp/bridge/tool-bridge.js +303 -0
  40. package/dist/core/mcp/cache/resource-cache.js +41 -0
  41. package/dist/core/mcp/catalog/discovery.js +51 -0
  42. package/dist/core/mcp/catalog/notification-router.js +28 -0
  43. package/dist/core/mcp/catalog/prompt-catalog.js +4 -0
  44. package/dist/core/mcp/catalog/resource-catalog.js +7 -0
  45. package/dist/core/mcp/catalog/tool-catalog.js +4 -0
  46. package/dist/core/mcp/client/connection-manager.js +239 -0
  47. package/dist/core/mcp/client/lifecycle.js +13 -0
  48. package/dist/core/mcp/client/transport-factory.js +168 -0
  49. package/dist/core/mcp/config/index.js +32 -0
  50. package/dist/core/mcp/config/schema-v2.js +129 -0
  51. package/dist/core/mcp/host/elicitation-provider.js +209 -0
  52. package/dist/core/mcp/host/roots-provider.js +70 -0
  53. package/dist/core/mcp/host/sampling-provider.js +170 -0
  54. package/dist/core/mcp/index.js +4 -0
  55. package/dist/core/mcp/observability/events.js +19 -0
  56. package/dist/core/mcp/policy/approval-policy.js +2 -0
  57. package/dist/core/mcp/policy/classifier.js +172 -0
  58. package/dist/core/mcp/policy/grants.js +356 -0
  59. package/dist/core/mcp/policy/uri-policy.js +60 -0
  60. package/dist/core/mcp/schema/json-schema-to-zod.js +511 -0
  61. package/dist/core/mcp/types.js +2 -0
  62. package/dist/core/protocols/a2a/agent-card.js +38 -12
  63. package/dist/core/protocols/a2a/sdk/executor.js +105 -36
  64. package/dist/core/protocols/a2a/sdk/server.js +1311 -3
  65. package/dist/core/protocols/acp/acp-checkpoint-probe.js +113 -0
  66. package/dist/core/protocols/acp/acp-session-persistence.js +336 -0
  67. package/dist/core/protocols/acp/acp-types.js +17 -0
  68. package/dist/core/protocols/acp/formal-agent.js +389 -502
  69. package/dist/core/protocols/acp/handlers.js +3 -0
  70. package/dist/core/protocols/acp/permission-provider.js +11 -39
  71. package/dist/core/protocols/acp/stdio-server.js +20 -1
  72. package/dist/core/protocols/acp/tool-kind-mapping.js +62 -0
  73. package/dist/core/protocols/shared/flow-mode-mapping.js +0 -8
  74. package/dist/core/public-capabilities/flow-mode-metadata.js +0 -6
  75. package/dist/core/public-capabilities/projections.js +1 -0
  76. package/dist/core/runtime/agent-server-runtime.js +2 -3
  77. package/dist/core/runtime/spawn-command.js +8 -2
  78. package/dist/core/runtime/spawn-interactive.js +26 -0
  79. package/dist/core/session/manager.js +48 -25
  80. package/dist/core/tools/builtin/index.js +6 -1
  81. package/dist/core/tools/builtin/proposal.js +0 -7
  82. package/dist/core/tools/builtin/workspace.js +76 -0
  83. package/dist/core/tools/dispatcher.js +1 -0
  84. package/dist/core/tools/loader.js +92 -46
  85. package/dist/core/verification/runner.js +60 -31
  86. package/dist/core/version.js +17 -0
  87. package/dist/core/workspace/capabilities.js +80 -0
  88. package/dist/locales/en.js +17 -3
  89. package/package.json +4 -2
  90. package/dist/core/protocols/a2a/mapper.js +0 -14
  91. package/dist/core/protocols/a2a/sdk/auth-middleware.js +0 -31
  92. package/dist/core/protocols/a2a/task-projection.js +0 -45
  93. package/dist/core/protocols/acp/checkpoint-meta.js +0 -2
  94. package/dist/core/tools/mcp/client.js +0 -308
  95. package/dist/core/tools/mcp/loader.js +0 -110
  96. package/dist/core/tools/mcp/schema.js +0 -54
  97. package/dist/core/tools/mcp/streamable-http.js +0 -101
  98. package/dist/core/tools/mcp/types.js +0 -26
@@ -1,20 +1,22 @@
1
- import { createHash } from 'crypto';
2
1
  import { PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
3
2
  import { text } from '../../../locales/index.js';
4
- import { mkdir, open, readFile, rename, stat, unlink, writeFile, } from '../../adapters/fs/node-fs.js';
5
3
  import { defaultPathAdapter } from '../../adapters/path/path-adapter.js';
6
4
  import { inferTurnStopReasonFromFailure } from '../../interaction/turn-stop-reason.js';
7
5
  import { recordAuditEvent } from '../../observability/audit-trail.js';
8
- import { readPlan } from '../../plan/index.js';
9
6
  import { toAcpPublicModes } from '../../public-capabilities/projections.js';
10
7
  import { buildPublicCapabilityRegistry } from '../../public-capabilities/registry.js';
11
8
  import { parseSlashInput } from '../../slash/parser.js';
9
+ import { Phase } from '../../types/runtime.js';
12
10
  import { buildCanonicalExecutionRequest } from '../shared/execution-request.js';
13
11
  import { parseAcpFlowMode } from '../shared/flow-mode-mapping.js';
12
+ import { probeCheckpoint, probeCheckpointForNewSession, } from './acp-checkpoint-probe.js';
14
13
  import { createAcpCommandRunner } from './acp-command-runner.js';
15
14
  import { createAcpFileSystem } from './acp-filesystem.js';
15
+ import { createAcpSessionPersistence } from './acp-session-persistence.js';
16
+ import { ACP_PERMISSION_POLICY_ASK, ACP_PERMISSION_POLICY_ALLOW_ALL, ACP_PERMISSION_POLICY_DENY_ALL, hashRepoPath, isPermissionPolicyValue, parseTimestamp, } from './acp-types.js';
16
17
  import { createAcpSessionStore, isTerminalTaskEvent } from './handlers.js';
17
18
  import { createAcpToolAuthorizationProvider } from './permission-provider.js';
19
+ import { mapToolKind } from './tool-kind-mapping.js';
18
20
  function formatInputRequiredMessage(inputRequired) {
19
21
  if (!inputRequired || !Array.isArray(inputRequired.questions))
20
22
  return null;
@@ -36,9 +38,6 @@ function formatInputRequiredMessage(inputRequired) {
36
38
  }
37
39
  const ACP_PERMISSION_POLICY_CONFIG_ID = '_salmonloop_permission_policy';
38
40
  const ACP_MODE_CONFIG_ID = '_salmonloop_mode';
39
- const ACP_PERMISSION_POLICY_ASK = 'ask';
40
- const ACP_PERMISSION_POLICY_DENY_ALL = 'deny_all';
41
- const ACP_PERMISSION_POLICY_ALLOW_ALL = 'allow_all';
42
41
  const ACP_DEFAULT_MODE_ID = 'autopilot';
43
42
  const ACP_SESSION_STORE_MAX_ENTRIES = 200;
44
43
  const ACP_SESSION_STORE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30;
@@ -46,6 +45,8 @@ const ACP_SESSION_STORE_LOCK_STALE_MS = 1000 * 30;
46
45
  const ACP_SESSION_STORE_LOCK_HEARTBEAT_MS = 1000 * 5;
47
46
  const ACP_SESSION_STORE_LOCK_ACQUIRE_TIMEOUT_MS = 1000 * 5;
48
47
  const ACP_SESSION_HISTORY_MAX_ENTRIES = 40;
48
+ const ACP_SESSION_LIST_PAGE_SIZE = 50;
49
+ const ACP_SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION]);
49
50
  function isAbsolutePath(filePath) {
50
51
  if (defaultPathAdapter.isAbsolute(filePath))
51
52
  return true;
@@ -86,6 +87,33 @@ function buildJsonResourceContentBlock(data) {
86
87
  },
87
88
  };
88
89
  }
90
+ function encodeSessionListCursor(input) {
91
+ return `${input.offset}:${input.cwd ?? ''}`;
92
+ }
93
+ function decodeSessionListCursor(cursor) {
94
+ const colon = cursor.indexOf(':');
95
+ if (colon < 0) {
96
+ throw new RequestError(-32602, 'Invalid params: invalid session/list cursor');
97
+ }
98
+ const offset = Number(cursor.slice(0, colon));
99
+ if (!Number.isInteger(offset) || offset < 0) {
100
+ throw new RequestError(-32602, 'Invalid params: invalid session/list cursor');
101
+ }
102
+ const cwd = cursor.slice(colon + 1) || null;
103
+ return { offset, cwd };
104
+ }
105
+ function isReplayableSessionContentBlock(block) {
106
+ if (!block || typeof block !== 'object')
107
+ return false;
108
+ switch (block.type) {
109
+ case 'text':
110
+ return typeof block.text === 'string';
111
+ case 'resource_link':
112
+ return typeof block.name === 'string' && typeof block.uri === 'string';
113
+ default:
114
+ return false;
115
+ }
116
+ }
89
117
  const ACP_AVAILABLE_COMMANDS = [
90
118
  { name: 'help', description: text.acp.slashHelpDescription },
91
119
  ];
@@ -103,6 +131,21 @@ function formatResourceLink(block) {
103
131
  const description = block.description ? ` - ${block.description}` : '';
104
132
  return `Resource: ${title} (${block.uri})${description}`;
105
133
  }
134
+ function formatEmbeddedResource(block) {
135
+ const resource = block.resource;
136
+ const uri = typeof resource.uri === 'string' ? resource.uri : 'embedded-resource';
137
+ const mimeType = typeof resource.mimeType === 'string' ? resource.mimeType : undefined;
138
+ if (typeof resource.text === 'string') {
139
+ const header = mimeType
140
+ ? `Embedded resource: ${uri} (${mimeType})`
141
+ : `Embedded resource: ${uri}`;
142
+ return `${header}\n${resource.text}`;
143
+ }
144
+ const header = mimeType
145
+ ? `Embedded binary resource: ${uri} (${mimeType})`
146
+ : `Embedded binary resource: ${uri}`;
147
+ return header;
148
+ }
106
149
  function extractTextFromPrompt(prompt, capabilities) {
107
150
  const parts = [];
108
151
  for (const block of prompt) {
@@ -115,18 +158,19 @@ function extractTextFromPrompt(prompt, capabilities) {
115
158
  break;
116
159
  case 'image':
117
160
  if (!capabilities.image) {
118
- throw new RequestError(-32000, 'Prompt content type image is not supported');
161
+ throw new RequestError(-32602, 'Prompt content type image is not supported');
119
162
  }
120
163
  break;
121
164
  case 'audio':
122
165
  if (!capabilities.audio) {
123
- throw new RequestError(-32000, 'Prompt content type audio is not supported');
166
+ throw new RequestError(-32602, 'Prompt content type audio is not supported');
124
167
  }
125
168
  break;
126
169
  case 'resource':
127
170
  if (!capabilities.embeddedContext) {
128
- throw new RequestError(-32000, 'Prompt content type resource is not supported');
171
+ throw new RequestError(-32602, 'Prompt content type resource is not supported');
129
172
  }
173
+ parts.push(formatEmbeddedResource(block));
130
174
  break;
131
175
  default:
132
176
  throw new RequestError(-32602, 'Invalid params: unsupported content block type');
@@ -134,46 +178,6 @@ function extractTextFromPrompt(prompt, capabilities) {
134
178
  }
135
179
  return parts.join('\n');
136
180
  }
137
- function mapToolKind(toolName, intent) {
138
- if (intent) {
139
- switch (intent.toUpperCase()) {
140
- case 'READ':
141
- return 'read';
142
- case 'LIST':
143
- return 'read';
144
- case 'SEARCH':
145
- return 'search';
146
- case 'WRITE':
147
- return 'edit';
148
- case 'INFRA':
149
- return 'execute';
150
- case 'AGENT':
151
- return 'think';
152
- }
153
- }
154
- const name = toolName.toLowerCase();
155
- if (name.includes('read') ||
156
- name.includes('get') ||
157
- name.includes('view') ||
158
- name.includes('ls') ||
159
- name.includes('list'))
160
- return 'read';
161
- if (name.includes('write') || name.includes('edit') || name.includes('patch'))
162
- return 'edit';
163
- if (name.includes('delete') || name.includes('remove') || name.includes('rm'))
164
- return 'delete';
165
- if (name.includes('move') || name.includes('rename') || name.includes('mv'))
166
- return 'move';
167
- if (name.includes('grep') || name.includes('search') || name.includes('find'))
168
- return 'search';
169
- if (name.includes('run') || name.includes('exec') || name.includes('spawn'))
170
- return 'execute';
171
- if (name.includes('plan') || name.includes('think') || name.includes('reason'))
172
- return 'think';
173
- if (name.includes('fetch') || name.includes('curl') || name.includes('http'))
174
- return 'fetch';
175
- return 'other';
176
- }
177
181
  function buildToolCallContent(textValue) {
178
182
  return [{ type: 'content', content: buildTextContentBlock(textValue) }];
179
183
  }
@@ -220,7 +224,7 @@ function loopEventToSessionUpdate(event) {
220
224
  toolCallId: event.callId,
221
225
  status: 'pending',
222
226
  title: event.toolName,
223
- kind: mapToolKind(event.toolName, event.toolIntent),
227
+ kind: mapToolKind(event.toolName, { intent: event.toolIntent }),
224
228
  content: [],
225
229
  rawInput: event.input,
226
230
  locations: extractLocationFromInput(event.input),
@@ -243,11 +247,6 @@ function loopEventToSessionUpdate(event) {
243
247
  return null;
244
248
  }
245
249
  }
246
- function isPermissionPolicyValue(value) {
247
- return (value === ACP_PERMISSION_POLICY_ASK ||
248
- value === ACP_PERMISSION_POLICY_DENY_ALL ||
249
- value === ACP_PERMISSION_POLICY_ALLOW_ALL);
250
- }
251
250
  function buildConfigOptions(state) {
252
251
  return [
253
252
  {
@@ -277,8 +276,8 @@ function buildConfigOptions(state) {
277
276
  {
278
277
  type: 'select',
279
278
  id: ACP_MODE_CONFIG_ID,
280
- name: 'Execution Flow',
281
- description: 'Choose how the agent should execute this session.',
279
+ name: text.acp.executionFlowName,
280
+ description: text.acp.executionFlowDescription,
282
281
  currentValue: state.modeId,
283
282
  options: ACP_PUBLIC_MODES.map((mode) => ({
284
283
  value: mode.id,
@@ -339,16 +338,6 @@ function buildCurrentModeUpdateIfChanged(state) {
339
338
  state.lastModeDigest = digest;
340
339
  return buildCurrentModeUpdate(state.modeId);
341
340
  }
342
- function getLegacyPermissionPolicyForModeValue(value) {
343
- const normalized = String(value ?? '')
344
- .trim()
345
- .toLowerCase();
346
- if (normalized === 'interactive')
347
- return ACP_PERMISSION_POLICY_ASK;
348
- if (normalized === 'yolo')
349
- return ACP_PERMISSION_POLICY_ALLOW_ALL;
350
- return null;
351
- }
352
341
  function getPermissionPolicyForAuthorization(state) {
353
342
  return state.permissionPolicy;
354
343
  }
@@ -450,6 +439,108 @@ function buildPlanUpdateFromCoreIfChanged(read, state) {
450
439
  entries,
451
440
  };
452
441
  }
442
+ function namedPairsToRecord(pairs) {
443
+ const record = {};
444
+ for (const entry of pairs) {
445
+ record[entry.name] = entry.value;
446
+ }
447
+ return record;
448
+ }
449
+ function defaultAcpMcpCapabilities() {
450
+ return {
451
+ tools: {
452
+ exposeToModel: true,
453
+ allow: ['*'],
454
+ phases: [Phase.VERIFY],
455
+ approval: 'ask',
456
+ },
457
+ resources: {
458
+ allowUris: [],
459
+ autoInclude: false,
460
+ subscribe: false,
461
+ maxBytes: 64_000,
462
+ ttlMs: 30_000,
463
+ },
464
+ prompts: {
465
+ exposeAs: 'none',
466
+ allow: [],
467
+ },
468
+ roots: { mode: 'none' },
469
+ sampling: { enabled: false, maxTokens: 0, maxDepth: 0 },
470
+ elicitation: { enabled: false },
471
+ };
472
+ }
473
+ function acpMcpServersToResolved(mcpServers) {
474
+ if (!Array.isArray(mcpServers))
475
+ return [];
476
+ const resolved = [];
477
+ for (const server of mcpServers) {
478
+ const transportType = 'type' in server ? server.type : 'stdio';
479
+ if (transportType === 'sse' || transportType === 'acp') {
480
+ throw new RequestError(-32602, `Invalid params: unsupported MCP server transport "${transportType}"`);
481
+ }
482
+ if ('type' in server && server.type === 'http') {
483
+ const httpServer = server;
484
+ resolved.push({
485
+ name: httpServer.name,
486
+ enabled: true,
487
+ transport: {
488
+ type: 'http',
489
+ url: httpServer.url,
490
+ headers: namedPairsToRecord(httpServer.headers),
491
+ },
492
+ auth: { type: 'none', scopes: [] },
493
+ trust: 'remote',
494
+ capabilities: defaultAcpMcpCapabilities(),
495
+ scope: 'repo',
496
+ });
497
+ continue;
498
+ }
499
+ if (transportType !== 'stdio') {
500
+ throw new RequestError(-32602, `Invalid params: unsupported MCP server transport "${transportType}"`);
501
+ }
502
+ const stdioServer = server;
503
+ resolved.push({
504
+ name: stdioServer.name,
505
+ enabled: true,
506
+ transport: {
507
+ type: 'stdio',
508
+ command: stdioServer.command,
509
+ args: stdioServer.args,
510
+ env: namedPairsToRecord(stdioServer.env),
511
+ },
512
+ auth: { type: 'none', scopes: [] },
513
+ trust: 'local',
514
+ capabilities: defaultAcpMcpCapabilities(),
515
+ scope: 'repo',
516
+ });
517
+ }
518
+ return resolved;
519
+ }
520
+ function acpMcpServersToExtensions(mcpServers) {
521
+ const resolvedServers = acpMcpServersToResolved(mcpServers);
522
+ if (resolvedServers.length === 0)
523
+ return undefined;
524
+ return {
525
+ mcpServers: resolvedServers,
526
+ toolPlugins: [],
527
+ skillDiscovery: { paths: [], scope: 'repo' },
528
+ };
529
+ }
530
+ function validateAcpMcpServers(mcpServers) {
531
+ void acpMcpServersToResolved(mcpServers);
532
+ }
533
+ function validateUnsupportedAdditionalDirectories(additionalDirectories) {
534
+ if (Array.isArray(additionalDirectories) && additionalDirectories.length > 0) {
535
+ throw new RequestError(-32602, 'Invalid params: additionalDirectories is not supported by this agent');
536
+ }
537
+ }
538
+ function negotiateProtocolVersion(clientProtocolVersion) {
539
+ if (ACP_SUPPORTED_PROTOCOL_VERSIONS.has(clientProtocolVersion)) {
540
+ return clientProtocolVersion;
541
+ }
542
+ return PROTOCOL_VERSION;
543
+ }
453
544
  function extractSlashInput(prompt) {
454
545
  if (prompt.length !== 1)
455
546
  return null;
@@ -472,6 +563,9 @@ function isKnownSlashCommand(commandName) {
472
563
  const normalized = normalizeSlashName(commandName);
473
564
  return ACP_AVAILABLE_COMMANDS.some((cmd) => cmd.name.toLowerCase() === normalized);
474
565
  }
566
+ function isPersistableSession(session) {
567
+ return session.materialized || session.history.length > 0 || typeof session.taskId === 'string';
568
+ }
475
569
  async function awaitTerminalEvent(params) {
476
570
  if (!params.eventBus)
477
571
  return null;
@@ -505,8 +599,9 @@ export function createAcpFormalAgent(deps) {
505
599
  embeddedContext: deps.capabilityPolicy?.promptCapabilities?.embeddedContext ?? false,
506
600
  };
507
601
  const mcpCapabilities = {
508
- http: deps.capabilityPolicy?.mcpCapabilities?.http ?? false,
602
+ http: deps.capabilityPolicy?.mcpCapabilities?.http ?? true,
509
603
  sse: deps.capabilityPolicy?.mcpCapabilities?.sse ?? false,
604
+ acp: deps.capabilityPolicy?.mcpCapabilities?.acp ?? false,
510
605
  };
511
606
  const sessionPersistencePath = deps.sessionPersistencePath;
512
607
  const sessionStorePolicy = {
@@ -518,319 +613,17 @@ export function createAcpFormalAgent(deps) {
518
613
  lockAcquireTimeoutMs: deps.sessionStorePolicy?.lockAcquireTimeoutMs ?? ACP_SESSION_STORE_LOCK_ACQUIRE_TIMEOUT_MS,
519
614
  };
520
615
  const executionBinding = deps.executionBinding ?? 'local';
521
- let sessionsHydrated = false;
522
- let hydratePromise = null;
523
- function parseTimestamp(value) {
524
- if (typeof value !== 'string' || value.length === 0)
525
- return 0;
526
- const parsed = Date.parse(value);
527
- return Number.isFinite(parsed) ? parsed : 0;
528
- }
529
- function pruneSessionRecords(records) {
530
- const cutoff = Date.now() - sessionStorePolicy.maxAgeMs;
531
- return [...records]
532
- .filter((record) => parseTimestamp(record.updatedAt) >= cutoff)
533
- .sort((a, b) => parseTimestamp(b.updatedAt) - parseTimestamp(a.updatedAt))
534
- .slice(0, sessionStorePolicy.maxEntries);
535
- }
536
- function normalizePersistedSessionStore(input) {
537
- if (!input || typeof input !== 'object') {
538
- return { schemaVersion: 2, sessions: [] };
539
- }
540
- const raw = input;
541
- if (!Array.isArray(raw.sessions))
542
- return { schemaVersion: 2, sessions: [] };
543
- if (raw.schemaVersion === 1) {
544
- return {
545
- schemaVersion: 2,
546
- sessions: raw.sessions.map((entry) => ({
547
- id: entry.id,
548
- cwd: entry.cwd,
549
- mcpServers: entry.mcpServers,
550
- createdAt: entry.createdAt,
551
- updatedAt: entry.updatedAt,
552
- title: entry.title,
553
- taskId: undefined,
554
- history: [],
555
- permissionPolicy: isPermissionPolicyValue(String(deps.defaultPermissionPolicy))
556
- ? deps.defaultPermissionPolicy
557
- : ACP_PERMISSION_POLICY_ASK,
558
- modeId: resolveExposedAcpModeId(deps.defaultModeId),
559
- })),
560
- };
561
- }
562
- if (raw.schemaVersion === 2) {
563
- return { schemaVersion: 2, sessions: raw.sessions };
564
- }
565
- return { schemaVersion: 2, sessions: [] };
566
- }
567
- function isPidAlive(pid) {
568
- if (!Number.isInteger(pid) || pid <= 0)
569
- return false;
570
- try {
571
- process.kill(pid, 0);
572
- return true;
573
- }
574
- catch (error) {
575
- if (error &&
576
- typeof error === 'object' &&
577
- 'code' in error &&
578
- error.code === 'EPERM') {
579
- return true;
580
- }
581
- return false;
582
- }
583
- }
584
- function isFileMissing(error) {
585
- return Boolean(error &&
586
- typeof error === 'object' &&
587
- 'code' in error &&
588
- (error.code === 'ENOENT' ||
589
- error.code === 'ENOTDIR'));
590
- }
591
- async function persistSessionsBestEffort() {
592
- if (!sessionPersistencePath)
593
- return;
594
- const dir = defaultPathAdapter.dirname(sessionPersistencePath);
595
- const lockPath = `${sessionPersistencePath}.lock`;
596
- const baseRecords = sessions.list().map((session) => {
597
- const runtimeState = ensureSessionRuntimeState(session.id);
598
- return {
599
- id: session.id,
600
- cwd: session.cwd,
601
- mcpServers: session.mcpServers,
602
- createdAt: session.createdAt,
603
- updatedAt: session.updatedAt,
604
- title: session.title,
605
- taskId: session.taskId,
606
- history: session.history.slice(-sessionStorePolicy.historyMaxEntries),
607
- permissionPolicy: runtimeState.permissionPolicy,
608
- modeId: runtimeState.modeId,
609
- };
610
- });
611
- const prunedRecords = pruneSessionRecords(baseRecords);
612
- const keepIds = new Set(prunedRecords.map((record) => record.id));
613
- for (const record of sessions.list()) {
614
- if (!keepIds.has(record.id)) {
615
- sessions.delete(record.id);
616
- }
617
- }
618
- const payload = { schemaVersion: 2, sessions: prunedRecords };
619
- const primaryRepoPath = prunedRecords[0]?.cwd;
620
- const lockAuditDetails = {
621
- lockPath,
622
- lockPathHash: createHash('sha256').update(lockPath).digest('hex').slice(0, 16),
623
- repoPathHash: primaryRepoPath ? hashRepoPath(primaryRepoPath) : undefined,
624
- };
625
- const tryClearStaleLock = async () => {
626
- try {
627
- const raw = await readFile(lockPath, 'utf8');
628
- const parsed = JSON.parse(raw);
629
- const createdAtMs = typeof parsed.createdAtMs === 'number' && Number.isFinite(parsed.createdAtMs)
630
- ? parsed.createdAtMs
631
- : null;
632
- if (createdAtMs === null)
633
- return;
634
- if (Date.now() - createdAtMs <= sessionStorePolicy.lockStaleMs)
635
- return;
636
- if (typeof parsed.pid === 'number' && isPidAlive(parsed.pid))
637
- return;
638
- await unlink(lockPath);
639
- recordAuditEvent('acp.session.lock.stale_reclaimed', lockAuditDetails, {
640
- source: 'acp',
641
- severity: 'low',
642
- scope: 'session',
643
- phase: 'PREFLIGHT',
644
- });
645
- }
646
- catch {
647
- try {
648
- const lockStat = await stat(lockPath);
649
- const ageMs = Date.now() - lockStat.mtimeMs;
650
- if (Number.isFinite(ageMs) && ageMs > sessionStorePolicy.lockStaleMs * 2) {
651
- await unlink(lockPath);
652
- recordAuditEvent('acp.session.lock.corrupted_reclaimed', {
653
- ...lockAuditDetails,
654
- ageMs: Math.max(0, Math.floor(ageMs)),
655
- }, { source: 'acp', severity: 'medium', scope: 'session', phase: 'PREFLIGHT' });
656
- }
657
- }
658
- catch {
659
- // ignore
660
- }
661
- }
662
- };
663
- let lockHandle;
664
- try {
665
- await mkdir(dir, { recursive: true });
666
- const acquireDeadlineMs = Date.now() + Math.max(250, sessionStorePolicy.lockAcquireTimeoutMs);
667
- for (let attempt = 0; Date.now() < acquireDeadlineMs; attempt += 1) {
668
- try {
669
- lockHandle = await open(lockPath, 'wx');
670
- await lockHandle.writeFile(JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }), 'utf8');
671
- break;
672
- }
673
- catch {
674
- await tryClearStaleLock();
675
- const delayMs = Math.min(250, 20 * (attempt + 1));
676
- await new Promise((resolve) => setTimeout(resolve, delayMs));
677
- }
678
- }
679
- if (!lockHandle) {
680
- recordAuditEvent('acp.session.lock.acquire_timeout', lockAuditDetails, {
681
- source: 'acp',
682
- severity: 'medium',
683
- scope: 'session',
684
- phase: 'PREFLIGHT',
685
- });
686
- throw new Error('ACP_SESSION_PERSIST_LOCK_TIMEOUT');
687
- }
688
- const heartbeat = setInterval(() => {
689
- void writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }), 'utf8');
690
- }, Math.max(1000, sessionStorePolicy.lockHeartbeatMs));
691
- const tempPath = defaultPathAdapter.join(dir, `.sessions.v1.json.tmp-${process.pid}-${Date.now()}`);
692
- try {
693
- let existing = { schemaVersion: 2, sessions: [] };
694
- try {
695
- const existingRaw = await readFile(sessionPersistencePath, 'utf8');
696
- existing = normalizePersistedSessionStore(JSON.parse(existingRaw));
697
- }
698
- catch {
699
- // ignore read failure; writing fresh payload is acceptable
700
- }
701
- const merged = new Map();
702
- for (const entry of existing.sessions)
703
- merged.set(entry.id, entry);
704
- for (const entry of payload.sessions)
705
- merged.set(entry.id, entry);
706
- const mergedPayload = {
707
- schemaVersion: 2,
708
- sessions: pruneSessionRecords(Array.from(merged.values())),
709
- };
710
- await writeFile(tempPath, JSON.stringify(mergedPayload, null, 2), 'utf8');
711
- await rename(tempPath, sessionPersistencePath);
712
- }
713
- finally {
714
- clearInterval(heartbeat);
715
- }
716
- }
717
- catch (error) {
718
- recordAuditEvent('acp.session.persist.failed', {
719
- errorName: error instanceof Error ? error.name : typeof error,
720
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
721
- }
722
- finally {
723
- if (lockHandle) {
724
- try {
725
- await lockHandle.close();
726
- }
727
- catch {
728
- // ignore
729
- }
730
- try {
731
- await unlink(lockPath);
732
- }
733
- catch {
734
- // ignore
735
- }
736
- }
737
- }
738
- }
739
- async function hydrateSessionsOnce() {
740
- if (sessionsHydrated)
741
- return;
742
- if (hydratePromise)
743
- return hydratePromise;
744
- hydratePromise = (async () => {
745
- sessionsHydrated = true;
746
- if (!sessionPersistencePath)
747
- return;
748
- try {
749
- const raw = await readFile(sessionPersistencePath, 'utf8');
750
- const parsed = normalizePersistedSessionStore(JSON.parse(raw));
751
- for (const stored of pruneSessionRecords(parsed.sessions)) {
752
- sessions.upsert({
753
- id: stored.id,
754
- cwd: stored.cwd,
755
- mcpServers: Array.isArray(stored.mcpServers) ? stored.mcpServers : [],
756
- createdAt: stored.createdAt,
757
- updatedAt: stored.updatedAt,
758
- title: stored.title,
759
- taskId: stored.taskId,
760
- history: Array.isArray(stored.history)
761
- ? stored.history.slice(-sessionStorePolicy.historyMaxEntries)
762
- : [],
763
- cancelRequested: false,
764
- });
765
- if (!sessionRuntime.has(stored.id)) {
766
- sessionRuntime.set(stored.id, createSessionRuntimeStateFromPersisted({
767
- permissionPolicy: stored.permissionPolicy,
768
- defaultPermissionPolicy: deps.defaultPermissionPolicy,
769
- modeId: stored.modeId,
770
- defaultModeId: deps.defaultModeId,
771
- }));
772
- }
773
- }
774
- }
775
- catch (error) {
776
- if (isFileMissing(error))
777
- return;
778
- recordAuditEvent('acp.session.hydrate.failed', {
779
- errorName: error instanceof Error ? error.name : typeof error,
780
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
781
- }
782
- })();
783
- return hydratePromise;
784
- }
785
- function hashRepoPath(repoPath) {
786
- return createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
787
- }
788
- function toCheckpointMeta(input) {
789
- if (!input)
790
- return null;
791
- return {
792
- id: input.id,
793
- createdAt: input.createdAt ?? null,
794
- strategy: input.strategy ?? null,
795
- backend: input.backend ?? null,
796
- };
797
- }
798
- function toResumeHint(probe) {
799
- if (!probe || probe.valid)
800
- return null;
801
- switch (probe.reason) {
802
- case 'not_found':
803
- return {
804
- code: 'CHECKPOINT_NOT_FOUND',
805
- message: 'Checkpoint not found. Start a new session.',
806
- };
807
- case 'manifest_parse_error':
808
- return {
809
- code: 'CHECKPOINT_MANIFEST_PARSE_ERROR',
810
- message: 'Checkpoint metadata is corrupted. Recreate checkpoint metadata and retry.',
811
- };
812
- case 'manifest_io_error':
813
- return {
814
- code: 'CHECKPOINT_MANIFEST_IO_ERROR',
815
- message: 'Checkpoint metadata is unreadable due to filesystem I/O issues.',
816
- };
817
- case 'manifest_lock_timeout':
818
- return {
819
- code: 'CHECKPOINT_MANIFEST_LOCK_TIMEOUT',
820
- message: 'Checkpoint metadata is busy (lock timeout). Retry shortly.',
821
- };
822
- case 'manifest_unavailable':
823
- return {
824
- code: 'CHECKPOINT_MANIFEST_UNAVAILABLE',
825
- message: 'Checkpoint metadata is unavailable in current runtime.',
826
- };
827
- default:
828
- return {
829
- code: 'CHECKPOINT_RESUME_UNAVAILABLE',
830
- message: 'Checkpoint resume is unavailable. Start a new session or retry.',
831
- };
832
- }
833
- }
616
+ const sessionPersistence = createAcpSessionPersistence({
617
+ path: sessionPersistencePath ?? '',
618
+ storePolicy: sessionStorePolicy,
619
+ defaultPermissionPolicy: deps.defaultPermissionPolicy ?? ACP_PERMISSION_POLICY_ASK,
620
+ defaultModeId: deps.defaultModeId,
621
+ sessions,
622
+ sessionRuntime,
623
+ isPersistableSession,
624
+ ensureSessionRuntimeState,
625
+ resolveExposedAcpModeId,
626
+ });
834
627
  async function emitSessionUpdate(sessionId, update) {
835
628
  await deps.conn.sessionUpdate({ sessionId, update });
836
629
  }
@@ -853,15 +646,13 @@ export function createAcpFormalAgent(deps) {
853
646
  if (!shouldRefreshPlanForEvent(params.event))
854
647
  return;
855
648
  const { state, event } = params;
856
- const planReader = deps.planReader ?? {
857
- readBySession: async ({ repoPath, sessionId }) => await readPlan({ persistenceRoot: repoPath, sessionId }),
858
- };
649
+ const planReader = deps.planReader;
859
650
  if (event?.type === 'plan.runtime.ready') {
860
651
  state.runtimePlanSessionId = event.sessionId;
861
652
  state.runtimePlanPathHint = event.planPathHint;
862
653
  state.lastPlanDigest = null;
863
654
  }
864
- if (!state.runtimePlanSessionId)
655
+ if (!state.runtimePlanSessionId || !planReader)
865
656
  return;
866
657
  try {
867
658
  const read = await planReader.readBySession({
@@ -883,73 +674,129 @@ export function createAcpFormalAgent(deps) {
883
674
  }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PLAN' });
884
675
  }
885
676
  }
886
- async function loadSessionInternal(params) {
887
- await hydrateSessionsOnce();
677
+ async function resolveExistingSession(params) {
678
+ await sessionPersistence.hydrate();
679
+ validateUnsupportedAdditionalDirectories(params.additionalDirectories);
680
+ validateAcpMcpServers(params.mcpServers);
888
681
  const session = sessions.get(params.sessionId);
889
682
  if (!session) {
890
683
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
891
684
  }
892
685
  if (session.cwd !== params.cwd) {
686
+ throw new RequestError(-32602, 'Invalid params: cwd does not match session cwd');
687
+ }
688
+ if (params.mcpServers) {
689
+ const mcpServers = params.mcpServers;
893
690
  sessions.update(params.sessionId, (current) => ({
894
691
  ...current,
895
- cwd: params.cwd,
896
- mcpServers: params.mcpServers ?? [],
692
+ mcpServers,
897
693
  }));
898
- await persistSessionsBestEffort();
694
+ await sessionPersistence.persist();
899
695
  }
900
696
  return session;
901
697
  }
698
+ function toSessionInfo(session) {
699
+ return {
700
+ sessionId: session.id,
701
+ cwd: session.cwd,
702
+ title: typeof session.title === 'string' && session.title.trim() ? session.title : null,
703
+ updatedAt: session.updatedAt,
704
+ };
705
+ }
902
706
  function ensureSessionRuntimeState(sessionId) {
903
707
  const existing = sessionRuntime.get(sessionId);
904
708
  if (existing)
905
709
  return existing;
710
+ const session = sessions.get(sessionId);
906
711
  const created = createSessionRuntimeStateFromPersisted({
712
+ permissionPolicy: session?.permissionPolicy,
907
713
  defaultPermissionPolicy: deps.defaultPermissionPolicy,
714
+ modeId: session?.modeId,
908
715
  defaultModeId: deps.defaultModeId,
909
716
  });
910
717
  sessionRuntime.set(sessionId, created);
911
718
  return created;
912
719
  }
720
+ async function cancelSessionTaskBestEffort(session) {
721
+ sessions.update(session.id, (current) => ({ ...current, cancelRequested: true }));
722
+ if (session.taskId) {
723
+ await deps.facade.cancelTask(session.taskId);
724
+ }
725
+ }
726
+ async function closeSessionRecord(params) {
727
+ const session = sessions.get(params.sessionId);
728
+ if (!session)
729
+ return;
730
+ const runtimeState = ensureSessionRuntimeState(params.sessionId);
731
+ sessions.update(params.sessionId, (current) => ({
732
+ ...current,
733
+ permissionPolicy: runtimeState.permissionPolicy,
734
+ modeId: runtimeState.modeId,
735
+ }));
736
+ await cancelSessionTaskBestEffort(session);
737
+ const latestSession = sessions.get(params.sessionId) ?? session;
738
+ if (!isPersistableSession(latestSession)) {
739
+ sessionRuntime.delete(params.sessionId);
740
+ sessions.delete(params.sessionId);
741
+ await sessionPersistence.persist();
742
+ return;
743
+ }
744
+ sessions.update(params.sessionId, (current) => ({ ...current, cancelRequested: false }));
745
+ await sessionPersistence.persist();
746
+ sessionRuntime.delete(params.sessionId);
747
+ }
748
+ async function deleteSessionRecord(params) {
749
+ const session = sessions.get(params.sessionId);
750
+ if (!session)
751
+ return;
752
+ await cancelSessionTaskBestEffort(session);
753
+ sessionPersistence.markDeleted(params.sessionId);
754
+ sessionRuntime.delete(params.sessionId);
755
+ sessions.delete(params.sessionId);
756
+ await sessionPersistence.persist();
757
+ }
913
758
  return {
914
759
  async initialize(params) {
915
760
  if (typeof params.protocolVersion !== 'number' || !Number.isFinite(params.protocolVersion)) {
916
761
  throw new RequestError(-32602, 'Invalid params: protocolVersion is required');
917
762
  }
918
763
  clientCapabilities = params.clientCapabilities;
919
- // Protocol version negotiation:
920
- // - If the client's requested version is supported, return the same version
921
- // - Otherwise, return the latest version the agent supports
922
- // Currently, the agent only supports protocol version 1
923
- const supportedProtocolVersion = PROTOCOL_VERSION;
924
- const negotiatedVersion = params.protocolVersion <= supportedProtocolVersion
925
- ? params.protocolVersion
926
- : supportedProtocolVersion;
927
764
  return {
928
- protocolVersion: negotiatedVersion,
765
+ protocolVersion: negotiateProtocolVersion(params.protocolVersion),
929
766
  agentInfo: deps.agentInfo,
930
767
  authMethods: [],
931
768
  agentCapabilities: {
932
769
  loadSession: loadSessionCapability,
933
770
  promptCapabilities: promptCapabilities,
934
771
  mcpCapabilities: mcpCapabilities,
935
- sessionCapabilities: {},
772
+ sessionCapabilities: {
773
+ list: {},
774
+ resume: {},
775
+ close: {},
776
+ delete: {},
777
+ },
936
778
  },
937
779
  };
938
780
  },
939
- async authenticate() {
940
- return;
781
+ async authenticate(params) {
782
+ throw new RequestError(-32602, `Invalid params: unsupported auth methodId "${params.methodId}"`);
941
783
  },
942
784
  async newSession(params) {
943
- await hydrateSessionsOnce();
785
+ await sessionPersistence.hydrate();
944
786
  if (!isAbsolutePath(params.cwd)) {
945
787
  throw new RequestError(-32602, 'Invalid params: cwd must be an absolute path');
946
788
  }
789
+ validateUnsupportedAdditionalDirectories(params.additionalDirectories);
790
+ validateAcpMcpServers(params.mcpServers);
947
791
  const session = sessions.create({
948
792
  cwd: params.cwd,
949
793
  mcpServers: params.mcpServers ?? [],
950
794
  title: deriveSessionTitleFromCwd(params.cwd),
795
+ permissionPolicy: isPermissionPolicyValue(String(deps.defaultPermissionPolicy))
796
+ ? deps.defaultPermissionPolicy
797
+ : ACP_PERMISSION_POLICY_ASK,
798
+ modeId: resolveExposedAcpModeId(deps.defaultModeId),
951
799
  });
952
- await persistSessionsBestEffort();
953
800
  const runtimeState = ensureSessionRuntimeState(session.id);
954
801
  await emitSessionInfoUpdateBestEffort(session.id);
955
802
  // Restore session state on creation
@@ -961,18 +808,11 @@ export function createAcpFormalAgent(deps) {
961
808
  await emitSessionUpdate(session.id, modeUpdate);
962
809
  let sessionMeta;
963
810
  if (deps.checkpointReader) {
964
- const checkpoints = await deps.checkpointReader.listBySession({
811
+ const result = await probeCheckpointForNewSession(deps.checkpointReader, {
965
812
  repoPath: params.cwd,
966
813
  sessionId: session.id,
967
- limit: 1,
968
814
  });
969
- const latest = checkpoints.at(-1);
970
- sessionMeta = {
971
- salmonloop: {
972
- latestCheckpointId: latest?.id ?? null,
973
- checkpoint: toCheckpointMeta(latest),
974
- },
975
- };
815
+ sessionMeta = result._meta;
976
816
  }
977
817
  return {
978
818
  sessionId: session.id,
@@ -983,9 +823,9 @@ export function createAcpFormalAgent(deps) {
983
823
  },
984
824
  async loadSession(params) {
985
825
  if (!loadSessionCapability) {
986
- throw new RequestError(-32601, '"Method not found": session/load');
826
+ throw new RequestError(-32601, 'Method not found: session/load');
987
827
  }
988
- await loadSessionInternal(params);
828
+ await resolveExistingSession(params);
989
829
  let session = sessions.get(params.sessionId);
990
830
  const runtimeState = ensureSessionRuntimeState(session.id);
991
831
  if (typeof session.title !== 'string' || !session.title.trim()) {
@@ -994,7 +834,7 @@ export function createAcpFormalAgent(deps) {
994
834
  ...current,
995
835
  title: deriveSessionTitleFromCwd(current.cwd),
996
836
  })) ?? session;
997
- await persistSessionsBestEffort();
837
+ await sessionPersistence.persist();
998
838
  }
999
839
  runtimeState.lastSessionInfoDigest = null;
1000
840
  await emitSessionInfoUpdateBestEffort(session.id);
@@ -1013,13 +853,11 @@ export function createAcpFormalAgent(deps) {
1013
853
  if (modeUpdate)
1014
854
  await emitSessionUpdate(session.id, modeUpdate);
1015
855
  for (const entry of session.history) {
1016
- if (entry.role !== 'assistant')
1017
- continue;
1018
856
  for (const block of entry.content) {
1019
- if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
857
+ if (isReplayableSessionContentBlock(block)) {
1020
858
  await emitSessionUpdate(session.id, {
1021
- sessionUpdate: 'agent_message_chunk',
1022
- content: buildTextContentBlock(block.text),
859
+ sessionUpdate: entry.role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
860
+ content: block,
1023
861
  });
1024
862
  }
1025
863
  }
@@ -1029,56 +867,12 @@ export function createAcpFormalAgent(deps) {
1029
867
  modes: buildModesState(runtimeState.modeId),
1030
868
  };
1031
869
  if (deps.checkpointReader) {
1032
- const startedAt = Date.now();
1033
- const checkpoints = await deps.checkpointReader.listBySession({
870
+ const result = await probeCheckpoint(deps.checkpointReader, {
1034
871
  repoPath: params.cwd,
1035
- sessionId: params.sessionId,
1036
- limit: 1,
1037
- });
1038
- const latest = checkpoints.at(-1);
1039
- let resumeProbe = null;
1040
- if (latest?.id && deps.checkpointReader.probeById) {
1041
- const probed = await deps.checkpointReader.probeById({
1042
- repoPath: params.cwd,
1043
- checkpointId: latest.id,
1044
- });
1045
- resumeProbe = {
1046
- checkpointId: latest.id,
1047
- valid: probed.valid,
1048
- reason: probed.reason,
1049
- };
1050
- }
1051
- else if (latest?.id && deps.checkpointReader.getById) {
1052
- const found = await deps.checkpointReader.getById({
1053
- repoPath: params.cwd,
1054
- checkpointId: latest.id,
1055
- });
1056
- resumeProbe = {
1057
- checkpointId: latest.id,
1058
- valid: Boolean(found),
1059
- reason: found ? 'ok' : 'not_found',
1060
- };
1061
- }
1062
- const resumeReady = resumeProbe?.valid ?? Boolean(latest);
1063
- recordAuditEvent('acp.checkpoint.read', {
1064
872
  sessionId: params.sessionId,
1065
873
  repoPathHash: hashRepoPath(params.cwd),
1066
- latestCheckpointId: latest?.id ?? null,
1067
- hit: Boolean(latest),
1068
- latencyMs: Date.now() - startedAt,
1069
- resumeProbe: resumeProbe ?? undefined,
1070
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
1071
- const resumeHint = toResumeHint(resumeProbe);
1072
- response._meta = {
1073
- salmonloop: {
1074
- latestCheckpointId: latest?.id ?? null,
1075
- checkpoint: toCheckpointMeta(latest),
1076
- resumeReady,
1077
- resumeProbe,
1078
- resumeHint: resumeHint?.message ?? null,
1079
- resumeHintCode: resumeHint?.code ?? null,
1080
- },
1081
- };
874
+ });
875
+ response._meta = result._meta;
1082
876
  }
1083
877
  else {
1084
878
  recordAuditEvent('acp.checkpoint.read', {
@@ -1091,34 +885,108 @@ export function createAcpFormalAgent(deps) {
1091
885
  }
1092
886
  return response;
1093
887
  },
888
+ async listSessions(params) {
889
+ await sessionPersistence.hydrate();
890
+ if (typeof params.cwd === 'string' && params.cwd && !isAbsolutePath(params.cwd)) {
891
+ throw new RequestError(-32602, 'Invalid params: cwd must be an absolute path');
892
+ }
893
+ const cwdFilter = typeof params.cwd === 'string' && params.cwd ? params.cwd : null;
894
+ let offset = 0;
895
+ if (typeof params.cursor === 'string' && params.cursor) {
896
+ const decodedCursor = decodeSessionListCursor(params.cursor);
897
+ if (decodedCursor.cwd !== cwdFilter) {
898
+ throw new RequestError(-32602, 'Invalid params: cursor does not match cwd filter');
899
+ }
900
+ offset = decodedCursor.offset;
901
+ }
902
+ const filtered = sessions
903
+ .list()
904
+ .filter((session) => !cwdFilter || session.cwd === cwdFilter)
905
+ .sort((a, b) => parseTimestamp(b.updatedAt) - parseTimestamp(a.updatedAt));
906
+ const page = filtered.slice(offset, offset + ACP_SESSION_LIST_PAGE_SIZE);
907
+ const nextOffset = offset + page.length;
908
+ const nextCursor = nextOffset < filtered.length
909
+ ? encodeSessionListCursor({ offset: nextOffset, cwd: cwdFilter })
910
+ : undefined;
911
+ return {
912
+ sessions: page.map(toSessionInfo),
913
+ ...(nextCursor ? { nextCursor } : {}),
914
+ };
915
+ },
916
+ async resumeSession(params) {
917
+ const session = await resolveExistingSession({
918
+ sessionId: params.sessionId,
919
+ cwd: params.cwd,
920
+ mcpServers: params.mcpServers,
921
+ additionalDirectories: params.additionalDirectories,
922
+ });
923
+ const runtimeState = ensureSessionRuntimeState(session.id);
924
+ runtimeState.lastSessionInfoDigest = null;
925
+ await emitSessionInfoUpdateBestEffort(session.id);
926
+ const commandsUpdate = buildAvailableCommandsUpdateIfChanged(runtimeState);
927
+ if (commandsUpdate)
928
+ await emitSessionUpdate(session.id, commandsUpdate);
929
+ const modeUpdate = buildCurrentModeUpdateIfChanged(runtimeState);
930
+ if (modeUpdate)
931
+ await emitSessionUpdate(session.id, modeUpdate);
932
+ const response = {
933
+ configOptions: buildConfigOptions(runtimeState),
934
+ modes: buildModesState(runtimeState.modeId),
935
+ };
936
+ if (deps.checkpointReader) {
937
+ const result = await probeCheckpoint(deps.checkpointReader, {
938
+ repoPath: session.cwd,
939
+ sessionId: params.sessionId,
940
+ repoPathHash: hashRepoPath(session.cwd),
941
+ });
942
+ response._meta = result._meta;
943
+ }
944
+ return response;
945
+ },
946
+ async closeSession(params) {
947
+ await sessionPersistence.hydrate();
948
+ await closeSessionRecord({ sessionId: params.sessionId });
949
+ return {};
950
+ },
951
+ async unstable_deleteSession(params) {
952
+ await sessionPersistence.hydrate();
953
+ await deleteSessionRecord({ sessionId: params.sessionId });
954
+ return {};
955
+ },
1094
956
  async setSessionConfigOption(params) {
1095
- await hydrateSessionsOnce();
957
+ await sessionPersistence.hydrate();
1096
958
  if (!sessions.get(params.sessionId)) {
1097
959
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1098
960
  }
1099
961
  const runtimeState = ensureSessionRuntimeState(params.sessionId);
1100
962
  if (params.configId === ACP_PERMISSION_POLICY_CONFIG_ID) {
963
+ if (typeof params.value !== 'string') {
964
+ throw new RequestError(-32602, `Invalid params: "${params.configId}" does not support boolean values`);
965
+ }
1101
966
  if (!isPermissionPolicyValue(params.value)) {
1102
967
  throw new RequestError(-32602, `Invalid params: unsupported value "${params.value}" for "${params.configId}"`);
1103
968
  }
1104
969
  runtimeState.permissionPolicy = params.value;
1105
970
  }
1106
971
  else if (params.configId === ACP_MODE_CONFIG_ID) {
972
+ if (typeof params.value !== 'string') {
973
+ throw new RequestError(-32602, `Invalid params: "${params.configId}" does not support boolean values`);
974
+ }
1107
975
  const parsedModeId = parseAcpFlowMode(params.value);
1108
976
  if (!parsedModeId || !ACP_PUBLIC_MODE_IDS.has(parsedModeId)) {
1109
977
  throw new RequestError(-32602, `Invalid params: unsupported value "${params.value}" for "${params.configId}"`);
1110
978
  }
1111
979
  runtimeState.modeId = parsedModeId;
1112
- const legacyPermissionPolicy = getLegacyPermissionPolicyForModeValue(params.value);
1113
- if (legacyPermissionPolicy) {
1114
- runtimeState.permissionPolicy = legacyPermissionPolicy;
1115
- }
1116
980
  }
1117
981
  else {
1118
982
  throw new RequestError(-32602, `Invalid params: unsupported configId "${params.configId}"`);
1119
983
  }
1120
- sessions.update(params.sessionId, (current) => ({ ...current }));
1121
- await persistSessionsBestEffort();
984
+ sessions.update(params.sessionId, (current) => ({
985
+ ...current,
986
+ permissionPolicy: runtimeState.permissionPolicy,
987
+ modeId: runtimeState.modeId,
988
+ }));
989
+ await sessionPersistence.persist();
1122
990
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1123
991
  const update = buildConfigOptionUpdateIfChanged(runtimeState);
1124
992
  if (update) {
@@ -1131,7 +999,7 @@ export function createAcpFormalAgent(deps) {
1131
999
  return { configOptions: buildConfigOptions(runtimeState) };
1132
1000
  },
1133
1001
  async setSessionMode(params) {
1134
- await hydrateSessionsOnce();
1002
+ await sessionPersistence.hydrate();
1135
1003
  if (!sessions.get(params.sessionId)) {
1136
1004
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1137
1005
  }
@@ -1141,12 +1009,12 @@ export function createAcpFormalAgent(deps) {
1141
1009
  throw new RequestError(-32602, `Invalid params: unsupported modeId "${params.modeId}"`);
1142
1010
  }
1143
1011
  runtimeState.modeId = resolvedModeId;
1144
- const legacyPermissionPolicy = getLegacyPermissionPolicyForModeValue(params.modeId);
1145
- if (legacyPermissionPolicy) {
1146
- runtimeState.permissionPolicy = legacyPermissionPolicy;
1147
- }
1148
- sessions.update(params.sessionId, (current) => ({ ...current }));
1149
- await persistSessionsBestEffort();
1012
+ sessions.update(params.sessionId, (current) => ({
1013
+ ...current,
1014
+ permissionPolicy: runtimeState.permissionPolicy,
1015
+ modeId: runtimeState.modeId,
1016
+ }));
1017
+ await sessionPersistence.persist();
1150
1018
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1151
1019
  const configUpdate = buildConfigOptionUpdateIfChanged(runtimeState);
1152
1020
  if (configUpdate) {
@@ -1160,7 +1028,7 @@ export function createAcpFormalAgent(deps) {
1160
1028
  return {};
1161
1029
  },
1162
1030
  async prompt(params) {
1163
- await hydrateSessionsOnce();
1031
+ await sessionPersistence.hydrate();
1164
1032
  const session = sessions.get(params.sessionId);
1165
1033
  if (!session) {
1166
1034
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
@@ -1173,7 +1041,10 @@ export function createAcpFormalAgent(deps) {
1173
1041
  const runtimeState = ensureSessionRuntimeState(params.sessionId);
1174
1042
  // Check for cancellation before starting processing
1175
1043
  if (sessions.get(params.sessionId)?.cancelRequested === true) {
1176
- return { stopReason: 'cancelled' };
1044
+ return {
1045
+ stopReason: 'cancelled',
1046
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1047
+ };
1177
1048
  }
1178
1049
  sessions.update(params.sessionId, (current) => {
1179
1050
  const title = typeof current.title === 'string' && current.title.trim()
@@ -1183,13 +1054,16 @@ export function createAcpFormalAgent(deps) {
1183
1054
  ...current,
1184
1055
  cancelRequested: false,
1185
1056
  title,
1057
+ materialized: true,
1058
+ permissionPolicy: runtimeState.permissionPolicy,
1059
+ modeId: runtimeState.modeId,
1186
1060
  history: [
1187
1061
  ...current.history,
1188
1062
  { role: 'user', content: params.prompt },
1189
1063
  ],
1190
1064
  };
1191
1065
  });
1192
- await persistSessionsBestEffort();
1066
+ await sessionPersistence.persist();
1193
1067
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1194
1068
  const configUpdate = buildConfigOptionUpdateIfChanged(runtimeState);
1195
1069
  if (configUpdate) {
@@ -1221,14 +1095,20 @@ export function createAcpFormalAgent(deps) {
1221
1095
  { role: 'assistant', content: [buildTextContentBlock(responseText)] },
1222
1096
  ],
1223
1097
  }));
1224
- await persistSessionsBestEffort();
1098
+ await sessionPersistence.persist();
1225
1099
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1226
- return { stopReason: 'end_turn' };
1100
+ return {
1101
+ stopReason: 'end_turn',
1102
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1103
+ };
1227
1104
  }
1228
1105
  }
1229
1106
  // Check for cancellation again before creating task
1230
1107
  if (sessions.get(params.sessionId)?.cancelRequested === true) {
1231
- return { stopReason: 'cancelled' };
1108
+ return {
1109
+ stopReason: 'cancelled',
1110
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1111
+ };
1232
1112
  }
1233
1113
  const pendingUpdates = [];
1234
1114
  const executionRequest = buildCanonicalExecutionRequest({
@@ -1245,6 +1125,7 @@ export function createAcpFormalAgent(deps) {
1245
1125
  fileSystemOverride: effectiveExecutionBinding === 'client'
1246
1126
  ? createAcpFileSystem({ conn: deps.conn, sessionId: params.sessionId })
1247
1127
  : undefined,
1128
+ extensions: acpMcpServersToExtensions(session.mcpServers),
1248
1129
  authorizationProvider: createAcpToolAuthorizationProvider({
1249
1130
  conn: deps.conn,
1250
1131
  sessionId: params.sessionId,
@@ -1270,34 +1151,39 @@ export function createAcpFormalAgent(deps) {
1270
1151
  },
1271
1152
  });
1272
1153
  sessions.update(params.sessionId, (current) => ({ ...current, taskId: task.id }));
1273
- await persistSessionsBestEffort();
1154
+ await sessionPersistence.persist();
1274
1155
  if (signal.aborted) {
1275
1156
  await emitSessionUpdate(params.sessionId, {
1276
1157
  sessionUpdate: 'agent_message_chunk',
1277
- content: buildTextContentBlock(ensureMarkdownParagraphBreak('Task cancelled.')),
1158
+ content: buildTextContentBlock(ensureMarkdownParagraphBreak(text.acp.taskCancelled)),
1278
1159
  });
1279
- return { stopReason: 'cancelled' };
1160
+ return {
1161
+ stopReason: 'cancelled',
1162
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1163
+ };
1280
1164
  }
1281
1165
  const terminalEvent = await awaitTerminalEvent({ taskId: task.id, eventBus: deps.eventBus });
1282
1166
  let stopReason = 'end_turn';
1283
- let assistantText = 'Task completed.';
1167
+ let assistantText = text.acp.taskCompleted;
1284
1168
  let assistantMeta;
1285
1169
  let latest;
1286
1170
  const cancelRequested = sessions.get(params.sessionId)?.cancelRequested === true;
1287
1171
  if (cancelRequested) {
1288
- assistantText = 'Task cancelled.';
1172
+ assistantText = text.acp.taskCancelled;
1289
1173
  stopReason = 'cancelled';
1290
1174
  }
1291
1175
  else if (terminalEvent?.type === 'task.failed') {
1292
1176
  latest = await deps.facade.getTask(task.id);
1293
1177
  const failureMessage = typeof latest?.failure?.message === 'string' ? latest.failure.message : undefined;
1294
- assistantText = failureMessage ? `Task failed: ${failureMessage}` : 'Task failed.';
1178
+ assistantText = failureMessage
1179
+ ? text.acp.taskFailedWithReason(failureMessage)
1180
+ : text.acp.taskFailed;
1295
1181
  const inferred = inferTurnStopReasonFromFailure(latest?.failure);
1296
1182
  if (inferred)
1297
1183
  stopReason = inferred;
1298
1184
  }
1299
1185
  else if (terminalEvent?.type === 'task.awaiting_input') {
1300
- assistantText = 'Task awaiting input.';
1186
+ assistantText = text.acp.taskAwaitingInput;
1301
1187
  latest = await deps.facade.getTask(task.id);
1302
1188
  const formatted = latest?.inputRequired
1303
1189
  ? formatInputRequiredMessage(latest.inputRequired)
@@ -1309,7 +1195,7 @@ export function createAcpFormalAgent(deps) {
1309
1195
  }
1310
1196
  }
1311
1197
  else if (terminalEvent?.type === 'task.cancelled') {
1312
- assistantText = 'Task cancelled.';
1198
+ assistantText = text.acp.taskCancelled;
1313
1199
  stopReason = 'cancelled';
1314
1200
  }
1315
1201
  await emitSessionUpdate(params.sessionId, {
@@ -1330,7 +1216,7 @@ export function createAcpFormalAgent(deps) {
1330
1216
  { role: 'assistant', content: [buildTextContentBlock(assistantText)] },
1331
1217
  ],
1332
1218
  }));
1333
- await persistSessionsBestEffort();
1219
+ await sessionPersistence.persist();
1334
1220
  const latestSession = sessions.get(params.sessionId);
1335
1221
  if (latestSession) {
1336
1222
  const sessionInfoUpdate = buildSessionInfoUpdateIfChanged(latestSession, runtimeState);
@@ -1342,16 +1228,19 @@ export function createAcpFormalAgent(deps) {
1342
1228
  }
1343
1229
  // Wait for all pending session updates to be sent before responding
1344
1230
  await Promise.all(pendingUpdates);
1345
- return { stopReason };
1231
+ return {
1232
+ stopReason,
1233
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1234
+ };
1346
1235
  },
1347
1236
  async cancel(params) {
1348
- await hydrateSessionsOnce();
1237
+ await sessionPersistence.hydrate();
1349
1238
  const session = sessions.get(params.sessionId);
1350
1239
  if (!session)
1351
1240
  return;
1352
1241
  // Mark the session as cancelled
1353
1242
  sessions.update(params.sessionId, (current) => ({ ...current, cancelRequested: true }));
1354
- await persistSessionsBestEffort();
1243
+ await sessionPersistence.persist();
1355
1244
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1356
1245
  // If a task is running, cancel it
1357
1246
  if (session.taskId) {
@@ -1360,8 +1249,6 @@ export function createAcpFormalAgent(deps) {
1360
1249
  // Note: The prompt method will check the cancelRequested flag and return
1361
1250
  // StopReason::Cancelled as required by the protocol
1362
1251
  },
1363
- extMethod: async () => ({}),
1364
- extNotification: async () => { },
1365
1252
  };
1366
1253
  }
1367
1254
  //# sourceMappingURL=formal-agent.js.map