@dungle-scrubs/tallow 0.8.7 → 0.8.8
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/dist/cli.js +244 -54
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/sdk.d.ts +39 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +322 -13
- package/dist/sdk.js.map +1 -1
- package/dist/session-utils.d.ts +8 -0
- package/dist/session-utils.d.ts.map +1 -1
- package/dist/session-utils.js +38 -1
- package/dist/session-utils.js.map +1 -1
- package/extensions/__integration__/plan-rejection-feedback.test.ts +272 -0
- package/extensions/__integration__/worktree.test.ts +88 -0
- package/extensions/_icons/index.ts +2 -3
- package/extensions/_shared/inline-preview.ts +2 -4
- package/extensions/_shared/interop-events.ts +48 -0
- package/extensions/_shared/shell-policy.ts +2 -1
- package/extensions/_shared/tallow-paths.ts +48 -0
- package/extensions/agent-commands-tool/__tests__/parsing.test.ts +40 -1
- package/extensions/agent-commands-tool/index.ts +38 -6
- package/extensions/background-task-tool/index.ts +2 -2
- package/extensions/bash-tool-enhanced/index.ts +3 -4
- package/extensions/cd-tool/index.ts +3 -3
- package/extensions/claude-bridge/index.ts +2 -1
- package/extensions/command-prompt/__tests__/plugin-sources.test.ts +49 -0
- package/extensions/command-prompt/index.ts +36 -0
- package/extensions/context-files/index.ts +2 -1
- package/extensions/context-fork/index.ts +2 -1
- package/extensions/context-usage/__tests__/tool-result-memory.test.ts +62 -0
- package/extensions/context-usage/index.ts +169 -8
- package/extensions/debug/__tests__/analysis.test.ts +35 -2
- package/extensions/debug/__tests__/diagnostics-commands.test.ts +11 -2
- package/extensions/debug/analysis.ts +53 -16
- package/extensions/debug/index.ts +84 -10
- package/extensions/debug/logger.ts +2 -2
- package/extensions/health/index.ts +2 -2
- package/extensions/hooks/__tests__/claude-compat.test.ts +31 -0
- package/extensions/hooks/extension.json +3 -1
- package/extensions/hooks/hooks.schema.json +17 -1
- package/extensions/hooks/index.ts +52 -7
- package/extensions/lsp/index.ts +2 -7
- package/extensions/output-styles-tool/index.ts +2 -4
- package/extensions/plan-mode-tool/extension.json +1 -0
- package/extensions/plan-mode-tool/index.ts +119 -4
- package/extensions/prompt-suggestions/index.ts +2 -3
- package/extensions/random-spinner/index.ts +2 -3
- package/extensions/read-tool-enhanced/__tests__/notebook-read.test.ts +100 -0
- package/extensions/read-tool-enhanced/__tests__/notebook.test.ts +136 -0
- package/extensions/read-tool-enhanced/extension.json +4 -4
- package/extensions/read-tool-enhanced/index.ts +112 -10
- package/extensions/read-tool-enhanced/notebook.ts +526 -0
- package/extensions/rewind/__tests__/snapshots.test.ts +43 -1
- package/extensions/rewind/snapshots.ts +18 -6
- package/extensions/session-memory/index.ts +3 -2
- package/extensions/stats/stats-log.ts +2 -3
- package/extensions/subagent-tool/__tests__/discovery-defaults.test.ts +23 -0
- package/extensions/subagent-tool/__tests__/isolation-frontmatter.test.ts +100 -0
- package/extensions/subagent-tool/__tests__/model-router.test.ts +8 -0
- package/extensions/subagent-tool/agents.ts +77 -16
- package/extensions/subagent-tool/index.ts +213 -48
- package/extensions/subagent-tool/model-router.ts +3 -3
- package/extensions/subagent-tool/process.ts +233 -22
- package/extensions/subagent-tool/schema.ts +11 -0
- package/extensions/subagent-tool/widget.ts +15 -2
- package/extensions/tasks/__tests__/widget-subagents.test.ts +28 -7
- package/extensions/tasks/commands/register-tasks-extension.ts +15 -33
- package/extensions/tasks/state/index.ts +2 -2
- package/extensions/theme-selector/index.ts +3 -7
- package/extensions/worktree/__tests__/lifecycle.test.ts +115 -0
- package/extensions/worktree/extension.json +31 -0
- package/extensions/worktree/index.ts +149 -0
- package/extensions/worktree/lifecycle.ts +372 -0
- package/package.json +1 -1
- package/skills/tallow-expert/SKILL.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { runGitCommandSync } from "../../_shared/shell-policy.js";
|
|
@@ -114,6 +114,48 @@ describe("SnapshotManager", () => {
|
|
|
114
114
|
expect(result.restored.length).toBeGreaterThan(0);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it("creates snapshots from nested subdirectories", () => {
|
|
118
|
+
const subDir = join(tmpDir, "sub");
|
|
119
|
+
mkdirSync(subDir, { recursive: true });
|
|
120
|
+
|
|
121
|
+
writeFileSync(join(tmpDir, "root.txt"), "base-root");
|
|
122
|
+
git(["add", "-A"], tmpDir);
|
|
123
|
+
git(["commit", "-m", "base"], tmpDir);
|
|
124
|
+
|
|
125
|
+
const subMgr = new SnapshotManager(subDir, "nested-session");
|
|
126
|
+
writeFileSync(join(tmpDir, "root.txt"), "changed-from-subdir");
|
|
127
|
+
|
|
128
|
+
const ref = subMgr.createSnapshot(1);
|
|
129
|
+
expect(ref).toBe("refs/tallow/rewind/nested-session/turn-1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("restores full repo state when manager cwd is a subdirectory", () => {
|
|
133
|
+
const subDir = join(tmpDir, "sub");
|
|
134
|
+
mkdirSync(subDir, { recursive: true });
|
|
135
|
+
writeFileSync(join(tmpDir, "root.txt"), "base-root");
|
|
136
|
+
writeFileSync(join(subDir, "a.txt"), "base-a");
|
|
137
|
+
git(["add", "-A"], tmpDir);
|
|
138
|
+
git(["commit", "-m", "base"], tmpDir);
|
|
139
|
+
|
|
140
|
+
const subMgr = new SnapshotManager(subDir, "nested-session");
|
|
141
|
+
writeFileSync(join(tmpDir, "root.txt"), "snap-root");
|
|
142
|
+
writeFileSync(join(subDir, "a.txt"), "snap-a");
|
|
143
|
+
writeFileSync(join(subDir, "new.txt"), "snap-new");
|
|
144
|
+
const ref = subMgr.createSnapshot(1);
|
|
145
|
+
expect(ref).not.toBeNull();
|
|
146
|
+
|
|
147
|
+
writeFileSync(join(tmpDir, "root.txt"), "after-root");
|
|
148
|
+
writeFileSync(join(subDir, "a.txt"), "after-a");
|
|
149
|
+
writeFileSync(join(subDir, "after-only.txt"), "after-only");
|
|
150
|
+
|
|
151
|
+
subMgr.restoreSnapshot(ref as string);
|
|
152
|
+
|
|
153
|
+
expect(readFileSync(join(tmpDir, "root.txt"), "utf-8")).toBe("snap-root");
|
|
154
|
+
expect(readFileSync(join(subDir, "a.txt"), "utf-8")).toBe("snap-a");
|
|
155
|
+
expect(readFileSync(join(subDir, "new.txt"), "utf-8")).toBe("snap-new");
|
|
156
|
+
expect(existsSync(join(subDir, "after-only.txt"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
117
159
|
it("should remove files created after the snapshot point", () => {
|
|
118
160
|
// Snapshot at turn 1 — no new-file.ts
|
|
119
161
|
writeFileSync(join(tmpDir, "a.txt"), "v1");
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
18
19
|
import { unlinkSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
19
21
|
import { join } from "node:path";
|
|
20
22
|
import { runGitCommandSync } from "../_shared/shell-policy.js";
|
|
21
23
|
|
|
@@ -43,7 +45,7 @@ export interface SnapshotInfo {
|
|
|
43
45
|
* Snapshots are stored as lightweight refs that can be restored independently.
|
|
44
46
|
*/
|
|
45
47
|
export class SnapshotManager {
|
|
46
|
-
private readonly
|
|
48
|
+
private readonly repoRoot: string;
|
|
47
49
|
private readonly refPrefix: string;
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -53,10 +55,20 @@ export class SnapshotManager {
|
|
|
53
55
|
* @param sessionId - Session ID for namespacing refs
|
|
54
56
|
*/
|
|
55
57
|
constructor(cwd: string, sessionId: string) {
|
|
56
|
-
this.
|
|
58
|
+
this.repoRoot = this.resolveRepoRoot(cwd) ?? cwd;
|
|
57
59
|
this.refPrefix = `refs/tallow/rewind/${sessionId}`;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the canonical git repository root for the manager's cwd.
|
|
64
|
+
*
|
|
65
|
+
* @param cwd - Working directory that may be a nested subdirectory
|
|
66
|
+
* @returns Absolute repo root path, or null when outside a git worktree
|
|
67
|
+
*/
|
|
68
|
+
private resolveRepoRoot(cwd: string): string | null {
|
|
69
|
+
return runGitCommandSync(["rev-parse", "--show-toplevel"], cwd, 10_000);
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
/**
|
|
61
73
|
* Checks whether the cwd is inside a git repository.
|
|
62
74
|
*
|
|
@@ -84,7 +96,7 @@ export class SnapshotManager {
|
|
|
84
96
|
* @returns The ref name, or null if nothing to snapshot
|
|
85
97
|
*/
|
|
86
98
|
createSnapshot(turnIndex: number): string | null {
|
|
87
|
-
const tmpIndex = join(
|
|
99
|
+
const tmpIndex = join(tmpdir(), `tallow-snapshot-index-${process.pid}-${randomUUID()}`);
|
|
88
100
|
|
|
89
101
|
try {
|
|
90
102
|
// Stage everything into the temp index (captures untracked files too)
|
|
@@ -154,7 +166,7 @@ export class SnapshotManager {
|
|
|
154
166
|
// Use fs.unlinkSync for reliability — git rm only works for tracked files.
|
|
155
167
|
for (const file of filesToDelete) {
|
|
156
168
|
try {
|
|
157
|
-
unlinkSync(join(this.
|
|
169
|
+
unlinkSync(join(this.repoRoot, file));
|
|
158
170
|
} catch {
|
|
159
171
|
// File might already be gone — best effort
|
|
160
172
|
}
|
|
@@ -252,7 +264,7 @@ export class SnapshotManager {
|
|
|
252
264
|
* @returns Trimmed output, or null on failure
|
|
253
265
|
*/
|
|
254
266
|
private git(args: string[]): string | null {
|
|
255
|
-
return runGitCommandSync(args, this.
|
|
267
|
+
return runGitCommandSync(args, this.repoRoot, 10_000);
|
|
256
268
|
}
|
|
257
269
|
|
|
258
270
|
/**
|
|
@@ -267,7 +279,7 @@ export class SnapshotManager {
|
|
|
267
279
|
*/
|
|
268
280
|
private gitWithEnv(args: string[], env: Record<string, string>): string | null {
|
|
269
281
|
const result = spawnSync("git", args, {
|
|
270
|
-
cwd: this.
|
|
282
|
+
cwd: this.repoRoot,
|
|
271
283
|
encoding: "utf-8",
|
|
272
284
|
timeout: 10_000,
|
|
273
285
|
maxBuffer: 10 * 1024 * 1024,
|
|
@@ -19,6 +19,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
19
19
|
import { Text } from "@mariozechner/pi-tui";
|
|
20
20
|
import { Type } from "@sinclair/typebox";
|
|
21
21
|
import { getIcon } from "../_icons/index.js";
|
|
22
|
+
import { getDefaultTallowHomeDir, getTallowHomeDir } from "../_shared/tallow-paths.js";
|
|
22
23
|
import { buildCuratorPrompt } from "./curator-prompt.js";
|
|
23
24
|
import { SessionIndexer } from "./indexer.js";
|
|
24
25
|
import type { SearchResult } from "./types.js";
|
|
@@ -45,7 +46,7 @@ let indexer: SessionIndexer | null = null;
|
|
|
45
46
|
* @returns Path to the tallow config directory (e.g., ~/.tallow or ~/.tallow-fuse)
|
|
46
47
|
*/
|
|
47
48
|
function getTallowHome(): string {
|
|
48
|
-
return
|
|
49
|
+
return getTallowHomeDir();
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -66,7 +67,7 @@ function discoverAllSessionsDirs(): string[] {
|
|
|
66
67
|
if (existsSync(currentSessions)) dirs.add(currentSessions);
|
|
67
68
|
|
|
68
69
|
// Default tallow home (if different from current)
|
|
69
|
-
const defaultSessions = join(
|
|
70
|
+
const defaultSessions = join(getDefaultTallowHomeDir(), "sessions");
|
|
70
71
|
if (existsSync(defaultSessions)) dirs.add(defaultSessions);
|
|
71
72
|
|
|
72
73
|
// All per-project tallow homes from work-dirs config
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import * as os from "node:os";
|
|
11
10
|
import * as path from "node:path";
|
|
11
|
+
import { getTallowHomeDir } from "../_shared/tallow-paths.js";
|
|
12
12
|
|
|
13
13
|
// ── Schema ───────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -54,8 +54,7 @@ export interface SessionStats {
|
|
|
54
54
|
* @returns Absolute path to stats.jsonl
|
|
55
55
|
*/
|
|
56
56
|
export function getStatsLogPath(): string {
|
|
57
|
-
|
|
58
|
-
return path.join(tallowHome, "stats.jsonl");
|
|
57
|
+
return path.join(getTallowHomeDir(), "stats.jsonl");
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
// ── Write ────────────────────────────────────────────────────────────────────
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - Agent scoring heuristics
|
|
11
11
|
*/
|
|
12
12
|
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { resolveEffectiveIsolation } from "../agents.js";
|
|
13
14
|
|
|
14
15
|
// ── Helpers (mirrored from subagent-tool/index.ts) ───────────────────────────
|
|
15
16
|
|
|
@@ -319,3 +320,25 @@ describe("mergeDefaults", () => {
|
|
|
319
320
|
expect(result.missingAgentBehavior).toBe("match-or-ephemeral"); // built-in (others didn't set)
|
|
320
321
|
});
|
|
321
322
|
});
|
|
323
|
+
|
|
324
|
+
describe("resolveEffectiveIsolation", () => {
|
|
325
|
+
it("uses per-call isolation when provided", () => {
|
|
326
|
+
const effective = resolveEffectiveIsolation("worktree", undefined, undefined);
|
|
327
|
+
expect(effective).toBe("worktree");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("falls back to agent frontmatter isolation", () => {
|
|
331
|
+
const effective = resolveEffectiveIsolation(undefined, "worktree", undefined);
|
|
332
|
+
expect(effective).toBe("worktree");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("falls back to defaults isolation", () => {
|
|
336
|
+
const effective = resolveEffectiveIsolation(undefined, undefined, "worktree");
|
|
337
|
+
expect(effective).toBe("worktree");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("returns undefined when isolation is unset everywhere", () => {
|
|
341
|
+
const effective = resolveEffectiveIsolation(undefined, undefined, undefined);
|
|
342
|
+
expect(effective).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverAgents } from "../agents.js";
|
|
6
|
+
|
|
7
|
+
const createdDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const dir of createdDirs.splice(0)) {
|
|
11
|
+
rmSync(dir, { force: true, recursive: true });
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a temporary project directory with a `.tallow/agents` folder.
|
|
17
|
+
*
|
|
18
|
+
* @returns Project root path
|
|
19
|
+
*/
|
|
20
|
+
function createProjectWithAgentsDir(): string {
|
|
21
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "subagent-isolation-test-"));
|
|
22
|
+
createdDirs.push(projectRoot);
|
|
23
|
+
mkdirSync(join(projectRoot, ".tallow", "agents"), { recursive: true });
|
|
24
|
+
return projectRoot;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write a markdown agent definition into a temp project.
|
|
29
|
+
*
|
|
30
|
+
* @param projectRoot - Project root containing `.tallow/agents`
|
|
31
|
+
* @param fileName - Markdown filename
|
|
32
|
+
* @param content - Full markdown content
|
|
33
|
+
*/
|
|
34
|
+
function writeAgentFile(projectRoot: string, fileName: string, content: string): void {
|
|
35
|
+
writeFileSync(join(projectRoot, ".tallow", "agents", fileName), content, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("agent frontmatter isolation", () => {
|
|
39
|
+
it("parses `isolation: worktree` from agent frontmatter", () => {
|
|
40
|
+
const projectRoot = createProjectWithAgentsDir();
|
|
41
|
+
writeAgentFile(
|
|
42
|
+
projectRoot,
|
|
43
|
+
"worker.md",
|
|
44
|
+
`---
|
|
45
|
+
name: worker
|
|
46
|
+
description: test agent
|
|
47
|
+
isolation: worktree
|
|
48
|
+
---
|
|
49
|
+
You are a worker.
|
|
50
|
+
`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const discovered = discoverAgents(projectRoot, "project");
|
|
54
|
+
expect(discovered.agents).toHaveLength(1);
|
|
55
|
+
expect(discovered.agents[0]?.isolation).toBe("worktree");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses defaults isolation from _defaults.md", () => {
|
|
59
|
+
const projectRoot = createProjectWithAgentsDir();
|
|
60
|
+
writeAgentFile(
|
|
61
|
+
projectRoot,
|
|
62
|
+
"_defaults.md",
|
|
63
|
+
`---
|
|
64
|
+
isolation: worktree
|
|
65
|
+
---
|
|
66
|
+
`
|
|
67
|
+
);
|
|
68
|
+
writeAgentFile(
|
|
69
|
+
projectRoot,
|
|
70
|
+
"worker.md",
|
|
71
|
+
`---
|
|
72
|
+
name: worker
|
|
73
|
+
description: test agent
|
|
74
|
+
---
|
|
75
|
+
You are a worker.
|
|
76
|
+
`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const discovered = discoverAgents(projectRoot, "project");
|
|
80
|
+
expect(discovered.defaults.isolation).toBe("worktree");
|
|
81
|
+
expect(discovered.agents[0]?.isolation).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects invalid isolation values deterministically", () => {
|
|
85
|
+
const projectRoot = createProjectWithAgentsDir();
|
|
86
|
+
writeAgentFile(
|
|
87
|
+
projectRoot,
|
|
88
|
+
"worker.md",
|
|
89
|
+
`---
|
|
90
|
+
name: worker
|
|
91
|
+
description: test agent
|
|
92
|
+
isolation: sandbox
|
|
93
|
+
---
|
|
94
|
+
You are a worker.
|
|
95
|
+
`
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(() => discoverAgents(projectRoot, "project")).toThrow(/Invalid isolation/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -15,6 +15,7 @@ const { loadRoutingConfig } = await import("../model-router.js");
|
|
|
15
15
|
let testCwd = "";
|
|
16
16
|
let testHome = "";
|
|
17
17
|
let originalHome: string | undefined;
|
|
18
|
+
let originalTallowHome: string | undefined;
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Write a JSON file, creating parent directories as needed.
|
|
@@ -32,7 +33,9 @@ beforeEach(() => {
|
|
|
32
33
|
testCwd = mkdtempSync(join(tmpdir(), "tallow-routing-cwd-"));
|
|
33
34
|
testHome = mkdtempSync(join(tmpdir(), "tallow-routing-home-"));
|
|
34
35
|
originalHome = process.env.HOME;
|
|
36
|
+
originalTallowHome = process.env.TALLOW_CODING_AGENT_DIR;
|
|
35
37
|
process.env.HOME = testHome;
|
|
38
|
+
process.env.TALLOW_CODING_AGENT_DIR = join(testHome, ".tallow");
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
afterEach(() => {
|
|
@@ -41,6 +44,11 @@ afterEach(() => {
|
|
|
41
44
|
} else {
|
|
42
45
|
process.env.HOME = originalHome;
|
|
43
46
|
}
|
|
47
|
+
if (originalTallowHome === undefined) {
|
|
48
|
+
delete process.env.TALLOW_CODING_AGENT_DIR;
|
|
49
|
+
} else {
|
|
50
|
+
process.env.TALLOW_CODING_AGENT_DIR = originalTallowHome;
|
|
51
|
+
}
|
|
44
52
|
rmSync(testCwd, { recursive: true, force: true });
|
|
45
53
|
rmSync(testHome, { recursive: true, force: true });
|
|
46
54
|
});
|
|
@@ -12,6 +12,8 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { getTallowPath } from "../_shared/tallow-paths.js";
|
|
16
|
+
import type { IsolationMode } from "./schema.js";
|
|
15
17
|
|
|
16
18
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -30,6 +32,7 @@ export interface AgentConfig {
|
|
|
30
32
|
mcpServers?: string[];
|
|
31
33
|
maxTurns?: number;
|
|
32
34
|
model?: string;
|
|
35
|
+
isolation?: IsolationMode;
|
|
33
36
|
systemPrompt: string;
|
|
34
37
|
source: "user" | "project";
|
|
35
38
|
filePath: string;
|
|
@@ -47,6 +50,7 @@ export interface AgentDefaults {
|
|
|
47
50
|
disallowedTools?: string[];
|
|
48
51
|
maxTurns?: number;
|
|
49
52
|
mcpServers?: string[];
|
|
53
|
+
isolation?: IsolationMode;
|
|
50
54
|
/** How to handle missing agent names. Default: "match-or-ephemeral" */
|
|
51
55
|
missingAgentBehavior?: "match-or-ephemeral" | "error";
|
|
52
56
|
/** Agent name to use as fallback when no match found */
|
|
@@ -81,6 +85,29 @@ export const PI_BUILTIN_TOOLS = ["read", "bash", "edit", "write", "grep", "find"
|
|
|
81
85
|
/** Minimum score threshold for best-match to be accepted over ephemeral. */
|
|
82
86
|
const MATCH_THRESHOLD = 40;
|
|
83
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parse and validate isolation frontmatter/default values.
|
|
90
|
+
*
|
|
91
|
+
* @param value - Raw frontmatter/default value
|
|
92
|
+
* @param sourcePath - File path used in deterministic error messages
|
|
93
|
+
* @param fieldName - Field name used in deterministic error messages
|
|
94
|
+
* @returns Parsed isolation mode when present
|
|
95
|
+
* @throws {Error} When value is invalid
|
|
96
|
+
*/
|
|
97
|
+
function parseIsolationValue(
|
|
98
|
+
value: unknown,
|
|
99
|
+
sourcePath: string,
|
|
100
|
+
fieldName: "isolation"
|
|
101
|
+
): IsolationMode | undefined {
|
|
102
|
+
if (value == null || value === "") return undefined;
|
|
103
|
+
if (typeof value !== "string") {
|
|
104
|
+
throw new Error(`Invalid ${fieldName} in ${sourcePath}: expected string "worktree".`);
|
|
105
|
+
}
|
|
106
|
+
const normalized = value.trim().toLowerCase();
|
|
107
|
+
if (normalized === "worktree") return "worktree";
|
|
108
|
+
throw new Error(`Invalid ${fieldName} in ${sourcePath}: received "${value}". Allowed: worktree.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
// ── Functions ────────────────────────────────────────────────────────────────
|
|
85
112
|
|
|
86
113
|
/**
|
|
@@ -136,13 +163,19 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
136
163
|
continue;
|
|
137
164
|
}
|
|
138
165
|
|
|
139
|
-
const { frontmatter, body } = parseFrontmatter<Record<string,
|
|
140
|
-
|
|
166
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
|
|
167
|
+
const name = typeof frontmatter.name === "string" ? frontmatter.name.trim() : "";
|
|
168
|
+
const description =
|
|
169
|
+
typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
|
|
170
|
+
if (!name || !description) continue;
|
|
141
171
|
|
|
142
|
-
const rawTools =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
const rawTools =
|
|
173
|
+
typeof frontmatter.tools === "string"
|
|
174
|
+
? frontmatter.tools
|
|
175
|
+
.split(",")
|
|
176
|
+
.map((t: string) => t.trim())
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
: undefined;
|
|
146
179
|
|
|
147
180
|
// Separate Task(agent_type) entries from regular tool names
|
|
148
181
|
const TASK_PATTERN = /^Task\((.+)\)$/;
|
|
@@ -171,13 +204,16 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
171
204
|
}
|
|
172
205
|
}
|
|
173
206
|
|
|
174
|
-
const disallowedTools =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
207
|
+
const disallowedTools =
|
|
208
|
+
typeof frontmatter.disallowedTools === "string"
|
|
209
|
+
? frontmatter.disallowedTools
|
|
210
|
+
.split(",")
|
|
211
|
+
.map((t: string) => t.trim())
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
: undefined;
|
|
178
214
|
|
|
179
215
|
const parsedMaxTurns = frontmatter.maxTurns
|
|
180
|
-
? Number.parseInt(frontmatter.maxTurns, 10)
|
|
216
|
+
? Number.parseInt(String(frontmatter.maxTurns), 10)
|
|
181
217
|
: undefined;
|
|
182
218
|
const maxTurns = parsedMaxTurns && parsedMaxTurns > 0 ? parsedMaxTurns : undefined;
|
|
183
219
|
|
|
@@ -189,7 +225,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
189
225
|
if (typeof entry === "string") return true;
|
|
190
226
|
if (typeof entry === "object" && entry !== null) {
|
|
191
227
|
console.warn(
|
|
192
|
-
`MCP: Inline server definitions not supported in agent "${
|
|
228
|
+
`MCP: Inline server definitions not supported in agent "${name}". ` +
|
|
193
229
|
`Use a string reference to a configured server name.`
|
|
194
230
|
);
|
|
195
231
|
return false;
|
|
@@ -206,16 +242,19 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
206
242
|
}
|
|
207
243
|
}
|
|
208
244
|
|
|
245
|
+
const isolation = parseIsolationValue(frontmatter.isolation, filePath, "isolation");
|
|
246
|
+
|
|
209
247
|
agents.push({
|
|
210
|
-
name
|
|
211
|
-
description
|
|
248
|
+
name,
|
|
249
|
+
description,
|
|
212
250
|
tools: tools.length > 0 ? tools : undefined,
|
|
213
251
|
disallowedTools: disallowedTools && disallowedTools.length > 0 ? disallowedTools : undefined,
|
|
214
252
|
skills: skills && skills.length > 0 ? skills : undefined,
|
|
215
253
|
allowedAgentTypes: allowedAgentTypes.length > 0 ? allowedAgentTypes : undefined,
|
|
216
254
|
mcpServers: mcpServers && mcpServers.length > 0 ? mcpServers : undefined,
|
|
217
255
|
maxTurns,
|
|
218
|
-
model: frontmatter.model,
|
|
256
|
+
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
|
|
257
|
+
isolation,
|
|
219
258
|
systemPrompt: body,
|
|
220
259
|
source,
|
|
221
260
|
filePath,
|
|
@@ -307,6 +346,10 @@ function loadDefaultsFromDir(dir: string): AgentDefaults | undefined {
|
|
|
307
346
|
.map((s) => s.trim())
|
|
308
347
|
.filter(Boolean);
|
|
309
348
|
}
|
|
349
|
+
const isolation = parseIsolationValue(frontmatter.isolation, filePath, "isolation");
|
|
350
|
+
if (isolation) {
|
|
351
|
+
defaults.isolation = isolation;
|
|
352
|
+
}
|
|
310
353
|
if (frontmatter.missingAgentBehavior === "error") {
|
|
311
354
|
defaults.missingAgentBehavior = "error";
|
|
312
355
|
}
|
|
@@ -334,6 +377,7 @@ function mergeDefaults(...sources: (AgentDefaults | undefined)[]): AgentDefaults
|
|
|
334
377
|
if (src.disallowedTools) merged.disallowedTools = src.disallowedTools;
|
|
335
378
|
if (src.maxTurns != null) merged.maxTurns = src.maxTurns;
|
|
336
379
|
if (src.mcpServers) merged.mcpServers = src.mcpServers;
|
|
380
|
+
if (src.isolation) merged.isolation = src.isolation;
|
|
337
381
|
if (src.missingAgentBehavior) merged.missingAgentBehavior = src.missingAgentBehavior;
|
|
338
382
|
if (src.fallbackAgent) merged.fallbackAgent = src.fallbackAgent;
|
|
339
383
|
}
|
|
@@ -350,7 +394,7 @@ function mergeDefaults(...sources: (AgentDefaults | undefined)[]): AgentDefaults
|
|
|
350
394
|
* @returns Discovery result with agents and project directory path
|
|
351
395
|
*/
|
|
352
396
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
353
|
-
const userTallowDir =
|
|
397
|
+
const userTallowDir = getTallowPath("agents");
|
|
354
398
|
const userClaudeDir = path.join(os.homedir(), ".claude", "agents");
|
|
355
399
|
const projectDirs = findProjectAgentsDirs(cwd);
|
|
356
400
|
const projectAgentsDir = projectDirs.at(-1) ?? null;
|
|
@@ -391,6 +435,22 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
391
435
|
return { agents: Array.from(agentMap.values()), projectAgentsDir, defaults };
|
|
392
436
|
}
|
|
393
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Resolve effective isolation by precedence: call param > agent > defaults.
|
|
440
|
+
*
|
|
441
|
+
* @param callIsolation - Isolation passed in the current tool invocation
|
|
442
|
+
* @param agentIsolation - Isolation from resolved agent frontmatter
|
|
443
|
+
* @param defaultIsolation - Isolation from `_defaults.md`
|
|
444
|
+
* @returns Effective isolation mode when configured
|
|
445
|
+
*/
|
|
446
|
+
export function resolveEffectiveIsolation(
|
|
447
|
+
callIsolation: IsolationMode | undefined,
|
|
448
|
+
agentIsolation: IsolationMode | undefined,
|
|
449
|
+
defaultIsolation: IsolationMode | undefined
|
|
450
|
+
): IsolationMode | undefined {
|
|
451
|
+
return callIsolation ?? agentIsolation ?? defaultIsolation;
|
|
452
|
+
}
|
|
453
|
+
|
|
394
454
|
/**
|
|
395
455
|
* Computes the effective tool list from allowlist and denylist.
|
|
396
456
|
*
|
|
@@ -477,6 +537,7 @@ export function resolveAgentForExecution(
|
|
|
477
537
|
disallowedTools: defaults?.disallowedTools,
|
|
478
538
|
maxTurns: defaults?.maxTurns,
|
|
479
539
|
mcpServers: defaults?.mcpServers,
|
|
540
|
+
isolation: defaults?.isolation,
|
|
480
541
|
systemPrompt:
|
|
481
542
|
`You are ${agentName}, a specialized subagent. ` +
|
|
482
543
|
"Complete the delegated task thoroughly and return your results.",
|