gentle-pi 2.6.4 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/gentle-shell.md +30 -18
- package/docs/readme-reference.md +5 -5
- package/extensions/gentle-agents.ts +8 -0
- package/extensions/gentle-shell.ts +52 -70
- package/lib/agents-runner.ts +7 -2
- package/lib/native-review-cli.ts +9 -0
- package/lib/session-change-capture.ts +87 -0
- package/lib/session-changes.ts +140 -0
- package/lib/shell-bar.ts +6 -1
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +4 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +14 -0
- package/tests/gentle-agents.test.ts +34 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-shell.test.ts +136 -196
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/session-change-capture.test.ts +68 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +103 -0
- package/tests/shell-bar.test.ts +14 -0
package/README.md
CHANGED
|
@@ -140,7 +140,7 @@ Review the exact change, not a moving target. Native review keeps one candidate
|
|
|
140
140
|
|
|
141
141
|
The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) brings a more persistent, inspectable Pi workspace:
|
|
142
142
|
|
|
143
|
-
- **Shell:**
|
|
143
|
+
- **Shell:** `/gentle:changes` groups captured write/edit changes from the current agent session and its subagents, without startup repository scans; fullscreen navigation, sidebars, and mouse support stay available. See the [capture limits and shell-command coverage](docs/gentle-shell.md#what-appears-in-changes).
|
|
144
144
|
- **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles; applying one replaces the routing of every agent, and the panel shows the routing the runtime actually uses even when `models.json` is sparse.
|
|
145
145
|
- **Control and recovery:** native SDD requires parent-confirmed preflight; native review supports intended-untracked selection, consent, and provider continuations. Subsystems install with explicit recovery guidance when npm lifecycle scripts were skipped; Pi Git installs are recognized globally; custom ask responses are opt-in. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
|
|
146
146
|
|
package/docs/gentle-shell.md
CHANGED
|
@@ -13,7 +13,7 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases
|
|
|
13
13
|
- The Agents List and Details views preserve the orchestrator/session hierarchy and completion, abort, and lost-exit history. Parent-child queries and notifications have an explicit handoff path, while model, effort, and usage stay observable per task.
|
|
14
14
|
- Named `/gentle:profiles` atomically route the orchestrator separately from packaged and review roles; see the [technical reference](readme-reference.md#agent-model-profiles) for the profile model.
|
|
15
15
|
|
|
16
|
-
The source checkout currently prepares `gentle-pi` `2.
|
|
16
|
+
The source checkout currently prepares `gentle-pi` `2.7.0` with a package-local Gentle AI `v2.9.1` pin; this is not a claim that `2.7.0` is published.
|
|
17
17
|
|
|
18
18
|
## Shell interactions and runtime behavior
|
|
19
19
|
|
|
@@ -23,6 +23,8 @@ In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi
|
|
|
23
23
|
|
|
24
24
|
The rail reuses its last frame until something it paints changes, so silent frames stay cheap and live session state still lands on the next frame: a model switch, a new thinking level, context growth, session cost, session name and extension statuses all refresh the Status card without a redraw of the rest of the sidebar.
|
|
25
25
|
|
|
26
|
+
The sidebar Status card also shows `Profile` in its Model section when the profiles store has a valid active marker. It follows profile changes on the next render. Missing, unreadable, or invalid stores leave the line hidden. The compact bottom bar is unchanged.
|
|
27
|
+
|
|
26
28
|
The status bar replaces pi's three-line footer with a single line of segments:
|
|
27
29
|
|
|
28
30
|
```text
|
|
@@ -47,28 +49,38 @@ The prompt wraps pi's editor in a rounded frame with a petal that shows what the
|
|
|
47
49
|
- The hint appears only while the editor is empty.
|
|
48
50
|
- If another extension already installed a custom editor, Gentle Shell leaves it alone.
|
|
49
51
|
|
|
50
|
-
Changes
|
|
52
|
+
Changes shows **captured write/edit operations from this agent session and its owned subagents**. It does not scan the repository on startup, read all untracked files, or poll live files in the background. Fullscreen, the sidebar, and mouse interaction are unchanged.
|
|
51
53
|
|
|
52
54
|
```text
|
|
53
55
|
✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
58
|
+
### What appears in Changes
|
|
59
|
+
|
|
60
|
+
- A worktree appears only after a captured successful mutation. Reading a file, opening a directory, registering a worktree, or launching a child is not mutation evidence.
|
|
61
|
+
- Diffs compare the content observed before the agent's first captured operation with its latest captured result, not with HEAD. Consecutive agent edits combine; an agent revert removes its net change.
|
|
62
|
+
- Edits from your editor or other sessions do not update these captured diffs. If an external or unobserved edit breaks continuity before the next agent operation on the same file, the file is marked **diff unavailable**, rather than mixing ownership.
|
|
63
|
+
- Only worktrees in the coordinating session's Git clone are accepted. Child evidence is accepted only from an owned task with paired successful write/edit events and a matching target.
|
|
64
|
+
- **Coverage is deliberately limited to write/edit tools.** Shell commands, custom mutation tools, failed/interrupted outcomes and children without the capture extension provide no attributed diff. A missing row does not mean the repository is clean or that no other changes occurred.
|
|
65
|
+
|
|
66
|
+
### Bounds and session lifetime
|
|
67
|
+
|
|
68
|
+
Capture reads only the named target, up to 64 KiB and 2,000 text lines. Binary, oversized, nonregular and unverifiable snapshots show unavailable counts, never fabricated zero-count proof. At most 256 operation identities and 4 MiB of serialized evidence are retained per session; reaching the limit produces a warning.
|
|
69
|
+
|
|
70
|
+
Snapshots are stored locally in Pi custom entries (`gentle-pi.session-change/v1`), including bounded before/after source text. Exit/resume and reload restore captures only for the exact same session UUID. New sessions and forks do not inherit attribution from another UUID. Ephemeral `--no-session` runs do not persist after exit. Capturing remains active in headless children and when the visual shell is disabled.
|
|
71
|
+
|
|
72
|
+
The separate `session_worktree_register` tool still registers canonical same-clone roots for coordination, but registration alone never adds files to Changes. Existing `gentle-pi.session-worktree/v1` entries do not establish file-level attribution.
|
|
73
|
+
|
|
74
|
+
### Browse captured diffs
|
|
75
|
+
|
|
76
|
+
`/gentle:changes` or `alt+g` opens the two-pane viewer. Worktrees are accordion groups on the left; selecting a file displays its captured diff on the right.
|
|
77
|
+
|
|
78
|
+
- `j`/`k` or arrows navigate. On a group, Enter, Space or Right expands it; Left returns to its parent or collapses it. `ctrl+j/k` or Page Up/Down scroll the diff; Escape or `q` closes.
|
|
79
|
+
- Fullscreen left-click selects files; mouse wheels scroll the file list and diff independently. Hover does not open files.
|
|
80
|
+
- Opening, pressing `r`, and the overlay's refresh cadence consult only the captured session model. They never rescan Git or load the current file contents. Same-line-count edits invalidate the diff preview by content revision.
|
|
81
|
+
- On a file, `o` or Enter opens the actual current file in `$VISUAL` or `$EDITOR`, with its worktree as cwd. Edits made there are external and are not attributed to the agent.
|
|
82
|
+
- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut; `off` disables it. `GENTLE_PI_SHELL_CHANGES_POLL_MS` controls only the open overlay's in-memory refresh. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` no longer enables filesystem polling.
|
|
83
|
+
- No captured changes means no widget and an informational notice; it does not assert that the working tree is clean.
|
|
72
84
|
|
|
73
85
|
Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider:
|
|
74
86
|
|
package/docs/readme-reference.md
CHANGED
|
@@ -30,7 +30,7 @@ This reference preserves the detailed installation, configuration, SDD/OpenSpec,
|
|
|
30
30
|
| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. |
|
|
31
31
|
| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. |
|
|
32
32
|
| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. |
|
|
33
|
-
| **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.9.
|
|
33
|
+
| **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.9.1 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. |
|
|
34
34
|
| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. |
|
|
35
35
|
|
|
36
36
|
## Native pointer regions
|
|
@@ -67,7 +67,7 @@ The stable release is [`v2.6.0`](https://github.com/Gentleman-Programming/gentle
|
|
|
67
67
|
|
|
68
68
|
### Source checkout
|
|
69
69
|
|
|
70
|
-
This checkout prepares `gentle-pi` `2.
|
|
70
|
+
This checkout prepares `gentle-pi` `2.7.0`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v2.9.1`, distinct from the published `v2.6.0` pairing.
|
|
71
71
|
|
|
72
72
|
### Pi compatibility
|
|
73
73
|
|
|
@@ -94,7 +94,7 @@ pi install npm:gentle-pi@2.6.0
|
|
|
94
94
|
|
|
95
95
|
RDD remains opt-in. Enable it only through an explicit user decision with `/gentle:review-mode enable`; `status` lets you inspect the mode without changing it.
|
|
96
96
|
|
|
97
|
-
The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.9.
|
|
97
|
+
The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.9.1`; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.9.1` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error. To recover explicitly, if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before changing to the installed `gentle-pi` package directory. Then run `node scripts/install-gentle-ai.mjs`. This invokes the package-owned installer without relying on a global binary or npm configuration change. A missing binary can result from skipped lifecycle scripts, but does not prove that lifecycle scripts were disabled.
|
|
98
98
|
|
|
99
99
|
Recommended companion packages:
|
|
100
100
|
|
|
@@ -250,13 +250,13 @@ flowchart TD
|
|
|
250
250
|
|
|
251
251
|
VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent.
|
|
252
252
|
|
|
253
|
-
For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.9.
|
|
253
|
+
For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.9.1 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates.
|
|
254
254
|
|
|
255
255
|
Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing.
|
|
256
256
|
|
|
257
257
|
Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action.
|
|
258
258
|
|
|
259
|
-
Once the source checkout's pinned gentle-ai runtime (currently v2.9.
|
|
259
|
+
Once the source checkout's pinned gentle-ai runtime (currently v2.9.1) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path.
|
|
260
260
|
|
|
261
261
|
### FINALIZE wrapper input
|
|
262
262
|
|
|
@@ -3,6 +3,7 @@ import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isPare
|
|
|
3
3
|
import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddAttemptResult, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
6
|
+
import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
|
|
6
7
|
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
7
8
|
import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
|
|
8
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -885,6 +886,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
885
886
|
const root = deps.resolveWorktree(tool.path, task.cwd)?.root;
|
|
886
887
|
const childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
|
|
887
888
|
if (!root || root !== childRoot || !worktrees.roots().includes(root)) return;
|
|
889
|
+
if (tool.evidence?.root === root) {
|
|
890
|
+
try {
|
|
891
|
+
let path = tool.path.replace(/^@/, "");
|
|
892
|
+
if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
|
|
893
|
+
if (realpathSync(resolve(task.cwd, path)) === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
|
|
894
|
+
} catch { /* Missing or mismatched targets cannot supply session diffs. */ }
|
|
895
|
+
}
|
|
888
896
|
recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
|
|
889
897
|
},
|
|
890
898
|
onFinish: (task, observations) => {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { execFile, spawnSync } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import { statSync } from "node:fs";
|
|
5
|
+
import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
5
6
|
import * as os from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
8
|
-
import { CHANGE_STATUS,
|
|
9
|
+
import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
9
10
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
10
|
-
import { SessionWorktreeRegistry,
|
|
11
|
+
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
11
12
|
import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
12
13
|
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
13
14
|
import { framePromptLines, PROMPT_HINT, PROMPT_STATE, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
@@ -15,6 +16,8 @@ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, p
|
|
|
15
16
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
16
17
|
import { sidebarPart } from "../lib/shell-sidebar.ts";
|
|
17
18
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
19
|
+
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
20
|
+
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
18
21
|
|
|
19
22
|
// Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
|
|
20
23
|
// status bar, the petal prompt, the working-tree changes widget and overlay,
|
|
@@ -39,6 +42,7 @@ interface ShellBarComponent {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
interface BuildOptions {
|
|
45
|
+
profile?: string;
|
|
42
46
|
home?: string;
|
|
43
47
|
dirty?: number;
|
|
44
48
|
usage?: ProviderUsage;
|
|
@@ -47,6 +51,7 @@ interface BuildOptions {
|
|
|
47
51
|
export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
|
|
48
52
|
|
|
49
53
|
export interface ShellDeps {
|
|
54
|
+
activeProfile(): string | undefined;
|
|
50
55
|
fetch: typeof fetch;
|
|
51
56
|
now(): number;
|
|
52
57
|
devBinary(): DevBinaryNotice | undefined;
|
|
@@ -54,6 +59,31 @@ export interface ShellDeps {
|
|
|
54
59
|
gitRunner(cwd: string): GitRunner;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// The rail digest runs every frame. Cache parsing by file identity and metadata,
|
|
63
|
+
// not just mtime: profile writes replace the store atomically. Keep the cache
|
|
64
|
+
// local to this shell instance and recheck on the next frame after panel edits.
|
|
65
|
+
export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined {
|
|
66
|
+
const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
|
|
67
|
+
let fingerprint: string | undefined;
|
|
68
|
+
let name: string | undefined;
|
|
69
|
+
return () => {
|
|
70
|
+
try {
|
|
71
|
+
const stat = statSync(path, { bigint: true });
|
|
72
|
+
const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
73
|
+
if (next !== fingerprint) {
|
|
74
|
+
const result = readProfilesFileResult(path);
|
|
75
|
+
name = result.status === "valid" ? result.file.active : undefined;
|
|
76
|
+
fingerprint = next;
|
|
77
|
+
}
|
|
78
|
+
return name;
|
|
79
|
+
} catch {
|
|
80
|
+
fingerprint = undefined;
|
|
81
|
+
name = undefined;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
function ambientDevBinary(): DevBinaryNotice | undefined {
|
|
58
88
|
try {
|
|
59
89
|
const override = resolveGentleAiDevBinaryOverride();
|
|
@@ -64,7 +94,7 @@ function ambientDevBinary(): DevBinaryNotice | undefined {
|
|
|
64
94
|
}
|
|
65
95
|
}
|
|
66
96
|
|
|
67
|
-
const defaultShellDeps: ShellDeps = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
|
|
97
|
+
const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
|
|
68
98
|
|
|
69
99
|
interface AssistantUsageEntry {
|
|
70
100
|
type: string;
|
|
@@ -104,6 +134,7 @@ export function buildShellBarModel(
|
|
|
104
134
|
.map(([, text]) => text);
|
|
105
135
|
return {
|
|
106
136
|
cwd: shortenHome(ctx.sessionManager.getCwd(), home),
|
|
137
|
+
profile: options.profile,
|
|
107
138
|
branch: footerData.getGitBranch(),
|
|
108
139
|
dirty: options.dirty,
|
|
109
140
|
sessionName: ctx.sessionManager.getSessionName(),
|
|
@@ -221,7 +252,6 @@ const CHANGES_WIDGET_KEY = "gentle-shell-changes";
|
|
|
221
252
|
const CHANGES_COMMAND_NAME = "gentle:changes";
|
|
222
253
|
const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
223
254
|
const CHANGES_POLL_DEFAULT_MS = 2000;
|
|
224
|
-
const CHANGES_WATCH_DEFAULT_MS = 5000;
|
|
225
255
|
const GIT_TIMEOUT_MS = 5000;
|
|
226
256
|
const OVERLAY_HEIGHT_RATIO = 0.8;
|
|
227
257
|
const OVERLAY_MIN_ROWS = 8;
|
|
@@ -246,14 +276,6 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
|
|
|
246
276
|
});
|
|
247
277
|
}
|
|
248
278
|
|
|
249
|
-
function lineCounter(cwd: string): LineCounter {
|
|
250
|
-
return async (path: string) => {
|
|
251
|
-
const text = await readFile(join(cwd, path), "utf8");
|
|
252
|
-
if (text.length === 0) return 0;
|
|
253
|
-
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
279
|
export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
|
|
258
280
|
const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
|
|
259
281
|
const result = await git(args);
|
|
@@ -295,28 +317,19 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
|
|
|
295
317
|
return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
|
|
296
318
|
}
|
|
297
319
|
|
|
298
|
-
// Background watch: the widget and the bar follow edits made outside pi.
|
|
299
|
-
// 0 or "off" disables it; tool events still refresh.
|
|
300
|
-
function changesWatchMs(env: NodeJS.ProcessEnv): number | undefined {
|
|
301
|
-
const value = env.GENTLE_PI_SHELL_CHANGES_WATCH_MS?.trim().toLowerCase();
|
|
302
|
-
if (value === "0" || value === "off") return undefined;
|
|
303
|
-
return positiveMs(value, CHANGES_WATCH_DEFAULT_MS);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
320
|
function changesFingerprint(model: ChangesModel): string {
|
|
307
|
-
return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}`).join("|");
|
|
321
|
+
return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`).join("|");
|
|
308
322
|
}
|
|
309
323
|
|
|
310
324
|
interface OverlayDeps {
|
|
311
|
-
|
|
325
|
+
loadDiff(root: string, file: ChangedFile): string;
|
|
312
326
|
worktrees(): WorktreeChanges[];
|
|
313
327
|
refresh(): Promise<ChangesModel>;
|
|
314
328
|
apply(ctx: ExtensionContext, model: ChangesModel): void;
|
|
315
329
|
pollMs: number;
|
|
316
330
|
}
|
|
317
331
|
|
|
318
|
-
//
|
|
319
|
-
// another agent, a git checkout) show up without reopening it.
|
|
332
|
+
// Refresh only the captured session model. Never read live files or Git here.
|
|
320
333
|
async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
|
|
321
334
|
let host: ExternalEditorHost | undefined;
|
|
322
335
|
let view: WorktreeChangesView | undefined;
|
|
@@ -334,7 +347,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
334
347
|
view = new WorktreeChangesView(deps.worktrees(), {
|
|
335
348
|
theme,
|
|
336
349
|
rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
|
|
337
|
-
loadDiff: (root, file) =>
|
|
350
|
+
loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
|
|
338
351
|
onOpen: (root, file) => done({ root, file }),
|
|
339
352
|
onRefresh: () => void refresh(),
|
|
340
353
|
onClose: () => done(null),
|
|
@@ -451,8 +464,9 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
|
|
|
451
464
|
}
|
|
452
465
|
|
|
453
466
|
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
467
|
+
installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
|
|
454
468
|
if (!shellEnabled(env)) return;
|
|
455
|
-
const deps: ShellDeps = { ...defaultShellDeps, ...overrides };
|
|
469
|
+
const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
|
|
456
470
|
const usage = new UsageStore();
|
|
457
471
|
let renderHost: ShellRenderHost | undefined;
|
|
458
472
|
let usageFetchedAt = 0;
|
|
@@ -500,11 +514,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
500
514
|
},
|
|
501
515
|
});
|
|
502
516
|
let prompt: GentlePromptEditor | undefined;
|
|
503
|
-
let changes:
|
|
517
|
+
let changes: SessionChanges | undefined;
|
|
504
518
|
let registry: SessionWorktreeRegistry | undefined;
|
|
505
519
|
let currentContext: ExtensionContext | undefined;
|
|
506
|
-
const pendingTools = new Map<string, { sessionId: string; path?: string }>();
|
|
507
|
-
let watch: NodeJS.Timeout | undefined;
|
|
508
520
|
let shown = "";
|
|
509
521
|
const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
|
|
510
522
|
const fingerprint = changesFingerprint(model);
|
|
@@ -515,22 +527,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
515
527
|
const refreshChanges = async (ctx: ExtensionContext) => {
|
|
516
528
|
const tracker = changes;
|
|
517
529
|
if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
|
|
530
|
+
tracker.restore(ctx.sessionManager.getEntries());
|
|
518
531
|
const model = await tracker.refresh();
|
|
519
532
|
if (changes === tracker) applyChanges(ctx, model);
|
|
520
533
|
};
|
|
521
|
-
const
|
|
522
|
-
if (watch) clearInterval(watch);
|
|
523
|
-
watch = undefined;
|
|
524
|
-
};
|
|
525
|
-
const unsubscribeWorktrees = pi.events.on(SESSION_WORKTREE_CHANGED, (data) => {
|
|
534
|
+
const unsubscribeWorktrees = pi.events.on(SESSION_CHANGE_EVENT, (data) => {
|
|
526
535
|
if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
|
|
536
|
+
if ((data as { notice?: string }).notice) currentContext.ui.notify((data as { notice: string }).notice, "warning");
|
|
527
537
|
void refreshChanges(currentContext);
|
|
528
538
|
});
|
|
529
539
|
pi.registerTool({
|
|
530
540
|
name: "session_worktree_register",
|
|
531
541
|
renderShell: "self",
|
|
532
542
|
label: "Register session worktree",
|
|
533
|
-
description: "Register a worktree
|
|
543
|
+
description: "Register a worktree in the same Git clone for session coordination. Registration does not attribute file changes; Changes shows captured write/edit operations only.",
|
|
534
544
|
parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
|
|
535
545
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
536
546
|
if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
|
|
@@ -540,16 +550,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
540
550
|
},
|
|
541
551
|
});
|
|
542
552
|
pi.on("session_start", async (_event, ctx) => {
|
|
543
|
-
stopWatch();
|
|
544
553
|
registry?.close();
|
|
545
|
-
pendingTools.clear();
|
|
546
554
|
currentContext = ctx;
|
|
547
555
|
changes = undefined;
|
|
548
556
|
registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
|
|
549
557
|
registry.start();
|
|
550
|
-
const sessionRegistry = registry;
|
|
551
558
|
if (!ctx.hasUI) return;
|
|
552
|
-
changes = new
|
|
559
|
+
changes = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
|
|
553
560
|
const tracker = changes;
|
|
554
561
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
555
562
|
renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
|
|
@@ -558,7 +565,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
558
565
|
// part for: model, effort, context, cost, session name and extension
|
|
559
566
|
// statuses. The digest is what keeps the fullscreen memo honest, and it
|
|
560
567
|
// rebuilds the model exactly as the narrow bottom bar does every frame.
|
|
561
|
-
const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? "") });
|
|
568
|
+
const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() });
|
|
562
569
|
const part = sidebarPart(tui, "footer", bottom, {
|
|
563
570
|
digest: () => JSON.stringify(footerModel()),
|
|
564
571
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width),
|
|
@@ -580,24 +587,15 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
580
587
|
? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
|
|
581
588
|
: undefined,
|
|
582
589
|
);
|
|
583
|
-
await tracker.start();
|
|
584
590
|
if (changes !== tracker) return;
|
|
585
591
|
shown = "";
|
|
586
592
|
applyChanges(ctx, tracker.model);
|
|
587
|
-
stopWatch();
|
|
588
|
-
const watchMs = changesWatchMs(env);
|
|
589
|
-
if (watchMs) {
|
|
590
|
-
watch = setInterval(() => void refreshChanges(ctx), watchMs);
|
|
591
|
-
watch.unref();
|
|
592
|
-
}
|
|
593
593
|
});
|
|
594
594
|
pi.on("session_shutdown", () => {
|
|
595
|
-
stopWatch();
|
|
596
595
|
registry?.close();
|
|
597
596
|
registry = undefined;
|
|
598
597
|
changes = undefined;
|
|
599
598
|
currentContext = undefined;
|
|
600
|
-
pendingTools.clear();
|
|
601
599
|
unsubscribeWorktrees();
|
|
602
600
|
});
|
|
603
601
|
const openChanges = async (ctx: ExtensionContext) => {
|
|
@@ -605,19 +603,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
605
603
|
const tracker = changes;
|
|
606
604
|
const model = await tracker.refresh();
|
|
607
605
|
if (model.files.length === 0) {
|
|
608
|
-
ctx.ui.notify("No changes
|
|
606
|
+
ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
|
|
609
607
|
return;
|
|
610
608
|
}
|
|
611
|
-
await showChangesOverlay(ctx, {
|
|
609
|
+
await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
|
|
612
610
|
};
|
|
613
611
|
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
614
|
-
description: "Browse this session
|
|
612
|
+
description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
|
|
615
613
|
handler: async (_args, ctx) => openChanges(ctx),
|
|
616
614
|
});
|
|
617
615
|
const shortcut = changesShortcut(env);
|
|
618
616
|
if (shortcut) {
|
|
619
617
|
pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
620
|
-
description: "Open
|
|
618
|
+
description: "Open captured agent session changes",
|
|
621
619
|
handler: async (ctx) => openChanges(ctx),
|
|
622
620
|
});
|
|
623
621
|
}
|
|
@@ -631,20 +629,4 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
631
629
|
await refreshChanges(ctx);
|
|
632
630
|
void refreshUsage(ctx, false);
|
|
633
631
|
});
|
|
634
|
-
pi.on("tool_execution_start", (event, ctx) => {
|
|
635
|
-
pendingTools.set(event.toolCallId, { sessionId: ctx.sessionManager.getSessionId() });
|
|
636
|
-
});
|
|
637
|
-
pi.on("tool_result", (event, ctx) => {
|
|
638
|
-
const pending = pendingTools.get(event.toolCallId);
|
|
639
|
-
if (pending?.sessionId === ctx.sessionManager.getSessionId()) pending.path = toolWorktreePath(event.toolName, event.input);
|
|
640
|
-
});
|
|
641
|
-
pi.on("tool_execution_end", async (event, ctx) => {
|
|
642
|
-
const pending = pendingTools.get(event.toolCallId);
|
|
643
|
-
pendingTools.delete(event.toolCallId);
|
|
644
|
-
if (!event.isError && pending?.path !== undefined && pending.sessionId === registry?.sessionId && pending.sessionId === ctx.sessionManager.getSessionId()) {
|
|
645
|
-
try { registry.register(pending.path, `tool:${event.toolName}`); }
|
|
646
|
-
catch { /* Non-project paths and missing roots do not expand the registry. */ }
|
|
647
|
-
}
|
|
648
|
-
await refreshChanges(ctx);
|
|
649
|
-
});
|
|
650
632
|
}
|
package/lib/agents-runner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
|
|
2
3
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
3
4
|
import { stripVTControlCharacters } from "node:util";
|
|
4
5
|
import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
|
|
@@ -100,7 +101,7 @@ export interface RunnerHooks {
|
|
|
100
101
|
onNotification?(task: TaskRecord, message: string): boolean | void;
|
|
101
102
|
onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
|
|
102
103
|
// Parent-only observation of a paired successful filesystem tool, not prose.
|
|
103
|
-
onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
|
|
104
|
+
onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string; evidence?: SessionChangeEvidence }): void | Promise<void>;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
export interface RemediationHarnessPlan { command?: string; naReason?: string }
|
|
@@ -821,7 +822,11 @@ export class AgentRunner {
|
|
|
821
822
|
live.mutationStarts.delete(event.callId);
|
|
822
823
|
const task = this.store.get(id);
|
|
823
824
|
if (mutation && task && raw.isError === false && !event.isError) {
|
|
824
|
-
try {
|
|
825
|
+
try {
|
|
826
|
+
const evidence = (raw.result as { details?: { gentleSessionChange?: unknown } } | undefined)?.details?.gentleSessionChange;
|
|
827
|
+
const observed = isSessionChangeEvidence(evidence) && evidence.id === mutation.toolCallId ? { ...mutation, evidence: structuredClone(evidence) } : mutation;
|
|
828
|
+
void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, observed)).catch(() => {});
|
|
829
|
+
}
|
|
825
830
|
catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
|
|
826
831
|
}
|
|
827
832
|
}
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -993,6 +993,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
993
993
|
// remain dark because neither is proven to reach the negotiated START
|
|
994
994
|
// path Pi consumes.
|
|
995
995
|
"2.9.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
996
|
+
// v2.9.1 shipped restoring compatible OpenCode review consent (#4584) and
|
|
997
|
+
// deriving Claude Code SDD dispatch authority from the session transcript
|
|
998
|
+
// (#4575, #4551). Ground-truthed by diffing contracts/review-integration/v2
|
|
999
|
+
// and contracts/review-provider-contract between the v2.9.0 and v2.9.1 tags
|
|
1000
|
+
// in the gentle-ai source tree: zero bytes changed. Neither change touches
|
|
1001
|
+
// the closed START/STATUS fields this row negotiates, so it repeats 2.9.0
|
|
1002
|
+
// exactly. riskEvidence and hint remain dark because neither is proven to
|
|
1003
|
+
// reach the negotiated START path Pi consumes.
|
|
1004
|
+
"2.9.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
996
1005
|
});
|
|
997
1006
|
|
|
998
1007
|
export interface NativeReviewProcessDiagnostics {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, relative, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
|
|
7
|
+
import { SessionChanges, readChangeSnapshot, sameSnapshot, textSnapshot, isSessionChangeEvidence,
|
|
8
|
+
SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
|
|
9
|
+
|
|
10
|
+
interface Pending { sessionId: string; inputPath: string; path: string; root: string; relativePath: string; before: ChangeSnapshot; toolName: string; evidence?: SessionChangeEvidence }
|
|
11
|
+
const normalized = (text: string) => text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
12
|
+
const unknown = (): ChangeSnapshot => ({ kind: "unavailable", reason: "Tool snapshots could not be verified; diff unavailable." });
|
|
13
|
+
|
|
14
|
+
/** Observe explicit write/edit outcomes, never infer ownership from Git status or shell text. */
|
|
15
|
+
export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, resolver: WorktreeResolver = resolveSessionWorktree): void {
|
|
16
|
+
const child = env.GENTLE_PI_AGENTS_CHILD === "1";
|
|
17
|
+
const pending = new Map<string, Pending>();
|
|
18
|
+
let current: ExtensionContext | undefined;
|
|
19
|
+
let store: SessionChanges | undefined;
|
|
20
|
+
const publish = (evidence: SessionChangeEvidence) => {
|
|
21
|
+
if (!current || !store || current.sessionManager.getSessionId() !== store.sessionId) return;
|
|
22
|
+
if (store.record(evidence)) pi.appendEntry(SESSION_CHANGE_ENTRY, { sessionId: store.sessionId, evidence });
|
|
23
|
+
pi.events.emit(SESSION_CHANGE_EVENT, { sessionId: store.sessionId, notice: store.notice });
|
|
24
|
+
};
|
|
25
|
+
pi.on("session_start", (_event, ctx) => {
|
|
26
|
+
pending.clear(); current = ctx;
|
|
27
|
+
store = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
|
|
28
|
+
});
|
|
29
|
+
const off = pi.events.on(SESSION_CHANGE_RELAY, (value) => {
|
|
30
|
+
const data = value as { sessionId?: string; evidence?: unknown };
|
|
31
|
+
if (child || !current || data?.sessionId !== current.sessionManager.getSessionId() || !isSessionChangeEvidence(data.evidence)) return;
|
|
32
|
+
try {
|
|
33
|
+
const own = resolver(current.cwd, current.cwd);
|
|
34
|
+
const target = resolver(data.evidence.root, current.cwd);
|
|
35
|
+
if (own && target?.root === data.evidence.root && own.commonDir === target.commonDir) publish(data.evidence);
|
|
36
|
+
} catch { /* Observation cannot change tool outcomes. */ }
|
|
37
|
+
});
|
|
38
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
39
|
+
const input = event.input as Record<string, unknown>;
|
|
40
|
+
if (!["write", "edit"].includes(event.toolName) || typeof input.path !== "string" || pending.size >= 32) return;
|
|
41
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
42
|
+
try {
|
|
43
|
+
let spelling = input.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
44
|
+
if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
|
|
45
|
+
const path = resolve(ctx.cwd, spelling);
|
|
46
|
+
try { if ((await lstat(path)).isSymbolicLink()) return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
|
|
47
|
+
let ancestor = dirname(path);
|
|
48
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
49
|
+
try { await lstat(ancestor); break; } catch { if (dirname(ancestor) === ancestor) return; ancestor = dirname(ancestor); }
|
|
50
|
+
}
|
|
51
|
+
const canonicalAncestor = await realpath(ancestor);
|
|
52
|
+
const canonicalPath = resolve(canonicalAncestor, relative(ancestor, path));
|
|
53
|
+
const own = resolver(ctx.cwd, ctx.cwd), target = resolver(canonicalAncestor, ctx.cwd);
|
|
54
|
+
if (!own || !target || own.commonDir !== target.commonDir) return;
|
|
55
|
+
const before = await readChangeSnapshot(canonicalPath);
|
|
56
|
+
if (ctx.sessionManager.getSessionId() !== sessionId || current?.sessionManager.getSessionId() !== sessionId) return;
|
|
57
|
+
pending.set(event.toolCallId, { sessionId, inputPath: input.path, path: canonicalPath, root: target.root, relativePath: relative(target.root, canonicalPath), before, toolName: event.toolName });
|
|
58
|
+
} catch { /* No bookkeeping error blocks an edit. */ }
|
|
59
|
+
});
|
|
60
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
61
|
+
const item = pending.get(event.toolCallId);
|
|
62
|
+
if (!item || item.sessionId !== ctx.sessionManager.getSessionId() || item.toolName !== event.toolName || event.input.path !== item.inputPath || event.isError !== false) { pending.delete(event.toolCallId); return; }
|
|
63
|
+
try {
|
|
64
|
+
const after = await readChangeSnapshot(item.path);
|
|
65
|
+
let before = item.before;
|
|
66
|
+
let verified = after;
|
|
67
|
+
if (event.toolName === "write" && after.kind === "text" && (typeof event.input.content !== "string" || !sameSnapshot(after, textSnapshot(event.input.content)))) {
|
|
68
|
+
before = unknown(); verified = unknown();
|
|
69
|
+
}
|
|
70
|
+
if (event.toolName === "edit" && before.kind === "text" && after.kind === "text") {
|
|
71
|
+
const patch = (event.details as { patch?: unknown } | undefined)?.patch;
|
|
72
|
+
if (typeof event.input.path !== "string" || patch !== generateUnifiedPatch(event.input.path, normalized(before.text), normalized(after.text))) { before = unknown(); verified = unknown(); }
|
|
73
|
+
}
|
|
74
|
+
if (item.sessionId !== ctx.sessionManager.getSessionId() || pending.get(event.toolCallId) !== item) return;
|
|
75
|
+
item.evidence = { id: event.toolCallId, root: item.root, path: item.relativePath, before, after: verified };
|
|
76
|
+
// Use the existing RPC tool-result envelope, not a new IPC channel or a model message.
|
|
77
|
+
if (child) return { details: { ...(event.details && typeof event.details === "object" ? event.details : {}), gentleSessionChange: item.evidence } };
|
|
78
|
+
} catch { pending.delete(event.toolCallId); }
|
|
79
|
+
});
|
|
80
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
81
|
+
const item = pending.get(event.toolCallId); pending.delete(event.toolCallId);
|
|
82
|
+
if (!child && event.isError === false && item?.evidence && item.sessionId === ctx.sessionManager.getSessionId()) {
|
|
83
|
+
try { publish(item.evidence); } catch { /* Preserve the tool's outcome. */ }
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
pi.on("session_shutdown", () => { pending.clear(); current = undefined; store = undefined; off(); });
|
|
87
|
+
}
|