@ikarmic/forge-core 5.0.0 → 5.1.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 (163) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.d.ts +8 -0
  3. package/dist/agent/agent.js +148 -24
  4. package/dist/agent/background.d.ts +58 -0
  5. package/dist/agent/background.js +139 -0
  6. package/dist/agent/enhance-prompt.d.ts +3 -1
  7. package/dist/agent/enhance-prompt.js +6 -2
  8. package/dist/agent/goal.d.ts +54 -0
  9. package/dist/agent/goal.js +65 -0
  10. package/dist/agent/prompts.d.ts +25 -1
  11. package/dist/agent/prompts.js +61 -17
  12. package/dist/agents/agent-files.d.ts +43 -0
  13. package/dist/agents/agent-files.js +109 -0
  14. package/dist/checkpoints/store.js +5 -1
  15. package/dist/commands/custom-commands.d.ts +9 -4
  16. package/dist/commands/custom-commands.js +13 -2
  17. package/dist/config-file.d.ts +43 -0
  18. package/dist/config-file.js +249 -5
  19. package/dist/config-merge.d.ts +13 -0
  20. package/dist/config-merge.js +67 -0
  21. package/dist/config-schema.d.ts +340 -0
  22. package/dist/config-schema.js +126 -0
  23. package/dist/config.js +2 -1
  24. package/dist/context/compaction.js +4 -0
  25. package/dist/context/mask.js +1 -1
  26. package/dist/custom-providers.d.ts +95 -0
  27. package/dist/custom-providers.js +120 -0
  28. package/dist/git/auto-commit.d.ts +2 -2
  29. package/dist/git/auto-commit.js +5 -4
  30. package/dist/github/github-tools.d.ts +2 -2
  31. package/dist/hooks/runner.js +3 -2
  32. package/dist/import/claude-code.d.ts +39 -0
  33. package/dist/import/claude-code.js +254 -0
  34. package/dist/index.d.ts +34 -5
  35. package/dist/index.js +27 -4
  36. package/dist/journey/answers-block.d.ts +17 -0
  37. package/dist/journey/answers-block.js +15 -0
  38. package/dist/journey/index.d.ts +9 -0
  39. package/dist/journey/index.js +8 -0
  40. package/dist/journey/journey.d.ts +13 -0
  41. package/dist/journey/journey.js +40 -0
  42. package/dist/journey/offer.d.ts +25 -0
  43. package/dist/journey/offer.js +48 -0
  44. package/dist/journey/questions.d.ts +23 -0
  45. package/dist/journey/questions.js +49 -0
  46. package/dist/journey/stages.d.ts +13 -0
  47. package/dist/journey/stages.js +107 -0
  48. package/dist/journey/survey.d.ts +10 -0
  49. package/dist/journey/survey.js +203 -0
  50. package/dist/journey/trigger.d.ts +19 -0
  51. package/dist/journey/trigger.js +79 -0
  52. package/dist/journey/types.d.ts +72 -0
  53. package/dist/journey/types.js +1 -0
  54. package/dist/lsp/client.d.ts +5 -0
  55. package/dist/lsp/client.js +39 -3
  56. package/dist/mcp/index.d.ts +5 -1
  57. package/dist/mcp/index.js +3 -1
  58. package/dist/mcp/manager.d.ts +17 -3
  59. package/dist/mcp/manager.js +27 -2
  60. package/dist/mcp/marketplace.d.ts +2 -0
  61. package/dist/mcp/marketplace.js +10 -4
  62. package/dist/mcp/mcp-client.d.ts +12 -2
  63. package/dist/mcp/mcp-client.js +74 -17
  64. package/dist/mcp/mcp-config.d.ts +2 -0
  65. package/dist/mcp/mcp-config.js +1 -0
  66. package/dist/mcp/oauth.d.ts +55 -0
  67. package/dist/mcp/oauth.js +197 -0
  68. package/dist/mcp/resource-tools.d.ts +8 -0
  69. package/dist/mcp/resource-tools.js +56 -0
  70. package/dist/memory/session-digest.d.ts +12 -0
  71. package/dist/memory/session-digest.js +47 -0
  72. package/dist/memory/stale.js +2 -1
  73. package/dist/model-alias.d.ts +18 -2
  74. package/dist/model-alias.js +27 -3
  75. package/dist/model-catalog.js +0 -1
  76. package/dist/model-list.js +2 -1
  77. package/dist/model-overrides.d.ts +30 -0
  78. package/dist/model-overrides.js +48 -0
  79. package/dist/orchestrator/specialists-registry.js +2 -3
  80. package/dist/orchestrator/specialists.js +4 -4
  81. package/dist/policy/ca-certs.d.ts +8 -0
  82. package/dist/policy/ca-certs.js +30 -0
  83. package/dist/policy/proxy.d.ts +10 -3
  84. package/dist/policy/proxy.js +33 -6
  85. package/dist/pricing.js +4 -0
  86. package/dist/providers.d.ts +7 -0
  87. package/dist/providers.js +39 -5
  88. package/dist/semantic/embedder.d.ts +15 -0
  89. package/dist/semantic/embedder.js +25 -0
  90. package/dist/semantic/semantic-index.d.ts +10 -2
  91. package/dist/semantic/semantic-index.js +16 -7
  92. package/dist/sessions/session-store.js +2 -1
  93. package/dist/skills/builtin.d.ts +2 -0
  94. package/dist/skills/builtin.js +68 -0
  95. package/dist/skills/index.d.ts +2 -0
  96. package/dist/skills/index.js +2 -0
  97. package/dist/skills/loader.d.ts +10 -3
  98. package/dist/skills/loader.js +20 -7
  99. package/dist/skills/marketplace.js +2 -1
  100. package/dist/skills/remote.d.ts +14 -0
  101. package/dist/skills/remote.js +131 -0
  102. package/dist/skills/types.d.ts +10 -0
  103. package/dist/specs/generate.d.ts +18 -9
  104. package/dist/specs/generate.js +174 -14
  105. package/dist/specs/index.d.ts +2 -2
  106. package/dist/specs/index.js +1 -1
  107. package/dist/specs/store.d.ts +14 -2
  108. package/dist/specs/store.js +26 -1
  109. package/dist/specs/types.d.ts +11 -0
  110. package/dist/steering/extra-instructions.d.ts +8 -0
  111. package/dist/steering/extra-instructions.js +104 -0
  112. package/dist/steering/folder-instructions.d.ts +8 -0
  113. package/dist/steering/folder-instructions.js +28 -0
  114. package/dist/steering/select.js +2 -1
  115. package/dist/timed-fetch.d.ts +12 -0
  116. package/dist/timed-fetch.js +83 -0
  117. package/dist/tools/apply-patch.d.ts +52 -0
  118. package/dist/tools/apply-patch.js +197 -0
  119. package/dist/tools/ask-user.d.ts +27 -0
  120. package/dist/tools/ask-user.js +60 -0
  121. package/dist/tools/browser-tools.d.ts +6 -6
  122. package/dist/tools/browser-tools.js +3 -13
  123. package/dist/tools/codebase-search.d.ts +4 -2
  124. package/dist/tools/codebase-search.js +2 -2
  125. package/dist/tools/edit-file.js +5 -3
  126. package/dist/tools/fetch-url.d.ts +7 -0
  127. package/dist/tools/fetch-url.js +52 -19
  128. package/dist/tools/find-symbol.d.ts +2 -2
  129. package/dist/tools/ignore-rules.js +2 -1
  130. package/dist/tools/index.d.ts +5 -1
  131. package/dist/tools/index.js +10 -1
  132. package/dist/tools/memory.d.ts +6 -6
  133. package/dist/tools/notebook.d.ts +2 -2
  134. package/dist/tools/permission-rules.d.ts +32 -0
  135. package/dist/tools/permission-rules.js +182 -0
  136. package/dist/tools/read-file.d.ts +5 -0
  137. package/dist/tools/read-file.js +12 -2
  138. package/dist/tools/read-only.d.ts +1 -1
  139. package/dist/tools/read-only.js +2 -0
  140. package/dist/tools/run-command.d.ts +4 -2
  141. package/dist/tools/run-command.js +12 -8
  142. package/dist/tools/sandbox-loader.js +3 -13
  143. package/dist/tools/search-files.d.ts +8 -2
  144. package/dist/tools/search-files.js +43 -0
  145. package/dist/tools/shared.d.ts +5 -0
  146. package/dist/tools/shared.js +30 -2
  147. package/dist/tools/shell-env.d.ts +7 -0
  148. package/dist/tools/shell-env.js +9 -0
  149. package/dist/tools/sub-agent-wait.d.ts +29 -0
  150. package/dist/tools/sub-agent-wait.js +60 -0
  151. package/dist/tools/sub-agent.d.ts +50 -4
  152. package/dist/tools/sub-agent.js +213 -125
  153. package/dist/tools/text-encoding.d.ts +10 -0
  154. package/dist/tools/text-encoding.js +40 -0
  155. package/dist/tools/todo.d.ts +36 -0
  156. package/dist/tools/todo.js +42 -0
  157. package/dist/tools/write-file.js +15 -9
  158. package/dist/tools/write-tools.d.ts +12 -0
  159. package/dist/tools/write-tools.js +18 -0
  160. package/dist/types.d.ts +98 -0
  161. package/dist/worktree/worktree.d.ts +3 -0
  162. package/dist/worktree/worktree.js +22 -0
  163. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@ikarmic/forge-core`
2
2
 
3
- The agent engine for [IKarmic AI Forge](https://github.com/ikarmic/forge). Shared by the CLI and the planned VS Code extension.
3
+ The agent engine for [IKarmic AI Forge](https://github.com/ikarmicai/ikarmic-forge). Shared by the CLI and the VS Code extension.
4
4
 
5
5
  ## What's in the box
6
6
 
@@ -15,6 +15,8 @@ export declare class Agent {
15
15
  private readonly checkpoints;
16
16
  private readonly browser;
17
17
  private readonly audit;
18
+ private readonly permissionRules;
19
+ private todoList;
18
20
  constructor(config: AgentConfig);
19
21
  /** Files whose modification should trigger a pre-tool checkpoint. */
20
22
  private get captureBeforeTools();
@@ -42,6 +44,12 @@ export declare class Agent {
42
44
  * we never want to "skip".
43
45
  */
44
46
  private wrapToolsWithCache;
47
+ /**
48
+ * What the permission rules say about one tool call: `allow` (every target allowed), `deny` (any target denied,
49
+ * with the reason the model sees), `ask` (a target asks, a `.env` file is read, or a command leaves the workspace),
50
+ * or undefined when no rule applies and the normal approval flow decides.
51
+ */
52
+ private decidePermission;
45
53
  private requestApproval;
46
54
  /**
47
55
  * Build the default LLM-based summarizer for compaction. Picks the
@@ -1,6 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { release } from 'node:os';
3
+ import path from 'node:path';
1
4
  import { streamText } from 'ai';
2
5
  import { lintFile, lintMessage } from '../lint/auto-lint.js';
3
6
  import { buildMemoryBlock } from '../memory/memory-store.js';
7
+ import { filesTouchedBy } from '../tools/write-tools.js';
8
+ import { buildExtraInstructionsBlock } from '../steering/extra-instructions.js';
4
9
  import { buildProjectInstructionsBlock } from '../steering/project-instructions.js';
5
10
  import { buildRepoMap, repoMapBlock } from '../repomap/repo-map.js';
6
11
  import { buildLlmSummarizer, compactMessages, effectiveLimitTokens, getModelWindowTokens, shouldCompactForBudget } from '../context/compaction.js';
@@ -17,6 +22,7 @@ import { embedderFromEnv } from '../semantic/embedder.js';
17
22
  import { DEFAULT_VERIFY_RETRIES, failureMessage, runVerification } from './verify.js';
18
23
  import { LoopGuard, resultKeyOf } from './loop-guard.js';
19
24
  import { isListedCommand } from '../tools/command-allow.js';
25
+ import { compilePermissionRules, evaluatePermission, isSensitiveFile, permissionFor, touchesOutsideCwd, } from '../tools/permission-rules.js';
20
26
  import { createLanguageModel } from '../providers.js';
21
27
  import { BUILTIN_TOOLS } from '../tools/index.js';
22
28
  import { ToolResultCache, cachedExecute } from '../tools/result-cache.js';
@@ -26,15 +32,16 @@ import { composeSkillsPrompt, selectSkills } from '../skills/activator.js';
26
32
  import { MAX_TOOL_INJECTIONS, selectAtStart, selectOnToolTouch, toWorkspaceRelative, wrapSteering } from '../steering/select.js';
27
33
  import { applyCacheHints } from './cache-hints.js';
28
34
  import { attachmentParts, formatBytes, mediaPartTokens } from '../attachments.js';
29
- import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT } from './prompts.js';
35
+ import { renderAnswersBlock } from '../journey/answers-block.js';
36
+ import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, environmentBlock } from './prompts.js';
30
37
  /** Read-only tool names whose output flows through the redactor. */
31
38
  const DEFAULT_MAX_CONTINUATIONS = 3;
32
39
  export const DEFAULT_MAX_DURATION_MS = 10 * 60 * 1000;
33
40
  const REDACTABLE_TOOLS = new Set(['read_file', 'search_files', 'list_files']);
34
41
  /** Tools whose successful `path` argument can trigger mid-run fileMatch steering. */
35
- const STEERING_FILE_TOOLS = new Set(['read_file', 'write_file', 'edit_file', 'edit_notebook']);
42
+ const STEERING_FILE_TOOLS = new Set(['read_file', 'write_file', 'edit_file', 'edit_notebook', 'apply_patch']);
36
43
  /** Tools that change a file, so auto-lint checks their `path` afterwards. */
37
- const FILE_WRITE_TOOLS = new Set(['write_file', 'edit_file', 'edit_notebook']);
44
+ const FILE_WRITE_TOOLS = new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch']);
38
45
  /** Pure reads whose results may be reused within a run. Every other tool invalidates the cache. */
39
46
  const CACHEABLE_TOOLS = new Set(['read_file', 'read_image', 'list_files', 'search_files', 'find_symbol', 'list_symbols', 'find_references', 'codebase_search', 'memory_read', 'fetch_url', 'web_search']);
40
47
  /**
@@ -52,12 +59,16 @@ export class Agent {
52
59
  checkpoints;
53
60
  browser;
54
61
  audit;
62
+ permissionRules;
63
+ todoList = [];
55
64
  constructor(config) {
56
65
  // Organization policy first: everything below sees the already-tightened config.
57
66
  const policy = config.policy === undefined ? getOrgPolicy()?.policy : (config.policy ?? undefined);
58
67
  config = applyPolicy(config, policy);
59
68
  this.audit = config.audit ?? (policy?.audit?.enabled ? createFileAuditSink(policy.audit.file) : undefined);
60
69
  this.config = config;
70
+ this.permissionRules = compilePermissionRules(config.permissionRules);
71
+ this.todoList = config.initialTodos ? [...config.initialTodos] : [];
61
72
  const allTools = (config.disableBuiltins
62
73
  ? [...(config.extraTools ?? [])]
63
74
  : [...BUILTIN_TOOLS, ...(config.extraTools ?? [])]);
@@ -72,12 +83,19 @@ export class Agent {
72
83
  if (webSearch)
73
84
  allTools.push(createWebSearchTool(webSearch));
74
85
  if (config.semanticSearch) {
75
- allTools.push(createCodebaseSearchTool(config.semanticSearch.embedder ?? embedderFromEnv()));
86
+ allTools.push(createCodebaseSearchTool(config.semanticSearch.embedder ?? embedderFromEnv(), {
87
+ ...(config.semanticSearch.extensions ? { extensions: config.semanticSearch.extensions } : {}),
88
+ ...(config.semanticSearch.minScore !== undefined ? { minScore: config.semanticSearch.minScore } : {}),
89
+ ...(config.semanticSearch.defaultLimit ? { defaultLimit: config.semanticSearch.defaultLimit } : {}),
90
+ }));
76
91
  }
77
92
  const memoryFiltered = config.disableMemory
78
93
  ? allTools.filter((t) => t.name !== 'memory_read' && t.name !== 'memory_write' && t.name !== 'memory_forget')
79
94
  : allTools;
80
- const sandboxedTools = applySandbox(memoryFiltered, config.toolSandbox);
95
+ // `ask_user` is offered only when a surface can answer and the run is not autonomous.
96
+ const canAsk = Boolean(config.askUser) && !config.auto && !config.disableAskUser;
97
+ const askFiltered = canAsk ? memoryFiltered : memoryFiltered.filter((t) => t.name !== 'ask_user');
98
+ const sandboxedTools = applySandbox(askFiltered, config.toolSandbox);
81
99
  this.tools = this.wrapToolsWithCache(sandboxedTools, config.cacheSize, config.cacheTtlMs, config.disableCache);
82
100
  this.cache = new ToolResultCache({
83
101
  maxSize: config.disableCache ? 0 : (config.cacheSize ?? 256),
@@ -96,7 +114,7 @@ export class Agent {
96
114
  const cfg = this.checkpoints?.captureBeforeTools;
97
115
  if (!cfg) {
98
116
  // Default: file-mutating tools + run_command (which can mutate via shell).
99
- return new Set(['write_file', 'edit_file', 'edit_notebook', 'run_command']);
117
+ return new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command']);
100
118
  }
101
119
  return new Set(cfg);
102
120
  }
@@ -235,12 +253,30 @@ export class Agent {
235
253
  let verification;
236
254
  // Infinite output: text of a reply that hit the output limit and is being continued.
237
255
  let continuedText = '';
256
+ // The answer text of the step now streaming, kept so a Stop does not throw away what was already said.
257
+ let partialStepText = '';
238
258
  let continuations = 0;
239
259
  const maxContinuations = this.config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS;
240
260
  const ctx = {
241
261
  cwd: this.config.cwd ?? process.cwd(),
242
262
  auto: this.config.auto ?? false,
243
263
  parentModel: this.config.model,
264
+ ...(this.config.subAgentModel ? { subAgentModel: this.config.subAgentModel } : {}),
265
+ ...(this.config.backgroundAgents ? { background: this.config.backgroundAgents } : {}),
266
+ ...(this.config.autoApproveTools ? { autoApproveTools: this.config.autoApproveTools } : {}),
267
+ ...(this.config.permissionRules ? { permissionRules: this.config.permissionRules } : {}),
268
+ ...(this.config.toolSandbox ? { toolSandbox: this.config.toolSandbox } : {}),
269
+ shownFolderInstructions: new Set(),
270
+ ...(this.config.maxToolOutputChars ? { maxOutputChars: this.config.maxToolOutputChars } : {}),
271
+ ...(this.config.askUser ? { askUser: this.config.askUser } : {}),
272
+ ...(this.config.askBudget ? { askBudget: this.config.askBudget } : {}),
273
+ todos: {
274
+ get: () => this.todoList,
275
+ set: (todos) => {
276
+ this.todoList = todos;
277
+ emit({ type: 'todo_updated', todos });
278
+ },
279
+ },
244
280
  ...(this.config.commandRunner ? { commandRunner: this.config.commandRunner } : {}),
245
281
  ...(abortSignal ? { abortSignal } : {}),
246
282
  ...(this.config.memoryMaxBodyChars !== undefined ? { memoryMaxBodyChars: this.config.memoryMaxBodyChars } : {}),
@@ -289,13 +325,16 @@ export class Agent {
289
325
  // Track per-turn token deltas so we can compute per-turn cost.
290
326
  const turnStart = { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens };
291
327
  let reasoningText = '';
328
+ partialStepText = '';
292
329
  // Inline <think> blocks (MiniMax over OpenAI-compatible, some open models) become reasoning events.
293
330
  const thinkSplitter = new ThinkSplitter();
294
331
  let wasInThink = false;
295
332
  const emitSegments = (segments) => {
296
333
  for (const seg of segments) {
297
- if (seg.kind === 'text')
334
+ if (seg.kind === 'text') {
335
+ partialStepText += seg.text;
298
336
  emit({ type: 'text_delta', text: seg.text });
337
+ }
299
338
  else {
300
339
  reasoningText += seg.text;
301
340
  emit({ type: 'reasoning_delta', text: seg.text });
@@ -329,6 +368,7 @@ export class Agent {
329
368
  tools: toAiSdkTools(this.tools),
330
369
  abortSignal,
331
370
  ...(this.config.model.reasoningEffort ? { reasoning: this.config.model.reasoningEffort } : {}),
371
+ ...(this.config.model.temperature !== undefined ? { temperature: this.config.model.temperature } : {}),
332
372
  onChunk: ({ chunk }) => {
333
373
  if (chunk.type === 'text-delta') {
334
374
  emitSegments(thinkSplitter.push(chunk.text));
@@ -351,6 +391,7 @@ export class Agent {
351
391
  },
352
392
  onFinish: ({ text: rawText, usage }) => {
353
393
  emitSegments(thinkSplitter.flush());
394
+ partialStepText = '';
354
395
  if (reasoningText) {
355
396
  emit({ type: 'reasoning_done', text: reasoningText });
356
397
  reasoningText = '';
@@ -577,12 +618,16 @@ export class Agent {
577
618
  this.audit?.record({ event: 'tool_result', tool: call.toolName, isError: false, durationMs: Date.now() - toolStartedAt }, auditCtx);
578
619
  if (FILE_WRITE_TOOLS.has(call.toolName))
579
620
  editedSinceVerify = true;
580
- const editedPath = toolArgs.path;
581
- if (FILE_WRITE_TOOLS.has(call.toolName) && typeof editedPath === 'string')
582
- editedFiles.add(editedPath);
583
- if (this.config.autoLint && FILE_WRITE_TOOLS.has(call.toolName) && typeof editedPath === 'string') {
584
- const report = await lintFile(editedPath, this.config.autoLint, { ...ctx, policy: undefined }).catch(() => null);
585
- if (report) {
621
+ // Every file the call worked on: a patch can change several (RB-13).
622
+ const touchedFiles = filesTouchedBy(call.toolName, toolArgs);
623
+ if (FILE_WRITE_TOOLS.has(call.toolName))
624
+ for (const p of touchedFiles)
625
+ editedFiles.add(p);
626
+ if (this.config.autoLint && FILE_WRITE_TOOLS.has(call.toolName)) {
627
+ for (const editedPath of touchedFiles) {
628
+ const report = await lintFile(editedPath, this.config.autoLint, { ...ctx, policy: undefined }).catch(() => null);
629
+ if (!report)
630
+ continue;
586
631
  emit({ type: 'lint', path: report.path, passed: report.passed, problems: report.problems.length });
587
632
  if (!report.passed) {
588
633
  const note = lintMessage(report);
@@ -600,11 +645,8 @@ export class Agent {
600
645
  }
601
646
  currentToolResults.push(toolResult(call.toolCallId, call.toolName, finalResult));
602
647
  trackLoop(call.toolName, toolArgs, finalResult);
603
- if (STEERING_FILE_TOOLS.has(call.toolName)) {
604
- const touched = toolArgs.path;
605
- if (typeof touched === 'string')
606
- touchedPaths.push(touched);
607
- }
648
+ if (STEERING_FILE_TOOLS.has(call.toolName))
649
+ touchedPaths.push(...filesTouchedBy(call.toolName, toolArgs));
608
650
  emit({
609
651
  type: 'tool_result',
610
652
  name: call.toolName,
@@ -780,6 +822,14 @@ export class Agent {
780
822
  const lastMessage = messages[messages.length - 1];
781
823
  if (continuedText !== '' && lastMessage?.role === 'user' && lastMessage.content === CONTINUE_PROMPT)
782
824
  messages.pop();
825
+ // Stopped mid-answer: keep the part the user already saw, marked as cut short, so "continue" can pick it up
826
+ // instead of starting over. Only when the model is the next speaker (after the user or a tool result).
827
+ if (reason === 'aborted' && partialStepText.trim()) {
828
+ const last = messages[messages.length - 1];
829
+ if (last && (last.role === 'user' || last.role === 'tool')) {
830
+ messages.push({ role: 'assistant', content: `${partialStepText.trim()}\n\n[Stopped by the user before finishing.]` });
831
+ }
832
+ }
783
833
  // The browser is per-run: never leave a Chrome process behind.
784
834
  await this.browser?.close().catch(() => undefined);
785
835
  emit({ type: 'done', reason });
@@ -883,21 +933,68 @@ export class Agent {
883
933
  return { ...t, execute: invalidating };
884
934
  });
885
935
  }
936
+ /**
937
+ * What the permission rules say about one tool call: `allow` (every target allowed), `deny` (any target denied,
938
+ * with the reason the model sees), `ask` (a target asks, a `.env` file is read, or a command leaves the workspace),
939
+ * or undefined when no rule applies and the normal approval flow decides.
940
+ */
941
+ decidePermission(toolName, args, cwd) {
942
+ const p = permissionFor(toolName, args, cwd);
943
+ if (!p || p.targets.length === 0)
944
+ return undefined;
945
+ const rules = this.permissionRules;
946
+ const explicit = rules.filter((r) => r.pattern !== '*' && r.pattern !== '**');
947
+ const actions = p.targets.map((t) => {
948
+ // A broad rule never covers a secrets file: only a rule that names it can allow it.
949
+ // Headless runs (no approve callback) have nobody to ask, so they keep reading as before.
950
+ if (p.permission === 'read' && isSensitiveFile(t))
951
+ return evaluatePermission(explicit, 'read', t) ?? (this.config.approve ? 'ask' : evaluatePermission(rules, 'read', t));
952
+ return evaluatePermission(rules, p.permission, t);
953
+ });
954
+ if (p.permission === 'bash') {
955
+ const command = args.command;
956
+ if (typeof command === 'string' && touchesOutsideCwd(command, cwd)) {
957
+ const external = evaluatePermission(rules, 'external_directory', command);
958
+ if (external === 'deny')
959
+ return { action: 'deny', reason: `Blocked by permission rule (external_directory ${command}).` };
960
+ if (external !== 'allow' && this.config.approve)
961
+ actions.push('ask');
962
+ }
963
+ }
964
+ const denied = actions.indexOf('deny');
965
+ if (denied >= 0)
966
+ return { action: 'deny', reason: `Blocked by permission rule (${p.permission} ${p.targets[denied]}).` };
967
+ if (actions.includes('ask'))
968
+ return { action: 'ask', reason: '' };
969
+ if (actions.every((a) => a === 'allow'))
970
+ return { action: 'allow', reason: '' };
971
+ return undefined;
972
+ }
886
973
  async requestApproval(def, id, args, abortSignal, ctx) {
974
+ // Bookkeeping tools (the todo list) never ask.
975
+ if (def.noApproval)
976
+ return { approved: true };
977
+ // Permission rules come first: deny blocks, allow runs, ask skips every shortcut below.
978
+ const rule = this.decidePermission(def.name, args, ctx?.cwd ?? this.config.cwd ?? process.cwd());
979
+ if (rule?.action === 'deny')
980
+ return { approved: false, note: rule.reason };
981
+ if (rule?.action === 'allow')
982
+ return { approved: true };
983
+ const forceAsk = rule?.action === 'ask';
887
984
  // Read-only tools the user chose not to be asked about (never a destructive one).
888
- if (!def.destructive && this.config.autoApproveTools?.includes(def.name)) {
985
+ if (!forceAsk && !def.destructive && this.config.autoApproveTools?.includes(def.name)) {
889
986
  return { approved: true };
890
987
  }
891
988
  // Memory writes the user chose not to be asked about. memory_forget is deleted data: always asked.
892
- if (def.name === 'memory_write' && this.config.memoryAutoApproveWrites) {
989
+ if (!forceAsk && def.name === 'memory_write' && this.config.memoryAutoApproveWrites) {
893
990
  return { approved: true };
894
991
  }
895
992
  // A plain command the user already said "always allow" for: exact match only (see command-allow.ts).
896
- if (def.name === 'run_command' && this.config.autoApproveCommands?.length && isListedCommand(args, this.config.autoApproveCommands)) {
993
+ if (!forceAsk && def.name === 'run_command' && this.config.autoApproveCommands?.length && isListedCommand(args, this.config.autoApproveCommands)) {
897
994
  return { approved: true };
898
995
  }
899
996
  const auto = this.config.auto ?? false;
900
- if (auto && !(def.destructive && !this.config.allowDestructive)) {
997
+ if (!forceAsk && auto && !(def.destructive && !this.config.allowDestructive)) {
901
998
  return { approved: true };
902
999
  }
903
1000
  let preview;
@@ -1093,6 +1190,15 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
1093
1190
  // Unreadable instruction files are skipped, never fatal.
1094
1191
  }
1095
1192
  }
1193
+ let extraInstructionsBlock = '';
1194
+ if (config.extraInstructions?.length) {
1195
+ try {
1196
+ extraInstructionsBlock = await buildExtraInstructionsBlock(cwd, config.extraInstructions);
1197
+ }
1198
+ catch {
1199
+ // Skipped, never fatal.
1200
+ }
1201
+ }
1096
1202
  let mapBlock = '';
1097
1203
  if (config.repoMap) {
1098
1204
  try {
@@ -1102,10 +1208,28 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
1102
1208
  // A map failure shouldn't kill a run: just skip.
1103
1209
  }
1104
1210
  }
1211
+ // Interview answers from a guided journey. This function runs only when no
1212
+ // history is passed (see the `options.history` branch in `run`), so every
1213
+ // block here is assembled once, on a session's first turn, and then rides
1214
+ // inside `history[0]`. That is why a journey's interview must finish before
1215
+ // the first run: an answer that arrives on turn 3 can never enter the system
1216
+ // message, and a middle turn would be summarised away by compaction.
1217
+ const answersBlock = renderAnswersBlock(config.journeyAnswers ?? []);
1218
+ // Facts the model would otherwise have to guess: the platform decides
1219
+ // whether `cat` or `type` works, and it cannot see the date at all.
1220
+ const envBlock = environmentBlock({
1221
+ cwd,
1222
+ platform: process.platform,
1223
+ osVersion: release(),
1224
+ isGitRepo: existsSync(path.join(cwd, '.git')),
1225
+ today: new Date().toISOString().slice(0, 10),
1226
+ model: config.model?.model,
1227
+ });
1105
1228
  // Compose the final system prompt: base + (memory, skills) blocks in a
1106
- // stable order so cache hits survive across runs that share state.
1229
+ // stable order so cache hits survive across runs that share state. The
1230
+ // environment block goes last: it is the part that varies per session.
1107
1231
  const steeringBlock = steeringSystem.map(wrapSteering).join('\n\n');
1108
- const blocks = [steeringBlock, instructionsBlock, config.projectCommands ?? '', memoryBlock, skillsBlock, mapBlock].filter((b) => b.length > 0);
1232
+ const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, envBlock].filter((b) => b.length > 0);
1109
1233
  const systemContent = blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
1110
1234
  return [
1111
1235
  { role: 'system', content: systemContent },
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sub-agents that keep working after `sub_agent` has returned. The chat owns one registry per conversation: the
3
+ * model starts a run with `sub_agent { background: true }` and gets an id back at once; it collects the answer with
4
+ * `sub_agent_wait`, or the chat hands it every finished answer it has not seen with the next message. The person sees
5
+ * the runs in a strip above the message box and can stop one, stop all, or dismiss a finished one.
6
+ */
7
+ import type { SubAgentResult } from '../tools/sub-agent.js';
8
+ export type BackgroundState = 'running' | 'done' | 'failed' | 'stopped';
9
+ export interface BackgroundAgent {
10
+ id: string;
11
+ task: string;
12
+ /** The agent file it runs as, when it was started with one. */
13
+ agent?: string;
14
+ state: BackgroundState;
15
+ startedAt: number;
16
+ endedAt?: number;
17
+ result?: SubAgentResult;
18
+ error?: string;
19
+ }
20
+ /** More than this at once costs more than anyone meant to spend. */
21
+ export declare const MAX_RUNNING_BACKGROUND = 4;
22
+ export interface StartOptions {
23
+ task: string;
24
+ agent?: string | undefined;
25
+ /** Runs the work; must finish soon after `signal` aborts. */
26
+ run: (signal: AbortSignal) => Promise<SubAgentResult>;
27
+ }
28
+ export declare class BackgroundAgents {
29
+ private readonly entries;
30
+ private readonly listeners;
31
+ private counter;
32
+ start(options: StartOptions): BackgroundAgent;
33
+ list(): BackgroundAgent[];
34
+ get(id: string): BackgroundAgent | undefined;
35
+ stop(id: string): void;
36
+ stopAll(): void;
37
+ /** Stop everything and forget it all (a new chat starts with none). */
38
+ clear(): void;
39
+ /** Remove a finished entry from the list. A running one must be stopped first. */
40
+ dismiss(id: string): boolean;
41
+ /**
42
+ * Wait until the named runs (default: all that are running) have finished, or the timeout passes. Never throws:
43
+ * the answer is the current state of each run.
44
+ */
45
+ wait(ids?: readonly string[], options?: {
46
+ timeoutMs?: number;
47
+ signal?: AbortSignal;
48
+ }): Promise<BackgroundAgent[]>;
49
+ /** Finished runs the model has not been told about yet; each is returned once. */
50
+ takeUndelivered(): BackgroundAgent[];
51
+ /** Mark runs as already known to the model (a `sub_agent_wait` answered for them). */
52
+ markDelivered(ids: readonly string[]): void;
53
+ onChange(listener: () => void): () => void;
54
+ private changed;
55
+ private view;
56
+ }
57
+ /** The model-facing text for finished runs: what each was asked and what it answered. */
58
+ export declare function backgroundResultsBlock(finished: readonly BackgroundAgent[]): string;
@@ -0,0 +1,139 @@
1
+ /** More than this at once costs more than anyone meant to spend. */
2
+ export const MAX_RUNNING_BACKGROUND = 4;
3
+ export class BackgroundAgents {
4
+ entries = new Map();
5
+ listeners = new Set();
6
+ counter = 0;
7
+ start(options) {
8
+ const running = [...this.entries.values()].filter((e) => e.state === 'running').length;
9
+ if (running >= MAX_RUNNING_BACKGROUND) {
10
+ throw new Error(`At most ${MAX_RUNNING_BACKGROUND} background agents can run at once; wait for one to finish or stop one.`);
11
+ }
12
+ this.counter += 1;
13
+ const controller = new AbortController();
14
+ const entry = {
15
+ id: `bg-${this.counter}`,
16
+ task: options.task,
17
+ ...(options.agent ? { agent: options.agent } : {}),
18
+ state: 'running',
19
+ startedAt: Date.now(),
20
+ controller,
21
+ finished: Promise.resolve(),
22
+ delivered: false,
23
+ };
24
+ entry.finished = options.run(controller.signal).then((result) => {
25
+ entry.result = result;
26
+ // A stop makes the child return with reason "aborted": that is a stopped run, not a finished one.
27
+ entry.state = controller.signal.aborted || result.reason === 'aborted' ? 'stopped' : 'done';
28
+ entry.endedAt = Date.now();
29
+ this.changed();
30
+ }, (err) => {
31
+ entry.error = err?.message ?? String(err);
32
+ entry.state = controller.signal.aborted ? 'stopped' : 'failed';
33
+ entry.endedAt = Date.now();
34
+ this.changed();
35
+ });
36
+ this.entries.set(entry.id, entry);
37
+ this.changed();
38
+ return this.view(entry);
39
+ }
40
+ list() {
41
+ return [...this.entries.values()].map((e) => this.view(e));
42
+ }
43
+ get(id) {
44
+ const e = this.entries.get(id);
45
+ return e ? this.view(e) : undefined;
46
+ }
47
+ stop(id) {
48
+ const e = this.entries.get(id);
49
+ if (e?.state === 'running')
50
+ e.controller.abort();
51
+ }
52
+ stopAll() {
53
+ for (const e of this.entries.values())
54
+ if (e.state === 'running')
55
+ e.controller.abort();
56
+ }
57
+ /** Stop everything and forget it all (a new chat starts with none). */
58
+ clear() {
59
+ this.stopAll();
60
+ this.entries.clear();
61
+ this.changed();
62
+ }
63
+ /** Remove a finished entry from the list. A running one must be stopped first. */
64
+ dismiss(id) {
65
+ const e = this.entries.get(id);
66
+ if (!e || e.state === 'running')
67
+ return false;
68
+ this.entries.delete(id);
69
+ this.changed();
70
+ return true;
71
+ }
72
+ /**
73
+ * Wait until the named runs (default: all that are running) have finished, or the timeout passes. Never throws:
74
+ * the answer is the current state of each run.
75
+ */
76
+ async wait(ids, options = {}) {
77
+ const chosen = ids ? ids.map((id) => this.entries.get(id)).filter((e) => e !== undefined) : [...this.entries.values()].filter((e) => e.state === 'running');
78
+ const all = Promise.all(chosen.map((e) => e.finished));
79
+ const races = [all];
80
+ let timer;
81
+ if (options.timeoutMs !== undefined)
82
+ races.push(new Promise((resolve) => (timer = setTimeout(resolve, options.timeoutMs))));
83
+ if (options.signal) {
84
+ const signal = options.signal;
85
+ races.push(new Promise((resolve) => (signal.aborted ? resolve(undefined) : signal.addEventListener('abort', () => resolve(undefined), { once: true }))));
86
+ }
87
+ await Promise.race(races);
88
+ if (timer)
89
+ clearTimeout(timer);
90
+ return chosen.map((e) => this.view(e));
91
+ }
92
+ /** Finished runs the model has not been told about yet; each is returned once. */
93
+ takeUndelivered() {
94
+ const out = [];
95
+ for (const e of this.entries.values()) {
96
+ if (e.state !== 'running' && !e.delivered) {
97
+ e.delivered = true;
98
+ out.push(this.view(e));
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ /** Mark runs as already known to the model (a `sub_agent_wait` answered for them). */
104
+ markDelivered(ids) {
105
+ for (const id of ids) {
106
+ const e = this.entries.get(id);
107
+ if (e && e.state !== 'running')
108
+ e.delivered = true;
109
+ }
110
+ }
111
+ onChange(listener) {
112
+ this.listeners.add(listener);
113
+ return () => this.listeners.delete(listener);
114
+ }
115
+ changed() {
116
+ for (const l of this.listeners) {
117
+ try {
118
+ l();
119
+ }
120
+ catch {
121
+ // a listener that throws must not stop the run bookkeeping
122
+ }
123
+ }
124
+ }
125
+ view(e) {
126
+ const { controller: _c, finished: _f, delivered: _d, ...rest } = e;
127
+ return { ...rest };
128
+ }
129
+ }
130
+ /** The model-facing text for finished runs: what each was asked and what it answered. */
131
+ export function backgroundResultsBlock(finished) {
132
+ if (finished.length === 0)
133
+ return '';
134
+ const parts = finished.map((a) => {
135
+ const body = a.state === 'done' ? (a.result?.finalText ?? '').trim() || '(no text)' : a.state === 'failed' ? `failed: ${a.error ?? 'unknown error'}` : `stopped by the user${a.result?.finalText ? `; it had said: ${a.result.finalText.trim()}` : ''}`;
136
+ return `<background-agent id="${a.id}" state="${a.state}" task="${a.task.replace(/"/g, "'").slice(0, 200)}">\n${body}\n</background-agent>`;
137
+ });
138
+ return `<background-agents>\n${parts.join('\n')}\n</background-agents>`;
139
+ }
@@ -1,7 +1,9 @@
1
1
  import type { LanguageModel } from 'ai';
