salmon-loop 0.2.3 → 0.2.13

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 (42) hide show
  1. package/dist/cli/chat.js +1 -0
  2. package/dist/cli/commands/chat.js +17 -18
  3. package/dist/cli/commands/context.js +15 -3
  4. package/dist/cli/commands/help-format.js +12 -0
  5. package/dist/cli/commands/registry.js +4 -7
  6. package/dist/cli/commands/run/config-resolution.js +30 -24
  7. package/dist/cli/commands/run/handler.js +16 -17
  8. package/dist/cli/commands/run/loop-params.js +1 -0
  9. package/dist/cli/commands/run/parse-options.js +2 -2
  10. package/dist/cli/commands/run/validate-options.js +0 -5
  11. package/dist/cli/commands/run/verbose.js +2 -7
  12. package/dist/cli/commands/serve.js +29 -22
  13. package/dist/cli/locales/en.js +2 -0
  14. package/dist/cli/program-bootstrap.js +6 -1
  15. package/dist/cli/program-commands.js +4 -0
  16. package/dist/cli/program-options.js +1 -0
  17. package/dist/cli/slash/runtime.js +3 -3
  18. package/dist/cli/utils/output-format.js +6 -0
  19. package/dist/cli/utils/resolve-cli-config.js +98 -0
  20. package/dist/cli/utils/verbose-level.js +8 -0
  21. package/dist/core/config/load.js +22 -8
  22. package/dist/core/config/merge.js +27 -0
  23. package/dist/core/config/paths.js +24 -5
  24. package/dist/core/config/resolve.js +7 -5
  25. package/dist/core/config/validate.js +21 -0
  26. package/dist/core/facades/cli-command-chat.js +1 -1
  27. package/dist/core/facades/cli-context.js +1 -0
  28. package/dist/core/grizzco/engine/transaction/transaction-runner.js +8 -0
  29. package/dist/core/grizzco/steps/preflight.js +4 -1
  30. package/dist/core/intent/chat-intent.js +0 -4
  31. package/dist/core/llm/ai-sdk/request-params.js +1 -1
  32. package/dist/core/protocols/a2a/sdk/executor.js +6 -5
  33. package/dist/core/protocols/acp/formal-agent.js +163 -20
  34. package/dist/core/protocols/acp/permission-provider.js +20 -0
  35. package/dist/core/protocols/shared/execution-request.js +24 -0
  36. package/dist/core/session/compression.js +4 -4
  37. package/dist/core/session/manager.js +3 -2
  38. package/dist/core/strata/layers/worktree.js +4 -4
  39. package/dist/core/tools/builtin/fs.js +4 -4
  40. package/dist/interfaces/cli/task-runner.js +4 -3
  41. package/dist/locales/en.js +52 -0
  42. package/package.json +3 -3
@@ -1,14 +1,9 @@
1
1
  import { readFile } from '../adapters/fs/node-fs.js';
2
2
  import { ConfigError } from './errors.js';
3
3
  import { parseConfigText } from './file-format.js';
4
- import { getDefaultRepoConfigPaths, resolveConfigPath } from './paths.js';
4
+ import { getDefaultRepoConfigPaths, getDefaultUserConfigPaths, resolveConfigPath, } from './paths.js';
5
5
  import { validateConfigFileV1 } from './validate.js';
