@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
@@ -18,16 +18,16 @@ import {
18
18
  unlinkSync,
19
19
  watch,
20
20
  } from "node:fs";
21
- import { homedir } from "node:os";
22
21
  import { join } from "node:path";
23
22
  import type { AgentMessage } from "@mariozechner/pi-agent-core";
24
23
  import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
25
24
  import { atomicWriteFileSync } from "../../_shared/atomic-write.js";
25
+ import { getTallowPath } from "../../_shared/tallow-paths.js";
26
26
 
27
27
  // ── Constants ────────────────────────────────────────────────────────────────
28
28
 
29
29
  /** Directory root for team-based shared task lists. */
30
- export const TEAMS_DIR = join(homedir(), ".tallow", "teams");
30
+ export const TEAMS_DIR = getTallowPath("teams");
31
31
 
32
32
  /** Max age for team directories before cleanup (7 days in ms). */
33
33
  export const TEAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1,11 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
3
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
6
4
  import { Container, Key, matchesKey, Text } from "@mariozechner/pi-tui";
7
5
  import { Type } from "@sinclair/typebox";
8
6
  import { atomicWriteFileSync } from "../_shared/atomic-write.js";
7
+ import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
9
8
 
10
9
  /**
11
10
  * Theme switcher extension with live preview.
@@ -305,7 +304,7 @@ function filterByTags(themes: readonly ThemeEntry[], tags: string[]): readonly T
305
304
  * @returns `false` (disabled), `true` (pick from all), or `string[]` (filter by tags)
306
305
  */
