@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
@@ -0,0 +1,372 @@
1
+ import { type ExecFileSyncOptions, execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+
7
+ /** Managed worktree directory-name prefix in the system temp dir. */
8
+ export const TALLOW_WORKTREE_PREFIX = "tallow-worktree-";
9
+
10
+ /** Marker file written into managed worktrees for stale cleanup decisions. */
11
+ export const TALLOW_WORKTREE_MARKER_FILE = ".tallow-worktree.json";
12
+
13
+ /** Maximum runtime for git subprocess calls in milliseconds. */
14
+ const GIT_TIMEOUT_MS = 15_000;
15
+
16
+ /** Valid worktree isolation scopes. */
17
+ export type WorktreeScope = "session" | "subagent";
18
+
19
+ /** Options for creating a detached managed worktree. */
20
+ export interface CreateWorktreeOptions {
21
+ readonly scope?: WorktreeScope;
22
+ readonly id?: string;
23
+ readonly agentId?: string;
24
+ readonly timestampMs?: number;
25
+ }
26
+
27
+ /** Result from createWorktree. */
28
+ export interface CreatedWorktree {
29
+ readonly id: string;
30
+ readonly repoRoot: string;
31
+ readonly scope: WorktreeScope;
32
+ readonly timestampMs: number;
33
+ readonly worktreePath: string;
34
+ }
35
+
36
+ /** Result from removeWorktree. */
37
+ export interface RemoveWorktreeResult {
38
+ readonly method: "filesystem" | "git" | "none";
39
+ readonly removed: boolean;
40
+ }
41
+
42
+ /** Aggregate stats from stale worktree cleanup. */
43
+ export interface WorktreeCleanupStats {
44
+ readonly removedCount: number;
45
+ readonly scannedCount: number;
46
+ }
47
+
48
+ /** On-disk marker payload used to identify managed worktrees. */
49
+ interface WorktreeMarker {
50
+ readonly createdAt: string;
51
+ readonly id: string;
52
+ readonly pid: number;
53
+ readonly repoRoot: string;
54
+ readonly scope: WorktreeScope;
55
+ agentId?: string;
56
+ }
57
+
58
+ /**
59
+ * Validate that a working directory belongs to a git repository.
60
+ *
61
+ * @param cwd - Directory to validate
62
+ * @returns Resolved git repository root
63
+ * @throws {Error} When cwd is not inside a git repository
64
+ */
65
+ export function validateGitRepo(cwd: string): { readonly repoRoot: string } {
66
+ const resolvedCwd = resolve(cwd);
67
+ const repoRoot = runGit(["-C", resolvedCwd, "rev-parse", "--show-toplevel"], resolvedCwd);
68
+ if (!repoRoot) {
69
+ throw new Error(`Not inside a git repository: ${resolvedCwd}`);
70
+ }
71
+ return { repoRoot: resolve(repoRoot) };
72
+ }
73
+
74
+ /**
75
+ * Create a detached managed worktree for the given repository.
76
+ *
77
+ * @param repoRoot - Git repository root
78
+ * @param options - Optional scope and identifier overrides
79
+ * @returns Created worktree metadata
80
+ * @throws {Error} When creation fails
81
+ */
82
+ export function createWorktree(
83
+ repoRoot: string,
84
+ options: CreateWorktreeOptions = {}
85
+ ): CreatedWorktree {
86
+ const validatedRoot = validateGitRepo(repoRoot).repoRoot;
87
+ const scope = options.scope ?? "session";
88
+ const id = sanitizeSegment(options.id ?? randomUUID().slice(0, 8));
89
+ const timestampMs = options.timestampMs ?? Date.now();
90
+ const worktreePath = join(tmpdir(), `${TALLOW_WORKTREE_PREFIX}${scope}-${id}-${timestampMs}`);
91
+
92
+ runGit(["-C", validatedRoot, "worktree", "add", "--detach", worktreePath, "HEAD"], validatedRoot);
93
+
94
+ const marker: WorktreeMarker = {
95
+ createdAt: new Date(timestampMs).toISOString(),
96
+ id,
97
+ pid: process.pid,
98
+ repoRoot: validatedRoot,
99
+ scope,
100
+ };
101
+ if (options.agentId) {
102
+ marker.agentId = options.agentId;
103
+ }
104
+ writeMarkerFile(worktreePath, marker);
105
+
106
+ return {
107
+ id,
108
+ repoRoot: validatedRoot,
109
+ scope,
110
+ timestampMs,
111
+ worktreePath,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Remove a managed worktree path.
117
+ *
118
+ * Prefers `git worktree remove --force`. Falls back to filesystem deletion
119
+ * plus `git worktree prune` when git removal fails.
120
+ *
121
+ * @param worktreePath - Worktree path to remove
122
+ * @returns Removal outcome and method used
123
+ */
124
+ export function removeWorktree(worktreePath: string): RemoveWorktreeResult {
125
+ const absoluteWorktreePath = resolve(worktreePath);
126
+ if (!existsSync(absoluteWorktreePath)) {
127
+ return { method: "none", removed: false };
128
+ }
129
+
130
+ const marker = readMarkerFile(absoluteWorktreePath);
131
+ const repoRootCandidates = new Set<string>();
132
+ if (marker?.repoRoot) repoRootCandidates.add(resolve(marker.repoRoot));
133
+ const inferredRoot = inferRepoRootFromWorktree(absoluteWorktreePath);
134
+ if (inferredRoot) repoRootCandidates.add(inferredRoot);
135
+
136
+ for (const repoRoot of repoRootCandidates) {
137
+ try {
138
+ runGit(["-C", repoRoot, "worktree", "remove", "--force", absoluteWorktreePath], repoRoot);
139
+ pruneWorktrees(repoRoot);
140
+ return { method: "git", removed: !existsSync(absoluteWorktreePath) };
141
+ } catch {
142
+ // Fall through to filesystem fallback.
143
+ }
144
+ }
145
+
146
+ try {
147
+ rmSync(absoluteWorktreePath, { force: true, recursive: true });
148
+ } catch {
149
+ // Best-effort fallback path.
150
+ }
151
+ for (const repoRoot of repoRootCandidates) {
152
+ pruneWorktrees(repoRoot);
153
+ }
154
+ return { method: "filesystem", removed: !existsSync(absoluteWorktreePath) };
155
+ }
156
+
157
+ /**
158
+ * Remove stale managed worktrees for a repository.
159
+ *
160
+ * A managed worktree is considered stale when either:
161
+ * - it is not listed in `git worktree list --porcelain`, or
162
+ * - its marker PID is not running anymore.
163
+ *
164
+ * @param repoRoot - Repository root to clean
165
+ * @returns Number of scanned and removed worktrees
166
+ */
167
+ export function cleanupStaleWorktrees(repoRoot: string): WorktreeCleanupStats {
168
+ const validatedRoot = validateGitRepo(repoRoot).repoRoot;
169
+ pruneWorktrees(validatedRoot);
170
+ const activeWorktrees = listActiveWorktrees(validatedRoot);
171
+ const managedWorktrees = listManagedWorktreePaths();
172
+
173
+ let removedCount = 0;
174
+ let scannedCount = 0;
175
+
176
+ for (const worktreePath of managedWorktrees) {
177
+ const marker = readMarkerFile(worktreePath);
178
+ if (marker && resolve(marker.repoRoot) !== validatedRoot) continue;
179
+
180
+ scannedCount += 1;
181
+ const isActive = activeWorktrees.has(worktreePath);
182
+ const hasLiveOwnerPid = marker ? isProcessAlive(marker.pid) : false;
183
+ if (isActive && hasLiveOwnerPid) continue;
184
+ if (isActive && !marker) continue;
185
+ if (!marker && !isActive) continue;
186
+
187
+ const removal = removeWorktree(worktreePath);
188
+ if (removal.removed) {
189
+ removedCount += 1;
190
+ }
191
+ }
192
+
193
+ pruneWorktrees(validatedRoot);
194
+ return { removedCount, scannedCount };
195
+ }
196
+
197
+ /**
198
+ * List active git worktrees for a repository.
199
+ *
200
+ * @param repoRoot - Repository root
201
+ * @returns Absolute worktree paths currently registered with git
202
+ */
203
+ function listActiveWorktrees(repoRoot: string): Set<string> {
204
+ const output = runGit(["-C", repoRoot, "worktree", "list", "--porcelain"], repoRoot);
205
+ const active = new Set<string>();
206
+ for (const line of output.split("\n")) {
207
+ if (!line.startsWith("worktree ")) continue;
208
+ const value = line.slice("worktree ".length).trim();
209
+ if (!value) continue;
210
+ active.add(resolve(value));
211
+ }
212
+ return active;
213
+ }
214
+
215
+ /**
216
+ * Enumerate managed worktree directories in the system temp directory.
217
+ *
218
+ * @returns Absolute paths for managed worktree directories
219
+ */
220
+ function listManagedWorktreePaths(): string[] {
221
+ const root = tmpdir();
222
+ const entries = readdirSync(root, { withFileTypes: true });
223
+ const paths: string[] = [];
224
+ for (const entry of entries) {
225
+ if (!entry.name.startsWith(TALLOW_WORKTREE_PREFIX)) continue;
226
+ const fullPath = join(root, entry.name);
227
+ const isDirectory =
228
+ entry.isDirectory() || (entry.isSymbolicLink() && safeIsDirectory(fullPath));
229
+ if (!isDirectory) continue;
230
+ paths.push(resolve(fullPath));
231
+ }
232
+ return paths;
233
+ }
234
+
235
+ /**
236
+ * Check whether a path currently resolves to a directory.
237
+ *
238
+ * @param pathValue - Candidate directory path
239
+ * @returns True when the path is a directory
240
+ */
241
+ function safeIsDirectory(pathValue: string): boolean {
242
+ try {
243
+ return statSync(pathValue).isDirectory();
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Parse a managed worktree marker file.
251
+ *
252
+ * @param worktreePath - Worktree directory path
253
+ * @returns Marker payload when present and valid
254
+ */
255
+ function readMarkerFile(worktreePath: string): WorktreeMarker | undefined {
256
+ const markerPath = join(worktreePath, TALLOW_WORKTREE_MARKER_FILE);
257
+ if (!existsSync(markerPath)) return undefined;
258
+ try {
259
+ const parsed = JSON.parse(readFileSync(markerPath, "utf-8")) as Partial<WorktreeMarker>;
260
+ if (!parsed || typeof parsed !== "object") return undefined;
261
+ if (parsed.scope !== "session" && parsed.scope !== "subagent") return undefined;
262
+ if (typeof parsed.repoRoot !== "string") return undefined;
263
+ if (typeof parsed.id !== "string") return undefined;
264
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid)) return undefined;
265
+ if (typeof parsed.createdAt !== "string") return undefined;
266
+ return {
267
+ agentId: typeof parsed.agentId === "string" ? parsed.agentId : undefined,
268
+ createdAt: parsed.createdAt,
269
+ id: parsed.id,
270
+ pid: Math.floor(parsed.pid),
271
+ repoRoot: resolve(parsed.repoRoot),
272
+ scope: parsed.scope,
273
+ };
274
+ } catch {
275
+ return undefined;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Write marker metadata into a managed worktree directory.
281
+ *
282
+ * @param worktreePath - Worktree directory path
283
+ * @param marker - Marker payload
284
+ */
285
+ function writeMarkerFile(worktreePath: string, marker: WorktreeMarker): void {
286
+ const markerPath = join(worktreePath, TALLOW_WORKTREE_MARKER_FILE);
287
+ writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
288
+ }
289
+
290
+ /**
291
+ * Infer repository root from a worktree path.
292
+ *
293
+ * @param worktreePath - Worktree directory path
294
+ * @returns Repository root when detectable
295
+ */
296
+ function inferRepoRootFromWorktree(worktreePath: string): string | undefined {
297
+ try {
298
+ const output = runGit(["-C", worktreePath, "rev-parse", "--show-toplevel"], worktreePath);
299
+ return output ? resolve(output) : undefined;
300
+ } catch {
301
+ return undefined;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Run `git worktree prune` for a repository root.
307
+ *
308
+ * @param repoRoot - Repository root
309
+ */
310
+ function pruneWorktrees(repoRoot: string): void {
311
+ try {
312
+ runGit(["-C", repoRoot, "worktree", "prune"], repoRoot);
313
+ } catch {
314
+ // Best-effort cleanup path.
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Check whether a PID appears alive.
320
+ *
321
+ * @param pid - Process identifier
322
+ * @returns True when the process is alive
323
+ */
324
+ function isProcessAlive(pid: number): boolean {
325
+ if (!Number.isFinite(pid) || pid <= 0) return false;
326
+ try {
327
+ process.kill(pid, 0);
328
+ return true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Sanitize a user-provided segment for safe path construction.
336
+ *
337
+ * @param value - Raw segment value
338
+ * @returns Safe kebab-style segment
339
+ */
340
+ function sanitizeSegment(value: string): string {
341
+ const normalized = value
342
+ .trim()
343
+ .toLowerCase()
344
+ .replace(/[^a-z0-9._-]+/g, "-")
345
+ .replace(/^-+|-+$/g, "");
346
+ if (!normalized) return "task";
347
+ return normalized.slice(0, 48);
348
+ }
349
+
350
+ /**
351
+ * Execute a git command and return trimmed stdout.
352
+ *
353
+ * @param args - Git command arguments
354
+ * @param cwd - Working directory
355
+ * @returns Trimmed command output
356
+ * @throws {Error} When git exits non-zero
357
+ */
358
+ function runGit(args: string[], cwd: string): string {
359
+ const options: ExecFileSyncOptions = {
360
+ cwd,
361
+ encoding: "utf-8",
362
+ stdio: ["ignore", "pipe", "pipe"],
363
+ timeout: GIT_TIMEOUT_MS,
364
+ };
365
+ try {
366
+ const output = execFileSync("git", args, options) as string;
367
+ return output.trim();
368
+ } catch (error) {
369
+ const message = error instanceof Error ? error.message : String(error);
370
+ throw new Error(`git ${args.join(" ")} failed: ${message}`);
371
+ }
372
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungle-scrubs/tallow",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "An opinionated coding agent. Built on pi.",
5
5
  "piConfig": {
6
6
  "name": "tallow",
@@ -34,7 +34,7 @@ Relay that answer to the user.
34
34
  | Component | Location |
35
35
  |-----------|----------|
36
36
  | Core source | `src/` (agent-runner.ts, atomic-write.ts, auth-hardening.ts, cli.ts, config.ts, extensions-global.d.ts, fatal-errors.ts, index.ts, install.ts, interactive-mode-patch.ts, pid-manager.ts, plugins.ts, process-cleanup.ts, project-trust-banner.ts, project-trust.ts, runtime-path-provider.ts, sdk.ts, session-migration.ts, session-utils.ts, startup-profile.ts, startup-timing.ts) |
37
- | Extensions | `extensions/` — extension.json + index.ts each (50 bundled) |
37
+ | Extensions | `extensions/` — extension.json + index.ts each (51 bundled) |
38
38
  | Skills | `skills/` — subdirs with SKILL.md |
39
39
  | Agents | `agents/` — markdown with YAML frontmatter |
40
40
  | Themes | `themes/` — JSON files (34 dark-only themes) |