2
2
  import type { ModelConfig } from '../types.js';
3
+ /** The prompt for rewriting `input`: the user's own instruction when given (a blank one is ignored), else the built-in. */
4
+ export declare function enhanceRequestPrompt(input: string, instruction?: string): string;
3
5
  /**
4
6
  * Rewrite a draft chat message into a clearer request using `model` (typically the cheap model).
5
7
  * Never throws: an empty input, a missing model, a model error or an empty reply all return `input` unchanged.
6
8
  */
7
- export declare function enhancePrompt(input: string, model: ModelConfig | undefined, modelFactory?: (cfg: ModelConfig) => LanguageModel): Promise<string>;
9
+ export declare function enhancePrompt(input: string, model: ModelConfig | undefined, modelFactory?: (cfg: ModelConfig) => LanguageModel, instruction?: string): Promise<string>;
@@ -3,15 +3,19 @@ import { createLanguageModel } from '../providers.js';
3
3
  import { stripThinkTags } from './think-tags.js';
4
4
  const ENHANCE_INSTRUCTIONS = 'Rewrite the request below so a coding agent can act on it: say what to change and where, name the expected result, and keep every file, symbol and constraint the user mentioned. ' +
5
5
  'Do not invent requirements or add steps the user did not ask for. Reply with the rewritten request only, no quotes, no code fences, no preface.';