6
- export async function tryLoadConfigFile(opts) {
7
- if (!opts.enabled)
8
- return null;
9
- const candidatePaths = opts.configPath
10
- ? [resolveConfigPath(opts.repoRoot, opts.configPath)]
11
- : getDefaultRepoConfigPaths(opts.repoRoot);
6
+ async function loadFromCandidates(candidatePaths, required) {
12
7
  for (let i = 0; i < candidatePaths.length; i++) {
13
8
  const absPath = candidatePaths[i];
14
9
  try {
@@ -21,7 +16,7 @@ export async function tryLoadConfigFile(opts) {
21
16
  if ((e && typeof e === 'object' && 'code' in e ? e.code : undefined) ===
22
17
  'ENOENT') {
23
18
  const isLast = i === candidatePaths.length - 1;
24
- if (opts.required && isLast) {
19
+ if (required && isLast) {
25
20
  throw new ConfigError('CONFIG_FILE_NOT_FOUND', { path: absPath });
26
21
  }
27
22
  continue;
@@ -31,4 +26,23 @@ export async function tryLoadConfigFile(opts) {
31
26
  }
32
27
  return null;
33
28
  }
29
+ export async function tryLoadConfigFile(opts) {
30
+ if (!opts.enabled)
31
+ return null;
32
+ const candidatePaths = opts.configPath
33
+ ? [resolveConfigPath(opts.repoRoot, opts.configPath)]
34
+ : getDefaultRepoConfigPaths(opts.repoRoot);
35
+ return loadFromCandidates(candidatePaths, opts.required);
36
+ }
37
+ export async function loadConfigStack(opts) {
38
+ if (!opts.enabled)
39
+ return {};
40
+ if (opts.configPath) {
41
+ const loaded = await tryLoadConfigFile(opts);
42
+ return loaded ? { repo: loaded } : {};
43
+ }
44
+ const repo = await loadFromCandidates(getDefaultRepoConfigPaths(opts.repoRoot), false);
45
+ const user = await loadFromCandidates(getDefaultUserConfigPaths(), false);
46
+ return { repo: repo ?? undefined, user: user ?? undefined };
47
+ }
34
48
  //# sourceMappingURL=load.js.map
@@ -0,0 +1,27 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ function mergeValues(userValue, repoValue) {
5
+ if (repoValue === undefined)
6
+ return userValue;
7
+ if (userValue === undefined)
8
+ return repoValue;
9
+ if (isPlainObject(userValue) && isPlainObject(repoValue)) {
10
+ const merged = { ...userValue };
11
+ for (const [key, value] of Object.entries(repoValue)) {
12
+ merged[key] = mergeValues(userValue[key], value);
13
+ }
14
+ return merged;
15
+ }
16
+ return repoValue;
17
+ }
18
+ export function mergeConfigFiles(userConfig, repoConfig) {
19
+ if (!userConfig && !repoConfig)
20
+ return undefined;
21
+ if (!userConfig)
22
+ return repoConfig;
23
+ if (!repoConfig)
24
+ return userConfig;
25
+ return mergeValues(userConfig, repoConfig);
26
+ }
27
+ //# sourceMappingURL=merge.js.map
@@ -1,4 +1,11 @@
1
- import { join, resolve } from 'path';
1
+ import { homedir } from 'os';
2
+ import { defaultPathAdapter } from '../adapters/path/path-adapter.js';
3
+ function resolveUserConfigHome() {
4
+ const override = (process.env.SALMONLOOP_USER_CONFIG_HOME || '').trim();
5
+ if (override)
6
+ return defaultPathAdapter.resolve(override);
7
+ return homedir();
8
+ }
2
9
  /**
3
10
  * Repo-local configuration lives under ".salmonloop/" and is expected to be gitignored.
4
11
  * Runtime state is stored under ".salmonloop/runtime/" (audit, rejections, tmp, locks).
@@ -7,14 +14,26 @@ export function getDefaultRepoConfigPath(repoRoot) {
7
14
  return getDefaultRepoConfigPaths(repoRoot)[0];
8
15
  }
9
16
  export function getDefaultRepoConfigPaths(repoRoot) {
10
- const base = join(resolve(repoRoot), '.salmonloop', 'config');
11
- return [join(base, 'config.yaml'), join(base, 'config.yml'), join(base, 'config.json')];
17
+ const base = defaultPathAdapter.join(defaultPathAdapter.resolve(repoRoot), '.salmonloop', 'config');
18
+ return [
19
+ defaultPathAdapter.join(base, 'config.yaml'),
20
+ defaultPathAdapter.join(base, 'config.yml'),
21
+ defaultPathAdapter.join(base, 'config.json'),
22
+ ];
23
+ }
24
+ export function getDefaultUserConfigPaths() {
25
+ const base = defaultPathAdapter.join(resolveUserConfigHome(), '.salmonloop', 'config');
26
+ return [
27
+ defaultPathAdapter.join(base, 'config.yaml'),
28
+ defaultPathAdapter.join(base, 'config.yml'),
29
+ defaultPathAdapter.join(base, 'config.json'),
30
+ ];
12
31
  }
13
32
  export function resolveConfigPath(repoRoot, configPath) {
14
33
  // Relative paths are resolved against the target repo root (not the CLI's cwd).
15
- return resolve(repoRoot, configPath);
34
+ return defaultPathAdapter.resolve(repoRoot, configPath);
16
35
  }
17
36
  export function getDefaultIndexPath(repoRoot) {
18
- return join(resolve(repoRoot), '.salmonloop', 'index');
37
+ return defaultPathAdapter.join(defaultPathAdapter.resolve(repoRoot), '.salmonloop', 'index');
19
38
  }
20
39
  //# sourceMappingURL=paths.js.map
@@ -1,5 +1,6 @@
1
1
  import { resolveLlmOutputPolicy } from '../llm/output-policy.js';
2
- import { tryLoadConfigFile } from './load.js';
2
+ import { loadConfigStack } from './load.js';
3
+ import { mergeConfigFiles } from './merge.js';
3
4
  import { getDefaultRepoConfigPath } from './paths.js';
4
5
  import { resolveLlmFromConfig } from './resolve-llm.js';
5
6
  import { resolveAstValidationStrictness } from './resolvers/ast-validation.js';
@@ -15,20 +16,21 @@ export async function resolveConfig(opts) {
15
16
  const enabled = opts.enableConfigFile !== false;
16
17
  const path = opts.configFilePath;
17
18
  const required = Boolean(opts.configFilePath);
18
- const loaded = await tryLoadConfigFile({
19
+ const loaded = await loadConfigStack({
19
20
  repoRoot: opts.repoRoot,
20
21
  configPath: path,
21
22
  enabled,
22
23
  required,
23
24
  });
24
- const raw = loaded?.config;
25
+ const raw = mergeConfigFiles(loaded.user?.config, loaded.repo?.config);
25
26
  const uiLogMode = resolveUiLogMode(raw);
26
27
  const permissionMode = resolvePermissionMode(raw);
28
+ const sourcePath = loaded.repo?.path || loaded.user?.path || path || getDefaultRepoConfigPath(opts.repoRoot);
27
29
  return {
28
30
  source: {
29
31
  enabled,
30
- path: loaded?.path || path || getDefaultRepoConfigPath(opts.repoRoot),
31
- used: Boolean(loaded),
32
+ path: sourcePath,
33
+ used: Boolean(loaded.repo || loaded.user),
32
34
  },
33
35
  raw,
34
36
  permissionMode,
@@ -471,6 +471,27 @@ export function validateConfigFileV1(input) {
471
471
  }
472
472
  if (input.llm.activeModel !== undefined)
473
473
  cfg.llm.activeModel = input.llm.activeModel;
474
+ const llmAny = input.llm;
475
+ if (llmAny.simpleModel !== undefined && !isString(llmAny.simpleModel)) {
476
+ throw new ConfigError('CONFIG_INVALID_LLM_SIMPLE_MODEL', { expected: 'string' });
477
+ }
478
+ if (llmAny.simpleModel !== undefined)
479
+ cfg.llm.simpleModel = llmAny.simpleModel;
480
+ if (llmAny.mediumModel !== undefined && !isString(llmAny.mediumModel)) {
481
+ throw new ConfigError('CONFIG_INVALID_LLM_MEDIUM_MODEL', { expected: 'string' });
482
+ }
483
+ if (llmAny.mediumModel !== undefined)
484
+ cfg.llm.mediumModel = llmAny.mediumModel;
485
+ if (llmAny.complexModel !== undefined && !isString(llmAny.complexModel)) {
486
+ throw new ConfigError('CONFIG_INVALID_LLM_COMPLEX_MODEL', { expected: 'string' });
487
+ }
488
+ if (llmAny.complexModel !== undefined)
489
+ cfg.llm.complexModel = llmAny.complexModel;
490
+ if (llmAny.reasoningModel !== undefined && !isString(llmAny.reasoningModel)) {
491
+ throw new ConfigError('CONFIG_INVALID_LLM_REASONING_MODEL', { expected: 'string' });
492
+ }
493
+ if (llmAny.reasoningModel !== undefined)
494
+ cfg.llm.reasoningModel = llmAny.reasoningModel;
474
495
  if (input.llm.providers !== undefined) {
475
496
  if (!isRecord(input.llm.providers)) {
476
497
  throw new ConfigError('CONFIG_INVALID_LLM_PROVIDERS', { expected: 'object' });
@@ -1,4 +1,4 @@
1
- export { normalizePermissionMode, resolveConfig } from '../config/index.js';
1
+ export { ConfigError, normalizePermissionMode, resolveConfig } from '../config/index.js';
2
2
  export { ExtensionConfigError, resolveExtensions } from '../extensions/index.js';
3
3
  export { createRuntimeLlm } from '../llm/factory.js';
4
4
  export { getLogger } from '../observability/logger.js';
@@ -1,4 +1,5 @@
1
1
  export { defaultPathAdapter } from '../adapters/path/path-adapter.js';
2
+ export { ConfigError } from '../config/index.js';
2
3
  export { resolveConfig } from '../config/resolve.js';
3
4
  export { createContextCacheStore } from '../context/cache/store-factory.js';
4
5
  export { ContextService } from '../context/index.js';
@@ -1,4 +1,5 @@
1
1
  import { recordAuditEvent } from '../../../observability/audit-trail.js';
2
+ import { mapErrorForAudit } from '../../../observability/error-mapping.js';
2
3
  import { ReflectionEngine } from '../../../reflection/engine.js';
3
4
  import { executeSalmonLoopFlow } from '../../flows/SalmonLoopFlow.js';
4
5
  import { resolveAttemptFailure } from './attempt-failure.js';
@@ -120,6 +121,10 @@ export class FlowTransactionRunner {
120
121
  timestamp: this.params.now(),
121
122
  });
122
123
  }
124
+ const mappedAuditError = mapErrorForAudit({
125
+ message: attemptFailure.safeHint ?? attemptFailure.reason,
126
+ code: attemptFailure.errorCode ?? attemptFailure.reasonCode,
127
+ });
123
128
  recordAuditEvent('loop.attempt.failure', {
124
129
  attempt,
125
130
  flowMode: this.params.flowMode,
@@ -131,6 +136,9 @@ export class FlowTransactionRunner {
131
136
  failurePhase: attemptFailure.failurePhase,
132
137
  retryable: attemptFailure.retryable,
133
138
  errorCode: attemptFailure.errorCode,
139
+ errorSummary: mappedAuditError.summary,
140
+ errorCategory: mappedAuditError.category,
141
+ errorRedacted: mappedAuditError.redacted,
134
142
  lastStep: result.lastStep,
135
143
  }, {
136
144
  phase: attemptFailure.failurePhase,
@@ -6,7 +6,10 @@ import { preflight } from '../../verification/runner.js';
6
6
  import { resolveLlmToolCallingPolicy } from '../dsl/llm-strategy.js';
7
7
  export const runPreflight = async (ctx) => {
8
8
  const result = await preflight(ctx.workspace, ctx.emit, {
9
- ignoreDirty: ctx.mode === 'review' || ctx.mode === 'research' || ctx.mode === 'answer',
9
+ ignoreDirty: ctx.mode === 'review' ||
10
+ ctx.mode === 'research' ||
11
+ ctx.mode === 'answer' ||
12
+ ctx.options.permissionMode === 'yolo',
10
13
  });
11
14
  if (!result.ok) {
12
15
  const reason = result.reason || text.loop.preflightFailedNotGit;
@@ -64,10 +64,6 @@ function routeHeuristic(input) {
64
64
  'research',
65
65
  'investigate',
66
66
  'investigation',
67
- '调研',
68
- '研究',
69
- '资料搜集',
70
- '资料收集',
71
67
  ];
72
68
  if (researchSignals.some((s) => lower.includes(s))) {
73
69
  return {
@@ -4,7 +4,7 @@ export function buildAiSdkRequestParams(params) {
4
4
  messages: params.messages,
5
5
  tools: params.tools,
6
6
  temperature: params.options.temperature,
7
- maxOutputTokens: params.options.maxTokens,
7
+ maxOutputTokens: params.options.maxTokens != null ? Number(params.options.maxTokens) : undefined,
8
8
  stopSequences: params.options.stop,
9
9
  toolChoice: (params.options.toolChoice === 'none'
10
10
  ? 'none'
@@ -1,4 +1,5 @@
1
1
  import { InMemoryTaskStore } from '@a2a-js/sdk/server';
2
+ import { buildCanonicalExecutionRequest, buildInstructionFromParts, } from '../../shared/execution-request.js';
2
3
  export function createA2AInteractionExecutor(deps) {
3
4
  const store = deps.taskStore ?? new InMemoryTaskStore();
4
5
  const metadataByTaskId = new Map();
@@ -41,12 +42,13 @@ export function createA2AInteractionExecutor(deps) {
41
42
  }
42
43
  };
43
44
  try {
44
- const { task } = await deps.facade.createTask({
45
+ const executionRequest = buildCanonicalExecutionRequest({
45
46
  capability,
46
- request: { instruction: extractInstruction(requestContext.userMessage) },
47
+ instruction: extractInstruction(requestContext.userMessage),
47
48
  // Pass SDK's taskId to facade to ensure consistency with eventBusManager
48
49
  taskId: requestContext.taskId,
49
50
  });
51
+ const { task } = await deps.facade.createTask(executionRequest);
50
52
  resolvedTaskId = task.id;
51
53
  cleanupByTaskId.set(task.id, cleanup);
52
54
  metadataByTaskId.set(task.id, {
@@ -216,9 +218,8 @@ export function createA2AInteractionExecutor(deps) {
216
218
  function extractInstruction(message) {
217
219
  const textParts = message.parts
218
220
  .filter((part) => part.kind === 'text')
219
- .map((part) => part.text.trim())
220
- .filter(Boolean);
221
- return textParts.join('\n') || 'Run task';
221
+ .map((part) => part.text);
222
+ return buildInstructionFromParts(textParts, { fallbackInstruction: 'Run task' });
222
223
  }
223
224
  function delay(ms) {
224
225
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -7,6 +7,7 @@ import { inferTurnStopReasonFromFailure } from '../../interaction/turn-stop-reas
7
7
  import { recordAuditEvent } from '../../observability/audit-trail.js';
8
8
  import { readPlan } from '../../plan/index.js';
9
9
  import { parseSlashInput } from '../../slash/parser.js';
10
+ import { buildCanonicalExecutionRequest } from '../shared/execution-request.js';
10
11
  import { createAcpCommandRunner } from './acp-command-runner.js';
11
12
  import { createAcpFileSystem } from './acp-filesystem.js';
12
13
  import { createAcpSessionStore, isTerminalTaskEvent } from './handlers.js';
@@ -52,6 +53,16 @@ function isAbsolutePath(filePath) {
52
53
  return true; // UNC path
53
54
  return false;
54
55
  }
56
+ function deriveSessionTitleFromCwd(cwd) {
57
+ const trimmed = cwd.replace(/[\\/]+$/, '');
58
+ if (!trimmed)
59
+ return cwd;
60
+ const segments = trimmed.split(/[\\/]/).filter(Boolean);
61
+ const basename = segments.at(-1);
62
+ if (basename && basename.trim())
63
+ return basename;
64
+ return trimmed;
65
+ }
55
66
  function ensureMarkdownParagraphBreak(text) {
56
67
  if (!text)
57
68
  return text;
@@ -71,11 +82,6 @@ function buildJsonResourceContentBlock(data) {
71
82
  },
72
83
  };
73
84
  }
74
- const defaultPromptCapabilities = {
75
- image: false,
76
- audio: false,
77
- embeddedContext: false,
78
- };
79
85
  const ACP_AVAILABLE_COMMANDS = [
80
86
  { name: 'help', description: text.acp.slashHelpDescription },
81
87
  ];
@@ -294,12 +300,42 @@ function buildAvailableCommandsUpdateIfChanged(state) {
294
300
  availableCommands,
295
301
  };
296
302
  }
303
+ function buildSessionInfoUpdateIfChanged(session, state) {
304
+ const title = typeof session.title === 'string' ? session.title : null;
305
+ const updatedAt = typeof session.updatedAt === 'string' ? session.updatedAt : null;
306
+ const digest = JSON.stringify({ title, updatedAt });
307
+ if (digest === state.lastSessionInfoDigest)
308
+ return null;
309
+ state.lastSessionInfoDigest = digest;
310
+ return {
311
+ sessionUpdate: 'session_info_update',
312
+ title,
313
+ updatedAt,
314
+ };
315
+ }
297
316
  function isSessionModeId(value) {
298
317
  return value === 'interactive' || value === 'yolo';
299
318
  }
300
319
  function buildCurrentModeUpdate(modeId) {
301
320
  return { sessionUpdate: 'current_mode_update', currentModeId: modeId };
302
321
  }
322
+ function buildModesState(modeId) {
323
+ return {
324
+ currentModeId: modeId,
325
+ availableModes: [
326
+ {
327
+ id: 'interactive',
328
+ name: 'Interactive',
329
+ description: text.acp.modeInteractiveDescription,
330
+ },
331
+ {
332
+ id: 'yolo',
333
+ name: 'YOLO',
334
+ description: text.acp.modeYoloDescription,
335
+ },
336
+ ],
337
+ };
338
+ }
303
339
  function buildCurrentModeUpdateIfChanged(state) {
304
340
  const digest = state.modeId;
305
341
  if (digest === state.lastModeDigest)
@@ -326,6 +362,7 @@ function createSessionRuntimeStateFromPersisted(input) {
326
362
  lastCommandsDigest: null,
327
363
  lastConfigDigest: null,
328
364
  lastModeDigest: null,
365
+ lastSessionInfoDigest: null,
329
366
  permissionPolicy,
330
367
  modeId,
331
368
  };
@@ -456,6 +493,15 @@ export function createAcpFormalAgent(deps) {
456
493
  terminal: false,
457
494
  };
458
495
  const loadSessionCapability = deps.capabilityPolicy?.loadSession ?? true;
496
+ const promptCapabilities = {
497
+ image: deps.capabilityPolicy?.promptCapabilities?.image ?? false,
498
+ audio: deps.capabilityPolicy?.promptCapabilities?.audio ?? false,
499
+ embeddedContext: deps.capabilityPolicy?.promptCapabilities?.embeddedContext ?? false,
500
+ };
501
+ const mcpCapabilities = {
502
+ http: deps.capabilityPolicy?.mcpCapabilities?.http ?? false,
503
+ sse: deps.capabilityPolicy?.mcpCapabilities?.sse ?? false,
504
+ };
459
505
  const sessionPersistencePath = deps.sessionPersistencePath;
460
506
  const sessionStorePolicy = {
461
507
  maxEntries: deps.sessionStorePolicy?.maxEntries ?? ACP_SESSION_STORE_MAX_ENTRIES,
@@ -779,6 +825,21 @@ export function createAcpFormalAgent(deps) {
779
825
  async function emitSessionUpdate(sessionId, update) {
780
826
  await deps.conn.sessionUpdate({ sessionId, update });
781
827
  }
828
+ async function emitSessionInfoUpdateBestEffort(sessionId) {
829
+ const session = sessions.get(sessionId);
830
+ if (!session)
831
+ return;
832
+ const state = ensureSessionRuntimeState(sessionId);
833
+ const update = buildSessionInfoUpdateIfChanged(session, state);
834
+ if (!update)
835
+ return;
836
+ try {
837
+ await emitSessionUpdate(sessionId, update);
838
+ }
839
+ catch {
840
+ // Best-effort: do not fail the request due to notification delivery issues.
841
+ }
842
+ }
782
843
  async function emitRuntimePlanUpdateIfNeeded(params) {
783
844
  if (!shouldRefreshPlanForEvent(params.event))
784
845
  return;
@@ -846,14 +907,22 @@ export function createAcpFormalAgent(deps) {
846
907
  throw new RequestError(-32602, 'Invalid params: protocolVersion is required');
847
908
  }
848
909
  clientCapabilities = params.clientCapabilities;
910
+ // Protocol version negotiation:
911
+ // - If the client's requested version is supported, return the same version
912
+ // - Otherwise, return the latest version the agent supports
913
+ // Currently, the agent only supports protocol version 1
914
+ const supportedProtocolVersion = PROTOCOL_VERSION;
915
+ const negotiatedVersion = params.protocolVersion <= supportedProtocolVersion
916
+ ? params.protocolVersion
917
+ : supportedProtocolVersion;
849
918
  return {
850
- protocolVersion: PROTOCOL_VERSION,
919
+ protocolVersion: negotiatedVersion,
851
920
  agentInfo: deps.agentInfo,
852
921
  authMethods: [],
853
922
  agentCapabilities: {
854
923
  loadSession: loadSessionCapability,
855
- promptCapabilities: defaultPromptCapabilities,
856
- mcpCapabilities: { http: false, sse: false },
924
+ promptCapabilities: promptCapabilities,
925
+ mcpCapabilities: mcpCapabilities,
857
926
  sessionCapabilities: {},
858
927
  },
859
928
  };
@@ -866,9 +935,14 @@ export function createAcpFormalAgent(deps) {
866
935
  if (!isAbsolutePath(params.cwd)) {
867
936
  throw new RequestError(-32602, 'Invalid params: cwd must be an absolute path');
868
937
  }
869
- const session = sessions.create({ cwd: params.cwd, mcpServers: params.mcpServers ?? [] });
938
+ const session = sessions.create({
939
+ cwd: params.cwd,
940
+ mcpServers: params.mcpServers ?? [],
941
+ title: deriveSessionTitleFromCwd(params.cwd),
942
+ });
870
943
  await persistSessionsBestEffort();
871
944
  const runtimeState = ensureSessionRuntimeState(session.id);
945
+ await emitSessionInfoUpdateBestEffort(session.id);
872
946
  // Restore session state on creation
873
947
  const commandsUpdate = buildAvailableCommandsUpdateIfChanged(runtimeState);
874
948
  if (commandsUpdate)
@@ -894,6 +968,7 @@ export function createAcpFormalAgent(deps) {
894
968
  return {
895
969
  sessionId: session.id,
896
970
  configOptions: buildConfigOptions(runtimeState),
971
+ modes: buildModesState(runtimeState.modeId),
897
972
  ...(sessionMeta ? { _meta: sessionMeta } : {}),
898
973
  };
899
974
  },
@@ -902,8 +977,18 @@ export function createAcpFormalAgent(deps) {
902
977
  throw new RequestError(-32601, '"Method not found": session/load');
903
978
  }
904
979
  await loadSessionInternal(params);
905
- const session = sessions.get(params.sessionId);
980
+ let session = sessions.get(params.sessionId);
906
981
  const runtimeState = ensureSessionRuntimeState(session.id);
982
+ if (typeof session.title !== 'string' || !session.title.trim()) {
983
+ session =
984
+ sessions.update(session.id, (current) => ({
985
+ ...current,
986
+ title: deriveSessionTitleFromCwd(current.cwd),
987
+ })) ?? session;
988
+ await persistSessionsBestEffort();
989
+ }
990
+ runtimeState.lastSessionInfoDigest = null;
991
+ await emitSessionInfoUpdateBestEffort(session.id);
907
992
  // Restore plan state if session was running a task
908
993
  if (session.taskId && session.cwd) {
909
994
  await emitRuntimePlanUpdateIfNeeded({
@@ -932,6 +1017,7 @@ export function createAcpFormalAgent(deps) {
932
1017
  }
933
1018
  const response = {
934
1019
  configOptions: buildConfigOptions(runtimeState),
1020
+ modes: buildModesState(runtimeState.modeId),
935
1021
  };
936
1022
  if (deps.checkpointReader) {
937
1023
  const startedAt = Date.now();
@@ -1019,6 +1105,7 @@ export function createAcpFormalAgent(deps) {
1019
1105
  }
1020
1106
  sessions.update(params.sessionId, (current) => ({ ...current }));
1021
1107
  await persistSessionsBestEffort();
1108
+ await emitSessionInfoUpdateBestEffort(params.sessionId);
1022
1109
  const update = buildConfigOptionUpdateIfChanged(runtimeState);
1023
1110
  if (update) {
1024
1111
  await emitSessionUpdate(params.sessionId, update);
@@ -1029,6 +1116,26 @@ export function createAcpFormalAgent(deps) {
1029
1116
  }
1030
1117
  return { configOptions: buildConfigOptions(runtimeState) };
1031
1118
  },
1119
+ async setSessionMode(params) {
1120
+ await hydrateSessionsOnce();
1121
+ if (!sessions.get(params.sessionId)) {
1122
+ throw new RequestError(-32004, `Session not found: ${params.sessionId}`);
1123
+ }
1124
+ const runtimeState = ensureSessionRuntimeState(params.sessionId);
1125
+ if (!isSessionModeId(params.modeId)) {
1126
+ throw new RequestError(-32602, `Invalid params: unsupported modeId "${params.modeId}"`);
1127
+ }
1128
+ runtimeState.modeId = params.modeId;
1129
+ sessions.update(params.sessionId, (current) => ({ ...current }));
1130
+ await persistSessionsBestEffort();
1131
+ await emitSessionInfoUpdateBestEffort(params.sessionId);
1132
+ // Send mode update notification
1133
+ const modeUpdate = buildCurrentModeUpdateIfChanged(runtimeState);
1134
+ if (modeUpdate) {
1135
+ await emitSessionUpdate(params.sessionId, modeUpdate);
1136
+ }
1137
+ return {};
1138
+ },
1032
1139
  async prompt(params) {
1033
1140
  await hydrateSessionsOnce();
1034
1141
  const session = sessions.get(params.sessionId);
@@ -1039,12 +1146,20 @@ export function createAcpFormalAgent(deps) {
1039
1146
  const fsCaps = caps.fs;
1040
1147
  const clientExecutionReady = caps.terminal === true && Boolean(fsCaps?.readTextFile) && Boolean(fsCaps?.writeTextFile);
1041
1148
  const effectiveExecutionBinding = executionBinding === 'client' && !clientExecutionReady ? 'local' : executionBinding;
1042
- const promptText = extractTextFromPrompt(params.prompt, defaultPromptCapabilities);
1149
+ const promptText = extractTextFromPrompt(params.prompt, promptCapabilities);
1043
1150
  const runtimeState = ensureSessionRuntimeState(params.sessionId);
1151
+ // Check for cancellation before starting processing
1152
+ if (sessions.get(params.sessionId)?.cancelRequested === true) {
1153
+ return { stopReason: 'cancelled' };
1154
+ }
1044
1155
  sessions.update(params.sessionId, (current) => {
1156
+ const title = typeof current.title === 'string' && current.title.trim()
1157
+ ? current.title
1158
+ : deriveSessionTitleFromCwd(current.cwd);
1045
1159
  return {
1046
1160
  ...current,
1047
1161
  cancelRequested: false,
1162
+ title,
1048
1163
  history: [
1049
1164
  ...current.history,
1050
1165
  { role: 'user', content: params.prompt },
@@ -1052,6 +1167,7 @@ export function createAcpFormalAgent(deps) {
1052
1167
  };
1053
1168
  });
1054
1169
  await persistSessionsBestEffort();
1170
+ await emitSessionInfoUpdateBestEffort(params.sessionId);
1055
1171
  const configUpdate = buildConfigOptionUpdateIfChanged(runtimeState);
1056
1172
  if (configUpdate) {
1057
1173
  await emitSessionUpdate(params.sessionId, configUpdate);
@@ -1083,16 +1199,23 @@ export function createAcpFormalAgent(deps) {
1083
1199
  ],
1084
1200
  }));
1085
1201
  await persistSessionsBestEffort();
1202
+ await emitSessionInfoUpdateBestEffort(params.sessionId);
1086
1203
  return { stopReason: 'end_turn' };
1087
1204
  }
1088
1205
  }
1089
- const { task, signal } = await deps.facade.createTask({
1206
+ // Check for cancellation again before creating task
1207
+ if (sessions.get(params.sessionId)?.cancelRequested === true) {
1208
+ return { stopReason: 'cancelled' };
1209
+ }
1210
+ const pendingUpdates = [];
1211
+ const executionRequest = buildCanonicalExecutionRequest({
1090
1212
  capability: 'patch',
1091
- request: {
1092
- instruction: promptText,
1093
- checkpointSessionId: params.sessionId,
1094
- repoPath: session.cwd,
1095
- },
1213
+ instruction: promptText,
1214
+ checkpointSessionId: params.sessionId,
1215
+ repoPath: session.cwd,
1216
+ });
1217
+ const { task, signal } = await deps.facade.createTask({
1218
+ ...executionRequest,
1096
1219
  commandRunner: effectiveExecutionBinding === 'client'
1097
1220
  ? createAcpCommandRunner({ conn: deps.conn, sessionId: params.sessionId })
1098
1221
  : undefined,
@@ -1109,14 +1232,18 @@ export function createAcpFormalAgent(deps) {
1109
1232
  authorizationMode: 'blocking',
1110
1233
  onEvent: (event) => {
1111
1234
  for (const update of loopEventToSessionUpdates(event, runtimeState)) {
1112
- void emitSessionUpdate(params.sessionId, update);
1235
+ pendingUpdates.push(emitSessionUpdate(params.sessionId, update).catch(() => {
1236
+ // Ignore errors in session update notifications
1237
+ }));
1113
1238
  }
1114
- void emitRuntimePlanUpdateIfNeeded({
1239
+ pendingUpdates.push(emitRuntimePlanUpdateIfNeeded({
1115
1240
  sessionId: params.sessionId,
1116
1241
  repoPath: session.cwd,
1117
1242
  event,
1118
1243
  state: runtimeState,
1119
- });
1244
+ }).catch(() => {
1245
+ // Ignore errors in plan update notifications
1246
+ }));
1120
1247
  },
1121
1248
  });
1122
1249
  sessions.update(params.sessionId, (current) => ({ ...current, taskId: task.id }));
@@ -1181,6 +1308,17 @@ export function createAcpFormalAgent(deps) {
1181
1308
  ],
1182
1309
  }));
1183
1310
  await persistSessionsBestEffort();
1311
+ const latestSession = sessions.get(params.sessionId);
1312
+ if (latestSession) {
1313
+ const sessionInfoUpdate = buildSessionInfoUpdateIfChanged(latestSession, runtimeState);
1314
+ if (sessionInfoUpdate) {
1315
+ pendingUpdates.push(emitSessionUpdate(params.sessionId, sessionInfoUpdate).catch(() => {
1316
+ // Ignore errors in session update notifications
1317
+ }));
1318
+ }
1319
+ }
1320
+ // Wait for all pending session updates to be sent before responding
1321
+ await Promise.all(pendingUpdates);
1184
1322
  return { stopReason };
1185
1323
  },
1186
1324
  async cancel(params) {
@@ -1188,11 +1326,16 @@ export function createAcpFormalAgent(deps) {
1188
1326
  const session = sessions.get(params.sessionId);
1189
1327
  if (!session)
1190
1328
  return;
1329
+ // Mark the session as cancelled
1191
1330
  sessions.update(params.sessionId, (current) => ({ ...current, cancelRequested: true }));
1192
1331
  await persistSessionsBestEffort();
1332
+ await emitSessionInfoUpdateBestEffort(params.sessionId);
1333
+ // If a task is running, cancel it
1193
1334
  if (session.taskId) {
1194
1335
  await deps.facade.cancelTask(session.taskId);
1195
1336
  }
1337
+ // Note: The prompt method will check the cancelRequested flag and return
1338
+ // StopReason::Cancelled as required by the protocol
1196
1339
  },
1197
1340
  extMethod: async () => ({}),
1198
1341
  extNotification: async () => { },