gsd-pi 2.36.0 → 2.37.0-dev.68605cd
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/resources/extensions/cmux/index.js +321 -0
- package/dist/resources/extensions/cmux/package.json +7 -0
- package/dist/resources/extensions/gsd/auto-dashboard.js +334 -104
- package/dist/resources/extensions/gsd/auto-loop.js +29 -4
- package/dist/resources/extensions/gsd/auto.js +58 -5
- package/dist/resources/extensions/gsd/commands-cmux.js +120 -0
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
- package/dist/resources/extensions/gsd/commands.js +131 -34
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -0
- package/dist/resources/extensions/gsd/git-service.js +9 -1
- package/dist/resources/extensions/gsd/history.js +2 -1
- package/dist/resources/extensions/gsd/index.js +5 -0
- package/dist/resources/extensions/gsd/metrics.js +4 -2
- package/dist/resources/extensions/gsd/notifications.js +10 -1
- package/dist/resources/extensions/gsd/preferences-types.js +2 -0
- package/dist/resources/extensions/gsd/preferences-validation.js +29 -0
- package/dist/resources/extensions/gsd/preferences.js +3 -0
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +4 -3
- package/dist/resources/extensions/gsd/prompts/research-slice.md +3 -2
- package/dist/resources/extensions/gsd/session-lock.js +26 -6
- package/dist/resources/extensions/gsd/templates/preferences.md +6 -0
- package/dist/resources/extensions/search-the-web/native-search.js +45 -4
- package/dist/resources/extensions/shared/format-utils.js +5 -41
- package/dist/resources/extensions/shared/layout-utils.js +46 -0
- package/dist/resources/extensions/shared/mod.js +2 -1
- package/dist/resources/extensions/shared/terminal.js +5 -0
- package/dist/resources/extensions/subagent/index.js +180 -60
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
- package/packages/pi-tui/dist/terminal-image.d.ts.map +1 -1
- package/packages/pi-tui/dist/terminal-image.js +4 -0
- package/packages/pi-tui/dist/terminal-image.js.map +1 -1
- package/packages/pi-tui/src/terminal-image.ts +5 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/index.ts +384 -0
- package/src/resources/extensions/cmux/package.json +7 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +363 -116
- package/src/resources/extensions/gsd/auto-loop.ts +66 -6
- package/src/resources/extensions/gsd/auto.ts +77 -5
- package/src/resources/extensions/gsd/commands-cmux.ts +143 -0
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
- package/src/resources/extensions/gsd/commands.ts +139 -32
- package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -0
- package/src/resources/extensions/gsd/git-service.ts +12 -1
- package/src/resources/extensions/gsd/history.ts +2 -1
- package/src/resources/extensions/gsd/index.ts +8 -0
- package/src/resources/extensions/gsd/metrics.ts +4 -2
- package/src/resources/extensions/gsd/notifications.ts +10 -1
- package/src/resources/extensions/gsd/preferences-types.ts +13 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +26 -0
- package/src/resources/extensions/gsd/preferences.ts +4 -0
- package/src/resources/extensions/gsd/prompts/research-milestone.md +4 -3
- package/src/resources/extensions/gsd/prompts/research-slice.md +3 -2
- package/src/resources/extensions/gsd/session-lock.ts +41 -6
- package/src/resources/extensions/gsd/templates/preferences.md +6 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +39 -1
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
- package/src/resources/extensions/gsd/tests/cmux.test.ts +122 -0
- package/src/resources/extensions/gsd/tests/preferences.test.ts +23 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
- package/src/resources/extensions/search-the-web/native-search.ts +50 -4
- package/src/resources/extensions/shared/format-utils.ts +5 -44
- package/src/resources/extensions/shared/layout-utils.ts +49 -0
- package/src/resources/extensions/shared/mod.ts +7 -4
- package/src/resources/extensions/shared/terminal.ts +5 -0
- package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
- package/src/resources/extensions/subagent/index.ts +236 -79
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type AgentEndEvent,
|
|
15
15
|
type LoopDeps,
|
|
16
16
|
} from "../auto-loop.js";
|
|
17
|
+
import type { SessionLockStatus } from "../session-lock.js";
|
|
17
18
|
|
|
18
19
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -317,6 +318,8 @@ function makeMockDeps(
|
|
|
317
318
|
},
|
|
318
319
|
clearUnitTimeout: () => {},
|
|
319
320
|
updateProgressWidget: () => {},
|
|
321
|
+
syncCmuxSidebar: () => {},
|
|
322
|
+
logCmuxEvent: () => {},
|
|
320
323
|
invalidateAllCaches: () => {
|
|
321
324
|
callLog.push("invalidateAllCaches");
|
|
322
325
|
},
|
|
@@ -339,7 +342,7 @@ function makeMockDeps(
|
|
|
339
342
|
preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }),
|
|
340
343
|
syncProjectRootToWorktree: () => {},
|
|
341
344
|
checkResourcesStale: () => null,
|
|
342
|
-
validateSessionLock: () => true,
|
|
345
|
+
validateSessionLock: () => ({ valid: true } as SessionLockStatus),
|
|
343
346
|
updateSessionLock: () => {
|
|
344
347
|
callLog.push("updateSessionLock");
|
|
345
348
|
},
|
|
@@ -530,6 +533,41 @@ test("autoLoop exits on terminal complete state", async (t) => {
|
|
|
530
533
|
);
|
|
531
534
|
});
|
|
532
535
|
|
|
536
|
+
test("autoLoop passes structured session-lock failure details to the handler", async () => {
|
|
537
|
+
_resetPendingResolve();
|
|
538
|
+
|
|
539
|
+
const ctx = makeMockCtx();
|
|
540
|
+
ctx.ui.setStatus = () => {};
|
|
541
|
+
const pi = makeMockPi();
|
|
542
|
+
const s = makeLoopSession();
|
|
543
|
+
let observedLockStatus: SessionLockStatus | undefined;
|
|
544
|
+
|
|
545
|
+
const deps = makeMockDeps({
|
|
546
|
+
validateSessionLock: () =>
|
|
547
|
+
({
|
|
548
|
+
valid: false,
|
|
549
|
+
failureReason: "compromised",
|
|
550
|
+
expectedPid: process.pid,
|
|
551
|
+
}) as SessionLockStatus,
|
|
552
|
+
handleLostSessionLock: (_ctx, lockStatus) => {
|
|
553
|
+
observedLockStatus = lockStatus;
|
|
554
|
+
deps.callLog.push("handleLostSessionLock");
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await autoLoop(ctx, pi, s, deps);
|
|
559
|
+
|
|
560
|
+
assert.deepEqual(observedLockStatus, {
|
|
561
|
+
valid: false,
|
|
562
|
+
failureReason: "compromised",
|
|
563
|
+
expectedPid: process.pid,
|
|
564
|
+
});
|
|
565
|
+
assert.ok(
|
|
566
|
+
!deps.callLog.includes("resolveDispatch"),
|
|
567
|
+
"should stop before dispatch after lock validation fails",
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
533
571
|
test("autoLoop exits on terminal blocked state", async (t) => {
|
|
534
572
|
_resetPendingResolve();
|
|
535
573
|
|
|
@@ -153,6 +153,25 @@ async function main(): Promise<void> {
|
|
|
153
153
|
// After teardown, originalBase should be null
|
|
154
154
|
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
|
|
155
155
|
|
|
156
|
+
// ─── #1526: getMainBranch returns milestone branch in auto-worktree ──
|
|
157
|
+
console.log("\n=== #1526: getMainBranch() returns milestone/<MID> in auto-worktree ===");
|
|
158
|
+
{
|
|
159
|
+
const { GitServiceImpl } = await import("../git-service.ts");
|
|
160
|
+
|
|
161
|
+
// Create worktree
|
|
162
|
+
const wtPath = createAutoWorktree(tempDir, "M005");
|
|
163
|
+
// Don't set main_branch pref so getMainBranch falls through to worktree detection
|
|
164
|
+
const gitService = new GitServiceImpl(wtPath);
|
|
165
|
+
gitService.setMilestoneId("M005");
|
|
166
|
+
|
|
167
|
+
// Verify getMainBranch returns the milestone branch
|
|
168
|
+
const mainBranch = gitService.getMainBranch();
|
|
169
|
+
assertEq(mainBranch, "milestone/M005", "getMainBranch returns milestone/<MID> in auto-worktree");
|
|
170
|
+
|
|
171
|
+
// Cleanup
|
|
172
|
+
teardownAutoWorktree(tempDir, "M005");
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
|
|
157
176
|
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
|
|
158
177
|
{
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
buildCmuxProgress,
|
|
8
|
+
buildCmuxStatusLabel,
|
|
9
|
+
detectCmuxEnvironment,
|
|
10
|
+
markCmuxPromptShown,
|
|
11
|
+
resetCmuxPromptState,
|
|
12
|
+
resolveCmuxConfig,
|
|
13
|
+
shouldPromptToEnableCmux,
|
|
14
|
+
} from "../../cmux/index.ts";
|
|
15
|
+
import type { GSDState } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
test("detectCmuxEnvironment requires workspace, surface, and socket", () => {
|
|
18
|
+
const detected = detectCmuxEnvironment(
|
|
19
|
+
{
|
|
20
|
+
CMUX_WORKSPACE_ID: "workspace:1",
|
|
21
|
+
CMUX_SURFACE_ID: "surface:2",
|
|
22
|
+
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
|
23
|
+
},
|
|
24
|
+
(path) => path === "/tmp/cmux.sock",
|
|
25
|
+
() => true,
|
|
26
|
+
);
|
|
27
|
+
assert.equal(detected.available, true);
|
|
28
|
+
assert.equal(detected.cliAvailable, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("resolveCmuxConfig enables only when preference and environment are both active", () => {
|
|
32
|
+
const config = resolveCmuxConfig(
|
|
33
|
+
{ cmux: { enabled: true, notifications: true, sidebar: true, splits: true } },
|
|
34
|
+
{
|
|
35
|
+
CMUX_WORKSPACE_ID: "workspace:1",
|
|
36
|
+
CMUX_SURFACE_ID: "surface:2",
|
|
37
|
+
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
|
38
|
+
},
|
|
39
|
+
() => true,
|
|
40
|
+
() => true,
|
|
41
|
+
);
|
|
42
|
+
assert.equal(config.enabled, true);
|
|
43
|
+
assert.equal(config.notifications, true);
|
|
44
|
+
assert.equal(config.sidebar, true);
|
|
45
|
+
assert.equal(config.splits, true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("shouldPromptToEnableCmux only prompts once per session", () => {
|
|
49
|
+
resetCmuxPromptState();
|
|
50
|
+
assert.equal(shouldPromptToEnableCmux({}, {}, () => false, () => true), false);
|
|
51
|
+
|
|
52
|
+
assert.equal(
|
|
53
|
+
shouldPromptToEnableCmux(
|
|
54
|
+
{},
|
|
55
|
+
{
|
|
56
|
+
CMUX_WORKSPACE_ID: "workspace:1",
|
|
57
|
+
CMUX_SURFACE_ID: "surface:2",
|
|
58
|
+
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
|
59
|
+
},
|
|
60
|
+
() => true,
|
|
61
|
+
() => true,
|
|
62
|
+
),
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
markCmuxPromptShown();
|
|
66
|
+
assert.equal(
|
|
67
|
+
shouldPromptToEnableCmux(
|
|
68
|
+
{},
|
|
69
|
+
{
|
|
70
|
+
CMUX_WORKSPACE_ID: "workspace:1",
|
|
71
|
+
CMUX_SURFACE_ID: "surface:2",
|
|
72
|
+
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
|
73
|
+
},
|
|
74
|
+
() => true,
|
|
75
|
+
() => true,
|
|
76
|
+
),
|
|
77
|
+
false,
|
|
78
|
+
);
|
|
79
|
+
resetCmuxPromptState();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
|
|
83
|
+
const state: GSDState = {
|
|
84
|
+
activeMilestone: { id: "M001", title: "Milestone" },
|
|
85
|
+
activeSlice: { id: "S02", title: "Slice" },
|
|
86
|
+
activeTask: { id: "T03", title: "Task" },
|
|
87
|
+
phase: "executing",
|
|
88
|
+
recentDecisions: [],
|
|
89
|
+
blockers: [],
|
|
90
|
+
nextAction: "Keep going",
|
|
91
|
+
registry: [],
|
|
92
|
+
progress: {
|
|
93
|
+
milestones: { done: 0, total: 1 },
|
|
94
|
+
slices: { done: 1, total: 3 },
|
|
95
|
+
tasks: { done: 2, total: 5 },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
assert.equal(buildCmuxStatusLabel(state), "M001 S02/T03 · executing");
|
|
100
|
+
assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("cmux extension discovery opt-out", () => {
|
|
104
|
+
test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
|
|
105
|
+
const cmuxDir = path.resolve(
|
|
106
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
107
|
+
"../../cmux",
|
|
108
|
+
);
|
|
109
|
+
const pkgPath = path.join(cmuxDir, "package.json");
|
|
110
|
+
assert.ok(fs.existsSync(pkgPath), `${pkgPath} must exist`);
|
|
111
|
+
|
|
112
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
113
|
+
assert.ok(
|
|
114
|
+
pkg.pi !== undefined && typeof pkg.pi === "object",
|
|
115
|
+
'package.json must have a "pi" field to opt out of extension auto-discovery',
|
|
116
|
+
);
|
|
117
|
+
assert.ok(
|
|
118
|
+
!pkg.pi.extensions?.length,
|
|
119
|
+
"pi.extensions must be empty or absent — cmux is a library, not an extension",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -171,6 +171,29 @@ test("notification fields validate correctly", () => {
|
|
|
171
171
|
assert.equal(preferences.notifications?.on_complete, false);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
test("cmux fields validate correctly", () => {
|
|
175
|
+
const { preferences, errors } = validatePreferences({
|
|
176
|
+
cmux: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
notifications: true,
|
|
179
|
+
sidebar: false,
|
|
180
|
+
splits: true,
|
|
181
|
+
browser: false,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
assert.equal(errors.length, 0);
|
|
185
|
+
assert.equal(preferences.cmux?.enabled, true);
|
|
186
|
+
assert.equal(preferences.cmux?.sidebar, false);
|
|
187
|
+
assert.equal(preferences.cmux?.splits, true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("cmux unknown keys produce warnings", () => {
|
|
191
|
+
const { warnings } = validatePreferences({
|
|
192
|
+
cmux: { enabled: true, strange_mode: true } as any,
|
|
193
|
+
});
|
|
194
|
+
assert.ok(warnings.some((warning) => warning.includes('unknown cmux key "strange_mode"')));
|
|
195
|
+
});
|
|
196
|
+
|
|
174
197
|
test("git fields comprehensive validation", () => {
|
|
175
198
|
const { preferences, errors } = validatePreferences({
|
|
176
199
|
git: {
|
|
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
acquireSessionLock,
|
|
20
|
+
getSessionLockStatus,
|
|
20
21
|
validateSessionLock,
|
|
21
22
|
releaseSessionLock,
|
|
22
23
|
readSessionLockData,
|
|
@@ -201,6 +202,50 @@ async function main(): Promise<void> {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// ─── 7b. getSessionLockStatus with missing metadata → reason surfaced ──
|
|
206
|
+
console.log('\n=== 7b. missing lock metadata → structured reason ===');
|
|
207
|
+
{
|
|
208
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
209
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const status = getSessionLockStatus(base);
|
|
213
|
+
assertEq(status.valid, false, 'missing lock metadata is invalid');
|
|
214
|
+
assertEq(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
|
|
215
|
+
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(base, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── 7c. getSessionLockStatus with foreign PID → reason surfaced ───────
|
|
222
|
+
console.log('\n=== 7c. foreign PID in lock file → structured reason ===');
|
|
223
|
+
{
|
|
224
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
225
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const foreignPid = process.pid + 1000;
|
|
229
|
+
const lockFile = join(gsdRoot(base), 'auto.lock');
|
|
230
|
+
writeFileSync(lockFile, JSON.stringify({
|
|
231
|
+
pid: foreignPid,
|
|
232
|
+
startedAt: new Date().toISOString(),
|
|
233
|
+
unitType: 'execute-task',
|
|
234
|
+
unitId: 'M001/S01/T01',
|
|
235
|
+
unitStartedAt: new Date().toISOString(),
|
|
236
|
+
completedUnits: 0,
|
|
237
|
+
}, null, 2));
|
|
238
|
+
|
|
239
|
+
const status = getSessionLockStatus(base);
|
|
240
|
+
assertEq(status.valid, false, 'foreign PID lock is invalid');
|
|
241
|
+
assertEq(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
|
|
242
|
+
assertEq(status.existingPid, foreignPid, 'existing PID is included');
|
|
243
|
+
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(base, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
204
249
|
// ─── 8. Acquire after release is possible ─────────────────────────────
|
|
205
250
|
console.log('\n=== 8. acquire after release → re-acquirable ===');
|
|
206
251
|
{
|
|
@@ -16,6 +16,16 @@ export const CUSTOM_SEARCH_TOOL_NAMES = ["search-the-web", "search_and_read", "g
|
|
|
16
16
|
/** Thinking block types that require signature validation by the API */
|
|
17
17
|
const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Maximum number of native web searches allowed per session (agent unit).
|
|
21
|
+
* The Anthropic API's `max_uses` is per-request — it resets on each API call.
|
|
22
|
+
* When `pause_turn` triggers a resubmit, the model gets a fresh budget.
|
|
23
|
+
* This session-level cap prevents unbounded search accumulation (#1309).
|
|
24
|
+
*
|
|
25
|
+
* 15 = 3 full turns of 5 searches each — generous for research, but bounded.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_NATIVE_SEARCHES_PER_SESSION = 15;
|
|
28
|
+
|
|
19
29
|
/** When true, skip native web search injection and keep Brave/custom tools active on Anthropic. */
|
|
20
30
|
export function preferBraveSearch(): boolean {
|
|
21
31
|
// preferences.md takes priority over env var
|
|
@@ -74,6 +84,11 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
|
|
|
74
84
|
let isAnthropicProvider = false;
|
|
75
85
|
let modelSelectFired = false;
|
|
76
86
|
|
|
87
|
+
// Session-level native search counter (#1309).
|
|
88
|
+
// Tracks cumulative web_search_tool_result blocks across all turns in a session.
|
|
89
|
+
// Reset on session_start. Used to compute remaining budget for max_uses.
|
|
90
|
+
let sessionSearchCount = 0;
|
|
91
|
+
|
|
77
92
|
// Track provider changes via model selection — also handles diagnostics
|
|
78
93
|
// since model_select fires AFTER session_start and knows the provider.
|
|
79
94
|
pi.on("model_select", async (event: any, ctx: any) => {
|
|
@@ -161,13 +176,41 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
|
|
|
161
176
|
);
|
|
162
177
|
payload.tools = tools;
|
|
163
178
|
|
|
179
|
+
// ── Session-level search budget (#1309) ──────────────────────────────
|
|
180
|
+
// Count web_search_tool_result blocks in the conversation history to
|
|
181
|
+
// determine how many native searches have already been used this session.
|
|
182
|
+
// The Anthropic API's max_uses resets per request, so without this guard,
|
|
183
|
+
// pause_turn → resubmit cycles allow unlimited total searches.
|
|
184
|
+
if (Array.isArray(messages)) {
|
|
185
|
+
let historySearchCount = 0;
|
|
186
|
+
for (const msg of messages) {
|
|
187
|
+
const content = msg.content;
|
|
188
|
+
if (!Array.isArray(content)) continue;
|
|
189
|
+
for (const block of content) {
|
|
190
|
+
if ((block as any)?.type === "web_search_tool_result") {
|
|
191
|
+
historySearchCount++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Sync counter from history (handles session restore / context replay)
|
|
196
|
+
sessionSearchCount = historySearchCount;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const remaining = Math.max(0, MAX_NATIVE_SEARCHES_PER_SESSION - sessionSearchCount);
|
|
200
|
+
|
|
201
|
+
if (remaining <= 0) {
|
|
202
|
+
// Budget exhausted — don't inject the search tool at all.
|
|
203
|
+
// The model will proceed without web search capability.
|
|
204
|
+
return payload;
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
tools.push({
|
|
165
208
|
type: "web_search_20250305",
|
|
166
209
|
name: "web_search",
|
|
167
|
-
// Cap
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
max_uses: 5,
|
|
210
|
+
// Cap per-request searches to the lesser of 5 (per-turn cap) or the
|
|
211
|
+
// remaining session budget (#1309). This prevents the model from
|
|
212
|
+
// consuming unlimited searches via pause_turn → resubmit cycles.
|
|
213
|
+
max_uses: Math.min(5, remaining),
|
|
171
214
|
});
|
|
172
215
|
|
|
173
216
|
return payload;
|
|
@@ -175,6 +218,9 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
|
|
|
175
218
|
|
|
176
219
|
// Basic startup diagnostics — provider-specific info comes from model_select
|
|
177
220
|
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
221
|
+
// Reset session-level search budget (#1309)
|
|
222
|
+
sessionSearchCount = 0;
|
|
223
|
+
|
|
178
224
|
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
179
225
|
const hasJina = !!process.env.JINA_API_KEY;
|
|
180
226
|
const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared formatting
|
|
2
|
+
* Shared pure formatting utilities — no @gsd/pi-tui dependency.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
|
|
5
|
+
* live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
|
|
6
|
+
* run outside jiti's alias resolution (e.g. HTML report generation via
|
|
7
|
+
* dynamic import in auto-loop).
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
9
|
-
|
|
10
10
|
// ─── Duration Formatting ──────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
/** Format a millisecond duration as a compact human-readable string. */
|
|
@@ -31,45 +31,6 @@ export function formatTokenCount(count: number): string {
|
|
|
31
31
|
return `${(count / 1_000_000).toFixed(2)}M`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// ─── Layout Helpers ───────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
|
|
37
|
-
export function padRight(content: string, width: number): string {
|
|
38
|
-
const vis = visibleWidth(content);
|
|
39
|
-
return content + " ".repeat(Math.max(0, width - vis));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Build a line with left-aligned and right-aligned content. */
|
|
43
|
-
export function joinColumns(left: string, right: string, width: number): string {
|
|
44
|
-
const leftW = visibleWidth(left);
|
|
45
|
-
const rightW = visibleWidth(right);
|
|
46
|
-
if (leftW + rightW + 2 > width) {
|
|
47
|
-
return truncateToWidth(`${left} ${right}`, width);
|
|
48
|
-
}
|
|
49
|
-
return left + " ".repeat(width - leftW - rightW) + right;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Center content within `width` (ANSI-aware). */
|
|
53
|
-
export function centerLine(content: string, width: number): string {
|
|
54
|
-
const vis = visibleWidth(content);
|
|
55
|
-
if (vis >= width) return truncateToWidth(content, width);
|
|
56
|
-
const leftPad = Math.floor((width - vis) / 2);
|
|
57
|
-
return " ".repeat(leftPad) + content;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Join as many parts as fit within `width`, separated by `separator`. */
|
|
61
|
-
export function fitColumns(parts: string[], width: number, separator = " "): string {
|
|
62
|
-
const filtered = parts.filter(Boolean);
|
|
63
|
-
if (filtered.length === 0) return "";
|
|
64
|
-
let result = filtered[0];
|
|
65
|
-
for (let i = 1; i < filtered.length; i++) {
|
|
66
|
-
const candidate = `${result}${separator}${filtered[i]}`;
|
|
67
|
-
if (visibleWidth(candidate) > width) break;
|
|
68
|
-
result = candidate;
|
|
69
|
-
}
|
|
70
|
-
return truncateToWidth(result, width);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
34
|
// ─── Text Truncation ─────────────────────────────────────────────────────────
|
|
74
35
|
|
|
75
36
|
/** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI-aware TUI layout utilities that depend on @gsd/pi-tui.
|
|
3
|
+
*
|
|
4
|
+
* Separated from format-utils.ts so that modules needing only pure
|
|
5
|
+
* formatting (e.g. HTML report generation) can import format-utils
|
|
6
|
+
* without pulling in the @gsd/pi-tui dependency — which fails when
|
|
7
|
+
* loaded outside jiti's alias resolution context.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
11
|
+
|
|
12
|
+
// ─── Layout Helpers ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
|
|
15
|
+
export function padRight(content: string, width: number): string {
|
|
16
|
+
const vis = visibleWidth(content);
|
|
17
|
+
return content + " ".repeat(Math.max(0, width - vis));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build a line with left-aligned and right-aligned content. */
|
|
21
|
+
export function joinColumns(left: string, right: string, width: number): string {
|
|
22
|
+
const leftW = visibleWidth(left);
|
|
23
|
+
const rightW = visibleWidth(right);
|
|
24
|
+
if (leftW + rightW + 2 > width) {
|
|
25
|
+
return truncateToWidth(`${left} ${right}`, width);
|
|
26
|
+
}
|
|
27
|
+
return left + " ".repeat(width - leftW - rightW) + right;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Center content within `width` (ANSI-aware). */
|
|
31
|
+
export function centerLine(content: string, width: number): string {
|
|
32
|
+
const vis = visibleWidth(content);
|
|
33
|
+
if (vis >= width) return truncateToWidth(content, width);
|
|
34
|
+
const leftPad = Math.floor((width - vis) / 2);
|
|
35
|
+
return " ".repeat(leftPad) + content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Join as many parts as fit within `width`, separated by `separator`. */
|
|
39
|
+
export function fitColumns(parts: string[], width: number, separator = " "): string {
|
|
40
|
+
const filtered = parts.filter(Boolean);
|
|
41
|
+
if (filtered.length === 0) return "";
|
|
42
|
+
let result = filtered[0];
|
|
43
|
+
for (let i = 1; i < filtered.length; i++) {
|
|
44
|
+
const candidate = `${result}${separator}${filtered[i]}`;
|
|
45
|
+
if (visibleWidth(candidate) > width) break;
|
|
46
|
+
result = candidate;
|
|
47
|
+
}
|
|
48
|
+
return truncateToWidth(result, width);
|
|
49
|
+
}
|
|
@@ -13,15 +13,18 @@ export {
|
|
|
13
13
|
stripAnsi,
|
|
14
14
|
formatTokenCount,
|
|
15
15
|
formatDuration,
|
|
16
|
-
padRight,
|
|
17
|
-
joinColumns,
|
|
18
|
-
centerLine,
|
|
19
|
-
fitColumns,
|
|
20
16
|
sparkline,
|
|
21
17
|
normalizeStringArray,
|
|
22
18
|
fileLink,
|
|
23
19
|
} from "./format-utils.js";
|
|
24
20
|
|
|
21
|
+
export {
|
|
22
|
+
padRight,
|
|
23
|
+
joinColumns,
|
|
24
|
+
centerLine,
|
|
25
|
+
fitColumns,
|
|
26
|
+
} from "./layout-utils.js";
|
|
27
|
+
|
|
25
28
|
export { shortcutDesc } from "./terminal.js";
|
|
26
29
|
export { toPosixPath } from "./path-display.js";
|
|
27
30
|
export { showInterviewRound } from "./interview-ui.js";
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
const UNSUPPORTED_TERMS = ["apple_terminal", "warpterm"];
|
|
9
9
|
|
|
10
|
+
export function isCmuxTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
11
|
+
return Boolean(env.CMUX_WORKSPACE_ID && env.CMUX_SURFACE_ID);
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export function supportsCtrlAltShortcuts(): boolean {
|
|
11
15
|
const term = (process.env.TERM_PROGRAM || "").toLowerCase();
|
|
12
16
|
const jetbrains = (process.env.TERMINAL_EMULATOR || "").toLowerCase().includes("jetbrains");
|
|
17
|
+
if (isCmuxTerminal()) return true;
|
|
13
18
|
return !UNSUPPORTED_TERMS.some((t) => term.includes(t)) && !jetbrains;
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -2,13 +2,15 @@ import { describe, it } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
formatDuration,
|
|
5
|
+
sparkline,
|
|
6
|
+
stripAnsi,
|
|
7
|
+
} from "../format-utils.js";
|
|
8
|
+
import {
|
|
5
9
|
padRight,
|
|
6
10
|
joinColumns,
|
|
7
11
|
centerLine,
|
|
8
12
|
fitColumns,
|
|
9
|
-
|
|
10
|
-
stripAnsi,
|
|
11
|
-
} from "../format-utils.js";
|
|
13
|
+
} from "../layout-utils.js";
|
|
12
14
|
|
|
13
15
|
describe("formatDuration", () => {
|
|
14
16
|
it("formats seconds", () => {
|