@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.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +244 -54
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/sdk.d.ts +39 -0
  7. package/dist/sdk.d.ts.map +1 -1
  8. package/dist/sdk.js +322 -13
  9. package/dist/sdk.js.map +1 -1
  10. package/dist/session-utils.d.ts +8 -0
  11. package/dist/session-utils.d.ts.map +1 -1
  12. package/dist/session-utils.js +38 -1
  13. package/dist/session-utils.js.map +1 -1
  14. package/extensions/__integration__/plan-rejection-feedback.test.ts +272 -0
  15. package/extensions/__integration__/worktree.test.ts +88 -0
  16. package/extensions/_icons/index.ts +2 -3
  17. package/extensions/_shared/inline-preview.ts +2 -4
  18. package/extensions/_shared/interop-events.ts +48 -0
  19. package/extensions/_shared/shell-policy.ts +2 -1
  20. package/extensions/_shared/tallow-paths.ts +48 -0
  21. package/extensions/agent-commands-tool/__tests__/parsing.test.ts +40 -1
  22. package/extensions/agent-commands-tool/index.ts +38 -6
  23. package/extensions/background-task-tool/index.ts +2 -2
  24. package/extensions/bash-tool-enhanced/index.ts +3 -4
  25. package/extensions/cd-tool/index.ts +3 -3
  26. package/extensions/claude-bridge/index.ts +2 -1
  27. package/extensions/command-prompt/__tests__/plugin-sources.test.ts +49 -0
  28. package/extensions/command-prompt/index.ts +36 -0
  29. package/extensions/context-files/index.ts +2 -1
  30. package/extensions/context-fork/index.ts +2 -1
  31. package/extensions/context-usage/__tests__/tool-result-memory.test.ts +62 -0
  32. package/extensions/context-usage/index.ts +169 -8
  33. package/extensions/debug/__tests__/analysis.test.ts +35 -2
  34. package/extensions/debug/__tests__/diagnostics-commands.test.ts +11 -2
  35. package/extensions/debug/analysis.ts +53 -16
  36. package/extensions/debug/index.ts +84 -10
  37. package/extensions/debug/logger.ts +2 -2
  38. package/extensions/health/index.ts +2 -2
  39. package/extensions/hooks/__tests__/claude-compat.test.ts +31 -0
  40. package/extensions/hooks/extension.json +3 -1
  41. package/extensions/hooks/hooks.schema.json +17 -1
  42. package/extensions/hooks/index.ts +52 -7
  43. package/extensions/lsp/index.ts +2 -7
  44. package/extensions/output-styles-tool/index.ts +2 -4
  45. package/extensions/plan-mode-tool/extension.json +1 -0
  46. package/extensions/plan-mode-tool/index.ts +119 -4
  47. package/extensions/prompt-suggestions/index.ts +2 -3
  48. package/extensions/random-spinner/index.ts +2 -3
  49. package/extensions/read-tool-enhanced/__tests__/notebook-read.test.ts +100 -0
  50. package/extensions/read-tool-enhanced/__tests__/notebook.test.ts +136 -0
  51. package/extensions/read-tool-enhanced/extension.json +4 -4
  52. package/extensions/read-tool-enhanced/index.ts +112 -10
  53. package/extensions/read-tool-enhanced/notebook.ts +526 -0
  54. package/extensions/rewind/__tests__/snapshots.test.ts +43 -1
  55. package/extensions/rewind/snapshots.ts +18 -6
  56. package/extensions/session-memory/index.ts +3 -2
  57. package/extensions/stats/stats-log.ts +2 -3
  58. package/extensions/subagent-tool/__tests__/discovery-defaults.test.ts +23 -0
  59. package/extensions/subagent-tool/__tests__/isolation-frontmatter.test.ts +100 -0
  60. package/extensions/subagent-tool/__tests__/model-router.test.ts +8 -0
  61. package/extensions/subagent-tool/agents.ts +77 -16
  62. package/extensions/subagent-tool/index.ts +213 -48
  63. package/extensions/subagent-tool/model-router.ts +3 -3
  64. package/extensions/subagent-tool/process.ts +233 -22
  65. package/extensions/subagent-tool/schema.ts +11 -0
  66. package/extensions/subagent-tool/widget.ts +15 -2
  67. package/extensions/tasks/__tests__/widget-subagents.test.ts +28 -7
  68. package/extensions/tasks/commands/register-tasks-extension.ts +15 -33
  69. package/extensions/tasks/state/index.ts +2 -2
  70. package/extensions/theme-selector/index.ts +3 -7
  71. package/extensions/worktree/__tests__/lifecycle.test.ts +115 -0
  72. package/extensions/worktree/extension.json +31 -0
  73. package/extensions/worktree/index.ts +149 -0
  74. package/extensions/worktree/lifecycle.ts +372 -0
  75. package/package.json +1 -1
  76. 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 cwd: string;
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.cwd = cwd;
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(this.cwd, ".git", "tallow-snapshot-index");
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.cwd, file));
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.cwd, 10_000);
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.cwd,
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 process.env.TALLOW_CODING_AGENT_DIR ?? join(homedir(), ".tallow");
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(homedir(), ".tallow", "sessions");
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
- const tallowHome = process.env.TALLOW_CODING_AGENT_DIR || path.join(os.homedir(), ".tallow");
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, string>>(content);
140
- if (!(frontmatter.name && frontmatter.description)) continue;
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 = frontmatter.tools
143
- ?.split(",")
144
- .map((t: string) => t.trim())
145
- .filter(Boolean);
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 = frontmatter.disallowedTools
175
- ?.split(",")
176
- .map((t: string) => t.trim())
177
- .filter(Boolean);
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 "${frontmatter.name}". ` +
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: frontmatter.name,
211
- description: frontmatter.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 = path.join(os.homedir(), ".tallow", "agents");
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.",