@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.
Files changed (60) hide show
  1. package/dist/auth-hardening.d.ts +12 -0
  2. package/dist/auth-hardening.d.ts.map +1 -1
  3. package/dist/auth-hardening.js +30 -7
  4. package/dist/auth-hardening.js.map +1 -1
  5. package/dist/cli.js +5 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +1 -1
  8. package/dist/config.js +1 -1
  9. package/dist/install.js +2 -2
  10. package/dist/install.js.map +1 -1
  11. package/dist/interactive-mode-patch.d.ts.map +1 -1
  12. package/dist/interactive-mode-patch.js +119 -7
  13. package/dist/interactive-mode-patch.js.map +1 -1
  14. package/dist/model-metadata-overrides.d.ts +19 -0
  15. package/dist/model-metadata-overrides.d.ts.map +1 -0
  16. package/dist/model-metadata-overrides.js +38 -0
  17. package/dist/model-metadata-overrides.js.map +1 -0
  18. package/dist/sdk.d.ts +2 -0
  19. package/dist/sdk.d.ts.map +1 -1
  20. package/dist/sdk.js +28 -1
  21. package/dist/sdk.js.map +1 -1
  22. package/extensions/__integration__/teams-runtime.test.ts +22 -1
  23. package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
  24. package/extensions/_shared/shell-policy.ts +27 -0
  25. package/extensions/background-task-tool/index.ts +2 -1
  26. package/extensions/bash-tool-enhanced/index.ts +2 -1
  27. package/extensions/custom-footer/__tests__/index.test.ts +29 -0
  28. package/extensions/custom-footer/context-display.ts +49 -0
  29. package/extensions/custom-footer/index.ts +10 -23
  30. package/extensions/permissions/index.ts +31 -10
  31. package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
  32. package/extensions/plan-mode-tool/index.ts +6 -1
  33. package/extensions/skill-commands/__tests__/shared-skills-dirs.test.ts +113 -0
  34. package/extensions/skill-commands/index.ts +62 -5
  35. package/extensions/slash-command-bridge/index.ts +30 -1
  36. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
  37. package/extensions/subagent-tool/process.ts +132 -21
  38. package/extensions/tasks/__tests__/store.test.ts +26 -2
  39. package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
  40. package/extensions/tasks/index.ts +5 -5
  41. package/extensions/tasks/state/index.ts +90 -36
  42. package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
  43. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
  44. package/extensions/teams-tool/archive-store.ts +200 -0
  45. package/extensions/teams-tool/sessions/spawn.ts +244 -71
  46. package/extensions/teams-tool/tools/register-extension.ts +146 -105
  47. package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
  48. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  49. package/node_modules/@mariozechner/pi-tui/dist/keys.js +59 -7
  50. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  51. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  52. package/node_modules/@mariozechner/pi-tui/src/keys.ts +71 -7
  53. package/package.json +5 -5
  54. package/skills/tallow-expert/SKILL.md +1 -1
  55. package/templates/agents/architect.md +13 -5
  56. package/templates/agents/debug.md +3 -3
  57. package/templates/agents/explore.md +9 -2
  58. package/templates/agents/refactor.md +2 -2
  59. package/templates/agents/scout.md +3 -2
  60. 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
- // Get context percentage from last assistant message
208
- const branch = sessionManager.getBranch();
209
- const lastAssistant = branch
210
- .slice()
211
- .reverse()
212
- .find(
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 > 90) {
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 { getPermissions, recordAudit, reloadPermissions } from "../_shared/shell-policy.js";
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
- const specifier = getSpecifierDisplay(toolName, input, cwd);
146
- const confirmed = await confirmPermission(ctx, event.toolName, specifier, verdict);
147
- if (!confirmed) {
148
- recordPermissionAudit(event.toolName, cwd, "blocked", verdict);
149
- return {
150
- block: true,
151
- reason: `Permission request denied: ${buildBlockReason(verdict)}`,
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: true,
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 user expresses planning intent in natural language
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 claudeSkillPaths: string[] = [];
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)) claudeSkillPaths.push(userClaudeSkills);
194
+ if (fs.existsSync(userClaudeSkills)) extraSkillPaths.push(userClaudeSkills);
140
195
  if (isProjectTrusted(process.cwd()) && fs.existsSync(projectClaudeSkills)) {
141
- claudeSkillPaths.push(projectClaudeSkills);
196
+ extraSkillPaths.push(projectClaudeSkills);
142
197
  }
143
198
 
144
- // Load skills synchronously during extension init for autocomplete to work
145
- const { skills } = loadSkills({ skillPaths: claudeSkillPaths });
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
- "interactive confirmation path unavailable in subagent JSON mode"
139
- );
177
+ expect(result.errorMessage).toContain("slow provider startup");
178
+ expect(result.errorMessage).toContain("TALLOW_SUBAGENT_*");
140
179
  });
141
180
  });