@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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { ExtensionHarness } from "../../test-utils/extension-harness.js";
|
|
4
|
+
import planModeExtension from "../plan-mode-tool/index.js";
|
|
5
|
+
|
|
6
|
+
const PLAN_TEXT = [
|
|
7
|
+
"Plan:",
|
|
8
|
+
"1. Inspect tool failure paths in plan execution",
|
|
9
|
+
"2. Verify fallback behavior for blocked operations",
|
|
10
|
+
"3. Finalize recovery guidance for remaining step",
|
|
11
|
+
].join("\n");
|
|
12
|
+
|
|
13
|
+
interface ContextOptions {
|
|
14
|
+
readonly confirmResponse?: boolean;
|
|
15
|
+
readonly editorResponse?: string;
|
|
16
|
+
readonly hasUI: boolean;
|
|
17
|
+
readonly selectResponse?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a minimal extension context with configurable UI responses.
|
|
22
|
+
*
|
|
23
|
+
* @param options - Context and UI response options
|
|
24
|
+
* @returns Extension context for direct event firing
|
|
25
|
+
*/
|
|
26
|
+
function createContext(options: ContextOptions): ExtensionContext {
|
|
27
|
+
const theme = {
|
|
28
|
+
bg(_token: string, value: string) {
|
|
29
|
+
return value;
|
|
30
|
+
},
|
|
31
|
+
fg(_token: string, value: string) {
|
|
32
|
+
return value;
|
|
33
|
+
},
|
|
34
|
+
strikethrough(value: string) {
|
|
35
|
+
return value;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
abort: () => {},
|
|
41
|
+
compact: () => {},
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
getContextUsage: () => undefined,
|
|
44
|
+
getSystemPrompt: () => "",
|
|
45
|
+
hasPendingMessages: () => false,
|
|
46
|
+
hasUI: options.hasUI,
|
|
47
|
+
isIdle: () => true,
|
|
48
|
+
model: undefined,
|
|
49
|
+
modelRegistry: {
|
|
50
|
+
getApiKeyForProvider: async () => undefined,
|
|
51
|
+
} as never,
|
|
52
|
+
sessionManager: {
|
|
53
|
+
appendEntry: () => {},
|
|
54
|
+
getEntries: () => [],
|
|
55
|
+
} as never,
|
|
56
|
+
shutdown: () => {},
|
|
57
|
+
ui: {
|
|
58
|
+
async confirm() {
|
|
59
|
+
return options.confirmResponse ?? false;
|
|
60
|
+
},
|
|
61
|
+
async custom() {
|
|
62
|
+
return undefined as never;
|
|
63
|
+
},
|
|
64
|
+
async editor() {
|
|
65
|
+
return options.editorResponse;
|
|
66
|
+
},
|
|
67
|
+
async input() {
|
|
68
|
+
return undefined;
|
|
69
|
+
},
|
|
70
|
+
notify() {},
|
|
71
|
+
pasteToEditor() {},
|
|
72
|
+
async select() {
|
|
73
|
+
return options.selectResponse;
|
|
74
|
+
},
|
|
75
|
+
setEditorComponent() {},
|
|
76
|
+
setEditorText() {},
|
|
77
|
+
setFooter() {},
|
|
78
|
+
setHeader() {},
|
|
79
|
+
setStatus() {},
|
|
80
|
+
setTitle() {},
|
|
81
|
+
setToolsExpanded() {},
|
|
82
|
+
setWidget() {},
|
|
83
|
+
setWorkingMessage() {},
|
|
84
|
+
get theme() {
|
|
85
|
+
return theme as ExtensionContext["ui"]["theme"];
|
|
86
|
+
},
|
|
87
|
+
getAllThemes() {
|
|
88
|
+
return [];
|
|
89
|
+
},
|
|
90
|
+
getEditorText() {
|
|
91
|
+
return "";
|
|
92
|
+
},
|
|
93
|
+
getTheme() {
|
|
94
|
+
return undefined;
|
|
95
|
+
},
|
|
96
|
+
getToolsExpanded() {
|
|
97
|
+
return false;
|
|
98
|
+
},
|
|
99
|
+
setTheme() {
|
|
100
|
+
return { error: "Test stub", success: false };
|
|
101
|
+
},
|
|
102
|
+
} as ExtensionContext["ui"],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build an assistant message payload with a single text block.
|
|
108
|
+
*
|
|
109
|
+
* @param text - Assistant text content
|
|
110
|
+
* @returns Assistant message payload
|
|
111
|
+
*/
|
|
112
|
+
function assistantMessage(text: string): {
|
|
113
|
+
readonly content: readonly [{ readonly text: string; readonly type: "text" }];
|
|
114
|
+
readonly role: "assistant";
|
|
115
|
+
} {
|
|
116
|
+
return {
|
|
117
|
+
content: [{ text, type: "text" }],
|
|
118
|
+
role: "assistant",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Initialize plan execution mode with a 3-step plan and tracking enabled.
|
|
124
|
+
*
|
|
125
|
+
* @param harness - Extension harness with plan-mode extension loaded
|
|
126
|
+
* @returns Promise that resolves once execution mode is active
|
|
127
|
+
*/
|
|
128
|
+
async function initializeExecutionMode(harness: ExtensionHarness): Promise<void> {
|
|
129
|
+
harness.setFlag("plan", true);
|
|
130
|
+
await harness.fireEvent(
|
|
131
|
+
"session_start",
|
|
132
|
+
{ type: "session_start" },
|
|
133
|
+
createContext({ hasUI: true })
|
|
134
|
+
);
|
|
135
|
+
await harness.fireEvent(
|
|
136
|
+
"agent_end",
|
|
137
|
+
{ messages: [assistantMessage(PLAN_TEXT)] },
|
|
138
|
+
createContext({
|
|
139
|
+
hasUI: true,
|
|
140
|
+
selectResponse: "Execute the plan (track progress)",
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fire a failed tool_result event using the documented test payload shape.
|
|
147
|
+
*
|
|
148
|
+
* @param harness - Extension harness instance
|
|
149
|
+
* @param ctx - Extension context for the event
|
|
150
|
+
* @returns Promise that resolves after handlers run
|
|
151
|
+
*/
|
|
152
|
+
async function fireFailedToolResult(
|
|
153
|
+
harness: ExtensionHarness,
|
|
154
|
+
ctx: ExtensionContext
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
await harness.fireEvent(
|
|
157
|
+
"tool_result",
|
|
158
|
+
{
|
|
159
|
+
content: [{ text: "blocked", type: "text" }],
|
|
160
|
+
input: { command: "echo blocked" },
|
|
161
|
+
isError: true,
|
|
162
|
+
toolCallId: "tool-call-1",
|
|
163
|
+
toolName: "bash",
|
|
164
|
+
},
|
|
165
|
+
ctx
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fire a turn_end event that marks a plan step as complete.
|
|
171
|
+
*
|
|
172
|
+
* @param harness - Extension harness instance
|
|
173
|
+
* @param step - Step number to mark done
|
|
174
|
+
* @returns Promise that resolves after handlers run
|
|
175
|
+
*/
|
|
176
|
+
async function fireDoneStep(harness: ExtensionHarness, step: number): Promise<void> {
|
|
177
|
+
await harness.fireEvent(
|
|
178
|
+
"turn_end",
|
|
179
|
+
{ message: assistantMessage(`[DONE:${step}]`) },
|
|
180
|
+
createContext({ hasUI: true })
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
describe("Plan rejection feedback", () => {
|
|
185
|
+
it("ignores tool_result during execution when hasUI is false", async () => {
|
|
186
|
+
const harness = ExtensionHarness.create();
|
|
187
|
+
await harness.loadExtension(planModeExtension);
|
|
188
|
+
await initializeExecutionMode(harness);
|
|
189
|
+
|
|
190
|
+
await fireFailedToolResult(harness, createContext({ hasUI: false }));
|
|
191
|
+
|
|
192
|
+
expect(harness.sentUserMessages).toHaveLength(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("sends steer guidance with Step 1 prefix when user confirms and provides guidance", async () => {
|
|
196
|
+
const harness = ExtensionHarness.create();
|
|
197
|
+
await harness.loadExtension(planModeExtension);
|
|
198
|
+
await initializeExecutionMode(harness);
|
|
199
|
+
|
|
200
|
+
await fireFailedToolResult(
|
|
201
|
+
harness,
|
|
202
|
+
createContext({
|
|
203
|
+
confirmResponse: true,
|
|
204
|
+
editorResponse: "Use a narrower inspection command and continue.",
|
|
205
|
+
hasUI: true,
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(harness.sentUserMessages).toHaveLength(1);
|
|
210
|
+
const [message] = harness.sentUserMessages;
|
|
211
|
+
if (!message || typeof message.content !== "string") {
|
|
212
|
+
throw new Error("Expected one string user message");
|
|
213
|
+
}
|
|
214
|
+
expect(message.content.startsWith("[PLAN GUIDANCE — Step 1:")).toBe(true);
|
|
215
|
+
expect(message.options?.deliverAs).toBe("steer");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not send guidance when user declines confirmation", async () => {
|
|
219
|
+
const harness = ExtensionHarness.create();
|
|
220
|
+
await harness.loadExtension(planModeExtension);
|
|
221
|
+
await initializeExecutionMode(harness);
|
|
222
|
+
|
|
223
|
+
await fireFailedToolResult(
|
|
224
|
+
harness,
|
|
225
|
+
createContext({
|
|
226
|
+
confirmResponse: false,
|
|
227
|
+
hasUI: true,
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(harness.sentUserMessages).toHaveLength(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("advances tracked step guidance to Step 3 and stops after all steps complete", async () => {
|
|
235
|
+
const harness = ExtensionHarness.create();
|
|
236
|
+
await harness.loadExtension(planModeExtension);
|
|
237
|
+
await initializeExecutionMode(harness);
|
|
238
|
+
|
|
239
|
+
await fireDoneStep(harness, 1);
|
|
240
|
+
await fireDoneStep(harness, 2);
|
|
241
|
+
|
|
242
|
+
await fireFailedToolResult(
|
|
243
|
+
harness,
|
|
244
|
+
createContext({
|
|
245
|
+
confirmResponse: true,
|
|
246
|
+
editorResponse: "Focus on the final verification sequence.",
|
|
247
|
+
hasUI: true,
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(harness.sentUserMessages).toHaveLength(1);
|
|
252
|
+
const [stepThreeGuidance] = harness.sentUserMessages;
|
|
253
|
+
if (!stepThreeGuidance || typeof stepThreeGuidance.content !== "string") {
|
|
254
|
+
throw new Error("Expected one string user message");
|
|
255
|
+
}
|
|
256
|
+
expect(stepThreeGuidance.content.startsWith("[PLAN GUIDANCE — Step 3:")).toBe(true);
|
|
257
|
+
expect(stepThreeGuidance.options?.deliverAs).toBe("steer");
|
|
258
|
+
|
|
259
|
+
await fireDoneStep(harness, 3);
|
|
260
|
+
|
|
261
|
+
await fireFailedToolResult(
|
|
262
|
+
harness,
|
|
263
|
+
createContext({
|
|
264
|
+
confirmResponse: true,
|
|
265
|
+
editorResponse: "This should not be sent after completion.",
|
|
266
|
+
hasUI: true,
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(harness.sentUserMessages).toHaveLength(1);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createScriptedStreamFn } from "../../test-utils/mock-model.js";
|
|
7
|
+
import { createSessionRunner, type SessionRunner } from "../../test-utils/session-runner.js";
|
|
8
|
+
import worktreeExtension from "../worktree/index.js";
|
|
9
|
+
import { createWorktree, removeWorktree } from "../worktree/lifecycle.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a git command and return trimmed stdout.
|
|
13
|
+
*
|
|
14
|
+
* @param cwd - Working directory
|
|
15
|
+
* @param args - Git command args
|
|
16
|
+
* @returns Trimmed stdout
|
|
17
|
+
*/
|
|
18
|
+
function git(cwd: string, ...args: string[]): string {
|
|
19
|
+
const output = execFileSync("git", args, {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
return output.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a temporary git repo with one commit.
|
|
29
|
+
*
|
|
30
|
+
* @returns Repository root path
|
|
31
|
+
*/
|
|
32
|
+
function createTempRepo(): string {
|
|
33
|
+
const repo = mkdtempSync(join(tmpdir(), "tallow-worktree-integration-"));
|
|
34
|
+
git(repo, "init");
|
|
35
|
+
git(repo, "config", "user.email", "test@example.com");
|
|
36
|
+
git(repo, "config", "user.name", "Test User");
|
|
37
|
+
writeFileSync(join(repo, "README.md"), "# test\n", "utf-8");
|
|
38
|
+
git(repo, "add", "README.md");
|
|
39
|
+
git(repo, "commit", "-m", "init");
|
|
40
|
+
return repo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("worktree extension integration", () => {
|
|
44
|
+
let repoRoot = "";
|
|
45
|
+
let runner: SessionRunner | undefined;
|
|
46
|
+
let sessionWorktreePath = "";
|
|
47
|
+
let previousEnv: Record<string, string | undefined> = {};
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
repoRoot = createTempRepo();
|
|
51
|
+
const created = createWorktree(repoRoot, { scope: "session" });
|
|
52
|
+
sessionWorktreePath = created.worktreePath;
|
|
53
|
+
previousEnv = {
|
|
54
|
+
TALLOW_WORKTREE_ORIGINAL_CWD: process.env.TALLOW_WORKTREE_ORIGINAL_CWD,
|
|
55
|
+
TALLOW_WORKTREE_PATH: process.env.TALLOW_WORKTREE_PATH,
|
|
56
|
+
};
|
|
57
|
+
process.env.TALLOW_WORKTREE_ORIGINAL_CWD = repoRoot;
|
|
58
|
+
process.env.TALLOW_WORKTREE_PATH = sessionWorktreePath;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
runner?.dispose();
|
|
63
|
+
runner = undefined;
|
|
64
|
+
removeWorktree(sessionWorktreePath);
|
|
65
|
+
rmSync(repoRoot, { force: true, recursive: true });
|
|
66
|
+
if (previousEnv.TALLOW_WORKTREE_ORIGINAL_CWD === undefined) {
|
|
67
|
+
delete process.env.TALLOW_WORKTREE_ORIGINAL_CWD;
|
|
68
|
+
} else {
|
|
69
|
+
process.env.TALLOW_WORKTREE_ORIGINAL_CWD = previousEnv.TALLOW_WORKTREE_ORIGINAL_CWD;
|
|
70
|
+
}
|
|
71
|
+
if (previousEnv.TALLOW_WORKTREE_PATH === undefined) {
|
|
72
|
+
delete process.env.TALLOW_WORKTREE_PATH;
|
|
73
|
+
} else {
|
|
74
|
+
process.env.TALLOW_WORKTREE_PATH = previousEnv.TALLOW_WORKTREE_PATH;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("binds and runs with session worktree metadata enabled", async () => {
|
|
79
|
+
runner = await createSessionRunner({
|
|
80
|
+
cwd: sessionWorktreePath,
|
|
81
|
+
extensionFactories: [worktreeExtension],
|
|
82
|
+
streamFn: createScriptedStreamFn([{ text: "ok" }]),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await runner.run("hello");
|
|
86
|
+
expect(result.events.length).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
|
-
import * as os from "node:os";
|
|
19
|
-
import * as path from "node:path";
|
|
20
18
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
21
19
|
import { Loader } from "@mariozechner/pi-tui";
|
|
20
|
+
import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
|
|
22
21
|
|
|
23
22
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
24
23
|
|
|
@@ -141,7 +140,7 @@ export function getSpinner(): string[] {
|
|
|
141
140
|
* @returns User icon overrides
|
|
142
141
|
*/
|
|
143
142
|
function readIconSettings(): IconOverrides {
|
|
144
|
-
const settingsPath =
|
|
143
|
+
const settingsPath = getTallowSettingsPath();
|
|
145
144
|
try {
|
|
146
145
|
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
147
146
|
const settings = JSON.parse(raw) as { icons?: IconOverrides };
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
|
-
import
|
|
9
|
-
import * as path from "node:path";
|
|
8
|
+
import { getTallowSettingsPath } from "./tallow-paths.js";
|
|
10
9
|
|
|
11
10
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
|
|
12
11
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
@@ -47,8 +46,7 @@ export function extractPreview(output: string, maxLines = 3, maxLineWidth = 80):
|
|
|
47
46
|
*/
|
|
48
47
|
export function isInlineResultsEnabled(): boolean {
|
|
49
48
|
try {
|
|
50
|
-
const
|
|
51
|
-
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
49
|
+
const raw = fs.readFileSync(getTallowSettingsPath(), "utf-8");
|
|
52
50
|
const settings = JSON.parse(raw) as { inlineAgentResults?: boolean };
|
|
53
51
|
return settings.inlineAgentResults !== false;
|
|
54
52
|
} catch {
|
|
@@ -32,6 +32,37 @@ export const INTEROP_API_CHANNELS = {
|
|
|
32
32
|
/** Valid interop event channel names. */
|
|
33
33
|
export type InteropEventName = (typeof INTEROP_EVENT_NAMES)[keyof typeof INTEROP_EVENT_NAMES];
|
|
34
34
|
|
|
35
|
+
/** Canonical hook/event-bus names for worktree lifecycle notifications. */
|
|
36
|
+
export const WORKTREE_LIFECYCLE_EVENT_NAMES = {
|
|
37
|
+
create: "worktree_create",
|
|
38
|
+
remove: "worktree_remove",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Claude-compatible aliases for worktree lifecycle notifications. */
|
|
42
|
+
export const WORKTREE_LIFECYCLE_EVENT_ALIASES = {
|
|
43
|
+
[WORKTREE_LIFECYCLE_EVENT_NAMES.create]: "WorktreeCreate",
|
|
44
|
+
[WORKTREE_LIFECYCLE_EVENT_NAMES.remove]: "WorktreeRemove",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** Valid worktree lifecycle event names. */
|
|
48
|
+
export type WorktreeLifecycleEventName =
|
|
49
|
+
(typeof WORKTREE_LIFECYCLE_EVENT_NAMES)[keyof typeof WORKTREE_LIFECYCLE_EVENT_NAMES];
|
|
50
|
+
|
|
51
|
+
/** Valid worktree scopes in lifecycle payloads. */
|
|
52
|
+
export type WorktreeLifecycleScope = "session" | "subagent";
|
|
53
|
+
|
|
54
|
+
/** TypeBox schema for worktree lifecycle payloads shared across extensions. */
|
|
55
|
+
export const WorktreeLifecyclePayloadSchema = Type.Object({
|
|
56
|
+
agentId: Type.Optional(Type.String()),
|
|
57
|
+
repoRoot: Type.String(),
|
|
58
|
+
scope: Type.Union([Type.Literal("session"), Type.Literal("subagent")]),
|
|
59
|
+
timestamp: Type.Number(),
|
|
60
|
+
worktreePath: Type.String(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Typed worktree lifecycle payload. */
|
|
64
|
+
export type WorktreeLifecycleEventPayload = Static<typeof WorktreeLifecyclePayloadSchema>;
|
|
65
|
+
|
|
35
66
|
const SubagentStatusSchema = Type.Union([
|
|
36
67
|
Type.Literal("completed"),
|
|
37
68
|
Type.Literal("failed"),
|
|
@@ -158,6 +189,23 @@ export function emitInteropEvent<TName extends InteropEventName>(
|
|
|
158
189
|
events.emit(eventName, nextPayload);
|
|
159
190
|
}
|
|
160
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Emit canonical and Claude-compatible worktree lifecycle events.
|
|
194
|
+
*
|
|
195
|
+
* @param events - Shared extension event bus
|
|
196
|
+
* @param eventName - Canonical lifecycle event name
|
|
197
|
+
* @param payload - Worktree lifecycle payload
|
|
198
|
+
* @returns void
|
|
199
|
+
*/
|
|
200
|
+
export function emitWorktreeLifecycleEvent(
|
|
201
|
+
events: ExtensionAPI["events"],
|
|
202
|
+
eventName: WorktreeLifecycleEventName,
|
|
203
|
+
payload: WorktreeLifecycleEventPayload
|
|
204
|
+
): void {
|
|
205
|
+
events.emit(eventName, payload);
|
|
206
|
+
events.emit(WORKTREE_LIFECYCLE_EVENT_ALIASES[eventName], payload);
|
|
207
|
+
}
|
|
208
|
+
|
|
161
209
|
/**
|
|
162
210
|
* Parse and validate an interop event payload against its schema.
|
|
163
211
|
*
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseRules,
|
|
29
29
|
} from "./permissions.js";
|
|
30
30
|
import { isProjectTrusted } from "./project-trust.js";
|
|
31
|
+
import { getTallowSettingsPath } from "./tallow-paths.js";
|
|
31
32
|
|
|
32
33
|
/** Trust classification for shell/process execution sources. */
|
|
33
34
|
export type ShellTrustLevel = "explicit" | "implicit" | "internal";
|
|
@@ -454,7 +455,7 @@ export function isShellInterpolationEnabled(cwd: string = process.cwd()): boolea
|
|
|
454
455
|
if (process.env.TALLOW_ENABLE_SHELL_INTERPOLATION === "1") return true;
|
|
455
456
|
if (process.env.TALLOW_SHELL_INTERPOLATION === "1") return true;
|
|
456
457
|
|
|
457
|
-
const globalSettingsPath =
|
|
458
|
+
const globalSettingsPath = getTallowSettingsPath();
|
|
458
459
|
const settingsPaths = isProjectTrusted()
|
|
459
460
|
? [join(cwd, ".tallow", "settings.json"), globalSettingsPath]
|
|
460
461
|
: [globalSettingsPath];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the default tallow home directory when no runtime override is set.
|
|
6
|
+
*
|
|
7
|
+
* @returns Absolute path to the default ~/.tallow directory
|
|
8
|
+
*/
|
|
9
|
+
export function getDefaultTallowHomeDir(): string {
|
|
10
|
+
return join(homedir(), ".tallow");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the active tallow home directory.
|
|
15
|
+
*
|
|
16
|
+
* Priority:
|
|
17
|
+
* 1. TALLOW_CODING_AGENT_DIR (tallow bootstrap)
|
|
18
|
+
* 2. PI_CODING_AGENT_DIR (framework compatibility)
|
|
19
|
+
* 3. ~/.tallow (default)
|
|
20
|
+
*
|
|
21
|
+
* @returns Absolute path to the active tallow home directory
|
|
22
|
+
*/
|
|
23
|
+
export function getTallowHomeDir(): string {
|
|
24
|
+
return (
|
|
25
|
+
process.env.TALLOW_CODING_AGENT_DIR ??
|
|
26
|
+
process.env.PI_CODING_AGENT_DIR ??
|
|
27
|
+
getDefaultTallowHomeDir()
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a path under the active tallow home directory.
|
|
33
|
+
*
|
|
34
|
+
* @param segments - Path segments to append to the home directory
|
|
35
|
+
* @returns Absolute path under the active tallow home directory
|
|
36
|
+
*/
|
|
37
|
+
export function getTallowPath(...segments: string[]): string {
|
|
38
|
+
return join(getTallowHomeDir(), ...segments);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the active global settings path.
|
|
43
|
+
*
|
|
44
|
+
* @returns Absolute path to settings.json in the active tallow home
|
|
45
|
+
*/
|
|
46
|
+
export function getTallowSettingsPath(): string {
|
|
47
|
+
return getTallowPath("settings.json");
|
|
48
|
+
}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
6
6
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
8
|
+
import { delimiter, join, resolve } from "node:path";
|
|
9
9
|
import {
|
|
10
10
|
computeEffectiveTools,
|
|
11
|
+
getPluginAgentDirsFromEnv,
|
|
11
12
|
loadAgentsFromDir,
|
|
12
13
|
PI_BUILTIN_TOOLS,
|
|
13
14
|
parseAgent,
|
|
@@ -136,6 +137,44 @@ describe("resolvePath", () => {
|
|
|
136
137
|
});
|
|
137
138
|
});
|
|
138
139
|
|
|
140
|
+
// ── getPluginAgentDirsFromEnv ───────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("getPluginAgentDirsFromEnv", () => {
|
|
143
|
+
it("returns empty array for empty input", () => {
|
|
144
|
+
expect(getPluginAgentDirsFromEnv("")).toEqual([]);
|
|
145
|
+
expect(getPluginAgentDirsFromEnv(undefined)).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("parses existing directories with platform delimiter", () => {
|
|
149
|
+
const root = mkdtempSync(join(tmpdir(), "agent-plugin-dirs-"));
|
|
150
|
+
try {
|
|
151
|
+
const a = join(root, "plugin-a", "agents");
|
|
152
|
+
const b = join(root, "plugin-b", "agents");
|
|
153
|
+
mkdirSync(a, { recursive: true });
|
|
154
|
+
mkdirSync(b, { recursive: true });
|
|
155
|
+
|
|
156
|
+
const dirs = getPluginAgentDirsFromEnv(`${a}${delimiter}${b}`);
|
|
157
|
+
expect(dirs).toEqual([resolve(a), resolve(b)]);
|
|
158
|
+
} finally {
|
|
159
|
+
rmSync(root, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("deduplicates and skips missing directories", () => {
|
|
164
|
+
const root = mkdtempSync(join(tmpdir(), "agent-plugin-dirs-"));
|
|
165
|
+
try {
|
|
166
|
+
const a = join(root, "plugin-a", "agents");
|
|
167
|
+
mkdirSync(a, { recursive: true });
|
|
168
|
+
const missing = join(root, "missing", "agents");
|
|
169
|
+
|
|
170
|
+
const dirs = getPluginAgentDirsFromEnv(`${a}${delimiter}${missing}${delimiter}${a}`);
|
|
171
|
+
expect(dirs).toEqual([resolve(a)]);
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(root, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
139
178
|
// ── loadAgentsFromDir ────────────────────────────────────────────────────────
|
|
140
179
|
|
|
141
180
|
describe("loadAgentsFromDir", () => {
|
|
@@ -12,6 +12,7 @@ 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 type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { getTallowHomeDir } from "../_shared/tallow-paths.js";
|
|
15
16
|
|
|
16
17
|
export interface AgentFrontmatter {
|
|
17
18
|
name?: string;
|
|
@@ -237,14 +238,42 @@ export function getPackageAgentDirs(settingsPath: string): string[] {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
241
|
+
* Read plugin agent directories from the SDK-injected env var.
|
|
242
|
+
*
|
|
243
|
+
* SDK populates `TALLOW_PLUGIN_AGENTS_DIRS` with absolute `agents/` paths
|
|
244
|
+
* for resolved Claude-style plugins.
|
|
245
|
+
*
|
|
246
|
+
* @param envValue - Optional env value override for tests
|
|
247
|
+
* @returns Existing agent directories from the env var (deduplicated)
|
|
248
|
+
*/
|
|
249
|
+
export function getPluginAgentDirsFromEnv(envValue?: string): string[] {
|
|
250
|
+
const raw = (envValue ?? process.env.TALLOW_PLUGIN_AGENTS_DIRS ?? "").trim();
|
|
251
|
+
if (!raw) return [];
|
|
252
|
+
|
|
253
|
+
const seen = new Set<string>();
|
|
254
|
+
const dirs: string[] = [];
|
|
255
|
+
for (const dir of raw
|
|
256
|
+
.split(path.delimiter)
|
|
257
|
+
.map((v) => v.trim())
|
|
258
|
+
.filter(Boolean)) {
|
|
259
|
+
const normalized = path.resolve(dir);
|
|
260
|
+
if (!fs.existsSync(normalized) || seen.has(normalized)) continue;
|
|
261
|
+
seen.add(normalized);
|
|
262
|
+
dirs.push(normalized);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return dirs;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Loads agents from user, project, package/plugin, and .claude/ directories.
|
|
270
|
+
* Priority: bundled → packages/plugins → .claude/user → .tallow/user → .claude/project → .tallow/project
|
|
242
271
|
* Last wins per name, so .tallow/ takes precedence over .claude/.
|
|
243
272
|
*
|
|
244
273
|
* @returns Merged array of unique agents
|
|
245
274
|
*/
|
|
246
275
|
function loadAgents(): Agent[] {
|
|
247
|
-
const agentDir =
|
|
276
|
+
const agentDir = getTallowHomeDir();
|
|
248
277
|
const userDir = path.join(agentDir, "agents");
|
|
249
278
|
const userClaudeDir = path.join(os.homedir(), ".claude", "agents");
|
|
250
279
|
const projectDir = path.join(process.cwd(), ".tallow", "agents");
|
|
@@ -262,11 +291,14 @@ function loadAgents(): Agent[] {
|
|
|
262
291
|
const globalSettingsPath = path.join(agentDir, "settings.json");
|
|
263
292
|
const projectSettingsPath = path.join(process.cwd(), ".tallow", "settings.json");
|
|
264
293
|
const packageDirs = [
|
|
265
|
-
...
|
|
266
|
-
|
|
294
|
+
...new Set([
|
|
295
|
+
...getPackageAgentDirs(globalSettingsPath),
|
|
296
|
+
...getPackageAgentDirs(projectSettingsPath),
|
|
297
|
+
...getPluginAgentDirsFromEnv(),
|
|
298
|
+
]),
|
|
267
299
|
];
|
|
268
300
|
|
|
269
|
-
// Load in priority order: bundled → packages → .claude/user → .tallow/user → .claude/project → .tallow/project
|
|
301
|
+
// Load in priority order: bundled → packages/plugins → .claude/user → .tallow/user → .claude/project → .tallow/project
|
|
270
302
|
const agentMap = new Map<string, Agent>();
|
|
271
303
|
for (const agent of loadAgentsFromDir(bundledDir)) agentMap.set(agent.name, agent);
|
|
272
304
|
for (const dir of packageDirs) {
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
|
|
18
18
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
|
-
import * as os from "node:os";
|
|
21
20
|
import * as path from "node:path";
|
|
22
21
|
import {
|
|
23
22
|
type ExtensionAPI,
|
|
@@ -47,6 +46,7 @@ import {
|
|
|
47
46
|
} from "../_shared/interop-events.js";
|
|
48
47
|
import { registerPid, unregisterPid } from "../_shared/pid-registry.js";
|
|
49
48
|
import { enforceExplicitPolicy, recordAudit } from "../_shared/shell-policy.js";
|
|
49
|
+
import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
|
|
50
50
|
import {
|
|
51
51
|
appendSection,
|
|
52
52
|
dimProcessOutputLine,
|
|
@@ -1579,7 +1579,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1579
1579
|
pi.registerCommand("toggle-inline-results", {
|
|
1580
1580
|
description: "Toggle inline result notifications for background tasks and subagents",
|
|
1581
1581
|
handler: async (_args, ctx) => {
|
|
1582
|
-
const settingsPath =
|
|
1582
|
+
const settingsPath = getTallowSettingsPath();
|
|
1583
1583
|
let settings: Record<string, unknown> = {};
|
|
1584
1584
|
try {
|
|
1585
1585
|
const raw = fs.readFileSync(settingsPath, "utf-8");
|