salmon-loop 0.3.1 → 0.4.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 (123) hide show
  1. package/dist/cli/authorization/non-interactive.js +9 -13
  2. package/dist/cli/chat.js +12 -6
  3. package/dist/cli/commands/allowlist.js +1 -1
  4. package/dist/cli/commands/chat.js +13 -13
  5. package/dist/cli/commands/parallel.js +1 -1
  6. package/dist/cli/commands/run/handler.js +6 -3
  7. package/dist/cli/commands/run/loop-params.js +1 -0
  8. package/dist/cli/commands/run/parse-options.js +14 -26
  9. package/dist/cli/commands/run/runtime-llm.js +15 -12
  10. package/dist/cli/commands/serve.js +14 -1
  11. package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
  12. package/dist/cli/reporters/standard.js +2 -3
  13. package/dist/cli/reporters/stream-json.js +2 -1
  14. package/dist/cli/slash/runtime.js +2 -2
  15. package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
  16. package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
  17. package/dist/cli/ui/hooks/useLoopState.js +1 -1
  18. package/dist/core/ast/parser.js +18 -9
  19. package/dist/core/config/schema.js +738 -0
  20. package/dist/core/config/validate.js +11 -922
  21. package/dist/core/context/gatherers/ast-gatherer.js +4 -12
  22. package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
  23. package/dist/core/context/gatherers/knowledge-gatherer.js +3 -0
  24. package/dist/core/context/service.js +39 -8
  25. package/dist/core/context/token/encoding-registry.js +7 -6
  26. package/dist/core/extensions/index.js +48 -3
  27. package/dist/core/extensions/load.js +3 -2
  28. package/dist/core/extensions/merge.js +5 -1
  29. package/dist/core/extensions/paths.js +6 -0
  30. package/dist/core/extensions/schemas.js +21 -0
  31. package/dist/core/facades/cli-command-chat.js +2 -0
  32. package/dist/core/facades/cli-run-handler.js +1 -0
  33. package/dist/core/facades/cli-utils-serialize.js +2 -0
  34. package/dist/core/grizzco/dsl/llm-strategy.js +3 -2
  35. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +15 -10
  36. package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
  37. package/dist/core/grizzco/engine/transaction/attempt-failure.js +5 -4
  38. package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
  39. package/dist/core/grizzco/runtime/apply-back-runtime.js +2 -1
  40. package/dist/core/grizzco/services/registry.js +18 -0
  41. package/dist/core/grizzco/steps/audit.js +20 -10
  42. package/dist/core/grizzco/steps/display-report.js +4 -11
  43. package/dist/core/grizzco/steps/explore.js +9 -2
  44. package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
  45. package/dist/core/grizzco/steps/patch.js +1 -0
  46. package/dist/core/grizzco/steps/plan.js +58 -49
  47. package/dist/core/grizzco/steps/tool-runtime.js +3 -0
  48. package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
  49. package/dist/core/llm/ai-sdk/message-mapper.js +24 -18
  50. package/dist/core/llm/ai-sdk/request-params.js +1 -3
  51. package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
  52. package/dist/core/llm/ai-sdk/retry-classifier.js +6 -4
  53. package/dist/core/llm/contracts/repair.js +16 -8
  54. package/dist/core/llm/errors.js +13 -10
  55. package/dist/core/llm/output-policy.js +8 -0
  56. package/dist/core/llm/redact.js +1 -3
  57. package/dist/core/llm/sub-agent-factory.js +48 -0
  58. package/dist/core/llm/tool-calling-stub.js +48 -0
  59. package/dist/core/llm/utils.js +17 -6
  60. package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
  61. package/dist/core/mcp/bridge/tool-bridge.js +5 -14
  62. package/dist/core/mcp/client/connection-manager.js +3 -2
  63. package/dist/core/mcp/host/sampling-provider.js +1 -1
  64. package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
  65. package/dist/core/memory/relevant-retrieval.js +6 -4
  66. package/dist/core/observability/authorization-decisions.js +13 -12
  67. package/dist/core/observability/error-mapping.js +2 -1
  68. package/dist/core/observability/token-usage.js +5 -4
  69. package/dist/core/plugin/loader.js +5 -4
  70. package/dist/core/prompts/registry.js +11 -29
  71. package/dist/core/protocols/a2a/sdk/server.js +2 -3
  72. package/dist/core/protocols/acp/formal-agent.js +10 -4
  73. package/dist/core/protocols/acp/stdio-server.js +6 -6
  74. package/dist/core/runtime/agent-server-runtime.js +3 -2
  75. package/dist/core/runtime/initialize.js +70 -6
  76. package/dist/core/session/compaction/index.js +4 -3
  77. package/dist/core/session/manager.js +41 -47
  78. package/dist/core/session/token-tracker.js +18 -7
  79. package/dist/core/skills/parser.js +3 -2
  80. package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
  81. package/dist/core/skills/runtime/SkillRunner.js +5 -2
  82. package/dist/core/slash/steps/slash-execute.js +7 -5
  83. package/dist/core/slash/strategy.js +1 -1
  84. package/dist/core/strata/layers/worktree.js +7 -9
  85. package/dist/core/strata/runtime/synchronizer.js +10 -9
  86. package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
  87. package/dist/core/structured-output/json-schema-validator.js +1 -13
  88. package/dist/core/sub-agent/context-snapshot.js +12 -6
  89. package/dist/core/sub-agent/controller.js +70 -1
  90. package/dist/core/sub-agent/core/loop.js +25 -3
  91. package/dist/core/sub-agent/core/manager.js +319 -116
  92. package/dist/core/sub-agent/registry-defaults.js +12 -0
  93. package/dist/core/sub-agent/registry.js +8 -0
  94. package/dist/core/sub-agent/team.js +98 -0
  95. package/dist/core/sub-agent/tools/task-await.js +109 -0
  96. package/dist/core/sub-agent/tools/task-spawn.js +49 -7
  97. package/dist/core/sub-agent/tools/team.js +92 -0
  98. package/dist/core/sub-agent/types.js +11 -2
  99. package/dist/core/tools/budget.js +4 -11
  100. package/dist/core/tools/builtin/code-search/executor.js +46 -43
  101. package/dist/core/tools/builtin/fs.js +14 -6
  102. package/dist/core/tools/builtin/index.js +41 -107
  103. package/dist/core/tools/builtin/interaction.js +13 -15
  104. package/dist/core/tools/builtin/proposal.js +11 -2
  105. package/dist/core/tools/capability/executor.js +5 -5
  106. package/dist/core/tools/headless-payload.js +1 -3
  107. package/dist/core/tools/mapper.js +8 -42
  108. package/dist/core/tools/parallel/persistence.js +17 -5
  109. package/dist/core/tools/parallel/scheduler.js +23 -21
  110. package/dist/core/tools/permissions/permission-rules.js +66 -114
  111. package/dist/core/tools/plugins/loader.js +4 -3
  112. package/dist/core/tools/router.js +24 -53
  113. package/dist/core/tools/session.js +54 -97
  114. package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
  115. package/dist/core/tools/tool-visibility.js +2 -1
  116. package/dist/core/tools/types.js +10 -0
  117. package/dist/core/utils/error.js +79 -0
  118. package/dist/core/utils/serialize.js +63 -0
  119. package/dist/core/utils/zod.js +29 -0
  120. package/dist/core/workspace/capabilities.js +3 -2
  121. package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
  122. package/dist/locales/en.js +2 -1
  123. package/package.json +1 -1
