salmon-loop 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) 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 +123 -154
  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/slash/runtime.js +5 -1
  10. package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
  11. package/dist/core/adapters/fs/node-fs.js +1 -0
  12. package/dist/core/benchmark/patch-artifact.js +1 -1
  13. package/dist/core/context/service.js +36 -10
  14. package/dist/core/extensions/index.js +2 -35
  15. package/dist/core/extensions/redact.js +9 -3
  16. package/dist/core/extensions/schemas.js +2 -51
  17. package/dist/core/facades/cli-authorization-non-interactive.js +1 -1
  18. package/dist/core/facades/cli-serve.js +0 -1
  19. package/dist/core/grizzco/dsl/strategies.js +1 -3
  20. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +12 -7
  21. package/dist/core/grizzco/engine/transaction/attempt-failure.js +23 -23
  22. package/dist/core/grizzco/engine/transaction/report-mapper.js +3 -0
  23. package/dist/core/grizzco/engine/transaction/transaction-runner.js +14 -0
  24. package/dist/core/grizzco/flows/AutopilotFlow.js +1 -0
  25. package/dist/core/grizzco/flows/SalmonLoopFlow.js +1 -0
  26. package/dist/core/grizzco/steps/apply.js +0 -7
  27. package/dist/core/grizzco/steps/autopilot.js +108 -6
  28. package/dist/core/grizzco/steps/preflight.js +10 -0
  29. package/dist/core/grizzco/steps/tool-runtime.js +1 -0
  30. package/dist/core/interaction/events/bus.js +14 -0
  31. package/dist/core/interaction/orchestration/facade.js +10 -0
  32. package/dist/core/mcp/bridge/index.js +4 -0
  33. package/dist/core/mcp/bridge/prompt-command-provider.js +261 -0
  34. package/dist/core/mcp/bridge/resource-context-provider.js +259 -0
  35. package/dist/core/mcp/bridge/tool-bridge.js +303 -0
  36. package/dist/core/mcp/cache/resource-cache.js +41 -0
  37. package/dist/core/mcp/catalog/discovery.js +51 -0
  38. package/dist/core/mcp/catalog/notification-router.js +28 -0
  39. package/dist/core/mcp/catalog/prompt-catalog.js +4 -0
  40. package/dist/core/mcp/catalog/resource-catalog.js +7 -0
  41. package/dist/core/mcp/catalog/tool-catalog.js +4 -0
  42. package/dist/core/mcp/client/connection-manager.js +239 -0
  43. package/dist/core/mcp/client/lifecycle.js +13 -0
  44. package/dist/core/mcp/client/transport-factory.js +168 -0
  45. package/dist/core/mcp/config/index.js +32 -0
  46. package/dist/core/mcp/config/schema-v2.js +129 -0
  47. package/dist/core/mcp/host/elicitation-provider.js +209 -0
  48. package/dist/core/mcp/host/roots-provider.js +70 -0
  49. package/dist/core/mcp/host/sampling-provider.js +170 -0
  50. package/dist/core/mcp/index.js +4 -0
  51. package/dist/core/mcp/observability/events.js +19 -0
  52. package/dist/core/mcp/policy/approval-policy.js +2 -0
  53. package/dist/core/mcp/policy/classifier.js +172 -0
  54. package/dist/core/mcp/policy/grants.js +356 -0
  55. package/dist/core/mcp/policy/uri-policy.js +60 -0
  56. package/dist/core/mcp/schema/json-schema-to-zod.js +511 -0
  57. package/dist/core/mcp/types.js +2 -0
  58. package/dist/core/protocols/a2a/agent-card.js +36 -11
  59. package/dist/core/protocols/a2a/sdk/executor.js +105 -36
  60. package/dist/core/protocols/a2a/sdk/server.js +1311 -3
  61. package/dist/core/protocols/acp/acp-checkpoint-probe.js +113 -0
  62. package/dist/core/protocols/acp/acp-session-persistence.js +336 -0
  63. package/dist/core/protocols/acp/acp-types.js +17 -0
  64. package/dist/core/protocols/acp/formal-agent.js +271 -603
  65. package/dist/core/protocols/acp/handlers.js +3 -0
  66. package/dist/core/protocols/acp/permission-provider.js +11 -39
  67. package/dist/core/protocols/acp/stdio-server.js +20 -1
  68. package/dist/core/protocols/acp/tool-kind-mapping.js +62 -0
  69. package/dist/core/protocols/shared/flow-mode-mapping.js +0 -8
  70. package/dist/core/public-capabilities/flow-mode-metadata.js +0 -6
  71. package/dist/core/public-capabilities/projections.js +1 -0
  72. package/dist/core/runtime/agent-server-runtime.js +2 -3
  73. package/dist/core/runtime/spawn-command.js +8 -2
  74. package/dist/core/runtime/spawn-interactive.js +26 -0
  75. package/dist/core/session/manager.js +65 -35
  76. package/dist/core/tools/builtin/index.js +6 -1
  77. package/dist/core/tools/builtin/proposal.js +0 -7
  78. package/dist/core/tools/builtin/workspace.js +76 -0
  79. package/dist/core/tools/dispatcher.js +1 -0
  80. package/dist/core/tools/loader.js +92 -46
  81. package/dist/core/verification/runner.js +60 -31
  82. package/dist/core/workspace/capabilities.js +80 -0
  83. package/dist/locales/en.js +17 -3
  84. package/package.json +4 -2
  85. package/dist/core/protocols/a2a/mapper.js +0 -14
  86. package/dist/core/protocols/a2a/sdk/auth-middleware.js +0 -31
  87. package/dist/core/protocols/a2a/task-projection.js +0 -45
  88. package/dist/core/protocols/acp/checkpoint-meta.js +0 -2
  89. package/dist/core/tools/mcp/client.js +0 -309
  90. package/dist/core/tools/mcp/loader.js +0 -110
  91. package/dist/core/tools/mcp/schema.js +0 -54
  92. package/dist/core/tools/mcp/streamable-http.js +0 -101
  93. 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
  ];
@@ -130,17 +158,17 @@ function extractTextFromPrompt(prompt, capabilities) {
130
158
  break;
131
159
  case 'image':
132
160
  if (!capabilities.image) {
133
- throw new RequestError(-32000, 'Prompt content type image is not supported');
161
+ throw new RequestError(-32602, 'Prompt content type image is not supported');
134
162
  }
135
163
  break;
136
164
  case 'audio':
137
165
  if (!capabilities.audio) {
138
- throw new RequestError(-32000, 'Prompt content type audio is not supported');
166
+ throw new RequestError(-32602, 'Prompt content type audio is not supported');
139
167
  }
140
168
  break;
141
169
  case 'resource':
142
170
  if (!capabilities.embeddedContext) {
143
- throw new RequestError(-32000, 'Prompt content type resource is not supported');
171
+ throw new RequestError(-32602, 'Prompt content type resource is not supported');
144
172
  }
145
173
  parts.push(formatEmbeddedResource(block));
146
174
  break;
@@ -150,46 +178,6 @@ function extractTextFromPrompt(prompt, capabilities) {
150
178
  }
151
179
  return parts.join('\n');
152
180
  }
