@dungle-scrubs/tallow 0.8.24 → 0.8.26
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/dist/auth-hardening.d.ts +12 -0
- package/dist/auth-hardening.d.ts.map +1 -1
- package/dist/auth-hardening.js +30 -7
- package/dist/auth-hardening.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/install.js +2 -2
- package/dist/install.js.map +1 -1
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +119 -7
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +19 -0
- package/dist/model-metadata-overrides.d.ts.map +1 -0
- package/dist/model-metadata-overrides.js +38 -0
- package/dist/model-metadata-overrides.js.map +1 -0
- package/dist/sdk.d.ts +2 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +28 -1
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/teams-runtime.test.ts +22 -1
- package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
- package/extensions/_shared/shell-policy.ts +27 -0
- package/extensions/background-task-tool/index.ts +2 -1
- package/extensions/bash-tool-enhanced/index.ts +2 -1
- package/extensions/custom-footer/__tests__/index.test.ts +29 -0
- package/extensions/custom-footer/context-display.ts +49 -0
- package/extensions/custom-footer/index.ts +10 -23
- package/extensions/permissions/index.ts +31 -10
- package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
- package/extensions/plan-mode-tool/index.ts +6 -1
- package/extensions/skill-commands/__tests__/shared-skills-dirs.test.ts +113 -0
- package/extensions/skill-commands/index.ts +62 -5
- package/extensions/slash-command-bridge/index.ts +30 -1
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
- package/extensions/subagent-tool/process.ts +132 -21
- package/extensions/tasks/__tests__/store.test.ts +26 -2
- package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
- package/extensions/tasks/index.ts +5 -5
- package/extensions/tasks/state/index.ts +90 -36
- package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
- package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
- package/extensions/teams-tool/archive-store.ts +200 -0
- package/extensions/teams-tool/sessions/spawn.ts +244 -71
- package/extensions/teams-tool/tools/register-extension.ts +146 -105
- package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +59 -7
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +71 -7
- package/package.json +5 -5
- package/skills/tallow-expert/SKILL.md +1 -1
- package/templates/agents/architect.md +13 -5
- package/templates/agents/debug.md +3 -3
- package/templates/agents/explore.md +9 -2
- package/templates/agents/refactor.md +2 -2
- package/templates/agents/scout.md +3 -2
- package/extensions/__integration__/plan-rejection-feedback.test.ts +0 -272
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ContextUsage } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats token counts with k/M suffixes for readability.
|
|
5
|
+
*
|
|
6
|
+
* @param count - Token count to format
|
|
7
|
+
* @returns Formatted string (e.g., "1.2k", "5M")
|
|
8
|
+
*/
|
|
9
|
+
function formatTokens(count: number): string {
|
|
10
|
+
if (count < 1000) return count.toString();
|
|
11
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
12
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
13
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
14
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Formats footer context usage without reusing stale pre-compaction token counts.
|
|
19
|
+
*
|
|
20
|
+
* `ctx.getContextUsage()` intentionally returns `tokens: null` after compaction
|
|
21
|
+
* until a fresh assistant response arrives. The footer must preserve that
|
|
22
|
+
* unknown state instead of showing a bogus percentage from stale usage data.
|
|
23
|
+
*
|
|
24
|
+
* @param usage - Current context usage snapshot, if available
|
|
25
|
+
* @param fallbackContextWindow - Active model context window when usage is unavailable
|
|
26
|
+
* @param autoCompactEnabled - Whether to append the auto-compaction indicator
|
|
27
|
+
* @returns Display text plus raw percentage for severity coloring
|
|
28
|
+
*/
|
|
29
|
+
export function formatContextUsageDisplay(
|
|
30
|
+
usage: ContextUsage | undefined,
|
|
31
|
+
fallbackContextWindow: number,
|
|
32
|
+
autoCompactEnabled: boolean
|
|
33
|
+
): { readonly percent: number | null; readonly text: string } {
|
|
34
|
+
const autoIndicator = autoCompactEnabled ? " (auto)" : "";
|
|
35
|
+
const contextWindow = usage?.contextWindow ?? fallbackContextWindow;
|
|
36
|
+
const tokens = usage ? usage.tokens : 0;
|
|
37
|
+
|
|
38
|
+
if (contextWindow <= 0) {
|
|
39
|
+
return { percent: null, text: `?/?${autoIndicator}` };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const windowText = formatTokens(contextWindow);
|
|
43
|
+
if (tokens === null) {
|
|
44
|
+
return { percent: null, text: `?/${windowText}${autoIndicator}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const percent = (tokens / contextWindow) * 100;
|
|
48
|
+
return { percent, text: `${percent.toFixed(1)}%/${windowText}${autoIndicator}` };
|
|
49
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
20
20
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
21
21
|
import { runGitCommandSync } from "../_shared/shell-policy.js";
|
|
22
|
+
import { formatContextUsageDisplay } from "./context-display.js";
|
|
22
23
|
|
|
23
24
|
/** Cached git repository state for the footer display. */
|
|
24
25
|
interface GitState {
|
|
@@ -204,26 +205,12 @@ export default function customFooterExtension(pi: ExtensionAPI): void {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
(e) =>
|
|
214
|
-
e.type === "message" &&
|
|
215
|
-
e.message.role === "assistant" &&
|
|
216
|
-
(e.message as unknown as Record<string, string>).stopReason !== "aborted"
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
let contextTokens = 0;
|
|
220
|
-
if (lastAssistant?.type === "message" && lastAssistant.message.role === "assistant") {
|
|
221
|
-
const u = lastAssistant.message.usage;
|
|
222
|
-
contextTokens = u.input + u.output + u.cacheRead + u.cacheWrite;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const contextWindow = model?.contextWindow || 0;
|
|
226
|
-
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
208
|
+
const contextUsage = extensionCtx.getContextUsage();
|
|
209
|
+
const { percent: contextPercentValue, text: contextDisplay } = formatContextUsageDisplay(
|
|
210
|
+
contextUsage,
|
|
211
|
+
model?.contextWindow ?? 0,
|
|
212
|
+
autoCompactEnabled
|
|
213
|
+
);
|
|
227
214
|
|
|
228
215
|
// Build path (replace home with ~)
|
|
229
216
|
let pwd = process.cwd();
|
|
@@ -270,10 +257,10 @@ export default function customFooterExtension(pi: ExtensionAPI): void {
|
|
|
270
257
|
if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`);
|
|
271
258
|
|
|
272
259
|
// Context percentage with color
|
|
273
|
-
const autoIndicator = autoCompactEnabled ? " (auto)" : "";
|
|
274
|
-
const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
|
275
260
|
let contextStr: string;
|
|
276
|
-
if (contextPercentValue
|
|
261
|
+
if (contextPercentValue === null) {
|
|
262
|
+
contextStr = theme.fg("dim", contextDisplay);
|
|
263
|
+
} else if (contextPercentValue > 90) {
|
|
277
264
|
contextStr = theme.fg("error", contextDisplay);
|
|
278
265
|
} else if (contextPercentValue > 70) {
|
|
279
266
|
contextStr = theme.fg("warning", contextDisplay);
|
|
@@ -25,7 +25,12 @@ import {
|
|
|
25
25
|
type PermissionVerdict,
|
|
26
26
|
redactSensitiveReasonText,
|
|
27
27
|
} from "../_shared/permissions.js";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
getPermissions,
|
|
30
|
+
isYoloMode,
|
|
31
|
+
recordAudit,
|
|
32
|
+
reloadPermissions,
|
|
33
|
+
} from "../_shared/shell-policy.js";
|
|
29
34
|
|
|
30
35
|
// ── Helper: build expansion vars ─────────────────────────────────────────────
|
|
31
36
|
|
|
@@ -69,6 +74,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
74
|
pi.on("session_start", async (_event, ctx) => {
|
|
70
75
|
currentCwd = ctx.cwd;
|
|
71
76
|
|
|
77
|
+
// Yolo mode banner
|
|
78
|
+
if (isYoloMode()) {
|
|
79
|
+
ctx.ui?.notify(
|
|
80
|
+
"⚡ YOLO mode — auto-approving tool confirmations. Hard denies still enforced.",
|
|
81
|
+
"warning"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
// Eagerly load permissions to surface any config warnings at startup
|
|
73
86
|
const permissions = getPermissions(currentCwd);
|
|
74
87
|
const totalRules =
|
|
@@ -119,6 +132,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
119
132
|
return { block: true, reason: buildBlockReason(verdict) };
|
|
120
133
|
}
|
|
121
134
|
if (verdict.action === "ask") {
|
|
135
|
+
if (isYoloMode()) {
|
|
136
|
+
recordPermissionAudit(event.toolName, cwd, "confirmed", verdict);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
122
139
|
const confirmed = await confirmPermission(ctx, event.toolName, agent, verdict);
|
|
123
140
|
if (!confirmed) {
|
|
124
141
|
recordPermissionAudit(event.toolName, cwd, "blocked", verdict);
|
|
@@ -142,16 +159,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
if (verdict.action === "ask") {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
if (isYoloMode()) {
|
|
163
|
+
recordPermissionAudit(event.toolName, cwd, "confirmed", verdict);
|
|
164
|
+
} else {
|
|
165
|
+
const specifier = getSpecifierDisplay(toolName, input, cwd);
|
|
166
|
+
const confirmed = await confirmPermission(ctx, event.toolName, specifier, verdict);
|
|
167
|
+
if (!confirmed) {
|
|
168
|
+
recordPermissionAudit(event.toolName, cwd, "blocked", verdict);
|
|
169
|
+
return {
|
|
170
|
+
block: true,
|
|
171
|
+
reason: `Permission request denied: ${buildBlockReason(verdict)}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
recordPermissionAudit(event.toolName, cwd, "confirmed", verdict);
|
|
153
175
|
}
|
|
154
|
-
recordPermissionAudit(event.toolName, cwd, "confirmed", verdict);
|
|
155
176
|
}
|
|
156
177
|
|
|
157
178
|
if (verdict.action === "allow") {
|
|
@@ -63,10 +63,10 @@ function registerMockTools(pi: ExtensionAPI): void {
|
|
|
63
63
|
* @param entries - Session entries returned by sessionManager.getEntries
|
|
64
64
|
* @returns Context object compatible with extension handlers
|
|
65
65
|
*/
|
|
66
|
-
function createContext(entries: unknown[] = []): ExtensionContext {
|
|
66
|
+
function createContext(entries: unknown[] = [], hasUI = true): ExtensionContext {
|
|
67
67
|
return {
|
|
68
68
|
cwd: process.cwd(),
|
|
69
|
-
hasUI
|
|
69
|
+
hasUI,
|
|
70
70
|
ui: {
|
|
71
71
|
notify() {},
|
|
72
72
|
setStatus() {},
|
|
@@ -180,4 +180,34 @@ describe("plan-mode strict readonly enforcement", () => {
|
|
|
180
180
|
);
|
|
181
181
|
expect(blockedResult).toMatchObject({ block: true });
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
test("auto-enable only triggers for interactive UI input", async () => {
|
|
185
|
+
const [result] = await harness.fireEvent(
|
|
186
|
+
"input",
|
|
187
|
+
{ source: "interactive", text: "plan only fix auth" },
|
|
188
|
+
createContext([], true)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result).toEqual({ action: "transform", text: "fix auth" });
|
|
192
|
+
expect(harness.api.getActiveTools()).toEqual(
|
|
193
|
+
PLAN_MODE_ALLOWED_TOOLS.filter((name) => BASELINE_TOOLS.includes(name))
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("auto-enable ignores headless or non-interactive input", async () => {
|
|
198
|
+
const [headlessResult] = await harness.fireEvent(
|
|
199
|
+
"input",
|
|
200
|
+
{ source: "interactive", text: "plan only fix auth" },
|
|
201
|
+
createContext([], false)
|
|
202
|
+
);
|
|
203
|
+
const [rpcResult] = await harness.fireEvent(
|
|
204
|
+
"input",
|
|
205
|
+
{ source: "rpc", text: "plan only fix auth" },
|
|
206
|
+
createContext([], true)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(headlessResult).toEqual({ action: "continue" });
|
|
210
|
+
expect(rpcResult).toEqual({ action: "continue" });
|
|
211
|
+
expect(harness.api.getActiveTools()).toEqual([...BASELINE_TOOLS]);
|
|
212
|
+
});
|
|
183
213
|
});
|
|
@@ -378,13 +378,18 @@ Use action "enable" to enter plan mode, "disable" to exit, or "status" to check
|
|
|
378
378
|
}
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
-
// Auto-enable plan mode when
|
|
381
|
+
// Auto-enable plan mode when a human interactive session explicitly signals planning intent.
|
|
382
382
|
pi.on("input", async (event, ctx) => {
|
|
383
383
|
// No-op if already in plan mode
|
|
384
384
|
if (planModeEnabled) {
|
|
385
385
|
return { action: "continue" as const };
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// Headless/orchestrated prompts should never toggle workflow modes via string matching.
|
|
389
|
+
if (!ctx.hasUI || event.source !== "interactive") {
|
|
390
|
+
return { action: "continue" as const };
|
|
391
|
+
}
|
|
392
|
+
|
|
388
393
|
if (!detectPlanIntent(event.text)) {
|
|
389
394
|
return { action: "continue" as const };
|
|
390
395
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, beforeEach, 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 { resolveSharedSkillsDirsFromSettings } from "../index.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a temporary directory for test fixtures.
|
|
9
|
+
*
|
|
10
|
+
* @returns Path to the newly created temp directory
|
|
11
|
+
*/
|
|
12
|
+
function createTmpDir(): string {
|
|
13
|
+
return mkdtempSync(join(tmpdir(), "skill-cmds-shared-"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Write a settings.json file with the given content.
|
|
18
|
+
*
|
|
19
|
+
* @param dir - Directory to write settings.json into
|
|
20
|
+
* @param settings - Settings object to serialize
|
|
21
|
+
* @returns Path to the written settings.json
|
|
22
|
+
*/
|
|
23
|
+
function writeSettings(dir: string, settings: Record<string, unknown>): string {
|
|
24
|
+
const path = join(dir, "settings.json");
|
|
25
|
+
writeFileSync(path, JSON.stringify(settings, null, 2));
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("resolveSharedSkillsDirsFromSettings", () => {
|
|
30
|
+
let tmp: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
tmp = createTmpDir();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty array when settings file does not exist", () => {
|
|
41
|
+
expect(resolveSharedSkillsDirsFromSettings(join(tmp, "nope.json"))).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns empty array when settings file is invalid JSON", () => {
|
|
45
|
+
const path = join(tmp, "settings.json");
|
|
46
|
+
writeFileSync(path, "not json {{");
|
|
47
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns empty array when sharedSkillsDirs is missing", () => {
|
|
51
|
+
const path = writeSettings(tmp, { theme: "nord" });
|
|
52
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns empty array when sharedSkillsDirs is not an array", () => {
|
|
56
|
+
const path = writeSettings(tmp, { sharedSkillsDirs: "/some/path" });
|
|
57
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resolves absolute paths that exist as directories", () => {
|
|
61
|
+
const skillsDir = join(tmp, "my-skills");
|
|
62
|
+
mkdirSync(skillsDir);
|
|
63
|
+
const path = writeSettings(tmp, { sharedSkillsDirs: [skillsDir] });
|
|
64
|
+
|
|
65
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([skillsDir]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("skips non-existent directories silently", () => {
|
|
69
|
+
const path = writeSettings(tmp, {
|
|
70
|
+
sharedSkillsDirs: [join(tmp, "does-not-exist")],
|
|
71
|
+
});
|
|
72
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips paths that are files, not directories", () => {
|
|
76
|
+
const filePath = join(tmp, "not-a-dir");
|
|
77
|
+
writeFileSync(filePath, "hello");
|
|
78
|
+
const path = writeSettings(tmp, { sharedSkillsDirs: [filePath] });
|
|
79
|
+
|
|
80
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects relative paths", () => {
|
|
84
|
+
const path = writeSettings(tmp, {
|
|
85
|
+
sharedSkillsDirs: ["relative/path", "./also-relative", "no-slash"],
|
|
86
|
+
});
|
|
87
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects non-string and empty entries", () => {
|
|
91
|
+
const path = writeSettings(tmp, {
|
|
92
|
+
sharedSkillsDirs: [42, null, "", " ", true],
|
|
93
|
+
});
|
|
94
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("handles mixed valid and invalid entries", () => {
|
|
98
|
+
const validDir = join(tmp, "valid");
|
|
99
|
+
mkdirSync(validDir);
|
|
100
|
+
const path = writeSettings(tmp, {
|
|
101
|
+
sharedSkillsDirs: [validDir, "relative", join(tmp, "nonexistent"), 42],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([validDir]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("expands tilde paths (skips when dir does not exist)", () => {
|
|
108
|
+
const path = writeSettings(tmp, {
|
|
109
|
+
sharedSkillsDirs: ["~/.nonexistent-skills-test-dir-99999"],
|
|
110
|
+
});
|
|
111
|
+
expect(resolveSharedSkillsDirsFromSettings(path)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -125,24 +125,81 @@ function disableBuiltinSkillCommands(): void {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Resolve shared skill directories from global settings.
|
|
130
|
+
*
|
|
131
|
+
* Reads `sharedSkillsDirs`, tilde-expands each entry, and validates
|
|
132
|
+
* that it exists and is a directory. Mirrors the logic in `sdk.ts`
|
|
133
|
+
* so slash-command registration sees the same skills as the system prompt.
|
|
134
|
+
*
|
|
135
|
+
* @param settingsPath - Path to global settings.json
|
|
136
|
+
* @returns Array of validated, resolved directory paths
|
|
137
|
+
*/
|
|
138
|
+
export function resolveSharedSkillsDirsFromSettings(settingsPath: string): string[] {
|
|
139
|
+
if (!fs.existsSync(settingsPath)) return [];
|
|
140
|
+
let settings: Record<string, unknown>;
|
|
141
|
+
try {
|
|
142
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const raw = settings.sharedSkillsDirs;
|
|
147
|
+
if (!Array.isArray(raw)) return [];
|
|
148
|
+
|
|
149
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
|
|
150
|
+
const resolved: string[] = [];
|
|
151
|
+
|
|
152
|
+
for (const entry of raw) {
|
|
153
|
+
if (typeof entry !== "string" || !entry.trim()) continue;
|
|
154
|
+
const trimmed = entry.trim();
|
|
155
|
+
let expanded: string;
|
|
156
|
+
if (trimmed === "~") {
|
|
157
|
+
expanded = home;
|
|
158
|
+
} else if (trimmed.startsWith("~/")) {
|
|
159
|
+
expanded = join(home, trimmed.slice(2));
|
|
160
|
+
} else if (trimmed.startsWith("/")) {
|
|
161
|
+
expanded = trimmed;
|
|
162
|
+
} else {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const stats = fs.statSync(expanded);
|
|
167
|
+
if (stats.isDirectory()) resolved.push(expanded);
|
|
168
|
+
} catch {
|
|
169
|
+
// statSync failed — skip this entry
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return resolved;
|
|
173
|
+
}
|
|
174
|
+
|
|
128
175
|
export default function (pi: ExtensionAPI) {
|
|
129
176
|
disableBuiltinSkillCommands();
|
|
130
177
|
|
|
178
|
+
const agentDir =
|
|
179
|
+
process.env.PI_CODING_AGENT_DIR ??
|
|
180
|
+
join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".tallow");
|
|
181
|
+
const settingsPath = join(agentDir, "settings.json");
|
|
182
|
+
|
|
183
|
+
// Shared skill directories from global settings (e.g. ~/dev/skills)
|
|
184
|
+
const sharedSkillsDirs = resolveSharedSkillsDirsFromSettings(settingsPath);
|
|
185
|
+
|
|
131
186
|
// Include .claude/skills/ directories for Claude Code compatibility
|
|
132
|
-
const
|
|
187
|
+
const extraSkillPaths: string[] = [...sharedSkillsDirs];
|
|
133
188
|
const userClaudeSkills = join(
|
|
134
189
|
process.env.HOME ?? process.env.USERPROFILE ?? "~",
|
|
135
190
|
".claude",
|
|
136
191
|
"skills"
|
|
137
192
|
);
|
|
138
193
|
const projectClaudeSkills = join(process.cwd(), ".claude", "skills");
|
|
139
|
-
if (fs.existsSync(userClaudeSkills))
|
|
194
|
+
if (fs.existsSync(userClaudeSkills)) extraSkillPaths.push(userClaudeSkills);
|
|
140
195
|
if (isProjectTrusted(process.cwd()) && fs.existsSync(projectClaudeSkills)) {
|
|
141
|
-
|
|
196
|
+
extraSkillPaths.push(projectClaudeSkills);
|
|
142
197
|
}
|
|
143
198
|
|
|
144
|
-
// Load skills synchronously during extension init for autocomplete to work
|
|
145
|
-
|
|
199
|
+
// Load skills synchronously during extension init for autocomplete to work.
|
|
200
|
+
// includeDefaults: true picks up ~/.tallow/skills/ and ./skills/ (project).
|
|
201
|
+
// extraSkillPaths adds shared dirs + Claude bridge paths.
|
|
202
|
+
const { skills } = loadSkills({ agentDir, skillPaths: extraSkillPaths });
|
|
146
203
|
|
|
147
204
|
for (const skill of skills) {
|
|
148
205
|
// Validate name before registration — invalid names produce broken commands
|
|
@@ -395,7 +395,8 @@ WHEN TO USE:
|
|
|
395
395
|
|
|
396
396
|
WHEN NOT TO USE:
|
|
397
397
|
- The user already ran the command themselves
|
|
398
|
-
- You want to start a new session (suggest the user run /clear instead)
|
|
398
|
+
- You want to start a new session (suggest the user run /clear instead)
|
|
399
|
+
- Context usage is below 80% — there is no need to compact proactively. Do NOT compact between tasks "just in case". Compaction destroys conversation history and should only happen when the context window is nearly full.`,
|
|
399
400
|
parameters: Type.Object({
|
|
400
401
|
command: Type.String({
|
|
401
402
|
description:
|
|
@@ -490,6 +491,34 @@ WHEN NOT TO USE:
|
|
|
490
491
|
}
|
|
491
492
|
|
|
492
493
|
case "compact": {
|
|
494
|
+
// Guard: reject model-initiated compact when context usage is low.
|
|
495
|
+
// The model frequently compacts proactively at 15-30% usage, wasting
|
|
496
|
+
// context and losing valuable conversation history. Only allow
|
|
497
|
+
// programmatic compact when usage exceeds 80% of the context window.
|
|
498
|
+
const compactUsage = ctx.getContextUsage?.();
|
|
499
|
+
if (
|
|
500
|
+
compactUsage &&
|
|
501
|
+
compactUsage.tokens !== null &&
|
|
502
|
+
compactUsage.tokens > 0 &&
|
|
503
|
+
compactUsage.contextWindow > 0
|
|
504
|
+
) {
|
|
505
|
+
const usagePercent = (compactUsage.tokens / compactUsage.contextWindow) * 100;
|
|
506
|
+
if (usagePercent < 80) {
|
|
507
|
+
return {
|
|
508
|
+
content: [
|
|
509
|
+
{
|
|
510
|
+
type: "text",
|
|
511
|
+
text:
|
|
512
|
+
`Context usage is only ${Math.round(usagePercent)}% — compaction is not needed yet. ` +
|
|
513
|
+
"The session has plenty of context space remaining. " +
|
|
514
|
+
"Continue working normally; compaction will happen automatically when needed.",
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
details: { command, rejected: true, usagePercent },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
493
522
|
// Don't call ctx.compact() here — it aborts the agent mid-tool-call,
|
|
494
523
|
// orphaning the tool execution spinner (plan 95/98). Defer to a
|
|
495
524
|
// proven turn_end boundary so the tool completes normally first.
|
|
@@ -5,7 +5,14 @@ import {
|
|
|
5
5
|
createWatchdogHeartbeatState,
|
|
6
6
|
evaluateWatchdogStatus,
|
|
7
7
|
type ForegroundWatchdogThresholds,
|
|
8
|
+
isWatchdogHeartbeatEventType,
|
|
8
9
|
recordWatchdogHeartbeat,
|
|
10
|
+
recordWatchdogToolCallEnd,
|
|
11
|
+
recordWatchdogToolCallStart,
|
|
12
|
+
resolveForegroundWatchdogThresholds,
|
|
13
|
+
SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV,
|
|
14
|
+
SUBAGENT_STARTUP_TIMEOUT_MS_ENV,
|
|
15
|
+
SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV,
|
|
9
16
|
terminateProcessWithGrace,
|
|
10
17
|
} from "../process.js";
|
|
11
18
|
|
|
@@ -13,6 +20,7 @@ const TEST_THRESHOLDS: ForegroundWatchdogThresholds = {
|
|
|
13
20
|
inactivityTimeoutMs: 2_000,
|
|
14
21
|
killGraceMs: 50,
|
|
15
22
|
startupTimeoutMs: 1_000,
|
|
23
|
+
toolExecutionTimeoutMs: 8_000,
|
|
16
24
|
};
|
|
17
25
|
|
|
18
26
|
interface ManualTimer {
|
|
@@ -102,6 +110,38 @@ describe("foreground subagent liveness watchdog", () => {
|
|
|
102
110
|
expect(stalledStatus.phase).toBe("inactivity");
|
|
103
111
|
});
|
|
104
112
|
|
|
113
|
+
it("widens the timeout while a tool call is still running", () => {
|
|
114
|
+
let state = createWatchdogHeartbeatState(0);
|
|
115
|
+
state = recordWatchdogToolCallStart(state, 500);
|
|
116
|
+
expect(evaluateWatchdogStatus(state, 6_000, TEST_THRESHOLDS).kind).toBe("healthy");
|
|
117
|
+
|
|
118
|
+
const stalledStatus = evaluateWatchdogStatus(state, 8_600, TEST_THRESHOLDS);
|
|
119
|
+
expect(stalledStatus.kind).toBe("stalled");
|
|
120
|
+
if (stalledStatus.kind !== "stalled") return;
|
|
121
|
+
expect(stalledStatus.phase).toBe("tool_execution");
|
|
122
|
+
|
|
123
|
+
state = recordWatchdogToolCallEnd(state, 8_600);
|
|
124
|
+
expect(state.activeToolCalls).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("treats message updates and tool execution events as heartbeats", () => {
|
|
128
|
+
expect(isWatchdogHeartbeatEventType("message_update")).toBe(true);
|
|
129
|
+
expect(isWatchdogHeartbeatEventType("tool_execution_start")).toBe(true);
|
|
130
|
+
expect(isWatchdogHeartbeatEventType("tool_execution_end")).toBe(true);
|
|
131
|
+
expect(isWatchdogHeartbeatEventType("tool_result_end")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("applies env overrides for watchdog thresholds", () => {
|
|
135
|
+
const thresholds = resolveForegroundWatchdogThresholds({
|
|
136
|
+
[SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV]: "7000",
|
|
137
|
+
[SUBAGENT_STARTUP_TIMEOUT_MS_ENV]: "3000",
|
|
138
|
+
[SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]: "11000",
|
|
139
|
+
});
|
|
140
|
+
expect(thresholds.inactivityTimeoutMs).toBe(7_000);
|
|
141
|
+
expect(thresholds.startupTimeoutMs).toBe(3_000);
|
|
142
|
+
expect(thresholds.toolExecutionTimeoutMs).toBe(11_000);
|
|
143
|
+
});
|
|
144
|
+
|
|
105
145
|
it("stalled termination escalates and resolves without hanging", async () => {
|
|
106
146
|
const state = createWatchdogHeartbeatState(0);
|
|
107
147
|
const stalledStatus = evaluateWatchdogStatus(state, 1_001, TEST_THRESHOLDS);
|
|
@@ -134,8 +174,7 @@ describe("foreground subagent liveness watchdog", () => {
|
|
|
134
174
|
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
135
175
|
expect(resolvedCode).toBe(1);
|
|
136
176
|
expect(result.stopReason).toBe("stalled");
|
|
137
|
-
expect(result.errorMessage).toContain(
|
|
138
|
-
|
|
139
|
-
);
|
|
177
|
+
expect(result.errorMessage).toContain("slow provider startup");
|
|
178
|
+
expect(result.errorMessage).toContain("TALLOW_SUBAGENT_*");
|
|
140
179
|
});
|
|
141
180
|
});
|