@@ -268,34 +268,11 @@ export class ChatSessionManager {
268
268
  * List all sessions (sorted by update time)
269
269
  */
270
270
  async listSessions() {
271
- const files = await this.fileAdapter.readdir(this.storageDir).catch(() => []);
272
- const jsonFiles = files.filter((f) => f.endsWith('.json'));
273
- const sessions = [];
274
- for (let i = 0; i < jsonFiles.length; i += ChatSessionManager.FILE_READ_CHUNK_SIZE) {
275
- const chunk = jsonFiles.slice(i, i + ChatSessionManager.FILE_READ_CHUNK_SIZE);
276
- const promises = chunk.map(async (file) => {
277
- try {
278
- const filePath = join(this.storageDir, file);
279
- const data = await this.fileAdapter.readFile(filePath);
280
- const session = JSON.parse(data);
281
- return {
282
- id: session.meta.id,
283
- name: session.meta.name,
284
- updatedAt: session.meta.updatedAt,
285
- };
286
- }
287
- catch (error) {
288
- getLogger().warn(`Failed to list session file ${file}: ${error}`);
289
- return null;
290
- }
291
- });
292
- const results = await Promise.all(promises);
293
- for (const result of results) {
294
- if (result) {
295
- sessions.push(result);
296
- }
297
- }
298
- }
271
+ const sessions = await this.scanSessionFiles((session) => ({
272
+ id: session.meta.id,
273
+ name: session.meta.name,
274
+ updatedAt: session.meta.updatedAt,
275
+ }));
299
276
  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
300
277
  }
301
278
  /**
@@ -306,19 +283,26 @@ export class ChatSessionManager {
306
283
  const analysis = this.pruningEngine.analyzeSessions(sessions);
307
284
  let deleted = 0;
308
285
  let archived = 0;
286
+ const CHUNK_SIZE = 10;
309
287
  // Delete low-priority sessions
310
- for (const sessionId of analysis.sessionsToDelete) {
311
- await this.deleteSession(sessionId);
312
- deleted++;
288
+ for (let i = 0; i < analysis.sessionsToDelete.length; i += CHUNK_SIZE) {
289
+ const chunk = analysis.sessionsToDelete.slice(i, i + CHUNK_SIZE);
290
+ await Promise.all(chunk.map(async (sessionId) => {
291
+ await this.deleteSession(sessionId);
292
+ deleted++;
293
+ }));
313
294
  }
314
295
  // Archive medium-priority sessions
315
- for (const sessionId of analysis.sessionsToArchive) {
316
- const session = sessions.find((s) => s.meta.id === sessionId);
317
- if (session) {
318
- await this.archiveSession(session);
319
- await this.deleteSession(sessionId);
320
- archived++;
321
- }
296
+ for (let i = 0; i < analysis.sessionsToArchive.length; i += CHUNK_SIZE) {
297
+ const chunk = analysis.sessionsToArchive.slice(i, i + CHUNK_SIZE);
298
+ await Promise.all(chunk.map(async (sessionId) => {
299
+ const session = sessions.find((s) => s.meta.id === sessionId);
300
+ if (session) {
301
+ await this.archiveSession(session);
302
+ await this.deleteSession(sessionId);
303
+ archived++;
304
+ }
305
+ }));
322
306
  }
323
307
  return {
324
308
  deleted,
@@ -337,9 +321,22 @@ export class ChatSessionManager {
337
321
  * Load all sessions from storage
338
322
  */
339
323
  async loadAllSessions() {
324
+ return this.scanSessionFiles((session) => {
325
+ session.meta.chatState = normalizeChatState(session.meta.chatState);
326
+ session.meta.artifactState = normalizeSessionArtifactState(session.meta.artifactState);
327
+ session.meta.replacementState = normalizeToolResultReplacementState(session.meta.replacementState);
328
+ return session;
329
+ });
330
+ }
331
+ /**
332
+ * Shared scan-and-parse for session files.
333
+ * Reads JSON files from storageDir in chunks, parses each, and maps via the provided callback.
334
+ * Silently skips files that fail to parse.
335
+ */
336
+ async scanSessionFiles(mapFn) {
340
337
  const files = await this.fileAdapter.readdir(this.storageDir).catch(() => []);
341
338
  const jsonFiles = files.filter((f) => f.endsWith('.json'));
342
- const sessions = [];
339
+ const results = [];
343
340
  for (let i = 0; i < jsonFiles.length; i += ChatSessionManager.FILE_READ_CHUNK_SIZE) {
344
341
  const chunk = jsonFiles.slice(i, i + ChatSessionManager.FILE_READ_CHUNK_SIZE);
345
342
  const promises = chunk.map(async (file) => {
@@ -347,24 +344,21 @@ export class ChatSessionManager {
347
344
  const filePath = join(this.storageDir, file);
348
345
  const data = await this.fileAdapter.readFile(filePath);
349
346
  const session = JSON.parse(data);
350
- session.meta.chatState = normalizeChatState(session.meta.chatState);
351
- session.meta.artifactState = normalizeSessionArtifactState(session.meta.artifactState);
352
- session.meta.replacementState = normalizeToolResultReplacementState(session.meta.replacementState);
353
- return session;
347
+ return mapFn(session);
354
348
  }
355
349
  catch (error) {
356
350
  getLogger().warn(`Failed to load session file ${file}: ${error}`);
357
351
  return null;
358
352
  }
359
353
  });
360
- const results = await Promise.all(promises);
361
- for (const result of results) {
354
+ const chunkResults = await Promise.all(promises);
355
+ for (const result of chunkResults) {
362
356
  if (result) {
363
- sessions.push(result);
357
+ results.push(result);
364
358
  }
365
359
  }
366
360
  }
367
- return sessions;
361
+ return results;
368
362
  }
369
363
  /**
370
364
  * Delete a session file
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
  import { FileAdapter } from '../adapters/fs/index.js';
3
3
  import { logIgnoredError } from '../observability/ignored-error.js';
4
+ import { isRecord } from '../utils/serialize.js';
4
5
  /**
5
6
  * Token usage tracker for chat sessions.
6
7
  * Extracts and accumulates token statistics from LLM execution results.
@@ -21,26 +22,36 @@ export class TokenTracker {
21
22
  try {
22
23
  const auditRaw = await this.fileAdapter.readFile(result.auditPath, 'utf8');
23
24
  const audit = JSON.parse(auditRaw);
24
- const eventsRef = audit?.context?.eventsRef;
25
+ if (!isRecord(audit))
26
+ return null;
27
+ const context = isRecord(audit.context) ? audit.context : null;
28
+ const eventsRef = isRecord(context?.eventsRef) ? context.eventsRef : null;
25
29
  if (!eventsRef || typeof eventsRef.path !== 'string')
26
30
  return null;
27
31
  const eventsPath = path.isAbsolute(eventsRef.path)
28
32
  ? eventsRef.path
29
33
  : path.join(path.dirname(result.auditPath), eventsRef.path);
30
34
  const eventsRaw = await this.fileAdapter.readFile(eventsPath, 'utf8');
31
- const events = eventsRaw
32
- .split('\n')
33
- .filter((line) => line.trim().length > 0)
34
- .map((line) => JSON.parse(line));
35
+ const events = [];
36
+ for (const line of eventsRaw.split('\n')) {
37
+ if (line.trim().length === 0)
38
+ continue;
39
+ try {
40
+ events.push(JSON.parse(line));
41
+ }
42
+ catch {
43
+ // Skip malformed lines
44
+ }
45
+ }
35
46
  let inputTokens = 0;
36
47
  let outputTokens = 0;
37
48
  for (const event of events) {
38
- if (!event || typeof event !== 'object')
49
+ if (!isRecord(event))
39
50
  continue;
40
51
  if (event.action !== 'llm.usage')
41
52
  continue;
42
53
  const details = event.details;
43
- if (!details || typeof details !== 'object')
54
+ if (!isRecord(details))
44
55
  continue;
45
56
  const promptTokens = details.promptTokens;
46
57
  const completionTokens = details.completionTokens;
@@ -3,6 +3,7 @@ import { parse as parseYaml } from 'yaml';
3
3
  import { z } from 'zod';
4
4
  import { text } from '../../locales/index.js';
5
5
  import { tryGetLogger } from '../observability/logger.js';
6
+ import { errorMessage } from '../utils/error.js';
6
7
  /**
7
8
  * Safe logger accessor that never throws when the logger is not yet initialized.
8
9
  *
@@ -129,7 +130,7 @@ export class SkillParser {
129
130
  parsed = parseYaml(yamlRaw);
130
131
  }
131
132
  catch (error) {
132
- const reason = error instanceof Error ? error.message : String(error);
133
+ const reason = errorMessage(error);
133
134
  const msg = text.skills.yamlParseError(filePath, reason);
134
135
  safeLogger().error(msg);
135
136
  throw new Error(msg);
@@ -188,7 +189,7 @@ export class SkillParser {
188
189
  parsed = parseYaml(yamlRaw);
189
190
  }
190
191
  catch (error) {
191
- const reason = error instanceof Error ? error.message : String(error);
192
+ const reason = errorMessage(error);
192
193
  const msg = text.skills.yamlParseError(filePath, reason);
193
194
  safeLogger().error(msg);
194
195
  throw new Error(msg);
@@ -67,7 +67,7 @@ export class MicroTaskRunner {
67
67
  cmd,
68
68
  output: output,
69
69
  })),
70
- injectedPrompt: injectAction?.params?.prompt || '',
70
+ injectedPrompt: String(injectAction?.params?.prompt ?? ''),
71
71
  status: plan.shouldAbort ? 'FAILURE' : 'SUCCESS',
72
72
  };
73
73
  }
@@ -2,6 +2,7 @@ import * as crypto from 'crypto';
2
2
  import { MicroTaskRunner } from '../../grizzco/dsl/MicroTaskRunner.js';
3
3
  import { tryGetLogger } from '../../observability/logger.js';
4
4
  import { Phase } from '../../types/index.js';
5
+ import { isRecord } from '../../utils/serialize.js';
5
6
  import { emitSkillAuditEvent, generateSkillTraceId, hashSkillArgs } from '../audit.js';
6
7
  import { SkillParser } from '../parser.js';
7
8
  import { SkillStrategyDSL } from '../strategy.js';
@@ -227,7 +228,9 @@ export async function executeSkill(options) {
227
228
  argsHash,
228
229
  traceId,
229
230
  denyReason: result.error?.code || 'unknown',
230
- denySource: result.meta?.authorization?.source || 'policy',
231
+ denySource: isRecord(result.meta) && isRecord(result.meta.authorization)
232
+ ? result.meta.authorization.source
233
+ : 'policy',
231
234
  durationMs: Date.now() - startedAt,
232
235
  });
233
236
  }
@@ -264,7 +267,7 @@ export async function executeSkill(options) {
264
267
  cmd,
265
268
  output: String(output),
266
269
  })),
267
- injectedPrompt: String(inject?.params?.prompt ?? ''),
270
+ injectedPrompt: String((isRecord(inject?.params) ? inject.params.prompt : undefined) ?? ''),
268
271
  status,
269
272
  };
270
273
  }
@@ -2,12 +2,13 @@ function planToDecision(plan, options) {
2
2
  const action = plan.actions[0];
3
3
  if (!action)
4
4
  return { kind: 'consumed' };
5
+ const p = action.params ?? {};
5
6
  if (action.type === 'FORWARD_TEXT') {
6
- const next = String(action.params?.input ?? '');
7
+ const next = String(p.input ?? '');
7
8
  return { kind: 'forward', input: next };
8
9
  }
9
10
  if (action.type === 'UNKNOWN_SLASH') {
10
- const cmd = String(action.params?.commandName ?? '');
11
+ const cmd = String(p.commandName ?? '');
11
12
  if (options.unknownSlashPolicy === 'forward_as_text') {
12
13
  return { kind: 'forward', input: cmd };
13
14
  }
@@ -31,7 +32,8 @@ export function buildSlashExecuteStep(options, meta) {
31
32
  const decision = planToDecision(plan, { unknownSlashPolicy: options.unknownSlashPolicy });
32
33
  return { ...context, data: { ...data, __decision: decision } };
33
34
  }
34
- const commandName = String(action.params?.commandName ?? '');
35
+ const p = action.params ?? {};
36
+ const commandName = String(p.commandName ?? '');
35
37
  const spec = options.registry.find(commandName);
36
38
  if (!spec) {
37
39
  const decision = planToDecision({ ...plan, actions: [{ type: 'UNKNOWN_SLASH', params: { commandName } }] }, { unknownSlashPolicy: options.unknownSlashPolicy });
@@ -50,8 +52,8 @@ export function buildSlashExecuteStep(options, meta) {
50
52
  const req = {
51
53
  rawInput: context.input.raw,
52
54
  command: spec,
53
- argsText: String(action.params?.argsText ?? ''),
54
- tokens: Array.isArray(action.params?.tokens) ? action.params.tokens : [],
55
+ argsText: String(p.argsText ?? ''),
56
+ tokens: Array.isArray(p.tokens) ? p.tokens : [],
55
57
  meta,
56
58
  };
57
59
  const result = await handler.execute(req);
@@ -18,7 +18,7 @@ export const SlashStrategyDSL = (engine) => {
18
18
  .when((c) => c.input.isSlash && Boolean(c.resolved?.command), (p) => {
19
19
  p.setWorker('slash.execute');
20
20
  p.addAction('EXECUTE_SLASH', {
21
- commandName: engine.ctx.resolved.command.name,
21
+ commandName: engine.ctx.resolved?.command?.name ?? '',
22
22
  argsText: engine.ctx.input.argsText ?? '',
23
23
  tokens: engine.ctx.input.tokens ?? [],
24
24
  });
@@ -5,7 +5,9 @@ import { text } from '../../../locales/index.js';
5
5
  import { access, readdir, realpath, rm } from '../../adapters/fs/node-fs.js';
6
6
  import { GitAdapter } from '../../adapters/git/git-adapter.js';
7
7
  import { getLogger } from '../../observability/logger.js';
8
+ import { errorMessage } from '../../utils/error.js';
8
9
  import { isPathWithinDirectory, normalizePath } from '../../utils/path.js';
10
+ import { isRecord } from '../../utils/serialize.js';
9
11
  import { detectDependencyPaths } from './shadow-driver/strategy.js';
10
12
  function resolveEnvironmentMode(options) {
11
13
  return options.environmentMode === 'parity' ? 'parity' : 'strict';
@@ -73,14 +75,14 @@ async function removeProjectedWorktreeEntries(workPath) {
73
75
  worktreeRealPath = await realpath(workPath);
74
76
  }
75
77
  catch (error) {
76
- throw new Error(`Failed to resolve worktree path before git cleanup (${workPath}): ${error instanceof Error ? error.message : String(error)}`);
78
+ throw new Error(`Failed to resolve worktree path before git cleanup (${workPath}): ${errorMessage(error)}`);
77
79
  }
78
80
  let entries = [];
79
81
  try {
80
82
  entries = (await readdir(workPath, { withFileTypes: true }));
81
83
  }
82
84
  catch (error) {
83
- throw new Error(`Failed to enumerate worktree entries before git cleanup (${workPath}): ${error instanceof Error ? error.message : String(error)}`);
85
+ throw new Error(`Failed to enumerate worktree entries before git cleanup (${workPath}): ${errorMessage(error)}`);
84
86
  }
85
87
  for (const entry of entries) {
86
88
  const name = entry?.name;
@@ -116,7 +118,7 @@ async function pruneWorktreeDependencyRoots(baseRepoPath, worktreePath) {
116
118
  getLogger().debug(`Pruned disposable dependency root before worktree cleanup: ${dependencyRoot}`);
117
119
  }
118
120
  catch (error) {
119
- getLogger().debug(`Failed to prune dependency root before worktree cleanup (${dependencyRoot}): ${error instanceof Error ? error.message : String(error)}`);
121
+ getLogger().debug(`Failed to prune dependency root before worktree cleanup (${dependencyRoot}): ${errorMessage(error)}`);
120
122
  }
121
123
  }
122
124
  }
@@ -243,7 +245,7 @@ export class WorkspaceManager {
243
245
  return true;
244
246
  }
245
247
  catch (error) {
246
- if (error && typeof error === 'object' && error.code === 'ENOENT') {
248
+ if (isRecord(error) && error.code === 'ENOENT') {
247
249
  return false;
248
250
  }
249
251
  return true;
@@ -268,11 +270,7 @@ export class WorkspaceManager {
268
270
  }
269
271
  }
270
272
  catch (error) {
271
- const msg = error instanceof Error
272
- ? error instanceof Error
273
- ? error.message
274
- : String(error)
275
- : String(error);
273
+ const msg = errorMessage(error);
276
274
  onEvent?.({
277
275
  type: 'action.fallback',
278
276
  tool: 'git',
@@ -8,6 +8,7 @@ import { GitAdapter } from '../../adapters/git/git-adapter.js';
8
8
  import { logIgnoredError } from '../../observability/ignored-error.js';
9
9
  import { getLogger } from '../../observability/logger.js';
10
10
  import { getMonitor } from '../../observability/monitor.js';
11
+ import { errorMessage } from '../../utils/error.js';
11
12
  import { isCanonicalPathWithinDirectory } from '../../utils/path.js';
12
13
  import { detectDependencyPaths } from '../layers/shadow-driver/strategy.js';
13
14
  const SECURITY_BLOCKLIST = [
@@ -135,7 +136,7 @@ export class WorkspaceSynchronizer {
135
136
  detectedDependencyPaths = await detectDependencyPaths(repoPath);
136
137
  }
137
138
  catch (error) {
138
- getLogger().debug(`[checkpoint] Failed to detect dependency paths: ${error instanceof Error ? error.message : String(error)}`);
139
+ getLogger().debug(`[checkpoint] Failed to detect dependency paths: ${errorMessage(error)}`);
139
140
  }
140
141
  const candidates = new Set([
141
142
  ...DEFAULT_DEPENDENCY_ROOT_CANDIDATES,
@@ -445,7 +446,7 @@ export class WorkspaceSynchronizer {
445
446
  });
446
447
  }
447
448
  catch (error) {
448
- throw new Error(`Apply-back completed with conflicts (Atomic Patch). Rejection files (.rej) have been generated. Original error: ${error instanceof Error ? error.message : String(error)}`);
449
+ throw new Error(`Apply-back completed with conflicts (Atomic Patch). Rejection files (.rej) have been generated. Original error: ${errorMessage(error)}`);
449
450
  }
450
451
  }
451
452
  parseStatusEntries(statusPorcelainZ) {
@@ -624,7 +625,7 @@ export class WorkspaceSynchronizer {
624
625
  stagedPatchPath,
625
626
  };
626
627
  };
627
- dirtyBackup = (await createDirtyBackup());
628
+ dirtyBackup = await createDirtyBackup();
628
629
  getLogger().info(text.loop.applyBackCheckpointCreated());
629
630
  getLogger().info(text.loop.applyBackCheckpointLocation(dirtyBackup?.dir || ''));
630
631
  if (telemetry) {
@@ -673,7 +674,7 @@ export class WorkspaceSynchronizer {
673
674
  }
674
675
  }
675
676
  catch (error) {
676
- const err = error instanceof Error ? error : new Error(String(error));
677
+ const err = error instanceof Error ? error : new Error(errorMessage(error));
677
678
  if (telemetry) {
678
679
  telemetry.error = err.message;
679
680
  }
@@ -737,7 +738,7 @@ export class WorkspaceSynchronizer {
737
738
  await copyFile(path.join(trackedDir, ...file.split('/')), path.join(mainRepoPath, ...file.split('/')));
738
739
  }
739
740
  catch (e) {
740
- getLogger().error(`[applyBack] Failed to restore tracked file ${file}: ${e instanceof Error ? e.message : String(e)}`);
741
+ getLogger().error(`[applyBack] Failed to restore tracked file ${file}: ${errorMessage(e)}`);
741
742
  }
742
743
  }
743
744
  }
@@ -769,7 +770,7 @@ export class WorkspaceSynchronizer {
769
770
  }
770
771
  }
771
772
  catch (e) {
772
- const patchError = e instanceof Error ? e.message : String(e);
773
+ const patchError = errorMessage(e);
773
774
  getLogger().error(`[applyBack] Failed to restore staged state from patch. ${patchError}. ` +
774
775
  `Falling back to read-tree restore.`);
775
776
  try {
@@ -780,7 +781,7 @@ export class WorkspaceSynchronizer {
780
781
  }
781
782
  }
782
783
  catch (fallbackError) {
783
- const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
784
+ const fallbackMessage = errorMessage(fallbackError);
784
785
  if (telemetry) {
785
786
  telemetry.stagedRestoreSucceeded = false;
786
787
  telemetry.stagedRestoreError = `${patchError}; fallback read-tree failed: ${fallbackMessage}`;
@@ -808,7 +809,7 @@ export class WorkspaceSynchronizer {
808
809
  catch (snapshotRestoreError) {
809
810
  getLogger().error(`[applyBack] Snapshot restore failed during clean rollback. ` +
810
811
  `baseRef=${checkpointRef.baseRef}; ` +
811
- `error=${snapshotRestoreError instanceof Error ? snapshotRestoreError.message : String(snapshotRestoreError)}. ` +
812
+ `error=${errorMessage(snapshotRestoreError)}. ` +
812
813
  `Falling back to clean reset.`);
813
814
  }
814
815
  if (!restoredFromSnapshot) {
@@ -830,7 +831,7 @@ export class WorkspaceSynchronizer {
830
831
  await rm(dirtyBackup.dir, { recursive: true, force: true });
831
832
  }
832
833
  catch (cleanupError) {
833
- getLogger().debug(`[applyBack] Failed to cleanup dirty backup ${dirtyBackup.dir}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
834
+ getLogger().debug(`[applyBack] Failed to cleanup dirty backup ${dirtyBackup.dir}: ${errorMessage(cleanupError)}`);
834
835
  }
835
836
  }
836
837
  if (telemetry) {
@@ -1,14 +1,4 @@
1
- function isRecord(value) {
2
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
- }
4
- function getString(record, key) {
5
- const value = record[key];
6
- return typeof value === 'string' ? value : null;
7
- }
8
- function getRecord(record, key) {
9
- const value = record[key];
10
- return isRecord(value) ? value : null;
11
- }
1
+ import { isRecord, getString, getRecord } from '../../utils/serialize.js';
12
2
  /**
13
3
  * Best-effort conversion from our provider-agnostic `LLMStreamChunk` into
14
4
  * provider-agnostic canonical stream parts.
@@ -1,17 +1,5 @@
1
1
  import { Ajv } from 'ajv';
2
- function safeStringify(value) {
3
- try {
4
- return JSON.stringify(value);
5
- }
6
- catch {
7
- try {
8
- return String(value);
9
- }
10
- catch {
11
- return '[Unserializable]';
12
- }
13
- }
14
- }
2
+ import { safeStringify } from '../utils/serialize.js';
15
3
  function toSchemaKey(schema) {
16
4
  if (!schema || typeof schema !== 'object')
17
5
  return `non_object:${typeof schema}`;
@@ -1,5 +1,9 @@
1
1
  import { normalizeToolResultReplacementState, } from '../session/replacement-state.js';
2
2
  import { SUB_AGENT_CONTEXT_SNAPSHOT_VERSION, SUB_AGENT_CONTEXT_SNAPSHOT_FIELD_SEMANTICS, } from './types.js';
3
+ const SUPPORTED_SNAPSHOT_FIELDS = new Set([
4
+ 'version',
5
+ ...Object.keys(SUB_AGENT_CONTEXT_SNAPSHOT_FIELD_SEMANTICS),
6
+ ]);
3
7
  function deepClone(value) {
4
8
  if (typeof structuredClone === 'function') {
5
9
  return structuredClone(value);
@@ -39,7 +43,13 @@ function cloneConversationContext(messages) {
39
43
  function cloneToolCallingAudit(entries) {
40
44
  if (!Array.isArray(entries) || entries.length === 0)
41
45
  return undefined;
42
- return entries.map((entry) => deepClone(entry));
46
+ return entries.map((entry) => ({
47
+ ...entry,
48
+ toolResultPatchArtifact: cloneArtifactHandle(entry.toolResultPatchArtifact),
49
+ toolResultAuditArtifact: cloneArtifactHandle(entry.toolResultAuditArtifact),
50
+ toolResultReadArtifact: cloneArtifactHandle(entry.toolResultReadArtifact),
51
+ toolResultPreviewArtifact: cloneArtifactHandle(entry.toolResultPreviewArtifact),
52
+ }));
43
53
  }
44
54
  function cloneArtifactHints(hints) {
45
55
  if (!hints)
@@ -106,11 +116,7 @@ function normalizeSnapshotVersion(snapshot) {
106
116
  return version;
107
117
  }
108
118
  function assertSupportedSnapshotFields(snapshot) {
109
- const supportedFields = new Set([
110
- 'version',
111
- ...Object.keys(SUB_AGENT_CONTEXT_SNAPSHOT_FIELD_SEMANTICS),
112
- ]);
113
- const unknownFields = Object.keys(snapshot).filter((key) => !supportedFields.has(key));
119
+ const unknownFields = Object.keys(snapshot).filter((key) => !SUPPORTED_SNAPSHOT_FIELDS.has(key));
114
120
  if (unknownFields.length > 0) {
115
121
  throw new Error(`Unsupported sub-agent context snapshot fields: ${unknownFields.sort().join(', ')}`);
116
122
  }
@@ -1,6 +1,9 @@
1
- const LOG_HISTORY_LIMIT = 50;
1
+ const LOG_HISTORY_LIMIT = 200;
2
2
  export class InMemorySubAgentController {
3
3
  agents = new Map();
4
+ toolCallListeners = new Set();
5
+ results = new Map();
6
+ waiters = new Map();
4
7
  registerAgent(id, profile, status) {
5
8
  const existing = this.agents.get(id);
6
9
  if (existing) {
@@ -16,6 +19,8 @@ export class InMemorySubAgentController {
16
19
  updatedAt: new Date(),
17
20
  stopRequested: false,
18
21
  logs: [],
22
+ tokenUsage: 0,
23
+ toolCallCount: 0,
19
24
  });
20
25
  }
21
26
  updateStatus(id, status, summary) {
@@ -37,6 +42,35 @@ export class InMemorySubAgentController {
37
42
  agent.logs.splice(0, agent.logs.length - LOG_HISTORY_LIMIT);
38
43
  }
39
44
  }
45
+ addTokenUsage(id, tokens) {
46
+ const agent = this.agents.get(id);
47
+ if (!agent)
48
+ return;
49
+ agent.tokenUsage += tokens;
50
+ }
51
+ recordToolCall(id, toolName, durationMs, success) {
52
+ const agent = this.agents.get(id);
53
+ if (!agent)
54
+ return;
55
+ agent.toolCallCount++;
56
+ const event = {
57
+ type: 'tool.call.end',
58
+ agentId: id,
59
+ toolName,
60
+ timestamp: Date.now(),
61
+ durationMs,
62
+ success,
63
+ };
64
+ for (const listener of this.toolCallListeners) {
65
+ listener(event);
66
+ }
67
+ }
68
+ onToolCall(listener) {
69
+ this.toolCallListeners.add(listener);
70
+ return () => {
71
+ this.toolCallListeners.delete(listener);
72
+ };
73
+ }
40
74
  listAgents() {
41
75
  return Array.from(this.agents.values());
42
76
  }
@@ -62,6 +96,41 @@ export class InMemorySubAgentController {
62
96
  isStopRequested(id) {
63
97
  return this.agents.get(id)?.stopRequested ?? false;
64
98
  }
99
+ setResult(id, result) {
100
+ this.results.set(id, result);
101
+ const waiters = this.waiters.get(id);
102
+ if (waiters) {
103
+ for (const resolve of waiters) {
104
+ resolve(result);
105
+ }
106
+ this.waiters.delete(id);
107
+ }
108
+ }
109
+ async awaitResult(id, timeoutMs = 300_000) {
110
+ // Check if result is already available
111
+ const existing = this.results.get(id);
112
+ if (existing)
113
+ return existing;
114
+ // Wait for the result with timeout
115
+ return new Promise((resolve) => {
116
+ const timer = setTimeout(() => {
117
+ // Remove this waiter on timeout
118
+ const waiters = this.waiters.get(id);
119
+ if (waiters) {
120
+ const idx = waiters.indexOf(resolve);
121
+ if (idx >= 0)
122
+ waiters.splice(idx, 1);
123
+ }
124
+ resolve(undefined);
125
+ }, timeoutMs);
126
+ const waiters = this.waiters.get(id) ?? [];
127
+ waiters.push((result) => {
128
+ clearTimeout(timer);
129
+ resolve(result);
130
+ });
131
+ this.waiters.set(id, waiters);
132
+ });
133
+ }
65
134
  }
66
135
  export function createSubAgentController() {
67
136
  return new InMemorySubAgentController();
@@ -27,12 +27,34 @@ export class SmallfryLoop {
27
27
  async execute(initCtx) {
28
28
  getLogger().debug(`[SmallfryLoop] ${text.smallfry.status.working} (${this.profile.name})`);
29
29
  let pipeline = Pipeline.of(initCtx);
30
+ let turnCount = 0;
31
+ const maxTurns = this.profile.maxTurns;
30
32
  // Dynamic Phase Injection based on Stratagem
33
+ // PREFLIGHT is deterministic (no LLM call), so it doesn't count as a turn
31
34
  pipeline = pipeline.step('PREFLIGHT', runPreflight);
32
- pipeline = pipeline.step('CONTEXT', buildContext);
33
- pipeline = pipeline.step('PLAN', generatePlan);
35
+ // Each subsequent step makes at least one LLM call
36
+ if (maxTurns !== undefined && turnCount >= maxTurns) {
37
+ getLogger().warn(`[SmallfryLoop] maxTurns (${maxTurns}) reached before CONTEXT — stopping early`);
38
+ }
39
+ else {
40
+ pipeline = pipeline.step('CONTEXT', buildContext);
41
+ turnCount++;
42
+ }
43
+ if (maxTurns !== undefined && turnCount >= maxTurns) {
44
+ getLogger().warn(`[SmallfryLoop] maxTurns (${maxTurns}) reached before PLAN — stopping early`);
45
+ }
46
+ else {
47
+ pipeline = pipeline.step('PLAN', generatePlan);
48
+ turnCount++;
49
+ }
34
50
  if (this.profile.stratagem === 'surgeon') {
35
- pipeline = pipeline.step('PATCH', generatePatch);
51
+ if (maxTurns !== undefined && turnCount >= maxTurns) {
52
+ getLogger().warn(`[SmallfryLoop] maxTurns (${maxTurns}) reached before PATCH — stopping early`);
53
+ }
54
+ else {
55
+ pipeline = pipeline.step('PATCH', generatePatch);
56
+ turnCount++;
57
+ }
36
58
  }
37
59
  const report = await pipeline.execute();
38
60
  report.auditPath = await saveAudit(report, initCtx.options);