6
+ /** The prompt for rewriting `input`: the user's own instruction when given (a blank one is ignored), else the built-in. */
7
+ export function enhanceRequestPrompt(input, instruction) {
8
+ return `${instruction?.trim() || ENHANCE_INSTRUCTIONS}\n\nRequest:\n${input}`;
9
+ }
6
10
  /**
7
11
  * Rewrite a draft chat message into a clearer request using `model` (typically the cheap model).
8
12
  * Never throws: an empty input, a missing model, a model error or an empty reply all return `input` unchanged.
9
13
  */
10
- export async function enhancePrompt(input, model, modelFactory = createLanguageModel) {
14
+ export async function enhancePrompt(input, model, modelFactory = createLanguageModel, instruction) {
11
15
  if (!model || !input.trim())
12
16
  return input;
13
17
  try {
14
- const result = await generateText({ model: modelFactory(model), prompt: `${ENHANCE_INSTRUCTIONS}\n\nRequest:\n${input}` });
18
+ const result = await generateText({ model: modelFactory(model), prompt: enhanceRequestPrompt(input, instruction) });
15
19
  return clean(result.text) || input;
16
20
  }
17
21
  catch {
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ /**
4
+ * Session goals: one objective kept in focus across turns. Pure state machine plus the `goal_report` tool the
5
+ * model uses to say it is done or stuck. The loop that runs the turns lives in the surface (VS Code chat).
6
+ */
7
+ export type GoalStatus = 'active' | 'paused' | 'blocked' | 'complete';
8
+ export interface GoalState {
9
+ objective: string;
10
+ status: GoalStatus;
11
+ /** The model's own closing report (complete or blocked); not independently verified. */
12
+ report?: string;
13
+ /** Turns used so far. */
14
+ turns: number;
15
+ }
16
+ export interface GoalReport {
17
+ kind: 'complete' | 'blocked';
18
+ reason: string;
19
+ }
20
+ export interface TurnOutcome {
21
+ /** Tool calls the turn made; zero with no report means the model made no progress. */
22
+ toolCalls: number;
23
+ error?: boolean;
24
+ stopped?: boolean;
25
+ report?: GoalReport;
26
+ /** A permission request or tool call was rejected. */
27
+ rejected?: boolean;
28
+ }
29
+ export declare const MAX_OBJECTIVE_CHARS = 10000;
30
+ export declare function startGoal(objective: string): GoalState;
31
+ /**
32
+ * The goal after one turn. Stop, an error, no progress or an exhausted turn budget pause it; a rejection or a
33
+ * `blocked` report blocks it; a `complete` report finishes it. A report counts only when the turn ended cleanly.
34
+ */
35
+ export declare function applyTurnOutcome(g: GoalState, o: TurnOutcome, maxTurns: number): GoalState;
36
+ export declare function pauseGoal(g: GoalState): GoalState;
37
+ /** Paused and blocked goals continue; a complete one restarts from zero turns. */
38
+ export declare function resumeGoal(g: GoalState): GoalState;
39
+ /** The message that drives one goal turn. */
40
+ export declare function goalPrompt(g: GoalState): string;
41
+ declare const reportParams: z.ZodObject<{
42
+ kind: z.ZodEnum<["complete", "blocked"]>;
43
+ reason: z.ZodString;
44
+ }, "strip", z.ZodTypeAny, {
45
+ kind: "blocked" | "complete";
46
+ reason: string;
47
+ }, {
48
+ kind: "blocked" | "complete";
49
+ reason: string;
50
+ }>;
51
+ export declare function createGoalReportTool(onReport: (r: GoalReport) => void): ToolDefinition<z.infer<typeof reportParams>, {
52
+ recorded: true;
53
+ }>;
54
+ export {};