307
306
  function readRandomThemeSetting(): false | true | string[] {
308
- const settingsPath = join(homedir(), ".tallow", "settings.json");
307
+ const settingsPath = getTallowSettingsPath();
309
308
  try {
310
309
  const raw = readFileSync(settingsPath, "utf-8");
311
310
  const settings = JSON.parse(raw) as { randomThemeOnStart?: boolean | string[] };
@@ -319,10 +318,7 @@ function readRandomThemeSetting(): false | true | string[] {
319
318
  * Persist theme selection to settings.json so it survives restarts.
320
319
  */
321
320
  function persistTheme(themeName: string): void {
322
- const agentDir =
323
- process.env.PI_CODING_AGENT_DIR ??
324
- join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".tallow");
325
- const settingsPath = join(agentDir, "settings.json");
321
+ const settingsPath = getTallowSettingsPath();
326
322
  try {
327
323
  let settings: Record<string, unknown> = {};
328
324
  if (existsSync(settingsPath)) {
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { execFileSync } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ cleanupStaleWorktrees,
16
+ createWorktree,
17
+ removeWorktree,
18
+ TALLOW_WORKTREE_MARKER_FILE,
19
+ validateGitRepo,
20
+ } from "../lifecycle.js";
21
+
22
+ /**
23
+ * Run a git command and return trimmed stdout.
24
+ *
25
+ * @param cwd - Working directory
26
+ * @param args - Git arguments
27
+ * @returns Trimmed stdout
28
+ */
29
+ function git(cwd: string, ...args: string[]): string {
30
+ const output = execFileSync("git", args, {
31
+ cwd,
32
+ encoding: "utf-8",
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+ return output.trim();
36
+ }
37
+
38
+ /**
39
+ * Create a temporary git repository with an initial commit.
40
+ *
41
+ * @returns Repo root path
42
+ */
43
+ function createTempRepo(): string {
44
+ const repo = mkdtempSync(join(tmpdir(), "tallow-worktree-test-"));
45
+ git(repo, "init");
46
+ git(repo, "config", "user.email", "test@example.com");
47
+ git(repo, "config", "user.name", "Test User");
48
+ writeFileSync(join(repo, "README.md"), "# test\n", "utf-8");
49
+ git(repo, "add", "README.md");
50
+ git(repo, "commit", "-m", "init");
51
+ return repo;
52
+ }
53
+
54
+ describe("worktree lifecycle", () => {
55
+ let repoRoot = "";
56
+
57
+ beforeEach(() => {
58
+ repoRoot = createTempRepo();
59
+ });
60
+
61
+ afterEach(() => {
62
+ rmSync(repoRoot, { force: true, recursive: true });
63
+ });
64
+
65
+ it("creates a detached worktree inside a git repo", () => {
66
+ const created = createWorktree(repoRoot, { scope: "subagent" });
67
+ expect(existsSync(created.worktreePath)).toBe(true);
68
+ expect(created.scope).toBe("subagent");
69
+ const markerPath = join(created.worktreePath, TALLOW_WORKTREE_MARKER_FILE);
70
+ expect(existsSync(markerPath)).toBe(true);
71
+ removeWorktree(created.worktreePath);
72
+ });
73
+
74
+ it("fails validation outside git repositories", () => {
75
+ const outside = mkdtempSync(join(tmpdir(), "tallow-worktree-nogit-"));
76
+ try {
77
+ expect(() => validateGitRepo(outside)).toThrow(
78
+ /not inside a git repository|not a git repository/i
79
+ );
80
+ } finally {
81
+ rmSync(outside, { force: true, recursive: true });
82
+ }
83
+ });
84
+
85
+ it("removes worktrees and is idempotent for missing paths", () => {
86
+ const created = createWorktree(repoRoot, { scope: "session" });
87
+ const first = removeWorktree(created.worktreePath);
88
+ expect(first.removed).toBe(true);
89
+ expect(existsSync(created.worktreePath)).toBe(false);
90
+
91
+ const second = removeWorktree(created.worktreePath);
92
+ expect(second.method).toBe("none");
93
+ expect(second.removed).toBe(false);
94
+ });
95
+
96
+ it("resolves repository root from nested directories", () => {
97
+ const nested = join(repoRoot, "nested", "child");
98
+ mkdirSync(nested, { recursive: true });
99
+ const root = validateGitRepo(nested).repoRoot;
100
+ expect(realpathSync(root)).toBe(realpathSync(repoRoot));
101
+ });
102
+
103
+ it("prunes stale managed worktrees whose owner pid is gone", () => {
104
+ const created = createWorktree(repoRoot, { scope: "session" });
105
+ const markerPath = join(created.worktreePath, TALLOW_WORKTREE_MARKER_FILE);
106
+ const marker = JSON.parse(readFileSync(markerPath, "utf-8")) as Record<string, unknown>;
107
+ marker.pid = 999_999_999;
108
+ writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
109
+
110
+ const cleaned = cleanupStaleWorktrees(repoRoot);
111
+ expect(cleaned.scannedCount).toBeGreaterThan(0);
112
+ expect(cleaned.removedCount).toBeGreaterThanOrEqual(1);
113
+ expect(existsSync(created.worktreePath)).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "worktree",
3
+ "version": "0.1.0",
4
+ "description": "Managed git worktree isolation lifecycle for sessions and subagents.",
5
+ "whenToUse": "Use when tallow should run in temporary detached git worktrees with reliable cleanup and lifecycle hooks.",
6
+ "capabilities": {
7
+ "events": ["before_agent_start", "session_shutdown", "session_start"]
8
+ },
9
+ "permissionSurface": {
10
+ "filesystem": "write",
11
+ "shell": false,
12
+ "network": false,
13
+ "subprocess": true
14
+ },
15
+ "category": "integration",
16
+ "tags": ["worktree", "isolation", "git", "cleanup"],
17
+ "files": ["index.ts", "lifecycle.ts"],
18
+ "relationships": [
19
+ {
20
+ "name": "subagent-tool",
21
+ "kind": "enhances",
22
+ "reason": "Subagent worktree isolation uses the shared lifecycle primitives"
23
+ },
24
+ {
25
+ "name": "hooks",
26
+ "kind": "enhances",
27
+ "reason": "Emits worktree lifecycle events for hook automation"
28
+ }
29
+ ],
30
+ "npmDependencies": {}
31
+ }
@@ -0,0 +1,149 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ emitWorktreeLifecycleEvent,
4
+ type WorktreeLifecycleEventPayload,
5
+ } from "../_shared/interop-events.js";
6
+ import { cleanupStaleWorktrees, removeWorktree, validateGitRepo } from "./lifecycle.js";
7
+
8
+ /** Env var containing active session worktree path (set by CLI when -w/--worktree is used). */
9
+ const TALLOW_WORKTREE_PATH_ENV = "TALLOW_WORKTREE_PATH";
10
+
11
+ /** Env var containing original cwd before session worktree activation. */
12
+ const TALLOW_WORKTREE_ORIGINAL_CWD_ENV = "TALLOW_WORKTREE_ORIGINAL_CWD";
13
+
14
+ /** Marker added to system prompt so worktree context is only injected once. */
15
+ const SESSION_WORKTREE_PROMPT_MARKER = "Session worktree isolation is active.";
16
+
17
+ /** In-memory state for session-level worktree lifecycle management. */
18
+ interface SessionWorktreeState {
19
+ readonly originalCwd: string;
20
+ readonly repoRoot: string;
21
+ readonly worktreePath: string;
22
+ }
23
+
24
+ /**
25
+ * Worktree lifecycle extension.
26
+ *
27
+ * Responsibilities:
28
+ * - prune stale managed worktrees on session startup
29
+ * - emit session-level worktree lifecycle events for hooks
30
+ * - inject explicit worktree context into the system prompt
31
+ * - perform best-effort teardown on session shutdown
32
+ *
33
+ * @param pi - Extension API
34
+ */
35
+ export default function worktreeExtension(pi: ExtensionAPI): void {
36
+ let sessionState: SessionWorktreeState | undefined;
37
+ let cleanedUp = false;
38
+
39
+ /**
40
+ * Best-effort session-worktree cleanup routine.
41
+ *
42
+ * @param reason - Cleanup trigger reason for diagnostics
43
+ */
44
+ const cleanupSessionWorktree = (reason: "session_shutdown"): void => {
45
+ if (cleanedUp || !sessionState) return;
46
+ cleanedUp = true;
47
+ removeWorktree(sessionState.worktreePath);
48
+ emitWorktreeLifecycleEvent(pi.events, "worktree_remove", {
49
+ agentId: undefined,
50
+ repoRoot: sessionState.repoRoot,
51
+ scope: "session",
52
+ timestamp: Date.now(),
53
+ worktreePath: sessionState.worktreePath,
54
+ });
55
+ if (reason === "session_shutdown") {
56
+ delete process.env[TALLOW_WORKTREE_PATH_ENV];
57
+ delete process.env[TALLOW_WORKTREE_ORIGINAL_CWD_ENV];
58
+ }
59
+ };
60
+
61
+ pi.on("session_start", async (_event, ctx) => {
62
+ const cleanupRoot = resolveCleanupRoot(ctx.cwd);
63
+ if (cleanupRoot) {
64
+ try {
65
+ cleanupStaleWorktrees(cleanupRoot);
66
+ } catch {
67
+ // Startup stale cleanup is best-effort.
68
+ }
69
+ }
70
+
71
+ sessionState = resolveSessionWorktreeState(ctx.cwd);
72
+ cleanedUp = false;
73
+ if (!sessionState) return;
74
+
75
+ const payload: WorktreeLifecycleEventPayload = {
76
+ agentId: undefined,
77
+ repoRoot: sessionState.repoRoot,
78
+ scope: "session",
79
+ timestamp: Date.now(),
80
+ worktreePath: sessionState.worktreePath,
81
+ };
82
+ emitWorktreeLifecycleEvent(pi.events, "worktree_create", payload);
83
+ });
84
+
85
+ pi.on("before_agent_start", async (event) => {
86
+ if (!sessionState) return;
87
+ if (event.systemPrompt.includes(SESSION_WORKTREE_PROMPT_MARKER)) return;
88
+
89
+ const hint = [
90
+ SESSION_WORKTREE_PROMPT_MARKER,
91
+ `Current cwd is an isolated detached git worktree: ${sessionState.worktreePath}`,
92
+ `Original cwd before isolation: ${sessionState.originalCwd}`,
93
+ "Any file edits happen inside this temporary worktree.",
94
+ ].join("\n");
95
+ return {
96
+ systemPrompt: `${event.systemPrompt}\n\n${hint}`,
97
+ };
98
+ });
99
+
100
+ pi.on("session_shutdown", async () => {
101
+ cleanupSessionWorktree("session_shutdown");
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Resolve cleanup root for startup stale-worktree pruning.
107
+ *
108
+ * @param cwd - Current session cwd
109
+ * @returns Repository root when available
110
+ */
111
+ function resolveCleanupRoot(cwd: string): string | undefined {
112
+ const originalCwd = process.env[TALLOW_WORKTREE_ORIGINAL_CWD_ENV];
113
+ if (originalCwd) {
114
+ try {
115
+ return validateGitRepo(originalCwd).repoRoot;
116
+ } catch {
117
+ // Fall through to session cwd.
118
+ }
119
+ }
120
+
121
+ try {
122
+ return validateGitRepo(cwd).repoRoot;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Build session worktree state from environment metadata.
130
+ *
131
+ * @param fallbackCwd - Session cwd fallback when original cwd metadata is absent
132
+ * @returns Parsed session worktree state when metadata is valid
133
+ */
134
+ function resolveSessionWorktreeState(fallbackCwd: string): SessionWorktreeState | undefined {
135
+ const worktreePath = process.env[TALLOW_WORKTREE_PATH_ENV];
136
+ if (!worktreePath) return undefined;
137
+ const originalCwd = process.env[TALLOW_WORKTREE_ORIGINAL_CWD_ENV] ?? fallbackCwd;
138
+
139
+ try {
140
+ const repoRoot = validateGitRepo(originalCwd).repoRoot;
141
+ return {
142
+ originalCwd,
143
+ repoRoot,
144
+ worktreePath,
145
+ };
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }