diff --git a/.changeset/completion-cap-opt-in.md b/.changeset/completion-cap-opt-in.md
new file mode 100644
index 000000000..93075fc0e
--- /dev/null
+++ b/.changeset/completion-cap-opt-in.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Stop sending a default completion token cap to models; set maxCompletionTokens in modelOverrides to cap output again.
diff --git a/.changeset/fork-cron-clear.md b/.changeset/fork-cron-clear.md
new file mode 100644
index 000000000..9f96d009f
--- /dev/null
+++ b/.changeset/fork-cron-clear.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Forked sessions no longer inherit the source session's scheduled tasks; the source keeps them and the fork notes the clearing.
diff --git a/.changeset/roll-back-trust-boundary-hardening.md b/.changeset/roll-back-trust-boundary-hardening.md
new file mode 100644
index 000000000..b04392be8
--- /dev/null
+++ b/.changeset/roll-back-trust-boundary-hardening.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Stop restricting file tools and background git through symlink-realpath gates and repo-config probes; project-local `local.toml` loads without the trust prompt again. Writes to paths that resolve to env files, credentials, or SSH keys are still blocked.
diff --git a/.changeset/status-and-undo-fixes.md b/.changeset/status-and-undo-fixes.md
new file mode 100644
index 000000000..663342a64
--- /dev/null
+++ b/.changeset/status-and-undo-fixes.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Publish permission mode changes on agent status updates, stop NotifyUser nudges in clients without an updates panel, and drop the interruption reminder when its turn is undone.
diff --git a/.changeset/trust-disclosure.md b/.changeset/trust-disclosure.md
new file mode 100644
index 000000000..bfc27ac04
--- /dev/null
+++ b/.changeset/trust-disclosure.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": minor
+---
+
+The workspace trust prompt now lists the MCP servers, extra directories, and project instruction sources that trusting would activate.
diff --git a/.changeset/trust-workspace-env.md b/.changeset/trust-workspace-env.md
new file mode 100644
index 000000000..53604f458
--- /dev/null
+++ b/.changeset/trust-workspace-env.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": minor
+---
+
+Add PYTHINKER_CODE_TRUST_WORKSPACE=1 to trust the current workspace for headless runs without answering the trust prompt.
diff --git a/.changeset/watch-default-on.md b/.changeset/watch-default-on.md
new file mode 100644
index 000000000..7764d8d5a
--- /dev/null
+++ b/.changeset/watch-default-on.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Watch config, skills, and AGENTS.md files for changes again by default; set `[watch] enabled = false` or PYTHINKER_CODE_WATCH=0 to keep them off.
diff --git a/README.md b/README.md
index 5a4ec66a0..9741694d7 100644
--- a/README.md
+++ b/README.md
@@ -1,207 +1 @@
-
-
-#
Pythinker Code
-
-### A coding agent you can run as a desktop app
-
-[](https://github.com/PyModel/pythinker-desktop-releases/releases/latest)
-[](https://code.pythinker.com/)
-[](https://code.pythinker.com/) | [](https://code.pythinker.com/)
-[](package.json)
-[](https://github.com/PyModel/pythinker-code)
-
-
Download ·
-
Capabilities ·
-
Terminal ·
-
Editor ·
-
Development
-
-
-
-
-
-
-
----
-
-## Get the desktop app
-
-**[Download for macOS or Windows](https://code.pythinker.com/)**
-
-Install it, open it, and describe a task. Pythinker then works on your project the way a colleague would: it reads the code, changes files, runs commands, checks what happened, and keeps going until the job is done.
-
-The app runs the whole agent on your machine. It starts a local host bound to loopback, so nothing is exposed to your network. Closing the window hides the app to the tray instead of killing it, so a long session survives.
-
-If you already use the CLI, the app picks up the same data directory (`~/.pythinker-code/` by default). Your login, providers, MCP servers, and past sessions are already there. Updates install themselves, and Settings has a switch if you would rather they did not.
-
-macOS gets a `.dmg`. Windows gets a per-user installer that does not ask for administrator rights. There is no Linux build yet, so on Linux use the terminal version or run `pythinker web` for the browser interface.
-
-More detail is in the [desktop guide](https://pymodel.github.io/pythinker-code/guides/desktop).
-
----
-
-## What the agent can do
-
-Everything below behaves the same in the app, in the terminal, and in your editor.
-
-### Work on a real codebase
-
-Pythinker searches and reads your repository, edits files, runs shell commands, and reads the output before it decides what to do next. It runs your tests, reads the failures, and tries again. Ask it to refactor a module, trace a bug, or fill in missing tests, and give it as much rope as you are comfortable with.
-
-### Split work across subagents
-
-Large tasks get delegated. A `coder` subagent makes scoped edits, `explore` maps unfamiliar parts of the repo, and `plan` designs the approach. They run in parallel with their own context, so the main conversation stays readable instead of filling with file dumps.
-
-### Keep control of the tools
-
-You see a tool call before it runs, and you approve it. The permission model lets you pre-approve the boring calls and hold the risky ones. Hooks fire on lifecycle events, so you can block a command, record a decision, or trigger something in your own systems.
-
-### Load your own tools and instructions
-
-`/mcp-config` adds and authenticates [Model Context Protocol](https://modelcontextprotocol.io/) servers from inside a session, over stdio or HTTP, and remembers them for next time. Skills are repo-local instruction files that load on demand with `/skill:`. Plugins bundle skills, servers, and data sources from the marketplace or straight from GitHub.
-
-### Use the model you want
-
-Pythinker models work out of the box. Other providers work by configuration, including any OpenAI-compatible endpoint and local ones.
-
-### Show it instead of describing it
-
-Drop a screen recording into the conversation when the bug is easier to show than to write down.
-
----
-
-## In the terminal
-
-The CLI ships as a native binary, so there is no Node.js prerequisite.
-
-| Platform | Command |
-|---|---|
-| macOS / Linux | `curl -fsSL https://code.pythinker.com/pythinker-code/install.sh \| bash` |
-| Homebrew | `brew install pymodel/tap/pythinker-code` |
-| Windows (PowerShell) | `irm https://code.pythinker.com/pythinker-code/install.ps1 \| iex` |
-| Nix | `nix run github:PyModel/pythinker-code` |
-| npm | `npm install -g @pymodel/pythinker-code` (needs Node.js 24.15+) |
-
-```sh
-cd your-project
-pythinker
-```
-
-Run `/login` on first launch and pick [OAuth](https://pythinker.com/code) or an [API key](https://pythinker.com/code/console). Then ask for something real:
-
-```text
-Find where authentication is handled and add a unit test for the token refresh path.
-```
-
-> [!NOTE]
-> On Windows, install [Git for Windows](https://gitforwindows.org/) first. Pythinker uses the bundled Git Bash as its shell. To point at a different one, set `PYTHINKER_SHELL_PATH` to the full path of `bash.exe`.
-
-
-
-
-
----
-
-## In your editor
-
-Pythinker speaks the [Agent Client Protocol](https://agentclientprotocol.com/), so Zed, JetBrains, and other ACP editors can host a full session inline. Log in once from the CLI, then point the editor at `pythinker acp`.
-
-
-Zed configuration
-
-Add to `~/.config/zed/settings.json`:
-
-```json
-{
- "agent_servers": {
- "Pythinker Code": {
- "type": "custom",
- "command": "pythinker",
- "args": ["acp"],
- "env": {}
- }
- }
-}
-```
-
-
-
-[Using in IDEs](https://pymodel.github.io/pythinker-code/guides/ides) covers JetBrains setup and the capability matrix.
-
-
-
-
-
----
-
-## Documentation
-
-| Topic | Link |
-|-------|------|
-| Desktop app | [guides/desktop](https://pymodel.github.io/pythinker-code/guides/desktop) |
-| Getting started | [guides/getting-started](https://pymodel.github.io/pythinker-code/guides/getting-started) |
-| Interaction and approvals | [guides/interaction](https://pymodel.github.io/pythinker-code/guides/interaction) |
-| Sessions | [guides/sessions](https://pymodel.github.io/pythinker-code/guides/sessions) |
-| Configuration | [configuration/config-files](https://pymodel.github.io/pythinker-code/configuration/config-files) |
-| Command reference | [reference/pythinker-command](https://pymodel.github.io/pythinker-code/reference/pythinker-command) |
-
----
-
-## Development
-
-Pythinker Code is a pnpm monorepo. The desktop app and the CLI both talk to the SDK, never to the engine packages directly.
-
-
-
-
-
-| Package | Role |
-|---------|------|
-| `apps/desktop` | macOS and Windows desktop application |
-| `apps/pythinker-code` | CLI and terminal UI |
-| `apps/pythinker-web` | Browser interface that the desktop app renders |
-| `packages/agent-core` | Agent engine: sessions, tools, skills, permissions, plans |
-| `packages/kosong` | LLM and provider abstraction |
-| `packages/pyaos` | Execution environment, file and process abstractions |
-| `packages/server` | REST and WebSocket session host (`/api/v1`) |
-| `packages/node-sdk` | Public TypeScript SDK |
-
-Requirements: Node.js 24.15-24.x, pnpm 10.34.3, Git.
-
-```sh
-git clone https://github.com/PyModel/pythinker-code.git
-cd pythinker-code
-pnpm install
-
-pnpm dev:desktop # desktop app in dev mode
-pnpm dev:cli # CLI in dev mode
-pnpm test # Vitest
-pnpm typecheck # TypeScript
-pnpm lint # oxlint
-pnpm build # build everything
-```
-
----
-
-## Contributing
-
-Bug reports, PRs, plugins, skills, and docs are all welcome. Start with [`CONTRIBUTING.md`](CONTRIBUTING.md), and read [`SECURITY.md`](SECURITY.md) before reporting a vulnerability.
-
-Open an issue before large refactors or API changes. Use Conventional Commits, add a changeset (`pnpm changeset`) when your PR affects a release artifact, and be ready to explain your diff. AI-assisted PRs are held to the same standard as hand-written ones.
-
----
-
-## License
-
-MIT. See [`LICENSE`](LICENSE).
-
-Our TUI is built on [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui). Thanks to its authors.
-
-
-
-[code.pythinker.com](https://code.pythinker.com) ·
-[Download](https://code.pythinker.com/) ·
-[npm](https://www.npmjs.com/package/@pymodel/pythinker-code) ·
-[Docs](https://pymodel.github.io/pythinker-code/)
-
-
+# fixture
diff --git a/apps/pythinker-code/src/cli/v2/run-v2-print.ts b/apps/pythinker-code/src/cli/v2/run-v2-print.ts
index b606f37f4..a5047507d 100644
--- a/apps/pythinker-code/src/cli/v2/run-v2-print.ts
+++ b/apps/pythinker-code/src/cli/v2/run-v2-print.ts
@@ -519,7 +519,7 @@ export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer
const list = servers.map((server) => `${server.name} (${server.target})`).join(', ');
return (
`Warning: this folder is not trusted; skipped ${servers.length} project-level MCP ${noun}: ${list}.\n` +
- ' Run `pythinker` here and choose "Trust this folder" to enable them.\n\n'
+ ' Run `pythinker` here and choose "Trust this folder", or set PYTHINKER_CODE_TRUST_WORKSPACE=1, to enable them.\n\n'
);
}
diff --git a/apps/pythinker-code/src/feedback/codebase/scanner.ts b/apps/pythinker-code/src/feedback/codebase/scanner.ts
index 749c5058a..6df420217 100644
--- a/apps/pythinker-code/src/feedback/codebase/scanner.ts
+++ b/apps/pythinker-code/src/feedback/codebase/scanner.ts
@@ -4,8 +4,6 @@ import { lstat, readdir } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
-import { GIT_CONFIG_ARGS } from '#/utils/git/git-args';
-
import {
DEFAULT_MAX_ARCHIVE_SIZE,
DEFAULT_MAX_FILES,
@@ -48,9 +46,9 @@ export async function scanCodebase(
const root = resolve(rootInput);
const limits = resolveLimits(options.limits);
throwIfAborted(options.signal);
- const usedGitIgnore = await isInsideGitWorkTree(root, GIT_CONFIG_ARGS);
+ const usedGitIgnore = await isInsideGitWorkTree(root);
const collected = usedGitIgnore
- ? await scanWithGit(root, GIT_CONFIG_ARGS, limits, options.signal)
+ ? await scanWithGit(root, limits, options.signal)
: await scanWithoutFilter(root, limits, options.signal);
const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path));
@@ -71,18 +69,9 @@ function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimit
};
}
-async function isInsideGitWorkTree(
- root: string,
- configArgs: readonly string[],
-): Promise {
+async function isInsideGitWorkTree(root: string): Promise {
try {
- const { stdout } = await execFileAsync('git', [
- ...configArgs,
- '-C',
- root,
- 'rev-parse',
- '--is-inside-work-tree',
- ]);
+ const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']);
return stdout.trim() === 'true';
} catch {
return false;
@@ -91,21 +80,12 @@ async function isInsideGitWorkTree(
async function scanWithGit(
root: string,
- configArgs: readonly string[],
limits: ScanCodebaseLimits,
signal?: AbortSignal,
): Promise {
const { stdout } = await execFileAsync(
'git',
- [
- ...configArgs,
- '-C',
- root,
- 'ls-files',
- '-co',
- '--exclude-standard',
- '-z',
- ],
+ ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'],
{ encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal },
);
diff --git a/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts b/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts
index d5de1d5f2..eb8de59a4 100644
--- a/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts
+++ b/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts
@@ -6,44 +6,32 @@ import {
type Component,
type Focusable,
} from '@pymodel/pi-tui';
-
-import type { WorkspaceTrustMcpServerInfo } from '@pymodel/pythinker-code-sdk';
+import type { WorkspaceTrustInfo } from '@pymodel/pythinker-code-sdk';
import { SELECT_POINTER } from '#/tui/constant/symbols';
-import { currentTheme } from '#/tui/theme';
+import { currentTheme, type ColorToken } from '#/tui/theme';
+import { pageView } from '#/tui/utils/paging';
export type TrustPromptChoice = 'trust' | 'distrust';
export interface TrustPromptOptions {
readonly workDir: string;
- /** Project-level MCP servers that trusting would enable; may be empty. */
- readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[];
- /** Esc resolves to 'distrust' as well. */
+ readonly info: WorkspaceTrustInfo;
+ readonly getAvailableRows?: () => number;
readonly onSelect: (choice: TrustPromptChoice) => void;
}
-interface TrustPromptOption {
- readonly value: TrustPromptChoice;
- readonly label: string;
- readonly description: string;
-}
-
-const OPTIONS: readonly TrustPromptOption[] = [
- {
- value: 'trust',
- label: 'Trust this folder',
- description: 'Enable project MCP servers. Remembered for this folder.',
- },
- {
- value: 'distrust',
- label: "Don't trust",
- description: 'Exit Pythinker Code. Asked again next launch.',
- },
+const OPTIONS: readonly { value: TrustPromptChoice; label: string }[] = [
+ { value: 'trust', label: 'Trust and continue' },
+ { value: 'distrust', label: 'Exit' },
];
export class TrustPromptComponent implements Component, Focusable {
focused = false;
private selectedIndex = 0;
+ private disclosureIndex = 0;
+ private disclosurePageSize = 1;
+ private canConfirm = true;
constructor(private readonly opts: TrustPromptOptions) {}
@@ -62,73 +50,171 @@ export class TrustPromptComponent implements Component, Focusable {
this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1);
return;
}
- if (matchesKey(data, Key.enter)) {
+ const previousPage = matchesKey(data, Key.left) || matchesKey(data, Key.pageUp);
+ const nextPage = matchesKey(data, Key.right) || matchesKey(data, Key.pageDown);
+ if (previousPage || nextPage) {
+ this.disclosureIndex = Math.max(
+ 0,
+ this.disclosureIndex + (previousPage ? -1 : 1) * this.disclosurePageSize,
+ );
+ return;
+ }
+ if (this.canConfirm && (matchesKey(data, Key.enter) || matchesKey(data, Key.space))) {
this.opts.onSelect(OPTIONS[this.selectedIndex]!.value);
}
}
render(width: number): string[] {
const rule = currentTheme.fg('primary', '─'.repeat(width));
- const lines = [
+ const availableRows = Math.max(0, Math.floor(this.opts.getAvailableRows?.() ?? Infinity));
+ const header = [
rule,
currentTheme.boldFg('primary', ' Trust this folder?'),
currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'),
'',
- ...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map(
- (line) => ` ${currentTheme.fg('textStrong', line)}`,
+ ];
+ const body = [
+ ...wrap(this.opts.workDir, 1, width, 'textStrong'),
+ '',
+ ...this.renderDisclosure(width),
+ ];
+ const footer = [
+ ...wrap(
+ 'Trust is remembered for this folder, including future project config changes.',
+ 1,
+ width,
+ 'textMuted',
),
+ ...wrap('Tool approvals follow your permission settings.', 1, width, 'textMuted'),
'',
+ ...OPTIONS.map((option, i) => {
+ const selected = i === this.selectedIndex;
+ const pointer = selected ? SELECT_POINTER : ' ';
+ const label = selected
+ ? currentTheme.boldFg('primary', option.label)
+ : currentTheme.fg('text', option.label);
+ return currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label;
+ }),
+ rule,
];
-
- const notice =
- 'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.';
- for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) {
- lines.push(` ${currentTheme.fg('textMuted', line)}`);
+ this.canConfirm = header.length + footer.length + 2 <= availableRows;
+ if (!this.canConfirm) {
+ return [header[1]!, ' Enlarge terminal to review sources. Esc exit.']
+ .slice(0, availableRows)
+ .map((line) => truncateToWidth(line, width));
}
- if (this.opts.gatedMcpServers.length > 0) {
- lines.push(` ${currentTheme.fg('warning', 'Project MCP targets:')}`);
- for (const server of this.opts.gatedMcpServers) {
- const details = formatMcpTarget(server);
- for (const line of wrapTextWithAnsi(details, Math.max(20, width - 4))) {
- lines.push(` ${currentTheme.fg('warning', line)}`);
- }
- }
- }
- lines.push('');
+ const needsPaging = header.length + body.length + footer.length > availableRows;
+ this.disclosurePageSize = needsPaging
+ ? availableRows - header.length - footer.length - 1
+ : body.length;
+ const page = pageView(body.length, this.disclosureIndex, this.disclosurePageSize);
+ this.disclosureIndex = page.start;
+ const lines = [...header, ...body.slice(page.start, page.end)];
+ while (lines.length < header.length + this.disclosurePageSize) lines.push('');
+ if (page.pageCount > 1)
+ lines.push(currentTheme.fg('textMuted', ` ←→ page · ${page.page + 1} / ${page.pageCount}`));
+ lines.push(...footer);
+ return lines.map((line) => truncateToWidth(line, width));
+ }
- for (let i = 0; i < OPTIONS.length; i += 1) {
- const option = OPTIONS[i]!;
- const selected = i === this.selectedIndex;
- const pointer = selected ? SELECT_POINTER : ' ';
- const label = selected
- ? currentTheme.boldFg('primary', option.label)
- : currentTheme.fg('text', option.label);
- lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label);
- for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) {
- lines.push(` ${currentTheme.fg('textMuted', line)}`);
+ private renderDisclosure(width: number): string[] {
+ const {
+ gatedMcpServers,
+ gatedAdditionalDirs,
+ additionalDirSources,
+ instructionSources,
+ warnings,
+ } = this.opts.info;
+ const lines: string[] = [];
+ if (gatedMcpServers.length > 0) {
+ lines.push(
+ ...wrap(
+ `Start ${gatedMcpServers.length} MCP ${
+ gatedMcpServers.length === 1 ? 'server' : 'servers'
+ } automatically`,
+ 1,
+ width,
+ 'warning',
+ ),
+ );
+ const origins = [...new Set(gatedMcpServers.map((server) => server.origin))];
+ lines.push(
+ ...wrap(
+ `Config: ${origins.map((path) => relativize(this.opts.workDir, path)).join(', ')}`,
+ 3,
+ width,
+ 'textMuted',
+ ),
+ '',
+ );
+ }
+ if (gatedAdditionalDirs.length > 0) {
+ lines.push(
+ ...wrap(
+ `Access ${gatedAdditionalDirs.length} ${
+ gatedAdditionalDirs.length === 1 ? 'folder' : 'folders'
+ } outside this project`,
+ 1,
+ width,
+ 'warning',
+ ),
+ );
+ if (additionalDirSources.length > 0) {
+ lines.push(
+ ...wrap(
+ `Config: ${additionalDirSources
+ .map((path) => relativize(this.opts.workDir, path))
+ .join(', ')}`,
+ 3,
+ width,
+ 'textMuted',
+ ),
+ );
}
lines.push('');
}
-
- lines.push(rule);
- return lines.map((line) => truncateToWidth(line, width));
+ if (instructionSources.paths.length > 0) {
+ const hasInstructions =
+ instructionSources.agentsMdPaths.length > 0 || instructionSources.skills.length > 0;
+ const subject = hasInstructions
+ ? instructionSources.agentProfiles.length > 0
+ ? 'instructions and agent profiles'
+ : 'instructions'
+ : 'agent profiles';
+ lines.push(...wrap(`Load project ${subject}`, 1, width, 'text'));
+ lines.push(
+ ...wrap(
+ `Check: ${instructionSources.paths.map((path) => relativize(this.opts.workDir, path)).join(' · ')}`,
+ 3,
+ width,
+ 'textMuted',
+ ),
+ '',
+ );
+ }
+ for (const warning of warnings) lines.push(...wrap(warning, 1, width, 'warning'));
+ if (lines.length === 0)
+ lines.push(
+ ...wrap('No project integrations or instructions to activate.', 1, width, 'textMuted'),
+ '',
+ );
+ return lines;
}
}
-function formatMcpTarget(server: WorkspaceTrustMcpServerInfo): string {
- if (server.transport === 'stdio') {
- const args = server.args === undefined ? '' : ` args=${JSON.stringify(server.args)}`;
- const cwd = server.cwd === undefined ? '' : ` cwd=${server.cwd}`;
- return sanitizeForDisplay(`${server.name} (stdio): command=${server.command ?? ''}${args}${cwd}`);
- }
- return sanitizeForDisplay(`${server.name} (${server.transport}): url=${server.url ?? ''}`);
+function wrap(text: string, indent: number, width: number, color: ColorToken): string[] {
+ return wrapTextWithAnsi(sanitizeForDisplay(text), Math.max(1, width - indent)).map(
+ (line) => `${' '.repeat(indent)}${currentTheme.fg(color, line)}`,
+ );
+}
+
+function relativize(workDir: string, path: string): string {
+ const normalizedDir = workDir.replaceAll('\\', '/');
+ const normalizedPath = path.replaceAll('\\', '/');
+ const prefix = normalizedDir.endsWith('/') ? normalizedDir : `${normalizedDir}/`;
+ return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
-/**
- * Drops C0/C1 control characters (including ESC) from workspace-supplied text:
- * the trust prompt renders before the workspace is trusted, so a planted
- * `.mcp.json` must not inject terminal control sequences into it.
- */
function sanitizeForDisplay(value: string): string {
let result = '';
for (const char of value) {
diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts
index a2f44c533..62b5ada8a 100644
--- a/apps/pythinker-code/src/tui/pythinker-tui.ts
+++ b/apps/pythinker-code/src/tui/pythinker-tui.ts
@@ -3878,7 +3878,14 @@ export class PythinkerTUI {
try {
info = await this.harness.getWorkspaceTrustInfo(workDir);
} catch {
- info = { trusted: false, gatedMcpServers: [] };
+ info = {
+ trusted: false,
+ gatedMcpServers: [],
+ gatedAdditionalDirs: [],
+ additionalDirSources: [],
+ warnings: ['Could not inspect project settings.'],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
+ };
}
if (info.trusted) {
return false;
@@ -3889,7 +3896,9 @@ export class PythinkerTUI {
this.mountEditorReplacement(
new TrustPromptComponent({
workDir,
- gatedMcpServers: info.gatedMcpServers,
+ info,
+ getAvailableRows: () =>
+ this.state.terminal.rows - (this.state.ui instanceof TuiAltScreen ? 1 : 0),
onSelect: (c) => {
resolve(c);
},
diff --git a/apps/pythinker-code/src/utils/git/git-args.ts b/apps/pythinker-code/src/utils/git/git-args.ts
deleted file mode 100644
index fbcec0e07..000000000
--- a/apps/pythinker-code/src/utils/git/git-args.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
-
-export const GIT_CONFIG_ARGS: readonly string[] = [
- '-c',
- 'core.fsmonitor=false',
- '-c',
- `core.hooksPath=${NULL_DEVICE}`,
- '-c',
- 'commit.gpgSign=false',
- '-c',
- 'log.showSignature=false',
- '-c',
- 'merge.verifySignatures=false',
- '-c',
- 'core.editor=',
- '-c',
- 'gpg.program=',
- '-c',
- 'submodule.recurse=false',
-];
-
-export const GIT_DIFF_ARGS: readonly string[] = ['--no-ext-diff', '--no-textconv'];
diff --git a/apps/pythinker-code/src/utils/git/git-status.ts b/apps/pythinker-code/src/utils/git/git-status.ts
index 8ca6fc9f6..833418305 100644
--- a/apps/pythinker-code/src/utils/git/git-status.ts
+++ b/apps/pythinker-code/src/utils/git/git-status.ts
@@ -9,7 +9,6 @@
import { execFile, spawnSync } from 'node:child_process';
-import { GIT_CONFIG_ARGS, GIT_DIFF_ARGS } from '#/utils/git/git-args';
import { resolveCommandPath } from '#/utils/process/resolve-command';
const BRANCH_TTL_MS = 5_000;
@@ -98,18 +97,18 @@ export function createGitStatusCache(
if (repoDetected && !isRepo) return null;
if (!repoDetected) {
repoDetected = true;
- isRepo = detectGitRepo(git, workDir, GIT_CONFIG_ARGS);
+ isRepo = detectGitRepo(git, workDir);
}
if (!isRepo) return null;
const now = Date.now();
if (now - branch.fetchedAt >= BRANCH_TTL_MS) {
- branch = { value: readBranch(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now };
+ branch = { value: readBranch(git, workDir), fetchedAt: now };
}
if (branch.value === null) return null;
if (now - status.fetchedAt >= STATUS_TTL_MS) {
- status = { ...readStatus(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now };
+ status = { ...readStatus(git, workDir), fetchedAt: now };
}
refreshPullRequestIfNeeded(branch.value, now);
@@ -156,32 +155,24 @@ export function createGitStatusCache(
}
}
-function detectGitRepo(git: string, workDir: string, configArgs: readonly string[]): boolean {
+function detectGitRepo(git: string, workDir: string): boolean {
try {
- const result = spawnSync(
- git,
- [...configArgs, '-C', workDir, 'rev-parse', '--is-inside-work-tree'],
- {
- encoding: 'utf8',
- timeout: SPAWN_TIMEOUT_MS,
- },
- );
+ const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], {
+ encoding: 'utf8',
+ timeout: SPAWN_TIMEOUT_MS,
+ });
return result.status === 0 && result.stdout.trim() === 'true';
} catch {
return false;
}
}
-function readBranch(git: string, workDir: string, configArgs: readonly string[]): string | null {
+function readBranch(git: string, workDir: string): string | null {
try {
- const result = spawnSync(
- git,
- [...configArgs, '-C', workDir, 'branch', '--show-current'],
- {
- encoding: 'utf8',
- timeout: SPAWN_TIMEOUT_MS,
- },
- );
+ const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], {
+ encoding: 'utf8',
+ timeout: SPAWN_TIMEOUT_MS,
+ });
if (result.status !== 0) return null;
const name = result.stdout.trim();
return name.length > 0 ? name : null;
@@ -193,7 +184,6 @@ function readBranch(git: string, workDir: string, configArgs: readonly string[])
function readStatus(
git: string,
workDir: string,
- configArgs: readonly string[],
): {
dirty: boolean;
ahead: number;
@@ -202,15 +192,11 @@ function readStatus(
diffDeleted: number;
} {
try {
- const result = spawnSync(
- git,
- [...configArgs, '-C', workDir, 'status', '--porcelain', '-b'],
- {
- encoding: 'utf8',
- timeout: SPAWN_TIMEOUT_MS,
- maxBuffer: 4 * 1024 * 1024,
- },
- );
+ const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], {
+ encoding: 'utf8',
+ timeout: SPAWN_TIMEOUT_MS,
+ maxBuffer: 4 * 1024 * 1024,
+ });
if (result.status !== 0) {
return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 };
}
@@ -229,7 +215,7 @@ function readStatus(
dirty = true;
}
}
- const diff = dirty ? readDiffStats(git, workDir, configArgs) : { added: 0, deleted: 0 };
+ const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 };
return {
dirty,
ahead,
@@ -242,30 +228,13 @@ function readStatus(
}
}
-function readDiffStats(
- git: string,
- workDir: string,
- configArgs: readonly string[],
-): { added: number; deleted: number } {
+function readDiffStats(git: string, workDir: string): { added: number; deleted: number } {
try {
- const result = spawnSync(
- git,
- [
- ...configArgs,
- '-C',
- workDir,
- 'diff',
- ...GIT_DIFF_ARGS,
- '--numstat',
- 'HEAD',
- '--',
- ],
- {
- encoding: 'utf8',
- timeout: SPAWN_TIMEOUT_MS,
- maxBuffer: 4 * 1024 * 1024,
- },
- );
+ const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], {
+ encoding: 'utf8',
+ timeout: SPAWN_TIMEOUT_MS,
+ maxBuffer: 4 * 1024 * 1024,
+ });
if (result.status !== 0) return { added: 0, deleted: 0 };
let added = 0;
diff --git a/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts
index d975bdcd2..5d4169055 100644
--- a/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts
+++ b/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts
@@ -1,116 +1,193 @@
import { describe, expect, it, vi } from 'vitest';
-
-import type { WorkspaceTrustMcpServerInfo } from '@pymodel/pythinker-code-sdk';
-
+import type { WorkspaceTrustInfo } from '@pymodel/pythinker-code-sdk';
import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt';
-const ANSI_SGR = /\[[0-9;]*m/g;
-
-function strip(text: string): string {
- return text.replaceAll(ANSI_SGR, '');
+function info(overrides: Partial = {}): WorkspaceTrustInfo {
+ return {
+ trusted: false,
+ gatedMcpServers: [],
+ gatedAdditionalDirs: [],
+ additionalDirSources: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
+ warnings: [],
+ ...overrides,
+ };
}
-
-function renderLines(gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[] = []): string[] {
- const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers,
- onSelect: vi.fn(),
+function render(prompt: TrustPromptComponent, width = 80): string[] {
+ return prompt.render(width).map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, ''));
+}
+const workDir = '/tmp/example-project';
+function mixedInfo(): WorkspaceTrustInfo {
+ return info({
+ gatedMcpServers: [
+ {
+ name: 'github',
+ transport: 'stdio',
+ command: 'npx',
+ args: ['--private-argument'],
+ origin: `${workDir}/.mcp.json`,
+ },
+ {
+ name: 'docs',
+ transport: 'http',
+ url: 'https://example.test/private',
+ origin: `${workDir}/.pythinker-code/mcp.json`,
+ },
+ ],
+ gatedAdditionalDirs: ['/tmp/shared-assets', '/Users/example/Documents'],
+ additionalDirSources: [`${workDir}/.pythinker-code/local.toml`],
+ instructionSources: {
+ agentsMdPaths: [`${workDir}/AGENTS.md`],
+ skills: ['lint-fix', 'deploy'],
+ agentProfiles: ['reviewer'],
+ paths: [
+ `${workDir}/AGENTS.md`,
+ `${workDir}/.pythinker-code/skills/`,
+ `${workDir}/.pythinker-code/agents/`,
+ ],
+ },
});
- return prompt.render(100).map(strip);
}
describe('TrustPromptComponent', () => {
- it('renders the header vocabulary and the workspace path', () => {
- const lines = renderLines();
- const titleIdx = lines.findIndex((l) => l.includes('Trust this folder?'));
- expect(titleIdx).toBeGreaterThanOrEqual(0);
- const hint = lines[titleIdx + 1];
- expect(hint).toContain('↑↓ navigate');
- expect(hint).toContain('Enter select');
- expect(hint).toContain('Esc exit');
- expect(lines.some((l) => l.includes('/tmp/demo-workspace'))).toBe(true);
- });
-
- it('lists the gated project MCP servers when present', () => {
- const lines = renderLines([
- { name: 'nested-server', transport: 'stdio', command: 'nested-cmd', args: ['--safe'], cwd: '/tmp' },
- { name: 'root-server', transport: 'http', url: 'https://example.test/mcp' },
- ]);
- expect(lines.some((l) => l.includes('Project MCP targets'))).toBe(true);
- expect(lines.some((l) => l.includes('nested-server (stdio): command=nested-cmd'))).toBe(true);
- expect(lines.some((l) => l.includes('args=["--safe"] cwd=/tmp'))).toBe(true);
- expect(lines.some((l) => l.includes('root-server (http): url=https://example.test/mcp'))).toBe(true);
- expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false);
- });
-
- it('strips terminal control characters from workspace-supplied MCP targets', () => {
- const lines = renderLines([
- { name: 'evil', transport: 'stdio', command: 'cmd\u001B[2J\u0007evil' },
- { name: 'multi\nline', transport: 'http', url: 'https://example.test/\u001B]8;;https://evil.test\u0007' },
- ]);
+ it('fits typical consequences and actual source paths on an 80x24 terminal', () => {
+ const prompt = new TrustPromptComponent({
+ workDir,
+ info: mixedInfo(),
+ getAvailableRows: () => 23,
+ onSelect: vi.fn(),
+ });
+ const lines = render(prompt);
const text = lines.join('\n');
- // ESC and BEL are dropped, defusing the sequences into harmless literal text.
- expect(text).toContain('evil (stdio): command=cmd[2Jevil');
- expect(text).toContain('multiline (http): url=https://example.test/]8;;https://evil.test');
- expect(text).not.toContain('\u001B]8;;https://evil.test');
+ expect(lines.length).toBeLessThanOrEqual(23);
+ for (const label of [
+ 'Start 2 MCP servers automatically',
+ 'Config: .mcp.json, .pythinker-code/mcp.json',
+ 'Access 2 folders outside this project',
+ 'Config: .pythinker-code/local.toml',
+ 'AGENTS.md',
+ '.pythinker-code/skills',
+ '.pythinker-code/agents/',
+ 'Check:',
+ 'future project config changes',
+ 'Trust and continue',
+ ])
+ expect(text).toContain(label);
+ for (const hidden of [
+ 'page',
+ '--private-argument',
+ 'PRIVATE_KEY',
+ 'https://example.test/private',
+ 'subagents',
+ ])
+ expect(text).not.toContain(hidden);
});
-
- it('defaults to Trust this folder', () => {
- const onSelect = vi.fn();
- const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers: [],
- onSelect,
+ it('distinguishes empty activation from unreadable configuration', () => {
+ const empty = new TrustPromptComponent({ workDir, info: info(), onSelect: vi.fn() });
+ expect(render(empty).join('\n')).toContain(
+ 'No project integrations or instructions to activate.',
+ );
+ const failed = new TrustPromptComponent({
+ workDir,
+ info: info({ warnings: ['Could not inspect MCP configuration.'] }),
+ onSelect: vi.fn(),
});
- prompt.handleInput('\r');
- expect(onSelect).toHaveBeenCalledWith('trust');
+ const text = render(failed).join('\n');
+ expect(text).toContain('Could not inspect MCP configuration.');
+ expect(text).not.toContain('No project integrations');
+ expect(text).not.toContain('No project-level config');
+ expect(text).toContain('future project config changes');
});
-
- it('stays on trust when moving up past the top', () => {
- const onSelect = vi.fn();
+ it('pages through MCP sources with fixed choices and persistent trust copy', () => {
+ const paths = Array.from({ length: 8 }, (_, i) => `/outside/directory-${i}`);
+ const origins = Array.from({ length: 12 }, (_, i) => `/outside/source-${i}/mcp.json`);
+ let rows = 23;
const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers: [],
- onSelect,
+ workDir,
+ info: info({
+ gatedMcpServers: origins.map((origin, i) => ({
+ name: `server-${i}`,
+ transport: 'stdio',
+ origin,
+ })),
+ gatedAdditionalDirs: paths,
+ additionalDirSources: [`${workDir}/.pythinker-code/local.toml`],
+ }),
+ getAvailableRows: () => rows,
+ onSelect: vi.fn(),
});
- prompt.handleInput('\u001B[A');
- prompt.handleInput('\r');
- expect(onSelect).toHaveBeenCalledWith('trust');
+ const pages: string[] = [];
+ for (let i = 0; i < 30; i += 1) {
+ const lines = render(prompt, 60);
+ const text = lines.join('\n');
+ if (pages.includes(text)) break;
+ expect(lines.length).toBeLessThanOrEqual(rows);
+ expect(text).toContain('Trust and continue');
+ expect(text).toContain('Exit');
+ expect(text.replaceAll(/\s+/g, ' ')).toContain('future project config');
+ pages.push(text);
+ prompt.handleInput('\u001B[C');
+ }
+ expect(pages.length).toBeGreaterThan(1);
+ for (const path of origins) expect(pages.join('\n')).toContain(path);
+ expect(pages.join('\n')).not.toContain('/outside/directory-');
+ expect(pages.join('\n')).not.toContain('more');
+ prompt.handleInput('\u001B[D');
+ expect(render(prompt, 60).join('\n')).toBe(pages.at(-2));
+ prompt.handleInput('\u001B[6~');
+ expect(render(prompt, 60).join('\n')).toBe(pages.at(-1));
+ rows = 60;
+ expect(render(prompt, 60).join('\n')).not.toContain('page');
});
-
- it('selects distrust after moving the cursor down', () => {
- const onSelect = vi.fn();
+ it('cleans control characters and retains full long source paths across wrapping', () => {
+ const longPath = `/outside/${'x'.repeat(100)}/mcp.json`;
const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers: [],
- onSelect,
+ workDir: '/tmp/\u001B[2Jproject',
+ info: info({
+ gatedMcpServers: [{ name: 'ignored', transport: 'http', origin: longPath }],
+ gatedAdditionalDirs: ['/outside/\u001B[2Jdirectory\u0007'],
+ additionalDirSources: ['/outside/\u001B[2Jconfig\u0007'],
+ }),
+ onSelect: vi.fn(),
});
- prompt.handleInput('\u001B[B');
- prompt.handleInput('\r');
- expect(onSelect).toHaveBeenCalledWith('distrust');
+ const text = render(prompt).join('\n');
+ expect(text).not.toContain('\u001B');
+ expect(text).not.toContain('\u0007');
+ expect(text).toContain('/outside/[2Jconfig');
+ expect(text.replaceAll(/\s/g, '')).toContain(longPath);
});
-
- it('requires Enter to confirm trust and ignores Space', () => {
+ it('pauses confirmation in a tiny terminal and restores it after resizing', () => {
+ let rows = 4;
const onSelect = vi.fn();
const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers: [],
+ workDir,
+ info: mixedInfo(),
+ getAvailableRows: () => rows,
onSelect,
});
- prompt.handleInput(' ');
+ expect(render(prompt).join('\n')).toContain('Enlarge terminal');
+ prompt.handleInput('\r');
expect(onSelect).not.toHaveBeenCalled();
+ prompt.handleInput('\u001B');
+ expect(onSelect).toHaveBeenCalledWith('distrust');
+ onSelect.mockClear();
+ rows = 23;
+ render(prompt);
prompt.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith('trust');
});
-
- it('treats Esc as distrust', () => {
- const onSelect = vi.fn();
- const prompt = new TrustPromptComponent({
- workDir: '/tmp/demo-workspace',
- gatedMcpServers: [],
- onSelect,
- });
- prompt.handleInput('\u001B');
- expect(onSelect).toHaveBeenCalledWith('distrust');
+ it('preserves default trust, selection, and escape behavior', () => {
+ for (const { keys, expected } of [
+ { keys: ['\r'], expected: 'trust' },
+ { keys: ['\u001B[A', '\r'], expected: 'trust' },
+ { keys: ['\u001B[B', '\r'], expected: 'distrust' },
+ { keys: ['\u001B'], expected: 'distrust' },
+ ]) {
+ const onSelect = vi.fn();
+ const prompt = new TrustPromptComponent({ workDir, info: info(), onSelect });
+ render(prompt);
+ for (const key of keys) prompt.handleInput(key);
+ expect(onSelect).toHaveBeenCalledWith(expected);
+ }
});
});
diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts
index 7f1df042b..977c14742 100644
--- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts
+++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts
@@ -211,7 +211,7 @@ function makeHarness(session = makeSession(), overrides: Record
track: vi.fn(),
setTelemetryContext: vi.fn(),
getExperimentalFeatures: vi.fn(async () => []),
- getWorkspaceTrustInfo: vi.fn(async () => ({ trusted: true, gatedMcpServers: [] })),
+ getWorkspaceTrustInfo: vi.fn(async () => ({ trusted: true, gatedMcpServers: [], gatedAdditionalDirs: [], additionalDirSources: [], warnings: [], instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] } })),
supportsAtomicSectionReplace: vi.fn(() => false),
auth: {
status: vi.fn(async () => ({ providers: [] })),
@@ -2435,6 +2435,8 @@ describe('PythinkerTUI startup', () => {
const getWorkspaceTrustInfo = vi.fn(async () => ({
trusted: true,
gatedMcpServers: [],
+ gatedAdditionalDirs: [], additionalDirSources: [], warnings: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
}));
const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo });
const driver = makeDriver(harness, {
@@ -2464,6 +2466,8 @@ describe('PythinkerTUI startup', () => {
const getWorkspaceTrustInfo = vi.fn(async () => ({
trusted: false,
gatedMcpServers: [],
+ gatedAdditionalDirs: [], additionalDirSources: [], warnings: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
}));
const trustWorkspace = vi.fn(async () => {});
const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace });
@@ -2536,6 +2540,8 @@ describe('PythinkerTUI startup', () => {
const getWorkspaceTrustInfo = vi.fn(async () => ({
trusted: false,
gatedMcpServers: [],
+ gatedAdditionalDirs: [], additionalDirSources: [], warnings: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
}));
const trustWorkspace = vi.fn(async (): Promise => {
throw new Error('disk full');
diff --git a/apps/pythinker-code/test/tui/signal-handlers.test.ts b/apps/pythinker-code/test/tui/signal-handlers.test.ts
index bcf65e0d2..583bdb93e 100644
--- a/apps/pythinker-code/test/tui/signal-handlers.test.ts
+++ b/apps/pythinker-code/test/tui/signal-handlers.test.ts
@@ -47,7 +47,12 @@ function makeHarness() {
close: vi.fn(async () => {}),
track: vi.fn(),
setTelemetryContext: vi.fn(),
- getWorkspaceTrustInfo: vi.fn(async () => ({ trusted: true, gatedMcpServers: [] })),
+ getWorkspaceTrustInfo: vi.fn(async () => ({
+ trusted: true,
+ gatedMcpServers: [],
+ gatedAdditionalDirs: [], additionalDirSources: [], warnings: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
+ })),
};
}
diff --git a/apps/pythinker-code/test/utils/git/git-status.test.ts b/apps/pythinker-code/test/utils/git/git-status.test.ts
index 615ea9398..d74b82f41 100644
--- a/apps/pythinker-code/test/utils/git/git-status.test.ts
+++ b/apps/pythinker-code/test/utils/git/git-status.test.ts
@@ -216,49 +216,6 @@ describe('git status cache', () => {
expect(mocks.execFile).not.toHaveBeenCalled();
});
- it('disables repo-local command config on every git invocation', async () => {
- mocks.execFile.mockImplementation(
- (
- _cmd: string,
- _args: string[],
- _options: unknown,
- callback: (error: Error | null, stdout: string, stderr: string) => void,
- ) => {
- callback(new Error('no pull request'), '', '');
- },
- );
- mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => {
- if (args.includes('rev-parse')) return { status: 0, stdout: 'true\n' };
- if (args.includes('branch')) return { status: 0, stdout: 'main\n' };
- if (args.includes('status')) return { status: 0, stdout: '## main...origin/main\n M a.ts\n' };
- if (args.includes('diff')) return { status: 0, stdout: '1\t1\ta.ts\n' };
- return { status: 1, stdout: '' };
- });
-
- const cache = createGitStatusCache('/tmp/repo');
- expect(cache.getStatus()).not.toBeNull();
- await Promise.resolve();
-
- const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
- expect(mocks.spawnSync).toHaveBeenCalledTimes(4);
- for (const call of mocks.spawnSync.mock.calls) {
- const args = call[1] as string[];
- expect(args.slice(0, 4)).toEqual([
- '-c',
- 'core.fsmonitor=false',
- '-c',
- `core.hooksPath=${nullDevice}`,
- ]);
- }
- const diffCall = mocks.spawnSync.mock.calls.find((call) =>
- (call[1] as string[]).includes('diff'),
- );
- expect(diffCall).toBeDefined();
- const diffArgs = diffCall![1] as string[];
- expect(diffArgs).toContain('--no-ext-diff');
- expect(diffArgs).toContain('--no-textconv');
- });
-
it('spawns git and gh through their resolved absolute paths', async () => {
mocks.execFile.mockImplementation(
(
diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts
index c2364c0e8..658169e99 100644
--- a/apps/vis/server/src/lib/agent-record-types.ts
+++ b/apps/vis/server/src/lib/agent-record-types.ts
@@ -38,7 +38,7 @@ import type {
FileHistoryTracked,
GoalClear,
GoalCreate,
- GoalForked,
+ Forked,
GoalUpdate,
InteractionRequestEvent,
InteractionResolvedEvent,
@@ -169,7 +169,7 @@ export type AgentRecord =
| WireRecordOf<'dynamic_workflow_mode.exit', DynamicWorkflowModeExit>
| WireRecordOf<'file_history.checkpoint', FileHistoryCheckpointed>
| WireRecordOf<'file_history.tracked', FileHistoryTracked>
- | WireRecordOf<'forked', GoalForked>
+ | WireRecordOf<'forked', Forked>
| WireRecordOf<'full_compaction.begin', FullCompactionBegin>
| WireRecordOf<'full_compaction.cancel', FullCompactionCancel>
| WireRecordOf<'full_compaction.complete', FullCompactionComplete>
diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md
index 91ebd573b..98163ae81 100644
--- a/docs/configuration/config-files.md
+++ b/docs/configuration/config-files.md
@@ -474,11 +474,11 @@ Both values must be positive integers. A call's `max_chars` overrides the defaul
## `watch`
-`watch` controls filesystem watchers that reload local.toml, AGENTS.md, skills, MCP config, and `config.toml` itself. It defaults to off. Set `enabled` to `true` to attach watchers; with watchers off, changing the file later will not be picked up until restart.
+`watch` controls filesystem watchers that reload local.toml, AGENTS.md, skills, MCP config, and `config.toml` itself. It defaults to on. Set `enabled` to `false` to start with no watchers; changing the file later will not be picked up until restart.
| Field | Type | Default | Description |
| --- | --- | --- | --- |
-| `enabled` | `boolean` | `false` | Attach filesystem watchers; `false` disables every `watch()` for the process |
+| `enabled` | `boolean` | `true` | Attach filesystem watchers; `false` disables every `watch()` for the process |
`enabled` can be overridden by the `PYTHINKER_CODE_WATCH` environment variable, which takes higher priority than `config.toml`.
diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md
index bebad2470..3294304d6 100644
--- a/docs/configuration/env-vars.md
+++ b/docs/configuration/env-vars.md
@@ -146,13 +146,14 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `PYTHINKER_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentDynamicWorkflow` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_EXPERIMENTAL_TOOL_SELECT` | Experimental on-demand tool loading: tools of MCP servers marked `deferred: true` stay out of the top-level tool list and are loaded via `select_tools`; also requires the model to declare the `dynamically_loaded_tools` capability — see [MCP](../customization/mcp.md#loading-tools-on-demand) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_EXPERIMENTAL_TOWER` | Enable the experimental [`/tower`](../reference/slash-commands.md#modes--run-control) command for workspace-wide subagent coordination; the master `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
-| `PYTHINKER_CODE_WATCH` | Attach filesystem watchers that reload config and workspace files; higher priority than `[watch] enabled` (default `false`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
+| `PYTHINKER_CODE_WATCH` | Attach filesystem watchers that reload config and workspace files; higher priority than `[watch] enabled` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_SEARCH_WORKER` | Run the global search index in a dedicated worker thread; takes higher priority than `[database] search` in `config.toml` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_PERSISTENCE_MINIDB_READMODEL` | Use the minidb-backed read model for session indexing; takes higher priority than `[database] base` in `config.toml` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored |
| `PYTHINKER_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored |
| `PYTHINKER_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored |
| `PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP` | Maximum total attempts for a failing step (including the initial attempt); takes higher priority than `[loop_control] max_attempts_per_step` in `config.toml` (default `10`). The deprecated `PYTHINKER_LOOP_MAX_RETRIES_PER_STEP` is still honored with a warning when this variable is unset | Non-negative integer; invalid values are ignored |
+| `PYTHINKER_CODE_TRUST_WORKSPACE` | Mark the current workspace as trusted, equivalent to choosing "Trust this folder" at the interactive trust prompt; takes effect per process and does not write a persistent trust record | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_INFINITE_RETRY` | Retry every failed LLM request indefinitely — turn steps and background operations such as compaction alike — instead of failing the task; waits use exponential backoff (capped at 32 s) and honor the server's `Retry-After` header, and aborting still cancels immediately. Intended for long-running unattended evaluations against endpoints that may fail temporarily | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_TOKEN_COUNTING_STRATEGY` | Which context token count is reported externally (the context-size display); takes higher priority than `[token_counting] strategy` in `config.toml` (default `measured+estimated`) | `measured+estimated`, `measured`, `estimated` (case-insensitive); invalid values are ignored |
| `PYTHINKER_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.pymodel_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored |
diff --git a/docs/customization/mcp.md b/docs/customization/mcp.md
index a1c326003..4c15e5891 100644
--- a/docs/customization/mcp.md
+++ b/docs/customization/mcp.md
@@ -31,6 +31,8 @@ Deleting a server from the configuration does not interrupt open sessions: the s
When Pythinker Code finds project-level MCP servers in an untrusted folder, it shows each server's transport and launch target in the workspace trust prompt. The prompt defaults to `Trust this folder`; review the listed command and arguments or remote URL before confirming. Trusting the folder enables the project-level MCP servers for that workspace.
+Headless runs (for example `pythinker -p` in CI) cannot show the trust prompt, so project-level MCP servers stay disabled there unless the workspace is already trusted. Set [`PYTHINKER_CODE_TRUST_WORKSPACE`](../configuration/env-vars.md#runtime-switches) to `1` to trust the workspace for that process.
+
Structure of `mcp.json`:
```json
diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts
index d690ec53d..0ff629a4b 100644
--- a/packages/agent-core-v2/docs/wire-manifest.d.ts
+++ b/packages/agent-core-v2/docs/wire-manifest.d.ts
@@ -38,7 +38,7 @@
// dynamic_workflow_mode.exit contextMemory, dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts
// file_history.checkpoint fileHistory src/features/fileHistory/fileHistoryOps.ts
// file_history.tracked fileHistory src/features/fileHistory/fileHistoryOps.ts
-// forked (none) src/features/goal/goalOps.ts
+// forked (none) src/session/agentLifecycle/forked.ts
// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts
// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts
// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts
@@ -268,7 +268,7 @@ interface FileHistoryTrackedPayload {
/**
* states: (none)
- * owner: src/features/goal/goalOps.ts
+ * owner: src/session/agentLifecycle/forked.ts
*/
interface ForkedPayload {
_name: 'forked';
diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts
index acea65627..9083d4b62 100644
--- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts
+++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts
@@ -2,6 +2,7 @@ import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
+import { isUndoAnchor } from '#/agent/contextMemory/conversationTime';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import { TurnEnded } from '#/agent/loop/turnOps';
@@ -35,10 +36,12 @@ export class AgentInterruptionReminderService
this._register(
eventBus.subscribe(TurnEnded, (event) => {
if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return;
- const origin = lastComparableMessage(this.context.get())?.origin;
+ const history = this.context.get();
+ const origin = lastComparableMessage(history)?.origin;
if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return;
this.reminder.notify(INTERRUPTION_REMINDER, {
variant: INTERRUPTION_REMINDER_VARIANT,
+ ownerPromptId: history.findLast(isUndoAnchor)?.id,
});
}),
);
diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
index fcef80b0c..178248543 100644
--- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
+++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
@@ -660,26 +660,33 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
const turnConfig = this.resolveTurnConfig(overrides.source);
const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext();
const baseParams = turnConfig?.params ?? this.profile.resolveRequestParams();
+ const requester = this.modelCatalog.getRequester(resolved.modelAlias);
+ const maxCompletionTokensCap =
+ this.config.get('modelOverrides')?.maxCompletionTokens;
+ const usedContextTokens =
+ overrides.messages === undefined
+ ? this.tokenCounting.get(this.scopeContext.agentContext).size
+ : undefined;
const budgetParams = completionBudgetParams({
budget: resolveCompletionBudget({
maxOutputSize: overrides.maxOutputSize ?? resolved.maxOutputSize,
- reservedContextSize: resolved.reservedContextSize,
- maxCompletionTokensCap:
- this.config.get('modelOverrides')?.maxCompletionTokens,
+ maxCompletionTokensCap,
}),
capability: resolved.modelCapabilities,
- usedContextTokens:
- overrides.messages === undefined
- ? this.tokenCounting.get(this.scopeContext.agentContext).measured
- : undefined,
+ usedContextTokens,
});
- const requester = this.modelCatalog.getRequester(resolved.modelAlias);
+ const optedOut = maxCompletionTokensCap !== undefined && maxCompletionTokensCap <= 0;
const messages = overrides.messages ?? this.context.get();
return {
requester,
model: requester.model,
- params: { ...baseParams, ...budgetParams },
+ params: {
+ ...baseParams,
+ ...budgetParams,
+ ...(usedContextTokens === undefined ? {} : { usedContextTokens }),
+ ...(optedOut ? { maxCompletionTokens: maxCompletionTokensCap } : {}),
+ },
modelAlias: resolved.modelAlias,
thinkingEffort: resolved.thinkingLevel,
systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(),
diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts
index 5aaca3f17..a5f40575a 100644
--- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts
+++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts
@@ -14,6 +14,7 @@ import {
MAIN_AGENT_ID,
} from '#/session/agentLifecycle/agentLifecycle';
import { IAgentStateService } from '#/agent/state/agentState';
+import { AgentStatusUpdated } from '#/agent/usage/usageEvents';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode';
import {
@@ -58,6 +59,9 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss
void this.dispatcher.dispatch(
new PermissionSetMode({ agentId: this.scopeContext.agentId, mode }),
);
+ void this.dispatcher.dispatch(
+ new AgentStatusUpdated({ agentId: this.scopeContext.agentId, permission: mode }),
+ );
if (changed) this._onDidChangeMode.fire({ mode, previousMode });
}
diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
index bc12b9a92..b48d71cf7 100644
--- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
+++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
@@ -1,5 +1,5 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
-import { isProjectLocalConfigPath, isWithinWorkspace } from '#/tool/path-access';
+import { isWithinWorkspace } from '#/tool/path-access';
import { IGitService } from '#/app/git/git';
import type { IGitService as GitService } from '#/app/git/git';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
@@ -35,9 +35,6 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
const writeAccesses = writeFileAccesses(context);
if (writeAccesses.length === 0) return undefined;
- if (writeAccesses.some((access) => isProjectLocalConfigPath(access.path))) {
- return undefined;
- }
if (
!writeAccesses.every((access) =>
isWithinWorkspace(
diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
index 7181f52b5..a3ef1f35c 100644
--- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts
@@ -2,7 +2,6 @@ import {
resolvePathAccessPath,
type WorkspaceConfig,
} from '#/tool/path-access';
-import { checkRealPathWriteTarget } from '#/tool/realpath-access';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match';
import { IFileEditService } from '#/app/edit/fileEdit';
@@ -78,10 +77,6 @@ export class EditTool implements IEditTool {
if (lease.runtime.identity.generation !== inspected.identity.generation) {
return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' };
}
- const accessError = await checkRealPathWriteTarget(lease.runtime.fs!, path, workspace, env.pathClass);
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
return await this.execution(args, path, lease.runtime.fs!);
} finally {
lease.dispose();
diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts
index 16c5e3c66..7be6dda07 100644
--- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts
@@ -31,7 +31,6 @@ import {
SENSITIVE_DOT_VARIANT_SUFFIXES,
type WorkspaceConfig,
} from '#/tool/path-access';
-import { checkRealPathWithinWorkspace } from '#/tool/realpath-access';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match';
import globDescription from './glob.md?raw';
@@ -127,10 +126,6 @@ export class GlobTool implements IGlobTool {
if (lease.runtime.identity.generation !== inspected.identity.generation) {
return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' };
}
- const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, searchRoots[0]!, workspace, env.pathClass, { checkSensitive: false });
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
return await this.execution(
lease.runtime.fs!,
lease.runtime.process!,
diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts
index f85f8b4c4..174817c6e 100644
--- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts
@@ -23,7 +23,6 @@ import {
SENSITIVE_DOT_VARIANT_SUFFIXES,
type WorkspaceConfig,
} from '#/tool/path-access';
-import { checkRealPathWithinWorkspace } from '#/tool/realpath-access';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match';
import {
@@ -113,10 +112,6 @@ export class GrepTool implements IGrepTool {
if (lease.runtime.identity.generation !== inspected.identity.generation) {
return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' };
}
- const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, searchPaths[0]!, workspace, env.pathClass, { checkSensitive: false });
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
return await this.execution(lease.runtime.process!, lease.runtime.fs!, env, workspace, args, signal, searchPaths);
} finally {
lease.dispose();
diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts
index 4c33d7c92..ca6562dc8 100644
--- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts
@@ -22,7 +22,6 @@ import {
resolvePathAccessPath,
type WorkspaceConfig,
} from '#/tool/path-access';
-import { checkRealPathWithinWorkspace } from '#/tool/realpath-access';
import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match';
@@ -239,10 +238,6 @@ export class ReadTool implements IReadTool {
if (lease.runtime.identity.generation !== inspected.identity.generation) {
return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' };
}
- const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, path, workspace, env.pathClass);
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
const eventLog = this.resultTruncation.isWireJournalPath(path);
const result = await this.execution(runtimeFileSource(lease.runtime.fs!, path), args, eventLog);
return { ...result, spillExempt: true };
diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts
index dd1725007..2423b37ce 100644
--- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts
@@ -17,7 +17,6 @@ import {
sensitiveTargetError,
type WorkspaceConfig,
} from '#/tool/path-access';
-import { checkRealPathWriteTarget } from '#/tool/realpath-access';
import { toInputJsonSchema } from '#/tool/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match';
import { IWriteTool, WriteInputSchema, type WriteInput } from './write';
@@ -74,10 +73,6 @@ export class WriteTool implements IWriteTool {
}
const denied = await sensitiveTargetError(lease.runtime.fs!, args.path, path);
if (denied !== undefined) return { isError: true, output: denied };
- const accessError = await checkRealPathWriteTarget(lease.runtime.fs!, path, workspace, env.pathClass);
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
return await this.execution(lease.runtime.fs!, args, path);
} finally {
lease.dispose();
diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts
index 49ae9ed09..fff7ef5ba 100644
--- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts
+++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts
@@ -17,7 +17,6 @@ import {
type ToolExecution,
} from '#/tool/toolContract';
import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access';
-import { checkRealPathWithinWorkspace } from '#/tool/realpath-access';
import {
MEDIA_SNIFF_BYTES,
detectFileType,
@@ -261,10 +260,6 @@ export class ReadMediaFileTool implements AgentTool {
if (lease.runtime.identity.generation !== inspected.identity.generation) {
return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' };
}
- const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, path, workspace, env.pathClass);
- if (accessError !== undefined) {
- return { isError: true, output: accessError.message };
- }
return await this.execution(args, runtimeFileSource(lease.runtime.fs!, path), env);
} finally {
lease.dispose();
diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts
index be7a22809..d1e1f2466 100644
--- a/packages/agent-core-v2/src/agent/usage/usageEvents.ts
+++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts
@@ -15,6 +15,7 @@ export interface AgentStatusUpdatedPayload {
thinkingEffort?: string;
maxContextTokens?: number;
contextTokens?: number;
+ permission?: PermissionMode;
}
export class AgentStatusUpdated extends AgentEvent2 {
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
index d56b3dbed..aef8a3a4e 100644
--- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
@@ -1,5 +1,4 @@
import type { ILogger } from '#/_base/log/log';
-import type { IGitService } from '#/app/git/git';
import type { IHostProcessService } from '#/os/interface/hostProcess';
export const DEFAULT_AGENT_PROFILE_NAME = 'agent';
@@ -8,7 +7,6 @@ export interface AgentProfilePromptPrefixContext {
readonly cwd: string;
readonly process: IHostProcessService;
readonly log?: ILogger;
- readonly git?: IGitService;
}
export interface AgentProfileContext {
diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts
index 4c3905a44..76400b0bf 100644
--- a/packages/agent-core-v2/src/app/config/configService.ts
+++ b/packages/agent-core-v2/src/app/config/configService.ts
@@ -669,7 +669,7 @@ export class ConfigService extends Disposable implements IConfigService {
}
private applyWatchEnabled(): void {
- setWatchEnabled(this.get(WATCH_SECTION)?.enabled ?? false);
+ setWatchEnabled(this.get(WATCH_SECTION)?.enabled ?? true);
}
private deliveredValue(domain: string): unknown {
diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts
index 1dc81eff9..c4291df5d 100644
--- a/packages/agent-core-v2/src/app/git/git.ts
+++ b/packages/agent-core-v2/src/app/git/git.ts
@@ -53,24 +53,12 @@ export const fsDiffResponseSchema = z.object({
});
export type FsDiffResponse = z.infer;
-export interface RunGitOptions {
- readonly timeoutMs?: number;
- readonly env?: Record;
-}
-
-export interface RunGitResult {
- readonly exitCode: number;
- readonly stdout: string;
- readonly stderr: string;
-}
-
export interface IGitService {
readonly _serviceBrand: undefined;
status(cwd: string, pathFilter?: ReadonlySet): Promise;
diff(cwd: string, relPath: string, absPath: string): Promise;
findWorkTree(cwd: string): Promise;
- runGit(cwd: string, args: readonly string[], options?: RunGitOptions): Promise;
}
export const IGitService: ServiceIdentifier =
diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts
index 7ff37b028..512b5c4d9 100644
--- a/packages/agent-core-v2/src/app/git/gitService.ts
+++ b/packages/agent-core-v2/src/app/git/gitService.ts
@@ -1,13 +1,6 @@
-import type {
- FsDiffResponse,
- FsGitStatusResponse,
- FsPullRequest,
- RunGitOptions,
- RunGitResult,
-} from './git';
+import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
-import { GIT_DIFF_ARGS, hardenedGitConfigArgs } from '#/app/git/hardening';
import { ErrorCodes, Error2 } from '#/errors';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IRuntimeResolver, IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager';
@@ -18,7 +11,6 @@ import { findGitWorkTree, type GitWorkTree } from './workTree';
const DIFF_MAX_BYTES = 1_048_576;
-const CONFIG_PROBE_TIMEOUT_MS = 5_000;
const PR_SPAWN_TIMEOUT_MS = 5_000;
const PULL_REQUEST_TTL_MS = 60_000;
@@ -55,11 +47,7 @@ export class GitService implements IGitService {
if (dirty) {
const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
if (head.exitCode === 0) {
- const numstat = await this.runCommand(
- 'git',
- ['diff', '--no-color', ...GIT_DIFF_ARGS, '--numstat', 'HEAD', '--'],
- cwd,
- );
+ const numstat = await this.runCommand('git', ['diff', '--no-color', '--numstat', 'HEAD', '--'], cwd);
if (numstat.exitCode === 0) {
const stats = parseNumstat(numstat.stdout);
result.additions = stats.additions;
@@ -91,7 +79,7 @@ export class GitService implements IGitService {
if (untracked || !hasHead) {
const res = await this.runCommand(
'git',
- ['diff', '--no-color', ...GIT_DIFF_ARGS, '--no-index', '--', '/dev/null', relPath],
+ ['diff', '--no-color', '--no-index', '--', '/dev/null', relPath],
cwd,
);
if (res.exitCode !== 0 && res.exitCode !== 1) {
@@ -99,11 +87,7 @@ export class GitService implements IGitService {
}
diffStdout = res.stdout;
} else {
- const res = await this.runCommand(
- 'git',
- ['diff', '--no-color', ...GIT_DIFF_ARGS, 'HEAD', '--', relPath],
- cwd,
- );
+ const res = await this.runCommand('git', ['diff', '--no-color', 'HEAD', '--', relPath], cwd);
if (res.exitCode !== 0) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
@@ -133,30 +117,6 @@ export class GitService implements IGitService {
return findGitWorkTree(this.fs, cwd);
}
- async runGit(
- cwd: string,
- args: readonly string[],
- options: RunGitOptions = {},
- ): Promise {
- try {
- const configArgs = await hardenedGitConfigArgs(cwd, (probeArgs) =>
- this.spawnAndCollect('git', probeArgs, cwd, {
- timeoutMs: CONFIG_PROBE_TIMEOUT_MS,
- }),
- );
- if (configArgs === null) {
- return { exitCode: -1, stdout: '', stderr: 'git config probe failed' };
- }
- return await this.spawnAndCollect('git', [...configArgs, ...args], cwd, options);
- } catch (error) {
- return {
- exitCode: -1,
- stdout: '',
- stderr: error instanceof Error ? error.message : String(error),
- };
- }
- }
-
private async readPullRequest(cwd: string): Promise {
const cached = this.pullRequestCache.get(cwd);
const now = Date.now();
@@ -182,20 +142,8 @@ export class GitService implements IGitService {
cmd: string,
args: readonly string[],
cwd: string,
- options: RunGitOptions = {},
- ): Promise {
- if (cmd === 'git') {
- return this.runGit(cwd, args, options);
- }
- return this.spawnAndCollect(cmd, args, cwd, options);
- }
-
- private async spawnAndCollect(
- cmd: string,
- args: readonly string[],
- cwd: string,
- options: RunGitOptions,
- ): Promise {
+ options: RunOptions = {},
+ ): Promise {
const workspaceId = this.resolveWorkspaceId(cwd);
const lease = this.resolver.acquire({ workspaceId, runtimeId: 'local' }, ['process']);
const spawned = await lease.runtime.process!
@@ -263,6 +211,17 @@ export class GitService implements IGitService {
}
}
+interface RunResult {
+ readonly exitCode: number;
+ readonly stdout: string;
+ readonly stderr: string;
+}
+
+interface RunOptions {
+ readonly timeoutMs?: number;
+ readonly env?: Record;
+}
+
async function collect(stream: AsyncIterable): Promise {
const decoder = new TextDecoder();
let out = '';
diff --git a/packages/agent-core-v2/src/app/git/hardening.ts b/packages/agent-core-v2/src/app/git/hardening.ts
deleted file mode 100644
index 8b3a06722..000000000
--- a/packages/agent-core-v2/src/app/git/hardening.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import { open, readFile, realpath } from 'node:fs/promises';
-import { dirname, isAbsolute, join, normalize, resolve } from 'node:path';
-
-const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
-
-export const GIT_CONFIG_ARGS: readonly string[] = [
- '-c',
- 'core.fsmonitor=false',
- '-c',
- `core.hooksPath=${NULL_DEVICE}`,
- '-c',
- 'commit.gpgSign=false',
- '-c',
- 'log.showSignature=false',
- '-c',
- 'merge.verifySignatures=false',
- '-c',
- 'core.editor=',
- '-c',
- 'gpg.program=',
- '-c',
- 'submodule.recurse=false',
-];
-
-export const GIT_DIFF_ARGS: readonly string[] = ['--no-ext-diff', '--no-textconv'];
-
-export const INCLUDE_SECTION_RE = /^\s*\[\s*include(?:\.|\s|\]|if)/im;
-
-export function parseGitDirPointer(content: string): string | undefined {
- const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content;
- const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim();
- if (line === undefined || !line.startsWith('gitdir:')) return undefined;
- const rawPath = line.slice('gitdir:'.length).trim();
- return rawPath.length > 0 ? rawPath : undefined;
-}
-
-export function resolveConfigPaths(gitDir: string, commondirContent: string | undefined): string[] {
- const configPaths = [join(gitDir, 'config'), join(gitDir, 'config.worktree')];
- const commonDir = commondirContent?.trim();
- if (commonDir !== undefined && commonDir.length > 0) {
- configPaths.push(join(resolve(gitDir, commonDir), 'config'));
- }
- return configPaths;
-}
-
-export function buildDriverOverrides(outputs: readonly string[]): readonly string[] | null {
- const filterDrivers = new Set();
- const mergeDrivers = new Set();
- for (const output of outputs) {
- for (const line of output.split('\n')) {
- const filter = /^filter\.(.+)\.(?:clean|process|smudge)$/.exec(line);
- const filterDriver = filter?.[1];
- if (filterDriver !== undefined) {
- if (filterDriver.includes('=')) return null;
- filterDrivers.add(filterDriver);
- }
- const merge = /^merge\.(.+)\.driver$/.exec(line);
- const mergeDriver = merge?.[1];
- if (mergeDriver !== undefined) {
- if (mergeDriver.includes('=')) return null;
- mergeDrivers.add(mergeDriver);
- }
- }
- }
- const args: string[] = [];
- for (const driver of filterDrivers) {
- args.push(
- '-c',
- `filter.${driver}.clean=`,
- '-c',
- `filter.${driver}.process=`,
- '-c',
- `filter.${driver}.smudge=`,
- );
- }
- for (const driver of mergeDrivers) {
- args.push('-c', `merge.${driver}.driver=`);
- }
- return args;
-}
-
-export function isCoreWorktreeSafe(
- raw: string,
- resolvedGitDir: string,
- workTreeRoot: string,
-): boolean {
- const configured = isAbsolute(raw) ? normalize(raw) : resolve(resolvedGitDir, raw);
- if (process.platform === 'win32') {
- return normalize(configured).toLowerCase() === normalize(workTreeRoot).toLowerCase();
- }
- return normalize(configured) === normalize(workTreeRoot);
-}
-
-export interface GitProbeResult {
- readonly exitCode: number;
- readonly stdout: string;
-}
-
-export type GitProbe = (args: readonly string[]) => Promise;
-
-interface FilterArgsCacheEntry {
- readonly stamp: string | null;
- readonly args: readonly string[];
-}
-
-const filterArgsCache = new Map();
-
-export async function hardenedGitConfigArgs(
- cwd: string,
- probe: GitProbe,
-): Promise {
- const gitDir = await findGitDir(cwd);
- const stamp = await gitConfigStamp(cwd, gitDir);
- const cached = filterArgsCache.get(cwd);
- if (stamp !== null && cached?.stamp === stamp) return cached.args;
- if (!(await coreWorktreeSafe(cwd, probe, gitDir))) return null;
- const filterArgs = await probeFilterArgs(cwd, probe);
- if (filterArgs === null) return null;
- const args = [...GIT_CONFIG_ARGS, ...filterArgs];
- filterArgsCache.set(cwd, { stamp, args });
- return args;
-}
-
-async function coreWorktreeSafe(
- cwd: string,
- probe: GitProbe,
- gitDir: string | null,
-): Promise {
- if (gitDir === null) return true;
- let resolvedGitDir: string;
- let workTreeRoot: string;
- try {
- const realGitPath = await realpath(gitDir);
- workTreeRoot = await realpath(dirname(gitDir));
- const opened = await readGitPath(realGitPath);
- if (opened.directory) {
- resolvedGitDir = realGitPath;
- } else {
- const pointer = parseGitDirPointer(opened.text);
- if (pointer === undefined) return true;
- resolvedGitDir = resolve(dirname(realGitPath), pointer);
- }
- } catch {
- return false;
- }
- const results = await Promise.all(
- ['--local', '--worktree'].map((scope) =>
- probe([
- ...GIT_CONFIG_ARGS,
- '-C',
- cwd,
- 'config',
- scope,
- '--includes',
- '--get',
- 'core.worktree',
- ]).catch(() => null),
- ),
- );
- for (const result of results) {
- if (result === null || result.exitCode < 0) return false;
- if (result.exitCode !== 0) continue;
- const raw = result.stdout.trim();
- if (raw === '' || isCoreWorktreeSafe(raw, resolvedGitDir, workTreeRoot)) continue;
- return false;
- }
- return true;
-}
-
-async function probeFilterArgs(cwd: string, probe: GitProbe): Promise {
- const results = await Promise.all(
- ['--local', '--worktree'].map((scope) =>
- probe([
- ...GIT_CONFIG_ARGS,
- '-C',
- cwd,
- 'config',
- scope,
- '--includes',
- '--get-regexp',
- '--name-only',
- '^(filter|merge)\\.',
- ]).catch(() => null),
- ),
- );
- const outputs: string[] = [];
- for (const result of results) {
- if (result === null || result.exitCode < 0) return null;
- if (result.exitCode !== 0) continue;
- outputs.push(result.stdout);
- }
- return buildDriverOverrides(outputs);
-}
-
-async function gitConfigStamp(cwd: string, found: string | null): Promise {
- try {
- if (found === null) return null;
- let gitDir = found;
- const opened = await readGitPath(gitDir);
- if (!opened.directory) {
- const pointer = parseGitDirPointer(opened.text);
- if (pointer === undefined) return null;
- gitDir = resolve(dirname(found), pointer);
- }
- const commondir = await readFile(join(gitDir, 'commondir'), 'utf8').catch(() => undefined);
- const configPaths = resolveConfigPaths(gitDir, commondir);
- const reads = await Promise.all(configPaths.map(readConfigStamp));
- for (const read of reads) {
- if (read.content !== null && INCLUDE_SECTION_RE.test(read.content)) return null;
- }
- return reads.map((read) => read.stamp).join('|');
- } catch {
- return null;
- }
-}
-
-async function findGitDir(start: string): Promise {
- let dir = start;
- for (;;) {
- const candidate = join(dir, '.git');
- try {
- const handle = await open(candidate, 'r');
- await handle.close();
- return candidate;
- } catch {
- }
- const parent = dirname(dir);
- if (parent === dir) return null;
- dir = parent;
- }
-}
-
-interface GitPathRead {
- readonly directory: boolean;
- readonly text: string;
-}
-
-async function readGitPath(path: string): Promise {
- const handle = await open(path, 'r');
- try {
- const info = await handle.stat();
- if (info.isDirectory()) return { directory: true, text: '' };
- return { directory: false, text: await handle.readFile('utf8') };
- } finally {
- await handle.close();
- }
-}
-
-async function readConfigStamp(path: string): Promise<{ readonly stamp: string; readonly content: string | null }> {
- try {
- const handle = await open(path, 'r');
- try {
- const info = await handle.stat();
- return {
- stamp: `${path}:${String(info.mtimeMs)}:${String(info.size)}`,
- content: await handle.readFile('utf8'),
- };
- } finally {
- await handle.close();
- }
- } catch {
- return { stamp: `${path}:missing`, content: null };
- }
-}
diff --git a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts
index 2398c1904..2ec9b25c2 100644
--- a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts
+++ b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts
@@ -10,6 +10,7 @@ import { IPluginService } from '#/app/plugin/plugin';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { readWorkspaceTrust } from '#/workspace/workspaceTrust/trustRecord';
+import { trustWorkspaceEnvTrusted } from '#/workspace/workspaceTrust/workspaceTrustService';
import {
IMcpRegistryService,
@@ -45,7 +46,10 @@ export class McpRegistryService implements IMcpRegistryService {
}
} else {
const cwd = canonicalWorkspaceRoot(query.cwd);
- if (!(await readWorkspaceTrust(this.docs, cwd))) {
+ const trusted =
+ (await readWorkspaceTrust(this.docs, cwd)) ||
+ trustWorkspaceEnvTrusted((name) => this.bootstrap.getEnv(name));
+ if (!trusted) {
const userEntries = await this.store.list();
for (const server of userEntries) {
const { name, ...config } = server;
diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts
index 1f39e6114..5a566760d 100644
--- a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts
+++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts
@@ -1,18 +1,14 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
-export interface ProjectAdditionalDirsLocation {
+export interface ProjectAdditionalDirsLoadResult {
readonly projectRoot: string;
readonly configPath: string;
-}
-
-export interface ProjectAdditionalDirsLoadResult extends ProjectAdditionalDirsLocation {
readonly additionalDirs: readonly string[];
}
export interface IProjectLocalConfigService {
readonly _serviceBrand: undefined;
- locateAdditionalDirsConfig(workDir: string): Promise;
readAdditionalDirs(workDir: string): Promise;
resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise;
appendAdditionalDir(
diff --git a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts
index d527347b8..47387a369 100644
--- a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts
+++ b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts
@@ -3,6 +3,8 @@ import { assign, fromCallback, sendTo, setup, type Snapshot } from 'xstate';
import { IntervalTimer } from '#/_base/utils/timer';
import type { CronJobOrigin, CronMissedOrigin } from '#/agent/contextMemory/types';
+import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents';
+import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
import {
defineAgentRuntimeContract,
@@ -21,7 +23,9 @@ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/event
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { BugIndicatingError } from '#/errors';
import type { ContentPart } from '#/kosong/contract/message';
+import { IAgentReminderService } from '#/features/reminder/reminderService';
import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
+import { Forked } from '#/session/agentLifecycle/forked';
import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from './cronOps';
@@ -36,14 +40,27 @@ export const CRON_FIRED = 'cron_fired' as const;
export const CRON_MISSED = 'cron_missed' as const;
export const CRON_DELETED = 'cron_deleted' as const;
+const CRON_FORK_CLEARED_REMINDER = [
+ 'This fork does not have any scheduled cron tasks.',
+ 'Tasks from the source session continue to run in the source session.',
+ 'Create new tasks here if needed.',
+].join(' ');
+
+const CRON_FORK_CLEARED_REMINDER_NAME = 'cron_fork_cleared';
+
+function isCronForkClearedReminder(message: ContextMessage): boolean {
+ const origin = message.origin;
+ return origin?.kind === 'injection' && origin.variant === CRON_FORK_CLEARED_REMINDER_NAME;
+}
+
interface CronActorContext {
- readonly tasks: CronModelState;
+ readonly model: CronModelState;
readonly runtime: AgentRuntimeContext;
}
interface CronCommitEvent {
readonly type: 'cron.commit';
- readonly tasks: CronModelState;
+ readonly model: CronModelState;
}
interface CronTickEvent {
@@ -151,7 +168,7 @@ function removeTasks(
runtime: AgentRuntimeContext,
ids: readonly string[],
): readonly string[] {
- const removed = ids.filter((id) => runtime.getState().has(id));
+ const removed = ids.filter((id) => runtime.getState().tasks.has(id));
if (removed.length > 0) void runtime.dispatch(new CronDelete({ ids: removed }));
return removed;
}
@@ -263,7 +280,7 @@ async function processDue(
}
const advancedTo = lastDueMs ?? now;
state.lastSeenAt.set(task.id, advancedTo);
- if (runtime.getState().has(task.id)) {
+ if (runtime.getState().tasks.has(task.id)) {
void runtime.dispatch(new CronCursor({ id: task.id, lastFiredAt: advancedTo }));
}
}
@@ -276,11 +293,11 @@ async function tickCron(
): Promise {
await config.ready;
if (isDisposed()) return;
- if (readCronConfig(config).disabled || runtime.getState().size === 0) return;
+ if (readCronConfig(config).disabled || runtime.getState().tasks.size === 0) return;
if (runtime.get(IAgentLoopService).snapshot().state === 'running') return;
const now = state.clocks.wallNow();
await Promise.all(
- [...runtime.getState().values()].map((task) => processDue(runtime, state, task, now, isDisposed)),
+ [...runtime.getState().tasks.values()].map((task) => processDue(runtime, state, task, now, isDisposed)),
);
}
@@ -297,6 +314,11 @@ const cronEffects = fromCallback(({
sendBack: (event: CronActorEvent) => void;
}) => {
if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return;
+ if (input.runtime.getState().forkNotice.reminderPending) {
+ input.runtime.get(IAgentReminderService).notify(CRON_FORK_CLEARED_REMINDER, {
+ variant: CRON_FORK_CLEARED_REMINDER_NAME,
+ });
+ }
const config = configOf(input.runtime);
const timer = new IntervalTimer({ unref: true });
const state: CronEffectState = {
@@ -378,7 +400,7 @@ export class CronRuntime {
}
addTask(init: CronTaskInit): CronTask {
- const tasks = this.runtime.getState();
+ const tasks = this.runtime.getState().tasks;
let id: string | undefined;
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) {
const candidate = ulid();
@@ -400,11 +422,11 @@ export class CronRuntime {
}
getTask(id: string): CronTask | undefined {
- return this.runtime.getState().get(id);
+ return this.runtime.getState().tasks.get(id);
}
list(): readonly CronTask[] {
- return [...this.runtime.getState().values()];
+ return [...this.runtime.getState().tasks.values()];
}
isStale(task: CronTask): boolean {
@@ -413,7 +435,7 @@ export class CronRuntime {
getNextFireTime(): number | null {
let min: number | null = null;
- for (const task of this.runtime.getState().values()) {
+ for (const task of this.runtime.getState().tasks.values()) {
const next = nextFireFor(this.runtime, task);
if (next !== null && (min === null || next < min)) min = next;
}
@@ -421,7 +443,7 @@ export class CronRuntime {
}
getNextFireForTask(taskId: string): number | null {
- const task = this.runtime.getState().get(taskId);
+ const task = this.runtime.getState().tasks.get(taskId);
return task === undefined ? null : nextFireFor(this.runtime, task);
}
@@ -482,7 +504,10 @@ const cronActorLogic = setup({
},
actors: { cronEffects },
}).createMachine({
- context: ({ input }) => ({ tasks: new Map(), runtime: input }),
+ context: ({ input }) => ({
+ model: { tasks: new Map(), forkNotice: { reminderPending: false } },
+ runtime: input,
+ }),
initial: 'beforeRestore',
states: {
beforeRestore: {
@@ -509,7 +534,7 @@ const cronActorLogic = setup({
},
on: {
'cron.commit': {
- actions: assign({ tasks: ({ event }) => event.tasks }),
+ actions: assign({ model: ({ event }) => event.model }),
},
},
});
@@ -521,28 +546,40 @@ export const cronAgentRuntimeProvider = defineAgentRuntimeProvider {
if (event instanceof CronAdd) {
- state.set(event.task.id, event.task);
+ state.tasks.set(event.task.id, event.task);
return;
}
if (event instanceof CronDelete) {
- for (const id of event.ids) state.delete(id);
+ for (const id of event.ids) state.tasks.delete(id);
return;
}
if (event instanceof CronCursor) {
- const task = state.get(event.id);
- if (task !== undefined) state.set(event.id, { ...task, lastFiredAt: event.lastFiredAt });
+ const task = state.tasks.get(event.id);
+ if (task !== undefined) state.tasks.set(event.id, { ...task, lastFiredAt: event.lastFiredAt });
+ return;
+ }
+ if (event instanceof Forked) {
+ state.forkNotice.reminderPending =
+ state.tasks.size > 0 || state.forkNotice.reminderPending;
+ state.tasks.clear();
+ return;
+ }
+ if (event instanceof ContextAppendMessage) {
+ if (state.forkNotice.reminderPending && isCronForkClearedReminder(event.message)) {
+ state.forkNotice.reminderPending = false;
+ }
}
},
- read: (snapshot) => (snapshot as CronActorSnapshot).context.tasks,
- commit: (actor, tasks) => { actor.send({ type: 'cron.commit', tasks }); },
+ read: (snapshot) => (snapshot as CronActorSnapshot).context.model,
+ commit: (actor, model) => { actor.send({ type: 'cron.commit', model }); },
},
createApi: (context) => new CronRuntime(context),
inspect: (snapshot) =>
- [...(snapshot as CronActorSnapshot).context.tasks.values()].map((task) => ({
+ [...(snapshot as CronActorSnapshot).context.model.tasks.values()].map((task) => ({
id: task.id,
cron: task.cron,
recurring: task.recurring !== false,
diff --git a/packages/agent-core-v2/src/features/cron/cronOps.ts b/packages/agent-core-v2/src/features/cron/cronOps.ts
index 544aa817c..6c282e523 100644
--- a/packages/agent-core-v2/src/features/cron/cronOps.ts
+++ b/packages/agent-core-v2/src/features/cron/cronOps.ts
@@ -5,7 +5,10 @@ import type { CronJobOrigin } from '#/agent/contextMemory/types';
import type { CronTask } from '#/features/cron/cronTask';
import { Event2 } from '#/app/event/event2';
-export type CronModelState = Map;
+export interface CronModelState {
+ readonly tasks: Map;
+ readonly forkNotice: { reminderPending: boolean };
+}
const cronTaskSchema = z.object({
id: z.string(),
diff --git a/packages/agent-core-v2/src/features/cron/cronService.ts b/packages/agent-core-v2/src/features/cron/cronService.ts
index 1d8d0d7e8..30a305a21 100644
--- a/packages/agent-core-v2/src/features/cron/cronService.ts
+++ b/packages/agent-core-v2/src/features/cron/cronService.ts
@@ -23,7 +23,11 @@ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/event
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { BugIndicatingError } from '#/errors';
import type { ContentPart } from '#human/llm/message';
+import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+import { IAgentReminderService } from '#/features/reminder/reminderService';
import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
+import { Forked } from '#/session/agentLifecycle/forked';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from './cronOps';
@@ -31,6 +35,7 @@ import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from
registerEvent2Class(CronAdd);
registerEvent2Class(CronDelete);
registerEvent2Class(CronCursor);
+registerEvent2Class(Forked);
const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_POLL_INTERVAL_MS = 1_000;
@@ -43,14 +48,27 @@ export const CRON_FIRED = 'cron_fired' as const;
export const CRON_MISSED = 'cron_missed' as const;
export const CRON_DELETED = 'cron_deleted' as const;
+const CRON_FORK_CLEARED_REMINDER = [
+ 'This fork does not have any scheduled cron tasks.',
+ 'Tasks from the source session continue to run in the source session.',
+ 'Create new tasks here if needed.',
+].join(' ');
+
+const CRON_FORK_CLEARED_REMINDER_NAME = 'cron_fork_cleared';
+
+function isCronForkClearedReminder(message: ContextMessage): boolean {
+ const origin = message.origin;
+ return origin?.kind === 'injection' && origin.variant === CRON_FORK_CLEARED_REMINDER_NAME;
+}
+
interface CronActorContext {
- readonly tasks: CronModelState;
+ readonly model: CronModelState;
readonly runtime: AgentActorContext;
}
interface CronCommitEvent {
readonly type: 'cron.commit';
- readonly tasks: CronModelState;
+ readonly model: CronModelState;
}
interface CronTickEvent {
@@ -154,7 +172,7 @@ function removeTasks(
runtime: AgentActorContext,
ids: readonly string[],
): readonly string[] {
- const removed = ids.filter((id) => runtime.getState().has(id));
+ const removed = ids.filter((id) => runtime.getState().tasks.has(id));
if (removed.length > 0) void runtime.dispatch(new CronDelete({ ids: removed }));
return removed;
}
@@ -254,7 +272,7 @@ async function processDue(
}
const advancedTo = lastDueMs ?? now;
state.lastSeenAt.set(task.id, advancedTo);
- if (runtime.getState().has(task.id)) {
+ if (runtime.getState().tasks.has(task.id)) {
void runtime.dispatch(new CronCursor({ id: task.id, lastFiredAt: advancedTo }));
}
}
@@ -264,10 +282,10 @@ async function tickCron(
state: CronEffectState,
): Promise {
await configOf(runtime).ready;
- if (cronConfigOf(runtime).disabled || runtime.getState().size === 0) return;
+ if (cronConfigOf(runtime).disabled || runtime.getState().tasks.size === 0) return;
if (runtime.get(IAgentLoopService).snapshot().state === 'running') return;
const now = state.clocks.wallNow();
- await Promise.all([...runtime.getState().values()].map((task) => processDue(runtime, state, task, now)));
+ await Promise.all([...runtime.getState().tasks.values()].map((task) => processDue(runtime, state, task, now)));
}
const cronEffects = fromCallback(({
@@ -283,6 +301,11 @@ const cronEffects = fromCallback(({
sendBack: (event: CronActorEvent) => void;
}) => {
if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return;
+ if (input.runtime.getState().forkNotice.reminderPending) {
+ input.runtime.get(IAgentReminderService).notify(CRON_FORK_CLEARED_REMINDER, {
+ variant: CRON_FORK_CLEARED_REMINDER_NAME,
+ });
+ }
const timer = new IntervalTimer({ unref: true });
const state: CronEffectState = {
clocks: SYSTEM_CLOCKS,
@@ -356,7 +379,10 @@ const cronActorLogic = setup({
},
actors: { cronEffects },
}).createMachine({
- context: ({ input }) => ({ tasks: new Map(), runtime: input }),
+ context: ({ input }) => ({
+ model: { tasks: new Map(), forkNotice: { reminderPending: false } },
+ runtime: input,
+ }),
initial: 'beforeRestore',
states: {
beforeRestore: {
@@ -383,7 +409,7 @@ const cronActorLogic = setup({
},
on: {
'cron.commit': {
- actions: assign({ tasks: ({ event }) => event.tasks }),
+ actions: assign({ model: ({ event }) => event.model }),
},
},
});
@@ -429,24 +455,36 @@ export class AgentCronService extends AgentActorService implemen
this.actor = this.attachActor(cronActorLogic, {
id: 'cron',
durable: {
- events: [CronAdd, CronDelete, CronCursor],
+ events: [CronAdd, CronDelete, CronCursor, Forked, ContextAppendMessage],
undoable: false,
transition: (state, event) => {
if (event instanceof CronAdd) {
- state.set(event.task.id, event.task);
+ state.tasks.set(event.task.id, event.task);
return;
}
if (event instanceof CronDelete) {
- for (const id of event.ids) state.delete(id);
+ for (const id of event.ids) state.tasks.delete(id);
return;
}
if (event instanceof CronCursor) {
- const task = state.get(event.id);
- if (task !== undefined) state.set(event.id, { ...task, lastFiredAt: event.lastFiredAt });
+ const task = state.tasks.get(event.id);
+ if (task !== undefined) state.tasks.set(event.id, { ...task, lastFiredAt: event.lastFiredAt });
+ return;
+ }
+ if (event instanceof Forked) {
+ state.forkNotice.reminderPending =
+ state.tasks.size > 0 || state.forkNotice.reminderPending;
+ state.tasks.clear();
+ return;
+ }
+ if (event instanceof ContextAppendMessage) {
+ if (state.forkNotice.reminderPending && isCronForkClearedReminder(event.message)) {
+ state.forkNotice.reminderPending = false;
+ }
}
},
- read: (snapshot) => (snapshot as CronActorSnapshot).context.tasks,
- commit: (actor, tasks) => { actor.send({ type: 'cron.commit', tasks }); },
+ read: (snapshot) => (snapshot as CronActorSnapshot).context.model,
+ commit: (actor, model) => { actor.send({ type: 'cron.commit', model }); },
},
});
}
@@ -460,7 +498,7 @@ export class AgentCronService extends AgentActorService implemen
}
addTask(init: CronTaskInit): CronTask {
- const tasks = this.actor.getState();
+ const tasks = this.actor.getState().tasks;
let id: string | undefined;
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) {
const candidate = ulid();
@@ -482,11 +520,11 @@ export class AgentCronService extends AgentActorService implemen
}
getTask(id: string): CronTask | undefined {
- return this.actor.getState().get(id);
+ return this.actor.getState().tasks.get(id);
}
list(): readonly CronTask[] {
- return [...this.actor.getState().values()];
+ return [...this.actor.getState().tasks.values()];
}
isStale(task: CronTask): boolean {
@@ -495,7 +533,7 @@ export class AgentCronService extends AgentActorService implemen
getNextFireTime(): number | null {
let min: number | null = null;
- for (const task of this.actor.getState().values()) {
+ for (const task of this.actor.getState().tasks.values()) {
const next = nextFireFor(this.actor, task);
if (next !== null && (min === null || next < min)) min = next;
}
@@ -503,7 +541,7 @@ export class AgentCronService extends AgentActorService implemen
}
getNextFireForTask(taskId: string): number | null {
- const task = this.actor.getState().get(taskId);
+ const task = this.actor.getState().tasks.get(taskId);
return task === undefined ? null : nextFireFor(this.actor, task);
}
diff --git a/packages/agent-core-v2/src/features/goal/goalOps.ts b/packages/agent-core-v2/src/features/goal/goalOps.ts
index 6e2169b06..ffe5f6823 100644
--- a/packages/agent-core-v2/src/features/goal/goalOps.ts
+++ b/packages/agent-core-v2/src/features/goal/goalOps.ts
@@ -111,17 +111,6 @@ export interface GoalClear {
readonly agentId: string;
}
-const goalForkedSchema = z.object({ agentId: z.string() });
-
-export class GoalForked extends AgentEvent2> {
- static override readonly type = 'forked';
- static override readonly durable = true;
- static override readonly schema = goalForkedSchema;
-}
-export interface GoalForked {
- readonly agentId: string;
-}
-
export interface GoalUpdatedPayload {
readonly agentId: string;
snapshot: GoalSnapshot | null;
diff --git a/packages/agent-core-v2/src/features/goal/goalService.ts b/packages/agent-core-v2/src/features/goal/goalService.ts
index 713db4506..19ce2c3c5 100644
--- a/packages/agent-core-v2/src/features/goal/goalService.ts
+++ b/packages/agent-core-v2/src/features/goal/goalService.ts
@@ -49,6 +49,7 @@ import {
type PythinkerErrorPayload,
} from '#/errors';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
+import { Forked } from '#/session/agentLifecycle/forked';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { IEventDispatcher } from '#/state/eventDispatcher';
import type { ExecutableToolResult } from '#/tool/toolContract';
@@ -58,7 +59,6 @@ import { IGoalDeadlineScheduler } from './goalDeadlineScheduler';
import {
GoalClear,
GoalCreate,
- GoalForked,
GoalUpdate,
GoalUpdated,
type GoalModelState,
@@ -79,7 +79,7 @@ import type {
registerEvent2Class(GoalCreate);
registerEvent2Class(GoalUpdate);
registerEvent2Class(GoalClear);
-registerEvent2Class(GoalForked);
+registerEvent2Class(Forked);
const MAX_GOAL_OBJECTIVE_LENGTH = 4000;
@@ -1322,7 +1322,7 @@ export class AgentGoalService extends AgentActorService implem
this.actor = this.attachActor(goalActorLogic, {
id: 'goal',
durable: {
- events: [GoalCreate, GoalUpdate, GoalClear, GoalForked, ContextAppendMessage],
+ events: [GoalCreate, GoalUpdate, GoalClear, Forked, ContextAppendMessage],
undoable: false,
transition: (state, event) => {
if (event instanceof GoalCreate) {
@@ -1375,7 +1375,7 @@ export class AgentGoalService extends AgentActorService implem
state.forkNotice.goalPresent = false;
return;
}
- if (event instanceof GoalForked) {
+ if (event instanceof Forked) {
state.goal = null;
state.forkNotice.reminderPending =
state.forkNotice.goalPresent || state.forkNotice.reminderPending;
diff --git a/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts b/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts
index 2a0330ad4..1a35ff9c8 100644
--- a/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts
+++ b/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts
@@ -9,11 +9,12 @@ import {
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IFlagService } from '#/app/flag/flag';
import { IAgentReminderService } from '#/features/reminder/reminderService';
import { IEventDispatcher } from '#/state/eventDispatcher';
-import { NOTIFY_USER_FLAG_ID } from './flag';
+import { notifyUserAvailable } from './notifyUserAvailability';
import {
NOTIFY_USER_NUDGE_VARIANT,
lastMidResponsePosition,
@@ -38,11 +39,13 @@ const notifyUserNudgeReminders = fromCallback(({
};
}) => {
const runtime = input.runtime;
- if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return () => {};
+ const available = (): boolean =>
+ notifyUserAvailable(runtime.get(IFlagService), runtime.get(IBootstrapService));
+ if (!available()) return () => {};
const registration = runtime.get(IAgentReminderService).register(
NOTIFY_USER_NUDGE_VARIANT,
({ lastInjectedAt }): string | undefined => {
- if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return undefined;
+ if (!available()) return undefined;
if (runtime.get(IAgentToolRegistryService).resolve(NOTIFY_USER_TOOL_NAME) === undefined) {
return undefined;
}
diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts
index 5c5eca398..fa18c0fb5 100644
--- a/packages/agent-core-v2/src/features/tower/protocol/git.ts
+++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts
@@ -2,10 +2,7 @@ import { execFile } from 'node:child_process';
import { realpath } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
-import { GIT_DIFF_ARGS, hardenedGitConfigArgs, type GitProbeResult } from '#/app/git/hardening';
-
const GIT_TIMEOUT_MS = 60_000;
-const CONFIG_PROBE_TIMEOUT_MS = 5_000;
export class GitError extends Error {
constructor(
@@ -26,16 +23,10 @@ export async function git(
args: readonly string[],
options: GitOptions = {},
): Promise {
- const configArgs = await hardenedGitConfigArgs(cwd, (probeArgs) =>
- probeGitConfig(cwd, probeArgs),
- );
- if (configArgs === null) {
- throw new GitError(args, 'git config probe failed');
- }
return new Promise((resolve, reject) => {
execFile(
'git',
- [...configArgs, ...args],
+ [...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
@@ -53,27 +44,6 @@ export async function git(
});
}
-function probeGitConfig(cwd: string, args: readonly string[]): Promise {
- return new Promise((resolve) => {
- execFile(
- 'git',
- [...args],
- { cwd, timeout: CONFIG_PROBE_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
- (error, stdout) => {
- if (error === null) {
- resolve({ exitCode: 0, stdout });
- return;
- }
- const code: unknown = (error as { code?: unknown }).code;
- resolve({
- exitCode: typeof code === 'number' ? code : -1,
- stdout: typeof stdout === 'string' ? stdout : '',
- });
- },
- );
- });
-}
-
export async function tryGit(cwd: string, args: readonly string[]): Promise {
try {
return await git(cwd, args);
@@ -198,6 +168,6 @@ export async function diffNameOnly(
base: string,
ref: string,
): Promise {
- const out = await git(cwd, ['diff', ...GIT_DIFF_ARGS, '--name-only', `${base}...${ref}`]);
+ const out = await git(cwd, ['diff', '--name-only', `${base}...${ref}`]);
return out.length === 0 ? [] : out.split('\n').filter((line) => line.trim().length > 0);
}
diff --git a/packages/agent-core-v2/src/human/llm-pythinker/provider.ts b/packages/agent-core-v2/src/human/llm-pythinker/provider.ts
index 90bf8cc80..590a83194 100644
--- a/packages/agent-core-v2/src/human/llm-pythinker/provider.ts
+++ b/packages/agent-core-v2/src/human/llm-pythinker/provider.ts
@@ -3,7 +3,7 @@ import { anthropicBetaBase } from '#/llm/requester/bases/anthropic/requester';
import { openAIBase } from '#/llm/requester/bases/openai/requester';
import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester';
-import { pythinkerAnthropicTrait, pythinkerConnection, pythinkerOpenAITrait } from './trait';
+import { pythinkerAnthropicTrait, pythinkerConnection, pythinkerOpenAITrait, pythinkerResponsesTrait } from './trait';
import { classifyPythinkerQuotaError } from './errors';
import { pythinkerMediaContribution } from './media';
@@ -24,6 +24,7 @@ export const pythinkerProvider = createProvider({
},
openai_responses: {
base: openAIResponsesBase,
+ trait: pythinkerResponsesTrait,
connection: pythinkerConnection,
classifyError: classifyPythinkerQuotaError,
},
diff --git a/packages/agent-core-v2/src/human/llm-pythinker/trait.ts b/packages/agent-core-v2/src/human/llm-pythinker/trait.ts
index d28ac17a0..cc6b1557b 100644
--- a/packages/agent-core-v2/src/human/llm-pythinker/trait.ts
+++ b/packages/agent-core-v2/src/human/llm-pythinker/trait.ts
@@ -1,3 +1,4 @@
+import type { LlmModel } from '#/llm/model';
import type { ProtocolEndpoint, ProviderConnection } from '#/llm/protocol/connection';
import type { ContentPart, ToolDescription } from '#/llm/message';
import { providerImagePolicy } from '#/llm/media/image-formats';
@@ -8,6 +9,7 @@ import type {
OpenAIWireMessage,
OpenAIWireToolCall,
} from '#/llm/requester/bases/openai/contract';
+import type { OpenAIResponsesTrait } from '#/llm/requester/bases/openai-responses/trait';
import type { OpenAITrait } from '#/llm/requester/bases/openai/trait';
import { normalizePythinkerToolSchema } from './schema';
@@ -64,6 +66,19 @@ function convertPythinkerTool(tool: ToolDescription): Record {
const pythinkerAcceptedImageMimes = (): ReadonlySet => providerImagePolicy('pythinker').acceptedMimes;
+export function pythinkerUnsetCompletionTokens(input: {
+ readonly model: LlmModel;
+ readonly usedContextTokens?: number;
+}): number | undefined {
+ const window = input.model.maxContextSize;
+ if (window === undefined || window <= 0 || input.usedContextTokens === undefined) return undefined;
+ return Math.max(1, window - input.usedContextTokens);
+}
+
+export const pythinkerResponsesTrait: OpenAIResponsesTrait = {
+ completionTokensWhenUnset: pythinkerUnsetCompletionTokens,
+};
+
export const pythinkerOpenAITrait: OpenAITrait = {
strictThinkingValidation: true,
@@ -91,6 +106,8 @@ export const pythinkerOpenAITrait: OpenAITrait = {
max_completion_tokens: maxCompletionTokens,
}),
+ completionTokensWhenUnset: pythinkerUnsetCompletionTokens,
+
buildParams: (params) => {
const { extra_body: extraBody, ...rest } = params;
if (extraBody === undefined || extraBody === null) {
diff --git a/packages/agent-core-v2/src/human/llm/protocol/format.ts b/packages/agent-core-v2/src/human/llm/protocol/format.ts
index c25ed5e17..37fa76d42 100644
--- a/packages/agent-core-v2/src/human/llm/protocol/format.ts
+++ b/packages/agent-core-v2/src/human/llm/protocol/format.ts
@@ -12,7 +12,7 @@ export type FormatRequestInput = LlmRequestConfig & {
export function resolveMaxCompletionCap(input: FormatRequestInput): number | undefined {
const { maxCompletionTokens, usedContextTokens, maxContextTokens } = input;
- if (maxCompletionTokens === undefined) {
+ if (maxCompletionTokens === undefined || maxCompletionTokens <= 0) {
return undefined;
}
let cap = maxCompletionTokens;
diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts
index 4959ee973..b351d787f 100644
--- a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts
+++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts
@@ -133,7 +133,7 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = {
'haiku-3': 4096,
};
-const FALLBACK_MAX_TOKENS = 128000;
+const FALLBACK_MAX_TOKENS = 64000;
function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined {
const { family, major, minor } = version;
diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts
index 357e54035..5aac0822a 100644
--- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts
+++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts
@@ -81,7 +81,11 @@ export function prepareOpenAIResponsesRequest(
encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true),
).kwargs;
}
- const cap = resolveMaxCompletionCap(input);
+ const requested = input.maxCompletionTokens;
+ const cap =
+ requested !== undefined && requested <= 0
+ ? undefined
+ : (resolveMaxCompletionCap(input) ?? trait?.completionTokensWhenUnset?.(input));
if (cap !== undefined) {
kwargs = {
...kwargs,
diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts
index 304837481..d202bd39c 100644
--- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts
+++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts
@@ -1,4 +1,5 @@
import type { ToolDescription } from '#/llm/message';
+import type { LlmModel } from '#/llm/model';
import type { TraitContext } from '#/llm/protocol/base';
import type { ThinkingStrategy } from '#/llm/protocol/thinking';
import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester';
@@ -19,6 +20,11 @@ export interface OpenAIResponsesTrait {
ctx: TraitContext,
): Record | undefined;
+ completionTokensWhenUnset?(input: {
+ readonly model: LlmModel;
+ readonly usedContextTokens?: number;
+ }): number | undefined;
+
convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined;
mergeHistory?(
diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts
index a19bfc1c6..70f631266 100644
--- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts
+++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts
@@ -96,7 +96,11 @@ export function prepareOpenAIRequest(
if (input.responseFormat !== undefined) {
kwargs = { ...kwargs, response_format: responseFormatToOpenAI(input.responseFormat) };
}
- const cap = resolveMaxCompletionCap(input);
+ const requested = input.maxCompletionTokens;
+ const cap =
+ requested !== undefined && requested <= 0
+ ? undefined
+ : (resolveMaxCompletionCap(input) ?? trait?.completionTokensWhenUnset?.(input));
if (cap !== undefined) {
kwargs = {
...kwargs,
diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts
index d9377faa8..1a4784297 100644
--- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts
+++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts
@@ -1,4 +1,5 @@
import type { Message, ToolDescription } from '#/llm/message';
+import type { LlmModel } from '#/llm/model';
import type { TraitContext } from '#/llm/protocol/base';
import type { ThinkingStrategy } from '#/llm/protocol/thinking';
import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester';
@@ -20,6 +21,11 @@ export interface OpenAITrait {
ctx: TraitContext,
): Record | undefined;
+ completionTokensWhenUnset?(input: {
+ readonly model: LlmModel;
+ readonly usedContextTokens?: number;
+ }): number | undefined;
+
convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined;
convertMessage?(
diff --git a/packages/agent-core-v2/src/human/test/llm/trait.test.ts b/packages/agent-core-v2/src/human/test/llm/trait.test.ts
index 1f3964c2c..97f386493 100644
--- a/packages/agent-core-v2/src/human/test/llm/trait.test.ts
+++ b/packages/agent-core-v2/src/human/test/llm/trait.test.ts
@@ -739,6 +739,20 @@ describe('withMaxCompletionTokens', () => {
);
expect(client.body()['max_completion_tokens']).toBe(1000);
expect(client.body()['max_tokens']).toBeUndefined();
+
+ await requester.generate(
+ { model: { ...model, maxContextSize: 1000 } },
+ { messages, usedContextTokens: 40 },
+ { signal: new AbortController().signal },
+ );
+ expect(client.body()['max_completion_tokens']).toBe(960);
+
+ await requester.generate(
+ { model: { ...model, maxContextSize: 1000 }, maxCompletionTokens: 0 },
+ { messages, usedContextTokens: 40 },
+ { signal: new AbortController().signal },
+ );
+ expect(client.body()['max_completion_tokens']).toBeUndefined();
});
it('uses max_completion_tokens for reasoning models without a trait', async () => {
@@ -790,7 +804,7 @@ describe('withMaxCompletionTokens', () => {
{ messages },
{ signal: new AbortController().signal },
);
- expect(client.body()['max_tokens']).toBe(128000);
+ expect(client.body()['max_tokens']).toBe(64000);
const sonnet35 = { ...model, model: 'claude-3-5-sonnet-20241022' };
await requester.generate(
@@ -1461,7 +1475,7 @@ describe('anthropic thinking kwargs', () => {
expect(body['output_config']).toEqual({ effort: 'high' });
expect(body['betaFeatures']).toBeUndefined();
expect(body['betas']).toEqual(['context-management-2025-06-27']);
- expect(body['max_tokens']).toBe(128000);
+ expect(body['max_tokens']).toBe(64000);
expect(client.betaCalled()).toBe(true);
let bodyMessages = body['messages'] as Record[];
expect(bodyMessages[0]?.['content']).toEqual([
diff --git a/packages/agent-core-v2/src/human/test/utils/watch.test.ts b/packages/agent-core-v2/src/human/test/utils/watch.test.ts
index 67c518784..392cc5f67 100644
--- a/packages/agent-core-v2/src/human/test/utils/watch.test.ts
+++ b/packages/agent-core-v2/src/human/test/utils/watch.test.ts
@@ -20,14 +20,6 @@ const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)
const longTempDir = (prefix: string): Promise =>
mkdtemp(join(realpathSync.native(tmpdir()), prefix));
-beforeEach(() => {
- setWatchEnabled(true);
-});
-
-afterEach(() => {
- setWatchEnabled(false);
-});
-
class TestNativeWatcher {
private errorListener: ((error: NodeJS.ErrnoException) => void) | undefined;
closed = false;
@@ -477,10 +469,14 @@ describe('watch chokidar mode', () => {
it('does not start a filesystem watch when watch is disabled', async () => {
root = await mkdtemp(join(tmpdir(), 'watch-disabled-'));
setWatchEnabled(false);
- const events = await start();
- await writeFile(join(root, 'a.txt'), 'x');
- await wait(300);
- expect(events).toHaveLength(0);
+ try {
+ const events = await start();
+ await writeFile(join(root, 'a.txt'), 'x');
+ await wait(300);
+ expect(events).toHaveLength(0);
+ } finally {
+ setWatchEnabled(true);
+ }
});
it('stops firing after the handle is disposed', async () => {
diff --git a/packages/agent-core-v2/src/human/utils/watch.ts b/packages/agent-core-v2/src/human/utils/watch.ts
index e8fe23fbe..c1f4ca231 100644
--- a/packages/agent-core-v2/src/human/utils/watch.ts
+++ b/packages/agent-core-v2/src/human/utils/watch.ts
@@ -513,7 +513,7 @@ export const WATCH_ENV = 'PYTHINKER_CODE_WATCH';
const TRUE_WATCH_ENV = new Set(['1', 'true', 'yes', 'on']);
const FALSE_WATCH_ENV = new Set(['0', 'false', 'no', 'off']);
-let watchEnabledFromConfig = false;
+let watchEnabledFromConfig = true;
export function setWatchEnabled(enabled: boolean): void {
watchEnabledFromConfig = enabled;
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index f83e5fbf2..f24ea0eba 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -465,6 +465,7 @@ export * from '#/features/cron/tools/cron-delete/cron-delete';
import '#/session/agentLifecycle/profile/profiles';
export * from '#/session/agentLifecycle/agentLifecycle';
export * from '#/session/agentLifecycle/agentLifecycleService';
+export * from '#/session/agentLifecycle/forked';
export * from '#/session/agentLifecycle/mainAgent';
export * from '#/session/mcp/sessionMcpHandle';
import '#/app/mcpConfig/configSection';
diff --git a/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts
index b4fe8c01e..0cbc60df4 100644
--- a/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts
+++ b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts
@@ -1,50 +1,31 @@
import type { ModelCapability } from '../contract/capability';
-import type { CompletionBudgetConfig, CompletionBudgetParams } from './model.types';
+import type { CompletionBudgetParams } from './model.types';
const MIN_FLOOR = 1;
-const DEFAULT_UNKNOWN_CONTEXT_FALLBACK = 32000;
export function resolveCompletionBudget(args: {
readonly maxOutputSize?: number;
- readonly reservedContextSize?: number;
readonly maxCompletionTokensCap?: number;
-}): CompletionBudgetConfig | undefined {
+}): number | undefined {
if (args.maxCompletionTokensCap !== undefined) {
if (args.maxCompletionTokensCap <= 0) return undefined;
- return { hardCap: args.maxCompletionTokensCap };
+ return args.maxCompletionTokensCap;
}
if (args.maxOutputSize !== undefined && args.maxOutputSize > 0) {
- return { hardCap: args.maxOutputSize };
+ return args.maxOutputSize;
}
- if (args.reservedContextSize !== undefined && args.reservedContextSize > 0) {
- return { fallback: args.reservedContextSize };
- }
- return { fallback: DEFAULT_UNKNOWN_CONTEXT_FALLBACK };
-}
-
-export function computeCompletionBudgetCap(args: {
- readonly budget: CompletionBudgetConfig;
- readonly capability: ModelCapability | undefined;
-}): number {
- const maxCtx = args.capability?.max_context_tokens ?? 0;
- const cap =
- args.budget.hardCap ??
- (maxCtx > 0 ? maxCtx : args.budget.fallback ?? DEFAULT_UNKNOWN_CONTEXT_FALLBACK);
- return Math.max(MIN_FLOOR, cap);
+ return undefined;
}
export function completionBudgetParams(args: {
- readonly budget: CompletionBudgetConfig | undefined;
+ readonly budget: number | undefined;
readonly capability: ModelCapability | undefined;
readonly usedContextTokens?: number;
}): CompletionBudgetParams | undefined {
if (args.budget === undefined) return undefined;
return {
- maxCompletionTokens: computeCompletionBudgetCap({
- budget: args.budget,
- capability: args.capability,
- }),
+ maxCompletionTokens: Math.max(MIN_FLOOR, args.budget),
usedContextTokens: args.usedContextTokens,
maxContextTokens: args.capability?.max_context_tokens,
};
diff --git a/packages/agent-core-v2/src/llm-adapter/model/model.types.ts b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts
index 067924775..7b500d58b 100644
--- a/packages/agent-core-v2/src/llm-adapter/model/model.types.ts
+++ b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts
@@ -8,11 +8,6 @@ export interface ModelOverrides {
readonly maxCompletionTokens?: number;
}
-export interface CompletionBudgetConfig {
- readonly hardCap?: number;
- readonly fallback?: number;
-}
-
export interface CompletionBudgetParams {
readonly maxCompletionTokens: number;
readonly usedContextTokens?: number;
diff --git a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts
index f9809a471..cccfe8346 100644
--- a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts
+++ b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts
@@ -7,6 +7,7 @@ import {
pythinkerAnthropicTrait,
pythinkerConnection,
pythinkerOpenAITrait,
+ pythinkerResponsesTrait,
PYTHINKER_DEFAULT_BASE_URL,
} from '#human/llm-pythinker/trait';
import { classifyPythinkerQuotaError } from '#human/llm-pythinker/errors';
@@ -246,6 +247,7 @@ registerProviderDefinition({
registerProviderDefinition({
id: 'pythinker',
baseProtocol: 'openai_responses',
+ trait: pythinkerResponsesTrait,
connection: pythinkerConnection,
classifyError: classifyPythinkerQuotaError,
endpoint: pythinkerEndpoint,
diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts
index 54892c2ed..356239e17 100644
--- a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts
+++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts
@@ -7,12 +7,10 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import {
IProjectLocalConfigService,
type ProjectAdditionalDirsLoadResult,
- type ProjectAdditionalDirsLocation,
} from '#/app/projectLocalConfig/projectLocalConfig';
import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage';
-import { isWithinDirectory } from '#/tool/path-access';
const ProjectLocalTomlSchema = z.object({
workspace: z
@@ -37,13 +35,9 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService
@IHostFileSystem private readonly fs: IHostFileSystem,
) {}
- async locateAdditionalDirsConfig(workDir: string): Promise {
- const projectRoot = await this.findProjectRoot(workDir);
- return { projectRoot, configPath: this.getProjectLocalConfigPath(projectRoot) };
- }
-
async readAdditionalDirs(workDir: string): Promise {
- const { projectRoot, configPath } = await this.locateAdditionalDirsConfig(workDir);
+ const projectRoot = await this.findProjectRoot(workDir);
+ const configPath = this.getProjectLocalConfigPath(projectRoot);
const file = await this.readProjectLocalToml(configPath);
const additionalDirs = file?.parsed.workspace?.additional_dir;
@@ -71,7 +65,7 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService
const additionalDir = await this.resolveAdditionalDir(workDir, inputPath);
const file = (await this.readProjectLocalToml(configPath)) ?? { raw: {}, parsed: {} };
const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? [];
- const fileExistingDirs = await this.resolveExistingAdditionalDirs(
+ const fileExistingDirs = this.resolveExistingAdditionalDirs(
projectRoot,
fileAdditionalDirs,
);
@@ -162,14 +156,14 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService
return resolvedDirs;
}
- private async resolveExistingAdditionalDirs(
+ private resolveExistingAdditionalDirs(
projectRoot: string,
additionalDirs: readonly string[],
- ): Promise {
+ ): string[] {
const resolvedDirs: string[] = [];
for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
- const resolvedDir = await this.resolvePath(projectRoot, additionalDir);
+ const resolvedDir = this.resolvePath(projectRoot, additionalDir);
if (this.hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
@@ -182,38 +176,14 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService
additionalDir: string,
): Promise {
const normalizedInput = normalizeAdditionalDirInput(additionalDir);
- const resolvedDir = await this.resolvePath(baseDir, normalizedInput);
+ const resolvedDir = this.resolvePath(baseDir, normalizedInput);
await this.assertDirectory(resolvedDir);
return resolvedDir;
}
- private async resolvePath(baseDir: string, additionalDir: string): Promise {
+ private resolvePath(baseDir: string, additionalDir: string): string {
const expanded = this.expandHome(additionalDir);
- const resolvedDir = isAbsolute(expanded) ? normalize(expanded) : resolve(baseDir, expanded);
- if (await this.isBroadScopeDir(resolvedDir)) {
- throw new Error2(
- ErrorCodes.CONFIG_INVALID,
- 'workspace.additional_dir must not be the user home directory or the filesystem root',
- );
- }
- return resolvedDir;
- }
-
- private async isBroadScopeDir(resolvedDir: string): Promise {
- const homeDir = normalize(this.bootstrap.osHomeDir);
- if (dirname(resolvedDir) === resolvedDir) return true;
- const realDir = await this.realpathOrLexical(resolvedDir);
- if (dirname(realDir) === realDir) return true;
- const realHome = await this.realpathOrLexical(homeDir);
- return isWithinDirectory(homeDir, resolvedDir) || isWithinDirectory(realHome, realDir);
- }
-
- private async realpathOrLexical(path: string): Promise {
- try {
- return normalize(await this.fs.realpath(path));
- } catch {
- return path;
- }
+ return isAbsolute(expanded) ? normalize(expanded) : resolve(baseDir, expanded);
}
private expandHome(value: string): string {
diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts
index a12636855..4ae001b5a 100644
--- a/packages/agent-core-v2/src/program/program.ts
+++ b/packages/agent-core-v2/src/program/program.ts
@@ -20,6 +20,8 @@ import type { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/
import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService';
import type { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust';
import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService';
+import type { IWorkspaceTrustDisclosure } from '#/workspace/workspaceTrust/trustDisclosure';
+import { WorkspaceTrustDisclosureService } from '#/workspace/workspaceTrust/trustDisclosureService';
import type { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader';
import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService';
import type { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader';
@@ -91,6 +93,7 @@ interface ProgramGeneration {
readonly mcpConfig: IWorkspaceMcpConfigService;
readonly mcp: IWorkspaceMcpService;
readonly trust: IWorkspaceTrust;
+ readonly trustDisclosure: IWorkspaceTrustDisclosure;
readonly skills: IWorkspaceSkillCatalog;
readonly agentProfiles: IWorkspaceAgentProfileLoader;
readonly userAgentProfiles: IUserAgentProfileLoader;
@@ -144,6 +147,7 @@ export class Program {
get mcpConfig(): IWorkspaceMcpConfigService { return this.requireGeneration().mcpConfig; }
get mcp(): IWorkspaceMcpService { return this.requireGeneration().mcp; }
get trust(): IWorkspaceTrust { return this.requireGeneration().trust; }
+ get trustDisclosure(): IWorkspaceTrustDisclosure { return this.requireGeneration().trustDisclosure; }
get skills(): IWorkspaceSkillCatalog { return this.requireGeneration().skills; }
get agentProfiles(): IWorkspaceAgentProfileLoader { return this.requireGeneration().agentProfiles; }
get sessionControllerGeneration(): string { return this.requireGeneration().id; }
@@ -279,11 +283,11 @@ export class Program {
try {
const state = own(new WorkspaceStateService(this.dependencies.appState));
const localConfig = new FileProjectLocalConfigService(this.dependencies.bootstrap, runtime.fs!);
- const trust = own(new WorkspaceTrustService(this.context, this.dependencies.docs, state, this.dependencies.telemetry));
- const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state, trust));
+ const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state));
const git = new WorkspaceGitService(this.context, this.dependencies.git);
const fs = new WorkspaceFsService(this.context, dirs, runtime.fs!, this.resolver, this.dependencies.telemetry, git);
const instructions = own(new WorkspaceInstructionsService(this.context, runtime.fs!, runtime.environment, this.dependencies.bootstrap, this.dependencies.log, state));
+ const trust = own(new WorkspaceTrustService(this.context, this.dependencies.docs, state, this.dependencies.telemetry, this.dependencies.bootstrap));
const mcpConfig = own(new WorkspaceMcpConfigService(this.context, this.dependencies.bootstrap, this.dependencies.plugins, this.dependencies.log, this.dependencies.config, runtime.fs!, trust, this.dependencies.configStore));
const mcp = own(new WorkspaceMcpService(this.context, this.resolver, mcpConfig, this.dependencies.oauth, this.dependencies.log, this.dependencies.telemetry, this.dependencies.identity, this.dependencies.sessionManager));
const userAgentProfiles = own(new UserAgentProfileLoaderService(this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, this.dependencies.builtinAgentProfiles, this.context, this.dependencies.agentProfiles));
@@ -298,6 +302,7 @@ export class Program {
const workspaceSkills = own(new WorkspaceRootSkillSource(skillDiscovery, this.context, this.dependencies.config, this.dependencies.bootstrap));
const pluginSkills = new PluginSkillSource(skillDiscovery, this.dependencies.plugins);
const skills = own(new WorkspaceSkillCatalogService(this.dependencies.builtinSkills, userSkills, explicitSkills, extraSkills, workspaceSkills, pluginSkills, state));
+ const trustDisclosure = new WorkspaceTrustDisclosureService(this.context, runtime.fs!, this.dependencies.bootstrap, this.dependencies.config, localConfig, trust, skills, agentProfiles, this.dependencies.agentProfiles, instructions, this.dependencies.log);
return {
id: runtime.identity.generation,
lease,
@@ -309,6 +314,7 @@ export class Program {
mcpConfig,
mcp,
trust,
+ trustDisclosure,
skills,
agentProfiles,
userAgentProfiles,
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/forked.ts b/packages/agent-core-v2/src/session/agentLifecycle/forked.ts
new file mode 100644
index 000000000..30a910023
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/forked.ts
@@ -0,0 +1,15 @@
+/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */
+import { z } from 'zod';
+
+import { AgentEvent2 } from '#/app/event/event2';
+
+const forkedSchema = z.object({ agentId: z.string() });
+
+export class Forked extends AgentEvent2> {
+ static override readonly type = 'forked';
+ static override readonly durable = true;
+ static override readonly schema = forkedSchema;
+}
+export interface Forked {
+ readonly agentId: string;
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts
index d9356f65a..bf6a32e57 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts
@@ -1,5 +1,7 @@
+import type { Readable } from 'node:stream';
+
import type { ILogger } from '#/_base/log/log';
-import type { IGitService, RunGitResult } from '#/app/git/git';
+import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
const GIT_TIMEOUT_MS = 5_000;
const MAX_DIRTY_FILES = 20;
@@ -14,17 +16,26 @@ const ALLOWED_HOSTS = [
'git.sr.ht',
] as const;
-type TaggedGitResult = { readonly args: readonly string[]; readonly result: RunGitResult };
+type GitFailure =
+ | { readonly kind: 'timeout' }
+ | { readonly kind: 'spawn-error' }
+ | { readonly kind: 'command-failed'; readonly exitCode?: number; readonly stderr?: string };
+
+type GitResult =
+ | { readonly ok: true; readonly stdout: string }
+ | ({ readonly ok: false } & GitFailure);
+
+type TaggedGitResult = { readonly args: readonly string[]; readonly result: GitResult };
export async function collectGitContext(
- git: IGitService,
+ process: IHostProcessService,
cwd: string,
log?: ILogger,
): Promise {
const revParseArgs = ['rev-parse', '--is-inside-work-tree'] as const;
- const revParse = await git.runGit(cwd, revParseArgs, { timeoutMs: GIT_TIMEOUT_MS });
- if (revParse.exitCode !== 0) {
- if (isNotARepo(revParse.stderr)) {
+ const revParse = await runGit(process, cwd, revParseArgs);
+ if (!revParse.ok) {
+ if (revParse.kind === 'command-failed' && isNotARepo(revParse.stderr)) {
return ` `;
}
logGitFailure(cwd, revParseArgs, revParse, log);
@@ -38,14 +49,11 @@ export async function collectGitContext(
['log', '-3', '--format=%h %s'],
] as const;
const [remote, branch, status, gitLog] = (await Promise.all(
- commandArgs.map(async (args) => ({
- args,
- result: await git.runGit(cwd, args, { timeoutMs: GIT_TIMEOUT_MS }),
- })),
+ commandArgs.map(async (args) => ({ args, result: await runGit(process, cwd, args) })),
)) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult];
for (const { args, result } of [remote, branch, status, gitLog]) {
- if (result.exitCode !== 0) logGitFailure(cwd, args, result, log);
+ if (!result.ok) logGitFailure(cwd, args, result, log);
}
const remoteUrl = stdoutOf(remote.result);
@@ -127,30 +135,94 @@ function tryUrlPath(remoteUrl: string): string | null {
}
}
-function stdoutOf(result: RunGitResult): string {
- return result.exitCode === 0 ? result.stdout.trim() : '';
+function stdoutOf(result: GitResult): string {
+ return result.ok ? result.stdout : '';
}
-function isNotARepo(stderr: string): boolean {
- return stderr.includes('not a git repository');
+function isNotARepo(stderr: string | undefined): boolean {
+ return stderr !== undefined && stderr.includes('not a git repository');
}
function logGitFailure(
cwd: string,
args: readonly string[],
- result: RunGitResult,
+ failure: GitFailure,
log?: ILogger,
): void {
if (log === undefined) return;
const command = `git ${args.join(' ')}`;
- if (result.exitCode === -1) {
- log.warn('git context command failed to spawn', { cwd, command, stderr: result.stderr });
+ if (failure.kind === 'timeout') {
+ log.debug('git context command timed out', { cwd, command });
+ } else if (failure.kind === 'spawn-error') {
+ log.warn('git context command failed to spawn', { cwd, command });
} else {
log.debug('git context command failed', {
cwd,
command,
- exitCode: result.exitCode,
- stderr: result.stderr,
+ exitCode: failure.exitCode,
+ stderr: failure.stderr,
});
}
}
+
+async function runGit(
+ process: IHostProcessService,
+ cwd: string,
+ args: readonly string[],
+): Promise {
+ let proc: IHostProcess | undefined;
+ try {
+ proc = await process.spawn('git', ['-C', cwd, ...args], { cwd });
+ } catch {
+ return { ok: false, kind: 'spawn-error' };
+ }
+
+ try {
+ proc.stdin.end();
+ } catch {
+ }
+
+ const work = Promise.all([collectStream(proc.stdout), collectStream(proc.stderr), proc.wait()]);
+ work.catch(() => {});
+ let timer: ReturnType | undefined;
+ let timedOut = false;
+ try {
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ timedOut = true;
+ reject(new Error(`git ${args.join(' ')} timed out`));
+ }, GIT_TIMEOUT_MS);
+ });
+ const [stdout, stderr, exitCode] = await Promise.race([work, timeout]);
+ if (exitCode !== 0) {
+ return { ok: false, kind: 'command-failed', exitCode, stderr: stderr.trim() };
+ }
+ return { ok: true, stdout: stdout.trim() };
+ } catch {
+ try {
+ await proc.kill('SIGKILL');
+ } catch {
+ }
+ await work.catch(() => {});
+ if (timedOut) return { ok: false, kind: 'timeout' };
+ return { ok: false, kind: 'command-failed' };
+ } finally {
+ if (timer !== undefined) clearTimeout(timer);
+ if (proc !== undefined) await disposeProcess(proc);
+ }
+}
+
+async function collectStream(stream: Readable): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
+ }
+ return Buffer.concat(chunks).toString('utf-8');
+}
+
+async function disposeProcess(proc: IHostProcess): Promise {
+ try {
+ await proc.dispose();
+ } catch {
+ }
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts
index 43c91e121..ea38e446c 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts
@@ -115,10 +115,9 @@ registerAgentProfile({
tools: EXPLORE_TOOLS,
renderSystemPrompt: (context) =>
renderSystemPromptResult(EXPLORE_ROLE, context, { skillActive: skillActiveFor(EXPLORE_TOOLS) }),
- promptPrefix: async ({ cwd, git, log }) => {
- if (git === undefined) return '';
+ promptPrefix: async ({ cwd, process, log }) => {
try {
- return await collectGitContext(git, cwd, log);
+ return await collectGitContext(process, cwd, log);
} catch {
return '';
}
diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts
index 1a5cd3656..9d2a0ecbc 100644
--- a/packages/agent-core-v2/src/session/subagent/subagentService.ts
+++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts
@@ -22,7 +22,6 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { Runtime } from '#/runtime/runtime';
import { IConfigService } from '#/app/config/config';
-import { IGitService } from '#/app/git/git';
import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog';
import { ILogService } from '#/_base/log/log';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
@@ -71,7 +70,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
@ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog,
@IConfigService private readonly configService: IConfigService,
- @IGitService private readonly git: IGitService,
@IModelCatalog private readonly modelCatalog: IModelCatalog,
@ISessionContext private readonly sessionContext: ISessionContext,
@ILogService private readonly log: ILogService,
@@ -227,7 +225,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS
cwd: view.workDir,
process: runtime.process!,
log: this.log,
- git: this.git,
});
}
diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts
index e519ade39..68e6672d7 100644
--- a/packages/agent-core-v2/src/tool/path-access.ts
+++ b/packages/agent-core-v2/src/tool/path-access.ts
@@ -83,7 +83,7 @@ export function isSensitiveFile(path: string): boolean {
}
export type PathClass = 'posix' | 'win32';
-export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID' | 'PATH_SYMLINK_ESCAPE';
+export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID';
export type PathAccessOperation = 'read' | 'write' | 'search';
export type WorkspaceGuardMode = 'absolute-outside-allowed' | 'disabled';
@@ -190,10 +190,6 @@ export function isWithinWorkspace(
return false;
}
-export function isProjectLocalConfigPath(targetPath: string): boolean {
- return targetPath.replaceAll('\\', '/').toLowerCase().endsWith('/.pythinker-code/local.toml');
-}
-
export function extendWorkspaceWithSkillRoots(
workspace: T,
skillRoots: readonly string[],
diff --git a/packages/agent-core-v2/src/tool/realpath-access.ts b/packages/agent-core-v2/src/tool/realpath-access.ts
deleted file mode 100644
index aaebec687..000000000
--- a/packages/agent-core-v2/src/tool/realpath-access.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import * as pathe from 'pathe';
-
-import { isError2, unwrapErrorCause } from '#/_base/errors/errors';
-import { OsFsErrors } from '#/os/interface/hostFsErrors';
-import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
-import {
- isProjectLocalConfigPath,
- isSensitiveFile,
- isWithinDirectory,
- isWithinWorkspace,
- PathSecurityError,
- type PathClass,
- type WorkspaceConfig,
-} from '#/tool/path-access';
-
-function errnoCode(error: unknown): string | undefined {
- const unwrapped = unwrapErrorCause(error);
- if (typeof unwrapped === 'object' && unwrapped !== null && 'code' in unwrapped) {
- const code = (unwrapped as { code: unknown }).code;
- return typeof code === 'string' ? code : undefined;
- }
- return undefined;
-}
-
-function isMissingPathError(error: unknown): boolean {
- if (isError2(error)) {
- return (
- error.code === OsFsErrors.codes.OS_FS_NOT_FOUND ||
- error.code === OsFsErrors.codes.OS_FS_NOT_DIRECTORY
- );
- }
- const code = errnoCode(error);
- return code === 'ENOENT' || code === 'ENOTDIR';
-}
-
-async function realpathExistingPrefix(fs: IHostFileSystem, absPath: string): Promise {
- const tail: string[] = [];
- let current = absPath;
- for (let i = 0; i < 256; i++) {
- try {
- const real = await fs.realpath(current);
- return tail.length === 0 ? real : pathe.join(real, ...tail.toReversed());
- } catch (error) {
- if (!isMissingPathError(error)) throw error;
- const exists = await fs
- .lstat(current)
- .then(
- () => true,
- (lstatError) => {
- if (isMissingPathError(lstatError)) return false;
- throw lstatError;
- },
- );
- if (exists) {
- throw new PathSecurityError(
- 'PATH_SYMLINK_ESCAPE',
- absPath,
- current,
- `"${current}" is a symbolic link whose target does not exist. Access is blocked.`,
- );
- }
- const parent = pathe.dirname(current);
- if (parent === current) return absPath;
- tail.push(pathe.basename(current));
- current = parent;
- }
- }
- throw new PathSecurityError(
- 'PATH_SYMLINK_ESCAPE',
- absPath,
- absPath,
- `"${absPath}" is too deep to resolve to a real path. Access is blocked.`,
- );
-}
-
-async function realRoots(
- fs: IHostFileSystem,
- workspace: WorkspaceConfig,
-): Promise {
- const roots: string[] = [];
- for (const dir of [workspace.workspaceDir, ...workspace.additionalDirs]) {
- try {
- roots.push(await fs.realpath(dir));
- } catch {
- roots.push(dir);
- }
- }
- return roots;
-}
-
-export interface RealPathAccessOptions {
- readonly checkSensitive?: boolean;
-}
-
-export async function assertRealPathWithinWorkspace(
- fs: IHostFileSystem,
- absPath: string,
- workspace: WorkspaceConfig,
- pathClass: PathClass,
- options?: RealPathAccessOptions,
-): Promise {
- if (!isWithinWorkspace(absPath, workspace, pathClass)) {
- const resolved = await realpathExistingPrefix(fs, absPath);
- if (options?.checkSensitive !== false && isSensitiveFile(resolved)) {
- throw new PathSecurityError(
- 'PATH_SENSITIVE',
- absPath,
- resolved,
- `"${absPath}" resolves to "${resolved}" through a symbolic link, which matches a sensitive-file pattern (env / credential / SSH key). ` +
- 'Access is blocked to protect secrets.',
- );
- }
- return absPath;
- }
- const resolved = await realpathExistingPrefix(fs, absPath);
- if (options?.checkSensitive !== false && isSensitiveFile(resolved)) {
- throw new PathSecurityError(
- 'PATH_SENSITIVE',
- absPath,
- resolved,
- `"${absPath}" resolves to "${resolved}" through a symbolic link, which matches a sensitive-file pattern (env / credential / SSH key). ` +
- 'Access is blocked to protect secrets.',
- );
- }
- const roots = await realRoots(fs, workspace);
- if (roots.some((root) => isWithinDirectory(resolved, root, pathClass))) return resolved;
- throw new PathSecurityError(
- 'PATH_SYMLINK_ESCAPE',
- absPath,
- resolved,
- `"${absPath}" resolves to "${resolved}" through a symbolic link that points outside the working directory. ` +
- 'Access is blocked; use the real path directly or add the target directory to the workspace.',
- );
-}
-
-export async function assertRealPathWriteTarget(
- fs: IHostFileSystem,
- absPath: string,
- workspace: WorkspaceConfig,
- pathClass: PathClass,
-): Promise {
- const resolved = await assertRealPathWithinWorkspace(fs, absPath, workspace, pathClass);
- if (!isProjectLocalConfigPath(absPath) && isProjectLocalConfigPath(resolved)) {
- throw new PathSecurityError(
- 'PATH_SYMLINK_ESCAPE',
- absPath,
- resolved,
- `"${absPath}" resolves to the project-local config "${resolved}" through a symbolic link. ` +
- 'Access is blocked; use the real path so the write goes through approval.',
- );
- }
-}
-
-export async function checkRealPathWithinWorkspace(
- fs: IHostFileSystem,
- absPath: string,
- workspace: WorkspaceConfig,
- pathClass: PathClass,
- options?: RealPathAccessOptions,
-): Promise {
- try {
- await assertRealPathWithinWorkspace(fs, absPath, workspace, pathClass, options);
- return undefined;
- } catch (error) {
- if (error instanceof PathSecurityError) return error;
- throw error;
- }
-}
-
-export async function checkRealPathWriteTarget(
- fs: IHostFileSystem,
- absPath: string,
- workspace: WorkspaceConfig,
- pathClass: PathClass,
-): Promise {
- try {
- await assertRealPathWriteTarget(fs, absPath, workspace, pathClass);
- return undefined;
- } catch (error) {
- if (error instanceof PathSecurityError) return error;
- throw error;
- }
-}
diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts
index c62656f84..d11b0a20b 100644
--- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts
+++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts
@@ -6,12 +6,10 @@ import { TimeoutTimer } from '#/_base/utils/timer';
import { subtreeWatchFilter } from '#/_base/utils/paths';
import {
IProjectLocalConfigService,
- type ProjectAdditionalDirsLoadResult,
} from '#/app/projectLocalConfig/projectLocalConfig';
import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo';
import { IWorkspaceStateService } from '#/workspace/state/workspaceState';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
-import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust';
import { watchCandidates } from '#human/utils/watch';
import {
@@ -47,7 +45,6 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
@IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService,
@ILogService private readonly log: ILogService,
@IWorkspaceStateService private readonly states: IWorkspaceStateService,
- @IWorkspaceTrust private readonly trust: IWorkspaceTrust,
) {
super();
this.states.contributeState(workspaceDirsFileDirsKey);
@@ -56,16 +53,6 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
this.configPath = '';
this.ready = this.enqueue(() => this.reloadFromDisk());
void this.ready.then(() => this.watchLocalToml());
- this._register(
- this.trust.onDidChange(() => {
- if (!this.trust.isTrusted() && this.setFileDirs([])) {
- this.onDidChangeEmitter.fire();
- }
- void this.enqueue(() => this.reloadFromDisk()).catch((error) => {
- this.log.warn(`local.toml trust reload failed: ${String(error)}`);
- });
- }),
- );
}
private get fileDirs(): readonly string[] {
@@ -124,15 +111,7 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
);
this.projectRoot = persisted.projectRoot;
this.configPath = persisted.configPath;
- let changed: boolean;
- if (this.trust.isTrusted()) {
- changed = this.setFileDirs(persisted.additionalDirs);
- } else {
- const explicit = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [
- input.path,
- ]);
- changed = this.unionEphemeral(explicit);
- }
+ const changed = this.setFileDirs(persisted.additionalDirs);
if (changed) {
this.onDidChangeEmitter.fire();
}
@@ -144,7 +123,7 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
};
}
- const onDisk = await this.localConfig.locateAdditionalDirsConfig(this.workspace.cwd);
+ const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd);
this.projectRoot = onDisk.projectRoot;
this.configPath = onDisk.configPath;
const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [
@@ -163,18 +142,10 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
}
private async reloadFromDisk(): Promise {
- await this.trust.ready;
- const trustedAtStart = this.trust.isTrusted();
- const onDisk: ProjectAdditionalDirsLoadResult = trustedAtStart
- ? await this.localConfig.readAdditionalDirs(this.workspace.cwd)
- : {
- ...(await this.localConfig.locateAdditionalDirsConfig(this.workspace.cwd)),
- additionalDirs: [],
- };
+ const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd);
this.projectRoot = onDisk.projectRoot;
this.configPath = onDisk.configPath;
- const dirs = this.trust.isTrusted() ? onDisk.additionalDirs : [];
- if (this.setFileDirs(dirs)) {
+ if (this.setFileDirs(onDisk.additionalDirs)) {
this.onDidChangeEmitter.fire();
}
}
diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosure.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosure.ts
new file mode 100644
index 000000000..bbbe0bc9a
--- /dev/null
+++ b/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosure.ts
@@ -0,0 +1,35 @@
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface TrustGatedMcpServer {
+ readonly name: string;
+ readonly transport: 'stdio' | 'http' | 'sse';
+ readonly command?: string;
+ readonly args?: readonly string[];
+ readonly cwd?: string;
+ readonly url?: string;
+ readonly origin: string;
+}
+
+export interface TrustGatedInstructionSources {
+ readonly agentsMdPaths: readonly string[];
+ readonly skills: readonly string[];
+ readonly agentProfiles: readonly string[];
+ readonly paths: readonly string[];
+}
+
+export interface TrustGatedActivation {
+ readonly mcpServers: readonly TrustGatedMcpServer[];
+ readonly additionalDirs: readonly string[];
+ readonly additionalDirSources: readonly string[];
+ readonly warnings: readonly string[];
+ readonly instructionSources: TrustGatedInstructionSources;
+}
+
+export interface IWorkspaceTrustDisclosure {
+ readonly _serviceBrand: undefined;
+
+ describeGatedActivation(): Promise;
+}
+
+export const IWorkspaceTrustDisclosure: ServiceIdentifier =
+ createDecorator('workspaceTrustDisclosure');
diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosureService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosureService.ts
new file mode 100644
index 000000000..7e7f2adfb
--- /dev/null
+++ b/packages/agent-core-v2/src/workspace/workspaceTrust/trustDisclosureService.ts
@@ -0,0 +1,312 @@
+import { isAbsolute, relative } from 'pathe';
+
+import type { ILogService } from '#/_base/log/log';
+import type { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry';
+import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader';
+import type { IBootstrapService } from '#/app/bootstrap/bootstrap';
+import type { IConfigService } from '#/app/config/config';
+import { findGitWorkTree } from '#/app/git/workTree';
+import { loadMcpServersDetailed, resolveMcpJsonPaths } from '#/app/mcpConfig/configLoader';
+import type { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig';
+import {
+ MERGE_ALL_AVAILABLE_SKILLS_SECTION,
+ type MergeAllAvailableSkillsConfig,
+} from '#/features/skill/catalog/configSection';
+import { projectRoots } from '#/features/skill/catalog/skillRoots';
+import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog';
+import type { McpServerConfig } from '#/mcpCore/config-schema';
+import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
+import type { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader';
+import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
+import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions';
+
+import type {
+ IWorkspaceTrustDisclosure,
+ TrustGatedActivation,
+ TrustGatedInstructionSources,
+ TrustGatedMcpServer,
+} from './trustDisclosure';
+import type { IWorkspaceTrust } from './workspaceTrust';
+
+const EMPTY_INSTRUCTION_SOURCES: TrustGatedInstructionSources = {
+ agentsMdPaths: [],
+ skills: [],
+ agentProfiles: [],
+ paths: [],
+};
+
+const EMPTY_ACTIVATION: TrustGatedActivation = {
+ mcpServers: [],
+ additionalDirs: [],
+ additionalDirSources: [],
+ warnings: [],
+ instructionSources: EMPTY_INSTRUCTION_SOURCES,
+};
+
+const SKILL_DISCLOSURE_TIMEOUT_MS = 1000;
+
+export class WorkspaceTrustDisclosureService implements IWorkspaceTrustDisclosure {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ private readonly context: IWorkspaceContext,
+ private readonly fs: IHostFileSystem,
+ private readonly bootstrap: IBootstrapService,
+ private readonly config: IConfigService,
+ private readonly localConfig: IProjectLocalConfigService,
+ private readonly trust: IWorkspaceTrust,
+ private readonly skills: IWorkspaceSkillCatalog,
+ private readonly agentProfilesLoader: IWorkspaceAgentProfileLoader,
+ private readonly agentProfilesRegistry: IAgentProfileRegistry,
+ private readonly instructions: IWorkspaceInstructionsService,
+ private readonly log: ILogService,
+ ) {}
+
+ async describeGatedActivation(): Promise {
+ await this.trust.ready;
+ if (this.trust.isTrusted()) return EMPTY_ACTIVATION;
+ const warnings: string[] = [];
+ const [mcpServers, configuredDirs, skillRoots, instructionSources] = await Promise.all([
+ this.describeGatedMcpServers().catch((error: unknown) => {
+ this.log.warn(`trust disclosure: MCP scan failed: ${String(error)}`);
+ warnings.push('Could not inspect MCP configuration.');
+ return [];
+ }),
+ this.readGatedAdditionalDirs().catch((error: unknown) => {
+ this.log.warn(`trust disclosure: additional dirs scan failed: ${String(error)}`);
+ warnings.push('Could not inspect additional directory configuration.');
+ return { dirs: [], sources: [] };
+ }),
+ this.readGatedSkillRoots().catch((error: unknown) => {
+ this.log.warn(`trust disclosure: skill roots scan failed: ${String(error)}`);
+ warnings.push('Could not inspect project skill directories.');
+ return [];
+ }),
+ this.describeInstructionSources(warnings).catch((error: unknown) => {
+ this.log.warn(`trust disclosure: instruction sources scan failed: ${String(error)}`);
+ warnings.push('Could not inspect project instructions.');
+ return EMPTY_INSTRUCTION_SOURCES;
+ }),
+ ]);
+ const additionalDirs = [...configuredDirs.dirs, ...skillRoots].filter(
+ (dir, index, all) => all.indexOf(dir) === index,
+ );
+ const additionalDirSources = [...new Set([...configuredDirs.sources, ...skillRoots])];
+ return { mcpServers, additionalDirs, additionalDirSources, instructionSources, warnings };
+ }
+
+ private async describeGatedMcpServers(): Promise {
+ const cwd = this.context.cwd;
+ const homeDir = this.bootstrap.homeDir;
+ const [paths, loaded] = await Promise.all([
+ resolveMcpJsonPaths({ fs: this.fs, cwd, homeDir }),
+ loadMcpServersDetailed({ fs: this.fs, cwd, homeDir, includeProject: true }),
+ ]);
+ const projectPaths = new Set([paths.projectRoot, paths.project]);
+ const servers = Object.entries(loaded.servers)
+ .filter(([name]) => projectPaths.has(loaded.origins[name] ?? ''))
+ .filter(([, config]) => config.enabled !== false)
+ .map(([name, config]) => describeMcpServer(name, config, loaded.origins[name] ?? ''))
+ .toSorted((a, b) => a.name.localeCompare(b.name));
+ return Promise.all(
+ servers.map(async (server) => ({
+ ...server,
+ origin: await realpathOrSelf(this.fs, server.origin),
+ })),
+ );
+ }
+
+ private async readGatedAdditionalDirs(): Promise<{
+ dirs: readonly string[];
+ sources: readonly string[];
+ }> {
+ const result = await this.localConfig.readAdditionalDirs(this.context.cwd);
+ const realRoot = await realpathOrSelf(this.fs, result.projectRoot);
+ const dirs: string[] = [];
+ for (const dir of result.additionalDirs) {
+ const realPath = await realpathOrSelf(this.fs, dir);
+ if (isInsideOrEqualDir(realPath, realRoot)) continue;
+ dirs.push(realPath);
+ }
+ return {
+ dirs,
+ sources: dirs.length > 0 ? [await realpathOrSelf(this.fs, result.configPath)] : [],
+ };
+ }
+
+ private async readGatedSkillRoots(): Promise {
+ if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) return [];
+ const mergeAllAvailableSkills =
+ this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
+ const projectRoot =
+ (await findGitWorkTree(this.fs, this.context.cwd))?.root ?? this.context.cwd;
+ const [roots, realRoot] = await Promise.all([
+ projectRoots(this.context.cwd, { mergeAllAvailableSkills }),
+ realpathOrSelf(this.fs, projectRoot),
+ ]);
+ return roots
+ .filter((root) => !isInsideOrEqualDir(root.path, realRoot))
+ .map((root) => root.path);
+ }
+
+ private async describeInstructionSources(
+ warnings: string[],
+ ): Promise {
+ const [skillsReady] = await Promise.all([
+ waitForReady(this.skills.ready, SKILL_DISCLOSURE_TIMEOUT_MS),
+ this.agentProfilesLoader.ready,
+ this.instructions.ready,
+ ]);
+ if (!skillsReady) {
+ warnings.push('Project skills are still loading; inspect the project skill directories.');
+ }
+ const projectRoot =
+ (await findGitWorkTree(this.fs, this.context.cwd))?.root ?? this.context.cwd;
+ if (this.instructions.snapshot.agentsMdWarning !== undefined) {
+ warnings.push(this.instructions.snapshot.agentsMdWarning);
+ }
+ const skills = this.skills.catalog.listSkills().filter((skill) => skill.source === 'project');
+ const profiles = this.effectiveWorkspaceProfiles();
+ const agentsMdPaths: string[] = [];
+ for (const path of this.instructions.snapshot.agentsMdPaths ?? []) {
+ if (!isInsideOrEqualDir(path, projectRoot)) continue;
+ agentsMdPaths.push(await realpathOrSelf(this.fs, path));
+ }
+ agentsMdPaths.sort();
+ const workspaceProfiles = this.agentProfilesRegistry
+ .entries()
+ .find(
+ (entry) =>
+ entry.sourceId === 'workspace' && entry.workspaceKey === this.context.workspaceId,
+ );
+ const [skillPaths, profilePaths] = await Promise.all([
+ skillsReady
+ ? this.sourceLocations(
+ skills.map((skill) => skill.path),
+ this.skills.catalog.getSkillRoots(),
+ )
+ : this.projectSkillRootPaths(),
+ Promise.all(
+ (profiles.length > 0 ? workspaceProfiles?.contribution.scannedRoots ?? [] : [])
+ .map(async (root) => `${await realpathOrSelf(this.fs, root)}/`),
+ ),
+ ]);
+ return {
+ agentsMdPaths,
+ skills: skills.map((skill) => skill.name).toSorted(),
+ agentProfiles: profiles,
+ paths: [...new Set([...agentsMdPaths, ...skillPaths, ...profilePaths])],
+ };
+ }
+
+ private async sourceLocations(
+ paths: readonly string[],
+ roots: readonly string[],
+ ): Promise {
+ const realRoots = await Promise.all(roots.map((root) => realpathOrSelf(this.fs, root)));
+ realRoots.sort((a, b) => b.length - a.length);
+ const locations = await Promise.all(
+ paths.map(async (path) => {
+ const realPath = await realpathOrSelf(this.fs, path);
+ const root = realRoots.find((root) => isInsideOrEqualDir(realPath, root));
+ return root === undefined ? realPath : `${root}/`;
+ }),
+ );
+ return [...new Set(locations)].toSorted();
+ }
+
+ private async projectSkillRootPaths(): Promise {
+ const mergeAllAvailableSkills =
+ this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
+ return (await projectRoots(this.context.cwd, { mergeAllAvailableSkills }))
+ .map((root) => root.path)
+ .toSorted();
+ }
+
+ private effectiveWorkspaceProfiles(): readonly string[] {
+ const entries = this.agentProfilesRegistry
+ .entries()
+ .filter(
+ (entry) =>
+ entry.workspaceKey === undefined || entry.workspaceKey === this.context.workspaceId,
+ );
+ const builtinNames = new Set(
+ entries
+ .find((entry) => entry.sourceId === BUILTIN_AGENT_PROFILE_SOURCE_ID)
+ ?.contribution.profiles.map((profile) => profile.name) ?? [],
+ );
+ const winners = new Map();
+ const ordered = entries
+ .filter((entry) => entry.sourceId !== BUILTIN_AGENT_PROFILE_SOURCE_ID)
+ .toSorted((a, b) => b.priority - a.priority);
+ for (const entry of ordered) {
+ const seen = new Set();
+ for (const profile of entry.contribution.profiles) {
+ if (seen.has(profile.name)) continue;
+ seen.add(profile.name);
+ if (winners.has(profile.name)) continue;
+ if (builtinNames.has(profile.name) && profile.override !== true) continue;
+ winners.set(profile.name, entry.sourceId);
+ }
+ }
+ return [...winners]
+ .filter(([, sourceId]) => sourceId === 'workspace')
+ .map(([name]) => name)
+ .toSorted();
+ }
+}
+
+function describeMcpServer(
+ name: string,
+ config: McpServerConfig,
+ origin: string,
+): TrustGatedMcpServer {
+ if (config.transport === 'stdio') {
+ return {
+ name,
+ transport: config.transport,
+ command: config.command,
+ args: config.args,
+ cwd: config.cwd,
+ origin,
+ };
+ }
+ return {
+ name,
+ transport: config.transport,
+ url: config.url,
+ origin,
+ };
+}
+
+async function realpathOrSelf(fs: IHostFileSystem, dir: string): Promise {
+ try {
+ return await fs.realpath(dir);
+ } catch {
+ return dir;
+ }
+}
+
+function isInsideOrEqualDir(child: string, parent: string): boolean {
+ const rel = relative(parent, child);
+ return rel === '' || (rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel));
+}
+
+async function waitForReady(ready: Promise, timeoutMs: number): Promise {
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ ready.then(
+ () => true,
+ () => false,
+ ),
+ new Promise((resolve) => {
+ timer = setTimeout(() => {
+ resolve(false);
+ }, timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timer !== undefined) clearTimeout(timer);
+ }
+}
diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts
index 041323112..e8dafb1c4 100644
--- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts
+++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts
@@ -1,6 +1,8 @@
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter } from '#/_base/event';
+import { parseBooleanEnv } from '#/_base/utils/env';
import { defineState } from '#/state/state';
+import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IWorkspaceStateService } from '#/workspace/state/workspaceState';
@@ -9,6 +11,14 @@ import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext
import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust';
import { deleteWorkspaceTrust, readWorkspaceTrust, writeWorkspaceTrust } from './trustRecord';
+export const TRUST_WORKSPACE_ENV = 'PYTHINKER_CODE_TRUST_WORKSPACE';
+
+export function trustWorkspaceEnvTrusted(
+ getEnv: (name: string) => string | undefined,
+): boolean {
+ return parseBooleanEnv(getEnv(TRUST_WORKSPACE_ENV)) === true;
+}
+
export const workspaceTrustTrustedKey = defineState(
'workspaceTrust.trusted',
() => false,
@@ -19,6 +29,7 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust
readonly ready: Promise;
private readonly root: string;
+ private readonly envTrusted: boolean;
private readonly changeEmitter = this._register(new Emitter());
readonly onDidChange = this.changeEmitter.event;
@@ -27,10 +38,12 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
@IWorkspaceStateService private readonly states: IWorkspaceStateService,
@ITelemetryService private readonly telemetry: ITelemetryService,
+ @IBootstrapService bootstrap: IBootstrapService,
) {
super();
this.states.contributeState(workspaceTrustTrustedKey);
this.root = workspace.cwd;
+ this.envTrusted = trustWorkspaceEnvTrusted((name) => bootstrap.getEnv(name));
this.ready = this.initialize();
}
@@ -43,12 +56,12 @@ export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust
}
isTrusted(): boolean {
- return this.trusted;
+ return this.envTrusted || this.trusted;
}
async get(): Promise {
await this.ready;
- return this.trusted;
+ return this.isTrusted();
}
async trust(): Promise {
diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
index 87c41d184..1817b1693 100644
--- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
+++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
@@ -3017,7 +3017,7 @@ describe('FullCompaction', () => {
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
- expect(compactionMaxCompletionTokens).toEqual([32000]);
+ expect(compactionMaxCompletionTokens).toEqual([undefined]);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
@@ -3110,7 +3110,7 @@ describe('FullCompaction', () => {
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
- expect(compactionMaxCompletionTokens).toEqual([undefined]);
+ expect(compactionMaxCompletionTokens).toEqual([Number(maxCompletionTokens)]);
},
);
diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts
index 131fa8771..cdb485829 100644
--- a/packages/agent-core-v2/test/agent/loop/loop.test.ts
+++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts
@@ -105,7 +105,7 @@ describe('Agent loop', () => {
[wire] llm.tools_snapshot { "agentId": "main", "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "" }
[emit] thinking.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "" }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 1, "turnStep": "0.1", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 1, "turnStep": "0.1", "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "" }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 3, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 3, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 3, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 3, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
@@ -197,7 +197,7 @@ describe('Agent loop', () => {
[wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "blocked" }
[wire] llm.tools_snapshot { "agentId": "main", "hash": "31f596034f8d55aea1602cc4b9eecd69b1b09f8b756042d39a55dd676dfcdae0", "tools": [ { "name": "Agent", "description": "Launch a subagent to handle a task. The subagent runs as a same-process loop instance with its own context and wire file. Delegating also keeps the bulk of intermediate file contents out of your own context — you get a conclusion back instead of a pile of dumps.\\n\\nWriting the prompt:\\n- The subagent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics.\\n- Lookups (read this file, run that test): put the exact path or command in the prompt. The subagent should not have to search for things you already know.\\n- Investigations (figure out X, find why Y): give the question, not prescribed steps — fixed steps become dead weight when the premise is wrong.\\n- Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt.\\n\\nUsage notes:\\n- When the task continues earlier work a subagent already did, prefer resuming that agent (pass its \`resume\` id) over spawning a fresh instance — the resumed agent keeps its prior context.\\n- A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply.\\n\\nWhen NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it.\\n\\nOnce a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy.\\n\\n\\nWhen \`run_in_background=true\`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say.\\n\\nDefault to a foreground subagent (omit \`run_in_background\`) when your next step needs its result — foreground hands the result straight back. Reach for \`run_in_background=true\` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (by polling \`TaskOutput\`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead.\\n\\n\\nAvailable agent types (pass via subagent_type):\\n- plan: Read-only implementation planning and architecture design. Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, and architectural trade-off analysis before code changes are made.\\n Tools: Read, Glob, Grep, WebSearch, FetchURL\\n- coder: General software engineering agent — the only subagent type with file-editing tools; use it for any delegated task that must modify code. Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent.\\n Tools: Bash, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, Glob, Grep, Read, Skill, TaskList, TaskOutput, TaskStop, TodoList, WaitFor, WebSearch, FetchURL, Write, mcp__*\\n- explore: Fast codebase exploration with prompt-enforced read-only behavior. Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. \\"src/**/*.yaml\\"), search code for keywords (e.g. \\"database connection\\"), or answer questions about the codebase (e.g. \\"how does the auth module work?\\"). When calling this agent, specify the desired thoroughness level: \\"quick\\" for basic searches, \\"medium\\" for moderate exploration, or \\"thorough\\" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 search queries. Prefer launching multiple explore agents concurrently when investigating independent questions.\\n Tools: Bash, Read, Glob, Grep, WebSearch, FetchURL", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "prompt": { "type": "string", "description": "Full task prompt for the subagent" }, "description": { "type": "string", "description": "Short task description (3-5 words) for UI display" }, "subagent_type": { "description": "One of the available agent types (see \\"Available agent types\\" in this tool description). Defaults to \\"coder\\" when omitted.", "type": "string" }, "resume": { "description": "Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type — the resumed agent keeps its own type, and supplying both is rejected.", "type": "string" }, "run_in_background": { "description": "If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.", "type": "boolean" } }, "required": [ "prompt", "description" ], "additionalProperties": false } }, { "name": "AgentDynamicWorkflow", "description": "Launch multiple subagents from one prompt template, existing agent resumes, or both.\\n\\nUse AgentDynamicWorkflow when many subagents should run the same kind of task over different inputs. The placeholder is exactly \`{{item}}\`. For example, with \`prompt_template\` set to \`Review {{item}} for likely regressions.\` and \`items\` set to \`[\\"src/a.ts\\", \\"src/b.ts\\"]\`, AgentDynamicWorkflow launches two new subagents with those two concrete prompts. For a few differently-shaped tasks, make separate \`Agent\` calls in one message instead.\\n\\nUse \`resume_agent_ids\` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually \`continue\` if no extra information is needed). You may combine \`resume_agent_ids\` with \`items\` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in \`items\`.\\n\\nEach of these is enforced — a violation is rejected before any subagent starts: provide at least 2 \`items\` unless you pass \`resume_agent_ids\`; whenever \`items\` are present, \`prompt_template\` is required and must contain \`{{item}}\`; and the filled-in prompts must be distinct (two items that expand to the same prompt are rejected).\\n\\nUse enough subagents to keep the work focused and parallel. AgentDynamicWorkflow supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.\\n\\nIf \`AgentDynamicWorkflow\` is called, that call must be the only tool call in the response.\\n\\nThis capability is called a \\"dynamic workflow\\" (or simply \\"workflow\\"). Never use the word \\"swarm\\" in the \`description\` field, in subagent prompts, or when talking to the user about this tool.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "description": { "type": "string", "minLength": 1, "description": "Short description for the whole dynamic_workflow." }, "subagent_type": { "description": "Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.", "type": "string", "minLength": 1 }, "prompt_template": { "description": "Prompt template for each subagent. The {{item}} placeholder is replaced with each item value.", "type": "string", "minLength": 1 }, "items": { "description": "Values used to fill {{item}}. Each item launches one new subagent.", "maxItems": 128, "type": "array", "items": { "type": "string", "minLength": 1 } }, "resume_agent_ids": { "description": "Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.", "type": "object", "propertyNames": { "type": "string", "minLength": 1 }, "additionalProperties": { "type": "string", "minLength": 1 } } }, "required": [ "description" ], "additionalProperties": false } }, { "name": "AskUserQuestion", "description": "Use this tool when you need to ask the user questions with structured options during execution. This allows you to:\\n1. Collect user preferences or requirements before proceeding\\n2. Resolve ambiguous or underspecified instructions\\n3. Let the user decide between implementation approaches as you work\\n4. Present concrete options when multiple valid directions exist\\n\\n**When NOT to use:**\\n- When you can infer the answer from context — be decisive and proceed\\n- Trivial decisions that don't materially affect the outcome\\n\\nOverusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action.\\n\\n**Usage notes:**\\n- Users always have an \\"Other\\" option for custom input — don't create one yourself\\n- Use multi_select to allow multiple answers to be selected for a question\\n- Keep option labels concise (1-5 words), use descriptions for trade-offs and details\\n- Each question should have 2-4 meaningful, distinct options\\n- Question texts must be unique across the call, and option labels must be unique within each question\\n- You can ask 1-4 questions at a time; group related questions to minimize interruptions\\n- If you recommend a specific option, list it first and append \\"(Recommended)\\" to its label\\n- The result is JSON with an \`answers\` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked \\"Other\\"); if \`answers\` is empty and a \`note\` says the user dismissed it, they chose not to answer — do not treat this as selecting the recommended option; decide based on context and do not re-ask the same question\\n- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "questions": { "minItems": 1, "maxItems": 4, "type": "array", "items": { "type": "object", "properties": { "question": { "type": "string", "minLength": 1, "description": "A specific, actionable question. End with '?'." }, "header": { "default": "", "description": "Short category tag (max 12 chars, e.g. 'Auth', 'Style').", "type": "string" }, "options": { "minItems": 2, "maxItems": 4, "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string", "minLength": 1, "description": "Concise display text (1-5 words). If recommended, append '(Recommended)'." }, "description": { "default": "", "description": "Brief explanation of trade-offs or implications.", "type": "string" } }, "required": [ "label" ], "additionalProperties": false }, "description": "2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically." }, "multi_select": { "default": false, "description": "Whether the user can select multiple options.", "type": "boolean" } }, "required": [ "question", "options" ], "additionalProperties": false }, "description": "The questions to ask the user (1-4 questions)." }, "background": { "default": false, "description": "Set true to ask in the background and return immediately with a background task_id; you are notified automatically when the user answers — do not poll with TaskOutput while the question is pending.", "type": "boolean" } }, "required": [ "questions" ], "additionalProperties": false } }, { "name": "Bash", "description": "Execute a \`bash\` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.\\n\\n**Translate these to a dedicated tool instead:**\\n- \`cat\` / \`head\` / \`tail\` (known path) → \`Read\`\\n- \`sed\` / \`awk\` (in-place edit) → \`Edit\`\\n- \`echo > file\` / \`cat <\` is fine for listing a directory)\\n- \`grep\` / \`rg\` (search file contents) → \`Grep\`\\n- \`echo\` / \`printf\` (talk to the user) → just output text directly\\n\\nThe dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.\\n\\n**Output:**\\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a \`Command failed with exit code: N\` line; a command killed by its timeout or interrupted by the user ends with its own message instead.\\n\\nIf \`run_in_background=true\`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short \`description\`. Background commands default to a 600s timeout and \`timeout\` is capped at 86400s; set \`disable_timeout=true\` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use \`TaskOutput\` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use \`TaskStop\` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the background-task panel.\\n\\n**Guidelines for safety and security:**\\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the \`cwd\` argument (or use absolute paths) rather than relying on a \`cd\` from an earlier call.\\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to 60s and allow up to 300s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes. The user can also move a running foreground command to the background at any time.\\n- Avoid using \`..\` to access files or directories outside of the working directory.\\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\\n- Run git-mutating commands such as \`git commit\`, \`git push\`, \`git reset\`, and \`git rebase\` only when the user asks for them.\\n\\n**Guidelines for efficiency:**\\n- Use \`&&\` to chain commands that genuinely depend on each other, e.g. \`npm install && npm test\`. Independent read-only commands (separate \`git show\`, \`ls\`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with \`echo\` separators.\\n- Use \`;\` to run commands sequentially regardless of success/failure\\n- Use \`||\` for conditional execution (run second command only if first fails)\\n- Use pipe operations (\`|\`) and redirections (\`>\`, \`>>\`) to chain input and output between commands\\n- Always quote file paths containing spaces with double quotes (e.g., cd \\"/path with spaces/\\")\\n- Compose multi-step logic in a single call with \`if\` / \`case\` / \`for\` / \`while\` control flows.\\n- Prefer \`run_in_background=true\` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.\\n\\n**Commands available:**\\nThe following common command categories are usually available. Availability still depends on the host, so when in doubt run \`which \` first to confirm a command exists before relying on it.\\n- Navigation and inspection: \`ls\`, \`pwd\`, \`cd\`, \`stat\`, \`file\`, \`du\`, \`df\`, \`tree\`\\n- File and directory management: \`cp\`, \`mv\`, \`rm\`, \`mkdir\`, \`touch\`, \`ln\`, \`chmod\`, \`chown\`\\n- Text and data processing: \`wc\`, \`sort\`, \`uniq\`, \`cut\`, \`tr\`, \`diff\`, \`xargs\`\\n- Archives and compression: \`tar\`, \`gzip\`, \`gunzip\`, \`zip\`, \`unzip\`\\n- Networking and transfer: \`curl\`, \`wget\`, \`ping\`, \`ssh\`, \`scp\`\\n- Version control: \`git\`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the \`gh\` CLI when installed — it carries the user's GitHub auth and can return structured JSON\\n- Process and system: \`ps\`, \`kill\`, \`top\`, \`env\`, \`date\`, \`uname\`, \`whoami\`\\n- Language and package toolchains: \`node\`, \`npm\`, \`pnpm\`, \`yarn\`, \`python\`, \`pip\` (use whichever the project actually relies on)\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "command": { "type": "string", "minLength": 1, "description": "The command to execute." }, "cwd": { "description": "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", "type": "string" }, "timeout": { "default": 60, "description": "Optional timeout in seconds for the command to execute. Foreground default 60s, max 300s. Background default 600s, max 86400s. Ignored for background commands when disable_timeout=true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "description": { "description": "A short description for the background task. Required when run_in_background is true.", "type": "string" }, "run_in_background": { "description": "Whether to run the command as a background task.", "type": "boolean" }, "disable_timeout": { "description": "If true, do not apply a timeout to the command. Only applies when run_in_background is true.", "type": "boolean" } }, "required": [ "command" ], "additionalProperties": false } }, { "name": "CreateGoal", "description": "Create a durable, structured goal that the runtime will pursue across multiple turns.\\n\\nCall \`CreateGoal\` only when:\\n\\n- the user explicitly asks you to start a goal or work autonomously toward an outcome, or\\n- a host goal-intake prompt asks you to create one.\\n\\nDo NOT create a goal for greetings, ordinary questions, or vague requests that lack a\\nverifiable completion condition. A goal needs a checkable end state.\\n\\nWhen the request is vague, ask the user for the missing completion criterion before creating\\nthe goal. If the user clearly insists after you warn them that the wording is vague or risky,\\nrespect that and create the goal.\\n\\nInclude a \`completionCriterion\` when the user provides one, or when it can be stated without\\ninventing new requirements. Keep \`objective\` concise; reference long task descriptions by file\\npath rather than pasting them.\\n\\nCreating a goal fails if one already exists, so use \`replace: true\` only when the user explicitly\\nwants to abandon the current goal and start a new one.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "objective": { "type": "string", "minLength": 1, "description": "The objective to pursue. Must have a verifiable end state." }, "completionCriterion": { "description": "How to verify the goal is complete. Include when the user provides one.", "type": "string" }, "replace": { "description": "Replace an existing active, paused, or blocked goal instead of failing.", "type": "boolean" } }, "required": [ "objective" ], "additionalProperties": false } }, { "name": "CronCreate", "description": "Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\\n\\nUses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. \`0 9 * * *\` means 9am local — no timezone conversion needed.\\n\\n## One-shot tasks (recurring: false)\\n\\nFor \\"remind me at X\\" or \\"at , do Y\\" requests — fire once then auto-delete.\\nPin minute/hour/day-of-month/month to specific values:\\n \\"remind me at 2:30pm today to check the deploy\\" → cron: \\"30 14 *\\", recurring: false\\n \\"tomorrow morning, run the smoke test\\" → cron: \\"57 8 *\\", recurring: false\\n\\nOne-shots are best for near-term reminders. A task only fires while its session is still alive (see Session lifetime below), so favor near times — within hours or a few days — rather than scheduling weeks or months ahead.\\n\\n## Recurring jobs (recurring: true, the default)\\n\\nFor \\"every N minutes\\" / \\"every hour\\" / \\"weekdays at 9am\\" requests:\\n \\"*/5 * * * *\\" (every 5 min), \\"0 * * * *\\" (hourly), \\"0 9 * * 1-5\\" (weekdays at 9am local)\\n\\n## Avoid the :00 and :30 minute marks when the task allows it\\n\\nEvery user who asks for \\"9am\\" gets \`0 9\`, and every user who asks for \\"hourly\\" gets \`0 *\` — which means requests from across the planet land on the API at the same instant. When the user's request is approximate, pick a minute that is NOT 0 or 30:\\n \\"every morning around 9\\" → \\"57 8 * * *\\" or \\"3 9 * * *\\" (not \\"0 9 * * *\\")\\n \\"hourly\\" → \\"7 * * * *\\" (not \\"0 * * * *\\")\\n \\"in an hour or so, remind me to...\\" → pick whatever minute you land on, don't round\\n\\nOnly use minute 0 or 30 when the user names that exact time and clearly means it (\\"at 9:00 sharp\\", \\"at half past\\", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.\\n\\n## Coalesce semantics\\n\\nFires are delivered only while the session is idle: a fire that comes due during an active turn is held and delivered at the next idle moment, never injected mid-turn.\\n\\nIf the scheduler slept past multiple ideal fire times (laptop closed, long-running turn, etc.), only **one** fire is delivered when it wakes up. The origin carries \`coalescedCount\` showing how many ideal fires were collapsed into this single delivery. You should treat \`coalescedCount > 1\` as \\"I missed some checks; only the latest state matters\\" rather than running the prompt that many times.\\n\\n## Cron-fire envelope\\n\\nWhen a cron task fires, the prompt you scheduled is re-injected wrapped in an XML envelope that exposes the fire context:\\n\\n\`\`\`\\n\\n\\nyour original prompt text, verbatim\\n \\n \\n\`\`\`\\n\\nThe envelope is parseable. Use \`coalescedCount > 1\` to know multiple ideal fires were collapsed into a single delivery (treat as \\"only the latest state matters\\"), and \`stale=\\"true\\"\` as a cue that the task is past its 7-day threshold.\\n\\n## 7-day stale behavior\\n\\nRecurring tasks that have been alive for more than 7 days fire one\\nfinal time with \`stale: true\` on the envelope, and the system then\\nauto-deletes the task. The flag is the model's notice that this is\\nthe last delivery. If the schedule is still wanted, call \`CronCreate\`\\nagain with the same \`cron\` and \`prompt\` — that resets \`createdAt\` and\\nstarts a fresh 7-day window. One-shot tasks are never marked stale.\\n\\n## Jitter behavior\\n\\nAnti-herd jitter is applied deterministically per task id:\\n - Recurring: ideal fire time is shifted **forward** by an offset ≤ min(10% of the cron period, 15 minutes). A \`*/5 * * * *\` task can drift up to 30s; a \`0 9 * * *\` task can drift up to 15 minutes.\\n - One-shot: only when the ideal fire lands on \`:00\` or \`:30\` of the hour, the fire is pulled **earlier** by ≤ 90 seconds. Other minutes pass through unchanged.\\n\\n## One-shot vs recurring — when to pick which\\n\\nUse \`recurring: false\` for \\"remind me at X\\" style requests, single deadlines, \\"in N minutes do Y\\", and any task that should not repeat. Use \`recurring: true\` for periodic polling (CI status, build watchers, scheduled reports), workday rituals, and anything the user explicitly described as recurring.\\n\\n## Session lifetime\\n\\nCron tasks live in the current session. When you exit, they\\nare persisted under the session homedir; resuming the same session\\nreloads them and the scheduler resumes from each task's \`createdAt\`. Fire times that fell during the offline window are\\ncollapsed into a single delivery via \`coalescedCount\` (and recurring\\ntasks past their 7-day window arrive with \`stale: true\` as their final\\ndelivery).\\n\\nTasks do **not** carry over into a brand-new session — they are scoped\\nto the resumed session id, not to the working directory.\\n\\n## Limits\\n\\nA session holds at most 50 live cron tasks; creating one beyond that is rejected. (The \`prompt\` body is also capped — see its parameter description.) Expressions that never fire within the next 5 years (e.g. \`0 0 31 2 *\`, an impossible date) are rejected at create time.\\n\\n## Returned fields\\n\\n\`id\` (ULID), \`cron\` (the normalized expression), \`humanSchedule\` (English summary), \`recurring\`,\\n\`nextFireAt\` (local ISO timestamp with numeric offset, or null). \`id\` is needed by \`CronDelete\`.\\n\\n## Tell the user how to cancel or modify\\n\\nAfter successfully creating a task, proactively tell the user how they can cancel or modify it later. Users have no direct \`/cron\` command or self-service UI to manage reminders themselves; they must ask the model to make changes (e.g. \\"cancel my 9am reminder\\" or \\"change my daily check to 10am\\"). Include the task \`id\` in your message so the user can reference it.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "cron": { "type": "string", "description": "5-field cron expression in local time: \\"M H DoM Mon DoW\\" (e.g. \\"*/5 * * * *\\" = every 5 minutes; \\"30 14 28 2 *\\" = Feb 28 at 2:30pm local — a pinned date like this repeats yearly unless you also pass recurring: false)." }, "prompt": { "type": "string", "minLength": 1, "maxLength": 8192, "description": "The prompt to enqueue at each fire time. Limited to 8 KiB (UTF-8)." }, "recurring": { "default": true, "description": "true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete. Use false for \\"remind me at X\\" one-shot requests with pinned minute/hour/dom/month.", "type": "boolean" } }, "required": [ "cron", "prompt" ], "additionalProperties": false } }, { "name": "CronDelete", "description": "Cancel a scheduled cron job by id.\\n\\nUse this tool to remove a cron task previously scheduled with\\n\`CronCreate\`. The \`id\` is the ULID value returned by \`CronCreate\`, or\\nshown in the \`id:\` column of \`CronList\` — quote it verbatim, no\\nprefix.\\n\\nBehaviour by task kind:\\n\\n- **Recurring task** (\`recurring: true\`): stops all future fires\\n immediately. The scheduler picks up the deletion on its next tick.\\n- **One-shot task** (\`recurring: false\`): cancels the pending fire if\\n it has not happened yet. One-shots that have already fired\\n auto-delete themselves, so calling \`CronDelete\` on a fired one-shot\\n returns \\"no cron job with id ...\\".\\n\\nNot-found is reported as an error (not a silent no-op) so you can\\ncorrect yourself — typically by calling \`CronList\` to see which ids\\nare actually live, rather than re-trying with the same stale id.\\n\\nRefresh pattern (use when you want a stale recurring schedule to\\ncontinue):\\n\\nStale recurring tasks are auto-deleted by the system after their final\\nfire — there is nothing for \`CronDelete\` to remove at that point. To\\nkeep the schedule running, just call \`CronCreate\` with the same \`cron\`\\nand \`prompt\`. Use \`CronList\`'s \`prompt\` field to recall the original\\ntext after a context compaction.\\n\\n\`CronDelete\` remains the right call when you want to cancel a task\\nthat is still live (recurring not yet stale, or a one-shot still\\npending).\\n\\nGuidelines:\\n\\n- Users have no direct \`/cron\` command or self-service UI to delete\\n tasks themselves; they must ask the model to cancel a reminder.\\n When deleting on behalf of a user, confirm the action and report\\n the result plainly.\\n- Cron deletion is irreversible — there is no undo. If you delete the\\n wrong task, you must re-create it with \`CronCreate\`.\\n- If the model is unsure which id is current (e.g. after a context\\n compaction), call \`CronList\` first rather than guessing.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "id": { "type": "string", "description": "The cron job id (ULID) returned by CronCreate / CronList." } }, "required": [ "id" ], "additionalProperties": false } }, { "name": "CronList", "description": "List all cron jobs currently scheduled in this session.\\n\\nUse this tool to see every pending cron task — both recurring jobs and\\none-shot reminders — that you (or the user) have scheduled with\\n\`CronCreate\`. The output is the entry point for inspecting scheduled\\nwork: it returns a stable id, the original cron expression, a human\\nrendering, the next post-jitter fire time, the recurring flag, the\\ntask's age in days, and a stale indicator.\\n\\nEach record carries:\\n\\n- \`id\` — the task id (a ULID). Pass this to \`CronDelete\` to remove the\\n task, or quote it in user-facing messages when asking for\\n confirmation.\\n- \`cron\` — the verbatim 5-field cron expression as scheduled.\\n- \`humanSchedule\` — plain-English rendering (e.g. \`every 5 minutes\`).\\n- \`prompt\` — the scheduled prompt text, JSON-encoded so embedded\\n newlines stay on one line. Truncated to 200 UTF-8 bytes with\\n \`…(truncated)\` if longer. Use this to recall what a task is for\\n after a context compaction, and as the source for the\\n \`CronCreate\` refresh ritual.\\n- \`nextFireAt\` — local ISO timestamp with an explicit numeric offset\\n for the next fire **after jitter has been applied**. The actual fire\\n may land slightly before or after a round \`:00\` / \`:30\` minute mark\\n due to herd-avoidance jitter; this is the value the scheduler will\\n compare against, so it reflects what will really happen. \`null\` if\\n the expression has no fire in the next 5 years (should not happen\\n for tasks created through \`CronCreate\`, which validates).\\n- \`recurring\` — \`true\` for cadenced jobs, \`false\` for one-shots.\\n- \`ageDays\` — \`(now - createdAt) / day\`, two decimal places. Useful\\n when deciding whether a long-running cron is still relevant.\\n- \`stale\` — \`true\` when a recurring task is older than 7 days. The\\n system **auto-deletes the task after this fire** to bound session\\n lifetime; the \`stale: true\` flag is the model's notice that this is\\n the final delivery. To resume the same schedule, call \`CronCreate\`\\n again with the original \`cron\` and \`prompt\` (the \`prompt\` row above\\n carries it for exactly this purpose). One-shots are never marked\\n stale — they fire at most once by construction.\\n\\nGuidelines:\\n\\n- This tool is read-only and never mutates state, so it is always\\n safe to call (including in plan mode).\\n- Users cannot directly manage cron tasks themselves; if they want to\\n cancel or modify a schedule, route the request through the model\\n (i.e. call \`CronDelete\` or \`CronCreate\` on their behalf).\\n- The empty case returns \`cron_jobs: 0\\\\nNo cron jobs scheduled.\`. Cron\\n tasks survive a resume of the same session but do not bleed into new\\n sessions.\\n- After a context compaction, or whenever you are unsure which cron\\n jobs are live, call this tool to re-enumerate them rather than\\n guessing ids from earlier in the conversation.\\n- Records are separated by a line containing just \`---\`, in the\\n insertion order they were scheduled.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {}, "additionalProperties": false } }, { "name": "Edit", "description": "Perform exact replacements in existing files.\\n\\n- Edit is mandatory for every incremental change, especially small edits. DO NOT use Write or Bash \`sed\`.\\n- Read the target file before every Edit. DO NOT call Edit from memory, stale context, or a guessed \`old_string\`.\\n- Take \`old_string\` and \`new_string\` from the Read output view.\\n- Drop the line-number prefix and tab; match only file content.\\n- \`old_string\` must be unique unless \`replace_all\` is set.\\n- If \`old_string\` is ambiguous, add surrounding context. Use \`replace_all\` only when every occurrence should change — for example, renaming a symbol throughout the file.\\n- Multiple Edit calls may run in one response only when they do not target the same file.\\n- DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's \`old_string\`, causing \`old_string not found\`. Read the file again before the next Edit.\\n- A write lock serializes same-file edits in response order, but serialization does not make stale \`old_string\` valid.\\n- For pure CRLF files, Read shows LF; use LF in \`old_string\` and \`new_string\`, and Edit writes CRLF back.\\n- For mixed endings or lone carriage returns, Read shows carriage returns as \\\\r; include actual \\\\r escapes in those positions.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "path": { "type": "string", "description": "Path to the text file to edit. Relative paths resolve against the working directory; a path outside the working directory must be absolute." }, "old_string": { "type": "string", "minLength": 1, "description": "Exact content to replace from the Read output view, without the line-number prefix. Use LF for pure CRLF files; use actual \\\\r escapes where Read shows \\\\r." }, "new_string": { "type": "string", "description": "Replacement text in the same Read output view. LF is written back as CRLF only for pure CRLF files." }, "replace_all": { "description": "Set true only when every occurrence of old_string should be replaced.", "type": "boolean" } }, "required": [ "path", "old_string", "new_string" ], "additionalProperties": false } }, { "name": "EnterPlanMode", "description": "Use this tool proactively when you're about to start a non-trivial implementation task.\\nGetting user sign-off on your approach via ExitPlanMode before writing code prevents wasted effort.\\n\\nUse it when ANY of these conditions apply:\\n\\n1. New Feature Implementation - e.g. \\"Add a caching layer to the API\\"\\n2. Multiple Valid Approaches - e.g. \\"Optimize database queries\\" (indexing vs rewrite vs caching)\\n3. Code Modifications - e.g. \\"Refactor auth module to support OAuth\\"\\n4. Architectural Decisions - e.g. \\"Add WebSocket support\\"\\n5. Multi-File Changes - involves more than 2-3 files\\n6. Unclear Requirements - need exploration to understand scope\\n7. User Preferences Matter - if user input would materially change the implementation approach, use EnterPlanMode to structure the decision\\n\\nPermission mode notes:\\n- EnterPlanMode enters plan mode automatically without an approval prompt in all permission modes.\\n- In yolo and manual modes, ExitPlanMode still presents the plan to the user for approval.\\n- In auto permission mode, do not use AskUserQuestion; make the best decision from available context.\\n- In auto permission mode, ExitPlanMode exits plan mode without asking the user.\\n- Use EnterPlanMode only when planning itself adds value.\\n\\nWhen NOT to use:\\n- Single-line or few-line fixes (typos, obvious bugs, small tweaks)\\n- User gave very specific, detailed instructions\\n- Pure research/exploration tasks\\n\\nOnce you are in plan mode, a reminder walks you through the workflow (explore → design → write the plan file → \`ExitPlanMode\`) and enforces read-only access. For non-trivial tasks where you are unsure of the codebase structure or relevant code paths, use \`Agent(subagent_type=\\"explore\\")\` to investigate first when the \`Agent\` tool is available.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {}, "additionalProperties": false } }, { "name": "ExitPlanMode", "description": "Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.\\n\\n## How This Tool Works\\n- You should have already written your plan to the plan file specified in the plan mode reminder.\\n- This tool does NOT take the plan content as a parameter - it reads the plan from the file you wrote.\\n- The user will see the contents of your plan file when they review it. In auto permission mode, the tool reads the file and exits plan mode without asking the user.\\n\\n## When to Use\\nOnly use this tool for tasks that require planning implementation steps. For research tasks (searching files, reading code, understanding the codebase), do NOT use this tool.\\n\\n## What a good plan contains\\nList specific, verifiable steps grounded in the actual codebase — real files, functions, and commands, in a sensible order. Each step should be concrete enough to act on and to check. Avoid vague filler like \\"improve performance\\" or \\"add tests\\"; say what to change and where.\\n\\n## Multiple Approaches\\nIf your plan offers multiple alternative approaches, pass them via the \`options\` parameter so the user can choose which one to execute — see the \`options\` parameter for the format, count, and reserved labels. In yolo and manual modes the user sees all options alongside the host's Reject and Revise controls.\\n\\n## Before Using\\n- In auto permission mode, do NOT use AskUserQuestion; make the best decision from available context.\\n- In auto permission mode, this tool exits plan mode without asking the user.\\n- In yolo and manual modes, this tool still presents the plan to the user for approval.\\n- If auto permission mode is not active and you have unresolved questions, use AskUserQuestion first.\\n- If auto permission mode is not active and you have multiple approaches and haven't narrowed down yet, consider using AskUserQuestion first to let the user choose, then write a plan for the chosen approach only.\\n- Once your plan is finalized, use THIS tool to request approval.\\n- Do NOT use AskUserQuestion to ask \\"Is this plan OK?\\" or \\"Should I proceed?\\" - that is exactly what ExitPlanMode does.\\n- If rejected, revise based on feedback and call ExitPlanMode again.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "options": { "description": "When the plan contains multiple alternative approaches, list them here so the user can choose which one to execute. Provide up to 3 options; 2-3 distinct approaches work best when the plan offers a real choice. Passing a single option is allowed and is equivalent to a plain plan approval. Each option represents a distinct approach from the plan. Do not use \\"Reject\\", \\"Revise\\", \\"Approve\\", or \\"Reject and Exit\\" as labels.", "minItems": 1, "maxItems": 3, "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string", "minLength": 1, "maxLength": 80, "description": "Short name for this option (1-8 words). Append \\"(Recommended)\\" if you recommend this option." }, "description": { "default": "", "description": "Brief summary of this approach and its trade-offs.", "type": "string" } }, "required": [ "label" ], "additionalProperties": false } } }, "additionalProperties": false } }, { "name": "FetchURL", "description": "Fetch content from a URL. The content is returned either as the main text extracted from the page, or as the full response body verbatim; a note at the top of the result states which of the two you received, so you can judge how complete it is. Use this when you need to read a specific web page.\\n\\nOnly fully-formed public \`http\`/\`https\` URLs are supported; other schemes and private or loopback addresses are not fetched. Very large pages may be truncated or refused. The fetch carries no login or session for the target site, so pages behind authentication (private repositories, internal dashboards) return a login page or an error instead of the real content — if the text you get back looks like a generic landing or sign-in page, treat that as the login wall, not the answer, and reach the content through a credentialed route (an authenticated CLI or MCP tool) instead.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "url": { "type": "string", "description": "The URL to fetch content from." } }, "required": [ "url" ], "additionalProperties": false } }, { "name": "GetGoal", "description": "Read the current goal: its objective, completion criterion, status, and budgets (turns, tokens,\\ntime, and how much of each remains). When the goal has stopped, it also reports the terminal reason.\\n\\nUse \`GetGoal\` before deciding whether to continue working, report completion, report a blocker,\\nor respect a pause. It returns \`{ \\"goal\\": null }\` when there is no current goal.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {}, "additionalProperties": false } }, { "name": "Glob", "description": "Find files by glob pattern, sorted by modification time (most recent first).\\n\\nPowered by ripgrep. Respects \`.gitignore\`, \`.ignore\`, and \`.rgignore\` by default — set \`include_ignored\` to also match ignored files (e.g. build outputs, \`node_modules\`). Sensitive files (such as \`.env\`) are always filtered out. Matches are files only — directories themselves are never listed; to find a directory, glob for a file inside it (e.g. \`**/fixtures/**\`).\\n\\nGood patterns:\\n- \`*.ts\` — all files matching an extension, at any depth below the search root (a bare pattern without \`/\` matches recursively)\\n- \`src/*.ts\` — files directly inside \`src/\` (one level, not recursive)\\n- \`src/**/*.ts\` — recursive walk with a subdirectory anchor and extension\\n- \`**/*.py\` — recursive walk from the search root for an extension\\n- \`*.{ts,tsx}\` — brace expansion is supported\\n- \`{src,test}/**/*.ts\` — cartesian brace expansion is supported too\\n\\nResults default to 100 matching paths. Use \`offset\` (default 0) and \`head_limit\` (default 100) to page through results. When more matches are available, the result gives the next offset; keep the other search arguments unchanged. Set \`head_limit=0\` to remove the match-count limit. Pages still stay within the character retention limit, including notices: when it is reached, only complete paths are returned, with the next offset for continuation. Large pages are saved to a file with a path for Read.\\n\\nEach call searches the current filesystem again; pagination is not a snapshot, and file changes can shift results between pages. To collect a large list, use \`head_limit=0\`, read any saved output, and follow continuation offsets if the character limit is reached. Search timeouts, traversal errors, and output capture limits can still produce partial results; the result reports these limits, and pagination cannot recover paths that were never collected. Narrow the search and retry when it is incomplete.\\n\\nLarge-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when \`include_ignored\` is set:\\n- \`node_modules/**/*.js\`, \`.venv/**/*.py\`, \`__pycache__/**\`, \`target/**\` can produce thousands of results and waste search time and context. Prefer specific subpaths like \`node_modules/react/src/**/*.js\` unless you need a complete listing.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "pattern": { "type": "string", "description": "Glob pattern to match files." }, "head_limit": { "description": "Maximum number of matching paths to return after offset. Defaults to 100. Pass 0 to remove the match-count limit. The character limit still applies: large pages are saved for Read, and a continuation offset is provided when more paths remain. Search time and output capture limits still apply.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "offset": { "description": "Number of matching paths to skip. Defaults to 0. Each call searches the current filesystem again; changes can shift results between pages.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "path": { "description": "Directory to search. Accepts an absolute path, or a path relative to the current working directory. Defaults to the current working directory.", "type": "string" }, "include_ignored": { "description": "Also match files excluded by ignore files such as \`.gitignore\`, \`.ignore\`, and \`.rgignore\` (for example \`node_modules\` or build outputs). Sensitive files (such as \`.env\`) remain filtered out for safety. VCS metadata directories (\`.git\` and similar) are always skipped, even when this is true. Defaults to false.", "type": "boolean" }, "include_dirs": { "description": "Deprecated and ignored. Results are always files-only — directories are never listed. Accepted only so older calls that still pass this flag are not rejected by parameter validation.", "type": "boolean" } }, "required": [ "pattern" ], "additionalProperties": false } }, { "name": "Grep", "description": "Search file contents using regular expressions (powered by ripgrep).\\n\\nUse Grep when the task is to find unknown content or unknown file locations. Do not use shell \`grep\` or \`rg\` directly; this tool applies workspace path policy, output limits, and sensitive-file filtering.\\nALWAYS use Grep tool instead of running \`grep\` or \`rg\` from a shell — direct shell calls bypass workspace policy, output limits, and sensitive-file filtering.\\nIf you already know a concrete file path and need to inspect its contents, use Read directly instead.\\n\\nWrite patterns in ripgrep regex syntax, which differs from POSIX \`grep\` syntax. For example, braces are special, so escape them as \`\\\\{\` to match a literal \`{\`.\\n\\nHidden files (dotfiles such as \`.gitlab-ci.yml\` or \`.eslintrc.json\`) are searched by default. To also search files excluded by \`.gitignore\` (such as \`node_modules\` or build outputs), set \`include_ignored\` to \`true\`. Sensitive files (such as \`.env\`) are always skipped for safety, even when \`include_ignored\` is \`true\`.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "pattern": { "type": "string", "description": "Regular expression to search for." }, "path": { "description": "File or directory to search. Accepts an absolute path, or a path relative to the current working directory. Omit to search the current working directory. Use Read instead when you already know a concrete file path and need its contents.", "type": "string" }, "glob": { "description": "Optional glob filter for which files to search, e.g. \`*.ts\`. Matched against each file's full absolute path, so a path-anchored pattern like \`src/**/*.ts\` silently matches nothing — use a basename pattern (\`*.ts\`), or anchor with \`**/\` (\`**/src/**/*.ts\`). To scope the search to a directory, use \`path\` instead.", "type": "string" }, "type": { "description": "Optional ripgrep file type filter, such as ts or py. Prefer this over \`glob\` when filtering by language or file kind: it is more efficient and less error-prone than an equivalent glob pattern.", "type": "string" }, "output_mode": { "description": "Shape of the result. \`content\` shows matching lines (honors \`-A\`, \`-B\`, \`-C\`, \`-n\`, and \`head_limit\`); \`files_with_matches\` shows only the paths of files that contain a match, most-recently-modified first (honors \`head_limit\`); \`count_matches\` shows per-file match counts as \`path:count\` lines, preceded by an aggregate total line. Defaults to \`files_with_matches\`.", "type": "string", "enum": [ "content", "files_with_matches", "count_matches" ] }, "-i": { "description": "Perform a case-insensitive search. Defaults to false.", "type": "boolean" }, "-n": { "description": "Prefix each matching line with its line number. Applies only when \`output_mode\` is \`content\`. Defaults to true.", "type": "boolean" }, "-A": { "description": "Number of lines to show after each match. Applies only when \`output_mode\` is \`content\`.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "-B": { "description": "Number of lines to show before each match. Applies only when \`output_mode\` is \`content\`.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "-C": { "description": "Number of lines to show before and after each match. Applies only when \`output_mode\` is \`content\`; takes precedence over \`-A\` and \`-B\`.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "head_limit": { "description": "Limit output to the first N lines/entries after offset. Defaults to 250. Pass 0 for unlimited.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "offset": { "description": "Number of leading lines/entries to skip before applying \`head_limit\`. Use it together with \`head_limit\` to page through large result sets. Defaults to 0.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "multiline": { "description": "Enable multiline matching, where the pattern can span line boundaries and \`.\` also matches newlines. Defaults to false.", "type": "boolean" }, "include_ignored": { "description": "Also search files excluded by ignore files such as \`.gitignore\`, \`.ignore\`, and \`.rgignore\` (for example \`node_modules\` or build outputs). Sensitive files (such as \`.env\`) remain filtered out for safety. VCS metadata directories (\`.git\` and similar) are always skipped, even when this is true. Defaults to false.", "type": "boolean" } }, "required": [ "pattern" ], "additionalProperties": false } }, { "name": "Read", "description": "Read a text file from the local filesystem.\\n\\nThe path may be a \`pythinker-file://\` attachment reference. Its bytes come from the current session's storage, independently of the workspace runtime. Next Read keeps the reference so pagination also works after a fork. For a binary attachment, the error includes a server-local path when available; a converter must be able to access that filesystem. ReadMediaFile accepts the same reference for images and videos.\\n\\nIf the user provides a concrete file path to a text file, call Read directly. Do not \`Glob\`, \`ls\`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use \`ls\` via Bash for a known directory, or Glob when you need files matching a name pattern (Glob lists files only, never directories). Use \`Grep\` only when the task is to search for unknown content or locations.\\n\\nWhen you need several files, prefer to read them in parallel: emit multiple \`Read\` calls in a single response instead of reading one file per turn.\\n\\n- Relative paths resolve against the working directory; a path outside the working directory must be absolute.\\n- Returns text within \`max_chars\`, including line numbers and the status block, preferring complete lines. The configured default is 100000 characters; calls can request up to 500000. Characters use JavaScript string length, not UTF-8 bytes or tokens. Read results are not spilled or shortened again by the general tool-output limit.\\n- Omit \`n_lines\` to read toward the end of the file. There is no fixed line-count cap. When the task requires the full text of a large file, request a larger \`max_chars\`, up to 500000, in the first call.\\n- Page larger files with \`line_offset\` (1-based start line) and \`n_lines\`. If the result is incomplete, copy the \`Next Read\` arguments in the status block to continue without gaps or overlaps. Do not answer from a partial page when the task requires the remaining content.\\n- If a single line cannot fit on its own page, Read returns a fragment and reports its column range. Continue on the same line with the supplied \`column_offset\`; do not insert a newline between fragments of one source line. A partial line still counts toward the remaining \`n_lines\` until its ending is returned.\\n- \`column_offset\` is a zero-based position in the first line's displayed text, excluding its line-number prefix. It is supported only for forward reads. Offsets past the line or inside a Unicode surrogate pair return an error. Continuation refers to the current file contents; start a new read if the file changed.\\n- Pythinker Code agent event logs (\`wire.jsonl\` under the sessions directory) follow the same character budget; locate a record with Grep, read it with \`n_lines=1\`, and follow \`Next Read\` to retrieve every fragment of a long record.\\n- Sensitive files (\`.env\` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: \`.env.example\` / \`.env.sample\` / \`.env.template\` and public SSH keys such as \`id_rsa.pub\` read normally.\\n- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and checked with strict decoding first. If malformed sequences are found, Read returns readable text with U+FFFD replacements and a lossy-decoding warning on every page; do not treat this view as exact original text. The status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with \`iconv\`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused.\\n- Negative \`line_offset\` reads from the end of the file (for example, -100 reads the last 100 lines). If the requested tail range exceeds the character budget, the newest complete lines in that range are returned first; \`Next Read\` covers the omitted earlier range. If no complete line fits, Read reports this and supplies forward \`Next Read\` arguments for the entire unread range. Omit \`column_offset\` when using a negative \`line_offset\`.\\n- Output format: \`\\\\t\` per line.\\n- A \`... \` status block is appended after the file content. It reports the actual returned range, total lines, effective character budget, whether the requested range is complete, and whether EOF was reached. The block is not part of the file itself.\\n- Pure CRLF files are displayed with LF line endings; \`Edit\` matches this output and preserves CRLF when writing back.\\n- Mixed or lone carriage-return line endings are shown as \`\\\\r\` and require exact \`Edit.old_string\` escapes.\\n- After a successful \`Edit\`/\`Write\`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "path": { "type": "string", "description": "Path to a text file or a pythinker-file:// attachment reference in the current session. Relative filesystem paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use \`ls\` via Bash for a known directory, or Glob for pattern search." }, "line_offset": { "description": "The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file (for example, -100 reads the last 100 lines).", "anyOf": [ { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, { "type": "integer", "minimum": -9007199254740991, "exclusiveMaximum": 0 } ] }, "column_offset": { "description": "Zero-based character offset within the first line of a forward read, excluding its line-number prefix. Uses JavaScript string length in the displayed text. Copy continuation arguments from the previous result to resume a long line.", "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "n_lines": { "description": "The number of lines to read. Omit to read toward the end of the file. Results are bounded by max_chars, with continuation arguments when the requested range is incomplete.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "max_chars": { "description": "Maximum characters in the returned text, including line numbers and status. Omit for the configured default; requests above the configured maximum are capped.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 } }, "required": [ "path" ], "additionalProperties": false } }, { "name": "SetGoalBudget", "description": "Set a hard budget limit for the current goal.\\n\\nUse this only when the user clearly gives a runtime limit, such as:\\n\\n- \\"stop after 20 turns\\"\\n- \\"use no more than 500k tokens\\"\\n- \\"finish within 30 minutes\\"\\n\\nDo not invent limits. Do not call this for vague wording such as \\"spend some time\\" or\\n\\"try to be quick\\".\\n\\nIf the user gives a compound time, convert it to one supported unit before calling this tool.\\nFor example, \\"2 hours and 3 minutes\\" can be set as \`value: 123, unit: \\"minutes\\"\`.\\n\\nA time budget must be at least 1 second and convert to a finite number of milliseconds.\\nThere is no upper duration limit. Turn and token budgets must be positive and are rounded\\nto the nearest whole number (minimum 1).\\n\\nSupported units:\\n\\n- \`turns\`\\n- \`tokens\`\\n- \`milliseconds\`\\n- \`seconds\`\\n- \`minutes\`\\n- \`hours\`\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "value": { "type": "number", "exclusiveMinimum": 0, "description": "The positive numeric budget value." }, "unit": { "type": "string", "enum": [ "turns", "tokens", "milliseconds", "seconds", "minutes", "hours" ] } }, "required": [ "value", "unit" ], "additionalProperties": false } }, { "name": "Skill", "description": "Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do not re-invoke a skill to repeat work already done: if a \`\` block for it with the same \`args\` is already present in the conversation, follow those instructions directly instead of calling the tool again. Do call the tool again when you need the skill with different arguments — the loaded block was expanded with the earlier \`args\` and will not reflect new inputs.", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "skill": { "type": "string", "description": "The exact name of the skill to invoke, spelled as it appears in the current skill listing (e.g. \\"commit\\", \\"pdf\\")." }, "args": { "description": "Optional argument string for the skill, written like a command line (e.g. \`-m \\"fix bug\\"\`, \`123\`, a file path). It is split on whitespace (quotes group a token) and expanded into the skill's placeholders ($NAME, $1, $ARGUMENTS); if the skill body has no placeholders, the whole string is still appended as a trailing \`ARGUMENTS:\` line. Omit it only when there is nothing to pass.", "type": "string" } }, "required": [ "skill" ], "additionalProperties": false } }, { "name": "TaskList", "description": "List background tasks and their current status.\\n\\nUse this tool to discover which background tasks exist and where each one\\nstands. It is the entry point for inspecting background work: it returns a\\ntask ID, status, and description for every task it reports, plus the command,\\nPID, and (once finished) exit code for shell tasks, and a stop reason for any\\ntask that ended early.\\n\\nGuidelines:\\n\\n- After a context compaction, or whenever you are unsure which background\\n tasks are running or what their task IDs are, call this tool to\\n re-enumerate them instead of guessing a task ID.\\n- Prefer the default \`active_only=true\`, which lists only non-terminal tasks.\\n Pass \`active_only=false\` only when you specifically need to see tasks that\\n have already finished. With \`active_only=false\` the result may also include\\n \`lost\` tasks — tasks left over from a previous process that can no longer be\\n inspected or controlled; treat them as already terminated.\\n- \`limit\` caps how many tasks are returned. It accepts a value between 1 and\\n 100 and defaults to 20 when omitted.\\n- This tool only lists tasks; it does not return their output. Use it first\\n to locate the task ID you need, then call \`TaskOutput\` with that ID to read\\n the task's output and details.\\n- This tool is read-only and does not change any state, so it is always safe\\n to call, including in plan mode.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "active_only": { "default": true, "description": "Whether to list only non-terminal background tasks.", "type": "boolean" }, "limit": { "default": 20, "description": "Maximum number of tasks to return.", "type": "integer", "minimum": 1, "maximum": 100 } }, "additionalProperties": false } }, { "name": "TaskOutput", "description": "Retrieve a snapshot of a running or completed background task.\\n\\nUse this after \`Bash(run_in_background=true)\`, \`Agent(run_in_background=true)\`, or \`AskUserQuestion(background=true)\` to check progress, or to read the output of a task that has already completed.\\n\\nGuidelines:\\n- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives.\\n- This tool is always non-blocking: it returns the current status/output snapshot immediately and never waits for the task to finish.\\n- Do not use TaskOutput to wait for a result you need before continuing — if your next step depends on the task's result, run that task in the foreground instead. TaskOutput is for a deliberate progress check you will act on without blocking, not a way to sit and wait for a background task you just launched.\\n- This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log.\\n- For a terminal task, the metadata also explains why it ended. A shell command that runs to completion reports \`status: completed\` on a zero exit, or \`status: failed\` with its non-zero \`exit_code\` — judge that failure from the \`exit_code\`, because a plain command failure carries no \`stop_reason\` and no \`terminal_reason\`. \`terminal_reason\` is a categorical label emitted only when the end is not an ordinary exit: \`timed_out\` when the deadline aborted it, \`stopped\` when it was explicitly stopped, or \`failed\` when it errored without producing an exit code; the \`stopped\` and \`failed\` cases also carry a human-readable \`stop_reason\`. A task that finished on its own with a clean exit carries neither \`stop_reason\` nor \`terminal_reason\`.\\n- The full, never-truncated log is always available at output_path; use the \`Read\` tool with that path to page through it, whether or not the preview was truncated.\\n- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "task_id": { "type": "string", "description": "The background task ID to inspect." } }, "required": [ "task_id" ], "additionalProperties": false } }, { "name": "TaskStop", "description": "Stop a running background task.\\n\\nOnly use this when a task must genuinely be cancelled — for a task that is\\nfinishing normally, wait for its completion notification or inspect it with\\n\`TaskOutput\` instead of stopping it.\\n\\nGuidelines:\\n- This is a general-purpose stop capability for any background task. It is not\\n a bash-specific kill.\\n- Stopping a task is destructive: it may leave partial side effects behind.\\n Use it with care.\\n- If the task has already finished, this tool simply returns its current\\n status.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "task_id": { "type": "string", "description": "The background task ID to stop." }, "reason": { "default": "Stopped by TaskStop", "description": "Short reason recorded when the task is stopped.", "type": "string" } }, "required": [ "task_id" ], "additionalProperties": false } }, { "name": "TodoList", "description": "Use this tool to maintain a structured TODO list as you work through a multi-step task. Use it proactively and often when progress tracking helps the current work. This is especially useful in long-running investigations and implementation tasks with several tool calls; in plan mode, write the plan to the plan file rather than tracking it here.\\n\\n**When to use:**\\n- Multi-step tasks that span several tool calls\\n- Tracking investigation progress across a large codebase search\\n- Planning a sequence of edits before making them\\n- After receiving new multi-step instructions, capture the requirements as todos\\n- Before starting a tracked task, mark exactly one item as \`in_progress\`\\n- Immediately after finishing a tracked task, mark it \`done\`; do not batch completions at the end\\n\\n**When NOT to use:**\\n- Single-shot answers that complete in one or two tool calls\\n- Trivial requests where tracking adds no clarity\\n- Purely conversational or informational replies\\n\\n**Avoid churn:**\\n- Do not re-call this tool when nothing meaningful has changed since the last call — update the list only after real progress.\\n- When unsure of the current state, call query mode first (omit \`todos\`) to check the list before deciding what to update.\\n- If no available tool can move any task forward, tell the user where you are stuck instead of repeatedly re-ordering the same todos.\\n\\n**How to use:**\\n- Call with \`todos: [...]\` to replace the full list. Statuses: pending / in_progress / done.\\n- Call with no \`todos\` argument to retrieve the current list without changing it.\\n- Call with \`todos: []\` to clear the list.\\n- Keep titles short and actionable (e.g. \\"Read session-control.ts\\", \\"Add planMode flag to TurnManager\\").\\n- Update statuses as you make progress.\\n- When work is underway, keep exactly one task \`in_progress\`.\\n- Only mark a task \`done\` when it is fully accomplished.\\n- Never mark a task \`done\` if tests are failing, implementation is partial, unresolved errors remain, or required files/dependencies could not be found.\\n- If you encounter a blocker, keep the blocked task \`in_progress\` or add a new pending task describing what must be resolved.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "todos": { "description": "The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.", "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "description": "Short, actionable title for the todo." }, "status": { "type": "string", "enum": [ "pending", "in_progress", "done" ], "description": "Current status of the todo." } }, "required": [ "title", "status" ], "additionalProperties": false } } }, "additionalProperties": false } }, { "name": "UpdateGoal", "description": "Set the status of the current goal. This is how you resume, complete, or block an autonomous goal.\\n\\n- \`active\` — resume a paused or blocked goal when the user explicitly asks you to work on that goal.\\n- \`complete\` — the objective is satisfied and any stated validation has passed. The goal ends and a completion summary is recorded. Before using this, verify the current state against the actual objective and every explicit requirement. Treat weak or indirect evidence as not complete. Do not use \`complete\` merely because a budget is nearly exhausted or you want to stop.\\n- \`blocked\` — a genuine impasse prevents useful progress: an external condition, required user input, missing credentials or permissions, a persistent technical failure, or an impossible, unsafe, or contradictory objective. For non-terminal blockers, do not use \`blocked\` the first time you hit the blocker. The same blocking condition must repeat for at least 3 consecutive goal turns before you call \`blocked\`, counting the original/user-triggered turn and automatic continuations. If a previously blocked goal is resumed, treat the resumed run as a fresh blocked audit. If the objective itself is impossible, unsafe, or contradictory, call \`blocked\` in the same turn instead of running more goal turns. Do not use \`blocked\` because the work is large, hard, slow, uncertain, incomplete, still needs validation, would benefit from clarification, or needs more goal turns. Once the 3-turn threshold is met and you cannot make meaningful progress without user input or an external-state change, call \`blocked\` instead of leaving the goal active.\\n\\nMost active goal turns should not call this tool. If you complete one useful slice of work and material work remains, end the turn normally without calling UpdateGoal; the runtime will prompt you to continue in the next goal turn. Call \`complete\` only when all required work is done, any stated validation has passed, and there is no useful next action. Do not call \`complete\` after only producing a plan, summary, first pass, or partial result. Call \`blocked\` only after the blocked audit threshold is met. If you call \`blocked\`, you will be prompted to explain the blocker in your next message. Setting the status is the machine-readable signal; the completion summary or blocker explanation is yours to write in the following message.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "status": { "type": "string", "enum": [ "active", "complete", "blocked" ], "description": "The lifecycle status to set for the current goal. Use \`blocked\` for impossible, unsafe, or contradictory objectives, or after the same non-terminal blocking condition repeats for at least 3 consecutive goal turns." } }, "required": [ "status" ], "additionalProperties": false } }, { "name": "WaitFor", "description": "Wait for background tasks to finish without ending the current turn.\\n\\nUse this when your next step depends on the result of a running background task (a sub-agent, a background bash command, or a background AskUserQuestion). The call suspends inside the current turn until the task finishes or the timeout elapses, then returns the outcome so you can keep working in the same turn. While waiting, no LLM requests are made.\\n\\nGuidelines:\\n\\n- Do not call WaitFor right after dispatching work whose result you do not need yet — finished background tasks notify you automatically. WaitFor is for the moment you genuinely cannot proceed without a result.\\n- \`timeout\` is required, in seconds, capped at 600. To wait longer, call WaitFor again; waking up periodically also lets you re-evaluate the situation.\\n- A timeout is not an error: the result lists the tasks that are still running, and you decide whether to wait again or do other work meanwhile.\\n- Without \`task_id\`, the wait ends as soon as any background task that was running at call time finishes. Tasks started during the wait are not covered by it; their completion arrives via the usual automatic notification.\\n- With \`task_id\`, the wait ends when that task finishes. An unknown \`task_id\` is an error; a task that has already finished returns immediately.\\n- When no background tasks are running, WaitFor returns immediately without waiting.\\n- When the wait ends because a task finished, the result also lists other tasks that finished during the wait window, so failures surface with context.\\n- Waiting has no side effects on the waited tasks: WaitFor never stops a task, and interrupting the wait (for example, a user interruption) leaves every task running.\\n- A finished task's result is delivered exactly once: tasks reported by WaitFor do not also produce an automatic completion notification.\\n- You can only wait for background tasks started by this agent; task IDs belonging to other agents are unknown here.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "timeout": { "type": "integer", "exclusiveMinimum": 0, "maximum": 600, "description": "Maximum time to wait, in seconds (1-600). A timeout is not an error: the tool returns the tasks that are still running, and you can call it again to keep waiting." }, "task_id": { "description": "The background task ID to wait for. When omitted, the wait ends as soon as any background task that was running at call time finishes.", "type": "string" } }, "required": [ "timeout" ], "additionalProperties": false } }, { "name": "Write", "description": "Create, append to, or replace a file entirely.\\n\\n- Missing parent directories are created automatically (like \`mkdir(parents=True, exist_ok=True)\`).\\n- Mode defaults to overwrite; append adds content at EOF without adding a newline.\\n- Write is NOT ALLOWED for incremental changes to existing files, including trivial, one-line, quick, or cosmetic edits. Use Edit instead.\\n- Use Write only when the file does not exist, you intend a complete replacement, or the new contents have little continuity with the old contents.\\n- Do not create unsolicited documentation files (\`*.md\` write-ups, \`README\`s, summaries) just because a task finished — write one only when the user asks for it, or when a task or project instruction requires it (e.g. the plan-mode plan file, created with Write when plan mode directs you to, or a changeset the repo mandates).\\n- Read before overwriting an existing file.\\n- Write ignores the Read/Edit line-number view. NEVER include line prefixes.\\n- Write outputs content literally, including supplied line endings: \\\\n stays LF, \\\\r\\\\n stays CRLF.\\n- For new content too large for one call, overwrite the first chunk, then append subsequent chunks. Never chunk Write to modify an existing file.\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Missing parent directories are created automatically." }, "content": { "type": "string", "description": "Raw full file content to write exactly as provided. This does not use the Read/Edit text view." }, "mode": { "description": "Write mode. Defaults to overwrite. append adds content to the end exactly as provided and does not add a newline.", "type": "string", "enum": [ "overwrite", "append" ] } }, "required": [ "path", "content" ], "additionalProperties": false } } ], "time": "" }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "31f596034f8d55aea1602cc4b9eecd69b1b09f8b756042d39a55dd676dfcdae0", "messageCount": 1, "turnStep": "0.1", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "31f596034f8d55aea1602cc4b9eecd69b1b09f8b756042d39a55dd676dfcdae0", "messageCount": 1, "turnStep": "0.1", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] agent.status.updated { "time": "", "agentId": "main", "contextTokens": 8 }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
@@ -503,7 +503,7 @@ describe('Agent loop', () => {
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "I will look it up." }
[wire] llm.tools_snapshot { "agentId": "main", "hash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "tools": [ { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false } } ], "time": "" }
[emit] tool.call.delta { "time": "", "agentId": "main", "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 1, "turnStep": "0.1", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 1, "turnStep": "0.1", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] agent.status.updated { "time": "", "agentId": "main", "contextTokens": 20 }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
@@ -533,7 +533,7 @@ describe('Agent loop', () => {
[emit] turn.step.started { "time": "", "agentId": "main", "turnId": 0, "step": 2, "stepId": "" }
[wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 2 }, "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "The lookup result is lookup-result." }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 3, "turnStep": "0.2", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 3, "turnStep": "0.2", "time": "" }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 25, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] token_counting.measured { "agentId": "main", "length": 4, "tokens": 37, "time": "" }
@@ -1962,22 +1962,56 @@ describe('interruption reminder', () => {
await ctx.undoHistory(1);
- expect(
- ctx.contextData().history.map((message) => ({
- role: message.role,
- origin: message.origin,
- })),
- ).toEqual([
- {
- role: 'user',
- origin: { kind: 'injection', variant: 'interruption', ownerPromptId: undefined },
- },
- ]);
+ expect(ctx.contextData().history).toEqual([]);
+ await ctx.expectResumeMatches();
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
await ctx.untilTurnEnd();
+ expect(interruptionReminders()).toHaveLength(0);
+ });
+
+ it('keeps an interruption reminder when undo only removes a later turn', async () => {
+ ctx.mockNextResponse({ type: 'text', text: 'partial answer' });
+ const subscription = cancelOnFirstDelta();
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
+ await ctx.untilTurnEnd();
+ subscription.dispose();
+ const interruptedHistory = ctx.contextData().history;
+
+ ctx.mockNextResponse({ type: 'text', text: 'second answer' });
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
+ await ctx.untilTurnEnd();
+
+ await ctx.undoHistory(1);
+
+ expect(ctx.contextData().history).toEqual(interruptedHistory);
+ expect(interruptionReminders()).toHaveLength(1);
+ await ctx.expectResumeMatches();
+
+ await ctx.undoHistory(1);
+
+ expect(ctx.contextData().history).toEqual([]);
+ });
+
+ it('undo removes a cancelled retry reminder with its preceding user prompt', async () => {
+ ctx.mockNextResponse({ type: 'text', text: 'first answer' });
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
+ await ctx.untilTurnEnd();
+
+ ctx.mockNextResponse({ type: 'text', text: 'partial retry' });
+ const subscription = cancelOnFirstDelta();
+ const retry = submitPromptTurn(loop, {
+ message: { role: 'user', content: [] },
+ meta: { origin: { kind: 'retry' } },
+ }).turn;
+ await expect(retry.result).resolves.toMatchObject({ type: 'cancelled' });
+ subscription.dispose();
expect(interruptionReminders()).toHaveLength(1);
+
+ await ctx.undoHistory(1);
+
+ expect(ctx.contextData().history).toEqual([]);
});
it('drops unsigned thinking but keeps signed thinking on user cancel', async () => {
diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts
index 4768c3a9c..e71572862 100644
--- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts
+++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts
@@ -1,6 +1,3 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
import * as posixPath from 'node:path/posix';
import { Readable } from 'node:stream';
@@ -8,7 +5,7 @@ import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm-adapter/contract
import type { ContentPart } from '#human/llm/message';
import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors';
import { Jimp } from 'jimp';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { Emitter } from '#/_base/event';
import {
@@ -16,7 +13,6 @@ import {
setUnexpectedErrorHandler,
} from '#/_base/errors/unexpectedError';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { Runtime } from '#/runtime/runtime';
@@ -163,7 +159,6 @@ function createTestFs(files: Record): IHostFileSystem {
size: file?.size ?? file?.data.length ?? 0,
};
}),
- realpath: vi.fn(async (path: string) => path),
} as unknown as IHostFileSystem;
}
@@ -1265,42 +1260,3 @@ describe('createVideoUploader', () => {
expect(result.output).toContain('Convert it to JPEG first');
});
});
-
-describe('ReadMediaFileTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'read-media-symlink-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- function makeRealFsTool() {
- return new ReadMediaFileTool(
- runtimeFor(new HostFileSystem()),
- { workspaceDir: wsDir, additionalDirs: [] },
- capabilities(),
- );
- }
-
- it('rejects reading media through a symlink that points outside the workspace', async () => {
- const target = join(outsideDir, 'secret.png');
- await writeFile(target, pngBuffer());
- const link = join(wsDir, 'pic.png');
- await symlink(target, link);
-
- const result = await execute(makeRealFsTool(), { path: link });
-
- expect(result.isError).toBe(true);
- expect(result.output).toContain('symbolic link');
- });
-
-});
diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts
index f65ebea24..818f62952 100644
--- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts
+++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts
@@ -16,6 +16,7 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { IAgentStateService } from '#/agent/state/agentState';
import { AgentStateService } from '#/agent/state/agentStateService';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
+import { IEventBus } from '#/app/event/eventBus';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
@@ -62,11 +63,21 @@ let dispatcher: IEventDispatcher;
let svc: IAgentPermissionModeService;
let reminderLive = false;
let bootstrapEnv: NodeJS.ProcessEnv;
+let busEvents: { type: string; permission?: PermissionMode }[];
+
+const recordingEventBus: IEventBus = {
+ _serviceBrand: undefined,
+ publish: (event) => {
+ busEvents.push(event as { type: string; permission?: PermissionMode });
+ },
+ subscribe: () => ({ dispose: () => {} }),
+};
beforeEach(() => {
registeredInjection = undefined;
reminderLive = false;
bootstrapEnv = {};
+ busEvents = [];
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
@@ -76,7 +87,7 @@ beforeEach(() => {
ix.set(IAgentStateService, new AgentStateService());
ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService));
log = ix.get(IAppendLogStore);
- registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
+ registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log, eventBus: recordingEventBus });
dispatcher = registerTestEventDispatcher(ix);
svc = ix.get(IAgentPermissionModeService);
});
@@ -148,6 +159,16 @@ describe('AgentPermissionModeService (wire-backed)', () => {
expect('payload' in records[0]!).toBe(false);
});
+ it('publishes agent.status.updated with the permission slice on setMode', () => {
+ svc.setMode('auto');
+ svc.setMode('yolo');
+
+ expect(busEvents).toMatchObject([
+ { type: 'agent.status.updated', permission: 'auto' },
+ { type: 'agent.status.updated', permission: 'yolo' },
+ ]);
+ });
+
it('persists an explicitly configured manual mode when it matches the initial value', async () => {
svc.setMode('manual');
diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts
index 55556aa95..5ff675d32 100644
--- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts
+++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts
@@ -596,41 +596,8 @@ describe('AgentPermissionPolicyService git cwd write approval', () => {
});
});
- it('asks for .pythinker-code/local.toml writes inside the git cwd', async () => {
- await expect(evaluate({
- toolName: 'Write',
- args: { path: '.pythinker-code/local.toml', content: 'x' },
- accesses: ToolAccesses.writeFile(join(workspaceDir, '.pythinker-code/local.toml')),
- })).resolves.toMatchObject({
- policyName: 'fallback-ask',
- result: { kind: 'ask' },
- });
- });
- it('asks for .pythinker-code/local.toml edits inside the git cwd', async () => {
- await expect(evaluate({
- toolName: 'Edit',
- args: { path: '.pythinker-code/local.toml', old_string: 'a', new_string: 'b' },
- accesses: ToolAccesses.readWriteFile(join(workspaceDir, '.pythinker-code/local.toml')),
- })).resolves.toMatchObject({
- policyName: 'fallback-ask',
- result: { kind: 'ask' },
- });
- });
- it.each(['local.toml', '.pythinker-code/local.toml.bak', '.pythinker-code/other.toml'])(
- 'still approves %s inside the git cwd',
- async (relativePath) => {
- await expect(evaluate({
- toolName: 'Write',
- args: { path: relativePath, content: 'x' },
- accesses: ToolAccesses.writeFile(join(workspaceDir, relativePath)),
- })).resolves.toMatchObject({
- policyName: 'git-cwd-write-approve',
- result: { kind: 'approve' },
- });
- },
- );
it('asks for git control files before git-cwd approval', async () => {
await expect(evaluate({
diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
index 2aacfd465..7d7e6cf48 100644
--- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
+++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as posixPath from 'node:path/posix';
@@ -50,8 +50,7 @@ function createSpiedEditFs(
const readText = options.readText ?? vi.fn(async () => '');
const writeText = options.writeText ?? vi.fn(async () => undefined);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 }));
- const realpath = vi.fn(async (path: string) => path);
- const fs = { readText, writeText, stat, realpath } as unknown as IHostFileSystem;
+ const fs = { readText, writeText, stat } as unknown as IHostFileSystem;
return { fs, readText, writeText };
}
@@ -612,41 +611,3 @@ describe('EditTool', () => {
}
});
});
-
-describe('EditTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(() => {
- disposables = new DisposableStore();
- });
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'edit-symlink-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- });
-
- afterEach(async () => {
- disposables.dispose();
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('rejects editing through a symlink that points outside the workspace', async () => {
- const target = join(outsideDir, 'config.txt');
- await writeFile(target, 'alpha beta');
- const link = join(wsDir, 'config.txt');
- await symlink(target, link);
- const tool = buildTool(new HostFileSystem(), createTestEnv(), stubWorkspaceContext(wsDir));
-
- const result = await execute(tool, { path: link, old_string: 'beta', new_string: 'gamma' });
-
- expect(result).toMatchObject({ isError: true });
- expect(result.output).toContain('symbolic link');
- await expect(readFile(target, 'utf8')).resolves.toBe('alpha beta');
- });
-
-});
diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts
index 18ec8dbe3..aafe30dee 100644
--- a/packages/agent-core-v2/test/app/git/gitService.test.ts
+++ b/packages/agent-core-v2/test/app/git/gitService.test.ts
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
-import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -173,78 +173,6 @@ describe('GitService', () => {
});
});
- describe('repo-local config hardening', () => {
- it.skipIf(process.platform === 'win32')(
- 'does not execute fsmonitor or external diff commands from repo config',
- async () => {
- const outside = mkdtempSync(join(tmpdir(), 'git-service-evil-'));
- try {
- const marker = join(outside, 'ran');
- const helper = join(outside, 'helper.sh');
- writeFileSync(helper, `#!/bin/sh\ntouch "${marker}"\n`);
- chmodSync(helper, 0o755);
-
- writeFileSync(join(repo, 'a.txt'), 'line1\n');
- commitAll('init');
- git(repo, 'config', 'core.fsmonitor', helper);
- git(repo, 'config', 'diff.external', helper);
- writeFileSync(join(repo, 'a.txt'), 'line1\nline2\n');
-
- const status = await service.status(repo);
- expect(status.entries).toEqual({ 'a.txt': 'modified' });
- const diff = await service.diff(repo, 'a.txt', join(repo, 'a.txt'));
- expect(diff.diff).toContain('+line2');
-
- expect(existsSync(marker)).toBe(false);
- } finally {
- rmSync(outside, { recursive: true, force: true });
- }
- },
- 15000,
- );
-
- it.skipIf(process.platform === 'win32')(
- 'does not execute repo-configured clean or process filters',
- async () => {
- const outside = mkdtempSync(join(tmpdir(), 'git-service-filter-'));
- try {
- const cleanMarker = join(outside, 'clean-ran');
- const processMarker = join(outside, 'process-ran');
- writeFileSync(
- join(repo, '.gitattributes'),
- '*.txt filter=evilclean\n*.md filter=evilproc\n',
- );
- writeFileSync(join(repo, 'a.txt'), 'line1\n');
- writeFileSync(join(repo, 'b.md'), 'mark1\n');
- commitAll('init');
- git(repo, 'config', 'filter.evilclean.clean', `touch "${cleanMarker}"`);
- git(repo, 'config', 'filter.evilclean.smudge', 'cat');
- git(repo, 'config', 'filter.evilproc.process', `touch "${processMarker}"; cat`);
-
- writeFileSync(join(repo, 'a.txt'), 'line2\n');
- writeFileSync(join(repo, 'b.md'), 'mark2\n');
-
- git(repo, 'status', '--porcelain');
- expect(existsSync(cleanMarker)).toBe(true);
- git(repo, 'diff', '--numstat', 'HEAD', '--');
- expect(existsSync(processMarker)).toBe(true);
- rmSync(cleanMarker);
- rmSync(processMarker);
-
- const status = await service.status(repo);
- expect(status.entries).toEqual({ 'a.txt': 'modified', 'b.md': 'modified' });
- const diff = await service.diff(repo, 'a.txt', join(repo, 'a.txt'));
- expect(diff.diff).toContain('+line2');
-
- expect(existsSync(cleanMarker)).toBe(false);
- expect(existsSync(processMarker)).toBe(false);
- } finally {
- rmSync(outside, { recursive: true, force: true });
- }
- },
- 15000,
- );
- });
describe('findWorkTree', () => {
it('finds the repo root from a nested subdirectory', async () => {
diff --git a/packages/agent-core-v2/test/app/git/hardening.test.ts b/packages/agent-core-v2/test/app/git/hardening.test.ts
deleted file mode 100644
index 5d87c23b5..000000000
--- a/packages/agent-core-v2/test/app/git/hardening.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { mkdtemp, mkdir, symlink } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { describe, expect, it } from 'vitest';
-
-import {
- buildDriverOverrides,
- hardenedGitConfigArgs,
- INCLUDE_SECTION_RE,
- isCoreWorktreeSafe,
- parseGitDirPointer,
- resolveConfigPaths,
-} from '#/app/git/hardening';
-
-describe('buildDriverOverrides', () => {
- it('neutralizes filter and merge drivers found in probe output', () => {
- expect(buildDriverOverrides(['filter.evil.clean\nfilter.evil.process\nfilter.evil.smudge\nmerge.evil.driver\n'])).toEqual([
- '-c',
- 'filter.evil.clean=',
- '-c',
- 'filter.evil.process=',
- '-c',
- 'filter.evil.smudge=',
- '-c',
- 'merge.evil.driver=',
- ]);
- });
-
- it('returns null when a driver name contains an equals sign', () => {
- expect(buildDriverOverrides(['filter.evil=x.clean\n'])).toBeNull();
- expect(buildDriverOverrides(['merge.evil=x.driver\n'])).toBeNull();
- });
-
- it('ignores unrelated keys and empty output', () => {
- expect(buildDriverOverrides(['core.fsmonitor\n', ''])).toEqual([]);
- });
-});
-
-describe('parseGitDirPointer', () => {
- it('reads the pointer from the first line', () => {
- expect(parseGitDirPointer('gitdir: ../actual-git\n')).toBe('../actual-git');
- expect(parseGitDirPointer('\uFEFF gitdir: /abs/gitdir\r\nsecond')).toBe('/abs/gitdir');
- });
-
- it('returns undefined for content without a gitdir pointer', () => {
- expect(parseGitDirPointer('not a pointer')).toBeUndefined();
- expect(parseGitDirPointer('gitdir:\n')).toBeUndefined();
- });
-});
-
-describe('resolveConfigPaths', () => {
- it('lists the per-worktree config files', () => {
- expect(resolveConfigPaths('/repo/.git', undefined)).toEqual([
- '/repo/.git/config',
- '/repo/.git/config.worktree',
- ]);
- });
-
- it('adds the common config when a commondir file exists', () => {
- expect(resolveConfigPaths('/repo/.git/worktrees/wt', '../..\n')).toEqual([
- '/repo/.git/worktrees/wt/config',
- '/repo/.git/worktrees/wt/config.worktree',
- '/repo/.git/config',
- ]);
- });
-});
-
-describe('isCoreWorktreeSafe', () => {
- it('accepts a worktree that resolves to the work tree root', () => {
- expect(isCoreWorktreeSafe('/repo', '/repo/.git', '/repo')).toBe(true);
- expect(isCoreWorktreeSafe('..', '/repo/.git', '/repo')).toBe(true);
- });
-
- it('rejects a worktree that resolves anywhere else', () => {
- expect(isCoreWorktreeSafe('/outside', '/repo/.git', '/repo')).toBe(false);
- expect(isCoreWorktreeSafe('../..', '/repo/.git', '/repo')).toBe(false);
- });
-
- it('compares with Windows path semantics', () => {
- const originalPlatform = process.platform;
- Object.defineProperty(process, 'platform', { value: 'win32' });
- try {
- expect(isCoreWorktreeSafe('/REPO', '/repo/.git', '/repo')).toBe(true);
- expect(isCoreWorktreeSafe('/OTHER', '/repo/.git', '/repo')).toBe(false);
- } finally {
- Object.defineProperty(process, 'platform', { value: originalPlatform });
- }
- });
-});
-
-describe('hardenedGitConfigArgs', () => {
- it('rejects a work tree that matches the symlink target of .git, not the workspace', async () => {
- const root = await mkdtemp(join(tmpdir(), 'pythinker-git-link-'));
- const workspace = join(root, 'workspace');
- const outside = join(root, 'outside', 'project');
- await mkdir(join(outside, '.git'), { recursive: true });
- await mkdir(workspace, { recursive: true });
- await symlink(join(outside, '.git'), join(workspace, '.git'));
-
- const probe = async () => ({ exitCode: 0, stdout: `${outside}\n` });
- await expect(hardenedGitConfigArgs(workspace, probe)).resolves.toBeNull();
- });
-});
-
-describe('INCLUDE_SECTION_RE', () => {
- it('matches include and includeIf section headers', () => {
- expect(INCLUDE_SECTION_RE.test('[include]\n\tpath = extra.conf')).toBe(true);
- expect(INCLUDE_SECTION_RE.test('[includeIf "gitdir:~/src/**"]')).toBe(true);
- expect(INCLUDE_SECTION_RE.test(' [IncludeIf "gitdir:~/src/**"]')).toBe(true);
- });
-
- it('does not match include mentions outside section headers', () => {
- expect(INCLUDE_SECTION_RE.test('url = https://example.com/include.git')).toBe(false);
- expect(INCLUDE_SECTION_RE.test('[includefoo]')).toBe(false);
- });
-});
diff --git a/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts b/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts
index 5eaa52cbf..ce0cb0fc3 100644
--- a/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts
+++ b/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts
@@ -111,7 +111,10 @@ describe('McpManagementService', () => {
const ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(IFileSystemStorageService, storage);
- reg.definePartialInstance(IBootstrapService, { homeDir: home });
+ reg.definePartialInstance(IBootstrapService, {
+ homeDir: home,
+ getEnv: () => undefined,
+ });
reg.define(IMcpConfigStore, McpConfigStore);
reg.definePartialInstance(IPluginService, {
mcpServerEntries: async () => {
diff --git a/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts b/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts
index d26a8a2b2..ae12513ee 100644
--- a/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts
+++ b/packages/agent-core-v2/test/app/mcpRegistry/mcpRegistry.test.ts
@@ -24,6 +24,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
+import { TRUST_WORKSPACE_ENV } from '#/workspace/workspaceTrust/workspaceTrustService';
function stdioServer(name: string, command = 'npx'): GlobalMcpServerConfig {
return { name, transport: 'stdio', command };
@@ -51,21 +52,26 @@ describe('McpRegistryService', () => {
let pluginError: Error | undefined;
let trusted: boolean;
let trustedKey: string | undefined;
+ let env: NodeJS.ProcessEnv;
let registry: IMcpRegistryService;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'pythinker-mcp-registry-home-'));
- vi.stubEnv('PYTHINKER_CODE_HOME', home);
+ vi.stubEnv('KIMI_CODE_HOME', home);
disposables = new DisposableStore();
tempDirs = [home];
pluginEntries = [];
pluginError = undefined;
trusted = true;
trustedKey = undefined;
+ env = {};
const ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(IFileSystemStorageService, new InMemoryStorageService());
- reg.definePartialInstance(IBootstrapService, { homeDir: home });
+ reg.definePartialInstance(IBootstrapService, {
+ homeDir: home,
+ getEnv: (name: string) => env[name],
+ });
reg.define(IMcpConfigStore, McpConfigStore);
reg.definePartialInstance(IPluginService, {
mcpServerEntries: async () => {
@@ -206,6 +212,29 @@ describe('McpRegistryService', () => {
]);
});
+ it('loads project layers for an untrusted workspace when PYTHINKER_CODE_TRUST_WORKSPACE is set', async () => {
+ await writeJson(join(home, 'mcp.json'), {
+ mcpServers: { userOnly: { command: 'user-only' } },
+ });
+ const { project, sub } = await makeProject();
+ await writeJson(join(project, '.mcp.json'), {
+ mcpServers: { repoOnly: { command: 'repo-only' } },
+ });
+ await writeJson(join(sub, '.pythinker-code', 'mcp.json'), {
+ mcpServers: { localOnly: { command: 'local-only' } },
+ });
+ trusted = false;
+ env = { [TRUST_WORKSPACE_ENV]: '1' };
+
+ const entries = await registry.list({ cwd: sub });
+
+ expect(entries.map((entry) => entry.name).toSorted()).toEqual([
+ 'localOnly',
+ 'repoOnly',
+ 'userOnly',
+ ]);
+ });
+
it('checks trust at the queried cwd rather than the canonical git root', async () => {
const { project, sub } = await makeProject();
await writeJson(join(project, '.mcp.json'), {
diff --git a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts
index 419eb3e3c..2828eb545 100644
--- a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts
+++ b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts
@@ -32,7 +32,6 @@ import {
} from '#/app/workspace/workspacePersistence';
import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases';
import { WorkspaceAliasesService } from '#/app/workspaceAliases/workspaceAliasesService';
-import { setWatchEnabled } from '#human/utils/watch';
import { stubBootstrap } from '../bootstrap/stubs';
interface SessionIndexLine {
@@ -69,11 +68,9 @@ describe('WorkspaceAliasesService (file-backed)', () => {
'workspaceAliases',
);
homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-aliases-'));
- setWatchEnabled(true);
});
afterEach(async () => {
- setWatchEnabled(false);
currentHost?.dispose();
currentHost = undefined;
await fsp.rm(homeDir, { recursive: true, force: true });
diff --git a/packages/agent-core-v2/test/features/cron/sessionCron.test.ts b/packages/agent-core-v2/test/features/cron/sessionCron.test.ts
index c38f5e41d..571a9da48 100644
--- a/packages/agent-core-v2/test/features/cron/sessionCron.test.ts
+++ b/packages/agent-core-v2/test/features/cron/sessionCron.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
+import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IAgentCronService } from '#/features/cron/cronService';
import { CronCursor } from '#/features/cron/cronOps';
+import type { WireRecord } from '#/wire/record';
import {
createTestAgent,
@@ -20,6 +22,13 @@ async function bootCronContext(options: TestAgentOptions = {}): Promise {
+ const ctx = await bootCronContext();
+ ctx.get(IAgentCronService).list();
+ await ctx.restore(records);
+ return ctx;
+}
+
describe('session cron wire persistence', () => {
it('writes cron ops as durable wire records and rebuilds the task table on replay', async () => {
const persistence = new InMemoryWireRecordPersistence();
@@ -28,12 +37,15 @@ describe('session cron wire persistence', () => {
await first.restorePersisted();
const cron = first.get(IAgentCronService);
- const task = cron.addTask({ cron: '0 9 * * *', prompt: 'wire me', recurring: true });
- await first.dispatcher.dispatch(new CronCursor({ id: task.id, lastFiredAt: 1234 }));
+ const kept = cron.addTask({ cron: '0 9 * * *', prompt: 'keep', recurring: true });
+ const dropped = cron.addTask({ cron: '0 10 * * *', prompt: 'drop', recurring: true });
+ cron.removeTasks([dropped.id]);
+ await first.dispatcher.dispatch(new CronCursor({ id: kept.id, lastFiredAt: 1234 }));
await first.dispatcher.flush();
const types = persistence.records.map((record) => record.type);
expect(types).toContain('cron.add');
+ expect(types).toContain('cron.delete');
expect(types).toContain('cron.cursor');
} finally {
await first.dispose();
@@ -50,7 +62,7 @@ describe('session cron wire persistence', () => {
expect(rebuilt).toHaveLength(1);
expect(rebuilt[0]).toMatchObject({
cron: '0 9 * * *',
- prompt: 'wire me',
+ prompt: 'keep',
recurring: true,
lastFiredAt: 1234,
});
@@ -59,35 +71,65 @@ describe('session cron wire persistence', () => {
}
});
- it('drops deleted tasks on replay', async () => {
- const persistence = new InMemoryWireRecordPersistence();
- const first = await bootCronContext({ persistence });
- try {
- await first.restorePersisted();
+ it('clears inherited tasks at the fork record and delivers the fork-cleared reminder exactly once', async () => {
+ const addRecord = (id: string, prompt: string): WireRecord => ({
+ type: 'cron.add',
+ task: { id, cron: '0 9 * * *', prompt, createdAt: 1000, recurring: true },
+ });
- const cron = first.get(IAgentCronService);
- const kept = cron.addTask({ cron: '0 9 * * *', prompt: 'keep', recurring: true });
- const dropped = cron.addTask({ cron: '0 10 * * *', prompt: 'drop', recurring: true });
- cron.removeTasks([dropped.id]);
- await first.dispatcher.flush();
+ const withInherited = await restoreCronRecords([
+ addRecord('seed-inherited', 'inherited'),
+ { type: 'forked' },
+ ]);
+ try {
+ expect(withInherited.get(IAgentCronService).list()).toEqual([]);
+ const reminder = withInherited.get(IAgentContextMemoryService).get().at(-1);
+ expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'cron_fork_cleared' });
+ const text = JSON.stringify(reminder?.content);
+ expect(text).toContain('This fork does not have any scheduled cron tasks.');
+ expect(text).toContain('Tasks from the source session continue to run in the source session.');
+ } finally {
+ await withInherited.dispose();
+ }
- const types = persistence.records.map((record) => record.type);
- expect(types).toContain('cron.delete');
- expect(kept.id).not.toBe(dropped.id);
+ const noTasks = await restoreCronRecords([{ type: 'forked' }]);
+ try {
+ expect(noTasks.get(IAgentCronService).list()).toEqual([]);
+ expect(noTasks.get(IAgentContextMemoryService).get()).toEqual([]);
} finally {
- await first.dispose();
+ await noTasks.dispose();
}
- const second = await bootCronContext({
- persistence: new InMemoryWireRecordPersistence(persistence.records),
- });
+ const recreated = await restoreCronRecords([
+ addRecord('seed-inherited', 'inherited'),
+ { type: 'forked' },
+ addRecord('seed-recreated', 'recreated'),
+ ]);
try {
- await second.restorePersisted();
+ expect(recreated.get(IAgentCronService).list().map((task) => task.prompt)).toEqual([
+ 'recreated',
+ ]);
+ } finally {
+ await recreated.dispose();
+ }
- const resumed = second.get(IAgentCronService);
- expect(resumed.list().map((task) => task.prompt)).toEqual(['keep']);
+ const delivered = await restoreCronRecords([
+ addRecord('seed-inherited', 'inherited'),
+ { type: 'forked' },
+ {
+ type: 'context.append_message',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: '\nfork cleared\n ' }],
+ toolCalls: [],
+ origin: { kind: 'injection', variant: 'cron_fork_cleared' },
+ },
+ },
+ ]);
+ try {
+ expect(delivered.get(IAgentContextMemoryService).get()).toHaveLength(1);
} finally {
- await second.dispose();
+ await delivered.dispose();
}
});
diff --git a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts
index edd7c822d..11332a227 100644
--- a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts
+++ b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts
@@ -10,7 +10,6 @@ import { Event } from '#/_base/event';
import { ILogService } from '#/_base/log/log';
import { Error2, ErrorCodes } from '#/errors';
import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog';
-import { IGitService } from '#/app/git/git';
import { stubLog } from '../../_base/log/stubs';
import { stubFlag } from '../../app/flag/stubs';
import { stubAgentContext } from '../../agent/agentContext/stubs';
@@ -265,15 +264,10 @@ function realSubagents(
},
} as unknown as IModelCatalog;
const sessionContext = { _serviceBrand: undefined, cwd: '/repo' } as unknown as ISessionContext;
- const git = {
- _serviceBrand: undefined,
- runGit: vi.fn(async () => ({ exitCode: 1, stdout: '', stderr: '' })),
- } as unknown as IGitService;
return new SessionSubagentService(
agentLifecycle,
catalog,
config,
- git,
modelCatalog,
sessionContext,
stubLog(),
diff --git a/packages/agent-core-v2/test/features/notify/notifyUserNudgeService.test.ts b/packages/agent-core-v2/test/features/notify/notifyUserNudgeService.test.ts
index fc7ad166a..28e1a0035 100644
--- a/packages/agent-core-v2/test/features/notify/notifyUserNudgeService.test.ts
+++ b/packages/agent-core-v2/test/features/notify/notifyUserNudgeService.test.ts
@@ -1,12 +1,14 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { type HostUiCapability, IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IFlagService } from '#/app/flag/flag';
import { FlagService } from '#/app/flag/flagService';
import { NOTIFY_USER_FLAG_ID } from '#/features/notify/flag';
+import { NOTIFY_USER_UI_CAPABILITY } from '#/features/notify/notifyUserAvailability';
import { NOTIFY_USER_NUDGE_VARIANT } from '#/features/notify/notifyUserNudge';
import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user';
import type { ExecutableTool } from '#/tool/toolContract';
@@ -55,8 +57,9 @@ describe('AgentNotifyUserNudgeService', () => {
}
}
- beforeEach(async () => {
+ async function start(uiCapabilities: readonly HostUiCapability[]): Promise {
ctx = createTestAgent({ autoConfigure: false });
+ Object.assign(ctx.get(IBootstrapService).args, { uiCapabilities });
context = ctx.get(IAgentContextMemoryService);
loop = ctx.get(IAgentLoopService);
flags = ctx.get(IFlagService) as FlagService;
@@ -71,7 +74,7 @@ describe('AgentNotifyUserNudgeService', () => {
origin: { kind: 'user' },
});
ctx.configure();
- });
+ }
afterEach(async () => {
try {
@@ -82,6 +85,7 @@ describe('AgentNotifyUserNudgeService', () => {
});
it('stops injecting nudges when the flag is disabled mid-session', async () => {
+ await start([NOTIFY_USER_UI_CAPABILITY]);
appendSilentToolCalls(8);
await runWillBeginStepHooks(loop);
expect(nudgeInjections()).toHaveLength(1);
@@ -97,4 +101,19 @@ describe('AgentNotifyUserNudgeService', () => {
await runWillBeginStepHooks(loop);
expect(nudgeInjections()).toHaveLength(2);
});
+
+ it('does not inject nudges in a host without the update panel', async () => {
+ await start([]);
+ appendSilentToolCalls(8);
+ await runWillBeginStepHooks(loop);
+ expect(nudgeInjections()).toHaveLength(0);
+
+ context.append({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Halfway through the checks.' }],
+ toolCalls: [{ type: 'function', id: 'call_mid', name: 'Bash', arguments: '{}' }],
+ });
+ await runWillBeginStepHooks(loop);
+ expect(nudgeInjections()).toHaveLength(0);
+ });
});
diff --git a/packages/agent-core-v2/test/features/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts
index dd06aac24..0443cb32b 100644
--- a/packages/agent-core-v2/test/features/plan/plan.test.ts
+++ b/packages/agent-core-v2/test/features/plan/plan.test.ts
@@ -709,6 +709,7 @@ describe('Plan service', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.set_mode { "agentId": "main", "mode": "yolo", "time": "" }
+ [emit] agent.status.updated { "time": "", "agentId": "main", "permission": "yolo" }
[wire] plan_mode.enter { "agentId": "main", "id": "test-plan", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "planMode": true }
[emit] prompt.submitted { "time": "", "agentId": "main", "promptId": "", "userMessageId": "", "status": "running", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "createdAt": "" }
@@ -727,7 +728,7 @@ describe('Plan service', () => {
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "I will inspect safely." }
[wire] llm.tools_snapshot { "agentId": "main", "hash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "tools": [ { "name": "Bash", "description": "Execute a \`bash\` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.\\n\\n**Translate these to a dedicated tool instead:**\\n- \`cat\` / \`head\` / \`tail\` (known path) → \`Read\`\\n- \`sed\` / \`awk\` (in-place edit) → \`Edit\`\\n- \`echo > file\` / \`cat <\` is fine for listing a directory)\\n- \`grep\` / \`rg\` (search file contents) → \`Grep\`\\n- \`echo\` / \`printf\` (talk to the user) → just output text directly\\n\\nThe dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.\\n\\n**Output:**\\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a \`Command failed with exit code: N\` line; a command killed by its timeout or interrupted by the user ends with its own message instead.\\n\\nBackground execution is disabled for this agent. Do not set \`run_in_background=true\`.\\n\\n**Guidelines for safety and security:**\\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the \`cwd\` argument (or use absolute paths) rather than relying on a \`cd\` from an earlier call.\\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, set the \`timeout\` argument in seconds. The default is 60s; foreground commands allow up to 300s; a foreground command that hits its timeout is killed.\\n- Avoid using \`..\` to access files or directories outside of the working directory.\\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\\n- Run git-mutating commands such as \`git commit\`, \`git push\`, \`git reset\`, and \`git rebase\` only when the user asks for them.\\n\\n**Guidelines for efficiency:**\\n- Use \`&&\` to chain commands that genuinely depend on each other, e.g. \`npm install && npm test\`. Independent read-only commands (separate \`git show\`, \`ls\`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with \`echo\` separators.\\n- Use \`;\` to run commands sequentially regardless of success/failure\\n- Use \`||\` for conditional execution (run second command only if first fails)\\n- Use pipe operations (\`|\`) and redirections (\`>\`, \`>>\`) to chain input and output between commands\\n- Always quote file paths containing spaces with double quotes (e.g., cd \\"/path with spaces/\\")\\n- Compose multi-step logic in a single call with \`if\` / \`case\` / \`for\` / \`while\` control flows.\\n- Do not set \`run_in_background=true\`; background task management tools are not available.\\n\\n**Commands available:**\\nThe following common command categories are usually available. Availability still depends on the host, so when in doubt run \`which \` first to confirm a command exists before relying on it.\\n- Navigation and inspection: \`ls\`, \`pwd\`, \`cd\`, \`stat\`, \`file\`, \`du\`, \`df\`, \`tree\`\\n- File and directory management: \`cp\`, \`mv\`, \`rm\`, \`mkdir\`, \`touch\`, \`ln\`, \`chmod\`, \`chown\`\\n- Text and data processing: \`wc\`, \`sort\`, \`uniq\`, \`cut\`, \`tr\`, \`diff\`, \`xargs\`\\n- Archives and compression: \`tar\`, \`gzip\`, \`gunzip\`, \`zip\`, \`unzip\`\\n- Networking and transfer: \`curl\`, \`wget\`, \`ping\`, \`ssh\`, \`scp\`\\n- Version control: \`git\`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the \`gh\` CLI when installed — it carries the user's GitHub auth and can return structured JSON\\n- Process and system: \`ps\`, \`kill\`, \`top\`, \`env\`, \`date\`, \`uname\`, \`whoami\`\\n- Language and package toolchains: \`node\`, \`npm\`, \`pnpm\`, \`yarn\`, \`python\`, \`pip\` (use whichever the project actually relies on)\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "command": { "type": "string", "minLength": 1, "description": "The command to execute." }, "cwd": { "description": "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", "type": "string" }, "timeout": { "default": 60, "description": "Optional timeout in seconds for the command to execute. Foreground default 60s, max 300s. Background default 600s, max 86400s. Ignored for background commands when disable_timeout=true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "description": { "description": "A short description for the background task. Required when run_in_background is true.", "type": "string" }, "run_in_background": { "description": "Whether to run the command as a background task.", "type": "boolean" }, "disable_timeout": { "description": "If true, do not apply a timeout to the command. Only applies when run_in_background is true.", "type": "boolean" } }, "required": [ "command" ], "additionalProperties": false } } ], "time": "" }
[emit] tool.call.delta { "time": "", "agentId": "main", "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 2, "turnStep": "0.1", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 2, "turnStep": "0.1", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] agent.status.updated { "time": "", "agentId": "main", "contextTokens": 588 }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
@@ -743,7 +744,7 @@ describe('Plan service', () => {
[emit] turn.step.started { "time": "", "agentId": "main", "turnId": 0, "step": 2, "stepId": "" }
[wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 2 }, "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "The safe command printed plan-safe." }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 4, "turnStep": "0.2", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 4, "turnStep": "0.2", "time": "" }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 598, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 1163, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1163, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1163, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] token_counting.measured { "agentId": "main", "length": 5, "tokens": 610, "time": "" }
@@ -787,6 +788,7 @@ describe('Plan service', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.set_mode { "agentId": "main", "mode": "yolo", "time": "" }
+ [emit] agent.status.updated { "time": "", "agentId": "main", "permission": "yolo" }
[wire] plan_mode.enter { "agentId": "main", "id": "test-plan", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "planMode": true }
[emit] prompt.submitted { "time": "", "agentId": "main", "promptId": "", "userMessageId": "", "status": "running", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "createdAt": "" }
@@ -805,7 +807,7 @@ describe('Plan service', () => {
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "I will mutate a file." }
[wire] llm.tools_snapshot { "agentId": "main", "hash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "tools": [ { "name": "Bash", "description": "Execute a \`bash\` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.\\n\\n**Translate these to a dedicated tool instead:**\\n- \`cat\` / \`head\` / \`tail\` (known path) → \`Read\`\\n- \`sed\` / \`awk\` (in-place edit) → \`Edit\`\\n- \`echo > file\` / \`cat <\` is fine for listing a directory)\\n- \`grep\` / \`rg\` (search file contents) → \`Grep\`\\n- \`echo\` / \`printf\` (talk to the user) → just output text directly\\n\\nThe dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.\\n\\n**Output:**\\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a \`Command failed with exit code: N\` line; a command killed by its timeout or interrupted by the user ends with its own message instead.\\n\\nBackground execution is disabled for this agent. Do not set \`run_in_background=true\`.\\n\\n**Guidelines for safety and security:**\\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the \`cwd\` argument (or use absolute paths) rather than relying on a \`cd\` from an earlier call.\\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, set the \`timeout\` argument in seconds. The default is 60s; foreground commands allow up to 300s; a foreground command that hits its timeout is killed.\\n- Avoid using \`..\` to access files or directories outside of the working directory.\\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\\n- Run git-mutating commands such as \`git commit\`, \`git push\`, \`git reset\`, and \`git rebase\` only when the user asks for them.\\n\\n**Guidelines for efficiency:**\\n- Use \`&&\` to chain commands that genuinely depend on each other, e.g. \`npm install && npm test\`. Independent read-only commands (separate \`git show\`, \`ls\`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with \`echo\` separators.\\n- Use \`;\` to run commands sequentially regardless of success/failure\\n- Use \`||\` for conditional execution (run second command only if first fails)\\n- Use pipe operations (\`|\`) and redirections (\`>\`, \`>>\`) to chain input and output between commands\\n- Always quote file paths containing spaces with double quotes (e.g., cd \\"/path with spaces/\\")\\n- Compose multi-step logic in a single call with \`if\` / \`case\` / \`for\` / \`while\` control flows.\\n- Do not set \`run_in_background=true\`; background task management tools are not available.\\n\\n**Commands available:**\\nThe following common command categories are usually available. Availability still depends on the host, so when in doubt run \`which \` first to confirm a command exists before relying on it.\\n- Navigation and inspection: \`ls\`, \`pwd\`, \`cd\`, \`stat\`, \`file\`, \`du\`, \`df\`, \`tree\`\\n- File and directory management: \`cp\`, \`mv\`, \`rm\`, \`mkdir\`, \`touch\`, \`ln\`, \`chmod\`, \`chown\`\\n- Text and data processing: \`wc\`, \`sort\`, \`uniq\`, \`cut\`, \`tr\`, \`diff\`, \`xargs\`\\n- Archives and compression: \`tar\`, \`gzip\`, \`gunzip\`, \`zip\`, \`unzip\`\\n- Networking and transfer: \`curl\`, \`wget\`, \`ping\`, \`ssh\`, \`scp\`\\n- Version control: \`git\`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the \`gh\` CLI when installed — it carries the user's GitHub auth and can return structured JSON\\n- Process and system: \`ps\`, \`kill\`, \`top\`, \`env\`, \`date\`, \`uname\`, \`whoami\`\\n- Language and package toolchains: \`node\`, \`npm\`, \`pnpm\`, \`yarn\`, \`python\`, \`pip\` (use whichever the project actually relies on)\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "command": { "type": "string", "minLength": 1, "description": "The command to execute." }, "cwd": { "description": "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", "type": "string" }, "timeout": { "default": 60, "description": "Optional timeout in seconds for the command to execute. Foreground default 60s, max 300s. Background default 600s, max 86400s. Ignored for background commands when disable_timeout=true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "description": { "description": "A short description for the background task. Required when run_in_background is true.", "type": "string" }, "run_in_background": { "description": "Whether to run the command as a background task.", "type": "boolean" }, "disable_timeout": { "description": "If true, do not apply a timeout to the command. Only applies when run_in_background is true.", "type": "boolean" } }, "required": [ "command" ], "additionalProperties": false } } ], "time": "" }
[emit] tool.call.delta { "time": "", "agentId": "main", "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 2, "turnStep": "0.1", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 2, "turnStep": "0.1", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] agent.status.updated { "time": "", "agentId": "main", "contextTokens": 585 }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
@@ -821,7 +823,7 @@ describe('Plan service', () => {
[emit] turn.step.started { "time": "", "agentId": "main", "turnId": 0, "step": 2, "stepId": "" }
[wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 2 }, "time": "" }
[emit] assistant.delta { "time": "", "agentId": "main", "turnId": 0, "delta": "The command completed." }
- [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 4, "turnStep": "0.2", "time": "" }
+ [wire] llm.request { "agentId": "main", "kind": "loop", "provider": "openai", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "7b30e1dd479abcc62ec83674dc9db273088381cd9d5990d22d266ae39417d162", "messageCount": 4, "turnStep": "0.2", "time": "" }
[wire] usage.record { "agentId": "main", "model": "mock-model", "usage": { "inputOther": 594, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "" }
[emit] agent.status.updated { "time": "", "agentId": "main", "usage": { "byModel": { "mock-model": { "inputOther": 1156, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1156, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1156, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] token_counting.measured { "agentId": "main", "length": 5, "tokens": 603, "time": "" }
diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts
index f108b46e3..c965c11c7 100644
--- a/packages/agent-core-v2/test/features/tower/store.test.ts
+++ b/packages/agent-core-v2/test/features/tower/store.test.ts
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
-import { chmod, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
@@ -12,7 +12,6 @@ import {
TowerProtocolError,
TowerStore,
commitPaths,
- isWorktreeDirty,
parseFrontmatter,
worktreeAddNewBranch,
} from '../../../src/features/tower/protocol';
@@ -317,74 +316,7 @@ describe('init', () => {
});
});
-describe('git invocation hardening', () => {
- it.skipIf(process.platform === 'win32')(
- 'does not run repo-configured hooks when committing',
- async () => {
- const hooksDir = join(repo, 'evil-hooks');
- await mkdir(hooksDir, { recursive: true });
- const marker = join(repo, 'hook-ran');
- await writeFile(join(hooksDir, 'pre-commit'), `#!/bin/sh\ntouch "${marker}"\n`);
- await chmod(join(hooksDir, 'pre-commit'), 0o755);
- await git(repo, 'config', 'core.hooksPath', hooksDir);
- await writeFile(join(repo, 'x.txt'), 'x\n');
-
- await commitPaths(repo, ['x.txt'], 'commit x');
-
- expect(await git(repo, 'log', '-1', '--format=%s')).toBe('commit x');
- await expect(stat(marker)).rejects.toThrow();
- },
- );
-
- it.skipIf(process.platform === 'win32')(
- 'does not run a repo-configured fsmonitor command on status',
- async () => {
- const outside = await mkdtemp(join(tmpdir(), 'tower-fsm-'));
- try {
- const marker = join(outside, 'fsm-ran');
- const helper = join(outside, 'helper.sh');
- await writeFile(helper, `#!/bin/sh\ntouch "${marker}"\n`);
- await chmod(helper, 0o755);
- await git(repo, 'config', 'core.fsmonitor', helper);
-
- expect(await isWorktreeDirty(repo)).toBe(false);
- await expect(stat(marker)).rejects.toThrow();
- } finally {
- await rm(outside, { recursive: true, force: true });
- }
- },
- );
-
- it.skipIf(process.platform === 'win32')(
- 'does not run repo-configured clean filters on status and add',
- async () => {
- const outside = await mkdtemp(join(tmpdir(), 'tower-filter-'));
- try {
- const marker = join(outside, 'filter-ran');
- await writeFile(join(repo, '.gitattributes'), '*.txt filter=evil\n');
- await writeFile(join(repo, 'f.txt'), 'aaaa\n');
- await git(repo, 'add', '-A');
- await git(repo, 'commit', '-m', 'add f');
- await git(repo, 'config', 'filter.evil.clean', `touch "${marker}"`);
- await git(repo, 'config', 'filter.evil.smudge', 'cat');
-
- await writeFile(join(repo, 'f.txt'), 'bbbb\n');
- await git(repo, 'status', '--porcelain');
- await stat(marker);
- await rm(marker);
-
- expect(await isWorktreeDirty(repo)).toBe(true);
- await expect(stat(marker)).rejects.toThrow();
-
- await commitPaths(repo, ['f.txt'], 'commit f');
- expect(await git(repo, 'log', '-1', '--format=%s')).toBe('commit f');
- await expect(stat(marker)).rejects.toThrow();
- } finally {
- await rm(outside, { recursive: true, force: true });
- }
- },
- );
-
+describe('tower commit identity', () => {
it('falls back to the tower identity when the repository has no committer identity', async () => {
await git(repo, 'config', '--unset', 'user.name');
await git(repo, 'config', '--unset', 'user.email');
diff --git a/packages/agent-core-v2/test/llm-adapter/model/completionBudget.test.ts b/packages/agent-core-v2/test/llm-adapter/model/completionBudget.test.ts
index dbd68cfa5..b32d71d91 100644
--- a/packages/agent-core-v2/test/llm-adapter/model/completionBudget.test.ts
+++ b/packages/agent-core-v2/test/llm-adapter/model/completionBudget.test.ts
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
import type { ModelCapability } from '#/llm-adapter/contract/capability';
import {
completionBudgetParams,
- computeCompletionBudgetCap,
resolveCompletionBudget,
} from '#/llm-adapter/model/completion-budget';
@@ -17,43 +16,38 @@ const capability = (maxContextTokens: number): ModelCapability => ({
});
describe('resolveCompletionBudget', () => {
- it('prefers the explicit cap, then maxOutputSize, then reservedContextSize', () => {
+ it('prefers the explicit cap, then maxOutputSize, and omits the budget otherwise', () => {
expect(
- resolveCompletionBudget({ maxCompletionTokensCap: 100, maxOutputSize: 200, reservedContextSize: 300 }),
- ).toEqual({ hardCap: 100 });
- expect(resolveCompletionBudget({ maxOutputSize: 200, reservedContextSize: 300 })).toEqual({ hardCap: 200 });
- expect(resolveCompletionBudget({ reservedContextSize: 300 })).toEqual({ fallback: 300 });
- expect(resolveCompletionBudget({})).toEqual({ fallback: 32000 });
+ resolveCompletionBudget({ maxCompletionTokensCap: 100, maxOutputSize: 200 }),
+ ).toBe(100);
+ expect(resolveCompletionBudget({ maxOutputSize: 200 })).toBe(200);
+ expect(resolveCompletionBudget({})).toBeUndefined();
});
it('ignores non-positive caps and sizes', () => {
expect(resolveCompletionBudget({ maxCompletionTokensCap: 0 })).toBeUndefined();
expect(resolveCompletionBudget({ maxCompletionTokensCap: -5, maxOutputSize: 200 })).toBeUndefined();
- expect(resolveCompletionBudget({ maxOutputSize: 0, reservedContextSize: -1 })).toEqual({ fallback: 32000 });
+ expect(resolveCompletionBudget({ maxOutputSize: 0 })).toBeUndefined();
});
});
-describe('computeCompletionBudgetCap', () => {
- it('hardCap wins over the capability context size', () => {
- expect(computeCompletionBudgetCap({ budget: { hardCap: 50 }, capability: capability(128000) })).toBe(50);
- });
-
- it('falls back to the capability context size, then the configured fallback', () => {
- expect(computeCompletionBudgetCap({ budget: { fallback: 300 }, capability: capability(128000) })).toBe(128000);
- expect(computeCompletionBudgetCap({ budget: { fallback: 300 }, capability: capability(0) })).toBe(300);
- expect(computeCompletionBudgetCap({ budget: {}, capability: undefined })).toBe(32000);
+describe('completionBudgetParams (the budget fold)', () => {
+ it('applies the floor to the resolved budget regardless of the capability window', () => {
+ expect(completionBudgetParams({ budget: 50, capability: capability(128000) })).toEqual({
+ maxCompletionTokens: 50,
+ usedContextTokens: undefined,
+ maxContextTokens: 128000,
+ });
});
-});
-describe('completionBudgetParams (the budget fold)', () => {
it('returns undefined without a budget', () => {
expect(completionBudgetParams({ budget: undefined, capability: capability(1000) })).toBeUndefined();
});
- it('carries the measured usedContextTokens when the caller did not override messages', () => {
+ it('carries the used context size when the caller did not override messages', () => {
expect(
completionBudgetParams({
- budget: { hardCap: 8192 },
+ budget: 8192,
capability: capability(128000),
usedContextTokens: 5000,
}),
@@ -66,7 +60,7 @@ describe('completionBudgetParams (the budget fold)', () => {
it('omits usedContextTokens with explicit messages — no tightening against the current context', () => {
const params = completionBudgetParams({
- budget: { hardCap: 8192 },
+ budget: 8192,
capability: capability(128000),
usedContextTokens: undefined,
});
diff --git a/packages/agent-core-v2/test/llm-adapter/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/llm-adapter/protocol/protocolAdapterRegistry.test.ts
index f3681e334..9ee0e598c 100644
--- a/packages/agent-core-v2/test/llm-adapter/protocol/protocolAdapterRegistry.test.ts
+++ b/packages/agent-core-v2/test/llm-adapter/protocol/protocolAdapterRegistry.test.ts
@@ -111,7 +111,7 @@ describe('resolveAdapterIdentity', () => {
expect(registry.resolveAdapterIdentity('openai_responses', 'pythinker').baseId).toBe(
'openai_responses',
);
- expect(registry.resolveAdapterIdentity('openai_responses', 'pythinker').trait).toBeUndefined();
+ expect(registry.resolveAdapterIdentity('openai_responses', 'pythinker').trait).toBeDefined();
});
it('resolves unregistered pairs to the protocol itself with no vendor trait', () => {
@@ -270,7 +270,7 @@ describe('pythinker provider definitions', () => {
expect(anthropic?.baseProtocol).toBe('anthropic');
expect(anthropic?.trait).toBeDefined();
expect(responses?.baseProtocol).toBe('openai_responses');
- expect(responses?.trait).toBeUndefined();
+ expect(responses?.trait).toBeDefined();
for (const definition of [native, anthropic, responses]) {
expect(definition?.endpoint).toEqual({
apiKeyEnv: 'PYTHINKER_API_KEY',
diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts
index c0ea2303a..373c7cb18 100644
--- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts
+++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts
@@ -51,8 +51,7 @@ function fileStat(): HostFileStat {
function createTestFs(opts: { stat?: ReturnType; readdir?: ReturnType } = {}) {
const stat = opts.stat ?? vi.fn(async (): Promise => dirStat());
const readdir = opts.readdir ?? vi.fn(async (): Promise => []);
- const realpath = vi.fn(async (path: string) => path);
- const fs = { stat, readdir, realpath } as unknown as IHostFileSystem;
+ const fs = { stat, readdir } as unknown as IHostFileSystem;
return { fs, stat, readdir };
}
@@ -1047,59 +1046,3 @@ describe('GlobTool integration (real ripgrep)', () => {
}
});
});
-
-describe('GlobTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(async () => {
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-symlink-'));
- wsDir = path.join(tmpDir, 'ws');
- outsideDir = path.join(tmpDir, 'outside');
- await fs.mkdir(wsDir);
- await fs.mkdir(outsideDir);
- });
-
- afterEach(async () => {
- await fs.rm(tmpDir, { recursive: true, force: true });
- });
-
- function makeRealFsTool(spawn: ReturnType) {
- return new GlobTool(
- createRuntime(new HostFileSystem(), createTestEnv(), createTestProcessService(spawn)),
- stubWorkspaceContext(wsDir),
- noopTelemetryService,
- );
- }
-
- it('rejects a search root symlink that points outside the workspace', async () => {
- await fs.writeFile(path.join(outsideDir, 'secret.ts'), '');
- await fs.symlink(outsideDir, path.join(wsDir, 'external'));
- const spawn = execReturning('');
- const tool = makeRealFsTool(spawn);
-
- const result = await execute(tool, { pattern: '*.ts', path: path.join(wsDir, 'external') });
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toMatch(/symbolic link/);
- expect(spawn).not.toHaveBeenCalled();
- });
-
- it('allows searching when the workspace directory has a sensitive name', async () => {
- const credDir = path.join(tmpDir, 'credentials');
- await fs.mkdir(credDir);
- await fs.writeFile(path.join(credDir, 'a.ts'), '');
- const spawn = execReturning('');
- const tool = new GlobTool(
- createRuntime(new HostFileSystem(), createTestEnv(), createTestProcessService(spawn)),
- stubWorkspaceContext(credDir),
- noopTelemetryService,
- );
-
- const result = await execute(tool, { pattern: '*.ts' });
-
- expect(result.isError).not.toBe(true);
- expect(spawn).toHaveBeenCalled();
- });
-});
diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts
index b4c274cc6..40e26c9b8 100644
--- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts
+++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts
@@ -1,9 +1,6 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
import { Readable, type Writable } from 'node:stream';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { Service } from '#/_base/di/service';
@@ -36,7 +33,6 @@ import {
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem';
import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { Event } from '#/_base/event';
@@ -164,7 +160,7 @@ function createTestFs(pyaos: FakePyaos): IHostFileSystem {
readdir: () => notImplemented('readdir'),
mkdir: () => notImplemented('mkdir'),
remove: () => notImplemented('remove'),
- realpath: (path) => Promise.resolve(path),
+ realpath: () => notImplemented('realpath'),
};
}
@@ -1663,9 +1659,7 @@ describe('GrepTool', () => {
const tool = new GrepTool(createFakePyaos({ exec }), workspace);
const resultPromise = executeTool(tool, context({ pattern: 'hit' }, controller.signal));
- setTimeout(() => {
- controller.abort();
- }, 0);
+ controller.abort();
const result = await Promise.race([
resultPromise,
new Promise<'timed out'>((resolve) => {
@@ -2136,58 +2130,3 @@ describe('GrepTool', () => {
expect(exec).not.toHaveBeenCalled();
});
});
-
-describe('GrepTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'grep-symlink-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- function makeRealFsTool(spawn: ReturnType) {
- const environment = createTestEnv(createFakePyaos());
- const backend = Object.assign(
- new FakeRuntime(
- { workspaceId: 'workspace', runtimeId: 'local', generation: 'test' },
- { capabilities: ['fs', 'process'], pathClass: environment.pathClass },
- ),
- {
- process: { _serviceBrand: undefined, spawn } as unknown as IHostProcessService,
- fs: new HostFileSystem(),
- environment,
- },
- );
- const runtime: IAgentRuntimeService = {
- _serviceBrand: undefined,
- onDidChange: () => ({ dispose: () => {} }),
- isAvailable: () => true,
- inspect: () => backend,
- acquire: () => ({ runtime: backend, track: (resource) => resource, dispose: () => {} }),
- };
- return new ProductionGrepTool(runtime, stubWorkspaceContext(wsDir), noopTelemetryService);
- }
-
- it('rejects a search root symlink that points outside the workspace', async () => {
- await writeFile(join(outsideDir, 'secret.txt'), 'hit');
- await symlink(outsideDir, join(wsDir, 'external'));
- const spawn = vi.fn();
- const tool = makeRealFsTool(spawn);
-
- const result = await executeTool(tool, context({ pattern: 'hit', path: join(wsDir, 'external') }));
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toMatch(/symbolic link/);
- expect(spawn).not.toHaveBeenCalled();
- });
-
-});
diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts
index 8a7cf2377..2e5fd178d 100644
--- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts
+++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts
@@ -1,15 +1,10 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '#/tool/path-access';
import { MEDIA_SNIFF_BYTES } from '#/agent/media/file-type';
import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import {
type ReadInput,
ReadInputSchema,
@@ -119,8 +114,7 @@ function createSpiedFs(content: string) {
const readLines = vi.fn().mockImplementation(() => generateLines(content));
const readText = vi.fn(async () => content);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
- const realpath = vi.fn(async (path: string) => path);
- const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath } as unknown as IHostFileSystem;
+ const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem;
return { fs, readBytes, readLines, readText, stat };
}
@@ -162,7 +156,7 @@ function createSpiedMapFs(files: Record) {
size: file.size ?? file.bytes.length,
};
});
- const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath: vi.fn(async (path: string) => path) } as unknown as IHostFileSystem;
+ const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem;
return { fs, readBytes, readLines, readText, stat };
}
@@ -1264,8 +1258,7 @@ describe('ReadTool', () => {
n === undefined ? bytes : bytes.subarray(0, n),
);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
- const realpath = vi.fn(async (path: string) => path);
- const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath } as unknown as IHostFileSystem;
+ const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem;
const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/large.txt' });
@@ -1507,77 +1500,3 @@ describe('ReadTool', () => {
).rejects.toMatchObject({ code: 'runtime.unavailable' });
});
});
-
-describe('ReadTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'read-symlink-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- function makeRealFsTool(workDir: string, additionalDirs: readonly string[] = []) {
- return createReadTool(new HostFileSystem(), createTestEnv(), stubWorkspaceContext(workDir, additionalDirs));
- }
-
- it('rejects reading through a symlink that points outside the workspace', async () => {
- const target = join(outsideDir, 'secret.txt');
- await writeFile(target, 'top-secret');
- const link = join(wsDir, 'notes.md');
- await symlink(target, link);
-
- const result = await execute(makeRealFsTool(wsDir), { path: link });
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toMatch(/symbolic link/);
- expect(toolContentString(result)).not.toContain('top-secret');
- });
-
- it('blocks reading through a symlink that resolves to a sensitive file', async () => {
- const target = join(outsideDir, 'id_rsa');
- await writeFile(target, 'secret-key');
- const link = join(wsDir, 'notes.md');
- await symlink(target, link);
-
- const result = await execute(makeRealFsTool(wsDir), { path: link });
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toContain('sensitive-file pattern');
- expect(toolContentString(result)).not.toContain('secret-key');
- });
-
- it('blocks reading an absolute outside symlink that resolves to a sensitive file', async () => {
- const target = join(outsideDir, 'id_rsa');
- await writeFile(target, 'secret-key');
- const link = join(outsideDir, 'notes.md');
- await symlink(target, link);
-
- const result = await execute(makeRealFsTool(wsDir), { path: link });
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toContain('sensitive-file pattern');
- expect(toolContentString(result)).not.toContain('secret-key');
- });
-
- it('allows reading through a symlink that stays inside the workspace', async () => {
- const target = join(wsDir, 'real.txt');
- await writeFile(target, 'alpha\n');
- const link = join(wsDir, 'alias.txt');
- await symlink(target, link);
-
- const result = await execute(makeRealFsTool(wsDir), { path: link });
-
- expect(result.isError).not.toBe(true);
- expect(toolContentString(result)).toContain('1\talpha');
- });
-
-});
diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts
index 265ca786f..910258929 100644
--- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts
+++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts
@@ -1,12 +1,8 @@
-import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '#/tool/path-access';
import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context';
import { type WriteInput, WriteInputSchema } from '#/agent/tools/os/write/write';
import { WriteTool } from '#/agent/tools/os/write/writeTool';
@@ -427,59 +423,3 @@ describe('WriteTool', () => {
expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/file.txt', 'content');
});
});
-
-describe('WriteTool symlink escape', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'write-symlink-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('rejects writes through a symlink that points outside the workspace', async () => {
- const target = join(outsideDir, 'target.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'link.txt');
- await symlink(target, link);
- const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir));
-
- const result = await execute(tool, { path: link, content: 'pwned' });
-
- expect(result).toMatchObject({ isError: true });
- expect(toolContentString(result)).toMatch(/symbolic link/);
- await expect(readFile(target, 'utf8')).resolves.toBe('original');
- });
-
- it('allows writes through a symlink that stays inside the workspace', async () => {
- const target = join(wsDir, 'real.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'alias.txt');
- await symlink(target, link);
- const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir));
-
- const result = await execute(tool, { path: link, content: 'updated' });
-
- expect(result.isError).toBeFalsy();
- await expect(readFile(target, 'utf8')).resolves.toBe('updated');
- });
-
- it('allows writes to the project config through its real path', async () => {
- const configDir = join(wsDir, '.pythinker-code');
- await mkdir(configDir);
- const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir));
-
- const result = await execute(tool, { path: join(configDir, 'local.toml'), content: 'updated' });
-
- expect(result.isError).toBeFalsy();
- await expect(readFile(join(configDir, 'local.toml'), 'utf8')).resolves.toBe('updated');
- });
-});
diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts
deleted file mode 100644
index 95347ac30..000000000
--- a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { mkdtempSync } from 'node:fs';
-import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-
-import { dirname, join } from 'pathe';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-
-import type { IBootstrapService } from '#/app/bootstrap/bootstrap';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
-import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService';
-
-describe('FileProjectLocalConfigService additional_dir scope', () => {
- let homeDir: string;
- let workDir: string;
- let cleanupDirs: string[];
-
- beforeEach(() => {
- homeDir = mkdtempSync(join(tmpdir(), 'pythinker-local-config-home-'));
- workDir = mkdtempSync(join(tmpdir(), 'pythinker-local-config-work-'));
- cleanupDirs = [homeDir, workDir];
- });
-
- afterEach(async () => {
- await Promise.all(cleanupDirs.map((dir) => rm(dir, { recursive: true, force: true })));
- });
-
- function createService(): FileProjectLocalConfigService {
- const bootstrap = { _serviceBrand: undefined, osHomeDir: homeDir } as IBootstrapService;
- return new FileProjectLocalConfigService(bootstrap, new HostFileSystem());
- }
-
- async function writeLocalToml(additionalDirs: readonly string[]): Promise {
- const dir = join(workDir, '.pythinker-code');
- await mkdir(dir, { recursive: true });
- const entries = additionalDirs.map((dir) => `"${dir}"`).join(', ');
- await writeFile(join(dir, 'local.toml'), `[workspace]\nadditional_dir = [${entries}]\n`, 'utf8');
- }
-
- it('rejects an additional_dir that resolves to the user home directory', async () => {
- await writeLocalToml([homeDir]);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('rejects a bare ~ additional_dir', async () => {
- await writeLocalToml(['~']);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('rejects an additional_dir that resolves to the filesystem root', async () => {
- await writeLocalToml(['/']);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('rejects an additional_dir that is an ancestor of the user home directory', async () => {
- await writeLocalToml([dirname(homeDir)]);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('rejects an additional_dir that is the real target of a symlinked home directory', async () => {
- const realHome = await mkdtemp(join(tmpdir(), 'pythinker-local-config-realhome-'));
- cleanupDirs.push(realHome);
- const homeLink = join(workDir, 'home-link');
- await symlink(realHome, homeLink);
- const bootstrap = { _serviceBrand: undefined, osHomeDir: homeLink } as IBootstrapService;
- const service = new FileProjectLocalConfigService(bootstrap, new HostFileSystem());
- await writeLocalToml([realHome]);
-
- await expect(service.readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('still allows a subdirectory of the home directory', async () => {
- const shared = join(homeDir, 'shared');
- await mkdir(shared, { recursive: true });
- cleanupDirs.push(shared);
- await writeLocalToml([shared]);
-
- await expect(createService().readAdditionalDirs(workDir)).resolves.toMatchObject({
- additionalDirs: [shared],
- });
- });
-
- it('rejects an additional_dir that symlinks to the user home directory', async () => {
- const link = join(workDir, 'home-link');
- await symlink(homeDir, link);
- await writeLocalToml([link]);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('rejects an additional_dir that symlinks to the filesystem root', async () => {
- const link = join(workDir, 'root-link');
- await symlink('/', link);
- await writeLocalToml([link]);
-
- await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({
- code: 'config.invalid',
- });
- });
-
- it('still allows a symlink into a subdirectory of the home directory', async () => {
- const shared = join(homeDir, 'shared');
- await mkdir(shared, { recursive: true });
- const link = join(workDir, 'shared-link');
- await symlink(shared, link);
- await writeLocalToml([link]);
-
- await expect(createService().readAdditionalDirs(workDir)).resolves.toMatchObject({
- additionalDirs: [link],
- });
- });
-});
diff --git a/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts
index 3bb6dc30c..b6b643cec 100644
--- a/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts
+++ b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts
@@ -1,3 +1,5 @@
+import { Readable, type Writable } from 'node:stream';
+
import { describe, expect, it, vi } from 'vitest';
import {
@@ -6,25 +8,37 @@ import {
sanitizeRemoteUrl,
} from '#/session/agentLifecycle/profile/gitContext';
import type { ILogger } from '#/_base/log/log';
-import type { IGitService, RunGitResult } from '#/app/git/git';
+import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
+
+function processWith(stdout: string, exitCode: number, stderr = ''): IHostProcess {
+ const stdoutStream = Readable.from([Buffer.from(stdout)]);
+ const stderrStream = Readable.from([Buffer.from(stderr)]);
+ return {
+ _serviceBrand: undefined,
+ stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
+ stdout: stdoutStream,
+ stderr: stderrStream,
+ pid: 1,
+ exitCode,
+ wait: vi.fn().mockResolvedValue(exitCode),
+ kill: vi.fn(async () => {}),
+ dispose: vi.fn(async () => {
+ stdoutStream.destroy();
+ stderrStream.destroy();
+ }),
+ };
+}
type GitScript = Record;
-function gitService(script: GitScript): { git: IGitService; runGit: ReturnType } {
- const runGit = vi.fn(async (_cwd: string, args: readonly string[]): Promise => {
- const key = args.join(' ');
+function gitRunner(script: GitScript): { process: IHostProcessService; spawn: ReturnType } {
+ const spawn = vi.fn(async (_command: string, args: readonly string[]) => {
+ const key = args.slice(2).join(' ');
const out = script[key];
- if (out === undefined) return { exitCode: 1, stdout: '', stderr: '' };
- return { exitCode: out.exitCode ?? 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
+ if (out === undefined) return processWith('', 1);
+ return processWith(out.stdout ?? '', out.exitCode ?? 0, out.stderr ?? '');
});
- const git = {
- _serviceBrand: undefined,
- status: vi.fn(),
- diff: vi.fn(),
- findWorkTree: vi.fn(),
- runGit,
- } as unknown as IGitService;
- return { git, runGit };
+ return { process: { _serviceBrand: undefined, spawn } as IHostProcessService, spawn };
}
function spyLogger(): {
@@ -46,7 +60,7 @@ function spyLogger(): {
describe('collectGitContext', () => {
it('builds a git-context block with all sections', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true\n' },
'remote get-url origin': { stdout: 'git@github.com:owner/repo.git\n' },
'symbolic-ref --short HEAD': { stdout: 'main\n' },
@@ -54,7 +68,7 @@ describe('collectGitContext', () => {
'log -3 --format=%h %s': { stdout: 'abc123 Initial commit\ndef456 second commit' },
});
- const block = await collectGitContext(git, '/repo');
+ const block = await collectGitContext(hostProcess, '/repo');
expect(block.startsWith('\n')).toBe(true);
expect(block.endsWith('\n ')).toBe(true);
@@ -69,7 +83,7 @@ describe('collectGitContext', () => {
});
it('returns an unavailable block when the directory is not a git repository', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': {
exitCode: 128,
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
@@ -77,7 +91,7 @@ describe('collectGitContext', () => {
});
const { logger, debug, warn } = spyLogger();
- await expect(collectGitContext(git, '/not-a-repo', logger)).resolves.toBe(
+ await expect(collectGitContext(hostProcess, '/not-a-repo', logger)).resolves.toBe(
' ',
);
expect(debug).not.toHaveBeenCalled();
@@ -85,12 +99,12 @@ describe('collectGitContext', () => {
});
it('returns an empty string when rev-parse fails for a reason other than not-a-repo', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { exitCode: 1, stderr: 'fatal: some other git error' },
});
const { logger, debug } = spyLogger();
- await expect(collectGitContext(git, '/repo', logger)).resolves.toBe('');
+ await expect(collectGitContext(hostProcess, '/repo', logger)).resolves.toBe('');
expect(debug).toHaveBeenCalledWith(
'git context command failed',
expect.objectContaining({
@@ -102,12 +116,15 @@ describe('collectGitContext', () => {
});
it('returns an empty string when git fails to spawn', async () => {
- const { git } = gitService({
- 'rev-parse --is-inside-work-tree': { exitCode: -1, stderr: 'spawn failed' },
- });
+ const hostProcess = {
+ _serviceBrand: undefined,
+ spawn: vi.fn(async (): Promise => {
+ throw new Error('spawn failed');
+ }),
+ } as IHostProcessService;
const { logger, warn } = spyLogger();
- await expect(collectGitContext(git, '/repo', logger)).resolves.toBe('');
+ await expect(collectGitContext(hostProcess, '/repo', logger)).resolves.toBe('');
expect(warn).toHaveBeenCalledWith(
'git context command failed to spawn',
expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }),
@@ -116,7 +133,7 @@ describe('collectGitContext', () => {
it('caps dirty files at 20 and reports the remainder', async () => {
const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n');
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
'remote get-url origin': { stdout: '' },
'symbolic-ref --short HEAD': { stdout: '' },
@@ -124,22 +141,22 @@ describe('collectGitContext', () => {
'log -3 --format=%h %s': { stdout: '' },
});
- const block = await collectGitContext(git, '/repo');
+ const block = await collectGitContext(hostProcess, '/repo');
expect(block).toContain('Dirty files (25):');
expect(block).toContain(' ... and 5 more');
});
it('returns an empty string when only the working directory is known', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
});
- await expect(collectGitContext(git, '/repo')).resolves.toBe('');
+ await expect(collectGitContext(hostProcess, '/repo')).resolves.toBe('');
});
it('omits both Remote and Project for a disallowed remote host', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
'remote get-url origin': { stdout: 'git@internal.example.test:secret/repo.git' },
'symbolic-ref --short HEAD': { stdout: 'main' },
@@ -147,7 +164,7 @@ describe('collectGitContext', () => {
'log -3 --format=%h %s': { stdout: '' },
});
- const block = await collectGitContext(git, '/repo');
+ const block = await collectGitContext(hostProcess, '/repo');
expect(block).not.toContain('Remote:');
expect(block).not.toContain('Project:');
@@ -156,7 +173,7 @@ describe('collectGitContext', () => {
});
it('keeps branch and status when the origin remote is absent', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
'remote get-url origin': { exitCode: 2, stderr: "error: No such remote 'origin'" },
'symbolic-ref --short HEAD': { stdout: 'main' },
@@ -165,7 +182,7 @@ describe('collectGitContext', () => {
});
const { logger, debug } = spyLogger();
- const block = await collectGitContext(git, '/repo', logger);
+ const block = await collectGitContext(hostProcess, '/repo', logger);
expect(block).toContain('Branch: main');
expect(block).toContain('Dirty files (1):');
@@ -179,7 +196,7 @@ describe('collectGitContext', () => {
});
it('keeps branch and status when the repository has no commits yet', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
'remote get-url origin': { stdout: 'https://github.com/acme/widgets.git' },
'symbolic-ref --short HEAD': { stdout: 'main' },
@@ -190,7 +207,7 @@ describe('collectGitContext', () => {
},
});
- const block = await collectGitContext(git, '/repo');
+ const block = await collectGitContext(hostProcess, '/repo');
expect(block).toContain('Branch: main');
expect(block).toContain('Remote: https://github.com/acme/widgets.git');
@@ -199,7 +216,7 @@ describe('collectGitContext', () => {
});
it('omits the Branch section in detached HEAD state', async () => {
- const { git } = gitService({
+ const { process: hostProcess } = gitRunner({
'rev-parse --is-inside-work-tree': { stdout: 'true' },
'symbolic-ref --short HEAD': {
exitCode: 128,
@@ -210,24 +227,50 @@ describe('collectGitContext', () => {
'log -3 --format=%h %s': { stdout: 'abc123 first commit' },
});
- const block = await collectGitContext(git, '/repo');
+ const block = await collectGitContext(hostProcess, '/repo');
expect(block).not.toContain('Branch:');
expect(block).toContain('Remote: https://github.com/acme/widgets.git');
expect(block).toContain('Recent commits:');
});
- it('treats a timed-out git command as a failure', async () => {
- const { git } = gitService({
- 'rev-parse --is-inside-work-tree': { exitCode: -1, stderr: '' },
- });
- const { logger, warn } = spyLogger();
-
- await expect(collectGitContext(git, '/repo', logger)).resolves.toBe('');
- expect(warn).toHaveBeenCalledWith(
- 'git context command failed to spawn',
- expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }),
- );
+ it('treats a hanging git command as a failure (timeout)', async () => {
+ vi.useFakeTimers();
+ try {
+ const hostProcess = {
+ _serviceBrand: undefined,
+ spawn: vi.fn(async (): Promise => {
+ let release: (code: number) => void = () => {};
+ const exited = new Promise((resolve) => {
+ release = resolve;
+ });
+ return {
+ _serviceBrand: undefined,
+ stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
+ stdout: Readable.from(['']),
+ stderr: Readable.from(['']),
+ pid: 1,
+ exitCode: null,
+ wait: vi.fn(() => exited),
+ kill: vi.fn(async () => {
+ release(137);
+ }),
+ dispose: vi.fn(),
+ };
+ }),
+ } as IHostProcessService;
+ const { logger, debug } = spyLogger();
+
+ const promise = collectGitContext(hostProcess, '/repo', logger);
+ await vi.advanceTimersByTimeAsync(6_000);
+ await expect(promise).resolves.toBe('');
+ expect(debug).toHaveBeenCalledWith(
+ 'git context command timed out',
+ expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }),
+ );
+ } finally {
+ vi.useRealTimers();
+ }
});
});
diff --git a/packages/agent-core-v2/test/tool/path-access.test.ts b/packages/agent-core-v2/test/tool/path-access.test.ts
index 9fa2347b6..d2b0c8e87 100644
--- a/packages/agent-core-v2/test/tool/path-access.test.ts
+++ b/packages/agent-core-v2/test/tool/path-access.test.ts
@@ -4,22 +4,11 @@ import type { ShellPathBridge } from '#/_base/execEnv/shellPathBridge';
import {
DEFAULT_WORKSPACE_ACCESS_POLICY,
extendWorkspaceWithSkillRoots,
- isProjectLocalConfigPath,
isSensitiveFile,
resolvePathAccess,
resolvePathAccessPath,
} from '#/tool/path-access';
-describe('isProjectLocalConfigPath', () => {
- it('matches posix, Windows, and case variants of the project-local config', () => {
- expect(isProjectLocalConfigPath('/repo/.pythinker-code/local.toml')).toBe(true);
- expect(isProjectLocalConfigPath('C:\\repo\\.pythinker-code\\local.toml')).toBe(true);
- expect(isProjectLocalConfigPath('/repo/.PYTHINKER-CODE/LOCAL.TOML')).toBe(true);
- expect(isProjectLocalConfigPath('/repo/.pythinker-code/local.toml.bak')).toBe(false);
- expect(isProjectLocalConfigPath('/repo/other/local.toml')).toBe(false);
- });
-});
-
describe('isSensitiveFile', () => {
it('flags base .env files in any directory', () => {
for (const path of ['.env', '/app/.env', 'project/.env']) {
diff --git a/packages/agent-core-v2/test/tool/realpath-access.test.ts b/packages/agent-core-v2/test/tool/realpath-access.test.ts
deleted file mode 100644
index c1092a85d..000000000
--- a/packages/agent-core-v2/test/tool/realpath-access.test.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
-import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
-import type { WorkspaceConfig } from '#/tool/path-access';
-import { assertRealPathWithinWorkspace, assertRealPathWriteTarget } from '#/tool/realpath-access';
-
-describe('realpath access guard', () => {
- let tmpDir: string;
- let wsDir: string;
- let outsideDir: string;
- let fs: HostFileSystem;
- let workspace: WorkspaceConfig;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'realpath-access-'));
- wsDir = join(tmpDir, 'ws');
- outsideDir = join(tmpDir, 'outside');
- await mkdir(wsDir);
- await mkdir(outsideDir);
- fs = new HostFileSystem();
- workspace = { workspaceDir: wsDir, additionalDirs: [] };
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- it('allows a symlink that stays inside the workspace', async () => {
- const target = join(wsDir, 'real.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'alias.txt');
- await symlink(target, link);
-
- await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).resolves.toBeUndefined();
- });
-
- it('rejects a symlink that points outside the workspace', async () => {
- const target = join(outsideDir, 'target.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'link.txt');
- await symlink(target, link);
-
- await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow(
- /symbolic link/,
- );
- });
-
- it('rejects a path whose symlinked parent directory points outside the workspace', async () => {
- const fakeHome = join(tmpDir, 'fake-home');
- await mkdir(fakeHome);
- const target = join(fakeHome, '.bashrc');
- await writeFile(target, 'original');
- await symlink(fakeHome, join(wsDir, 'home'));
-
- await expect(
- assertRealPathWriteTarget(fs, join(wsDir, 'home', '.bashrc'), workspace, 'posix'),
- ).rejects.toThrow(/symbolic link/);
- });
-
- it('rejects a path that is too deep to verify', async () => {
- const target = join(outsideDir, 'target.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'link');
- await symlink(outsideDir, link);
-
- const deep = join(link, ...Array.from({ length: 300 }, () => 'a'), 'file.txt');
- await expect(assertRealPathWriteTarget(fs, deep, workspace, 'posix')).rejects.toThrow(
- /too deep/,
- );
- });
-
- it('blocks a target that resolves to a sensitive file', async () => {
- const target = join(outsideDir, 'id_rsa');
- await writeFile(target, 'secret-key');
- const link = join(wsDir, 'notes.md');
- await symlink(target, link);
-
- await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow(
- /sensitive-file pattern/,
- );
- });
-
- it('rejects a dangling symlink whose target does not exist', async () => {
- const link = join(wsDir, 'dangling.txt');
- await symlink(join(outsideDir, 'missing.txt'), link);
-
- await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow(
- /symbolic link/,
- );
- });
-
- it('rejects the project config through a symlink alias', async () => {
- const configDir = join(wsDir, '.pythinker-code');
- await mkdir(configDir);
- await writeFile(join(configDir, 'local.toml'), 'original');
- const alias = join(wsDir, 'config-link');
- await symlink(configDir, alias);
-
- await expect(
- assertRealPathWriteTarget(fs, join(alias, 'local.toml'), workspace, 'posix'),
- ).rejects.toThrow(/project-local config/);
- });
-
- it('allows the project config through its real path', async () => {
- const configDir = join(wsDir, '.pythinker-code');
- await mkdir(configDir);
- await writeFile(join(configDir, 'local.toml'), 'original');
-
- await expect(
- assertRealPathWriteTarget(fs, join(configDir, 'local.toml'), workspace, 'posix'),
- ).resolves.toBeUndefined();
- });
-
- it('allows a symlink that points into an additional dir', async () => {
- const target = join(outsideDir, 'shared.txt');
- await writeFile(target, 'original');
- const link = join(wsDir, 'shared.txt');
- await symlink(target, link);
- const withAdditional: WorkspaceConfig = { workspaceDir: wsDir, additionalDirs: [outsideDir] };
-
- await expect(assertRealPathWriteTarget(fs, link, withAdditional, 'posix')).resolves.toBeUndefined();
- });
-
- it('blocks an absolute outside symlink that resolves to a sensitive file', async () => {
- const target = join(outsideDir, 'id_rsa');
- await writeFile(target, 'secret-key');
- const link = join(tmpDir, 'notes.md');
- await symlink(target, link);
-
- await expect(assertRealPathWithinWorkspace(fs, link, workspace, 'posix')).rejects.toThrow(
- /sensitive-file pattern/,
- );
- });
-
- it('allows an absolute outside path that is not sensitive', async () => {
- const target = join(outsideDir, 'plain.txt');
- await writeFile(target, 'data');
-
- await expect(assertRealPathWithinWorkspace(fs, target, workspace, 'posix')).resolves.toBe(
- target,
- );
- });
-
- it('compares resolved config paths with Windows semantics', async () => {
- const realpath = vi.fn(async (path: string) => {
- if (path === 'C:/ws/alias/local.toml') return 'C:\\ws\\.pythinker-code\\local.toml';
- return path.replaceAll('/', '\\');
- });
- const winFs = { realpath } as unknown as IHostFileSystem;
- const winWorkspace: WorkspaceConfig = { workspaceDir: 'C:/ws', additionalDirs: [] };
-
- await expect(
- assertRealPathWriteTarget(winFs, 'C:/ws/.pythinker-code/local.toml', winWorkspace, 'win32'),
- ).resolves.toBeUndefined();
- await expect(
- assertRealPathWriteTarget(winFs, 'C:/ws/alias/local.toml', winWorkspace, 'win32'),
- ).rejects.toThrow(/project-local config/);
- });
-});
diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
index bee9d4b32..f8efa4c47 100644
--- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
+++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
@@ -30,7 +30,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos
readdir: () => notImplemented('FakeHostFs.readdir'),
mkdir: () => notImplemented('FakeHostFs.mkdir'),
remove: () => notImplemented('FakeHostFs.remove'),
- realpath: (path) => Promise.resolve(path),
+ realpath: () => notImplemented('FakeHostFs.realpath'),
};
return { ...fs, ...overrides };
}
diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts
index a72984884..d0810822a 100644
--- a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts
+++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentProfileLoader.test.ts
@@ -46,8 +46,6 @@ import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileL
import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader';
import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader';
-import { setWatchEnabled } from '#human/utils/watch';
-
import { stubBootstrap } from '../../app/bootstrap/stubs';
const watchMockState = vi.hoisted(() => ({ mode: 'inert' as 'inert' | 'real' }));
@@ -337,7 +335,6 @@ async function withStack(
describe('agent profile loaders + session catalog', () => {
beforeEach(() => {
watchMockState.mode = 'inert';
- setWatchEnabled(false);
delete process.env['PYTHINKER_CODE_WATCH'];
_clearAgentProfileContributionsForTests();
const builtinDefault: AgentProfile = normalizeAgentProfile({
@@ -762,7 +759,6 @@ describe('agent profile loaders + session catalog', () => {
it('rescans the workspace source when a project agent file changes on disk', async () => {
watchMockState.mode = 'real';
- setWatchEnabled(true);
process.env['PYTHINKER_CODE_WATCH'] = '1';
try {
await withFixture(async (fixture) => {
@@ -795,7 +791,6 @@ describe('agent profile loaders + session catalog', () => {
});
} finally {
delete process.env['PYTHINKER_CODE_WATCH'];
- setWatchEnabled(false);
}
}, 15000);
diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts
deleted file mode 100644
index 97c55d343..000000000
--- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import { mkdtempSync } from 'node:fs';
-import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-
-import { join } from 'pathe';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { DisposableStore } from '#/_base/di/lifecycle';
-import { createServices } from '#/_base/di/test';
-import { Emitter } from '#/_base/event';
-import { ILogService } from '#/_base/log/log';
-import type { IBootstrapService } from '#/app/bootstrap/bootstrap';
-import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig';
-import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
-import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService';
-import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
-import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
-import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService';
-import {
- IWorkspaceTrust,
- type WorkspaceTrustChange,
-} from '#/workspace/workspaceTrust/workspaceTrust';
-import type { WatchChange } from '#human/utils/watch';
-
-import { stubLog } from '../../_base/log/stubs';
-import { registerStateServices } from '../../state/stubs';
-
-const watchFires = new Map>();
-
-vi.mock('#human/utils/watch', () => {
- const watch = (path: string) => {
- let emitter = watchFires.get(path);
- if (emitter === undefined) {
- emitter = new Emitter();
- watchFires.set(path, emitter);
- }
- return { ready: Promise.resolve(), onDidChange: emitter.event, dispose: () => {} };
- };
- return {
- watch,
- watchCandidates: (root: string) => watch(root),
- };
-});
-
-describe('WorkspaceDirsService trust gating', () => {
- let cwd: string;
- let homeDir: string;
- let extraDir: string;
- let disposables: DisposableStore;
- let trusted: boolean;
- let trustFlips: Emitter;
- let changes: number;
-
- beforeEach(() => {
- cwd = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-cwd-'));
- homeDir = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-home-'));
- extraDir = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-extra-'));
- disposables = new DisposableStore();
- watchFires.clear();
- trusted = true;
- trustFlips = new Emitter();
- changes = 0;
- });
-
- afterEach(async () => {
- disposables.dispose();
- await Promise.all(
- [cwd, homeDir, extraDir].map((dir) => rm(dir, { recursive: true, force: true })),
- );
- });
-
- function createService(): IWorkspaceDirs {
- const ix = createServices(disposables, {
- strict: true,
- additionalServices: (reg) => {
- registerStateServices(reg);
- reg.definePartialInstance(IWorkspaceContext, { cwd });
- reg.defineInstance(
- IProjectLocalConfigService,
- new FileProjectLocalConfigService(
- { _serviceBrand: undefined, osHomeDir: homeDir } as IBootstrapService,
- new HostFileSystem(),
- ),
- );
- reg.defineInstance(ILogService, stubLog());
- reg.definePartialInstance(IWorkspaceTrust, {
- ready: Promise.resolve(),
- isTrusted: () => trusted,
- onDidChange: trustFlips.event,
- });
- reg.define(IWorkspaceDirs, WorkspaceDirsService);
- },
- });
- const service = ix.get(IWorkspaceDirs);
- service.onDidChange(() => {
- changes += 1;
- });
- return service;
- }
-
- async function writeLocalToml(additionalDirs: readonly string[]): Promise {
- const dir = join(cwd, '.pythinker-code');
- await mkdir(dir, { recursive: true });
- const file = join(dir, 'local.toml');
- const entries = additionalDirs.map((dir) => `"${dir}"`).join(', ');
- await writeFile(file, `[workspace]\nadditional_dir = [${entries}]\n`, 'utf8');
- return file;
- }
-
- it('loads local.toml additional dirs when the workspace is trusted', async () => {
- await writeLocalToml([extraDir]);
-
- const service = createService();
- await service.ready;
-
- expect(service.additionalDirs).toEqual([extraDir]);
- });
-
- it('does not restore configured dirs when trust is lost during a reload', async () => {
- await writeLocalToml([extraDir]);
- const service = createService();
- await service.ready;
- expect(service.additionalDirs).toEqual([extraDir]);
-
- const original = FileProjectLocalConfigService.prototype.readAdditionalDirs;
- const read = vi
- .spyOn(FileProjectLocalConfigService.prototype, 'readAdditionalDirs')
- .mockImplementation(async function (this: FileProjectLocalConfigService, workDir: string) {
- const result = await original.call(this, workDir);
- trusted = false;
- return result;
- });
- try {
- const file = join(cwd, '.pythinker-code', 'local.toml');
- watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' });
- await vi.waitFor(() => {
- expect(service.additionalDirs).toEqual([]);
- });
- } finally {
- read.mockRestore();
- }
- });
-
- it('ignores local.toml additional dirs while the workspace is untrusted', async () => {
- await writeLocalToml([extraDir]);
- trusted = false;
-
- const service = createService();
- await service.ready;
-
- expect(service.additionalDirs).toEqual([]);
- });
-
- it('loads the additional dirs when the workspace becomes trusted', async () => {
- await writeLocalToml([extraDir]);
- trusted = false;
- const service = createService();
- await service.ready;
- expect(service.additionalDirs).toEqual([]);
-
- trusted = true;
- trustFlips.fire({ trusted: true });
-
- await vi.waitFor(
- () => {
- expect(service.additionalDirs).toEqual([extraDir]);
- },
- { timeout: 10000, interval: 50 },
- );
- expect(changes).toBe(1);
- }, 20000);
-
- it('clears the additional dirs when the workspace loses trust', async () => {
- await writeLocalToml([extraDir]);
- const service = createService();
- await service.ready;
- expect(service.additionalDirs).toEqual([extraDir]);
-
- trusted = false;
- trustFlips.fire({ trusted: false });
-
- expect(service.additionalDirs).toEqual([]);
- }, 20000);
-
- it('ignores watched local.toml changes while the workspace is untrusted', async () => {
- trusted = false;
- const service = createService();
- await service.ready;
-
- const file = await writeLocalToml([extraDir]);
- watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' });
-
- await new Promise((resolve) => setTimeout(resolve, 500));
- expect(service.additionalDirs).toEqual([]);
- expect(changes).toBe(0);
- }, 20000);
-
- it('adds an ephemeral dir without parsing a planted local.toml while untrusted', async () => {
- await writeLocalToml([homeDir]);
- trusted = false;
- const service = createService();
- await service.ready;
-
- const result = await service.addDir({ path: extraDir, persist: false });
-
- expect(result.persisted).toBe(false);
- expect(result.additionalDirs).toEqual([extraDir]);
- });
-
- it('persists the explicit dir but loads only it while the workspace is untrusted', async () => {
- const plantedDir = join(homeDir, 'planted');
- await mkdir(plantedDir, { recursive: true });
- await writeLocalToml([plantedDir]);
- trusted = false;
- const service = createService();
- await service.ready;
- expect(service.additionalDirs).toEqual([]);
-
- const result = await service.addDir({ path: extraDir });
-
- expect(result.persisted).toBe(true);
- expect(result.additionalDirs).toEqual([extraDir]);
- expect(service.additionalDirs).toEqual([extraDir]);
- const onDisk = await readFile(join(cwd, '.pythinker-code', 'local.toml'), 'utf8');
- expect(onDisk).toContain(plantedDir);
- expect(onDisk).toContain(extraDir);
- });
-
- it('keeps the explicitly added dir after a watched reload while untrusted', async () => {
- trusted = false;
- const service = createService();
- await service.ready;
- await service.addDir({ path: extraDir });
- expect(service.additionalDirs).toEqual([extraDir]);
-
- const file = join(cwd, '.pythinker-code', 'local.toml');
- watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' });
-
- await new Promise((resolve) => setTimeout(resolve, 500));
- expect(service.additionalDirs).toEqual([extraDir]);
- }, 20000);
-
- it('loads every persisted dir after add-dir when the workspace is trusted', async () => {
- const plantedDir = join(homeDir, 'planted');
- await mkdir(plantedDir, { recursive: true });
- await writeLocalToml([plantedDir]);
- const service = createService();
- await service.ready;
- expect(service.additionalDirs).toEqual([plantedDir]);
-
- const result = await service.addDir({ path: extraDir });
-
- expect(result.persisted).toBe(true);
- expect(result.additionalDirs).toEqual([plantedDir, extraDir]);
- expect(service.additionalDirs).toEqual([plantedDir, extraDir]);
- });
-});
diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts
index cdb283381..68c338f3e 100644
--- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts
+++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts
@@ -357,7 +357,6 @@ function defaultGitStub(): IGitService {
}),
diff: async () => ({ path: '', diff: '', truncated: false }),
findWorkTree: async () => null,
- runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
};
}
@@ -424,8 +423,7 @@ describe('WorkspaceFsService.gitStatus', () => {
},
diff: async () => ({ path: '', diff: '', truncated: false }),
findWorkTree: async () => null,
- runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
- };
+ };
const fs = makeSession({}, emptyHandler, [], git);
const result = await fs.gitStatus({ paths: ['src/a.ts'] });
expect(calls).toHaveLength(1);
@@ -444,8 +442,7 @@ describe('WorkspaceFsService.gitStatus', () => {
},
diff: async () => ({ path: '', diff: '', truncated: false }),
findWorkTree: async () => null,
- runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
- };
+ };
const fs = makeSession({}, emptyHandler, [], git);
await expect(fs.gitStatus({})).rejects.toMatchObject({ code: 'fs.git_unavailable' });
});
@@ -470,8 +467,7 @@ describe('WorkspaceFsService.diff', () => {
return { path: rel, diff: '-old\n+new\n', truncated: false };
},
findWorkTree: async () => null,
- runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
- };
+ };
const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git);
const result = await fs.diff({ path: 'src/a.ts' });
expect(calls).toHaveLength(1);
diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts
index 2f026d64b..5bd92d3d2 100644
--- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts
+++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts
@@ -11,6 +11,7 @@ import { createServices } from '#/_base/di/test';
import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
+import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
import { IWorkspaceStateService } from '#/workspace/state/workspaceState';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
@@ -19,6 +20,7 @@ import {
type WorkspaceTrustChange,
} from '#/workspace/workspaceTrust/workspaceTrust';
import {
+ TRUST_WORKSPACE_ENV,
WorkspaceTrustService,
workspaceTrustTrustedKey,
} from '#/workspace/workspaceTrust/workspaceTrustService';
@@ -28,6 +30,7 @@ import {
writeWorkspaceTrust,
} from '#/workspace/workspaceTrust/trustRecord';
+import { stubBootstrap } from '../../app/bootstrap/stubs';
import { registerStateServices } from '../../state/stubs';
describe('WorkspaceTrustService', () => {
@@ -52,12 +55,14 @@ describe('WorkspaceTrustService', () => {
function createService(
root: string,
events?: WorkspaceTrustChange[],
+ env: NodeJS.ProcessEnv = {},
): { service: IWorkspaceTrust; states: IWorkspaceStateService } {
const ix = createServices(disposables, {
strict: true,
additionalServices: (reg) => {
registerStateServices(reg);
reg.definePartialInstance(IWorkspaceContext, { cwd: root });
+ reg.defineInstance(IBootstrapService, stubBootstrap(homeDir, env));
reg.defineInstance(
IAtomicDocumentStore,
new JsonAtomicDocumentStore(new FileStorageService(homeDir)),
@@ -81,6 +86,42 @@ describe('WorkspaceTrustService', () => {
expect(await service.get()).toBe(false);
});
+ it('trusts the workspace when PYTHINKER_CODE_TRUST_WORKSPACE is set', async () => {
+ const { service } = createService(cwd, undefined, {
+ [TRUST_WORKSPACE_ENV]: '1',
+ });
+ await service.ready;
+
+ expect(service.isTrusted()).toBe(true);
+ expect(await service.get()).toBe(true);
+ });
+
+ it('ignores PYTHINKER_CODE_TRUST_WORKSPACE values that do not parse as true', async () => {
+ const { service } = createService(cwd, undefined, {
+ [TRUST_WORKSPACE_ENV]: '0',
+ });
+ await service.ready;
+
+ expect(service.isTrusted()).toBe(false);
+ expect(await service.get()).toBe(false);
+ });
+
+ it('keeps persistence semantics under PYTHINKER_CODE_TRUST_WORKSPACE', async () => {
+ const events: WorkspaceTrustChange[] = [];
+ const { service, states } = createService(cwd, events, {
+ [TRUST_WORKSPACE_ENV]: '1',
+ });
+ await service.ready;
+
+ await service.trust();
+ expect(states.get(workspaceTrustTrustedKey)).toBe(true);
+
+ await service.untrust();
+ expect(states.get(workspaceTrustTrustedKey)).toBe(false);
+ expect(service.isTrusted()).toBe(true);
+ expect(events).toEqual([{ trusted: true }, { trusted: false }]);
+ });
+
it('trust() flips the state, fires once, and stays idempotent', async () => {
const events: WorkspaceTrustChange[] = [];
const { service } = createService(cwd, events);
diff --git a/packages/agent-gateway/src/routes/workspaces.ts b/packages/agent-gateway/src/routes/workspaces.ts
index 986a20d89..e04fc57d7 100644
--- a/packages/agent-gateway/src/routes/workspaces.ts
+++ b/packages/agent-gateway/src/routes/workspaces.ts
@@ -264,7 +264,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope):
const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply);
if (trust === undefined) return;
await trust.untrust();
- reply.send(okEnvelope({ trusted: false }, req.id));
+ reply.send(okEnvelope({ trusted: await trust.get() }, req.id));
},
);
app.post(
diff --git a/packages/agent-gateway/test/sessions.test.ts b/packages/agent-gateway/test/sessions.test.ts
index 74fc9b9d8..29c038d6c 100644
--- a/packages/agent-gateway/test/sessions.test.ts
+++ b/packages/agent-gateway/test/sessions.test.ts
@@ -1088,7 +1088,7 @@ describe('server-v2 /api/v1/sessions', () => {
expect(children.body.data.items.some((s) => s.id === forked.body.data.id)).toBe(false);
});
- it('fork inherits cron tasks through the copied wire', async () => {
+ it('fork clears inherited cron tasks while the source keeps them', async () => {
const cwd = home as string;
const parent = await postJson('/api/v1/sessions', { metadata: { cwd } });
const parentId = parent.body.data.id;
@@ -1107,7 +1107,8 @@ describe('server-v2 /api/v1/sessions', () => {
const resumed = await resumeSessionById((server as RunningServer).core.accessor, forkedId);
expect(resumed).toBeDefined();
const forkedCron = resumed!.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID)!.accessor.get(IAgentCronService);
- expect(forkedCron.list().map((t) => ({ id: t.id, prompt: t.prompt }))).toEqual([
+ expect(forkedCron.list()).toEqual([]);
+ expect(cron.list().map((t) => ({ id: t.id, prompt: t.prompt }))).toEqual([
{ id: task.id, prompt: 'fork me' },
]);
});
diff --git a/packages/agent-gateway/test/v2Sessions.test.ts b/packages/agent-gateway/test/v2Sessions.test.ts
index 04adf3078..128734ee0 100644
--- a/packages/agent-gateway/test/v2Sessions.test.ts
+++ b/packages/agent-gateway/test/v2Sessions.test.ts
@@ -154,7 +154,6 @@ const gitStub: IGitService = {
throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'not used in these tests');
},
findWorkTree: async () => null,
- runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
};
describe('server /api/v2/sessions', () => {
diff --git a/packages/agent-gateway/test/workspaces.test.ts b/packages/agent-gateway/test/workspaces.test.ts
index bc4b8f73d..52df18885 100644
--- a/packages/agent-gateway/test/workspaces.test.ts
+++ b/packages/agent-gateway/test/workspaces.test.ts
@@ -45,7 +45,6 @@ describe('server-v2 /api/v1/workspaces', () => {
beforeAll(async () => {
home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-workspaces-'));
- process.env['PYTHINKER_CODE_WATCH'] = '1';
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
@@ -65,7 +64,6 @@ describe('server-v2 /api/v1/workspaces', () => {
await rm(home, { recursive: true, force: true });
home = undefined;
}
- delete process.env['PYTHINKER_CODE_WATCH'];
});
async function restartWithFreshHome(): Promise {
@@ -78,7 +76,6 @@ describe('server-v2 /api/v1/workspaces', () => {
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-workspaces-'));
- process.env['PYTHINKER_CODE_WATCH'] = '1';
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
@@ -451,4 +448,25 @@ describe('server-v2 /api/v1/workspaces', () => {
const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, {});
expect(body.code).toBe(40001);
});
+
+ it('reports the effective trust state after untrust while PYTHINKER_CODE_TRUST_WORKSPACE is set', async () => {
+ vi.stubEnv('PYTHINKER_CODE_TRUST_WORKSPACE', '1');
+ const root = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-workspaces-trust-env-'));
+ try {
+ const created = await postJson('/api/v1/workspaces', { root });
+ expect(created.body.code).toBe(0);
+ const id = created.body.data.id;
+
+ const revoked = await postJson<{ trusted: boolean }>(`/api/v1/workspaces/${id}/untrust`);
+ expect(revoked.body.code).toBe(0);
+ expect(revoked.body.data.trusted).toBe(true);
+
+ const read = await getJson<{ trusted: boolean }>(`/api/v1/workspaces/${id}/trust`);
+ expect(read.body.code).toBe(0);
+ expect(read.body.data.trusted).toBe(true);
+ } finally {
+ vi.unstubAllEnvs();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts
index f6d6b45e8..10c16c9b4 100644
--- a/packages/node-sdk/src/sdk-rpc-client-v2.ts
+++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts
@@ -132,14 +132,9 @@ import { join } from 'node:path';
import { encodeWorkDirKey } from '@pymodel/agent-core-v2/_base/utils/workdir-slug';
import { McpConnectionManager } from '@pymodel/agent-core-v2/mcpCore/connection-manager';
-import {
- loadMcpServers,
- loadMcpServersDetailed,
- resolveMcpJsonPaths,
-} from '@pymodel/agent-core-v2/app/mcpConfig/configLoader';
+import { loadMcpServers } from '@pymodel/agent-core-v2/app/mcpConfig/configLoader';
import { fsSuggestRequestSchema } from '@pymodel/agent-core-v2/workspace/workspaceFs/fs';
import { IAppendLogStore } from '@pymodel/agent-core-v2/persistence/interface/appendLogStore';
-import type { McpServerConfig as WorkspaceMcpServerConfig } from '@pymodel/agent-core-v2/mcpCore/config-schema';
import {
bootstrap,
DEFAULT_AGENT_PROFILE_NAME,
@@ -317,6 +312,7 @@ import type {
TelemetryClient,
UploadFileOptions,
WorkspaceTrustInfo,
+ WorkspaceTrustInstructionSources,
} from '#/types';
import {
diagnosticsToConfigDiagnostics,
@@ -680,39 +676,33 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
/**
* klient has no workspace-trust facade; composed directly from the engine
* via {@link engineAccessor} — the same `handlerFor({ root })` path
- * `createSession` takes (materializing the workspace handler is a no-op
- * cost here: session creation does it anyway). The gated-server list is
- * the final merged config entries whose origins are project files (the
- * workspaceTrust gate inside the engine's `workspaceMcpConfig`),
- * computed best-effort: an unreadable/invalid project file degrades to an
- * empty list rather than failing the caller.
+ * `createSession` takes. The disclosure of what trusting would activate is
+ * computed inside the engine by `WorkspaceTrustDisclosureService`.
*/
override async getWorkspaceTrustInfo(workDir: string): Promise {
const handler = await this.engineAccessor
.get(IWorkspaceInstanceManager)
.getOrCreate({ root: workDir });
const trusted = await handler.program.trust.get();
- if (trusted) return { trusted: true, gatedMcpServers: [] };
- try {
- const fs = this.engineAccessor.get(IHostFileSystem);
- const [paths, loaded] = await Promise.all([
- resolveMcpJsonPaths({ fs, cwd: workDir, homeDir: this.homeDir }),
- loadMcpServersDetailed({
- fs,
- cwd: workDir,
- homeDir: this.homeDir,
- includeProject: true,
- }),
- ]);
- const projectPaths = new Set([paths.projectRoot, paths.project]);
- const gatedMcpServers = Object.entries(loaded.servers)
- .filter(([name]) => projectPaths.has(loaded.origins[name] ?? ''))
- .map(([name, config]) => describeWorkspaceMcpServer(name, config))
- .toSorted((a, b) => a.name.localeCompare(b.name));
- return { trusted: false, gatedMcpServers };
- } catch {
- return { trusted: false, gatedMcpServers: [] };
+ if (trusted) {
+ return {
+ trusted: true,
+ gatedMcpServers: [],
+ gatedAdditionalDirs: [],
+ additionalDirSources: [],
+ warnings: [],
+ instructionSources: EMPTY_INSTRUCTION_SOURCES,
+ };
}
+ const activation = await handler.program.trustDisclosure.describeGatedActivation();
+ return {
+ trusted: false,
+ gatedMcpServers: activation.mcpServers,
+ gatedAdditionalDirs: activation.additionalDirs,
+ additionalDirSources: activation.additionalDirSources,
+ warnings: activation.warnings,
+ instructionSources: activation.instructionSources,
+ };
}
/**
@@ -2849,18 +2839,9 @@ function toManagedServerInfo(server: McpManagedServer): McpManagedServerInfo {
} as McpManagedServerInfo;
}
-function describeWorkspaceMcpServer(
- name: string,
- config: WorkspaceMcpServerConfig,
-): WorkspaceTrustInfo['gatedMcpServers'][number] {
- if (config.transport === 'stdio') {
- return {
- name,
- transport: config.transport,
- command: config.command,
- args: config.args,
- cwd: config.cwd,
- };
- }
- return { name, transport: config.transport, url: config.url };
-}
+const EMPTY_INSTRUCTION_SOURCES: WorkspaceTrustInstructionSources = {
+ agentsMdPaths: [],
+ skills: [],
+ agentProfiles: [],
+ paths: [],
+};
diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts
index d0aa514a1..909c9e638 100644
--- a/packages/node-sdk/src/types.ts
+++ b/packages/node-sdk/src/types.ts
@@ -130,6 +130,7 @@ export type { PermissionMode };
* engine; the v1 engine has no workspace-trust concept and reports
* `{ trusted: true, gatedMcpServers: [] }`.
*/
+/** One project-level MCP server that trusting would start. */
export interface WorkspaceTrustMcpServerInfo {
readonly name: string;
readonly transport: 'stdio' | 'http' | 'sse';
@@ -137,12 +138,32 @@ export interface WorkspaceTrustMcpServerInfo {
readonly args?: readonly string[];
readonly cwd?: string;
readonly url?: string;
+ /** Absolute path of the config file declaring this server. */
+ readonly origin: string;
}
+/** Project-sourced instruction inputs that steer the agent once trusted (prompt-level, no code execution). */
+export interface WorkspaceTrustInstructionSources {
+ /** AGENTS.md files inside the project that will be injected into context (symlink-resolved). */
+ readonly agentsMdPaths: readonly string[];
+ /** Names of project-level skills that will load. */
+ readonly skills: readonly string[];
+ /** Names of project-level agent profiles that will load. */
+ readonly agentProfiles: readonly string[];
+ /** Files and configuration directories to inspect; directories end in a slash. */
+ readonly paths: readonly string[];
+}
+
+/** Trust state of a workspace directory, plus everything trusting it would activate. */
export interface WorkspaceTrustInfo {
readonly trusted: boolean;
- /** Safe descriptions of project-level MCP servers that trusting would enable. */
+ /** Project-level MCP servers that trusting would start. */
readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[];
+ /** Directories outside the project that trusting grants access to (symlink-resolved). */
+ readonly gatedAdditionalDirs: readonly string[];
+ readonly additionalDirSources: readonly string[];
+ readonly warnings: readonly string[];
+ readonly instructionSources: WorkspaceTrustInstructionSources;
}
/**
diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts
index 57dac1e62..53465f865 100644
--- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts
+++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts
@@ -8,7 +8,7 @@
* Wiring: real v2 engine bootstrapped on a temp PYTHINKER_CODE_HOME; remote provider calls are stubbed.
* Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts
*/
-import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -1239,6 +1239,10 @@ describe('SDKRpcClientV2 workspace trust', () => {
cwd: '/tmp/root',
env: { SECRET: 'hidden' },
},
+ 'disabled-server': {
+ command: 'never-runs',
+ enabled: false,
+ },
'http-server': {
transport: 'http',
url: 'https://example.test/mcp',
@@ -1259,14 +1263,34 @@ describe('SDKRpcClientV2 workspace trust', () => {
const info = await harness.getWorkspaceTrustInfo(workDir);
expect(info.trusted).toBe(false);
expect(info.gatedMcpServers).toEqual([
- { name: 'http-server', transport: 'http', url: 'https://example.test/mcp' },
- { name: 'nested-server', transport: 'stdio', command: 'nested-cmd' },
- { name: 'root-server', transport: 'stdio', command: 'root-cmd', args: ['--safe'], cwd: '/tmp/root' },
+ {
+ name: 'http-server',
+ transport: 'http',
+ url: 'https://example.test/mcp',
+ origin: await realpath(join(workDir, '.mcp.json')),
+ },
+ {
+ name: 'nested-server',
+ transport: 'stdio',
+ command: 'nested-cmd',
+ origin: await realpath(join(workDir, '.pythinker-code', 'mcp.json')),
+ },
+ {
+ name: 'root-server',
+ transport: 'stdio',
+ command: 'root-cmd',
+ args: ['--safe'],
+ cwd: '/tmp/root',
+ origin: await realpath(join(workDir, '.mcp.json')),
+ },
]);
const serialized = JSON.stringify(info);
+ // Environment variables and headers stay in the source configuration.
expect(serialized).not.toContain('hidden');
expect(serialized).not.toContain('SECRET');
expect(serialized).not.toContain('TOKEN');
+ // Disabled servers never connect, so they are not part of the disclosure.
+ expect(serialized).not.toContain('disabled-server');
} finally {
await harness.close();
}
@@ -1289,8 +1313,8 @@ describe('SDKRpcClientV2 workspace trust', () => {
join(workDir, '.mcp.json'),
JSON.stringify({
mcpServers: {
- github: { command: 'project-github', enabled: false },
- toString: { transport: 'http', url: 'https://example.test/mcp', enabled: false },
+ github: { command: 'project-github' },
+ toString: { transport: 'http', url: 'https://example.test/mcp' },
},
}),
'utf-8',
@@ -1305,22 +1329,44 @@ describe('SDKRpcClientV2 workspace trust', () => {
command: 'project-github',
args: undefined,
cwd: workDir,
+ origin: await realpath(join(workDir, '.mcp.json')),
+ },
+ {
+ name: 'toString',
+ transport: 'http',
+ url: 'https://example.test/mcp',
+ origin: await realpath(join(workDir, '.mcp.json')),
},
- { name: 'toString', transport: 'http', url: 'https://example.test/mcp' },
]);
} finally {
await harness.close();
}
});
- it('degrades the gated-server list to empty on an invalid project mcp.json', async () => {
+ it('reports failed configuration and instruction sources while retaining readable instructions', async () => {
const { harness } = await makeHarness();
const workDir = await mkdtemp(join(tmpdir(), 'pythinker-sdk-v2-work-'));
tempDirs.push(workDir);
await writeFile(join(workDir, '.mcp.json'), '{not json', 'utf-8');
+ await writeFile(join(workDir, 'AGENTS.md'), '# Project instructions\n', 'utf-8');
+ await mkdir(join(workDir, '.pythinker-code'));
+ await symlink(join(workDir, 'missing.md'), join(workDir, '.pythinker-code', 'AGENTS.md'));
try {
const info = await harness.getWorkspaceTrustInfo(workDir);
- expect(info).toEqual({ trusted: false, gatedMcpServers: [] });
+ expect(info).toEqual({
+ trusted: false,
+ gatedMcpServers: [],
+ gatedAdditionalDirs: [],
+ additionalDirSources: [],
+ warnings: expect.arrayContaining([
+ 'Could not inspect MCP configuration.',
+ expect.stringContaining(join(workDir, '.pythinker-code', 'AGENTS.md')),
+ ]),
+ instructionSources: {
+ agentsMdPaths: [await realpath(join(workDir, 'AGENTS.md'))],
+ skills: [], agentProfiles: [], paths: [await realpath(join(workDir, 'AGENTS.md'))],
+ },
+ });
} finally {
await harness.close();
}
@@ -1335,6 +1381,10 @@ describe('SDKRpcClientV2 workspace trust', () => {
expect(await harness.getWorkspaceTrustInfo(workDir)).toEqual({
trusted: true,
gatedMcpServers: [],
+ gatedAdditionalDirs: [],
+ additionalDirSources: [],
+ warnings: [],
+ instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
});
// The trust marker lives in the pythinker home, never in the checkout.
const markers = await readdir(join(homeDir, 'workspace-trust'));