153
- function mapToolKind(toolName, intent) {
154
- if (intent) {
155
- switch (intent.toUpperCase()) {
156
- case 'READ':
157
- return 'read';
158
- case 'LIST':
159
- return 'read';
160
- case 'SEARCH':
161
- return 'search';
162
- case 'WRITE':
163
- return 'edit';
164
- case 'INFRA':
165
- return 'execute';
166
- case 'AGENT':
167
- return 'think';
168
- }
169
- }
170
- const name = toolName.toLowerCase();
171
- if (name.includes('read') ||
172
- name.includes('get') ||
173
- name.includes('view') ||
174
- name.includes('ls') ||
175
- name.includes('list'))
176
- return 'read';
177
- if (name.includes('write') || name.includes('edit') || name.includes('patch'))
178
- return 'edit';
179
- if (name.includes('delete') || name.includes('remove') || name.includes('rm'))
180
- return 'delete';
181
- if (name.includes('move') || name.includes('rename') || name.includes('mv'))
182
- return 'move';
183
- if (name.includes('grep') || name.includes('search') || name.includes('find'))
184
- return 'search';
185
- if (name.includes('run') || name.includes('exec') || name.includes('spawn'))
186
- return 'execute';
187
- if (name.includes('plan') || name.includes('think') || name.includes('reason'))
188
- return 'think';
189
- if (name.includes('fetch') || name.includes('curl') || name.includes('http'))
190
- return 'fetch';
191
- return 'other';
192
- }
193
181
  function buildToolCallContent(textValue) {
194
182
  return [{ type: 'content', content: buildTextContentBlock(textValue) }];
195
183
  }
@@ -236,7 +224,7 @@ function loopEventToSessionUpdate(event) {
236
224
  toolCallId: event.callId,
237
225
  status: 'pending',
238
226
  title: event.toolName,
239
- kind: mapToolKind(event.toolName, event.toolIntent),
227
+ kind: mapToolKind(event.toolName, { intent: event.toolIntent }),
240
228
  content: [],
241
229
  rawInput: event.input,
242
230
  locations: extractLocationFromInput(event.input),
@@ -259,11 +247,6 @@ function loopEventToSessionUpdate(event) {
259
247
  return null;
260
248
  }
261
249
  }
262
- function isPermissionPolicyValue(value) {
263
- return (value === ACP_PERMISSION_POLICY_ASK ||
264
- value === ACP_PERMISSION_POLICY_DENY_ALL ||
265
- value === ACP_PERMISSION_POLICY_ALLOW_ALL);
266
- }
267
250
  function buildConfigOptions(state) {
268
251
  return [
269
252
  {
@@ -293,8 +276,8 @@ function buildConfigOptions(state) {
293
276
  {
294
277
  type: 'select',
295
278
  id: ACP_MODE_CONFIG_ID,
296
- name: 'Execution Flow',
297
- description: 'Choose how the agent should execute this session.',
279
+ name: text.acp.executionFlowName,
280
+ description: text.acp.executionFlowDescription,
298
281
  currentValue: state.modeId,
299
282
  options: ACP_PUBLIC_MODES.map((mode) => ({
300
283
  value: mode.id,
@@ -355,16 +338,6 @@ function buildCurrentModeUpdateIfChanged(state) {
355
338
  state.lastModeDigest = digest;
356
339
  return buildCurrentModeUpdate(state.modeId);
357
340
  }
358
- function getLegacyPermissionPolicyForModeValue(value) {
359
- const normalized = String(value ?? '')
360
- .trim()
361
- .toLowerCase();
362
- if (normalized === 'interactive')
363
- return ACP_PERMISSION_POLICY_ASK;
364
- if (normalized === 'yolo')
365
- return ACP_PERMISSION_POLICY_ALLOW_ALL;
366
- return null;
367
- }
368
341
  function getPermissionPolicyForAuthorization(state) {
369
342
  return state.permissionPolicy;
370
343
  }
@@ -466,19 +439,36 @@ function buildPlanUpdateFromCoreIfChanged(read, state) {
466
439
  entries,
467
440
  };
468
441
  }
469
- function envListToRecord(env) {
442
+ function namedPairsToRecord(pairs) {
470
443
  const record = {};
471
- for (const entry of env) {
444
+ for (const entry of pairs) {
472
445
  record[entry.name] = entry.value;
473
446
  }
474
447
  return record;
475
448
  }
476
- function headersListToRecord(headers) {
477
- const record = {};
478
- for (const entry of headers) {
479
- record[entry.name] = entry.value;
480
- }
481
- return record;
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
+ };
482
472
  }
483
473
  function acpMcpServersToResolved(mcpServers) {
484
474
  if (!Array.isArray(mcpServers))
@@ -494,11 +484,14 @@ function acpMcpServersToResolved(mcpServers) {
494
484
  resolved.push({
495
485
  name: httpServer.name,
496
486
  enabled: true,
497
- transport: 'http',
498
- url: httpServer.url,
499
- headers: headersListToRecord(httpServer.headers),
500
- allowTools: ['*'],
501
- allowResources: [],
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(),
502
495
  scope: 'repo',
503
496
  });
504
497
  continue;
@@ -510,12 +503,15 @@ function acpMcpServersToResolved(mcpServers) {
510
503
  resolved.push({
511
504
  name: stdioServer.name,
512
505
  enabled: true,
513
- transport: 'stdio',
514
- command: stdioServer.command,
515
- args: stdioServer.args,
516
- env: envListToRecord(stdioServer.env),
517
- allowTools: ['*'],
518
- allowResources: [],
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(),
519
515
  scope: 'repo',
520
516
  });
521
517
  }
@@ -534,6 +530,17 @@ function acpMcpServersToExtensions(mcpServers) {
534
530
  function validateAcpMcpServers(mcpServers) {
535
531
  void acpMcpServersToResolved(mcpServers);
536
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
+ }
537
544
  function extractSlashInput(prompt) {
538
545
  if (prompt.length !== 1)
539
546
  return null;
@@ -556,6 +563,9 @@ function isKnownSlashCommand(commandName) {
556
563
  const normalized = normalizeSlashName(commandName);
557
564
  return ACP_AVAILABLE_COMMANDS.some((cmd) => cmd.name.toLowerCase() === normalized);
558
565
  }
566
+ function isPersistableSession(session) {
567
+ return session.materialized || session.history.length > 0 || typeof session.taskId === 'string';
568
+ }
559
569
  async function awaitTerminalEvent(params) {
560
570
  if (!params.eventBus)
561
571
  return null;
@@ -603,368 +613,17 @@ export function createAcpFormalAgent(deps) {
603
613
  lockAcquireTimeoutMs: deps.sessionStorePolicy?.lockAcquireTimeoutMs ?? ACP_SESSION_STORE_LOCK_ACQUIRE_TIMEOUT_MS,
604
614
  };
605
615
  const executionBinding = deps.executionBinding ?? 'local';
606
- let sessionsHydrated = false;
607
- let hydratePromise = null;
608
- const deletedSessionIds = new Map();
609
- function parseTimestamp(value) {
610
- if (typeof value !== 'string' || value.length === 0)
611
- return 0;
612
- const parsed = Date.parse(value);
613
- return Number.isFinite(parsed) ? parsed : 0;
614
- }
615
- function pruneSessionRecords(records) {
616
- const cutoff = Date.now() - sessionStorePolicy.maxAgeMs;
617
- return [...records]
618
- .filter((record) => parseTimestamp(record.updatedAt) >= cutoff)
619
- .sort((a, b) => parseTimestamp(b.updatedAt) - parseTimestamp(a.updatedAt))
620
- .slice(0, sessionStorePolicy.maxEntries);
621
- }
622
- function normalizeDeletedSessionRecords(input) {
623
- if (!Array.isArray(input))
624
- return [];
625
- const byId = new Map();
626
- for (const entry of input) {
627
- if (!entry || typeof entry !== 'object')
628
- continue;
629
- const record = entry;
630
- if (typeof record.id !== 'string' || !record.id)
631
- continue;
632
- if (typeof record.deletedAt !== 'string' || !record.deletedAt)
633
- continue;
634
- const current = byId.get(record.id);
635
- if (!current || parseTimestamp(record.deletedAt) > parseTimestamp(current.deletedAt)) {
636
- byId.set(record.id, { id: record.id, deletedAt: record.deletedAt });
637
- }
638
- }
639
- return Array.from(byId.values());
640
- }
641
- function pruneDeletedSessionRecords(records) {
642
- const cutoff = Date.now() - sessionStorePolicy.maxAgeMs;
643
- return normalizeDeletedSessionRecords(records)
644
- .filter((record) => parseTimestamp(record.deletedAt) >= cutoff)
645
- .sort((a, b) => parseTimestamp(b.deletedAt) - parseTimestamp(a.deletedAt));
646
- }
647
- function normalizePersistedSessionStore(input) {
648
- if (!input || typeof input !== 'object') {
649
- return { schemaVersion: 2, sessions: [] };
650
- }
651
- const raw = input;
652
- if (!Array.isArray(raw.sessions))
653
- return { schemaVersion: 2, sessions: [] };
654
- if (raw.schemaVersion === 1) {
655
- return {
656
- schemaVersion: 2,
657
- sessions: raw.sessions.map((entry) => ({
658
- id: entry.id,
659
- cwd: entry.cwd,
660
- mcpServers: entry.mcpServers,
661
- createdAt: entry.createdAt,
662
- updatedAt: entry.updatedAt,
663
- title: entry.title,
664
- taskId: undefined,
665
- history: [],
666
- permissionPolicy: isPermissionPolicyValue(String(deps.defaultPermissionPolicy))
667
- ? deps.defaultPermissionPolicy
668
- : ACP_PERMISSION_POLICY_ASK,
669
- modeId: resolveExposedAcpModeId(deps.defaultModeId),
670
- })),
671
- deletedSessions: [],
672
- };
673
- }
674
- if (raw.schemaVersion === 2) {
675
- return {
676
- schemaVersion: 2,
677
- sessions: raw.sessions,
678
- deletedSessions: pruneDeletedSessionRecords(raw.deletedSessions),
679
- };
680
- }
681
- return { schemaVersion: 2, sessions: [] };
682
- }
683
- function isPidAlive(pid) {
684
- if (!Number.isInteger(pid) || pid <= 0)
685
- return false;
686
- try {
687
- process.kill(pid, 0);
688
- return true;
689
- }
690
- catch (error) {
691
- if (error &&
692
- typeof error === 'object' &&
693
- 'code' in error &&
694
- error.code === 'EPERM') {
695
- return true;
696
- }
697
- return false;
698
- }
699
- }
700
- function isFileMissing(error) {
701
- return Boolean(error &&
702
- typeof error === 'object' &&
703
- 'code' in error &&
704
- (error.code === 'ENOENT' ||
705
- error.code === 'ENOTDIR'));
706
- }
707
- async function persistSessionsBestEffort() {
708
- if (!sessionPersistencePath)
709
- return;
710
- const dir = defaultPathAdapter.dirname(sessionPersistencePath);
711
- const lockPath = `${sessionPersistencePath}.lock`;
712
- const baseRecords = sessions.list().map((session) => {
713
- const runtimeState = ensureSessionRuntimeState(session.id);
714
- return {
715
- id: session.id,
716
- cwd: session.cwd,
717
- mcpServers: session.mcpServers,
718
- createdAt: session.createdAt,
719
- updatedAt: session.updatedAt,
720
- title: session.title,
721
- taskId: session.taskId,
722
- history: session.history.slice(-sessionStorePolicy.historyMaxEntries),
723
- permissionPolicy: runtimeState.permissionPolicy,
724
- modeId: runtimeState.modeId,
725
- };
726
- });
727
- const prunedRecords = pruneSessionRecords(baseRecords);
728
- const keepIds = new Set(prunedRecords.map((record) => record.id));
729
- for (const record of sessions.list()) {
730
- if (!keepIds.has(record.id)) {
731
- sessions.delete(record.id);
732
- }
733
- }
734
- const payload = { schemaVersion: 2, sessions: prunedRecords };
735
- const payloadDeletedSessions = pruneDeletedSessionRecords(Array.from(deletedSessionIds, ([id, deletedAt]) => ({ id, deletedAt })));
736
- const primaryRepoPath = prunedRecords[0]?.cwd;
737
- const lockAuditDetails = {
738
- lockPath,
739
- lockPathHash: createHash('sha256').update(lockPath).digest('hex').slice(0, 16),
740
- repoPathHash: primaryRepoPath ? hashRepoPath(primaryRepoPath) : undefined,
741
- };
742
- const tryClearStaleLock = async () => {
743
- try {
744
- const raw = await readFile(lockPath, 'utf8');
745
- const parsed = JSON.parse(raw);
746
- const createdAtMs = typeof parsed.createdAtMs === 'number' && Number.isFinite(parsed.createdAtMs)
747
- ? parsed.createdAtMs
748
- : null;
749
- if (createdAtMs === null)
750
- return;
751
- if (Date.now() - createdAtMs <= sessionStorePolicy.lockStaleMs)
752
- return;
753
- if (typeof parsed.pid === 'number' && isPidAlive(parsed.pid))
754
- return;
755
- await unlink(lockPath);
756
- recordAuditEvent('acp.session.lock.stale_reclaimed', lockAuditDetails, {
757
- source: 'acp',
758
- severity: 'low',
759
- scope: 'session',
760
- phase: 'PREFLIGHT',
761
- });
762
- }
763
- catch {
764
- try {
765
- const lockStat = await stat(lockPath);
766
- const ageMs = Date.now() - lockStat.mtimeMs;
767
- if (Number.isFinite(ageMs) && ageMs > sessionStorePolicy.lockStaleMs * 2) {
768
- await unlink(lockPath);
769
- recordAuditEvent('acp.session.lock.corrupted_reclaimed', {
770
- ...lockAuditDetails,
771
- ageMs: Math.max(0, Math.floor(ageMs)),
772
- }, { source: 'acp', severity: 'medium', scope: 'session', phase: 'PREFLIGHT' });
773
- }
774
- }
775
- catch {
776
- // ignore
777
- }
778
- }
779
- };
780
- let lockHandle;
781
- try {
782
- await mkdir(dir, { recursive: true });
783
- const acquireDeadlineMs = Date.now() + Math.max(250, sessionStorePolicy.lockAcquireTimeoutMs);
784
- for (let attempt = 0; Date.now() < acquireDeadlineMs; attempt += 1) {
785
- try {
786
- lockHandle = await open(lockPath, 'wx');
787
- await lockHandle.writeFile(JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }), 'utf8');
788
- break;
789
- }
790
- catch {
791
- await tryClearStaleLock();
792
- const delayMs = Math.min(250, 20 * (attempt + 1));
793
- await new Promise((resolve) => setTimeout(resolve, delayMs));
794
- }
795
- }
796
- if (!lockHandle) {
797
- recordAuditEvent('acp.session.lock.acquire_timeout', lockAuditDetails, {
798
- source: 'acp',
799
- severity: 'medium',
800
- scope: 'session',
801
- phase: 'PREFLIGHT',
802
- });
803
- throw new Error('ACP_SESSION_PERSIST_LOCK_TIMEOUT');
804
- }
805
- const heartbeat = setInterval(() => {
806
- void writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }), 'utf8');
807
- }, Math.max(1000, sessionStorePolicy.lockHeartbeatMs));
808
- const tempPath = defaultPathAdapter.join(dir, `.sessions.v1.json.tmp-${process.pid}-${Date.now()}`);
809
- try {
810
- let existing = { schemaVersion: 2, sessions: [] };
811
- try {
812
- const existingRaw = await readFile(sessionPersistencePath, 'utf8');
813
- existing = normalizePersistedSessionStore(JSON.parse(existingRaw));
814
- }
815
- catch {
816
- // ignore read failure; writing fresh payload is acceptable
817
- }
818
- const merged = new Map();
819
- const mergedDeletedSessions = pruneDeletedSessionRecords([
820
- ...(existing.deletedSessions ?? []),
821
- ...payloadDeletedSessions,
822
- ]);
823
- const mergedDeletedIds = new Set(mergedDeletedSessions.map((record) => record.id));
824
- for (const record of mergedDeletedSessions) {
825
- deletedSessionIds.set(record.id, record.deletedAt);
826
- }
827
- for (const entry of existing.sessions)
828
- merged.set(entry.id, entry);
829
- for (const entry of payload.sessions)
830
- merged.set(entry.id, entry);
831
- for (const id of mergedDeletedIds)
832
- merged.delete(id);
833
- const mergedPayload = {
834
- schemaVersion: 2,
835
- sessions: pruneSessionRecords(Array.from(merged.values())),
836
- deletedSessions: mergedDeletedSessions,
837
- };
838
- await writeFile(tempPath, JSON.stringify(mergedPayload, null, 2), 'utf8');
839
- await rename(tempPath, sessionPersistencePath);
840
- }
841
- finally {
842
- clearInterval(heartbeat);
843
- }
844
- }
845
- catch (error) {
846
- recordAuditEvent('acp.session.persist.failed', {
847
- errorName: error instanceof Error ? error.name : typeof error,
848
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
849
- }
850
- finally {
851
- if (lockHandle) {
852
- try {
853
- await lockHandle.close();
854
- }
855
- catch {
856
- // ignore
857
- }
858
- try {
859
- await unlink(lockPath);
860
- }
861
- catch {
862
- // ignore
863
- }
864
- }
865
- }
866
- }
867
- async function hydrateSessionsOnce() {
868
- if (sessionsHydrated)
869
- return;
870
- if (hydratePromise)
871
- return hydratePromise;
872
- hydratePromise = (async () => {
873
- sessionsHydrated = true;
874
- if (!sessionPersistencePath)
875
- return;
876
- try {
877
- const raw = await readFile(sessionPersistencePath, 'utf8');
878
- const parsed = normalizePersistedSessionStore(JSON.parse(raw));
879
- const deletedIds = new Set(parsed.deletedSessions?.map((record) => record.id) ?? []);
880
- for (const record of parsed.deletedSessions ?? []) {
881
- deletedSessionIds.set(record.id, record.deletedAt);
882
- }
883
- for (const stored of pruneSessionRecords(parsed.sessions)) {
884
- if (deletedIds.has(stored.id))
885
- continue;
886
- sessions.upsert({
887
- id: stored.id,
888
- cwd: stored.cwd,
889
- mcpServers: Array.isArray(stored.mcpServers) ? stored.mcpServers : [],
890
- createdAt: stored.createdAt,
891
- updatedAt: stored.updatedAt,
892
- title: stored.title,
893
- taskId: stored.taskId,
894
- history: Array.isArray(stored.history)
895
- ? stored.history.slice(-sessionStorePolicy.historyMaxEntries)
896
- : [],
897
- cancelRequested: false,
898
- });
899
- if (!sessionRuntime.has(stored.id)) {
900
- sessionRuntime.set(stored.id, createSessionRuntimeStateFromPersisted({
901
- permissionPolicy: stored.permissionPolicy,
902
- defaultPermissionPolicy: deps.defaultPermissionPolicy,
903
- modeId: stored.modeId,
904
- defaultModeId: deps.defaultModeId,
905
- }));
906
- }
907
- }
908
- }
909
- catch (error) {
910
- if (isFileMissing(error))
911
- return;
912
- recordAuditEvent('acp.session.hydrate.failed', {
913
- errorName: error instanceof Error ? error.name : typeof error,
914
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
915
- }
916
- })();
917
- return hydratePromise;
918
- }
919
- function hashRepoPath(repoPath) {
920
- return createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
921
- }
922
- function toCheckpointMeta(input) {
923
- if (!input)
924
- return null;
925
- return {
926
- id: input.id,
927
- createdAt: input.createdAt ?? null,
928
- strategy: input.strategy ?? null,
929
- backend: input.backend ?? null,
930
- };
931
- }
932
- function toResumeHint(probe) {
933
- if (!probe || probe.valid)
934
- return null;
935
- switch (probe.reason) {
936
- case 'not_found':
937
- return {
938
- code: 'CHECKPOINT_NOT_FOUND',
939
- message: 'Checkpoint not found. Start a new session.',
940
- };
941
- case 'manifest_parse_error':
942
- return {
943
- code: 'CHECKPOINT_MANIFEST_PARSE_ERROR',
944
- message: 'Checkpoint metadata is corrupted. Recreate checkpoint metadata and retry.',
945
- };
946
- case 'manifest_io_error':
947
- return {
948
- code: 'CHECKPOINT_MANIFEST_IO_ERROR',
949
- message: 'Checkpoint metadata is unreadable due to filesystem I/O issues.',
950
- };
951
- case 'manifest_lock_timeout':
952
- return {
953
- code: 'CHECKPOINT_MANIFEST_LOCK_TIMEOUT',
954
- message: 'Checkpoint metadata is busy (lock timeout). Retry shortly.',
955
- };
956
- case 'manifest_unavailable':
957
- return {
958
- code: 'CHECKPOINT_MANIFEST_UNAVAILABLE',
959
- message: 'Checkpoint metadata is unavailable in current runtime.',
960
- };
961
- default:
962
- return {
963
- code: 'CHECKPOINT_RESUME_UNAVAILABLE',
964
- message: 'Checkpoint resume is unavailable. Start a new session or retry.',
965
- };
966
- }
967
- }
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
+ });
968
627
  async function emitSessionUpdate(sessionId, update) {
969
628
  await deps.conn.sessionUpdate({ sessionId, update });
970
629
  }
@@ -987,15 +646,13 @@ export function createAcpFormalAgent(deps) {
987
646
  if (!shouldRefreshPlanForEvent(params.event))
988
647
  return;
989
648
  const { state, event } = params;
990
- const planReader = deps.planReader ?? {
991
- readBySession: async ({ repoPath, sessionId }) => await readPlan({ persistenceRoot: repoPath, sessionId }),
992
- };
649
+ const planReader = deps.planReader;
993
650
  if (event?.type === 'plan.runtime.ready') {
994
651
  state.runtimePlanSessionId = event.sessionId;
995
652
  state.runtimePlanPathHint = event.planPathHint;
996
653
  state.lastPlanDigest = null;
997
654
  }
998
- if (!state.runtimePlanSessionId)
655
+ if (!state.runtimePlanSessionId || !planReader)
999
656
  return;
1000
657
  try {
1001
658
  const read = await planReader.readBySession({
@@ -1017,8 +674,9 @@ export function createAcpFormalAgent(deps) {
1017
674
  }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PLAN' });
1018
675
  }
1019
676
  }
1020
- async function loadSessionInternal(params) {
1021
- await hydrateSessionsOnce();
677
+ async function resolveExistingSession(params) {
678
+ await sessionPersistence.hydrate();
679
+ validateUnsupportedAdditionalDirectories(params.additionalDirectories);
1022
680
  validateAcpMcpServers(params.mcpServers);
1023
681
  const session = sessions.get(params.sessionId);
1024
682
  if (!session) {
@@ -1028,30 +686,12 @@ export function createAcpFormalAgent(deps) {
1028
686
  throw new RequestError(-32602, 'Invalid params: cwd does not match session cwd');
1029
687
  }
1030
688
  if (params.mcpServers) {
689
+ const mcpServers = params.mcpServers;
1031
690
  sessions.update(params.sessionId, (current) => ({
1032
691
  ...current,
1033
- mcpServers: params.mcpServers,
692
+ mcpServers,
1034
693
  }));
1035
- await persistSessionsBestEffort();
1036
- }
1037
- return session;
1038
- }
1039
- async function resumeSessionInternal(params) {
1040
- await hydrateSessionsOnce();
1041
- validateAcpMcpServers(params.mcpServers);
1042
- const session = sessions.get(params.sessionId);
1043
- if (!session) {
1044
- throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1045
- }
1046
- if (session.cwd !== params.cwd) {
1047
- throw new RequestError(-32602, 'Invalid params: cwd does not match session cwd');
1048
- }
1049
- if (params.mcpServers) {
1050
- sessions.update(params.sessionId, (current) => ({
1051
- ...current,
1052
- mcpServers: params.mcpServers ?? [],
1053
- }));
1054
- await persistSessionsBestEffort();
694
+ await sessionPersistence.persist();
1055
695
  }
1056
696
  return session;
1057
697
  }
@@ -1067,29 +707,62 @@ export function createAcpFormalAgent(deps) {
1067
707
  const existing = sessionRuntime.get(sessionId);
1068
708
  if (existing)
1069
709
  return existing;
710
+ const session = sessions.get(sessionId);
1070
711
  const created = createSessionRuntimeStateFromPersisted({
712
+ permissionPolicy: session?.permissionPolicy,
1071
713
  defaultPermissionPolicy: deps.defaultPermissionPolicy,
714
+ modeId: session?.modeId,
1072
715
  defaultModeId: deps.defaultModeId,
1073
716
  });
1074
717
  sessionRuntime.set(sessionId, created);
1075
718
  return created;
1076
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
+ }
1077
758
  return {
1078
759
  async initialize(params) {
1079
760
  if (typeof params.protocolVersion !== 'number' || !Number.isFinite(params.protocolVersion)) {
1080
761
  throw new RequestError(-32602, 'Invalid params: protocolVersion is required');
1081
762
  }
1082
763
  clientCapabilities = params.clientCapabilities;
1083
- // Protocol version negotiation:
1084
- // - If the client's requested version is supported, return the same version
1085
- // - Otherwise, return the latest version the agent supports
1086
- // Currently, the agent only supports protocol version 1
1087
- const supportedProtocolVersion = PROTOCOL_VERSION;
1088
- const negotiatedVersion = params.protocolVersion <= supportedProtocolVersion
1089
- ? params.protocolVersion
1090
- : supportedProtocolVersion;
1091
764
  return {
1092
- protocolVersion: negotiatedVersion,
765
+ protocolVersion: negotiateProtocolVersion(params.protocolVersion),
1093
766
  agentInfo: deps.agentInfo,
1094
767
  authMethods: [],
1095
768
  agentCapabilities: {
@@ -1100,25 +773,30 @@ export function createAcpFormalAgent(deps) {
1100
773
  list: {},
1101
774
  resume: {},
1102
775
  close: {},
776
+ delete: {},
1103
777
  },
1104
778
  },
1105
779
  };
1106
780
  },
1107
- async authenticate() {
1108
- return;
781
+ async authenticate(params) {
782
+ throw new RequestError(-32602, `Invalid params: unsupported auth methodId "${params.methodId}"`);
1109
783
  },
1110
784
  async newSession(params) {
1111
- await hydrateSessionsOnce();
785
+ await sessionPersistence.hydrate();
1112
786
  if (!isAbsolutePath(params.cwd)) {
1113
787
  throw new RequestError(-32602, 'Invalid params: cwd must be an absolute path');
1114
788
  }
789
+ validateUnsupportedAdditionalDirectories(params.additionalDirectories);
1115
790
  validateAcpMcpServers(params.mcpServers);
1116
791
  const session = sessions.create({
1117
792
  cwd: params.cwd,
1118
793
  mcpServers: params.mcpServers ?? [],
1119
794
  title: deriveSessionTitleFromCwd(params.cwd),
795
+ permissionPolicy: isPermissionPolicyValue(String(deps.defaultPermissionPolicy))
796
+ ? deps.defaultPermissionPolicy
797
+ : ACP_PERMISSION_POLICY_ASK,
798
+ modeId: resolveExposedAcpModeId(deps.defaultModeId),
1120
799
  });
1121
- await persistSessionsBestEffort();
1122
800
  const runtimeState = ensureSessionRuntimeState(session.id);
1123
801
  await emitSessionInfoUpdateBestEffort(session.id);
1124
802
  // Restore session state on creation
@@ -1130,18 +808,11 @@ export function createAcpFormalAgent(deps) {
1130
808
  await emitSessionUpdate(session.id, modeUpdate);
1131
809
  let sessionMeta;
1132
810
  if (deps.checkpointReader) {
1133
- const checkpoints = await deps.checkpointReader.listBySession({
811
+ const result = await probeCheckpointForNewSession(deps.checkpointReader, {
1134
812
  repoPath: params.cwd,
1135
813
  sessionId: session.id,
1136
- limit: 1,
1137
814
  });
1138
- const latest = checkpoints.at(-1);
1139
- sessionMeta = {
1140
- salmonloop: {
1141
- latestCheckpointId: latest?.id ?? null,
1142
- checkpoint: toCheckpointMeta(latest),
1143
- },
1144
- };
815
+ sessionMeta = result._meta;
1145
816
  }
1146
817
  return {
1147
818
  sessionId: session.id,
@@ -1152,9 +823,9 @@ export function createAcpFormalAgent(deps) {
1152
823
  },
1153
824
  async loadSession(params) {
1154
825
  if (!loadSessionCapability) {
1155
- throw new RequestError(-32601, '"Method not found": session/load');
826
+ throw new RequestError(-32601, 'Method not found: session/load');
1156
827
  }
1157
- await loadSessionInternal(params);
828
+ await resolveExistingSession(params);
1158
829
  let session = sessions.get(params.sessionId);
1159
830
  const runtimeState = ensureSessionRuntimeState(session.id);
1160
831
  if (typeof session.title !== 'string' || !session.title.trim()) {
@@ -1163,7 +834,7 @@ export function createAcpFormalAgent(deps) {
1163
834
  ...current,
1164
835
  title: deriveSessionTitleFromCwd(current.cwd),
1165
836
  })) ?? session;
1166
- await persistSessionsBestEffort();
837
+ await sessionPersistence.persist();
1167
838
  }
1168
839
  runtimeState.lastSessionInfoDigest = null;
1169
840
  await emitSessionInfoUpdateBestEffort(session.id);
@@ -1183,10 +854,10 @@ export function createAcpFormalAgent(deps) {
1183
854
  await emitSessionUpdate(session.id, modeUpdate);
1184
855
  for (const entry of session.history) {
1185
856
  for (const block of entry.content) {
1186
- if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
857
+ if (isReplayableSessionContentBlock(block)) {
1187
858
  await emitSessionUpdate(session.id, {
1188
859
  sessionUpdate: entry.role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
1189
- content: buildTextContentBlock(block.text),
860
+ content: block,
1190
861
  });
1191
862
  }
1192
863
  }
@@ -1196,56 +867,12 @@ export function createAcpFormalAgent(deps) {
1196
867
  modes: buildModesState(runtimeState.modeId),
1197
868
  };
1198
869
  if (deps.checkpointReader) {
1199
- const startedAt = Date.now();
1200
- const checkpoints = await deps.checkpointReader.listBySession({
870
+ const result = await probeCheckpoint(deps.checkpointReader, {
1201
871
  repoPath: params.cwd,
1202
- sessionId: params.sessionId,
1203
- limit: 1,
1204
- });
1205
- const latest = checkpoints.at(-1);
1206
- let resumeProbe = null;
1207
- if (latest?.id && deps.checkpointReader.probeById) {
1208
- const probed = await deps.checkpointReader.probeById({
1209
- repoPath: params.cwd,
1210
- checkpointId: latest.id,
1211
- });
1212
- resumeProbe = {
1213
- checkpointId: latest.id,
1214
- valid: probed.valid,
1215
- reason: probed.reason,
1216
- };
1217
- }
1218
- else if (latest?.id && deps.checkpointReader.getById) {
1219
- const found = await deps.checkpointReader.getById({
1220
- repoPath: params.cwd,
1221
- checkpointId: latest.id,
1222
- });
1223
- resumeProbe = {
1224
- checkpointId: latest.id,
1225
- valid: Boolean(found),
1226
- reason: found ? 'ok' : 'not_found',
1227
- };
1228
- }
1229
- const resumeReady = resumeProbe?.valid ?? Boolean(latest);
1230
- recordAuditEvent('acp.checkpoint.read', {
1231
872
  sessionId: params.sessionId,
1232
873
  repoPathHash: hashRepoPath(params.cwd),
1233
- latestCheckpointId: latest?.id ?? null,
1234
- hit: Boolean(latest),
1235
- latencyMs: Date.now() - startedAt,
1236
- resumeProbe: resumeProbe ?? undefined,
1237
- }, { source: 'acp', severity: 'low', scope: 'session', phase: 'PREFLIGHT' });
1238
- const resumeHint = toResumeHint(resumeProbe);
1239
- response._meta = {
1240
- salmonloop: {
1241
- latestCheckpointId: latest?.id ?? null,
1242
- checkpoint: toCheckpointMeta(latest),
1243
- resumeReady,
1244
- resumeProbe,
1245
- resumeHint: resumeHint?.message ?? null,
1246
- resumeHintCode: resumeHint?.code ?? null,
1247
- },
1248
- };
874
+ });
875
+ response._meta = result._meta;
1249
876
  }
1250
877
  else {
1251
878
  recordAuditEvent('acp.checkpoint.read', {
@@ -1259,23 +886,39 @@ export function createAcpFormalAgent(deps) {
1259
886
  return response;
1260
887
  },
1261
888
  async listSessions(params) {
1262
- await hydrateSessionsOnce();
889
+ await sessionPersistence.hydrate();
1263
890
  if (typeof params.cwd === 'string' && params.cwd && !isAbsolutePath(params.cwd)) {
1264
891
  throw new RequestError(-32602, 'Invalid params: cwd must be an absolute path');
1265
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
+ }
1266
902
  const filtered = sessions
1267
903
  .list()
1268
- .filter((session) => !params.cwd || session.cwd === params.cwd)
904
+ .filter((session) => !cwdFilter || session.cwd === cwdFilter)
1269
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;
1270
911
  return {
1271
- sessions: filtered.map(toSessionInfo),
912
+ sessions: page.map(toSessionInfo),
913
+ ...(nextCursor ? { nextCursor } : {}),
1272
914
  };
1273
915
  },
1274
916
  async resumeSession(params) {
1275
- const session = await resumeSessionInternal({
917
+ const session = await resolveExistingSession({
1276
918
  sessionId: params.sessionId,
1277
919
  cwd: params.cwd,
1278
920
  mcpServers: params.mcpServers,
921
+ additionalDirectories: params.additionalDirectories,
1279
922
  });
1280
923
  const runtimeState = ensureSessionRuntimeState(session.id);
1281
924
  runtimeState.lastSessionInfoDigest = null;
@@ -1286,57 +929,64 @@ export function createAcpFormalAgent(deps) {
1286
929
  const modeUpdate = buildCurrentModeUpdateIfChanged(runtimeState);
1287
930
  if (modeUpdate)
1288
931
  await emitSessionUpdate(session.id, modeUpdate);
1289
- return {
932
+ const response = {
1290
933
  configOptions: buildConfigOptions(runtimeState),
1291
934
  modes: buildModesState(runtimeState.modeId),
1292
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;
1293
945
  },
1294
946
  async closeSession(params) {
1295
- await hydrateSessionsOnce();
1296
- const session = sessions.get(params.sessionId);
1297
- if (!session)
1298
- return {};
1299
- sessions.update(params.sessionId, (current) => ({ ...current, cancelRequested: true }));
1300
- if (session.taskId) {
1301
- await deps.facade.cancelTask(session.taskId);
1302
- }
1303
- deletedSessionIds.set(params.sessionId, new Date().toISOString());
1304
- sessionRuntime.delete(params.sessionId);
1305
- sessions.delete(params.sessionId);
1306
- await persistSessionsBestEffort();
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 });
1307
954
  return {};
1308
955
  },
1309
956
  async setSessionConfigOption(params) {
1310
- await hydrateSessionsOnce();
957
+ await sessionPersistence.hydrate();
1311
958
  if (!sessions.get(params.sessionId)) {
1312
959
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1313
960
  }
1314
961
  const runtimeState = ensureSessionRuntimeState(params.sessionId);
1315
- if (typeof params.value !== 'string') {
1316
- throw new RequestError(-32602, `Invalid params: unsupported non-string value for "${params.configId}"`);
1317
- }
1318
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
+ }
1319
966
  if (!isPermissionPolicyValue(params.value)) {
1320
967
  throw new RequestError(-32602, `Invalid params: unsupported value "${params.value}" for "${params.configId}"`);
1321
968
  }
1322
969
  runtimeState.permissionPolicy = params.value;
1323
970
  }
1324
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
+ }
1325
975
  const parsedModeId = parseAcpFlowMode(params.value);
1326
976
  if (!parsedModeId || !ACP_PUBLIC_MODE_IDS.has(parsedModeId)) {
1327
977
  throw new RequestError(-32602, `Invalid params: unsupported value "${params.value}" for "${params.configId}"`);
1328
978
  }
1329
979
  runtimeState.modeId = parsedModeId;
1330
- const legacyPermissionPolicy = getLegacyPermissionPolicyForModeValue(params.value);
1331
- if (legacyPermissionPolicy) {
1332
- runtimeState.permissionPolicy = legacyPermissionPolicy;
1333
- }
1334
980
  }
1335
981
  else {
1336
982
  throw new RequestError(-32602, `Invalid params: unsupported configId "${params.configId}"`);
1337
983
  }
1338
- sessions.update(params.sessionId, (current) => ({ ...current }));
1339
- await persistSessionsBestEffort();
984
+ sessions.update(params.sessionId, (current) => ({
985
+ ...current,
986
+ permissionPolicy: runtimeState.permissionPolicy,
987
+ modeId: runtimeState.modeId,
988
+ }));
989
+ await sessionPersistence.persist();
1340
990
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1341
991
  const update = buildConfigOptionUpdateIfChanged(runtimeState);
1342
992
  if (update) {
@@ -1349,7 +999,7 @@ export function createAcpFormalAgent(deps) {
1349
999
  return { configOptions: buildConfigOptions(runtimeState) };
1350
1000
  },
1351
1001
  async setSessionMode(params) {
1352
- await hydrateSessionsOnce();
1002
+ await sessionPersistence.hydrate();
1353
1003
  if (!sessions.get(params.sessionId)) {
1354
1004
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1355
1005
  }
@@ -1359,12 +1009,12 @@ export function createAcpFormalAgent(deps) {
1359
1009
  throw new RequestError(-32602, `Invalid params: unsupported modeId "${params.modeId}"`);
1360
1010
  }
1361
1011
  runtimeState.modeId = resolvedModeId;
1362
- const legacyPermissionPolicy = getLegacyPermissionPolicyForModeValue(params.modeId);
1363
- if (legacyPermissionPolicy) {
1364
- runtimeState.permissionPolicy = legacyPermissionPolicy;
1365
- }
1366
- sessions.update(params.sessionId, (current) => ({ ...current }));
1367
- await persistSessionsBestEffort();
1012
+ sessions.update(params.sessionId, (current) => ({
1013
+ ...current,
1014
+ permissionPolicy: runtimeState.permissionPolicy,
1015
+ modeId: runtimeState.modeId,
1016
+ }));
1017
+ await sessionPersistence.persist();
1368
1018
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1369
1019
  const configUpdate = buildConfigOptionUpdateIfChanged(runtimeState);
1370
1020
  if (configUpdate) {
@@ -1378,7 +1028,7 @@ export function createAcpFormalAgent(deps) {
1378
1028
  return {};
1379
1029
  },
1380
1030
  async prompt(params) {
1381
- await hydrateSessionsOnce();
1031
+ await sessionPersistence.hydrate();
1382
1032
  const session = sessions.get(params.sessionId);
1383
1033
  if (!session) {
1384
1034
  throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
@@ -1391,7 +1041,10 @@ export function createAcpFormalAgent(deps) {
1391
1041
  const runtimeState = ensureSessionRuntimeState(params.sessionId);
1392
1042
  // Check for cancellation before starting processing
1393
1043
  if (sessions.get(params.sessionId)?.cancelRequested === true) {
1394
- return { stopReason: 'cancelled' };
1044
+ return {
1045
+ stopReason: 'cancelled',
1046
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1047
+ };
1395
1048
  }
1396
1049
  sessions.update(params.sessionId, (current) => {
1397
1050
  const title = typeof current.title === 'string' && current.title.trim()
@@ -1401,13 +1054,16 @@ export function createAcpFormalAgent(deps) {
1401
1054
  ...current,
1402
1055
  cancelRequested: false,
1403
1056
  title,
1057
+ materialized: true,
1058
+ permissionPolicy: runtimeState.permissionPolicy,
1059
+ modeId: runtimeState.modeId,
1404
1060
  history: [
1405
1061
  ...current.history,
1406
1062
  { role: 'user', content: params.prompt },
1407
1063
  ],
1408
1064
  };
1409
1065
  });
1410
- await persistSessionsBestEffort();
1066
+ await sessionPersistence.persist();
1411
1067
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1412
1068
  const configUpdate = buildConfigOptionUpdateIfChanged(runtimeState);
1413
1069
  if (configUpdate) {
@@ -1439,14 +1095,20 @@ export function createAcpFormalAgent(deps) {
1439
1095
  { role: 'assistant', content: [buildTextContentBlock(responseText)] },
1440
1096
  ],
1441
1097
  }));
1442
- await persistSessionsBestEffort();
1098
+ await sessionPersistence.persist();
1443
1099
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1444
- return { stopReason: 'end_turn' };
1100
+ return {
1101
+ stopReason: 'end_turn',
1102
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1103
+ };
1445
1104
  }
1446
1105
  }
1447
1106
  // Check for cancellation again before creating task
1448
1107
  if (sessions.get(params.sessionId)?.cancelRequested === true) {
1449
- return { stopReason: 'cancelled' };
1108
+ return {
1109
+ stopReason: 'cancelled',
1110
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1111
+ };
1450
1112
  }
1451
1113
  const pendingUpdates = [];
1452
1114
  const executionRequest = buildCanonicalExecutionRequest({
@@ -1489,34 +1151,39 @@ export function createAcpFormalAgent(deps) {
1489
1151
  },
1490
1152
  });
1491
1153
  sessions.update(params.sessionId, (current) => ({ ...current, taskId: task.id }));
1492
- await persistSessionsBestEffort();
1154
+ await sessionPersistence.persist();
1493
1155
  if (signal.aborted) {
1494
1156
  await emitSessionUpdate(params.sessionId, {
1495
1157
  sessionUpdate: 'agent_message_chunk',
1496
- content: buildTextContentBlock(ensureMarkdownParagraphBreak('Task cancelled.')),
1158
+ content: buildTextContentBlock(ensureMarkdownParagraphBreak(text.acp.taskCancelled)),
1497
1159
  });
1498
- return { stopReason: 'cancelled' };
1160
+ return {
1161
+ stopReason: 'cancelled',
1162
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1163
+ };
1499
1164
  }
1500
1165
  const terminalEvent = await awaitTerminalEvent({ taskId: task.id, eventBus: deps.eventBus });
1501
1166
  let stopReason = 'end_turn';
1502
- let assistantText = 'Task completed.';
1167
+ let assistantText = text.acp.taskCompleted;
1503
1168
  let assistantMeta;
1504
1169
  let latest;
1505
1170
  const cancelRequested = sessions.get(params.sessionId)?.cancelRequested === true;
1506
1171
  if (cancelRequested) {
1507
- assistantText = 'Task cancelled.';
1172
+ assistantText = text.acp.taskCancelled;
1508
1173
  stopReason = 'cancelled';
1509
1174
  }
1510
1175
  else if (terminalEvent?.type === 'task.failed') {
1511
1176
  latest = await deps.facade.getTask(task.id);
1512
1177
  const failureMessage = typeof latest?.failure?.message === 'string' ? latest.failure.message : undefined;
1513
- assistantText = failureMessage ? `Task failed: ${failureMessage}` : 'Task failed.';
1178
+ assistantText = failureMessage
1179
+ ? text.acp.taskFailedWithReason(failureMessage)
1180
+ : text.acp.taskFailed;
1514
1181
  const inferred = inferTurnStopReasonFromFailure(latest?.failure);
1515
1182
  if (inferred)
1516
1183
  stopReason = inferred;
1517
1184
  }
1518
1185
  else if (terminalEvent?.type === 'task.awaiting_input') {
1519
- assistantText = 'Task awaiting input.';
1186
+ assistantText = text.acp.taskAwaitingInput;
1520
1187
  latest = await deps.facade.getTask(task.id);
1521
1188
  const formatted = latest?.inputRequired
1522
1189
  ? formatInputRequiredMessage(latest.inputRequired)
@@ -1528,7 +1195,7 @@ export function createAcpFormalAgent(deps) {
1528
1195
  }
1529
1196
  }
1530
1197
  else if (terminalEvent?.type === 'task.cancelled') {
1531
- assistantText = 'Task cancelled.';
1198
+ assistantText = text.acp.taskCancelled;
1532
1199
  stopReason = 'cancelled';
1533
1200
  }
1534
1201
  await emitSessionUpdate(params.sessionId, {
@@ -1549,7 +1216,7 @@ export function createAcpFormalAgent(deps) {
1549
1216
  { role: 'assistant', content: [buildTextContentBlock(assistantText)] },
1550
1217
  ],
1551
1218
  }));
1552
- await persistSessionsBestEffort();
1219
+ await sessionPersistence.persist();
1553
1220
  const latestSession = sessions.get(params.sessionId);
1554
1221
  if (latestSession) {
1555
1222
  const sessionInfoUpdate = buildSessionInfoUpdateIfChanged(latestSession, runtimeState);
@@ -1561,16 +1228,19 @@ export function createAcpFormalAgent(deps) {
1561
1228
  }
1562
1229
  // Wait for all pending session updates to be sent before responding
1563
1230
  await Promise.all(pendingUpdates);
1564
- return { stopReason };
1231
+ return {
1232
+ stopReason,
1233
+ ...(params.messageId ? { userMessageId: params.messageId } : {}),
1234
+ };
1565
1235
  },
1566
1236
  async cancel(params) {
1567
- await hydrateSessionsOnce();
1237
+ await sessionPersistence.hydrate();
1568
1238
  const session = sessions.get(params.sessionId);
1569
1239
  if (!session)
1570
1240
  return;
1571
1241
  // Mark the session as cancelled
1572
1242
  sessions.update(params.sessionId, (current) => ({ ...current, cancelRequested: true }));
1573
- await persistSessionsBestEffort();
1243
+ await sessionPersistence.persist();
1574
1244
  await emitSessionInfoUpdateBestEffort(params.sessionId);
1575
1245
  // If a task is running, cancel it
1576
1246
  if (session.taskId) {
@@ -1579,8 +1249,6 @@ export function createAcpFormalAgent(deps) {
1579
1249
  // Note: The prompt method will check the cancelRequested flag and return
1580
1250
  // StopReason::Cancelled as required by the protocol
1581
1251
  },
1582
- extMethod: async () => ({}),
1583
- extNotification: async () => { },
1584
1252
  };
1585
1253
  }
1586
1254
  //# sourceMappingURL=formal-agent.js.map