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 logo Pythinker Code - -### A coding agent you can run as a desktop app - -[![release](https://img.shields.io/github/v/release/PyModel/pythinker-desktop-releases?style=flat-square&label=release&color=4f46e5)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest) -[![Downloads](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/PyModel/pythinker-code/badges/downloads-total.json&style=flat&label=Downloads&labelColor=18181b&color=dfb317)](https://code.pythinker.com/) -[![macOS](https://img.shields.io/badge/macOS-e5e7eb?style=flat-square&logo=apple&logoColor=000000)](https://code.pythinker.com/) | [![Windows](https://img.shields.io/badge/Windows-e5e7eb?style=flat-square&logo=data:image/svg%2Bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+V2luZG93cyAxMTwvdGl0bGU+PHBhdGggZmlsbD0iIzAwNzhENCIgZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiLz48L3N2Zz4=)](https://code.pythinker.com/) -[![Node.js](https://img.shields.io/badge/Node.js-24.15%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](package.json) -[![visitors](https://komarev.com/ghpvc/?username=PyModel-pythinker-code&label=visitors&color=4f46e5&style=flat-square)](https://github.com/PyModel/pythinker-code) - -Download  ·  -Capabilities  ·  -Terminal  ·  -Editor  ·  -Development - -
- -Pythinker Desktop - -
- ---- - -## 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`. - -
-Pythinker Code terminal demo -
- ---- - -## 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. - -
-ACP IDE integration demo -
- ---- - -## 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. - -

- Pythinker Code architecture -

- -| 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": "