From 28b9b62290ab32334a3715cf1c2f9654eac725f9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 23 Sep 2026 17:09:59 -0400 Subject: [PATCH 1/8] fix: harden workspace trust and land session and terminal behavior Large transcripts no longer overflow the wire cache. Forks keep the source title kind. File tools and git calls fail closed on symlink escapes. The terminal can switch layout, click a fold, and jump to the bottom. --- .agents/skills/review-pr/SKILL.md | 73 +++ .agents/skills/write-pr/SKILL.md | 116 ++++ .github/pull_request_template.md | 26 +- AGENTS.md | 2 +- .../scripts/native/02-sea-blob.mjs | 10 +- .../scripts/native/sea-options.mjs | 16 + apps/pythinker-code/scripts/native/smoke.mjs | 24 +- apps/pythinker-code/src/cli/run-shell.ts | 12 +- .../src/feedback/codebase/scanner.ts | 30 +- .../pythinker-code/src/tui/commands/config.ts | 55 +- .../pythinker-code/src/tui/commands/reload.ts | 5 + .../src/tui/commands/session.ts | 17 +- .../src/tui/components/chrome/footer.ts | 8 +- .../tui/components/chrome/gutter-container.ts | 89 +++- .../src/tui/components/dialogs/compaction.ts | 12 + .../components/dialogs/settings-selector.ts | 7 + .../components/dialogs/tui-mode-selector.ts | 35 ++ .../tui/components/messages/goal-markers.ts | 12 + .../src/tui/components/messages/thinking.ts | 15 + .../src/tui/components/messages/tool-call.ts | 29 +- .../messages/tool-renderers/truncated.ts | 2 +- .../src/tui/components/panes/activity-pane.ts | 5 +- apps/pythinker-code/src/tui/config.ts | 62 ++- .../src/tui/constant/pythinker-tui.ts | 1 + apps/pythinker-code/src/tui/pythinker-tui.ts | 72 ++- apps/pythinker-code/src/tui/tui-state.ts | 10 +- apps/pythinker-code/src/tui/types.ts | 3 +- .../src/tui/utils/component-capabilities.ts | 22 +- apps/pythinker-code/src/utils/git/git-args.ts | 22 + .../src/utils/git/git-status.ts | 95 +++- .../test/native/build-scripts.test.ts | 15 + .../test/tui/activity-pane.test.ts | 6 +- .../test/tui/commands/reload.test.ts | 35 +- .../tui/commands/tui-mode-preferences.test.ts | 92 ++++ .../test/tui/components/chrome/footer.test.ts | 18 +- .../components/dialogs/choice-picker.test.ts | 3 +- .../components/panes/activity-pane.test.ts | 20 +- apps/pythinker-code/test/tui/config.test.ts | 117 +++- .../test/tui/create-tui-state.test.ts | 6 +- .../test/tui/fullscreen-layout.test.ts | 6 +- .../tui/pythinker-tui-message-flow.test.ts | 309 ++++++++++- .../test/tui/pythinker-tui-startup.test.ts | 79 ++- .../test/tui/signal-handlers.test.ts | 1 + .../test/utils/git/git-status.test.ts | 55 ++ apps/vis/server/src/lib/agent-record-types.ts | 10 + apps/vis/server/src/lib/context-projector.ts | 5 + .../vis/web/src/components/wire/renderers.tsx | 55 ++ docs/configuration/config-files.md | 5 + docs/configuration/env-vars.md | 1 + docs/reference/server-api.md | 2 + .../agent-core-v2/docs/state-manifest.d.ts | 12 + .../agent-core-v2/docs/wire-manifest.d.ts | 80 ++- .../scripts/gen-wire-manifest.mts | 13 +- .../agent/contextMemory/contextTranscript.ts | 13 +- .../src/agent/contextMemory/loopEventFold.ts | 56 +- .../agent/contextMemory/toolResultRender.ts | 26 + .../src/agent/contextMemory/types.ts | 9 + .../src/agent/contextProjector/projection.ts | 1 + .../src/agent/loop/loopService.ts | 12 +- .../src/agent/loop/machine/engine.ts | 2 +- .../src/agent/loop/machine/tools.ts | 4 +- .../policies/git-cwd-write-approve.ts | 5 +- .../src/agent/task/taskService.ts | 10 +- .../src/agent/task/tools/format.ts | 11 + .../agent-core-v2/src/agent/task/wallTime.ts | 9 + .../src/agent/toolDedupe/toolDedupeService.ts | 35 +- .../src/agent/toolExecutor/toolExecutor.ts | 1 + .../agent/toolExecutor/toolExecutorService.ts | 1 + .../src/agent/toolPolicy/toolPolicyService.ts | 12 +- .../src/agent/tools/edit/editTool.ts | 5 + .../src/agent/tools/os/glob/globTool.ts | 5 + .../src/agent/tools/os/grep/grepTool.ts | 5 + .../src/agent/tools/os/read/readTool.ts | 5 + .../src/agent/tools/os/write/writeTool.ts | 5 + .../read-media-file/readMediaFileTool.ts | 5 + .../tools/task/task-list/taskListTool.ts | 4 +- .../tools/task/task-output/taskOutputTool.ts | 4 +- .../tools/task/task-stop/taskStopTool.ts | 3 + .../tools/task/task-wait/taskWaitTool.ts | 6 +- .../agentProfileCatalog.ts | 2 + .../src/app/config/configService.ts | 61 ++- packages/agent-core-v2/src/app/git/git.ts | 12 + .../agent-core-v2/src/app/git/gitService.ts | 75 ++- .../agent-core-v2/src/app/git/hardening.ts | 237 +++++++++ .../agent-core-v2/src/app/plugin/plugin.ts | 1 + .../src/app/plugin/pluginService.ts | 24 +- .../projectLocalConfig/projectLocalConfig.ts | 6 +- .../agent-core-v2/src/app/telemetry/events.ts | 22 + .../src/app/telemetry/telemetryService.ts | 4 +- .../features/dateChange/dateChangeService.ts | 24 +- .../injection/tower-mode-full-reminder.md | 34 +- .../src/features/tower/protocol/git.ts | 34 +- .../src/features/tower/protocol/store.ts | 83 ++- .../src/features/tower/protocol/types.ts | 1 + .../src/features/tower/tools/merge/merge.md | 2 + .../features/tower/tools/merge/mergeTool.ts | 14 +- .../features/tower/tools/mission/mission.md | 4 +- .../features/tower/tools/mission/mission.ts | 10 + .../tower/tools/mission/missionTool.ts | 7 +- .../src/features/tower/tools/review/review.md | 2 + .../features/tower/tools/review/reviewTool.ts | 26 +- .../src/features/tower/tools/spawn/spawn.md | 2 + .../features/tower/tools/spawn/spawnTool.ts | 91 +++- .../src/features/tower/tools/status/status.md | 2 +- .../features/tower/tools/status/statusTool.ts | 15 + .../src/features/tower/towerService.ts | 14 + .../src/mcpCore/oauth/service.ts | 41 +- .../node-fs/projectLocalConfigService.ts | 51 +- packages/agent-core-v2/src/program/program.ts | 4 +- .../agentLifecycle/profile/gitContext.ts | 112 +--- .../agentLifecycle/profile/profiles.ts | 5 +- .../src/session/subagent/mirrorAgentRun.ts | 37 +- .../src/session/subagent/subagentService.ts | 3 + .../agent-core-v2/src/tool/args-validator.ts | 40 +- .../agent-core-v2/src/tool/path-access.ts | 6 +- .../agent-core-v2/src/tool/realpath-access.ts | 183 +++++++ .../sessionLifecycleService.ts | 2 +- .../workspaceDirs/workspaceDirsService.ts | 35 +- .../agentsMdReminder/agentsMdReminder.test.ts | 5 +- .../agent/contextMemory/loopEventFold.test.ts | 76 ++- .../projector-tool-exchanges.test.ts | 8 + .../test/agent/loop/loop.test.ts | 48 +- .../test/agent/loop/machineTools.test.ts | 5 + .../test/agent/media/tools/read-media.test.ts | 46 +- .../permissionPolicyService.test.ts | 36 ++ .../agent/pluginCommand/pluginCommand.test.ts | 1 + .../test/agent/profile/apply-profile.test.ts | 1 + .../test/agent/task/rpc-events.test.ts | 5 +- .../test/agent/task/tools/task-tools.test.ts | 10 +- .../test/agent/toolDedupe/toolDedupe.test.ts | 47 +- .../agent/toolExecutor/toolExecutor.test.ts | 5 + .../toolPolicy/toolPolicyService.test.ts | 50 ++ .../test/app/edit/tools/edit.test.ts | 43 +- .../test/app/git/gitService.test.ts | 75 ++- .../test/app/git/hardening.test.ts | 98 ++++ .../agent-core-v2/test/app/plugin/stubs.ts | 2 + .../dynamic_workflow/dynamic_workflow.test.ts | 6 + .../test/features/plan/plan.test.ts | 28 +- .../skill/workspace/skillCatalog.test.ts | 5 +- .../test/features/tower/store.test.ts | 499 +++++++++++++++++- .../features/tower/tools/spawnTool.test.ts | 110 ++++ .../features/tower/tools/towerTools.test.ts | 167 ++++++ .../test/features/tower/towerService.test.ts | 43 ++ .../agent-core-v2/test/harness/snapshots.ts | 8 +- packages/agent-core-v2/test/index.test.ts | 11 +- .../test/mcpCore/oauth/service.test.ts | 42 +- .../os/backends/node-local/tools/glob.test.ts | 59 ++- .../os/backends/node-local/tools/grep.test.ts | 67 ++- .../os/backends/node-local/tools/read.test.ts | 89 +++- .../backends/node-local/tools/write.test.ts | 75 ++- .../node-fs/projectLocalConfigService.test.ts | 127 +++++ .../agentLifecycle/profile/gitContext.test.ts | 137 ++--- .../test/state/eventDispatcher.test.ts | 19 + .../test/tool/path-access.test.ts | 11 + .../test/tool/realpath-access.test.ts | 164 ++++++ .../test/tools/fixtures/fake-exec.ts | 2 +- .../agentProfileLoader.test.ts | 1 + .../workspaceDirs/workspaceDirs.test.ts | 220 ++++++++ .../workspace/workspaceFs/fsService.test.ts | 14 +- .../agent-gateway/src/protocol/rest-config.ts | 2 + .../src/services/transcript/coreEventMap.ts | 2 +- .../services/transcript/transcriptService.ts | 79 ++- .../src/services/transcript/wireCache.ts | 2 +- packages/agent-gateway/test/config.test.ts | 15 + .../test/modelCatalogCatalog.test.ts | 2 +- .../test/services/transcript.test.ts | 194 ++++++- packages/agent-gateway/test/sessions.test.ts | 54 ++ .../agent-gateway/test/v2Sessions.test.ts | 1 + packages/node-sdk/src/config/schema.ts | 2 + packages/node-sdk/src/config/toml.ts | 1 + packages/node-sdk/src/v2/config-mapper.ts | 1 + .../node-sdk/test/sdk-rpc-client-v2.test.ts | 15 + packages/telemetry/src/client.ts | 1 - packages/telemetry/src/index.ts | 6 +- packages/transcript/src/contract/schema.ts | 2 +- packages/transcript/src/history/foldFacts.ts | 126 ++++- packages/transcript/src/history/groupTurns.ts | 10 +- packages/transcript/src/model/turn.ts | 2 +- packages/transcript/src/ops/apply.ts | 2 +- packages/transcript/test/layers.test.ts | 96 +++- packages/transcript/test/store.test.ts | 4 +- plugins/marketplace.json | 4 +- .../pythinker-webbridge/pythinker.plugin.json | 10 +- .../skills/pythinker-webbridge/SKILL.md | 42 +- .../references/operations.md | 70 ++- 185 files changed, 6006 insertions(+), 618 deletions(-) create mode 100644 .agents/skills/review-pr/SKILL.md create mode 100644 .agents/skills/write-pr/SKILL.md create mode 100644 apps/pythinker-code/scripts/native/sea-options.mjs create mode 100644 apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts create mode 100644 apps/pythinker-code/src/utils/git/git-args.ts create mode 100644 apps/pythinker-code/test/native/build-scripts.test.ts create mode 100644 apps/pythinker-code/test/tui/commands/tui-mode-preferences.test.ts create mode 100644 packages/agent-core-v2/src/agent/task/wallTime.ts create mode 100644 packages/agent-core-v2/src/app/git/hardening.ts create mode 100644 packages/agent-core-v2/src/tool/realpath-access.ts create mode 100644 packages/agent-core-v2/test/agent/toolPolicy/toolPolicyService.test.ts create mode 100644 packages/agent-core-v2/test/app/git/hardening.test.ts create mode 100644 packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts create mode 100644 packages/agent-core-v2/test/tool/realpath-access.test.ts create mode 100644 packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md new file mode 100644 index 000000000..a94d1d27b --- /dev/null +++ b/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,73 @@ +--- +name: review-pr +description: Use when reviewing a pull request in the pythinker-code repository — evaluate the change against the template structure. +disable-model-invocation: true +--- + +# Review PR + +Review a pull request by evaluating the description against the template and the actual diff, then write a structured review summary in English. + +## Workflow + +1. Fetch the PR description and metadata: + + ```bash + gh pr view --json title,body,url,files,additions,deletions,baseRefName,headRefName + ``` + +2. Read the full diff: + + ```bash + gh pr diff + ``` + +3. Read enough surrounding code to verify the claims in the PR description. + +4. Evaluate each section against the criteria below. + +5. Write the review summary in English. + +## Review Criteria + +### Requirement or Bug + +- Is the linked issue valid and relevant? +- If no issue, is the requirement clearly stated in one or two sentences? + +### Bug Reproduction Steps + +- For bug PRs: are the steps clear and reproducible? +- Can you follow the steps to confirm the bug exists on the base branch? + +### Root Cause + +- For bug PRs: is the root cause convincingly explained? +- Does the stated root cause match what you see in the diff? +- Is it clear whether this is a fundamental fix or a workaround? + +### Code Changes + +- Does the description match the actual diff? +- Are visual outlines (diff blocks, call trees, file trees) accurate and helpful? +- Is the approach sound? Are there simpler alternatives? +- Are there edge cases the author missed? + +### Impact Scope + +- Are all affected modules identified? Cross-check with the diff file list. +- Does test coverage match the claimed scope? +- Are there untested paths that carry risk? + +### Checklist + +- Are all applicable items checked? +- For items marked as "not needed", do you agree? + +## Output + +Write the review summary in English. For each section: + +- State whether it is adequately filled in. +- Flag anything missing, inaccurate, or inconsistent with the diff. +- If the PR is ready, say so. If changes are needed, list them as actionable items. diff --git a/.agents/skills/write-pr/SKILL.md b/.agents/skills/write-pr/SKILL.md new file mode 100644 index 000000000..14eedba4b --- /dev/null +++ b/.agents/skills/write-pr/SKILL.md @@ -0,0 +1,116 @@ +--- +name: write-pr +description: Use when creating a pull request in the pythinker-code repository — how to fill in each section of the PR template with concise, reviewer-friendly content. +--- + +# Write PR Description + +Create or update the pull request for the current branch with a description that helps the reviewer understand why the change exists and the shape of the implementation. + +## Workflow + +1. Read the PR template: + + `Read(.github/pull_request_template.md)` + +2. Identify or create the pull request: + - Check the current branch for an existing PR: `gh pr view --json url,number,title,state 2>/dev/null`. + - If no PR exists, inspect `git status --short --branch` and the commits on the current branch. + - Commit remaining changes, push the branch with an upstream, and create the PR with `gh pr create`. + - Follow the repository's git safety protocol. + +3. Gather the context needed to explain the change: + - Read the linked issue and any relevant task artifacts. + - Read the complete diff (`git diff main...HEAD`) and enough surrounding code to understand behavior and ownership. + - Use `gh pr view` to collect PR metadata and changed files if the PR already exists. + +4. Write the PR description following the template sections: + - **Requirement or Bug** — one sentence or `Resolve #`. Nothing more. + - **Bug Reproduction Steps** — bug PRs only; `N/A` for features. Write `See linked issue` when steps are already there. + - **Root Cause** — bug PRs only; `N/A` for features. State the cause and whether this is a fundamental fix or a workaround. + - **Code Changes** — use visual outline views (see below) instead of prose whenever they explain the change better. + - **Impact Scope** — list affected modules and test coverage. + - **Checklist** — check every box that applies. + +5. Publish the description: + - Save to a temp file, then `gh pr edit --body-file ` or `gh pr create --body-file `. + - Confirm the update succeeded. + +## Visual Outline for Code Changes + +Prefer structural views over prose. Use the smallest combination that explains the implementation. Omit categories that did not change. + +Show logic or algorithm changes as pseudocode diff: + +```diff + on(save) +- write content ++ if content is unchanged ++ return cached result ++ write new content ++ invalidate cache +``` + +Show runtime control flow as a call tree diff: + +```diff + submitForm + createSession + persistPrompt ++ expandSkillMention + launchAgent +- navigateToSession ++ navigateToSession ++ subscribeToEvents +``` + +Show file responsibility changes as a shallow file tree diff: + +```diff + src/ + ├── commands/ ++│ └── show-me.ts # expands the slash command + ├── sessions/ +-└── transport.ts ++└── transport/ ++ ├── client.ts ++ └── stream.ts +``` + +Show component or UI structure changes as a tree diff: + +```diff + + useSessionEvents() + ++ + ++ +``` + +Show component interaction, control flow, or data flow with Mermaid (especially useful for explaining bug mechanics): + +```mermaid +sequenceDiagram + participant User + participant UI + participant Daemon + User->>UI: choose command + UI->>Daemon: send expanded prompt + Daemon-->>UI: stream result +``` + +Show key data structure or type changes in a language-specific block: + +```ts +interface SessionEvents { + onTurnStart(cb: (turn: Turn) => void): void; + onTurnEnd(cb: (turn: Turn) => void): void; +} +``` + +Rules for visual outlines: +- Use `diff` blocks when the point is what changes and the surrounding shape already exists. +- Show the complete target shape in a language-specific or `text` block when most of it is new or diff notation would obscure ownership or order. +- Tell the story in the order that makes it easiest to understand — files first, or data structures first, whichever fits. +- Write as one human talking to another: simple, coherent, concise language. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4875d5cea..3d3acf8be 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,24 +5,34 @@ External PRs are accepted for approved bug fixes only: link an issue that a main See https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md for more. --> -## Related Issue +## Requirement or Bug - + -Resolve #(issue_number) +## Bug Reproduction Steps -## Problem + - +## Root Cause -## What changed + - +## Code Changes + + + +## Impact Scope + + ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. -- [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). +- [ ] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [ ] I have added tests that prove my feature works. - [ ] Ran `gen-changesets` skill, or this PR needs no changeset. - [ ] Ran `gen-docs` skill, or this PR needs no doc update. diff --git a/AGENTS.md b/AGENTS.md index 966679cae..bdaf8547d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ Gate behind flags. Env: `PYTHINKER_CODE_EXPERIMENTAL_` toggles one; `PYTHI - Prefer `rg` / `rg --files` for code reading. - Follow existing boundaries and local patterns. - Replace internal identifiers with neutral placeholders in public text/test data (e.g. `example.com`, `example.test`, `YOUR_API_KEY`). Before opening a PR, ask a read-only agent to audit the diff for context-specific internal identifiers. -- PR titles: Conventional Commit style (e.g. `chore: remove legacy format commands`). +- When creating a PR, use the `write-pr` skill (`.agents/skills/write-pr/SKILL.md`) to write the PR description. PR titles: Conventional Commit style (e.g. `chore: remove legacy format commands`). - Fill in `.github/pull_request_template.md` — link the issue, describe changes. No placeholder text or vague AI-generated PR summaries; the human author must understand the change well enough to explain the code, edge cases, and why the approach fits. - Run `gen-changesets` skill before submitting PRs. Changesets must strictly follow its rules: one short user-facing sentence stating only what changed; skip any change users cannot perceive. Never decide `major` on your own — stop, explain, and get explicit user confirmation first; default to `minor`, fall back to `patch`. - Changeset text is shipped text: the desktop release body is generated from `apps/desktop/CHANGELOG.md`, and the in-app updater shows it to users verbatim. A release body must state what changed for users — never a build stamp, a commit hash, or placeholder text. `desktop-release.yml` fails a stable release whose version has no changelog entry. diff --git a/apps/pythinker-code/scripts/native/02-sea-blob.mjs b/apps/pythinker-code/scripts/native/02-sea-blob.mjs index 434a7861c..a29cc174d 100644 --- a/apps/pythinker-code/scripts/native/02-sea-blob.mjs +++ b/apps/pythinker-code/scripts/native/02-sea-blob.mjs @@ -17,6 +17,7 @@ import { targetTriple, } from './paths.mjs'; import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; +import { SEA_EXEC_ARGV, seaCodeCacheEnabled } from './sea-options.mjs'; async function ensureBundleExists() { try { @@ -53,8 +54,15 @@ async function writeSeaConfig(target) { Object.entries(seaAssets).sort(([a], [b]) => a.localeCompare(b)), ), disableExperimentalSEAWarning: true, - useCodeCache: false, + // Compiles main.cjs at build time so startup skips the parse of the whole + // bundle. The Node docs list dynamic `import()` as unsupported with a code + // cache, but that limitation is in the ESM main-script path; a CommonJS + // main script keeps a working `import()` (verified on Node 24, and relied + // on by `__plugin_run_node` to load plugin scripts), so the bundle keeps + // its dynamic imports as-is. + useCodeCache: seaCodeCacheEnabled(target), useSnapshot: false, + execArgv: [...SEA_EXEC_ARGV], }; await writeFile(nativeSeaConfigPath(), `${JSON.stringify(config, null, 2)}\n`); diff --git a/apps/pythinker-code/scripts/native/sea-options.mjs b/apps/pythinker-code/scripts/native/sea-options.mjs new file mode 100644 index 000000000..337d99284 --- /dev/null +++ b/apps/pythinker-code/scripts/native/sea-options.mjs @@ -0,0 +1,16 @@ +// Runtime options baked into the single-executable binary (see 02-sea-blob.mjs). + +// Node flags applied when the binary starts (`process.execArgv` at runtime). +// V8 sizes the young generation from the machine's physical memory, which on +// developer machines means a 64 MB semi-space and roughly 100 MB of resident +// memory that `pythinker` never needs; 16 MB keeps scavenges cheap for the CLI's +// allocation pattern while dropping that overhead. Users can still extend the +// flags with NODE_OPTIONS (the default `execArgvExtension: "env"`). +export const SEA_EXEC_ARGV = Object.freeze(['--max-semi-space-size=16']); + +// The V8 code cache generated at build time only loads on the platform and +// architecture it was compiled on, so cross-target bundles (a +// PYTHINKER_CODE_BUILD_TARGET that differs from the build host) must leave it out. +export function seaCodeCacheEnabled(target, host = `${process.platform}-${process.arch}`) { + return target === host; +} diff --git a/apps/pythinker-code/scripts/native/smoke.mjs b/apps/pythinker-code/scripts/native/smoke.mjs index c2aab9cc9..b05da4b0b 100644 --- a/apps/pythinker-code/scripts/native/smoke.mjs +++ b/apps/pythinker-code/scripts/native/smoke.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -89,4 +89,26 @@ try { await rm(smokeHome, { recursive: true, force: true }); } +// Plugin MCP servers declared with `command: "node"` are re-executed through +// this binary as `__plugin_run_node`, which loads the plugin script with a +// dynamic import(). Keep that path covered so bundler or SEA settings that +// break dynamic import() fail here instead of inside users' plugins. +const pluginRoot = resolve(smokeHome, 'plugin'); +await rm(smokeHome, { recursive: true, force: true }); +await mkdir(pluginRoot, { recursive: true }); +try { + const pluginEntry = resolve(pluginRoot, 'entry.mjs'); + await writeFile( + pluginEntry, + 'await Promise.resolve();\nconsole.log(`plugin entry ran ${process.argv.slice(2).join(" ")}`);\n', + ); + const pluginOutput = await runPythinkerWithEnv(['__plugin_run_node', pluginEntry, 'alpha', 'beta'], { + PYTHINKER_CODE_HOME: smokeHome, + PYTHINKER_PLUGIN_ROOT: pluginRoot, + }); + assertIncludes(pluginOutput, 'plugin entry ran alpha beta', 'plugin node entry'); +} finally { + await rm(smokeHome, { recursive: true, force: true }); +} + console.log(`Native smoke passed: ${executablePath}`); diff --git a/apps/pythinker-code/src/cli/run-shell.ts b/apps/pythinker-code/src/cli/run-shell.ts index e4d394f0c..f7dbab98a 100644 --- a/apps/pythinker-code/src/cli/run-shell.ts +++ b/apps/pythinker-code/src/cli/run-shell.ts @@ -246,7 +246,11 @@ export async function runShell( const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + try { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch { + // A failed flush must not block the exit message. + } const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); const hints: string[] = []; @@ -290,7 +294,11 @@ export async function runShell( removeCrashHandlers(); setCrashPhase('shutdown'); trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + try { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch { + // A failed flush must not hide the startup error. + } await harness.close(); throw error; } diff --git a/apps/pythinker-code/src/feedback/codebase/scanner.ts b/apps/pythinker-code/src/feedback/codebase/scanner.ts index 6df420217..749c5058a 100644 --- a/apps/pythinker-code/src/feedback/codebase/scanner.ts +++ b/apps/pythinker-code/src/feedback/codebase/scanner.ts @@ -4,6 +4,8 @@ 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, @@ -46,9 +48,9 @@ export async function scanCodebase( const root = resolve(rootInput); const limits = resolveLimits(options.limits); throwIfAborted(options.signal); - const usedGitIgnore = await isInsideGitWorkTree(root); + const usedGitIgnore = await isInsideGitWorkTree(root, GIT_CONFIG_ARGS); const collected = usedGitIgnore - ? await scanWithGit(root, limits, options.signal) + ? await scanWithGit(root, GIT_CONFIG_ARGS, limits, options.signal) : await scanWithoutFilter(root, limits, options.signal); const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); @@ -69,9 +71,18 @@ function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimit }; } -async function isInsideGitWorkTree(root: string): Promise { +async function isInsideGitWorkTree( + root: string, + configArgs: readonly string[], +): Promise { try { - const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + const { stdout } = await execFileAsync('git', [ + ...configArgs, + '-C', + root, + 'rev-parse', + '--is-inside-work-tree', + ]); return stdout.trim() === 'true'; } catch { return false; @@ -80,12 +91,21 @@ async function isInsideGitWorkTree(root: string): Promise { async function scanWithGit( root: string, + configArgs: readonly string[], limits: ScanCodebaseLimits, signal?: AbortSignal, ): Promise { const { stdout } = await execFileAsync( 'git', - ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + [ + ...configArgs, + '-C', + root, + 'ls-files', + '-co', + '--exclude-standard', + '-z', + ], { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, ); diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index e4f41ba6c..f858b531b 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -22,11 +22,12 @@ import { PermissionSelectorComponent } from '../components/dialogs/permission-se import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { SurveyPreferenceSelectorComponent } from '../components/dialogs/survey-preference-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; +import { TuiModeSelectorComponent } from '../components/dialogs/tui-mode-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; -import { DEFAULT_MARKDOWN_CONFIG, DEFAULT_TUI_CONFIG, saveTuiConfig, type MarkdownConfig, type TuiConfig } from '../config'; +import { DEFAULT_MARKDOWN_CONFIG, DEFAULT_TUI_CONFIG, saveTuiConfig, type MarkdownConfig, type TuiConfig, type TuiMode } from '../config'; import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; +import { NO_ACTIVE_SESSION_MESSAGE, TUI_MODE_RESTART_NOTICE } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { setMarkdownMermaidMode, type MermaidRenderMode } from '../utils/markdown-options'; import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; @@ -59,6 +60,7 @@ function hasConversationHistory(host: SlashCommandHost): boolean { export function currentTuiConfig(host: Pick): TuiConfig { return { theme: host.state.appState.theme, + tuiMode: host.state.appState.tuiMode, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, @@ -947,6 +949,54 @@ export async function applyMermaidPreferenceChoice( host.showStatus(`Mermaid diagrams ${enabled ? 'enabled' : 'disabled'}.`); } +export function showTuiModePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new TuiModeSelectorComponent({ + currentValue: host.state.appState.tuiMode ?? 'regular', + onSelect: (value) => { + host.restoreEditor(); + void applyTuiModeChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type TuiModeHost = { + readonly state: { + readonly appState: Pick; + readonly ui: Pick; + }; + setAppState(patch: Pick): void; + showStatus(msg: string, color?: string): void; + showNotice(msg: string): void; +}; + +export async function applyTuiModeChoice(host: TuiModeHost, tuiMode: TuiMode): Promise { + if (tuiMode === (host.state.appState.tuiMode ?? 'regular')) { + host.showStatus(`TUI mode already ${tuiMode}.`); + return; + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + tuiMode, + }); + } catch (error) { + host.showStatus(`Failed to save TUI mode: ${formatErrorMessage(error)}`, 'error'); + return; + } + + host.setAppState({ tuiMode }); + host.showStatus(`TUI mode set to ${tuiMode}.`, 'success'); + if (tuiMode !== host.state.ui.mode) { + host.showNotice(TUI_MODE_RESTART_NOTICE); + } +} + export function showSettingsSelector(host: SlashCommandHost): void { host.mountEditorReplacement( new SettingsSelectorComponent({ @@ -966,6 +1016,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'model': showModelPicker(host); return; case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; + case 'tuiMode': showTuiModePicker(host); return; case 'mermaid': showMermaidPreferencePicker(host); return; case 'editor': showEditorPicker(host); return; case 'survey': showSurveyPreferencePicker(host); return; diff --git a/apps/pythinker-code/src/tui/commands/reload.ts b/apps/pythinker-code/src/tui/commands/reload.ts index 057e7126c..47a5232a1 100644 --- a/apps/pythinker-code/src/tui/commands/reload.ts +++ b/apps/pythinker-code/src/tui/commands/reload.ts @@ -2,6 +2,7 @@ import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; import { currentTheme, lightColors } from '#/tui/theme'; import { DEFAULT_MARKDOWN_CONFIG, loadTuiConfig, type TuiConfig } from '../config'; +import { TUI_MODE_RESTART_NOTICE } from '../constant/pythinker-tui'; import { setMarkdownMermaidMode, setMarkdownRenderLatex } from '../utils/markdown-options'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -68,6 +69,7 @@ export async function applyReloadedTuiConfig( host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, + tuiMode: config.tuiMode, disablePasteBurst: config.disablePasteBurst, renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, @@ -78,6 +80,9 @@ export async function applyReloadedTuiConfig( markdown: config.markdown, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); + if ((config.tuiMode ?? 'regular') !== host.state.ui.mode) { + host.showNotice(TUI_MODE_RESTART_NOTICE); + } } function applyRuntimeConfig(host: SlashCommandHost, config: PythinkerConfig): void { diff --git a/apps/pythinker-code/src/tui/commands/session.ts b/apps/pythinker-code/src/tui/commands/session.ts index 864869294..717da43d1 100644 --- a/apps/pythinker-code/src/tui/commands/session.ts +++ b/apps/pythinker-code/src/tui/commands/session.ts @@ -2,8 +2,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Session } from '@pymodel/pythinker-code-sdk'; - import { detectInstallSource } from '#/cli/update/source'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { detectShellEnvironment } from '#/utils/process/shell-env'; @@ -58,12 +56,8 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P return; } - const sourceTitle = forkSourceTitle(host, session); try { - const forked = await host.harness.forkSession({ - id: session.id, - title: `Fork: ${sourceTitle}`, - }); + const forked = await host.harness.forkSession({ id: session.id }); const forkId = forked.id; try { await forked.close(); @@ -111,15 +105,6 @@ function forkResumeCommand(workDir: string, forkId: string): string { return `${changeDir} && pythinker --resume ${quoteShellArg(forkId)}`; } -function forkSourceTitle(host: SlashCommandHost, session: Session): string { - const currentTitle = host.state.appState.sessionTitle?.trim(); - if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; - - const summaryTitle = - typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; - return summaryTitle.length > 0 ? summaryTitle : session.id; -} - export async function handleExportMdCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 9b49418c7..160e174a5 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -218,7 +218,9 @@ export class FooterComponent implements Component { this.state = state; this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; - this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + this.gitCache = createGitStatusCache(state.workDir, { + onChange: this.onRefresh, + }); this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); this.syncStatusLineRunner(state); @@ -227,7 +229,9 @@ export class FooterComponent implements Component { setState(state: AppState): void { if (state.workDir !== this.gitCacheWorkDir) { this.gitCacheWorkDir = state.workDir; - this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + this.gitCache = createGitStatusCache(state.workDir, { + onChange: this.onRefresh, + }); } this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); diff --git a/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts b/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts index c92c4c21a..20d9db429 100644 --- a/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts @@ -16,7 +16,12 @@ */ import { Container } from '@pymodel/pi-tui'; -import type { Component, TuiMouseDispatchResult, TuiMouseEvent } from '@pymodel/pi-tui'; +import type { + Component, + TuiMouseDispatchResult, + TuiMouseEvent, + TuiMouseEventResult, +} from '@pymodel/pi-tui'; import { prefixPreservingOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; @@ -31,6 +36,12 @@ interface TranscriptRenderCache { export class GutterContainer extends Container { private renderCache: TranscriptRenderCache | undefined; + private unhandledClick: ((index: number) => TuiMouseEventResult | undefined) | undefined; + + setUnhandledClick(handler: (index: number) => TuiMouseEventResult | undefined): void { + this.unhandledClick = handler; + } + constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -98,6 +109,80 @@ export class GutterContainer extends Container { // so translate before delegating or clicks land a gutter-width off. override handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined { const inner = Math.max(1, event.width - this.leftPad - this.rightPad); - return super.handleMouse({ ...event, x: event.x - this.leftPad, width: inner }); + const adjusted: TuiMouseEvent = { ...event, x: event.x - this.leftPad, width: inner }; + if (adjusted.y < 0 || adjusted.y >= adjusted.height) return undefined; + + const heights = this.childHeights(inner, event.width); + let childY = 0; + for (let index = 0; index < this.children.length; index++) { + const height = heights[index] ?? 0; + if (adjusted.y < childY || adjusted.y >= childY + height) { + childY += height; + continue; + } + const child = this.children[index]!; + const handled = dispatchToChild(child, { ...adjusted, y: adjusted.y - childY, height }); + if (handled) return handled; + if ( + this.unhandledClick === undefined || + adjusted.type !== 'click' || + adjusted.button !== 'left' + ) { + return undefined; + } + const fallback = this.unhandledClick(index); + if (fallback === undefined || (!fallback.handled && !fallback.capture && !fallback.focus)) { + return undefined; + } + return { + handled: true, + capture: fallback.capture, + focus: fallback.focus, + render: fallback.render, + target: { + component: this, + originX: event.screenX - event.x, + originY: event.screenY - event.y, + width: event.width, + height: event.height, + }, + }; + } + return undefined; } + + private childHeights(innerWidth: number, outerWidth: number): number[] { + const cache = this.renderCache; + if ( + cache !== undefined && + cache.width === outerWidth && + cache.childRefs.length === this.children.length && + cache.childRefs.every((child, index) => child === this.children[index]) + ) { + return cache.prefixed.map((lines) => lines.length); + } + return this.children.map((child) => child.render(innerWidth).length); + } +} + +function dispatchToChild( + child: Component, + event: TuiMouseEvent, +): TuiMouseDispatchResult | undefined { + const result = child.handleMouse?.(event); + if (!result) return undefined; + if ('target' in result) return result as TuiMouseDispatchResult; + if (!result.handled && !result.capture && !result.focus) return undefined; + return { + ...result, + handled: true, + focusTarget: result.focus ? child : undefined, + target: { + component: child, + originX: event.screenX - event.x, + originY: event.screenY - event.y, + width: event.width, + height: event.height, + }, + }; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts index a07a66dcf..18c44eddb 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts @@ -120,6 +120,18 @@ export class CompactionComponent extends Container { this.ui?.requestRender(); } + hasHiddenContent(): boolean { + return this.summary !== undefined && this.summary.length > 0; + } + + isExpanded(): boolean { + return this.expanded; + } + + omitsExpandHint(): boolean { + return true; + } + private addSummaryChild(): void { if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { return; diff --git a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts index e8086f616..7bc66a9c4 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts @@ -3,6 +3,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; export type SettingsSelection = | 'model' | 'theme' + | 'tuiMode' | 'mermaid' | 'editor' | 'permission' @@ -27,6 +28,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Theme', description: 'Change the terminal UI theme.', }, + { + value: 'tuiMode', + label: 'TUI mode', + description: 'Choose the regular or fullscreen layout.', + }, { value: 'mermaid', label: 'Mermaid diagrams', @@ -63,6 +69,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { return ( value === 'model' || value === 'theme' || + value === 'tuiMode' || value === 'mermaid' || value === 'editor' || value === 'permission' || diff --git a/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts new file mode 100644 index 000000000..fe1e3e7ff --- /dev/null +++ b/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts @@ -0,0 +1,35 @@ +import type { TuiMode } from '../../config'; +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const TUI_MODE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'regular', + label: 'Regular', + description: 'Render into the terminal\'s native scrollback.', + }, + { + value: 'fullscreen', + label: 'Fullscreen (experimental)', + description: 'Alternate screen with in-app scrolling, selection, and transcript search.', + }, +]; + +export interface TuiModeSelectorOptions { + readonly currentValue: TuiMode; + readonly onSelect: (value: TuiMode) => void; + readonly onCancel: () => void; +} + +export class TuiModeSelectorComponent extends ChoicePickerComponent { + constructor(opts: TuiModeSelectorOptions) { + super({ + title: 'TUI mode', + options: [...TUI_MODE_OPTIONS], + currentValue: opts.currentValue, + onSelect: (value) => { + if (value === 'regular' || value === 'fullscreen') opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/pythinker-code/src/tui/components/messages/goal-markers.ts b/apps/pythinker-code/src/tui/components/messages/goal-markers.ts index 9e0a6daad..efbd7452b 100644 --- a/apps/pythinker-code/src/tui/components/messages/goal-markers.ts +++ b/apps/pythinker-code/src/tui/components/messages/goal-markers.ts @@ -54,6 +54,18 @@ export class GoalMarkerComponent implements Component { this.expanded = expanded; } + hasHiddenContent(): boolean { + return this.expandable && this.detail !== undefined && this.detail.length > 0; + } + + isExpanded(): boolean { + return this.expanded; + } + + omitsExpandHint(): boolean { + return true; + } + render(width: number): string[] { const dot = currentTheme.fg(this.accentToken, this.marker); const head = currentTheme.fg(this.textToken, this.headline); diff --git a/apps/pythinker-code/src/tui/components/messages/thinking.ts b/apps/pythinker-code/src/tui/components/messages/thinking.ts index 68d3cc84d..d5c516709 100644 --- a/apps/pythinker-code/src/tui/components/messages/thinking.ts +++ b/apps/pythinker-code/src/tui/components/messages/thinking.ts @@ -24,6 +24,7 @@ export class ThinkingComponent implements Component { private showMarker: boolean; private mode: ThinkingRenderMode; private expanded = false; + private previewHidesLines = false; private readonly ui: TUI | undefined; private spinnerFrame = 0; private spinnerInterval: ReturnType | undefined; @@ -87,6 +88,18 @@ export class ThinkingComponent implements Component { this.markRenderDirty(); } + hasHiddenContent(): boolean { + return this.previewHidesLines; + } + + isExpanded(): boolean { + return this.expanded; + } + + omitsExpandHint(): boolean { + return true; + } + render(width: number): string[] { if ( isRenderCacheEnabled() && @@ -98,6 +111,8 @@ export class ThinkingComponent implements Component { const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + this.previewHidesLines = + this.mode === 'finalized' && contentLines.length > THINKING_PREVIEW_LINES; let rendered: string[]; if (this.mode === 'live') { diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index 8b18a23fe..f8a374516 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -44,7 +44,7 @@ import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal' import { searchNoticeOnly } from './tool-renderers/grep-output'; import { parseReadMediaOutput } from './tool-renderers/media'; import { computeWriteStats } from './tool-renderers/chip'; -import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; +import { nonEmptyLines, outcomeLine, outcomeRows } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; import { isSpilledToolOutput } from './tool-renderers/types'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; @@ -761,6 +761,11 @@ export class ToolCallComponent extends Container { ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && child.wasTruncated()), ); + } else if ( + !this.truncatedAtLastRender && + this.collapsedOutcomeClips(width) + ) { + this.truncatedAtLastRender = true; } if (allReused) { @@ -806,6 +811,28 @@ export class ToolCallComponent extends Container { return this.hiddenContent || this.truncatedAtLastRender; } + private collapsedOutcomeClips(width: number): boolean { + const result = this.result; + if (result === undefined || result.output.length === 0 || result.is_error === true) { + return false; + } + const name = this.toolCall.name; + if (name !== 'Bash' && !isGenericToolResult(name)) return false; + const leadsWithMetadata = + name === 'Bash' && + (result.output.startsWith('task_id:') || isSpilledToolOutput(result.output)); + const rows = outcomeRows( + result.output, + name === 'Bash' && !leadsWithMetadata ? 'last' : 'first', + ); + for (const row of rows) { + if (!(row instanceof TruncatedHeaderLine)) continue; + row.render(width); + if (row.wasTruncated()) return true; + } + return false; + } + /** Whether the global ctrl+o toggle currently has this card expanded. */ isExpanded(): boolean { return this.expanded; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts index abac5b6fb..45f28d9dd 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -86,7 +86,7 @@ export class TruncatedOutputComponent implements Component { render(width: number): string[] { const contentLines = this.textComponent.render(width); - if (!this.expanded) this.truncatedAtLastRender = contentLines.length > this.maxLines; + this.truncatedAtLastRender = contentLines.length > this.maxLines; if (this.expanded || contentLines.length <= this.maxLines) { return contentLines; diff --git a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts index a848d24fb..06d1fa83b 100644 --- a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts +++ b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts @@ -26,7 +26,10 @@ export class ActivityPaneComponent extends Container { this.spinnerRef = options.spinner; if ( - (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + (options.mode === 'waiting' || + options.mode === 'tool' || + options.mode === 'composing' || + options.mode === 'thinking') && options.spinner !== undefined ) { this.addChild(new Spacer(1)); diff --git a/apps/pythinker-code/src/tui/config.ts b/apps/pythinker-code/src/tui/config.ts index f764ed5b5..cc26dc524 100644 --- a/apps/pythinker-code/src/tui/config.ts +++ b/apps/pythinker-code/src/tui/config.ts @@ -18,6 +18,10 @@ import { getDataDir } from '#/utils/paths'; export const INVALID_TUI_CONFIG_MESSAGE = 'Invalid TUI config in ~/.pythinker-code/tui.toml; using defaults.'; +function legacyFullscreenEnvMode(): TuiMode | undefined { + return process.env['PYTHINKER_CODE_TUI_FULL_SCREEN'] === '1' ? 'fullscreen' : undefined; +} + export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); @@ -61,8 +65,12 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { mermaid: 'final', }; +export const TuiModeSchema = z.enum(['regular', 'fullscreen']); +export type TuiMode = z.infer; + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + tui_mode: z.string().optional(), render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), @@ -93,6 +101,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + tuiMode: TuiModeSchema.optional(), /** LaTeX math rendering in Markdown; optional only so older hand-built test * fixtures still typecheck. */ renderLatex: z.boolean().optional(), @@ -128,6 +137,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -163,28 +173,50 @@ export async function loadTuiConfig( warn?: (message: string) => void, ): Promise { if (!existsSync(filePath)) { - await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); - return DEFAULT_TUI_CONFIG; + const envTuiMode = legacyFullscreenEnvMode(); + const config: TuiConfig = + envTuiMode === undefined ? DEFAULT_TUI_CONFIG : { ...DEFAULT_TUI_CONFIG, tuiMode: envTuiMode }; + try { + await saveTuiConfig(config, filePath); + } catch { + warn?.('[tui.toml] could not save the default config'); + } + return config; } try { const text = await readFile(filePath, 'utf-8'); - return parseTuiConfig(text, warn); + const shape = parseTuiConfigShape(text); + const config = normalizeTuiConfig(shape, warn); + if (shape.tui_mode !== undefined) { + return config; + } + const envTuiMode = legacyFullscreenEnvMode(); + if (envTuiMode === undefined) { + return config; + } + const migrated: TuiConfig = { ...config, tuiMode: envTuiMode }; + try { + await saveTuiConfig(migrated, filePath); + } catch { + warn?.('[tui.toml] could not save the migrated tui_mode preference'); + } + return migrated; } catch { throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); } } +function parseTuiConfigShape(tomlText: string): TuiConfigFileShape { + const raw = tomlText.trim().length === 0 ? {} : (parseToml(tomlText) as Record); + return TuiConfigFileSchema.parse(raw); +} + export function parseTuiConfig( tomlText: string, warn?: (message: string) => void, ): TuiConfig { - if (tomlText.trim().length === 0) { - return DEFAULT_TUI_CONFIG; - } - const raw = parseToml(tomlText) as Record; - const parsed = TuiConfigFileSchema.parse(raw); - return normalizeTuiConfig(parsed, warn); + return normalizeTuiConfig(parseTuiConfigShape(tomlText), warn); } export async function saveTuiConfig( @@ -224,8 +256,18 @@ export function normalizeTuiConfig( warn(`[tui.toml] ignoring unknown markdown.mermaid value: ${mermaidValue}`); } } + const tuiModeValue = config.tui_mode; + let tuiMode: TuiMode = 'regular'; + if (tuiModeValue !== undefined) { + if (tuiModeValue === 'regular' || tuiModeValue === 'fullscreen') { + tuiMode = tuiModeValue; + } else { + warn(`[tui.toml] ignoring unknown tui_mode value: ${tuiModeValue}`); + } + } return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + tuiMode, renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, @@ -268,6 +310,7 @@ export function renderTuiConfig(config: TuiConfig): string { if (statusCommand) { statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); } + const tuiModeLine = `tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" ("fullscreen" is experimental)`; const markdownSection = config.markdown?.mermaid === 'off' ? `[markdown]\nmermaid = "off" # "final" | "off"\n` @@ -290,6 +333,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.pythinker-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +${tuiModeLine} render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit diff --git a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts index 17a010b6a..865bbe275 100644 --- a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts @@ -6,6 +6,7 @@ export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const TUI_MODE_RESTART_NOTICE = 'TUI mode takes effect after restarting Pythinker Code.'; export const SESSIONLESS_STARTUP_NOTICE = 'No session yet — one will be created on your first message.'; export const TOWER_STATUS_PROMPT = diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 4462d3977..a2f44c533 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -32,6 +32,7 @@ import { Spacer, TuiAltScreen, TuiMainScreen, + type TuiMouseEventResult, } from '@pymodel/pi-tui'; import { resolve } from 'pathe'; @@ -159,6 +160,7 @@ import { type TUIStartupState, } from './types'; import { + countedByExpandHint, hasDispose, hasHiddenContent, isExpandable, @@ -235,11 +237,11 @@ export interface PythinkerTUIStartupInput { } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; -type LoadingTipKind = 'activity' | 'composing'; +type LoadingTipKind = 'activity' | 'braille'; function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { if (mode === 'waiting' || mode === 'tool') return 'activity'; - if (mode === 'composing') return 'composing'; + if (mode === 'composing' || mode === 'thinking') return 'braille'; return undefined; } @@ -282,6 +284,7 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { streamingStartTime: 0, stepRetry: null, theme: input.tuiConfig.theme, + tuiMode: input.tuiConfig.tuiMode, version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, @@ -471,6 +474,9 @@ export class PythinkerTUI { this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); this.state.footer.setExpandHintProvider(() => this.toolOutputExpandHint()); + this.state.transcriptContainer.setUnhandledClick((index) => + this.toggleClickedFoldBlock(index), + ); this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); @@ -3318,10 +3324,10 @@ export class PythinkerTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); const tipKind = loadingTipKind(effectiveMode); - // Pick a fresh loading tip when the loading kind changes. The same kind - // covers waiting/tool (both activity spinners) and any intermediate thinking - // phase, so a continuous burst of tool calls does not flip tips. Clear the - // cache only when there is no loading UI at all. + // Pick a fresh loading tip when the loading kind changes: waiting/tool + // share the activity kind and thinking/composing share the braille kind, so a + // burst of tool calls or thinking/composing alternation does not flip + // tips. Clear the cache only when there is no loading UI at all. if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { this.currentLoadingTip = undefined; } else if ( @@ -3379,12 +3385,21 @@ export class PythinkerTUI { break; } case 'thinking': { - this.stopActivitySpinner(); + const spinner = this.ensureActivitySpinner('Thinking…', (s) => + currentTheme.fg('primary', s), + ); this.syncAgentDynamicWorkflowActivitySpinner(undefined); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'thinking', + spinner, + tip: this.currentLoadingTip?.tip, + }), + ); break; } case 'composing': { - const spinner = this.ensureActivitySpinner('working…', (s) => + const spinner = this.ensureActivitySpinner('Working…', (s) => currentTheme.fg('primary', s), ); this.syncAgentDynamicWorkflowActivitySpinner(undefined); @@ -3414,9 +3429,9 @@ export class PythinkerTUI { case 'session': { this.stopActivitySpinner(); this.syncAgentDynamicWorkflowActivitySpinner(undefined); - // Keep a placeholder row so the activity area does not fully shrink - // when the spinner is removed at the end of streaming; combined with - // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + // Working modes occupy two rows (a spacer above the loader); idle + // keeps a one-row placeholder so the dock only shifts once at turn + // boundaries instead of on every intra-turn mode flip. this.state.activityContainer.addChild(new Spacer(1)); break; } @@ -3488,17 +3503,32 @@ export class PythinkerTUI { // card with hidden content keeps the collapse hint on. for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; - if (isExpandedComponent(child) && hasHiddenContent(child)) return 'collapse'; + if (isExpandedComponent(child) && countedByExpandHint(child)) return 'collapse'; } return null; } const cutoff = this.expandCutoff(children); for (let i = children.length - 1; i >= cutoff; i--) { - if (hasHiddenContent(children[i])) return 'expand'; + if (countedByExpandHint(children[i])) return 'expand'; } return null; } + private toggleClickedFoldBlock(index: number): TuiMouseEventResult | undefined { + const children = this.state.transcriptContainer.children; + const hit = children[index]; + if (hit === undefined || !isExpandable(hit) || !hasHiddenContent(hit)) return undefined; + if (isExpandedComponent(hit)) { + hit.setExpanded(false); + } else if (index >= this.expandCutoff(children)) { + hit.setExpanded(true); + } else { + return undefined; + } + this.state.ui.requestRender(); + return { handled: true }; + } + toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; const children = this.state.transcriptContainer.children; @@ -3836,12 +3866,11 @@ export class PythinkerTUI { /** * agent-core-v2 startup gate: before any session is created, ask whether to * trust this folder when the workspace is not trusted yet (project-level MCP - * servers stay disabled while untrusted). Best-effort throughout — a failed - * check or trust write never blocks startup. Choosing "don't trust" (or Esc) - * exits the program before any session is created; the prompt reappears on - * the next launch: the engine's untrusted state is indistinguishable from - * never-trusted. Returns true when the prompt started the event loop (the - * caller must not start it again). + * servers stay disabled while untrusted). A failed trust-info read is treated + * as untrusted and still prompts. Choosing "don't trust" (or Esc) exits the + * program before any session is created. A failed trust write still enters + * the TUI for this process and re-asks on the next launch. Returns true when + * the prompt started the event loop (the caller must not start it again). */ private async maybeRunWorkspaceTrustPrompt(): Promise { const workDir = this.state.appState.workDir; @@ -3849,9 +3878,11 @@ export class PythinkerTUI { try { info = await this.harness.getWorkspaceTrustInfo(workDir); } catch { + info = { trusted: false, gatedMcpServers: [] }; + } + if (info.trusted) { return false; } - if (info.trusted) return false; this.startEventLoop(); const choice = await new Promise((resolve) => { this.state.activeDialog = 'trust-prompt'; @@ -3878,7 +3909,6 @@ export class PythinkerTUI { try { await this.harness.trustWorkspace(workDir); } catch { - // A failed write leaves the workspace untrusted (re-asked next launch). } return true; } diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index 49fcd7a47..9b2f56bf9 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -37,7 +37,7 @@ import { export interface TUIState { ui: TUI; terminal: ProcessTerminal; - transcriptContainer: Container; + transcriptContainer: GutterContainer; activityContainer: Container; todoPanelContainer: Container; todoPanel: TodoPanelComponent; @@ -97,12 +97,9 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const terminal = new ProcessTerminal(); setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); setMarkdownMermaidMode(initialAppState.markdown?.mermaid ?? DEFAULT_MARKDOWN_CONFIG.mermaid); - // Fullscreen is experimental and env-gated for now: PYTHINKER_CODE_TUI_FULL_SCREEN=1. - const fullscreen = process.env['PYTHINKER_CODE_TUI_FULL_SCREEN'] === '1'; const ui = - fullscreen + initialAppState.tuiMode === 'fullscreen' ? new TuiAltScreen(terminal, undefined, undefined, { - scrollToEndIndicator: () => 'Jump to bottom (click) ↓', // Mouse capture takes over the terminal's native link activation, so // route OSC 8 clicks through our own opener. openUrl: (url) => { @@ -123,6 +120,9 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { }) .catch(() => {}); }, + // Clickable pill centered on the transcript's last row while it is + // scrolled away from the end. + scrollToEndIndicator: () => currentTheme.fg('primary', ' ↓ Jump to bottom '), }) : new TuiMainScreen(terminal); diff --git a/apps/pythinker-code/src/tui/types.ts b/apps/pythinker-code/src/tui/types.ts index 3ab1ad68e..dbc2f5b75 100644 --- a/apps/pythinker-code/src/tui/types.ts +++ b/apps/pythinker-code/src/tui/types.ts @@ -10,7 +10,7 @@ import type { ToolInputDisplay, } from '@pymodel/pythinker-code-sdk'; -import type { MarkdownConfig, NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; +import type { MarkdownConfig, NotificationsConfig, StatusLineConfig, TuiMode, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -72,6 +72,7 @@ export interface AppState { /** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */ stepRetry: StepRetryState | null; theme: ThemeName; + tuiMode?: TuiMode; version: string; editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ diff --git a/apps/pythinker-code/src/tui/utils/component-capabilities.ts b/apps/pythinker-code/src/tui/utils/component-capabilities.ts index 810f08cae..7e0023293 100644 --- a/apps/pythinker-code/src/tui/utils/component-capabilities.ts +++ b/apps/pythinker-code/src/tui/utils/component-capabilities.ts @@ -4,14 +4,17 @@ export interface Expandable { /** * An expandable component that can say whether ctrl+o would change what it - * shows — content it keeps out of its collapsed form. Drives the footer's - * `ctrl+o expand` / `ctrl+o collapse` hint. + * shows — content it keeps out of its collapsed form. */ export interface HidesContent extends Expandable { hasHiddenContent(): boolean; isExpanded(): boolean; } +export interface OmitsExpandHint { + omitsExpandHint(): boolean; +} + export interface Disposable { dispose(): void; } @@ -34,6 +37,21 @@ export function hasHiddenContent(obj: unknown): boolean { ); } +export function countedByExpandHint(obj: unknown): boolean { + if (omitsExpandHint(obj)) return false; + return hasHiddenContent(obj); +} + +function omitsExpandHint(obj: unknown): boolean { + return ( + typeof obj === 'object' && + obj !== null && + 'omitsExpandHint' in obj && + typeof (obj as OmitsExpandHint).omitsExpandHint === 'function' && + (obj as OmitsExpandHint).omitsExpandHint() + ); +} + /** Whether an expandable component currently shows its expanded form. */ export function isExpandedComponent(obj: unknown): boolean { return ( diff --git a/apps/pythinker-code/src/utils/git/git-args.ts b/apps/pythinker-code/src/utils/git/git-args.ts new file mode 100644 index 000000000..fbcec0e07 --- /dev/null +++ b/apps/pythinker-code/src/utils/git/git-args.ts @@ -0,0 +1,22 @@ +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 56b7f0af6..8ca6fc9f6 100644 --- a/apps/pythinker-code/src/utils/git/git-status.ts +++ b/apps/pythinker-code/src/utils/git/git-status.ts @@ -9,6 +9,7 @@ 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; @@ -69,11 +70,11 @@ export function createGitStatusCache( workDir: string, options: GitStatusCacheOptions = {}, ): GitStatusCache { - // This cache is constructed before the workspace trust gate, so the git - // binary must be resolved through PATH to an absolute path — a bare name - // would let cmd.exe pick up a `git.exe` planted in the workspace. + // Resolve through PATH to an absolute path — a bare name would let cmd.exe + // pick up a `git.exe` planted in the workspace. const git = resolveCommandPath('git', workDir); - const isRepo = git !== undefined && detectGitRepo(git, workDir); + let repoDetected = false; + let isRepo = false; let branch: BranchState = { value: null, fetchedAt: 0 }; let status: StatusState = { dirty: false, @@ -93,16 +94,22 @@ export function createGitStatusCache( return { getStatus: () => { - if (!isRepo || git === undefined) return null; + if (git === undefined) return null; + if (repoDetected && !isRepo) return null; + if (!repoDetected) { + repoDetected = true; + isRepo = detectGitRepo(git, workDir, GIT_CONFIG_ARGS); + } + if (!isRepo) return null; const now = Date.now(); if (now - branch.fetchedAt >= BRANCH_TTL_MS) { - branch = { value: readBranch(git, workDir), fetchedAt: now }; + branch = { value: readBranch(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now }; } if (branch.value === null) return null; if (now - status.fetchedAt >= STATUS_TTL_MS) { - status = { ...readStatus(git, workDir), fetchedAt: now }; + status = { ...readStatus(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now }; } refreshPullRequestIfNeeded(branch.value, now); @@ -149,24 +156,32 @@ export function createGitStatusCache( } } -function detectGitRepo(git: string, workDir: string): boolean { +function detectGitRepo(git: string, workDir: string, configArgs: readonly string[]): boolean { try { - const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); + const result = spawnSync( + git, + [...configArgs, '-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): string | null { +function readBranch(git: string, workDir: string, configArgs: readonly string[]): string | null { try { - const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); + const result = spawnSync( + git, + [...configArgs, '-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; @@ -178,6 +193,7 @@ function readBranch(git: string, workDir: string): string | null { function readStatus( git: string, workDir: string, + configArgs: readonly string[], ): { dirty: boolean; ahead: number; @@ -186,11 +202,15 @@ function readStatus( diffDeleted: number; } { try { - const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }); + const result = spawnSync( + git, + [...configArgs, '-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 }; } @@ -209,7 +229,7 @@ function readStatus( dirty = true; } } - const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; + const diff = dirty ? readDiffStats(git, workDir, configArgs) : { added: 0, deleted: 0 }; return { dirty, ahead, @@ -222,13 +242,30 @@ function readStatus( } } -function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { +function readDiffStats( + git: string, + workDir: string, + configArgs: readonly string[], +): { added: number; deleted: number } { try { - const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }); + const result = spawnSync( + git, + [ + ...configArgs, + '-C', + workDir, + 'diff', + ...GIT_DIFF_ARGS, + '--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/native/build-scripts.test.ts b/apps/pythinker-code/test/native/build-scripts.test.ts new file mode 100644 index 000000000..5c4277f97 --- /dev/null +++ b/apps/pythinker-code/test/native/build-scripts.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { SEA_EXEC_ARGV, seaCodeCacheEnabled } from '../../scripts/native/sea-options.mjs'; + +describe('sea-options', () => { + it('bakes a bounded young generation into the binary', () => { + expect(SEA_EXEC_ARGV).toEqual(['--max-semi-space-size=16']); + expect(Object.isFrozen(SEA_EXEC_ARGV)).toBe(true); + }); + + it('only enables the V8 code cache when the target matches the build host', () => { + expect(seaCodeCacheEnabled('darwin-arm64', 'darwin-arm64')).toBe(true); + expect(seaCodeCacheEnabled('linux-x64', 'darwin-arm64')).toBe(false); + }); +}); diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index 63955402f..3721fe649 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -142,7 +142,7 @@ describe('updateActivityPane terminal progress', () => { expect(setProgress).toHaveBeenLastCalledWith(false); }); - it('keeps terminal progress active without showing a thinking spinner', () => { + it('keeps terminal progress active while showing the thinking spinner', () => { vi.useFakeTimers(); try { const { driver, state, setProgress } = makeDriverWithTerminalProgress(); @@ -153,8 +153,8 @@ describe('updateActivityPane terminal progress', () => { expect(setProgress).toHaveBeenCalledTimes(1); expect(setProgress).toHaveBeenLastCalledWith(true); - expect(state.activitySpinner).toBeNull(); - expect(state.activityContainer.children).toHaveLength(0); + expect(state.activitySpinner).not.toBeNull(); + expect(state.activityContainer.children).toHaveLength(1); state.appState.streamingPhase = 'idle'; driver.updateActivityPane(); diff --git a/apps/pythinker-code/test/tui/commands/reload.test.ts b/apps/pythinker-code/test/tui/commands/reload.test.ts index 96d2d608e..cf73c1f9a 100644 --- a/apps/pythinker-code/test/tui/commands/reload.test.ts +++ b/apps/pythinker-code/test/tui/commands/reload.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleReloadCommand, @@ -24,7 +24,12 @@ import { const tempDirs: string[] = []; const originalPythinkerCodeHome = process.env['PYTHINKER_CODE_HOME']; +beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', ''); +}); + afterEach(async () => { + vi.unstubAllEnvs(); setExperimentalFeatures([]); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); @@ -192,6 +197,29 @@ auto_install = false 'success', ); }); + + it('notices a required restart when tui_mode differs from the running UI mode', async () => { + await writeTuiConfig('tui_mode = "fullscreen"\n'); + const host = makeHost(); + + await handleReloadTuiCommand(host); + + expect(host.setAppState).toHaveBeenCalledWith( + expect.objectContaining({ tuiMode: 'fullscreen' }), + ); + expect(host.showNotice).toHaveBeenCalledWith( + 'TUI mode takes effect after restarting Pythinker Code.', + ); + }); + + it('does not notice when tui_mode matches the running UI mode', async () => { + await writeTuiConfig('theme = "dark"\n'); + const host = makeHost(); + + await handleReloadTuiCommand(host); + + expect(host.showNotice).not.toHaveBeenCalled(); + }); }); async function writeTuiConfig(text: string): Promise { @@ -218,6 +246,9 @@ function makeHost({ editor: { setDisablePasteBurst: vi.fn(), }, + ui: { + mode: 'regular' as const, + }, theme: { palette: { success: '#00ff00', @@ -249,6 +280,7 @@ function makeHost({ refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), + showNotice: vi.fn(), } as unknown as SlashCommandHost & { readonly harness: { readonly reloadSession: ReturnType; @@ -258,5 +290,6 @@ function makeHost({ readonly refreshSlashCommandAutocomplete: ReturnType; readonly reloadCurrentSessionView: ReturnType; readonly showStatus: ReturnType; + readonly showNotice: ReturnType; }; } diff --git a/apps/pythinker-code/test/tui/commands/tui-mode-preferences.test.ts b/apps/pythinker-code/test/tui/commands/tui-mode-preferences.test.ts new file mode 100644 index 000000000..cac612374 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/tui-mode-preferences.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applyTuiModeChoice } from '#/tui/commands/config'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +function makeHost( + tuiMode: 'regular' | 'fullscreen', + runningMode: 'regular' | 'fullscreen' = 'regular', +) { + return { + state: { + appState: { + theme: 'auto' as const, + tuiMode, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + ui: { mode: runningMode }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + }; +} + +describe('tui mode preference commands', () => { + it('saves fullscreen to tui.toml, mirrors appState, and notices the required restart', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost('regular'); + + await applyTuiModeChoice(host, 'fullscreen'); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ tuiMode: 'fullscreen' }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ tuiMode: 'fullscreen' }); + expect(host.showStatus).toHaveBeenCalledWith('TUI mode set to fullscreen.', 'success'); + expect(host.showNotice).toHaveBeenCalledWith( + 'TUI mode takes effect after restarting Pythinker Code.', + ); + }); + + it('skips the restart notice when switching back to the running mode', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost('fullscreen', 'regular'); + + await applyTuiModeChoice(host, 'regular'); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ tuiMode: 'regular' }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ tuiMode: 'regular' }); + expect(host.showNotice).not.toHaveBeenCalled(); + }); + + it('does not rewrite the config when the value is unchanged', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost('regular'); + + await applyTuiModeChoice(host, 'regular'); + + expect(mocks.saveTuiConfig).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('TUI mode already regular.'); + }); + + it('reports a save failure without touching appState', async () => { + mocks.saveTuiConfig.mockRejectedValueOnce(new Error('disk full')); + const host = makeHost('regular'); + + await applyTuiModeChoice(host, 'fullscreen'); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Failed to save TUI mode: disk full', 'error'); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index 800d5761c..00c03d7eb 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; @@ -7,6 +7,22 @@ import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; +const gitStatusMocks = vi.hoisted(() => ({ + createGitStatusCache: vi.fn(), +})); + +vi.mock('#/utils/git/git-status', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createGitStatusCache: gitStatusMocks.createGitStatusCache }; +}); + +beforeEach(() => { + gitStatusMocks.createGitStatusCache.mockClear(); + gitStatusMocks.createGitStatusCache.mockImplementation(() => ({ + getStatus: vi.fn(() => null), + })); +}); + const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; function truecolorCodes(text: string): Set { diff --git a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts index c0fac1c1e..bdb13ffcb 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts @@ -107,7 +107,8 @@ describe('ChoicePickerComponent', () => { const settingsOutput = settings.render(120).map(strip); expect(settingsOutput).toContain(' ❯ Model'); expect(settingsOutput).toContain(' Switch the active model and thinking mode.'); - expect(settingsOutput).toContain(' Turn automatic CLI updates on or off.'); + expect(settingsOutput).toContain(' TUI mode'); + expect(settingsOutput).toContain(' Choose the regular or fullscreen layout.'); const upgradePreference = new UpdatePreferenceSelectorComponent({ currentValue: true, diff --git a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts index 0a7efc096..2665fc303 100644 --- a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts @@ -27,7 +27,7 @@ function createMockSpinner(initialText = 'working') { } describe('ActivityPaneComponent', () => { - it('renders waiting loader after a spacer', () => { + it('renders the waiting loader after a spacer', () => { const { spinner } = createMockSpinner('loading'); const component = new ActivityPaneComponent({ mode: 'waiting', @@ -37,7 +37,7 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); }); - it('renders composing spinner after a spacer', () => { + it('renders the composing spinner after a spacer', () => { const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ mode: 'composing', @@ -47,6 +47,16 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it('renders the thinking spinner after a spacer', () => { + const { spinner } = createMockSpinner('thinking'); + const component = new ActivityPaneComponent({ + mode: 'thinking', + spinner, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'thinking']); + }); + it('renders the detail line under the waiting spinner', () => { const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ @@ -61,8 +71,8 @@ describe('ActivityPaneComponent', () => { expect(lines).toEqual(['', 'working', ' 429 · rate limited']); }); - it.each(['waiting', 'tool', 'composing'] as const)( - 'renders %s spinner with tip after a spacer', + it.each(['waiting', 'tool', 'composing', 'thinking'] as const)( + 'renders the %s spinner with tip after a spacer', (mode) => { const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ @@ -91,7 +101,7 @@ describe('ActivityPaneComponent', () => { }, ); - it('renders nothing for hidden and thinking modes', () => { + it('renders nothing for hidden, or for thinking without a spinner', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); diff --git a/apps/pythinker-code/test/tui/config.test.ts b/apps/pythinker-code/test/tui/config.test.ts index 55912ac3c..509e9df8a 100644 --- a/apps/pythinker-code/test/tui/config.test.ts +++ b/apps/pythinker-code/test/tui/config.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_TUI_CONFIG, @@ -17,11 +17,13 @@ let dir: string; let filePath: string; beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', ''); dir = mkdtempSync(join(tmpdir(), 'pythinker-tui-config-')); filePath = join(dir, 'tui.toml'); }); afterEach(() => { + vi.unstubAllEnvs(); rmSync(dir, { recursive: true, force: true }); }); @@ -60,6 +62,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', + tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -118,6 +121,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -168,6 +172,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -351,3 +356,113 @@ mermaid = "${value}" expect((await loadTuiConfig(filePath)).markdown).toEqual({ mermaid: 'off' }); }); }); + +describe('TUI config tui_mode', () => { + it('defaults tui_mode to regular when omitted', () => { + expect(parseTuiConfig(`theme = "dark"`).tuiMode).toBe('regular'); + }); + + it('parses tui_mode = "fullscreen"', () => { + const config = parseTuiConfig(` +tui_mode = "fullscreen" +`); + + expect(config.tuiMode).toBe('fullscreen'); + }); + + it('warns and falls back to regular for unknown tui_mode values without failing the file', () => { + const warnings: string[] = []; + const config = parseTuiConfig( + ` +theme = "dark" +tui_mode = "weird" +`, + (message) => warnings.push(message), + ); + + expect(config.tuiMode).toBe('regular'); + expect(config.theme).toBe('dark'); + expect(warnings).toEqual(['[tui.toml] ignoring unknown tui_mode value: weird']); + }); + + it('writes a live tui_mode key even at the default value', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('\ntui_mode = "regular"'); + expect(text).not.toContain('# tui_mode'); + }); + + it('writes a live tui_mode when fullscreen and round-trips it', async () => { + await saveTuiConfig({ ...DEFAULT_TUI_CONFIG, tuiMode: 'fullscreen' }, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('\ntui_mode = "fullscreen"'); + expect((await loadTuiConfig(filePath)).tuiMode).toBe('fullscreen'); + }); +}); + +describe('TUI config tui_mode env migration', () => { + it('migrates PYTHINKER_CODE_TUI_FULL_SCREEN=1 into tui_mode when the key is absent', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + writeFileSync(filePath, 'theme = "dark"\n', 'utf-8'); + const warnings: string[] = []; + + const config = await loadTuiConfig(filePath, (message) => warnings.push(message)); + + expect(config.tuiMode).toBe('fullscreen'); + expect(readFileSync(filePath, 'utf-8')).toContain('\ntui_mode = "fullscreen"'); + expect(warnings).toEqual([]); + }); + + it('ignores the env when tui_mode is explicitly regular', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + writeFileSync(filePath, 'tui_mode = "regular"\n', 'utf-8'); + + const config = await loadTuiConfig(filePath); + + expect(config.tuiMode).toBe('regular'); + expect(readFileSync(filePath, 'utf-8')).toBe('tui_mode = "regular"\n'); + }); + + it('does not migrate over an explicitly set unknown value', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + writeFileSync(filePath, 'tui_mode = "weird"\n', 'utf-8'); + const warnings: string[] = []; + + const config = await loadTuiConfig(filePath, (message) => warnings.push(message)); + + expect(config.tuiMode).toBe('regular'); + expect(warnings).toEqual(['[tui.toml] ignoring unknown tui_mode value: weird']); + expect(readFileSync(filePath, 'utf-8')).toBe('tui_mode = "weird"\n'); + }); + + it('only honors the exact value 1 like the old gate', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', 'true'); + writeFileSync(filePath, 'theme = "dark"\n', 'utf-8'); + + const config = await loadTuiConfig(filePath); + + expect(config.tuiMode).toBe('regular'); + expect(readFileSync(filePath, 'utf-8')).toBe('theme = "dark"\n'); + }); + + it('migrates when the config file does not exist yet', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + + const config = await loadTuiConfig(filePath); + + expect(config.tuiMode).toBe('fullscreen'); + expect(readFileSync(filePath, 'utf-8')).toContain('\ntui_mode = "fullscreen"'); + }); + + it('does not re-migrate after a regular preference has been saved', async () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + await saveTuiConfig({ ...DEFAULT_TUI_CONFIG, tuiMode: 'regular' }, filePath); + + const config = await loadTuiConfig(filePath); + + expect(config.tuiMode).toBe('regular'); + expect(readFileSync(filePath, 'utf-8')).toContain('\ntui_mode = "regular"'); + }); +}); diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index 15500733e..e02cd209b 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { TuiAltScreen, TuiMainScreen } from '@pymodel/pi-tui'; @@ -135,9 +135,8 @@ describe('createTUIState', () => { }); it('builds an alternate-screen renderer with a docked layout in fullscreen mode', () => { - vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); const state = createTUIState({ - initialAppState: fakeInitialAppState(), + initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, startup: { continueLast: false, yolo: false, @@ -145,7 +144,6 @@ describe('createTUIState', () => { plan: false, }, }); - vi.unstubAllEnvs(); expect(state.ui).toBeInstanceOf(TuiAltScreen); expect(state.ui.mode).toBe('fullscreen'); diff --git a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts index ea65674c8..b3dc2a79c 100644 --- a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts +++ b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts @@ -5,7 +5,7 @@ * shrink distribution with no minSize, so a tall transcript crushed it and * the editor's bottom border row was clipped off screen. */ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Spacer, type Terminal, TuiAltScreen } from '@pymodel/pi-tui'; import { VirtualTerminal } from '../../../../packages/pi-tui/test/virtual-terminal'; @@ -70,12 +70,10 @@ async function mountFullscreen(): Promise<{ vt: VirtualTerminal; }> { const opts: PythinkerTUIOptions = { - initialAppState: fakeInitialAppState(), + initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, startup: { continueLast: false, yolo: false, auto: false, plan: false }, }; - vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); const state = createTUIState(opts); - vi.unstubAllEnvs(); const vt = new VirtualTerminal(WIDTH, HEIGHT); (state.ui as { terminal: Terminal }).terminal = vt; diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 793962a90..59d305e11 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -9,6 +9,7 @@ import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, + type TuiMouseEvent, } from '@pymodel/pi-tui'; import type { ApprovalRequest, @@ -8133,10 +8134,7 @@ command = "vim" driver.handleUserInput('/fork ignored args'); await vi.waitFor(() => { - expect(forkSession).toHaveBeenCalledWith({ - id: 'ses-source', - title: 'Fork: Source title', - }); + expect(forkSession).toHaveBeenCalledWith({ id: 'ses-source' }); expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( 'Session forked (ses-fork). Still in the original session; switch to the fork via /sessions.', ); @@ -8235,10 +8233,7 @@ command = "vim" driver.handleUserInput('/fork'); await vi.waitFor(() => { - expect(forkSession).toHaveBeenCalledWith({ - id: 'ses-source', - title: 'Fork: ses-source', - }); + expect(forkSession).toHaveBeenCalledWith({ id: 'ses-source' }); expect(driver.getCurrentSessionId()).toBe('ses-source'); expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( 'Failed to fork session: fork unavailable', @@ -9235,3 +9230,301 @@ describe('PythinkerTUI session rating survey', () => { } }); }); + +describe('transcript fold block clicks', () => { + const transcriptWidth = 120; + + function hiddenOutput(name: string): string { + return [`${name}-body`, `${name}-mid`, `${name}-more`, `${name}-tail`].join('\n'); + } + + function emitBash( + driver: MessageDriver, + toolCallId: string, + command: string, + output: string, + ): void { + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId, + name: 'Bash', + args: { command }, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId, + output, + isError: undefined, + } as Event, + vi.fn(), + ); + } + + function renderFooterLine1(driver: MessageDriver): string { + return stripSgr(driver.state.footer.render(160)[0] ?? ''); + } + + function clickTranscriptLine(driver: MessageDriver, needle: string): void { + const lines = driver.state.transcriptContainer.render(transcriptWidth); + const y = lines.findIndex((line) => stripSgr(line).includes(needle)); + expect(y).toBeGreaterThanOrEqual(0); + const event: TuiMouseEvent = { + type: 'click', + button: 'left', + x: 2, + y, + screenX: 2, + screenY: y, + width: transcriptWidth, + height: lines.length, + shift: false, + alt: false, + ctrl: false, + clickCount: 1, + }; + driver.state.transcriptContainer.handleMouse(event); + } + + it('opens only the clicked tool card while the footer still offers expand', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_alpha', 'echo alpha', hiddenOutput('alpha')); + emitBash(driver, 'call_beta', 'echo beta', hiddenOutput('beta')); + + const collapsed = stripSgr(renderTranscript(driver)); + expect(collapsed).toContain('alpha-tail'); + expect(collapsed).not.toContain('alpha-body'); + expect(collapsed).not.toContain('beta-body'); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + + clickTranscriptLine(driver, 'alpha-tail'); + + const opened = stripSgr(renderTranscript(driver)); + expect(opened).toContain('alpha-body'); + expect(opened).not.toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + + clickTranscriptLine(driver, 'alpha-body'); + + const closed = stripSgr(renderTranscript(driver)); + expect(closed).not.toContain('alpha-body'); + expect(closed).not.toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + }); + + it('lets ctrl+o overwrite a clicked card and keeps a neighbor open', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_alpha', 'echo alpha', hiddenOutput('alpha')); + emitBash(driver, 'call_beta', 'echo beta', hiddenOutput('beta')); + + clickTranscriptLine(driver, 'alpha-tail'); + driver.toggleToolOutputExpansion(); + + const expanded = stripSgr(renderTranscript(driver)); + expect(expanded).toContain('alpha-body'); + expect(expanded).toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + clickTranscriptLine(driver, 'alpha-body'); + + const oneClosed = stripSgr(renderTranscript(driver)); + expect(oneClosed).not.toContain('alpha-body'); + expect(oneClosed).toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + driver.toggleToolOutputExpansion(); + + const collapsed = stripSgr(renderTranscript(driver)); + expect(collapsed).not.toContain('alpha-body'); + expect(collapsed).not.toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + }); + + it('closes an aged-out open card and does not open it again', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_old', 'echo old', hiddenOutput('old')); + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).toContain('old-body'); + + for (let i = 0; i < 4; i++) { + driver.appendTranscriptEntry({ + id: `later-${String(i)}`, + kind: 'user', + renderMode: 'plain', + content: `later turn ${String(i)}`, + }); + } + emitBash(driver, 'call_recent', 'echo recent', hiddenOutput('recent')); + + expect(stripSgr(renderTranscript(driver))).toContain('old-body'); + expect(stripSgr(renderTranscript(driver))).toContain('recent-body'); + + clickTranscriptLine(driver, 'old-body'); + + const oldClosed = stripSgr(renderTranscript(driver)); + expect(oldClosed).not.toContain('old-body'); + expect(oldClosed).toContain('recent-body'); + expect(driver.state.toolOutputExpanded).toBe(true); + + clickTranscriptLine(driver, 'old-tail'); + expect(stripSgr(renderTranscript(driver))).not.toContain('old-body'); + expect(stripSgr(renderTranscript(driver))).toContain('recent-body'); + + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).not.toContain('old-body'); + expect(stripSgr(renderTranscript(driver))).not.toContain('recent-body'); + expect(driver.state.toolOutputExpanded).toBe(false); + + driver.toggleToolOutputExpansion(); + const reopened = stripSgr(renderTranscript(driver)); + expect(reopened).not.toContain('old-body'); + expect(reopened).toContain('recent-body'); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + }); + + it('does not change a fold block when the click lands on message text', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_alpha', 'echo alpha', hiddenOutput('alpha')); + driver.appendTranscriptEntry({ + id: 'user-plain', + kind: 'user', + renderMode: 'plain', + content: 'plain user sentence', + }); + + clickTranscriptLine(driver, 'plain user sentence'); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('plain user sentence'); + expect(transcript).not.toContain('alpha-body'); + expect(transcript).toContain('alpha-tail'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + }); + + it('toggles a long finalized thinking block without advertising it in the footer', async () => { + const { driver } = await makeDriver(); + const longThinking = ['think-one', 'think-two', 'think-three', 'think-four'].join('\n'); + driver.streamingUI.onThinkingUpdate(longThinking); + const streaming = stripSgr(renderTranscript(driver)); + expect(streaming).toContain('think-four'); + expect(streaming).not.toContain('think-one'); + + clickTranscriptLine(driver, 'think-four'); + expect(stripSgr(renderTranscript(driver))).not.toContain('think-one'); + + driver.streamingUI.onThinkingEnd(); + + const collapsed = stripSgr(renderTranscript(driver)); + expect(collapsed).toContain('think-one'); + expect(collapsed).not.toContain('think-four'); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + + clickTranscriptLine(driver, 'think-one'); + + const opened = stripSgr(renderTranscript(driver)); + expect(opened).toContain('think-four'); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + + clickTranscriptLine(driver, 'think-four'); + expect(stripSgr(renderTranscript(driver))).not.toContain('think-four'); + + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).toContain('think-four'); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + expect(driver.state.toolOutputExpanded).toBe(true); + + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).not.toContain('think-four'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + }); + + it('keeps the expand hint when clicked cards are all open', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_alpha', 'echo alpha', hiddenOutput('alpha')); + emitBash(driver, 'call_beta', 'echo beta', hiddenOutput('beta')); + + clickTranscriptLine(driver, 'alpha-tail'); + clickTranscriptLine(driver, 'beta-tail'); + + const opened = stripSgr(renderTranscript(driver)); + expect(opened).toContain('alpha-body'); + expect(opened).toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(false); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + }); + + it('drops the collapse hint after every open card is clicked shut', async () => { + const { driver } = await makeDriver(); + emitBash(driver, 'call_alpha', 'echo alpha', hiddenOutput('alpha')); + emitBash(driver, 'call_beta', 'echo beta', hiddenOutput('beta')); + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + clickTranscriptLine(driver, 'alpha-body'); + clickTranscriptLine(driver, 'beta-body'); + + const closed = stripSgr(renderTranscript(driver)); + expect(closed).not.toContain('alpha-body'); + expect(closed).not.toContain('beta-body'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + }); + + it('closes a wrapped ! card that was opened from its preview', async () => { + const marker = 'shell-tail-marker'; + const stdout = `${'x'.repeat(4000)}${marker}`; + const runShellCommand = vi.fn(async () => ({ stdout, stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('echo-shell'); + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('ctrl+o to expand'); + expect(transcript).not.toContain(marker); + }); + + clickTranscriptLine(driver, 'more lines'); + expect(stripSgr(renderTranscript(driver))).toContain(marker); + + clickTranscriptLine(driver, marker); + const closed = stripSgr(renderTranscript(driver)); + expect(closed).not.toContain(marker); + expect(closed).toContain('ctrl+o to expand'); + expect(driver.state.toolOutputExpanded).toBe(false); + }); + + it('closes a wide tool card that mounted while the transcript was expanded', async () => { + const { driver } = await makeDriver(); + driver.toggleToolOutputExpansion(); + const output = `wide-head ${'w'.repeat(500)} wide-tail`; + emitBash(driver, 'call_wide', 'echo wide', output); + + expect(stripSgr(renderTranscript(driver))).toContain('wide-tail'); + + clickTranscriptLine(driver, 'echo wide'); + + const closed = stripSgr(renderTranscript(driver)); + expect(closed).not.toContain('wide-tail'); + expect(closed).toContain('wide-head'); + expect(driver.state.toolOutputExpanded).toBe(true); + }); +}); 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 98ce5e4b2..7f1df042b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -211,6 +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: [] })), supportsAtomicSectionReplace: vi.fn(() => false), auth: { status: vi.fn(async () => ({ providers: [] })), @@ -289,11 +290,9 @@ describe('PythinkerTUI startup', () => { }); }); - it('mounts the docked fullscreen layout when PYTHINKER_CODE_TUI_FULL_SCREEN=1', async () => { + it('mounts the docked fullscreen layout when tui_mode is fullscreen', async () => { const harness = makeHarness(makeSession()); - vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); - const driver = makeDriver(harness, { ...makeStartupInput() }); - vi.unstubAllEnvs(); + const driver = makeDriver(harness, { ...makeStartupInput({}, { tuiMode: 'fullscreen' }) }); expect(driver.state.ui.mode).toBe('fullscreen'); expect(driver.state.ui.children).toHaveLength(0); @@ -2500,6 +2499,78 @@ describe('PythinkerTUI startup', () => { expect(onExit).toHaveBeenCalledWith(0); }); + it('prompts for workspace trust when trust info cannot be read', async () => { + const getWorkspaceTrustInfo = vi.fn(async () => { + throw new Error('unavailable'); + }); + const trustWorkspace = vi.fn(async () => {}); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + }) as unknown as MigrateExitDriver & { + mountEditorReplacement(panel: { handleInput(data: string): void }): void; + }; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + const mountSpy = vi.spyOn(driver, 'mountEditorReplacement'); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + const startPromise = driver.start(); + await vi.waitFor(() => { + expect(mountSpy).toHaveBeenCalled(); + }); + mountSpy.mock.calls[0]![0].handleInput('\u001B[A'); + mountSpy.mock.calls[0]![0].handleInput('\r'); + await startPromise; + + expect(trustWorkspace).toHaveBeenCalledWith('/tmp/proj-a'); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('continues startup when persisting trust fails', async () => { + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: false, + gatedMcpServers: [], + })); + const trustWorkspace = vi.fn(async (): Promise => { + throw new Error('disk full'); + }); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + }) as unknown as MigrateExitDriver & { + mountEditorReplacement(panel: { handleInput(data: string): void }): void; + }; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const mountSpy = vi.spyOn(driver, 'mountEditorReplacement'); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + const startPromise = driver.start(); + await vi.waitFor(() => { + expect(mountSpy).toHaveBeenCalled(); + }); + mountSpy.mock.calls[0]![0].handleInput('\u001B[A'); + mountSpy.mock.calls[0]![0].handleInput('\r'); + await startPromise; + + expect(trustWorkspace).toHaveBeenCalledWith('/tmp/proj-a'); + expect(migrationSpy).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalledWith(0); + }); + it('does not mount the footer when resuming a missing session fails', async () => { const harness = makeHarness(makeSession(), { listSessions: vi.fn(async () => []), diff --git a/apps/pythinker-code/test/tui/signal-handlers.test.ts b/apps/pythinker-code/test/tui/signal-handlers.test.ts index e6fe9570d..bcf65e0d2 100644 --- a/apps/pythinker-code/test/tui/signal-handlers.test.ts +++ b/apps/pythinker-code/test/tui/signal-handlers.test.ts @@ -47,6 +47,7 @@ function makeHarness() { close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), + getWorkspaceTrustInfo: vi.fn(async () => ({ trusted: true, gatedMcpServers: [] })), }; } 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 962bd8aa1..615ea9398 100644 --- a/apps/pythinker-code/test/utils/git/git-status.test.ts +++ b/apps/pythinker-code/test/utils/git/git-status.test.ts @@ -216,6 +216,49 @@ 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( ( @@ -277,6 +320,18 @@ describe('git status cache', () => { ).toBe('main [±]'); }); + it('skips git on later renders once the directory is known to be a non-repo', () => { + mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); + const cache = createGitStatusCache('/tmp/not-a-repo'); + + expect(cache.getStatus()).toBeNull(); + const probeCalls = mocks.spawnSync.mock.calls.length; + expect(probeCalls).toBe(1); + + expect(cache.getStatus()).toBeNull(); + expect(mocks.spawnSync.mock.calls.length).toBe(probeCalls); + }); + it('formats pull request badges as terminal hyperlinks when requested', () => { const linked = formatGitBadge( { diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index d7f09da13..c2364c0e8 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -54,6 +54,11 @@ import type { PromptAborted, PromptCompleted, PromptSteered, + SubagentCancelled, + SubagentCompleted, + SubagentFailed, + SubagentSpawned, + SubagentStarted, TaskStarted, TaskTerminated, TaskWaitDelivered, @@ -190,6 +195,11 @@ export type AgentRecord = | WireRecordOf<'prompt.completed', PromptCompleted> | WireRecordOf<'prompt.steered', PromptSteered> | WireRecordOf<'runtime.set_binding', RuntimeSetBinding> + | WireRecordOf<'subagent.cancelled', SubagentCancelled> + | WireRecordOf<'subagent.completed', SubagentCompleted> + | WireRecordOf<'subagent.failed', SubagentFailed> + | WireRecordOf<'subagent.spawned', SubagentSpawned> + | WireRecordOf<'subagent.started', SubagentStarted> | WireRecordOf<'task.started', TaskStarted> | WireRecordOf<'task.terminated', TaskTerminated> | WireRecordOf<'task.waitDelivered', TaskWaitDelivered> diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index b7f607386..fc774cf2b 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -592,6 +592,11 @@ export function projectContext( case 'task.started': case 'task.terminated': case 'task.waitDelivered': + case 'subagent.spawned': + case 'subagent.started': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.cancelled': case 'cron.add': case 'cron.cursor': case 'cron.delete': diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index e60bda3e1..a07985103 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -1313,6 +1313,61 @@ export const WIRE_RENDERERS: RendererMap = { }), }, + 'subagent.spawned': { + tone: 'subagent', + label: 'sub+', + headline: (r) => ({ + main: ( + + + {r.dynamicWorkflowIndex === undefined ? 'child' : `member ${r.dynamicWorkflowIndex}`} + + {r.subagentId} + {r.subagentName} + + ), + right: r.runInBackground ? background : undefined, + }), + }, + + 'subagent.started': { + tone: 'subagent', + label: 'sub↻', + headline: (r) => ({ main: {r.subagentId} }), + }, + + 'subagent.completed': { + tone: 'subagent', + label: 'sub✓', + headline: (r) => ({ + main: ( + + {r.subagentId} + {truncate(r.resultSummary, 120)} + + ), + }), + }, + + 'subagent.failed': { + tone: 'subagent', + label: 'sub✗', + headline: (r) => ({ + main: ( + + {r.subagentId} + {truncate(r.error, 120)} + + ), + }), + }, + + 'subagent.cancelled': { + tone: 'subagent', + label: 'sub⊘', + headline: (r) => ({ main: {r.subagentId} }), + }, + 'token_counting.measured': { tone: 'meta', label: 'tokens', diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index a8046b8b8..b1e90bfe2 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -103,6 +103,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `extra_agent_dirs` | `array` | — | Extra custom agent search directories, layered on top of the default directories | | `builtin_product_skills` | `boolean` | `true` | Whether the built-in skills that document Pythinker Code itself are offered to the model | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | +| `auto_session_title` | `boolean` | `true` | Whether clients may automatically generate session titles; disabled only when explicitly set to `false` | | [`providers`](#providers) | `table` | `{}` | API provider table | | [`models`](#models) | `table` | — | Model alias table | | [`thinking`](#thinking) | `table` | — | Default parameters for Thinking mode | @@ -558,6 +559,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto`, `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | +| `tui_mode` | `string` | `regular` | UI layout: `regular` renders into the terminal scrollback; `fullscreen` makes parts of the UI mouse-interactive | | `render_latex` | `boolean` | `true` | Render LaTeX math expressions in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | On resume or when submitting after a long idle stretch, warn that the context cache may have expired and offer to compact or start a new session (v2 engine only) | @@ -579,6 +581,7 @@ Model, cwd, git branch, permission mode, plan mode, context usage, session id, v ```toml # ~/.pythinker-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name +tui_mode = "regular" # "regular" | "fullscreen" render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit @@ -620,6 +623,8 @@ The `[workspace]` table groups project-level workspace settings: additional_dir = ["/absolute/path/to/shared"] ``` +`.pythinker-code/local.toml` is gated by workspace trust: it takes effect only after you trust the project folder in the startup trust prompt, and its `additional_dir` entries are ignored while the workspace is untrusted. Entries that resolve to your home directory or the filesystem root are rejected. + Because directories are stored as absolute paths, which are specific to your machine, we recommend adding `.pythinker-code/local.toml` to your project's `.gitignore` so it is not committed. ## Next steps diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 5a3785724..bebad2470 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -139,6 +139,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_IDENTITY_NAME` | Display name the agent calls itself in the system prompt; takes higher priority than `[identity] name` in `config.toml` and is never written back to it | Any non-empty string; blank values read as unset | | `PYTHINKER_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `PYTHINKER_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Pythinker Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `PYTHINKER_CODE_REPEAT_BREAKER` | Whether repeating the same tool call many times in a row injects reminders and eventually force-stops the turn. Unset keeps this on | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off`; any other value is ignored | | `PYTHINKER_CODE_TUI_FULL_SCREEN` | Control the fullscreen TUI with a fixed prompt dock, scrollable transcript, mouse text selection, clickable links, transcript search, and a clickable jump-to-bottom control. Fullscreen is enabled by default | `0` restores the legacy inline UI; unset or any other value keeps fullscreen enabled | | `PYTHINKER_CODE_DANGEROUS_COMMAND_GUARD` | Override [`[permission].dangerous_command_guard`](./config-files.md#permission). The guard asks before dangerous or unanalyzable `Bash` commands in interactive modes and blocks them in Auto mode | `true` or `false`; default `true` | | `PYTHINKER_CODE_PERMISSION_MODE_REMINDER` | Stop injecting the automatic permission-mode reminder (the context note that explains Auto mode) into the model context; set to a false value or an empty value to disable | Truthy keeps the reminder enabled; falsy: `0`/`false`/`no`/`off` disables it; an empty value disables it | diff --git a/docs/reference/server-api.md b/docs/reference/server-api.md index 93ec80464..8274cc832 100644 --- a/docs/reference/server-api.md +++ b/docs/reference/server-api.md @@ -304,6 +304,7 @@ On success, `data` is the config object; its fields mirror the top-level domains | `secondary_model` | object | Secondary model pool for subagents | | `experimental` | object | Experimental flag id → enabled | | `telemetry` | boolean | Whether anonymous telemetry is enabled | +| `auto_session_title` | boolean | Whether clients may automatically generate session titles | | `raw` | object | Raw parsed `config.toml` content, unmodeled fields included | #### `POST /api/v1/config` @@ -336,6 +337,7 @@ The body is a partial config object — any subset of the response domains above | `secondary_model` | body | object | Secondary model pool for subagents | | `experimental` | body | object | Experimental flag id → enabled | | `telemetry` | body | boolean | Whether anonymous telemetry is enabled | +| `auto_session_title` | body | boolean | Whether clients may automatically generate session titles | On success, `data` is the full updated config in the same shape as `GET /api/v1/config`. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 163127fa8..8a0e612e9 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -924,6 +924,18 @@ export interface AgentStateSnapshot { detail?: unknown; }>; readonly note?: string; + readonly usage?: /* TokenUsage — packages/agent-core-v2/src/human/llm/usage.ts */ { + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; + raw?: Record; + }; + readonly llmTiming?: /* ContextMessageTiming — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + }; + readonly durationMs?: number; })[]; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index a8f68b195..d690ec53d 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -12,7 +12,7 @@ // type syntax; when a named type is expanded inline, its name appears as a doc // comment (`/** ContextMessage */`). Bare type names (ContentPart, // ContextMessage, …) refer to the real types in src/ — they are intentionally -// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl) +// not resolved here. On disk (wire.jsonl) // the journal opens with a metadata line {"type": "metadata", // "protocol_version", "created_at"}; each record is {"type", ...payload, // "time"} — object payloads spread at the top level. @@ -24,7 +24,7 @@ // cross-reducers), blobs (the folding states whose blob codec offloads inline // media to blob storage), owner (the source file declaring the class). -// Index (59 record types) +// Index (64 record types) // config.update profile src/agent/profile/profileOps.ts // context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts // context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts @@ -63,6 +63,11 @@ // prompt.completed (none) src/agent/prompt/promptEvents.ts // prompt.steered (none) src/agent/prompt/promptEvents.ts // runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// subagent.cancelled (none) src/session/subagent/mirrorAgentRun.ts +// subagent.completed (none) src/session/subagent/mirrorAgentRun.ts +// subagent.failed (none) src/session/subagent/mirrorAgentRun.ts +// subagent.spawned (none) src/session/subagent/mirrorAgentRun.ts +// subagent.started (none) src/session/subagent/mirrorAgentRun.ts // task.started task src/agent/task/taskOps.ts // task.terminated task src/agent/task/taskOps.ts // task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts @@ -139,6 +144,12 @@ interface ContextAppendMessagePayload { isError?: boolean; toolCallDisplays?: Record; note?: string; + usage?: TokenUsage; + llmTiming?: { + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }; + durationMs?: number; }; } @@ -590,6 +601,66 @@ interface RuntimeSetBindingPayload { runtimeId: string; } +/** + * states: (none) + * owner: src/session/subagent/mirrorAgentRun.ts + */ +interface SubagentCancelledPayload { + _name: 'subagent.cancelled'; + subagentId: string; +} + +/** + * states: (none) + * owner: src/session/subagent/mirrorAgentRun.ts + */ +interface SubagentCompletedPayload { + _name: 'subagent.completed'; + subagentId: string; + resultSummary: string; + usage?: TokenUsage; + contextTokens?: number; +} + +/** + * states: (none) + * owner: src/session/subagent/mirrorAgentRun.ts + */ +interface SubagentFailedPayload { + _name: 'subagent.failed'; + subagentId: string; + error: string; +} + +/** + * states: (none) + * owner: src/session/subagent/mirrorAgentRun.ts + */ +interface SubagentSpawnedPayload { + _name: 'subagent.spawned'; + subagentId: string; + subagentName: string; + parentToolCallId: string; + parentToolCallUuid?: string; + parentAgentId?: string; + callerAgentId?: string; + description?: string; + dynamicWorkflowIndex?: number; + runInBackground: boolean; + model?: string; + thinkingEffort?: string; + taskId?: string; +} + +/** + * states: (none) + * owner: src/session/subagent/mirrorAgentRun.ts + */ +interface SubagentStartedPayload { + _name: 'subagent.started'; + subagentId: string; +} + /** * states: task * owner: src/agent/task/taskOps.ts @@ -927,6 +998,11 @@ interface WirePayloadMap { "prompt.completed": PromptCompletedPayload; "prompt.steered": PromptSteeredPayload; "runtime.set_binding": RuntimeSetBindingPayload; + "subagent.cancelled": SubagentCancelledPayload; + "subagent.completed": SubagentCompletedPayload; + "subagent.failed": SubagentFailedPayload; + "subagent.spawned": SubagentSpawnedPayload; + "subagent.started": SubagentStartedPayload; "task.started": TaskStartedPayload; "task.terminated": TaskTerminatedPayload; "task.waitDelivered": TaskWaitDeliveredPayload; diff --git a/packages/agent-core-v2/scripts/gen-wire-manifest.mts b/packages/agent-core-v2/scripts/gen-wire-manifest.mts index 7d7974d73..5563e594d 100644 --- a/packages/agent-core-v2/scripts/gen-wire-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-wire-manifest.mts @@ -205,7 +205,6 @@ type SketchDict = { [key: string]: Sketch }; type Sketch = string | SketchDict | [Sketch]; const TYPE_KEY = '_type'; -const MORE_KEY = '…'; function stringifySketch(sketch: Sketch): string { if (typeof sketch === 'string') return sketch; @@ -318,10 +317,6 @@ function renderTsType(sketch: Sketch, indent: string): { doc?: string; lines: st function emitTsDict(lines: string[], dict: SketchDict, indent: string): void { for (const [key, sketch] of Object.entries(dict)) { - if (key === MORE_KEY) { - lines.push(`${indent}// …`); - continue; - } if (key === TYPE_KEY) continue; if (key.startsWith('...')) { lines.push(`${indent}// spread: ${key}`); @@ -575,13 +570,7 @@ function renderTsFields( depth: number, ): SketchDict { const dict: SketchDict = {}; - let count = 0; for (const [name, f] of fields) { - if (count >= 8) { - dict[MORE_KEY] = '…'; - break; - } - count += 1; dict[`${name}${f.optional ? '?' : ''}`] = summarizeTsTypeExpr( f.type, file, @@ -961,7 +950,7 @@ export async function buildWireManifest(): Promise { '// type syntax; when a named type is expanded inline, its name appears as a doc', '// comment (`/** ContextMessage */`). Bare type names (ContentPart,', '// ContextMessage, …) refer to the real types in src/ — they are intentionally', - '// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl)', + '// not resolved here. On disk (wire.jsonl)', '// the journal opens with a metadata line {"type": "metadata",', '// "protocol_version", "created_at"}; each record is {"type", ...payload,', '// "time"} — object payloads spread at the top level.', diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 5f7fd4cee..db75f8055 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,4 +1,5 @@ import { type ContentPart, type ToolCall } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; import type { WireRecord } from '#/wire/record'; import { @@ -8,7 +9,7 @@ import { } from './compactionHandoff'; import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; import { createLoopEventFold, type LoopRecordedEvent } from './loopEventFold'; -import type { ContextMessage } from './types'; +import type { ContextMessage, ContextMessageTiming } from './types'; export interface ContextTranscript { readonly entries: readonly ContextMessage[]; @@ -30,6 +31,8 @@ interface MutableMessage { isError?: boolean; note?: string; origin?: ContextMessage['origin']; + usage?: TokenUsage; + llmTiming?: ContextMessageTiming; } interface MutableEntry { @@ -73,7 +76,11 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { transcript.splice(index, 1); foldedLength = Math.max(0, foldedLength - 1); }, - sealOpenAssistant: () => { + sealOpenAssistant: (meta) => { + if (openEntry !== undefined) { + openEntry.message.usage = meta?.usage; + openEntry.message.llmTiming = meta?.llmTiming; + } openEntry = undefined; }, pushToolMessage: (message, time) => { @@ -175,6 +182,8 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), ...(message.isError !== undefined ? { isError: message.isError } : {}), ...(message.origin !== undefined ? { origin: message.origin } : {}), + usage: message.usage, + llmTiming: message.llmTiming, }, time, }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index ef34fa92a..f05f9a438 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -6,12 +6,18 @@ import type { ContentPart, ToolCall } from '#human/llm/message'; import type { TokenUsage } from '#human/llm/usage'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import type { ContextMessage } from './types'; +import type { ContextMessage, ContextMessageTiming } from './types'; +import { shouldRenderWallTime } from './toolResultRender'; import { isVacuousContentPart } from './vacuousContent'; const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; +export interface AssistantSealMeta { + readonly usage?: TokenUsage; + readonly llmTiming?: ContextMessageTiming; +} + export type LoopRecordedEvent = | { readonly type: 'step.begin'; @@ -64,6 +70,7 @@ export type LoopRecordedEvent = readonly output: string | readonly ContentPart[]; readonly isError?: boolean; readonly note?: string; + readonly durationMs?: number; }; readonly parentUuid?: string; }; @@ -73,7 +80,7 @@ export interface LoopEventFoldSink { appendOpenContent(part: ContentPart): void; appendOpenToolCall(call: ToolCall, display?: ToolInputDisplay): void; dropOpenAssistant(): void; - sealOpenAssistant(): void; + sealOpenAssistant(meta?: AssistantSealMeta): void; pushToolMessage(message: ContextMessage, time: number | undefined): void; pushMessage(message: ContextMessage, time: number | undefined): void; } @@ -92,7 +99,7 @@ export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { interface InitialFoldState { readonly openHasToolCalls: boolean; readonly openVacuous: boolean; - readonly pendingToolCallIds: readonly string[]; + readonly pendingToolCalls: readonly { readonly id: string; readonly name: string }[]; } function createLoopEventFoldWithState( @@ -102,7 +109,9 @@ function createLoopEventFoldWithState( let openStepUuid: string | null | undefined = initial === undefined ? undefined : null; let openHasToolCalls = initial?.openHasToolCalls ?? false; let openVacuous = initial?.openVacuous ?? true; - const pending = new Set(initial?.pendingToolCallIds); + const pending = new Map( + initial?.pendingToolCalls.map((call) => [call.id, call.name]), + ); let deferred: { message: ContextMessage; time: number | undefined }[] = []; const flushDeferred = (): void => { @@ -112,19 +121,19 @@ function createLoopEventFoldWithState( }; const closePending = (time: number | undefined): void => { if (pending.size === 0) return; - for (const toolCallId of pending) { + for (const toolCallId of pending.keys()) { sink.pushToolMessage(interruptedToolMessage(toolCallId), time); } pending.clear(); flushDeferred(); }; - const settleOpen = (time: number | undefined): void => { + const settleOpen = (time: number | undefined, meta?: AssistantSealMeta): void => { if (openStepUuid === undefined) return; closePending(time); if (!openHasToolCalls && openVacuous) { sink.dropOpenAssistant(); } else { - sink.sealOpenAssistant(); + sink.sealOpenAssistant(meta); } openStepUuid = undefined; }; @@ -157,7 +166,7 @@ function createLoopEventFoldWithState( } case 'step.end': { if (event.finishReason === 'interrupted' || event.finishReason === 'error') return; - settleOpen(time); + settleOpen(time, stepEndSealMeta(event)); flushDeferred(); return; } @@ -177,12 +186,13 @@ function createLoopEventFoldWithState( ...(event.extras !== undefined ? { extras: event.extras } : {}), }; sink.appendOpenToolCall(call, event.display); - pending.add(event.toolCallId); + pending.set(event.toolCallId, event.name); openHasToolCalls = true; return; } case 'tool.result': { if (!pending.has(event.toolCallId)) return; + const toolName = pending.get(event.toolCallId)!; pending.delete(event.toolCallId); const output = event.result.output; sink.pushToolMessage( @@ -193,6 +203,7 @@ function createLoopEventFoldWithState( ), isError: event.result.isError, note: event.result.note, + durationMs: shouldRenderWallTime(toolName) ? event.result.durationMs : undefined, }, time, ); @@ -307,8 +318,13 @@ function createImmutableFoldSink(initial: readonly ContextMessage[]): ImmutableF current = Object.freeze([...current.slice(0, openIndex), ...current.slice(openIndex + 1)]); openIndex = -1; }, - sealOpenAssistant: () => { - updateOpen((message) => ({ ...message, partial: undefined })); + sealOpenAssistant: (meta) => { + updateOpen((message) => ({ + ...message, + usage: meta?.usage, + llmTiming: meta?.llmTiming, + partial: undefined, + })); openIndex = -1; }, pushToolMessage: (message) => { @@ -341,9 +357,9 @@ function recoverFoldState(state: readonly ContextMessage[]): InitialFoldState | return { openHasToolCalls: open.toolCalls.length > 0, openVacuous: open.content.every(isVacuousContentPart), - pendingToolCallIds: open.toolCalls - .map((call) => call.id) - .filter((toolCallId) => !resolvedToolCallIds.has(toolCallId)), + pendingToolCalls: open.toolCalls + .filter((call) => !resolvedToolCallIds.has(call.id)) + .map((call) => ({ id: call.id, name: call.name })), }; } @@ -353,3 +369,15 @@ function interruptedToolMessage(toolCallId: string): ContextMessage { isError: true, }; } + +function stepEndSealMeta( + event: Extract, +): AssistantSealMeta | undefined { + const timing: ContextMessageTiming = { + llmFirstTokenLatencyMs: event.llmFirstTokenLatencyMs, + llmStreamDurationMs: event.llmStreamDurationMs, + }; + const hasTiming = Object.values(timing).some((value) => value !== undefined); + if (event.usage === undefined && !hasTiming) return undefined; + return { usage: event.usage, llmTiming: hasTiming ? timing : undefined }; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts index c7bad66c0..349ea9d04 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts @@ -1,5 +1,7 @@ import type { ContentPart } from '#human/llm/message'; +import { isMcpToolName } from '#/tool/toolContract'; + const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; const TOOL_EMPTY_STATUS = 'Tool output is empty.'; const TOOL_EMPTY_ERROR_STATUS = @@ -10,10 +12,34 @@ export interface RenderableToolResult { readonly output: string | readonly ContentPart[]; readonly note?: string; readonly isError?: boolean; + readonly durationMs?: number; +} + +const WALL_TIME_TOOL_NAMES: ReadonlySet = new Set([ + 'Agent', + 'AgentDynamicWorkflow', + 'Bash', + 'FetchURL', + 'Glob', + 'Grep', + 'WebSearch', +]); + +export function shouldRenderWallTime(toolName: string): boolean { + return WALL_TIME_TOOL_NAMES.has(toolName) || isMcpToolName(toolName); } export function renderToolResultForModel(result: RenderableToolResult): ContentPart[] { const rendered = renderStatus(result); + if (result.durationMs !== undefined) { + const header = `Wall time: ${(result.durationMs / 1000).toFixed(3)} seconds`; + const first = rendered[0]; + if (first?.type === 'text') { + rendered.splice(0, 1, textPart(`${header}\n${first.text}`)); + } else { + rendered.unshift(textPart(header)); + } + } if (result.note === undefined || result.note.length === 0) return rendered; const only = rendered[0]; if (rendered.length === 1 && only?.type === 'text') { diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index b7e505f26..164e8b187 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -1,5 +1,6 @@ import type { Message } from '#/llm-adapter/contract/message'; import type { ContentPart } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import type { AgentTaskStatus } from '#/agent/task/task'; @@ -124,6 +125,11 @@ export type PromptOrigin = | HookResultOrigin | RetryOrigin; +export interface ContextMessageTiming { + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; +} + export type ContextMessage = Message & { readonly id?: string; readonly providerMessageId?: string; @@ -131,6 +137,9 @@ export type ContextMessage = Message & { readonly isError?: boolean; toolCallDisplays?: Record; readonly note?: string; + readonly usage?: TokenUsage; + readonly llmTiming?: ContextMessageTiming; + readonly durationMs?: number; }; export interface UserMessageRecord { diff --git a/packages/agent-core-v2/src/agent/contextProjector/projection.ts b/packages/agent-core-v2/src/agent/contextProjector/projection.ts index 8e837f82d..7158c6326 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/projection.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/projection.ts @@ -343,6 +343,7 @@ function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): Conten output: outputFromToolContent(source.content), isError: source.isError, note: source.note, + durationMs: source.durationMs, }) : source.content; return cleanContent(source, content, onAnomaly); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 99de89482..daeb09775 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -42,6 +42,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IFileService } from '#/app/file/fileService'; +import { IPluginService } from '#/app/plugin/plugin'; import type { TurnEndedEvent as TurnEndedTelemetryEvent, TurnInterruptedEvent, @@ -166,6 +167,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { @IWireService private readonly wire: IWireService, @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IPluginService private readonly plugins: IPluginService, ) { super(); this.states.contributeState(turnKey); @@ -219,7 +221,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.activeRequestTrace = trace; }, onEvent: (event) => this.projectMachineEvent(event), - onToolResult: (toolCallId, result) => this.appendMachineToolResult(toolCallId, result), + onToolResult: (toolCallId, result, durationMs) => + this.appendMachineToolResult(toolCallId, result, durationMs), }; } @@ -1213,6 +1216,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { mode: active.mode ?? 'agent', provider_type, protocol, + enabled_plugins: this.plugins.enabledPluginIds()?.join(','), }; this.telemetry.track2('turn_started', started); return active; @@ -1667,7 +1671,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { }, })) { if (result.toolCallId === toolCallId) { - this.appendMachineToolResult(toolCallId, result.result); + this.appendMachineToolResult(toolCallId, result.result, result.durationMs); } } } catch (error) { @@ -1701,6 +1705,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { readonly stopTurn?: boolean; readonly stopTurnReason?: string; }, + durationMs?: number, ): void { const turn = this.active; const step = turn?.current; @@ -1709,7 +1714,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { type: 'tool.result', parentUuid: step.toolCallUuids.get(toolCallId) ?? randomUUID(), toolCallId, - result: { output: result.output, isError: result.isError, note: result.note }, + result: { output: result.output, isError: result.isError, note: result.note, durationMs }, }); step.resolvedToolIds.add(toolCallId); if (result.stopTurn === true) { @@ -2087,6 +2092,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { provider_type: turn.providerType, protocol: turn.protocol, trace_id: traceId, + enabled_plugins: this.plugins.enabledPluginIds()?.join(','), }; this.telemetry.track2('turn_ended', ended); this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); diff --git a/packages/agent-core-v2/src/agent/loop/machine/engine.ts b/packages/agent-core-v2/src/agent/loop/machine/engine.ts index 9a8e98d53..be312fc0c 100644 --- a/packages/agent-core-v2/src/agent/loop/machine/engine.ts +++ b/packages/agent-core-v2/src/agent/loop/machine/engine.ts @@ -133,7 +133,7 @@ export interface CreateMachineEngineOptions { readonly promptGate?: PromptGate; readonly onTrace?: (trace: LLMRequestTrace) => void; readonly onEvent?: (event: MachineEngineEvent) => void; - readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult, durationMs: number) => void; } export interface MachineEngineRetrySnapshot { diff --git a/packages/agent-core-v2/src/agent/loop/machine/tools.ts b/packages/agent-core-v2/src/agent/loop/machine/tools.ts index c05ad3e40..d94ae04d7 100644 --- a/packages/agent-core-v2/src/agent/loop/machine/tools.ts +++ b/packages/agent-core-v2/src/agent/loop/machine/tools.ts @@ -37,7 +37,7 @@ export interface CreateMachineToolsOptions { readonly steerSignal?: () => AbortSignal | undefined; readonly trace?: () => LLMRequestTrace | undefined; readonly onToolCall?: (payload: ToolCallStartedPayload) => void; - readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult, durationMs: number) => void; readonly onBatchError?: (error: unknown) => void; } @@ -167,7 +167,7 @@ export function createMachineTools(options: CreateMachineToolsOptions): MachineT const applyResult = (entry: PendingEntry, matched: ToolExecutionResult): void => { const id = entry.input.toolCall.id; const { result } = matched; - options.onToolResult?.(id, result); + options.onToolResult?.(id, result, matched.durationMs); extras.set(id, { stopTurn: result.stopTurn, stopTurnReason: result.stopTurnReason, 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 b48d71cf7..bc12b9a92 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 { isWithinWorkspace } from '#/tool/path-access'; +import { isProjectLocalConfigPath, 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,6 +35,9 @@ 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/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 9d9700b65..01cefa35d 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -36,6 +36,7 @@ import { type AgentTaskSettlement, } from './types'; import { renderNotificationXml } from './notificationXml'; +import { formatTaskWallTime } from './wallTime'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IConfigService } from '#/app/config/config'; @@ -1540,11 +1541,12 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { : info.stopReason ? `${info.description} ${info.status === 'killed' ? 'was stopped' : info.status}. Reason: ${info.stopReason}` : `${info.description} ${info.status}.`; + const timed = `Wall time: ${formatTaskWallTime(info)}\n${baseLine}`; - if (info.kind !== 'agent') return baseLine; - if (info.status === 'completed') return baseLine; + if (info.kind !== 'agent') return timed; + if (info.status === 'completed') return timed; const agentId = info.agentId; - if (agentId === undefined || agentId === info.taskId) return baseLine; + if (agentId === undefined || agentId === info.taskId) return timed; const recovery = [ '', @@ -1554,7 +1556,7 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { 'The subagent retains its full prior context across the restart, but any in-flight tool call lost its result and may need to be redone.', ].join('\n'); - return `${baseLine}${recovery}`; + return `${timed}${recovery}`; } function buildAgentTaskNotification( diff --git a/packages/agent-core-v2/src/agent/task/tools/format.ts b/packages/agent-core-v2/src/agent/task/tools/format.ts index 0f14bc127..f55cd5143 100644 --- a/packages/agent-core-v2/src/agent/task/tools/format.ts +++ b/packages/agent-core-v2/src/agent/task/tools/format.ts @@ -1,3 +1,5 @@ +import { formatTaskWallTime } from '#/agent/task/wallTime'; + function formatValue(value: unknown): string { return typeof value === 'string' ? value : String(value); } @@ -12,3 +14,12 @@ export function formatPlainObject(record: object): string { .map(([key, value]) => `${fieldName(key)}: ${formatValue(value)}`) .join('\n'); } + +export function formatTaskRecord( + record: T, +): string { + const { startedAt: _startedAt, endedAt: _endedAt, ...rest } = record; + const body = formatPlainObject(rest); + const wallTime = `Wall time: ${formatTaskWallTime(record)}`; + return body.length === 0 ? wallTime : `${wallTime}\n${body}`; +} diff --git a/packages/agent-core-v2/src/agent/task/wallTime.ts b/packages/agent-core-v2/src/agent/task/wallTime.ts new file mode 100644 index 000000000..7c7a54f85 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/wallTime.ts @@ -0,0 +1,9 @@ +export interface AgentTaskTiming { + readonly startedAt: number; + readonly endedAt: number | null; +} + +export function formatTaskWallTime(task: AgentTaskTiming): string { + const durationMs = Math.max(0, (task.endedAt ?? Date.now()) - task.startedAt); + return `${(durationMs / 1000).toFixed(3)} seconds`; +} diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 4acf2a3b2..9f4239e28 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -13,7 +13,9 @@ import type { } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { parseBooleanEnv } from '#/_base/utils/env'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; @@ -27,6 +29,8 @@ import { type ToolDedupeResult, } from './toolDedupe'; +export const REPEAT_BREAKER_ENV = 'PYTHINKER_CODE_REPEAT_BREAKER'; + const REMINDER_TEXT_1 = '\n\n' + wrapSystemReminder( @@ -185,15 +189,18 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS private readonly stepDeferreds = new Map>(); private readonly handoffVetoedCallIds = new Set(); private forceStoppedInStep = false; + private readonly repeatBreakerEnabled: boolean; constructor( @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IAgentStateService private readonly states: IAgentStateService, + @IBootstrapService bootstrap: IBootstrapService, @IEventBus eventBus: IEventBus, ) { super(); + this.repeatBreakerEnabled = parseBooleanEnv(bootstrap.getEnv(REPEAT_BREAKER_ENV)) !== false; this.states.contributeState(toolDedupeStepCallsKey); this.states.contributeState(toolDedupeOriginalCallIndexKey); this.states.contributeState(toolDedupeSyntheticCallIdsKey); @@ -527,19 +534,21 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS let finalResult = result; let action: 'none' | 'r1' | 'r2' | 'r3' | 'stop' = 'none'; - if (streak >= REPEAT_FORCE_STOP_STREAK) { - finalResult = forceStopResult(result, REMINDER_TEXT_3); - action = 'stop'; - this.forceStoppedInStep = true; - } else if (streak >= REPEAT_REMINDER_3_START) { - finalResult = appendReminder(result, REMINDER_TEXT_3); - action = 'r3'; - } else if (streak >= REPEAT_REMINDER_2_START) { - finalResult = appendReminder(result, makeReminderText2(streak)); - action = 'r2'; - } else if (streak >= REPEAT_REMINDER_1_START) { - finalResult = appendReminder(result, REMINDER_TEXT_1); - action = 'r1'; + if (this.repeatBreakerEnabled) { + if (streak >= REPEAT_FORCE_STOP_STREAK) { + finalResult = forceStopResult(result, REMINDER_TEXT_3); + action = 'stop'; + this.forceStoppedInStep = true; + } else if (streak >= REPEAT_REMINDER_3_START) { + finalResult = appendReminder(result, REMINDER_TEXT_3); + action = 'r3'; + } else if (streak >= REPEAT_REMINDER_2_START) { + finalResult = appendReminder(result, makeReminderText2(streak)); + action = 'r2'; + } else if (streak >= REPEAT_REMINDER_1_START) { + finalResult = appendReminder(result, REMINDER_TEXT_1); + action = 'r1'; + } } if (streak >= 2) { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index c1cf3349d..5be15344f 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -32,6 +32,7 @@ export interface ToolExecutionResult { readonly toolCallId: string; readonly toolName: string; readonly result: ToolResult; + readonly durationMs: number; } export type MissingToolDescriber = (toolName: string) => string | undefined; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index d3b6a0d71..67dea1841 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -311,6 +311,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCallId: call.toolCall.id, toolName: call.toolName, result: finalized, + durationMs: timedResult.durationMs, }; } diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts index 3c16c69f3..2da90eecb 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -15,6 +15,7 @@ import { IAgentToolPolicyService } from './toolPolicy'; export class AgentToolPolicyService extends Disposable implements IAgentToolPolicyService { declare readonly _serviceBrand: undefined; + private globalPolicy: ToolsConfig | undefined; constructor( @IAgentProfileService private readonly profile: IAgentProfileService, @@ -24,6 +25,13 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); + this.globalPolicy = this.config.get(TOOLS_SECTION); + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain !== TOOLS_SECTION) return; + this.globalPolicy = this.config.get(TOOLS_SECTION); + }), + ); this._register( toolExecutor.registerToolCallGuard(({ name, source }) => { const active = @@ -55,7 +63,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli { workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile: { disallowedTools: profile.disallowedTools }, - global: this.config.get(TOOLS_SECTION), + global: this.globalPolicy, sessionDisabledTools: this.sessionToolPolicy.disabledTools(), }, name, @@ -72,7 +80,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli { workspaceDisabledTools: this.toolPolicyGate.disabledTools, profile, - global: this.config.get(TOOLS_SECTION), + global: this.globalPolicy, sessionDisabledTools: this.sessionToolPolicy.disabledTools(), }, name, 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 a3ef1f35c..7181f52b5 100644 --- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -2,6 +2,7 @@ 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'; @@ -77,6 +78,10 @@ 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 7be6dda07..16c5e3c66 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,6 +31,7 @@ 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'; @@ -126,6 +127,10 @@ 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 174817c6e..f85f8b4c4 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,6 +23,7 @@ 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 { @@ -112,6 +113,10 @@ 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 ca6562dc8..4c33d7c92 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,6 +22,7 @@ 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'; @@ -238,6 +239,10 @@ 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 2423b37ce..dd1725007 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,6 +17,7 @@ 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'; @@ -73,6 +74,10 @@ 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 fff7ef5ba..49ae9ed09 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,6 +17,7 @@ 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, @@ -260,6 +261,10 @@ 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/tools/task/task-list/taskListTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts index fd2a6e841..aafb7bd9f 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts @@ -5,7 +5,7 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { IAgentTaskService } from '#/agent/task/task'; import type { AgentTaskInfo } from '#/agent/task/task'; -import { formatPlainObject } from '#/agent/task/tools/format'; +import { formatTaskRecord } from '#/agent/task/tools/format'; import { ITaskListTool, TaskListInputSchema, type TaskListInput } from './task-list'; import TASK_LIST_DESCRIPTION from './task-list.md?raw'; @@ -13,7 +13,7 @@ export function formatTaskList(tasks: readonly AgentTaskInfo[], activeOnly: bool const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; const header = `${label}: ${String(tasks.length)}`; if (tasks.length === 0) return `${header}\nNo background tasks found.`; - return `${header}\n${tasks.map((task) => formatPlainObject(task)).join('\n---\n')}`; + return `${header}\n${tasks.map((task) => formatTaskRecord(task)).join('\n---\n')}`; } export class TaskListTool implements ITaskListTool { diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts index ebf691ff4..0421444f9 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts @@ -9,7 +9,7 @@ import type { AgentTaskOutputSnapshot, } from '#/agent/task/task'; import { type AgentTaskStatus, TERMINAL_STATUSES } from '#/agent/task/types'; -import { formatPlainObject } from '#/agent/task/tools/format'; +import { formatTaskRecord } from '#/agent/task/tools/format'; import { ITaskOutputTool, TaskOutputInputSchema, type TaskOutputInput } from './task-output'; import TASK_OUTPUT_DESCRIPTION from './task-output.md?raw'; @@ -72,7 +72,7 @@ export class TaskOutputTool implements ITaskOutputTool { const output = await this.tasks.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); const lines = [ - formatPlainObject({ + formatTaskRecord({ retrievalStatus: retrievalStatus(current.status), ...current, outputPath: output.outputPath, diff --git a/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts index 40f873c65..fe474384a 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts @@ -5,6 +5,7 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { IAgentTaskService } from '#/agent/task/task'; import { TERMINAL_STATUSES } from '#/agent/task/types'; +import { formatTaskWallTime } from '#/agent/task/wallTime'; import { ITaskStopTool, TaskStopInputSchema, type TaskStopInput } from './task-stop'; import TASK_STOP_DESCRIPTION from './task-stop.md?raw'; @@ -36,6 +37,7 @@ export class TaskStopTool implements ITaskStopTool { if (TERMINAL_STATUSES.has(info.status)) { return { output: + `Wall time: ${formatTaskWallTime(info)}\n` + `task_id: ${info.taskId}\n` + `status: ${info.status}\n` + `reason: ${terminalStopReason(info.stopReason)}`, @@ -51,6 +53,7 @@ export class TaskStopTool implements ITaskStopTool { return { output: + `Wall time: ${formatTaskWallTime(result)}\n` + `task_id: ${result.taskId}\n` + `status: ${result.status}\n` + `reason: ${result.stopReason ?? reason}`, diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 9856173d3..81d570d91 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -11,7 +11,7 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { IAgentTaskService } from '#/agent/task/task'; import type { AgentTaskInfo, AgentTaskOutputSnapshot } from '#/agent/task/task'; import { TERMINAL_STATUSES } from '#/agent/task/types'; -import { formatPlainObject } from '#/agent/task/tools/format'; +import { formatPlainObject, formatTaskRecord } from '#/agent/task/tools/format'; import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool'; import { IFlagService } from '#/app/flag/flag'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -303,7 +303,7 @@ export class WaitForTool implements IWaitForTool { lines.push( '', '[completed_during_wait]', - extras.map((extra) => formatPlainObject(extra)).join('\n---\n'), + extras.map((extra) => formatTaskRecord(extra)).join('\n---\n'), 'Use TaskOutput with one of the task_id values above to read the full output.', ); } @@ -317,7 +317,7 @@ export class WaitForTool implements IWaitForTool { private async formatFinishedTask(info: AgentTaskInfo): Promise { const output = await this.tasks.getOutputSnapshot(info.taskId, OUTPUT_PREVIEW_BYTES); const lines = [ - formatPlainObject({ + formatTaskRecord({ ...info, outputPath: output.outputPath, terminalReason: terminalReason(info), diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index aef8a3a4e..d56b3dbed 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -1,4 +1,5 @@ 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'; @@ -7,6 +8,7 @@ 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 57432c803..4c3905a44 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -138,6 +138,38 @@ export function applySectionEnv( return target; } +function collectEnvNames(bindings: AnyEnvBindings, into: string[]): void { + if (isEnvBinding(bindings)) { + if (typeof bindings === 'string') { + into.push(bindings); + return; + } + into.push(bindings.env); + if (bindings.deprecatedEnv !== undefined) into.push(bindings.deprecatedEnv); + return; + } + for (const child of Object.values(bindings)) { + if (child !== undefined) collectEnvNames(child, into); + } +} + +function sameEnvValues( + a: readonly (string | undefined)[], + b: readonly (string | undefined)[], +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +interface EnvSectionResolution { + readonly base: unknown; + readonly envValues: readonly (string | undefined)[]; + readonly value: unknown; +} + function isSameSection( existing: ConfigSection, schema: ConfigSchema, @@ -319,6 +351,8 @@ export class ConfigService extends Disposable implements IConfigService { private lastDiagnosticsSnapshot = '[]'; private readonly configKey: string; private tainted = false; + private readonly envNamesBySection = new WeakMap(); + private readonly envSectionResolutions = new WeakMap(); constructor( @IConfigRegistry private readonly registry: IConfigRegistry, @@ -677,12 +711,32 @@ export class ConfigService extends Disposable implements IConfigService { return validated; } + private sectionEnvNames(section: ConfigSection): readonly string[] { + const cached = this.envNamesBySection.get(section); + if (cached !== undefined) return cached; + const names: string[] = []; + if (section.env !== undefined) collectEnvNames(section.env, names); + this.envNamesBySection.set(section, names); + return names; + } + private applySectionEnvBindings(effective: ResolvedConfig, reportErrors: boolean): void { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); for (const section of this.registry.listSections()) { if (section.env === undefined) continue; + const base = effective[section.domain]; + const envValues = this.sectionEnvNames(section).map(getEnv); + const resolved = this.envSectionResolutions.get(section); + if ( + !reportErrors && + resolved !== undefined && + resolved.base === base && + sameEnvValues(resolved.envValues, envValues) + ) { + effective[section.domain] = resolved.value; + continue; + } try { - const base = effective[section.domain]; const onDeprecatedEnv: OnDeprecatedEnv | undefined = reportErrors ? (oldName, newName) => { this.pushDiagnostic({ @@ -693,8 +747,11 @@ export class ConfigService extends Disposable implements IConfigService { } : undefined; const next = applySectionEnv(base, section.env, getEnv, onDeprecatedEnv); - effective[section.domain] = this.registry.validate(section.domain, next); + const value = this.registry.validate(section.domain, next); + effective[section.domain] = value; + this.envSectionResolutions.set(section, { base, envValues, value }); } catch (error) { + this.envSectionResolutions.delete(section); if (reportErrors) { this.pushDiagnostic({ domain: section.domain, diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index c4291df5d..1dc81eff9 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -53,12 +53,24 @@ 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 512b5c4d9..7ff37b028 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -1,6 +1,13 @@ -import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git'; +import type { + FsDiffResponse, + FsGitStatusResponse, + FsPullRequest, + RunGitOptions, + RunGitResult, +} 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'; @@ -11,6 +18,7 @@ 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; @@ -47,7 +55,11 @@ 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', '--numstat', 'HEAD', '--'], cwd); + const numstat = await this.runCommand( + 'git', + ['diff', '--no-color', ...GIT_DIFF_ARGS, '--numstat', 'HEAD', '--'], + cwd, + ); if (numstat.exitCode === 0) { const stats = parseNumstat(numstat.stdout); result.additions = stats.additions; @@ -79,7 +91,7 @@ export class GitService implements IGitService { if (untracked || !hasHead) { const res = await this.runCommand( 'git', - ['diff', '--no-color', '--no-index', '--', '/dev/null', relPath], + ['diff', '--no-color', ...GIT_DIFF_ARGS, '--no-index', '--', '/dev/null', relPath], cwd, ); if (res.exitCode !== 0 && res.exitCode !== 1) { @@ -87,7 +99,11 @@ export class GitService implements IGitService { } diffStdout = res.stdout; } else { - const res = await this.runCommand('git', ['diff', '--no-color', 'HEAD', '--', relPath], cwd); + const res = await this.runCommand( + 'git', + ['diff', '--no-color', ...GIT_DIFF_ARGS, 'HEAD', '--', relPath], + cwd, + ); if (res.exitCode !== 0) { throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`); } @@ -117,6 +133,30 @@ 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(); @@ -142,8 +182,20 @@ export class GitService implements IGitService { cmd: string, args: readonly string[], cwd: string, - options: RunOptions = {}, - ): Promise { + 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 { const workspaceId = this.resolveWorkspaceId(cwd); const lease = this.resolver.acquire({ workspaceId, runtimeId: 'local' }, ['process']); const spawned = await lease.runtime.process! @@ -211,17 +263,6 @@ 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 new file mode 100644 index 000000000..08e9838b1 --- /dev/null +++ b/packages/agent-core-v2/src/app/git/hardening.ts @@ -0,0 +1,237 @@ +import { readFile, realpath, stat } 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; + try { + const realGitPath = await realpath(gitDir); + if ((await stat(realGitPath)).isDirectory()) { + resolvedGitDir = realGitPath; + } else { + const pointer = parseGitDirPointer(await readFile(realGitPath, 'utf8')); + if (pointer === undefined) return true; + resolvedGitDir = resolve(dirname(realGitPath), pointer); + } + } catch { + return false; + } + const workTreeRoot = dirname(gitDir); + 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; + if (!(await stat(gitDir)).isDirectory()) { + const pointer = parseGitDirPointer(await readFile(gitDir, 'utf8')); + 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 stamps = await Promise.all(configPaths.map(stampConfigPath)); + for (const path of configPaths) { + const content = await readFile(path, 'utf8').catch(() => null); + if (content !== null && INCLUDE_SECTION_RE.test(content)) return null; + } + return stamps.join('|'); + } catch { + return null; + } +} + +async function findGitDir(start: string): Promise { + let dir = start; + for (;;) { + const candidate = join(dir, '.git'); + try { + await stat(candidate); + return candidate; + } catch { + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +async function stampConfigPath(path: string): Promise { + try { + const stats = await stat(path); + return `${path}:${String(stats.mtimeMs)}:${String(stats.size)}`; + } catch { + return `${path}:missing`; + } +} diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts index 7d362c9bd..5f99d1dd0 100644 --- a/packages/agent-core-v2/src/app/plugin/plugin.ts +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -61,6 +61,7 @@ export interface IPluginService { mcpServerEntries(): Promise; enabledHooks(): Promise; hasLoadedSnapshot(): boolean; + enabledPluginIds(): readonly string[] | undefined; readonly onDidReload: Event; readonly onDidMutate: Event; } diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 19595bbfe..e21fe3c80 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -4,6 +4,8 @@ import { Service } from '#/_base/di/service'; import { AsyncEmitter, Emitter, type Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { LifecycleScope } from '#/app/scopes'; +import type { PluginToggleEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; import type { SkillRoot } from '#/features/skill/catalog/types'; import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; @@ -20,6 +22,7 @@ import { type SetPluginEnabledInput, type SetPluginMcpServerEnabledInput, } from './plugin'; +import { normalizePluginId } from './types'; import type { EnabledPluginSessionStart, EnabledPluginSystemPrompt, @@ -66,6 +69,7 @@ export class PluginService extends Service implements IPluginService { @IBootstrapService bootstrap: IBootstrapService, @ISkillDiscovery discovery: ISkillDiscovery, @IProviderService private readonly providers: IProviderService, + @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); this.homeDir = bootstrap.homeDir; @@ -94,10 +98,17 @@ export class PluginService extends Service implements IPluginService { setPluginEnabled(input: SetPluginEnabledInput): Promise { return this.runNotifiedMutation(async () => { - await this.manager.setEnabled(input.id, input.enabled); + const id = normalizePluginId(input.id); + await this.manager.setEnabled(id, input.enabled); const notification = await this.reloadAndNotify({ - mutation: { kind: input.enabled ? 'enable' : 'disable', id: input.id }, + mutation: { kind: input.enabled ? 'enable' : 'disable', id }, }); + const event: PluginToggleEvent = { + plugin_id: id, + enabled: input.enabled, + enabled_plugins: this.enabledPluginIds()?.join(','), + }; + this.telemetry.track2('plugin_toggle', event); return { result: undefined, notification }; }); } @@ -221,6 +232,15 @@ export class PluginService extends Service implements IPluginService { return this.snapshotLoaded; } + enabledPluginIds(): readonly string[] | undefined { + if (!this.snapshotLoaded) return undefined; + return this.manager + .summaries() + .filter((plugin) => plugin.enabled && plugin.state === 'ok') + .map((plugin) => plugin.id) + .toSorted(); + } + private runSerializedOperation(operation: () => Promise): Promise { void this.startInitialLoad(); return this.enqueueMutation(async () => { diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts index 5a566760d..1f39e6114 100644 --- a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts +++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts @@ -1,14 +1,18 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -export interface ProjectAdditionalDirsLoadResult { +export interface ProjectAdditionalDirsLocation { 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/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index ceea08072..d428eccf5 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -58,6 +58,7 @@ export interface TurnStartedEvent { provider_type?: string; protocol?: string; thinking_effort?: string; + enabled_plugins?: string; } export interface TurnInterruptedEvent { @@ -81,6 +82,7 @@ export interface TurnEndedEvent { protocol?: string; thinking_effort?: string; trace_id?: string; + enabled_plugins?: string; } export interface PromptCacheProbeEvent { @@ -224,6 +226,12 @@ export interface RemoteControlToggleEvent { outcome: 'ok' | 'already_running' | 'rejected' | 'error'; } +export interface PluginToggleEvent { + plugin_id: string; + enabled: boolean; + enabled_plugins?: string; +} + export interface CompactionFinishedEvent { turn_id?: number; source: 'manual' | 'auto'; @@ -607,6 +615,8 @@ export const telemetryEventDefinitions = { provider_type: 'Provider protocol type', protocol: 'Request protocol', thinking_effort: 'Effective thinking effort the turn runs with', + enabled_plugins: + 'Comma-separated sorted ids of enabled, loaded plugins when the turn starts; empty string for a known empty set, absent when no plugin snapshot is available', }, }), turn_interrupted: defineAgentTelemetryEvent({ @@ -638,6 +648,8 @@ export const telemetryEventDefinitions = { thinking_effort: 'Effective thinking effort the turn ran with', trace_id: 'Trace id of the most recent LLM request in this turn; absent for non-Pythinker protocols', + enabled_plugins: + 'Comma-separated sorted ids of enabled, loaded plugins when the turn ends; empty string for a known empty set, absent when no plugin snapshot is available', }, }), prompt_cache_probe: defineAgentTelemetryEvent({ @@ -837,6 +849,16 @@ export const telemetryEventDefinitions = { outcome: 'How the request resolved', }, }), + plugin_toggle: defineTelemetryEvent({ + owner: 'pythinker-code', + comment: 'An installed plugin is enabled or disabled.', + properties: { + plugin_id: 'Id of the toggled plugin', + enabled: 'Whether the plugin is enabled after the toggle', + enabled_plugins: + 'Comma-separated sorted ids of enabled, loaded plugins after the toggle commits; empty string for a known empty set', + }, + }), compaction_finished: defineAgentTelemetryEvent({ owner: 'pythinker-code', comment: 'Context compaction completes.', diff --git a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts index 94fd9e7f3..0a0d477e2 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts @@ -164,8 +164,8 @@ export class TelemetryService for (const appender of this.appenders) { try { appender.track(record); - } catch (err) { - onUnexpectedError(err); + } catch (error) { + onUnexpectedError(error); } } } diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts index 4078263ce..d52e17741 100644 --- a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts @@ -38,15 +38,27 @@ interface DateChangeDiscloseEvent { readonly seed: DateDisclosure; } +let cachedLocalDateFormat: { readonly timeZone: string; readonly format: Intl.DateTimeFormat } | undefined; + +function localDateFormat(timeZone: string): Intl.DateTimeFormat { + if (cachedLocalDateFormat?.timeZone !== timeZone) { + cachedLocalDateFormat = { + timeZone, + format: new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }), + }; + } + return cachedLocalDateFormat.format; +} + function currentDateDisclosure(clock: IHostClock): Omit { const date = clock.now(); const timeZone = clock.timeZone(); - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(date); + const parts = localDateFormat(timeZone).formatToParts(date); const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find((candidate) => candidate.type === type)?.value ?? ''; return { diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index 041683be2..e4d1ec395 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -1,44 +1,38 @@ -Tower mode is active. You are the control tower for this repository — you plan missions, spawn worker and reviewer agents, route information, merge branches, and keep the human informed. You never write product code yourself. This supersedes any other instructions you have received. +Tower mode is active. You are the control tower for this repository — you plan missions, spawn worker and reviewer agents, route information, merge branches, and keep the human informed. This supersedes any other instructions you have received. Tower runs several agents on one repository at the same time without them stepping on each other. Three roles: - **The human** — owns the objective. May speak, launch, or redirect work **at any time**; nothing in this mode waits for the human. -- **The tower** — **you**, the main agent. Exactly one. You never write product code: you plan missions, spawn workers and reviewers, route information, merge branches, and keep the human informed. +- **The tower** — **you**, the main agent. Exactly one. You plan missions, spawn workers and reviewers, route information, merge branches, and keep the human informed. - **Workers and reviewers** — subagents you spawn with `TowerSpawn`. Each worker owns one mission in its own git worktree; reviewers audit branches. -**The protocol is enforced by tools, not by instructions.** All comms artifacts — inbox messages, findings, reviews, mission files, `MISSIONS.md`, the activity log — are produced by the `Tower*` tools. Workers and reviewers carry `TowerSend`, `TowerInbox`, `TowerFinding`, `TowerReview`, `TowerMission`, and `TowerStatus`; the tower additionally gets `TowerInit`, `TowerPlan`, `TowerSpawn`, `TowerMerge`, and `TowerTeardown`. File naming, frontmatter, recipient validity, review rounds, the merge gate, and the activity-log format are code. **Never create or edit files under `.tower/` by hand** (yours or via Bash): if a tool refuses, read the error — it tells you the correct next step. When something looks wrong, read `.tower/comms/log/activity.log` first; every action of every participant is there. +**The protocol is enforced by tools, not by instructions.** All comms artifacts — inbox messages, findings, reviews, mission files, `MISSIONS.md`, the activity log — are produced by the `Tower*` tools; never create or edit files under `.tower/` by hand. If a tool refuses, read the error — it tells you the correct next step. When something looks wrong, read `.tower/comms/log/activity.log` first; every action of every participant is there. Working principles: -1. **Clarify up front. Never block on the human mid-run.** Use `AskUserQuestion` to pin down requirements with the human before you plan and spawn, while ambiguity is still cheap — that is the phase where asking beats deciding. Once the fleet is running, make the reasonable call yourself: record the decision (it lands in the activity log), inform the human in passing, proceed. The return channel is your normal chat reply (the human reads it when they come back) plus `activity.log` — say what you decided and why, in the open. Escalations are reported, not asked — unless every remaining thread is blocked, keep the others moving. Workers and reviewers cannot ask the human at all (their profile has no `AskUserQuestion`); they escalate to you with `TowerSend`. The single mid-run exception is creating git history over a non-empty directory (below): there, ask when asking is possible (not under auto permission mode) and take the safe default when it is not. -2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You are the coordinator and the only merger, not a content relay: you relay wake-ups (resume an idle agent with `Agent(resume=..., run_in_background=true, prompt=...)` pointing at what it should read), triage findings, untangle conflicts, and merge. +1. **Clarify up front. Never block on the human mid-run.** Use `AskUserQuestion` to pin down requirements before you plan and spawn, while ambiguity is still cheap. Once the fleet is running, make the reasonable call yourself, record it (it lands in the activity log), inform the human in passing, and proceed — escalations are reported, not asked, and unless every remaining thread is blocked, keep the others moving. Workers and reviewers cannot ask the human at all; they escalate to you with `TowerSend`. +2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You relay wake-ups, triage findings, untangle conflicts, and merge; you are not a content relay. 3. **Scope isolation is real.** `TowerPlan` rejects overlapping scopes, and `TowerMerge` refuses branches that changed files outside their mission scope. Plan scopes carefully; if a mission legitimately needs more, you widen it with `TowerMission` (scope patch — only you can, and it is logged). -## Prepare (only when the directory is not a tower-ready git repo) - -`TowerInit` requires a git repository with at least one commit. If the session working directory is not inside one, the engine bootstraps it for you: `git init`, then an initial commit on the base branch — an empty directory gets `git commit --allow-empty -m "tower: init"`; a non-empty directory gets every present file committed as a dirty-base snapshot (`tower: snapshot of uncommitted base checkout changes (base )` — the same semantics as starting a tower over an uncommitted checkout). If the directory holds secrets or large files that must not enter history, move them out or add a `.gitignore` BEFORE starting the tower — the snapshot commits everything present. - ## Tower workflow -1. **Init** — `TowerInit`. It creates `.tower/` and records the base branch — when the human enabled tower mode with `/tower `, the workspace and base branch are already set up, so `TowerInit` just confirms them. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. -2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Titles must be **printable ASCII English** — any non-ASCII character is rejected and forces a re-plan. Give every title a unique identifier word (a business code, a module name, a ticket id). Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. -3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead, always in the background (`run_in_background=true`). Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. +1. **Init** — `TowerInit` creates `.tower/` and records the base branch (already set up when the human enabled tower mode with `/tower `). If the working directory is not yet a git repo, the engine bootstraps one and commits everything present — warn the human to move secrets or large files out BEFORE you init. Settle carried-over open missions **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — open missions keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. +2. **Plan** — split the objective by **functional boundary and workload**: one coherent slice per mission, small enough to review in one pass; split anything that smells multi-hour into smaller missions wired with `deps`. Prefer more, smaller missions — there is no fixed count. Then call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Titles must be **printable ASCII English** — any non-ASCII character is rejected and forces a re-plan. Give every title a unique identifier word (a business code, a module name, a ticket id). Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. +3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead, always in the background (`run_in_background=true`). Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. 4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: - Review request → first reconcile the worker's report against the mission tasks **item by item** (a silently dropped task means the mission is not done — send it back), then `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch) — the briefing hands the reviewer the mission text and the worker's report, so the review verifies intent, not only code health. Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. - - Review verdict not clean → resume the author (`Agent(resume=..., run_in_background=true, prompt=...)`) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). + - Review verdict not clean → the store flips the mission from 🟢 completed back to 🔵 active on its own (only ✅ merged is final); resume the author pointing at the review file — the author fixes and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). - Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving. - Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human. - Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting. -5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume with `run_in_background=true`) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review. -6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. Teardown does **not** exit tower mode — you remain the tower, ready to `TowerInit` the next objective, until the human turns the mode off with `/tower off`. A `/tower teardown` from the human is the same instruction at any earlier point. +5. **Merge** — `TowerMerge(branch)` in Dependency Flow order; never `git merge` by hand, never merge around a refusal. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope. After a merge, the result lists branches that now conflict: tell those workers to rebase onto the new base, resolve, and request re-review; their moved tip makes the gate demand a fresh clean review. +6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, summarize what every worker produced (per mission: what was built, the branch and its merge outcome, anything left undone) and call `TowerTeardown` **right away** — never tear down without that summary. Branches and `.tower/comms/` are kept and dirty worktrees are protected — only disk is freed. Teardown does **not** exit tower mode — you remain the tower, ready for the next objective, until the human turns the mode off with `/tower off`. A `/tower teardown` from the human is the same instruction at any earlier point. ## Hard rules for the tower - Exactly one tower. If a worker starts assigning work or merging, correct it on your next resume. - Never write product code yourself; integration fixes at merge time are yours, everything else goes to a worker. -- Mission tracking lives in the tower protocol (`TowerPlan`/`TowerMission`/`TowerStatus`, `MISSIONS.md`), never in `TodoList` — it is code-denied in tower mode because todo semantics (one task in progress at a time) would serialize the fleet. -- Workers negotiate through `TowerSend`; you relay wake-ups and step in for conflicts, caps, findings, and merges. +- Mission tracking lives in the tower protocol (`TowerPlan`/`TowerMission`/`TowerStatus`, `MISSIONS.md`), never in `TodoList` — it is code-denied in tower mode because todo semantics would serialize the fleet. +- Parallel work runs through the tower fleet (`TowerSpawn` per mission), never `AgentDynamicWorkflow` — it is code-denied while tower mode is active because dynamic_workflow and tower modes are mutually exclusive. - Every resume of a roster agent is a background call — `Agent(resume=..., run_in_background=true, prompt="...")`, never foreground: you never need a worker's return value inline (its output flows back through the tower protocol files and its completion wakes you), while a foreground resume blocks your whole turn and jams the fleet. The one exception: if your tool set lacks background execution (the Agent tool rejects `run_in_background=true` when TaskList, TaskOutput, or TaskStop is inactive), a foreground resume is the only form the tool accepts — use it there rather than leaving the agent unrecovered. -- Never hand-edit `.tower/` files. The tools are the protocol. -- You perform every merge, through `TowerMerge` — never `git merge` by hand, never merge around a refusal. -- Before `TowerTeardown`, summarize what every worker produced for the human — per mission: what was built, the branch and its merge outcome, and anything left undone. Never tear down without that summary. +- You perform every merge, through `TowerMerge`. 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 fa18c0fb5..5c5eca398 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -2,7 +2,10 @@ 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( @@ -23,10 +26,16 @@ 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', - [...args], + [...configArgs, ...args], { cwd, timeout: GIT_TIMEOUT_MS, @@ -44,6 +53,27 @@ 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); @@ -168,6 +198,6 @@ export async function diffNameOnly( base: string, ref: string, ): Promise { - const out = await git(cwd, ['diff', '--name-only', `${base}...${ref}`]); + const out = await git(cwd, ['diff', ...GIT_DIFF_ARGS, '--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/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index 80950ed37..314a94005 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -67,6 +67,8 @@ export class TowerProtocolError extends Error { } } +export const MAX_REVIEW_ROUNDS = 5; + export interface TowerInitResult { readonly base: string; readonly created: boolean; @@ -122,6 +124,7 @@ export interface TowerMissionPatch { readonly blocker?: string; readonly clearBlockers?: boolean; readonly taskDone?: string; + readonly taskDrop?: { readonly text: string; readonly reason?: string }; readonly owner?: string; readonly scope?: readonly string[]; readonly spawnBase?: string; @@ -634,6 +637,7 @@ export class TowerStore { patch.blocker === undefined && patch.clearBlockers === undefined && patch.taskDone === undefined && + patch.taskDrop === undefined && patch.owner === undefined && patch.scope === undefined && patch.spawnBase === undefined; @@ -683,7 +687,9 @@ export class TowerStore { } if (patch.clearBlockers === true) mission.blockers = []; if (patch.taskDone !== undefined) { - const task = mission.tasks.find((t) => !t.done && t.text.includes(patch.taskDone!)); + const task = mission.tasks.find( + (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDone!), + ); if (task === undefined) { throw new TowerProtocolError( `mission ${id} has no open task matching "${patch.taskDone}"`, @@ -691,6 +697,29 @@ export class TowerStore { } task.done = true; } + let taskDropLog: string | undefined; + if (patch.taskDrop !== undefined) { + const reason = patch.taskDrop.reason?.trim() ?? ''; + if (reason.length === 0) { + throw new TowerProtocolError( + `dropping a task from mission ${id} requires a reason — the drop is the escape hatch for legitimately descoped work, and the reason is recorded in the mission notes and the activity log for audit`, + ); + } + const task = mission.tasks.find( + (t) => !t.done && t.dropped !== true && t.text.includes(patch.taskDrop!.text), + ); + if (task === undefined) { + throw new TowerProtocolError( + `mission ${id} has no open task matching "${patch.taskDrop.text}"`, + ); + } + task.dropped = true; + taskDropLog = `dropped task "${task.text}": ${reason}`; + mission.notes.push(taskDropLog); + } + if (patch.status === 'completed' && patch.blocker === undefined) { + await this.assertCompletable(state, mission); + } await this.save(state); await this.renderMissionsIndex(state); @@ -701,6 +730,7 @@ export class TowerStore { patch.note === undefined && patch.blocker === undefined && patch.clearBlockers === undefined && + patch.taskDrop === undefined && patch.owner === undefined && patch.scope === undefined && patch.spawnBase === undefined; @@ -710,6 +740,7 @@ export class TowerStore { status: patch.status, note: patch.note !== undefined ? 'added' : undefined, blocker: patch.blocker !== undefined ? 'added' : undefined, + task_drop: taskDropLog, owner: patch.owner, scope: patch.scope?.join(','), spawn_base: patch.spawnBase, @@ -718,6 +749,28 @@ export class TowerStore { return mission; } + private async assertCompletable(state: TowerState, mission: TowerMission): Promise { + const open = mission.tasks.filter((t) => !t.done && t.dropped !== true); + if (open.length > 0) { + throw new TowerProtocolError( + `mission ${mission.id} cannot transition to completed — ${String(open.length)} open task(s): ${open.map((t) => `"${t.text}"`).join(', ')}; tick finished tasks with task_done, or drop legitimately descoped ones with task_drop (a reason is mandatory and lands in the mission notes and the activity log)`, + ); + } + if (mission.kind === 'survey') return; + if (!(await branchExists(this.repoRoot, mission.branch))) { + throw new TowerProtocolError( + `mission ${mission.id} cannot transition to completed — its branch "${mission.branch}" does not exist, so no work has landed; a build mission must produce a diff on its branch: spawn a worker to do the work, or have the tower abandon the mission (status=abandoned) if it is no longer needed`, + ); + } + const base = await this.diffBase(state, mission); + const changed = await diffNameOnly(this.repoRoot, base, mission.branch); + if (changed.length === 0) { + throw new TowerProtocolError( + `mission ${mission.id} cannot transition to completed — branch "${mission.branch}" has no changes vs "${base}"; a build mission must produce a diff on its branch: commit the work there first, or if the work turned out unnecessary, have the tower abandon the mission (status=abandoned) instead`, + ); + } + } + async send(callerName: string, input: TowerSendInput): Promise { const state = await this.load(); const to = input.to.trim(); @@ -881,6 +934,11 @@ export class TowerStore { const existing = await this.reviewsFor(input.target); const myRounds = existing.filter((r) => r.reviewer === callerName).length; + if (myRounds >= MAX_REVIEW_ROUNDS) { + throw new TowerProtocolError( + `branch "${input.target}" has already been through ${String(MAX_REVIEW_ROUNDS)} review rounds by "${callerName}" — the rework loop is not converging, so another round from the same reviewer is refused; redirect instead: reassign the work (spawn a different worker or a fresh reviewer), split the mission into smaller pieces, or descope it (TowerMission status=abandoned)`, + ); + } const round = myRounds + 1; const seq = await this.nextReviewSeq(); const reviewedCommit = await branchTip(this.repoRoot, input.target); @@ -933,6 +991,24 @@ export class TowerStore { }, rel, ); + if (input.status !== 'clean') { + const reworkMission = + reviewMissionId !== undefined + ? state.missions.find((m) => m.id === reviewMissionId) + : resolveMissionByBranch(state, input.target); + if (reworkMission !== undefined && reworkMission.status === 'completed') { + reworkMission.status = 'active'; + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(reworkMission); + await this.appendLog( + callerName, + 'mission.rework', + { id: reworkMission.id, verdict: input.status }, + join(MISSIONS_DIR, missionFileName(reworkMission.id, reworkMission.slug)), + ); + } + } return rel; } @@ -1313,7 +1389,10 @@ export class TowerStore { : []), '## Tasks', ...(mission.tasks.length > 0 - ? mission.tasks.map((t) => `- [${t.done ? 'x' : ' '}] ${t.text}`) + ? mission.tasks.map( + (t) => + `- [${t.done ? 'x' : t.dropped === true ? '-' : ' '}] ${t.text}${t.dropped === true ? ' (dropped)' : ''}`, + ) : ['- [ ] (no tasks recorded)']), '', '## Dependencies', diff --git a/packages/agent-core-v2/src/features/tower/protocol/types.ts b/packages/agent-core-v2/src/features/tower/protocol/types.ts index f01c52fed..ac031e2e4 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/types.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -34,6 +34,7 @@ export type TowerMissionKind = 'build' | 'survey'; export interface TowerMissionTask { text: string; done: boolean; + dropped?: boolean; } export interface TowerMission { diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md index 8d0280997..da46a9f7c 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md @@ -3,3 +3,5 @@ Merge a tower mission branch into the base branch (--no-ff). Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The merge also refuses when every mission record for the branch is closed (abandoned or already merged) — a merge never flips a historical mission's state; if the work must land, re-plan it under a fresh mission title. The scope diff starts from the mission's recorded spawn base while that snapshot commit is still part of the branch's history, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation; once a rebase drops the snapshot (typically because the WIP has since been committed on the base branch), the diff falls back to the base branch. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. The main checkout must be clean for the files the merge touches: if it still has uncommitted changes in any file the merge would overwrite, the merge is refused and nothing is merged — commit or stash those changes first, then retry. This matters when a mission branch carries a snapshot of the checkout's WIP: that WIP merges into the base history, so the checkout must not still hold the same changes uncommitted. + +When this merge closes the last open mission, the result reports the workspace ready for TowerTeardown. diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts index 8a1ff5dde..c08bbcb95 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts @@ -33,11 +33,21 @@ export class TowerMergeTool implements ITowerMergeTool { runTowerTool(async () => { const store = newTowerStore(this.sessionContext); const { mergeCommit, conflictsWith, noop } = await store.merge(args.branch); + const after = await store.load(); + const allClosed = + after.missions.length > 0 && + after.missions.every( + (mission) => mission.status === 'merged' || mission.status === 'abandoned', + ); + const teardownHint = + 'Every mission is now merged or abandoned — ready for TowerTeardown (branches and .tower/comms/ are kept; dirty worktrees are protected).'; if (noop === true) { return { output: [ `${args.branch} is a read-only survey with a zero-diff branch — mission marked merged, no git merge needed.`, - 'Continue with the remaining missions in Dependency Flow order.', + allClosed + ? teardownHint + : 'Continue with the remaining missions in Dependency Flow order.', ].join('\n'), }; } @@ -54,6 +64,8 @@ export class TowerMergeTool implements ITowerMergeTool { ), 'Tell each affected worker (Agent resume with run_in_background=true — never foreground: their output flows back through the tower protocol files) to rebase onto the updated base, resolve, push, and request a re-review.', ); + } else if (allClosed) { + lines.push(`The mission is now marked merged. ${teardownHint}`); } else { lines.push('The mission is now marked merged. Continue with the remaining missions in Dependency Flow order.'); } diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.md b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md index b8a605307..73b37d862 100644 --- a/packages/agent-core-v2/src/features/tower/tools/mission/mission.md +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md @@ -1,5 +1,7 @@ Read or update a tower mission. -With only an id, returns the mission view (status, tasks, blockers, notes). With patch fields, applies them: workers may only update the mission they own — the store rejects anything else. Use task_done to tick checklist items, note to log decisions, blocker when stuck (the tower watches for blocked missions). +With only an id, returns the mission view (status, tasks, blockers, notes). With patch fields, applies them: workers may only update the mission they own — the store rejects anything else. Use task_done to tick checklist items, task_drop (with task_drop_reason) to drop a legitimately descoped task — the reason is recorded in the mission notes and the activity log — note to log decisions, blocker when stuck (the tower watches for blocked missions). + +Status lifecycle: planned → active → completed → merged. "completed" means awaiting review, not done — the branch can still be sent back for rework: when a review with a non-clean verdict lands on the mission's branch, the store flips a completed mission back to active automatically (merged never flips; planned, blocked, and paused are left alone). Only "merged" is final. The store refuses the transition to completed while the mission still has open (neither done nor dropped) tasks — the error lists them — and, for build missions, while the mission branch has no diff vs its base; survey missions are exempt from the diff check. Tower only: status=abandoned gives a mission up without merging — its scope stops reserving files for TowerPlan, its dependents may merge, and its branch drops out of conflict checks. Use it for stale missions carried over from a previous session, or for work that will not land; abandoned missions stay in MISSIONS.md (🚫) as the audit trail. diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts index 61a8ac5a5..8016934cc 100644 --- a/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts @@ -19,6 +19,16 @@ export const TowerMissionToolInputSchema = z .string() .optional() .describe('Mark the first open task containing this text as done'), + task_drop: z + .string() + .optional() + .describe( + 'Mark the first open task containing this text as dropped — the escape hatch for legitimately descoped tasks; requires task_drop_reason and is recorded in the mission notes and the activity log', + ), + task_drop_reason: z + .string() + .optional() + .describe('Mandatory with task_drop: why the task is legitimately descoped'), scope: z .array(z.string()) .optional() diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts b/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts index 6f386bb81..2f76e2293 100644 --- a/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts @@ -34,6 +34,7 @@ export class TowerMissionTool implements ITowerMissionTool { args.blocker !== undefined || args.clear_blockers !== undefined || args.task_done !== undefined || + args.task_drop !== undefined || args.scope !== undefined; return { description: hasPatch @@ -62,11 +63,15 @@ export class TowerMissionTool implements ITowerMissionTool { blocker: args.blocker, clearBlockers: args.clear_blockers, taskDone: args.task_done, + taskDrop: + args.task_drop === undefined + ? undefined + : { text: args.task_drop, reason: args.task_drop_reason }, scope: args.scope, }); return { output: [ - `mission ${mission.id} updated — status: ${mission.status}, open tasks: ${String(mission.tasks.filter((t) => !t.done).length)}, blockers: ${String(mission.blockers.length)}`, + `mission ${mission.id} updated — status: ${mission.status}, open tasks: ${String(mission.tasks.filter((t) => !t.done && t.dropped !== true).length)}, blockers: ${String(mission.blockers.length)}`, '', await renderMission(store, mission), ].join('\n'), diff --git a/packages/agent-core-v2/src/features/tower/tools/review/review.md b/packages/agent-core-v2/src/features/tower/tools/review/review.md index 35cf7c0be..df8841ee5 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/review.md +++ b/packages/agent-core-v2/src/features/tower/tools/review/review.md @@ -1,3 +1,5 @@ Submit a review verdict for a branch you were assigned to review (via TowerSpawn review_target). The review is stamped with the current branch tip — if the branch moves afterwards, the tower must ask for a re-review before merging. Only reviewers assigned to the target (or the tower) may submit; the round number is assigned automatically. + +The submission result routes the next step: on a non-clean verdict it names the roster worker to resume (with this review file) so the findings get fixed and re-reviewed; on a clean verdict it reports the branch merge-ready for TowerMerge. diff --git a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts index 764434bc9..2fe7dd109 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts @@ -1,4 +1,5 @@ import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; +import { resolveMissionByBranch } from '#/features/tower/protocol/index'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; @@ -42,9 +43,28 @@ export class TowerReviewTool implements ITowerReviewTool { decision: args.decision, token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)), }); - return { - output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`, - }; + const lines = [`review submitted: ${rel}`]; + if (args.status === 'clean') { + lines.push( + `next: ${args.target} is merge-ready — the tower can TowerMerge it in Dependency Flow order.`, + ); + } else { + const owner = resolveMissionByBranch(state, args.target)?.owner; + const worker = owner === undefined ? undefined : store.findAgent(state, owner); + if (owner !== undefined && worker !== undefined) { + lines.push( + `next: resume ${owner} with this review file (${rel}): Agent(resume="${worker.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files. The branch must be fixed and re-reviewed before it can merge.`, + ); + } else { + lines.push( + `next: no worker on record owns ${args.target} — route this review file (${rel}) through the tower so it can reassign the fixes.`, + ); + } + } + lines.push( + 'Also notify the branch author (or the tower) with TowerSend so the verdict is seen.', + ); + return { output: lines.join('\n') }; }), }; } diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md index 4ac0ffabe..25aff42ae 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md @@ -2,6 +2,8 @@ Spawn a tower worker or reviewer as a background subagent and register it in the Workers: pass mission_id — the tool creates the mission worktree, marks the mission active with this worker as owner, and briefs the agent with the full mission text. Reviewers: pass review_target — when the branch belongs to a mission, the briefing carries the full mission text (title, tasks, and any verbatim user context) plus the author's own review-request when one is on file, so the review verifies intent against the mission, not only code health; the agent must submit its verdict via TowerReview. +When the branch already has history, the briefing carries it too: a worker respawned onto a reviewed branch gets the latest review round (verdict, findings, decision) and the previous worker's review-request, so rework continues from the verdict instead of starting cold; a reviewer gets the branch's earlier review rounds, so repeat findings are verified against the current tip rather than reported again. First spawns have no history sections. Note that a non-clean verdict flips a completed mission back to active — "completed" means awaiting review; only "merged" is final. + If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot while it remains part of the branch's history (falling back to the base branch once a rebase drops the snapshot commit), so the WIP is never mistaken for the worker's own scope. Snapshotting requires the main checkout to be on the recorded base branch: WIP sitting on a different branch (or a detached HEAD) belongs to that line of work, so the spawn is refused rather than mixing that content into the base — switch back to the base or commit/stash first. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is. The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with Agent(resume=..., run_in_background=true, prompt="...") instead of spawning a duplicate — never foreground: you never need the agent's return value inline; its output flows back through the tower protocol files. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 45490a1dd..52c4ac304 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -16,8 +16,10 @@ import { WORKTREES_DIR, isReservedTowerAgentName, missionFileName, + parseFrontmatter, resolveMissionByBranch, resolveTowerRepoRoot, + type TowerInboxItem, type TowerMission, type TowerState, } from '#/features/tower/protocol/index'; @@ -53,6 +55,27 @@ import DESCRIPTION from './spawn.md?raw'; type SubagentBinding = ReturnType; const REVIEW_REQUEST_SCAN_LIMIT = 50; +const REVIEW_HISTORY_ROUNDS = 5; +const REVIEW_EXCERPT_MAX_CHARS = 8000; + +function truncateSection(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n…(truncated)`; +} + +async function reviewExcerpt(store: TowerStore, file: string): Promise { + const { body } = parseFrontmatter(await readFile(store.abs(file), 'utf8')); + return truncateSection(body.trim(), REVIEW_EXCERPT_MAX_CHARS); +} + +async function findReviewRequest( + store: TowerStore, + author: string, +): Promise { + return (await store.readInbox(TOWER_NAME, REVIEW_REQUEST_SCAN_LIMIT)).find( + (item) => item.from === author && item.subject.startsWith('review-request'), + ); +} export class TowerSpawnTool implements ITowerSpawnTool { declare readonly _serviceBrand: undefined; @@ -401,7 +424,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { (mission.spawnBase !== undefined ? `- Your branch starts from snapshot commit ${mission.spawnBase.slice(0, 7)}: the base checkout's uncommitted changes (WIP), captured at spawn so you can build on them. That commit is your foundation — never revert, amend, or claim it as your own work; your own commits go on top of it.\n` : '') + - `- Your working directory is the main checkout, NOT your worktree — address the worktree explicitly: every Read/Write/Edit/Grep/Glob path must be absolute and under ${worktreeAbs}, and every Bash command must \`cd ${worktreeAbs}\` first. A permission guard hard-denies any Write/Edit outside it. Never touch the main checkout (${store.repoRoot}) or another agent's worktree slot.\n` + + `- Your working directory is the main checkout, NOT your worktree — address the worktree explicitly: every Read/Write/Edit/Grep/Glob path must be absolute and under ${worktreeAbs}, and every Bash command must \`cd ${worktreeAbs}\` first. A permission guard hard-denies any Write/Edit outside it; reads are unrestricted — you may read the main checkout (${store.repoRoot}) or another agent's worktree when coordination calls for it, but write only inside your own.\n` + (mission.kind === 'survey' ? `- Scope — what you investigate (read-only; reserves nothing): ${mission.scope.join(', ')}\n\n` : `- Scope — the only files you may change: ${mission.scope.join(', ')}\n\n`); @@ -424,10 +447,12 @@ export class TowerSpawnTool implements ITowerSpawnTool { extra ); } + const historySection = await this.workerHistorySection(store, mission, args.name); return ( `You are "${args.name}", a tower worker agent in a multi-agent workspace.\n\n` + workplace + `# Your mission\n\n${missionText.trim()}\n\n` + + historySection + `# Communication protocol\n` + '- Coordinate through tower tools ONLY: TowerSend / TowerInbox / TowerFinding / TowerMission / TowerStatus. Reach the tower and sibling agents with TowerSend; check TowerInbox regularly.\n' + '- NEVER create or edit files under `.tower/` by hand — the tools are the only writers; hand-written protocol files break the merge gate.\n' + @@ -435,7 +460,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { '- Keep your mission current with TowerMission: task_done as you finish tasks, note for decisions, blocker when stuck.\n' + '- Ambiguity is escalated, not guessed: if the mission and its Context leave substantive doubt about what to build, TowerSend(to="tower", subject="clarify-request", body=what needs pinning down) BEFORE acting — the tower relays to the human; you never ask the user directly.\n\n' + `# When the mission is done\n` + - '1. `git add` + `git commit` everything in your worktree (and `git push` only if a remote is configured).\n' + + "1. `git add` + `git commit` your mission's changes in the worktree (source files only — no build outputs).\n" + `2. Mark the mission completed: TowerMission(id="${mission.id}", status="completed").\n` + '3. Request review: TowerSend(to="tower", subject="review-request", body=what you changed and why, reconciled against the mission tasks item by item — the reviewer maps each task to your diff).\n' + '4. Finish with a structured final summary: files changed, key decisions, open follow-ups.' + @@ -455,16 +480,12 @@ export class TowerSpawnTool implements ITowerSpawnTool { ) ).trim()}\n\n` : ''; - const reviewRequest = - author !== undefined - ? (await store.readInbox(TOWER_NAME, REVIEW_REQUEST_SCAN_LIMIT)).find( - (item) => item.from === author && item.subject.startsWith('review-request'), - ) - : undefined; + const reviewRequest = author !== undefined ? await findReviewRequest(store, author) : undefined; const selfReportSection = reviewRequest !== undefined ? `# The author's own account (their review-request to the tower)\n${reviewRequest.body.trim()}\n\n` : ''; + const historySection = await this.reviewHistorySection(store, target); const checklist = targetMission !== undefined ? '1. Intent — does the diff deliver the mission above? Map every task to the changes; healthy code that answers the wrong requirement or silently drops a task is a finding, not a pass.\n2. Security\n3. Data integrity\n4. Performance\n5. Error handling\n6. Code quality\n\n' @@ -477,6 +498,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { '- Do NOT modify any code, and never create or edit files under `.tower/` by hand — protocol artifacts go through the tower tools.\n\n' + missionSection + selfReportSection + + historySection + `# Review checklist (in priority order)\n` + checklist + `# When done — both steps are mandatory\n` + @@ -488,4 +510,57 @@ export class TowerSpawnTool implements ITowerSpawnTool { extra ); } + + private async workerHistorySection( + store: TowerStore, + mission: TowerMission, + agentName: string, + ): Promise { + const latest = await store.latestReview(mission.branch); + const predecessor = + mission.owner !== undefined && mission.owner !== agentName ? mission.owner : undefined; + const reviewRequest = + predecessor !== undefined ? await findReviewRequest(store, predecessor) : undefined; + if (latest === undefined && reviewRequest === undefined) return ''; + const parts = [ + '# Previous work on this branch', + 'An earlier worker already ran on this branch — continue from its work instead of starting over, and address the review verdict below rather than re-opening what it already settled.', + ]; + if (latest !== undefined) { + parts.push( + `## Latest review — round ${String(latest.round)} by ${latest.reviewer}: ${latest.status}, merge verdict "${latest.merge}"`, + await reviewExcerpt(store, latest.file), + ); + } + if (reviewRequest !== undefined) { + parts.push( + "## The previous worker's review-request to the tower", + truncateSection(reviewRequest.body.trim(), REVIEW_EXCERPT_MAX_CHARS), + ); + } + return `${parts.join('\n\n')}\n\n`; + } + + private async reviewHistorySection(store: TowerStore, target: string): Promise { + const reviews = await store.reviewsFor(target); + if (reviews.length === 0) return ''; + const recent = reviews.slice(-REVIEW_HISTORY_ROUNDS); + const omitted = reviews.length - recent.length; + const parts = [ + '# Review history on this branch', + 'Earlier review rounds already ran on this branch — verify their findings against the current tip instead of reporting them again; re-report only what still reproduces.', + ]; + if (omitted > 0) { + parts.push( + `(${String(omitted)} earlier round(s) omitted — the full record lives in .tower/comms/reviews/.)`, + ); + } + for (const review of recent) { + parts.push( + `## Round ${String(review.round)} by ${review.reviewer}: ${review.status}, merge verdict "${review.merge}"`, + await reviewExcerpt(store, review.file), + ); + } + return `${parts.join('\n\n')}\n\n`; + } } diff --git a/packages/agent-core-v2/src/features/tower/tools/status/status.md b/packages/agent-core-v2/src/features/tower/tools/status/status.md index 25ea7bb71..22e9bb461 100644 --- a/packages/agent-core-v2/src/features/tower/tools/status/status.md +++ b/packages/agent-core-v2/src/features/tower/tools/status/status.md @@ -1 +1 @@ -Show the tower dashboard: missions (status/owner), the agent roster, the review-gate state of every unmerged branch (latest review round/status and whether the reviewed commit still matches the branch tip), your inbox message count, and the last activity log lines. +Show the tower dashboard: missions (status/owner), the agent roster, the review-gate state of every unmerged branch (latest review round/status and whether the reviewed commit still matches the branch tip), your inbox message count, and the last activity log lines. Missions that are planned but have no spawned worker yet are called out in a dedicated Awaiting spawn section so the tower cannot miss launching them. diff --git a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts index 69695e52f..21359a0e8 100644 --- a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts @@ -58,6 +58,7 @@ export class TowerStatusTool implements ITowerStatusTool { '## Missions', '', ...renderMissions(state), + ...renderUnspawnedMissions(state), ...renderDeathWarnings(state), '', '## Roster', @@ -176,6 +177,20 @@ function renderRoster(state: TowerState): string[] { }); } +function renderUnspawnedMissions(state: TowerState): string[] { + const pending = state.missions.filter((m) => m.status === 'planned' && m.owner === undefined); + if (pending.length === 0) return []; + return [ + '', + '## Awaiting spawn', + '', + ...pending.map( + (m) => + `- ${m.id} (${m.branch}) — planned but no worker spawned yet: launch one with TowerSpawn(kind="worker", mission_id="${m.id}", name="...")`, + ), + ]; +} + function renderDeathWarnings(state: TowerState): string[] { const deadByName = new Map( state.roster.agents.filter((a) => a.diedAt !== undefined).map((a) => [a.name, a]), diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index e7c0181b7..3c7ece2b8 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -178,6 +178,20 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); }), ); + this._register( + toolExecutor.onBeforeExecuteTool((event) => { + if (!this.flags.enabled(TOWER_FLAG_ID)) return; + if (!this.isActive) return; + if (event.toolCall.name !== 'AgentDynamicWorkflow') return; + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + 'AgentDynamicWorkflow is not available while tower mode is active — dynamic_workflow and tower modes are mutually exclusive, and the tower fleet runs through TowerSpawn, one mission per worker in its own worktree. If the work genuinely needs a dynamic_workflow instead, exit tower mode first.', + ), + ), + ); + }), + ); this._register( toolExecutor.onBeforeExecuteTool(async (event) => { if (!this.flags.enabled(TOWER_FLAG_ID)) return; diff --git a/packages/agent-core-v2/src/mcpCore/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts index cae78e863..a36b6eaa5 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -1,4 +1,8 @@ -import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { + auth, + discoverOAuthServerInfo, + type OAuthClientProvider, +} from '@modelcontextprotocol/sdk/client/auth.js'; import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { ILogger as Logger } from '#/_base/log/log'; @@ -93,6 +97,7 @@ const REFRESH_AHEAD_MS = 120_000; const MAX_TIMER_DELAY_MS = 0x7fffffff; const DEFAULT_AUTH_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 30_000; +const OFFLINE_ACCESS_SCOPE = 'offline_access'; const defaultScheduler: McpOAuthScheduler = { now: () => Date.now(), @@ -290,6 +295,38 @@ export class McpOAuthService { }) as typeof fetch; } + private async resolveRequestScope( + provider: McpOAuthClientProvider, + serverUrl: string | URL, + signal: AbortSignal, + ): Promise { + let discovery = await provider.discoveryState(); + if (discovery?.authorizationServerMetadata === undefined) { + try { + const info = await discoverOAuthServerInfo(serverUrl, { + fetchFn: this.authFetch(provider, [signal]), + }); + discovery = { + ...discovery, + authorizationServerUrl: info.authorizationServerUrl, + resourceMetadata: info.resourceMetadata ?? discovery?.resourceMetadata, + authorizationServerMetadata: info.authorizationServerMetadata, + }; + await provider.saveDiscoveryState(discovery); + } catch { + return undefined; + } + } + const advertised = discovery.authorizationServerMetadata?.scopes_supported; + if (advertised === undefined || !advertised.includes(OFFLINE_ACCESS_SCOPE)) return undefined; + const base = + discovery.resourceMetadata?.scopes_supported ?? + provider.clientMetadata.scope?.split(/\s+/).filter(Boolean) ?? + []; + if (base.includes(OFFLINE_ACCESS_SCOPE)) return undefined; + return [...base, OFFLINE_ACCESS_SCOPE].join(' '); + } + async beginAuthorization( serverName: string, serverUrl: string | URL, @@ -360,6 +397,7 @@ export class McpOAuthService { provider.setRedirectUrl(new URL(callbackServer.redirectUri)); await provider.ready; await provider.invalidateStaleRegistration(callbackServer.redirectUri); + const requestScope = await this.resolveRequestScope(provider, serverUrl, signal); let tokensSaved = false; const unsubscribeTokensSaved = this.onEvent((event) => { if ( @@ -373,6 +411,7 @@ export class McpOAuthService { try { const result = await auth(provider as OAuthClientProvider, { serverUrl, + scope: requestScope, fetchFn: this.authFetch(provider, [signal]), }); if (result !== 'REDIRECT') { 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 5bf608503..54892c2ed 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,10 +7,12 @@ 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 @@ -35,9 +37,13 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService @IHostFileSystem private readonly fs: IHostFileSystem, ) {} - async readAdditionalDirs(workDir: string): Promise { + async locateAdditionalDirsConfig(workDir: string): Promise { const projectRoot = await this.findProjectRoot(workDir); - const configPath = this.getProjectLocalConfigPath(projectRoot); + return { projectRoot, configPath: this.getProjectLocalConfigPath(projectRoot) }; + } + + async readAdditionalDirs(workDir: string): Promise { + const { projectRoot, configPath } = await this.locateAdditionalDirsConfig(workDir); const file = await this.readProjectLocalToml(configPath); const additionalDirs = file?.parsed.workspace?.additional_dir; @@ -65,7 +71,10 @@ 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 = this.resolveExistingAdditionalDirs(projectRoot, fileAdditionalDirs); + const fileExistingDirs = await this.resolveExistingAdditionalDirs( + projectRoot, + fileAdditionalDirs, + ); if (this.hasSameAdditionalDir(fileExistingDirs, additionalDir)) { return { projectRoot, configPath, additionalDirs: fileExistingDirs }; @@ -153,14 +162,14 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService return resolvedDirs; } - private resolveExistingAdditionalDirs( + private async resolveExistingAdditionalDirs( projectRoot: string, additionalDirs: readonly string[], - ): string[] { + ): Promise { const resolvedDirs: string[] = []; for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) { - const resolvedDir = this.resolvePath(projectRoot, additionalDir); + const resolvedDir = await this.resolvePath(projectRoot, additionalDir); if (this.hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue; resolvedDirs.push(resolvedDir); } @@ -173,14 +182,38 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService additionalDir: string, ): Promise { const normalizedInput = normalizeAdditionalDirInput(additionalDir); - const resolvedDir = this.resolvePath(baseDir, normalizedInput); + const resolvedDir = await this.resolvePath(baseDir, normalizedInput); await this.assertDirectory(resolvedDir); return resolvedDir; } - private resolvePath(baseDir: string, additionalDir: string): string { + private async resolvePath(baseDir: string, additionalDir: string): Promise { const expanded = this.expandHome(additionalDir); - return isAbsolute(expanded) ? normalize(expanded) : resolve(baseDir, expanded); + 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; + } } 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 d8339e779..a12636855 100644 --- a/packages/agent-core-v2/src/program/program.ts +++ b/packages/agent-core-v2/src/program/program.ts @@ -279,11 +279,11 @@ export class Program { try { const state = own(new WorkspaceStateService(this.dependencies.appState)); const localConfig = new FileProjectLocalConfigService(this.dependencies.bootstrap, runtime.fs!); - const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state)); + 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 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)); 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)); 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 bf6a32e57..d9356f65a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts @@ -1,7 +1,5 @@ -import type { Readable } from 'node:stream'; - import type { ILogger } from '#/_base/log/log'; -import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import type { IGitService, RunGitResult } from '#/app/git/git'; const GIT_TIMEOUT_MS = 5_000; const MAX_DIRTY_FILES = 20; @@ -16,26 +14,17 @@ const ALLOWED_HOSTS = [ 'git.sr.ht', ] as const; -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 }; +type TaggedGitResult = { readonly args: readonly string[]; readonly result: RunGitResult }; export async function collectGitContext( - process: IHostProcessService, + git: IGitService, cwd: string, log?: ILogger, ): Promise { const revParseArgs = ['rev-parse', '--is-inside-work-tree'] as const; - const revParse = await runGit(process, cwd, revParseArgs); - if (!revParse.ok) { - if (revParse.kind === 'command-failed' && isNotARepo(revParse.stderr)) { + const revParse = await git.runGit(cwd, revParseArgs, { timeoutMs: GIT_TIMEOUT_MS }); + if (revParse.exitCode !== 0) { + if (isNotARepo(revParse.stderr)) { return ``; } logGitFailure(cwd, revParseArgs, revParse, log); @@ -49,11 +38,14 @@ 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 runGit(process, cwd, args) })), + commandArgs.map(async (args) => ({ + args, + result: await git.runGit(cwd, args, { timeoutMs: GIT_TIMEOUT_MS }), + })), )) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult]; for (const { args, result } of [remote, branch, status, gitLog]) { - if (!result.ok) logGitFailure(cwd, args, result, log); + if (result.exitCode !== 0) logGitFailure(cwd, args, result, log); } const remoteUrl = stdoutOf(remote.result); @@ -135,94 +127,30 @@ function tryUrlPath(remoteUrl: string): string | null { } } -function stdoutOf(result: GitResult): string { - return result.ok ? result.stdout : ''; +function stdoutOf(result: RunGitResult): string { + return result.exitCode === 0 ? result.stdout.trim() : ''; } -function isNotARepo(stderr: string | undefined): boolean { - return stderr !== undefined && stderr.includes('not a git repository'); +function isNotARepo(stderr: string): boolean { + return stderr.includes('not a git repository'); } function logGitFailure( cwd: string, args: readonly string[], - failure: GitFailure, + result: RunGitResult, log?: ILogger, ): void { if (log === undefined) return; const command = `git ${args.join(' ')}`; - 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 }); + if (result.exitCode === -1) { + log.warn('git context command failed to spawn', { cwd, command, stderr: result.stderr }); } else { log.debug('git context command failed', { cwd, command, - exitCode: failure.exitCode, - stderr: failure.stderr, + exitCode: result.exitCode, + stderr: result.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 ea38e446c..43c91e121 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -115,9 +115,10 @@ registerAgentProfile({ tools: EXPLORE_TOOLS, renderSystemPrompt: (context) => renderSystemPromptResult(EXPLORE_ROLE, context, { skillActive: skillActiveFor(EXPLORE_TOOLS) }), - promptPrefix: async ({ cwd, process, log }) => { + promptPrefix: async ({ cwd, git, log }) => { + if (git === undefined) return ''; try { - return await collectGitContext(process, cwd, log); + return await collectGitContext(git, cwd, log); } catch { return ''; } diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 06618be9e..c5d7ca682 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -1,4 +1,6 @@ /* 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 type { IAgentScopeHandle } from '#/_base/di/scope'; import { isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; @@ -8,7 +10,7 @@ import { isProviderRateLimitError } from '#/llm-adapter/contract/errors'; import { type TokenUsage } from '#human/llm/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { SubagentCreatedEvent } from '#/app/telemetry/events'; -import { Event2 } from '#/app/event/event2'; +import { Event2, registerEvent2Class } from '#/app/event/event2'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -33,8 +35,24 @@ export interface SubagentSpawnedPayload { export class SubagentSpawned extends Event2 { static override readonly type = 'subagent.spawned'; static override readonly observable = true; + static override readonly durable = true; + static override readonly schema = z.object({ + subagentId: z.string(), + subagentName: z.string(), + parentToolCallId: z.string(), + parentToolCallUuid: z.string().optional(), + parentAgentId: z.string().optional(), + callerAgentId: z.string().optional(), + description: z.string().optional(), + dynamicWorkflowIndex: z.number().optional(), + runInBackground: z.boolean(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), + taskId: z.string().optional(), + }); } export interface SubagentSpawned extends SubagentSpawnedPayload {} +registerEvent2Class(SubagentSpawned); export interface SubagentStartedPayload { readonly subagentId: string; @@ -43,8 +61,11 @@ export interface SubagentStartedPayload { export class SubagentStarted extends Event2 { static override readonly type = 'subagent.started'; static override readonly observable = true; + static override readonly durable = true; + static override readonly schema = z.object({ subagentId: z.string() }); } export interface SubagentStarted extends SubagentStartedPayload {} +registerEvent2Class(SubagentStarted); export interface SubagentCompletedPayload { readonly subagentId: string; @@ -56,8 +77,16 @@ export interface SubagentCompletedPayload { export class SubagentCompleted extends Event2 { static override readonly type = 'subagent.completed'; static override readonly observable = true; + static override readonly durable = true; + static override readonly schema = z.object({ + subagentId: z.string(), + resultSummary: z.string(), + usage: z.custom().optional(), + contextTokens: z.number().optional(), + }); } export interface SubagentCompleted extends SubagentCompletedPayload {} +registerEvent2Class(SubagentCompleted); export interface SubagentFailedPayload { readonly subagentId: string; @@ -67,8 +96,11 @@ export interface SubagentFailedPayload { export class SubagentFailed extends Event2 { static override readonly type = 'subagent.failed'; static override readonly observable = true; + static override readonly durable = true; + static override readonly schema = z.object({ subagentId: z.string(), error: z.string() }); } export interface SubagentFailed extends SubagentFailedPayload {} +registerEvent2Class(SubagentFailed); export interface SubagentCancelledPayload { readonly subagentId: string; @@ -77,8 +109,11 @@ export interface SubagentCancelledPayload { export class SubagentCancelled extends Event2 { static override readonly type = 'subagent.cancelled'; static override readonly observable = true; + static override readonly durable = true; + static override readonly schema = z.object({ subagentId: z.string() }); } export interface SubagentCancelled extends SubagentCancelledPayload {} +registerEvent2Class(SubagentCancelled); export interface SubagentSpawnedEvent extends SubagentSpawnedPayload { readonly type: 'subagent.spawned'; diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 9d2a0ecbc..1a5cd3656 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -22,6 +22,7 @@ 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'; @@ -70,6 +71,7 @@ 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, @@ -225,6 +227,7 @@ 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/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts index 3585f03f7..281cf33b6 100644 --- a/packages/agent-core-v2/src/tool/args-validator.ts +++ b/packages/agent-core-v2/src/tool/args-validator.ts @@ -3,14 +3,28 @@ import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; -const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true }); -addFormats(DRAFT_07_AJV); +type AnyAjv = Ajv | Ajv2019 | Ajv2020; -const DRAFT_2019_AJV = new Ajv2019({ strict: false, allErrors: true }); -addFormats(DRAFT_2019_AJV); +let draft07Ajv: Ajv | undefined; +let draft2019Ajv: Ajv2019 | undefined; +let draft2020Ajv: Ajv2020 | undefined; -const DRAFT_2020_AJV = new Ajv2020({ strict: false, allErrors: true }); -addFormats(DRAFT_2020_AJV); +function withFormats(ajv: T): T { + addFormats(ajv); + return ajv; +} + +function draft07(): Ajv { + return (draft07Ajv ??= withFormats(new Ajv({ strict: false, allErrors: true }))); +} + +function draft2019(): Ajv2019 { + return (draft2019Ajv ??= withFormats(new Ajv2019({ strict: false, allErrors: true }))); +} + +function draft2020(): Ajv2020 { + return (draft2020Ajv ??= withFormats(new Ajv2020({ strict: false, allErrors: true }))); +} const DRAFT_2019_KEYWORDS = new Set([ 'dependentRequired', @@ -25,16 +39,16 @@ const DRAFT_2019_KEYWORDS = new Set([ const DRAFT_2020_KEYWORDS = new Set(['prefixItems', '$dynamicAnchor', '$dynamicRef']); -function ajvFor(schema: Record): Ajv | Ajv2019 | Ajv2020 { +function ajvFor(schema: Record): AnyAjv { const $schema = schema['$schema']; if (typeof $schema === 'string') { - if ($schema.includes('2020-12')) return DRAFT_2020_AJV; - if ($schema.includes('2019-09')) return DRAFT_2019_AJV; - return DRAFT_07_AJV; + if ($schema.includes('2020-12')) return draft2020(); + if ($schema.includes('2019-09')) return draft2019(); + return draft07(); } - if (containsSchemaKeyword(schema, DRAFT_2020_KEYWORDS)) return DRAFT_2020_AJV; - if (containsSchemaKeyword(schema, DRAFT_2019_KEYWORDS)) return DRAFT_2019_AJV; - return DRAFT_07_AJV; + if (containsSchemaKeyword(schema, DRAFT_2020_KEYWORDS)) return draft2020(); + if (containsSchemaKeyword(schema, DRAFT_2019_KEYWORDS)) return draft2019(); + return draft07(); } function containsSchemaKeyword(value: unknown, keywords: ReadonlySet): boolean { diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index 68e6672d7..e519ade39 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'; +export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID' | 'PATH_SYMLINK_ESCAPE'; export type PathAccessOperation = 'read' | 'write' | 'search'; export type WorkspaceGuardMode = 'absolute-outside-allowed' | 'disabled'; @@ -190,6 +190,10 @@ 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 new file mode 100644 index 000000000..aaebec687 --- /dev/null +++ b/packages/agent-core-v2/src/tool/realpath-access.ts @@ -0,0 +1,183 @@ +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/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index a43cc4e80..0db409698 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -646,7 +646,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec updatedAt: toEpochMs(sourceMeta?.updatedAt) || now, archived: false, title, - titleKind: opts.title !== undefined ? 'custom' : 'replaceable', + titleKind: opts.title !== undefined ? 'custom' : sourceMeta?.titleKind, forkedFrom: sourceId, agents, custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index b0cf16925..e70e966a3 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -4,10 +4,14 @@ import { ILogService } from '#/_base/log/log'; import { defineState } from '#/state/state'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +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 { @@ -43,6 +47,7 @@ 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); @@ -51,6 +56,16 @@ 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[] { @@ -109,7 +124,15 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { ); this.projectRoot = persisted.projectRoot; this.configPath = persisted.configPath; - const changed = this.setFileDirs(persisted.additionalDirs); + 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); + } if (changed) { this.onDidChangeEmitter.fire(); } @@ -140,7 +163,13 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { } private async reloadFromDisk(): Promise { - const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); + await this.trust.ready; + const onDisk: ProjectAdditionalDirsLoadResult = this.trust.isTrusted() + ? await this.localConfig.readAdditionalDirs(this.workspace.cwd) + : { + ...(await this.localConfig.locateAdditionalDirsConfig(this.workspace.cwd)), + additionalDirs: [], + }; this.projectRoot = onDisk.projectRoot; this.configPath = onDisk.configPath; if (this.setFileDirs(onDisk.additionalDirs)) { diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 42b67ffeb..7254dae78 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -148,7 +148,10 @@ function createHarness( dispatch: async () => {}, } as unknown as IEventDispatcher; reg.defineInstance(IEventDispatcher, dispatcher); - reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); + reg.defineInstance(IBootstrapService, { + homeDir, + getEnv: () => undefined, + } as unknown as IBootstrapService); const agentState = new AgentStateService(); agentState.contributeState(profileKey); agentState.set(profileKey, { diff --git a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts index 74bca6df3..1f53392e0 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts @@ -38,6 +38,7 @@ describe('loop-event fold parity', () => { toolCallId: m.toolCallId, isError: m.isError, note: m.note, + durationMs: m.durationMs, })); } @@ -47,7 +48,7 @@ describe('loop-event fold parity', () => { { role: 'assistant', content: [{ type: 'text', text: 'I will call.' }], - toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{"q":"moon"}' }], + toolCalls: [{ type: 'function', id: 'c1', name: 'Bash', arguments: '{"q":"moon"}' }], }, { role: 'tool', @@ -55,6 +56,19 @@ describe('loop-event fold parity', () => { toolCalls: [], toolCallId: 'c1', isError: false, + durationMs: 42, + }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c2', name: 'Lookup', arguments: '{"q":"mars"}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'other result' }], + toolCalls: [], + toolCallId: 'c2', + isError: false, }, ]), ); @@ -71,15 +85,29 @@ describe('loop-event fold parity', () => { type: 'tool.call', stepUuid: 's1', toolCallId: 'c1', - name: 'Lookup', + name: 'Bash', args: { q: 'moon' }, }, { type: 'tool.result', toolCallId: 'c1', - result: { output: 'lookup result', isError: false }, + result: { output: 'lookup result', isError: false, durationMs: 42 }, }, { type: 'step.end', uuid: 's1' }, + { type: 'step.begin', uuid: 's2' }, + { + type: 'tool.call', + stepUuid: 's2', + toolCallId: 'c2', + name: 'Lookup', + args: { q: 'mars' }, + }, + { + type: 'tool.result', + toolCallId: 'c2', + result: { output: 'other result', isError: false, durationMs: 7 }, + }, + { type: 'step.end', uuid: 's2' }, ]), ); @@ -439,4 +467,46 @@ describe('loop-event fold parity', () => { expect(folded).toEqual(baseline); }); + + it('attaches step.end usage and timing to the sealed assistant message', () => { + const [assistant] = foldAll([], [ + { type: 'step.begin', uuid: 'st1' }, + { + type: 'content.part', + stepUuid: 'st1', + part: { type: 'text', text: 'a1' }, + }, + { + type: 'step.end', + uuid: 'st1', + usage: { inputOther: 10, output: 20, inputCacheRead: 30, inputCacheCreation: 40 }, + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + }, + ]); + expect(assistant?.usage).toEqual({ + inputOther: 10, + output: 20, + inputCacheRead: 30, + inputCacheCreation: 40, + }); + expect(assistant?.llmTiming).toEqual({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + }); + }); + + it('seals without usage or timing when step.end carries neither', () => { + const [assistant] = foldAll([], [ + { type: 'step.begin', uuid: 'st1' }, + { + type: 'content.part', + stepUuid: 'st1', + part: { type: 'text', text: 'a1' }, + }, + { type: 'step.end', uuid: 'st1' }, + ]); + expect(assistant?.usage).toBeUndefined(); + expect(assistant?.llmTiming).toBeUndefined(); + }); }); diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index f3e34dcb6..6c8f46a95 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -134,6 +134,14 @@ describe('projector tool-exchange normalization', () => { const history = [user('go'), assistant('', ['c1']), toolResult('c1', 'one'), user('next')]; expect(shape(history)).toEqual(['user', 'assistant', 'tool:c1', 'user']); expect(project(history)).toHaveLength(4); + const timed = project([ + user('go'), + assistant('', ['c1']), + { ...toolResult('c1', 'one'), durationMs: 42 }, + ]); + expect(timed.at(-1)?.content).toEqual([ + { type: 'text', text: 'Wall time: 0.042 seconds\none' }, + ]); }); it('synthesizes a result for a trailing unanswered call', () => { 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 ba41fa024..131fa8771 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -35,11 +35,13 @@ import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; +import { IPluginService } from '#/app/plugin/plugin'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { agentService, + appService, createTestAgent, InMemoryWireRecordPersistence, permissionModeServices, @@ -49,6 +51,7 @@ import { type TestAgentContext, type TestAgentOptions, } from '../../harness'; +import { stubPluginService } from '../../app/plugin/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { submitPromptTurn } from './stubs'; @@ -546,11 +549,11 @@ describe('Agent loop', () => { [emit] turn.ended { "time": "