@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3
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/CHANGELOG.md +113 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +0 -5
- package/dist/types/main.d.ts +14 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +0 -9
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-selector.d.ts +16 -7
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +0 -18
- package/dist/types/modes/controllers/event-controller.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/messages.d.ts +11 -8
- package/dist/types/session/session-manager.d.ts +5 -2
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/index.d.ts +31 -0
- package/dist/types/tools/path-utils.d.ts +13 -1
- package/dist/types/tools/read.d.ts +2 -1
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/renderers.d.ts +0 -15
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/write.d.ts +0 -2
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +5 -7
- package/dist/types/tui/output-block.d.ts +0 -18
- package/package.json +9 -9
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +2 -4
- package/src/cli/gallery-cli.ts +4 -0
- package/src/cli/gallery-fixtures/codeintel.ts +0 -1
- package/src/cli/gallery-fixtures/fs.ts +68 -1
- package/src/cli/gallery-fixtures/types.ts +8 -1
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +4 -22
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings.ts +86 -41
- package/src/debug/index.ts +8 -0
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/diff.ts +86 -0
- package/src/edit/hashline/execute.ts +14 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/index.ts +31 -17
- package/src/edit/renderer.ts +116 -31
- package/src/eval/__tests__/llm-bridge.test.ts +20 -0
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/js/shared/prelude.txt +26 -10
- package/src/eval/llm-bridge.ts +14 -3
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +189 -61
- package/src/main.ts +144 -78
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +2 -2
- package/src/modes/components/assistant-message.ts +3 -15
- package/src/modes/components/custom-editor.ts +143 -111
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/plan-review-overlay.ts +26 -5
- package/src/modes/components/read-tool-group.ts +415 -35
- package/src/modes/components/session-selector.ts +89 -35
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/tool-execution.ts +7 -49
- package/src/modes/components/transcript-container.ts +108 -32
- package/src/modes/components/user-message.ts +1 -1
- package/src/modes/controllers/event-controller.ts +32 -1
- package/src/modes/controllers/input-controller.ts +56 -9
- package/src/modes/interactive-mode.ts +107 -20
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/types.ts +7 -0
- package/src/modes/utils/ui-helpers.ts +26 -5
- package/src/modes/workflow.ts +10 -10
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/plan-mode-active.md +56 -72
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +85 -10
- package/src/session/agent-session.ts +42 -15
- package/src/session/auth-storage.ts +2 -0
- package/src/session/messages.ts +21 -14
- package/src/session/session-manager.ts +98 -25
- package/src/session/yield-queue.ts +20 -2
- package/src/task/executor.ts +72 -36
- package/src/task/render.ts +3 -4
- package/src/tiny/title-client.ts +6 -1
- package/src/tools/bash.ts +7 -7
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/eval-render.ts +4 -23
- package/src/tools/eval.ts +13 -2
- package/src/tools/find.ts +148 -99
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/index.ts +32 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/path-utils.ts +47 -24
- package/src/tools/plan-mode-guard.ts +52 -7
- package/src/tools/read.ts +41 -20
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/renderers.ts +0 -15
- package/src/tools/search.ts +38 -3
- package/src/tools/ssh.ts +0 -1
- package/src/tools/todo.ts +1 -0
- package/src/tools/write.ts +5 -14
- package/src/tools/yield.ts +10 -1
- package/src/tui/code-cell.ts +1 -6
- package/src/tui/hyperlink.ts +13 -23
- package/src/tui/output-block.ts +2 -97
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/enhanced-paste.ts +30 -2
- package/src/web/search/providers/codex.ts +37 -8
package/src/session/messages.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { OutputMeta } from "../tools/output-meta";
|
|
|
34
34
|
import { formatOutputNotice } from "../tools/output-meta";
|
|
35
35
|
|
|
36
36
|
export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
|
|
37
|
+
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
|
|
37
38
|
|
|
38
39
|
export interface SkillPromptDetails {
|
|
39
40
|
name: string;
|
|
@@ -71,21 +72,29 @@ export function isSilentAbort(errorMessage: string | undefined): boolean {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/** Reason threaded through `AbortController.abort(reason)` when the user aborts
|
|
74
|
-
* the turn with Esc (see `AgentSession.abort`). The agent
|
|
75
|
-
*
|
|
76
|
-
* a deliberate
|
|
75
|
+
* the turn with Esc (see `AgentSession.abort`). The agent keeps it on the
|
|
76
|
+
* aborted assistant message's `errorMessage` so queued follow-ups/tool-result
|
|
77
|
+
* placeholders can distinguish a deliberate interrupt from a bare lifecycle
|
|
78
|
+
* abort, but interactive renderers suppress this redundant transcript line. */
|
|
77
79
|
export const USER_INTERRUPT_LABEL = "Interrupted by user";
|
|
78
80
|
|
|
81
|
+
export function isUserInterruptAbort(errorMessage: string | undefined): boolean {
|
|
82
|
+
return errorMessage === USER_INTERRUPT_LABEL;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function shouldRenderAbortReason(errorMessage: string | undefined): boolean {
|
|
86
|
+
return !isSilentAbort(errorMessage) && !isUserInterruptAbort(errorMessage);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
/** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
|
|
80
90
|
* reason (bare `abort()`). Renderers treat it as "no specific reason given". */
|
|
81
91
|
const GENERIC_ABORT_SENTINEL = "Request was aborted";
|
|
82
92
|
|
|
83
93
|
/** Resolve the operator-facing label for an aborted assistant turn. A custom
|
|
84
|
-
* abort reason
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* they stay in lockstep. */
|
|
94
|
+
* abort reason threaded onto `errorMessage` is returned verbatim; aborts with
|
|
95
|
+
* no threaded reason fall back to the retry-aware generic label. Call
|
|
96
|
+
* `shouldRenderAbortReason` before rendering when user interrupts should stay
|
|
97
|
+
* visually quiet. */
|
|
89
98
|
export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string {
|
|
90
99
|
if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) {
|
|
91
100
|
return errorMessage;
|
|
@@ -524,7 +533,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
524
533
|
case "custom":
|
|
525
534
|
case "hookMessage": {
|
|
526
535
|
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
|
|
527
|
-
const role = "
|
|
536
|
+
const role = "developer";
|
|
528
537
|
const attribution = m.attribution;
|
|
529
538
|
return {
|
|
530
539
|
role,
|
|
@@ -564,17 +573,15 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
564
573
|
const inner = file.content ? `\n${file.content}\n` : "\n";
|
|
565
574
|
return `<file path="${file.path}">${inner}</file>`;
|
|
566
575
|
})
|
|
567
|
-
.join("\n
|
|
568
|
-
const content: (TextContent | ImageContent)[] = [
|
|
569
|
-
{ type: "text" as const, text: `<system-reminder>\n${fileContents}\n</system-reminder>` },
|
|
570
|
-
];
|
|
576
|
+
.join("\n");
|
|
577
|
+
const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }];
|
|
571
578
|
for (const file of m.files) {
|
|
572
579
|
if (file.image) {
|
|
573
580
|
content.push(file.image);
|
|
574
581
|
}
|
|
575
582
|
}
|
|
576
583
|
return {
|
|
577
|
-
role: "
|
|
584
|
+
role: "developer",
|
|
578
585
|
content,
|
|
579
586
|
attribution: "user",
|
|
580
587
|
timestamp: m.timestamp,
|
|
@@ -753,8 +753,8 @@ export function buildSessionContext(
|
|
|
753
753
|
// turn's tool results are off the selected path: its result children live on a
|
|
754
754
|
// sibling branch, or it is the leaf itself (results are children below it). Left
|
|
755
755
|
// in place, `transformMessages` fabricates one synthetic "aborted"/"No result
|
|
756
|
-
// provided" result per dangling call
|
|
757
|
-
//
|
|
756
|
+
// provided" result per dangling call, which render as phantom failed calls and
|
|
757
|
+
// re-inject the failed batch into the model's
|
|
758
758
|
// context — the rewind/restore loop.
|
|
759
759
|
//
|
|
760
760
|
// Stripping is necessary but not sufficient: a *modified* assistant turn that still
|
|
@@ -845,11 +845,18 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
|
|
845
845
|
Bun.write(breadcrumbFile, content).catch(() => {});
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
+
interface TerminalBreadcrumb {
|
|
849
|
+
cwd: string;
|
|
850
|
+
sessionFile: string;
|
|
851
|
+
}
|
|
852
|
+
|
|
848
853
|
/**
|
|
849
|
-
* Read the terminal breadcrumb for the current terminal
|
|
850
|
-
* Returns the
|
|
854
|
+
* Read the raw terminal breadcrumb for the current terminal.
|
|
855
|
+
* Returns the recorded cwd + session file (verified to exist) regardless of
|
|
856
|
+
* whether the recorded cwd still matches the current one. Callers decide how
|
|
857
|
+
* to interpret a cwd mismatch (e.g. a moved/renamed worktree).
|
|
851
858
|
*/
|
|
852
|
-
async function
|
|
859
|
+
async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
|
|
853
860
|
const terminalId = getTerminalId();
|
|
854
861
|
if (!terminalId) return null;
|
|
855
862
|
|
|
@@ -862,12 +869,9 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
|
|
862
869
|
const breadcrumbCwd = lines[0];
|
|
863
870
|
const sessionFile = lines[1];
|
|
864
871
|
|
|
865
|
-
// Only return if cwd matches (user might have cd'd)
|
|
866
|
-
if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
|
|
867
|
-
|
|
868
872
|
// Verify the session file still exists
|
|
869
873
|
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
870
|
-
if (stat?.isFile()) return sessionFile;
|
|
874
|
+
if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
|
|
871
875
|
} catch (err) {
|
|
872
876
|
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
873
877
|
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
@@ -1968,6 +1972,7 @@ export class SessionManager {
|
|
|
1968
1972
|
#inMemoryArtifactCounter = 0;
|
|
1969
1973
|
readonly #blobStore: BlobStore;
|
|
1970
1974
|
#suppressBreadcrumb = false;
|
|
1975
|
+
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
1971
1976
|
|
|
1972
1977
|
private constructor(
|
|
1973
1978
|
private cwd: string,
|
|
@@ -2163,19 +2168,24 @@ export class SessionManager {
|
|
|
2163
2168
|
/**
|
|
2164
2169
|
* Move the session to a new working directory.
|
|
2165
2170
|
* Moves session files and artifacts on disk, updates all internal references,
|
|
2166
|
-
* and rewrites the session header with the new cwd.
|
|
2171
|
+
* and rewrites the session header with the new cwd. When provided,
|
|
2172
|
+
* `targetSessionDir` is used instead of deriving the default directory for
|
|
2173
|
+
* the new cwd (for `--continue --session-dir` / `--resume --session-dir`).
|
|
2167
2174
|
*/
|
|
2168
|
-
async moveTo(newCwd: string): Promise<void> {
|
|
2175
|
+
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
|
2169
2176
|
const resolvedCwd = path.resolve(newCwd);
|
|
2170
|
-
if (resolvedCwd === this.cwd) return;
|
|
2177
|
+
if (resolvedCwd === this.cwd && (!targetSessionDir || path.resolve(targetSessionDir) === this.sessionDir)) return;
|
|
2171
2178
|
|
|
2172
2179
|
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
|
2173
|
-
const newSessionDir =
|
|
2174
|
-
?
|
|
2175
|
-
:
|
|
2180
|
+
const newSessionDir = targetSessionDir
|
|
2181
|
+
? path.resolve(targetSessionDir)
|
|
2182
|
+
: managedSessionsRoot
|
|
2183
|
+
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
2184
|
+
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
2176
2185
|
let hadSessionFile = false;
|
|
2177
2186
|
|
|
2178
2187
|
if (this.persist && this.#sessionFile) {
|
|
2188
|
+
this.storage.ensureDirSync(newSessionDir);
|
|
2179
2189
|
// Close the persist writer before moving files
|
|
2180
2190
|
await this.#closePersistWriter();
|
|
2181
2191
|
this.#persistChain = Promise.resolve();
|
|
@@ -2186,25 +2196,29 @@ export class SessionManager {
|
|
|
2186
2196
|
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
2187
2197
|
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
2188
2198
|
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
2199
|
+
const sameSessionFile = path.resolve(oldSessionFile) === path.resolve(newSessionFile);
|
|
2200
|
+
const sameArtifactDir = path.resolve(oldArtifactDir) === path.resolve(newArtifactDir);
|
|
2189
2201
|
hadSessionFile = this.storage.existsSync(oldSessionFile);
|
|
2190
2202
|
let movedSessionFile = false;
|
|
2191
2203
|
let movedArtifactDir = false;
|
|
2192
2204
|
|
|
2193
2205
|
try {
|
|
2194
2206
|
// Guard: session file may not exist yet (no assistant messages persisted)
|
|
2195
|
-
if (hadSessionFile) {
|
|
2207
|
+
if (hadSessionFile && !sameSessionFile) {
|
|
2196
2208
|
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
2197
2209
|
movedSessionFile = true;
|
|
2198
2210
|
}
|
|
2199
2211
|
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2212
|
+
if (!sameArtifactDir) {
|
|
2213
|
+
try {
|
|
2214
|
+
const stat = await fs.promises.stat(oldArtifactDir);
|
|
2215
|
+
if (stat.isDirectory()) {
|
|
2216
|
+
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
|
2217
|
+
movedArtifactDir = true;
|
|
2218
|
+
}
|
|
2219
|
+
} catch (err) {
|
|
2220
|
+
if (!isEnoent(err)) throw err;
|
|
2205
2221
|
}
|
|
2206
|
-
} catch (err) {
|
|
2207
|
-
if (!isEnoent(err)) throw err;
|
|
2208
2222
|
}
|
|
2209
2223
|
} catch (err) {
|
|
2210
2224
|
if (movedArtifactDir) {
|
|
@@ -2730,6 +2744,23 @@ export class SessionManager {
|
|
|
2730
2744
|
return this.#sessionName;
|
|
2731
2745
|
}
|
|
2732
2746
|
|
|
2747
|
+
onSessionNameChanged(cb: () => void): () => void {
|
|
2748
|
+
this.#sessionNameChangedCallbacks.add(cb);
|
|
2749
|
+
return () => {
|
|
2750
|
+
this.#sessionNameChangedCallbacks.delete(cb);
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
#fireSessionNameChanged(): void {
|
|
2755
|
+
for (const cb of [...this.#sessionNameChangedCallbacks]) {
|
|
2756
|
+
try {
|
|
2757
|
+
cb();
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2733
2764
|
/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
|
|
2734
2765
|
static #sanitizeName(name: string): string {
|
|
2735
2766
|
return name
|
|
@@ -2765,6 +2796,7 @@ export class SessionManager {
|
|
|
2765
2796
|
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
2766
2797
|
await this.#rewriteFile();
|
|
2767
2798
|
}
|
|
2799
|
+
this.#fireSessionNameChanged();
|
|
2768
2800
|
return true;
|
|
2769
2801
|
}
|
|
2770
2802
|
|
|
@@ -3491,8 +3523,49 @@ export class SessionManager {
|
|
|
3491
3523
|
): Promise<SessionManager> {
|
|
3492
3524
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3493
3525
|
// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
|
|
3494
|
-
const
|
|
3495
|
-
const
|
|
3526
|
+
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
3527
|
+
const breadcrumbCwd = breadcrumb ? path.resolve(breadcrumb.cwd) : undefined;
|
|
3528
|
+
const resolvedCwd = path.resolve(cwd);
|
|
3529
|
+
let mostRecent: string | null | undefined;
|
|
3530
|
+
if (breadcrumb && breadcrumbCwd !== resolvedCwd) {
|
|
3531
|
+
// The terminal's last session was started in a different cwd. If that cwd no
|
|
3532
|
+
// longer exists (e.g. `git worktree move`/dir rename) and the new location has
|
|
3533
|
+
// no sessions of its own, re-root the session here instead of silently starting
|
|
3534
|
+
// fresh — otherwise the relocated session would be unreachable via --continue.
|
|
3535
|
+
// When an explicit sessionDir is reused across the move, the stale breadcrumb
|
|
3536
|
+
// file itself may be the most recent entry there; don't count it as a
|
|
3537
|
+
// current-directory session. If that shared dir also contains an older session
|
|
3538
|
+
// that already belongs to the current cwd, prefer that local session instead
|
|
3539
|
+
// of re-rooting the stale breadcrumb over it.
|
|
3540
|
+
const resolvedBreadcrumbCwd = path.resolve(breadcrumb.cwd);
|
|
3541
|
+
mostRecent = await findMostRecentSession(dir, storage);
|
|
3542
|
+
const sourceCwdGone = !fs.existsSync(resolvedBreadcrumbCwd);
|
|
3543
|
+
const breadcrumbSessionFile = path.resolve(breadcrumb.sessionFile);
|
|
3544
|
+
const mostRecentIsBreadcrumb =
|
|
3545
|
+
mostRecent !== null && mostRecent !== undefined && path.resolve(mostRecent) === breadcrumbSessionFile;
|
|
3546
|
+
let hasCurrentCwdSession = false;
|
|
3547
|
+
if (sourceCwdGone && mostRecentIsBreadcrumb) {
|
|
3548
|
+
const currentCwdSession = (await SessionManager.list(cwd, dir, storage)).find(
|
|
3549
|
+
session =>
|
|
3550
|
+
path.resolve(session.path) !== breadcrumbSessionFile &&
|
|
3551
|
+
session.cwd &&
|
|
3552
|
+
path.resolve(session.cwd) === resolvedCwd,
|
|
3553
|
+
);
|
|
3554
|
+
if (currentCwdSession) {
|
|
3555
|
+
mostRecent = currentCwdSession.path;
|
|
3556
|
+
hasCurrentCwdSession = true;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession));
|
|
3560
|
+
if (relocated) {
|
|
3561
|
+
process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`);
|
|
3562
|
+
const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
|
|
3563
|
+
await manager.moveTo(cwd, sessionDir);
|
|
3564
|
+
return manager;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
const terminalSession = breadcrumb && breadcrumbCwd === resolvedCwd ? breadcrumb.sessionFile : null;
|
|
3568
|
+
if (mostRecent === undefined) mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
|
|
3496
3569
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3497
3570
|
if (mostRecent) {
|
|
3498
3571
|
await manager.#initSessionFile(mostRecent);
|
|
@@ -10,7 +10,7 @@ export interface YieldDispatcher<P> {
|
|
|
10
10
|
|
|
11
11
|
export interface YieldQueueOptions {
|
|
12
12
|
isStreaming: () => boolean;
|
|
13
|
-
injectStreaming(msg: AgentMessage): void;
|
|
13
|
+
injectStreaming?(msg: AgentMessage): void;
|
|
14
14
|
injectIdle(messages: AgentMessage[]): Promise<void>;
|
|
15
15
|
scheduleIdleFlush(run: () => Promise<void>): void;
|
|
16
16
|
}
|
|
@@ -85,7 +85,7 @@ export class YieldQueue {
|
|
|
85
85
|
if (!message) continue;
|
|
86
86
|
if (mode === "streaming") {
|
|
87
87
|
try {
|
|
88
|
-
this.#options.injectStreaming(message);
|
|
88
|
+
this.#options.injectStreaming?.(message);
|
|
89
89
|
} catch (error) {
|
|
90
90
|
logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) });
|
|
91
91
|
}
|
|
@@ -102,6 +102,24 @@ export class YieldQueue {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Snapshot and remove all queued entries, returning one lazy thunk per kind.
|
|
107
|
+
* Each thunk applies the dispatcher's staleness filter and builds the batched
|
|
108
|
+
* message only when called — so the consumer (the agent loop) decides, at the
|
|
109
|
+
* moment it injects, whether the message is still worth delivering (a thunk may
|
|
110
|
+
* return null to skip). Background-job completions and late diagnostics reach
|
|
111
|
+
* the model between requests without the agent having to stop.
|
|
112
|
+
*/
|
|
113
|
+
drainLazy(): Array<() => AgentMessage | null> {
|
|
114
|
+
const thunks: Array<() => AgentMessage | null> = [];
|
|
115
|
+
for (const [kind, dispatcher] of this.#dispatchers) {
|
|
116
|
+
const entries = this.#drain(kind);
|
|
117
|
+
if (entries.length === 0) continue;
|
|
118
|
+
thunks.push(() => this.#build(kind, dispatcher, entries));
|
|
119
|
+
}
|
|
120
|
+
return thunks;
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
clear(): void {
|
|
106
124
|
this.#entries.clear();
|
|
107
125
|
this.#idleFlushPending = false;
|
package/src/task/executor.ts
CHANGED
|
@@ -34,7 +34,11 @@ import { SessionManager } from "../session/session-manager";
|
|
|
34
34
|
import { truncateTail } from "../session/streaming-output";
|
|
35
35
|
import type { ContextFileEntry } from "../tools";
|
|
36
36
|
import { normalizeSchema } from "../tools/jtd-to-json-schema";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
buildOutputValidator,
|
|
39
|
+
type OutputValidator,
|
|
40
|
+
summarizeValidationFailure,
|
|
41
|
+
} from "../tools/output-schema-validator";
|
|
38
42
|
|
|
39
43
|
import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
|
|
40
44
|
import { ToolAbortError } from "../tools/tool-errors";
|
|
@@ -256,21 +260,40 @@ function extractCompletionData(parsed: unknown): unknown {
|
|
|
256
260
|
return parsed;
|
|
257
261
|
}
|
|
258
262
|
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Resolve the final yielded payload, optionally splicing collected
|
|
265
|
+
* `report_finding` entries into a top-level `findings` array.
|
|
266
|
+
*
|
|
267
|
+
* Injection is suppressed when an active validator would reject the augmented
|
|
268
|
+
* payload (e.g. a caller-supplied schema with `additionalProperties: false`
|
|
269
|
+
* that does not declare `findings`). That keeps the in-tool yield validator
|
|
270
|
+
* (which only sees the raw, pre-injection data) in lockstep with this
|
|
271
|
+
* post-mortem validator — honoring the "accepted in-tool ⇒ accepted
|
|
272
|
+
* post-mortem" guarantee documented in `output-schema-validator.ts`. The
|
|
273
|
+
* dropped findings are still preserved verbatim in the agent's progress
|
|
274
|
+
* stream and JSONL artifact, so no information is lost when injection is
|
|
275
|
+
* suppressed.
|
|
276
|
+
*/
|
|
277
|
+
function normalizeCompleteData(
|
|
278
|
+
data: unknown,
|
|
279
|
+
reportFindings: ReviewFinding[] | undefined,
|
|
280
|
+
validator: OutputValidator | undefined,
|
|
281
|
+
): unknown {
|
|
282
|
+
const normalized = parseStringifiedJson(data ?? null);
|
|
261
283
|
if (
|
|
262
|
-
Array.isArray(reportFindings)
|
|
263
|
-
reportFindings.length
|
|
264
|
-
normalized
|
|
265
|
-
typeof normalized
|
|
266
|
-
|
|
284
|
+
!Array.isArray(reportFindings) ||
|
|
285
|
+
reportFindings.length === 0 ||
|
|
286
|
+
!normalized ||
|
|
287
|
+
typeof normalized !== "object" ||
|
|
288
|
+
Array.isArray(normalized)
|
|
267
289
|
) {
|
|
268
|
-
|
|
269
|
-
if (!("findings" in record)) {
|
|
270
|
-
normalized = { ...record, findings: reportFindings };
|
|
271
|
-
}
|
|
290
|
+
return normalized;
|
|
272
291
|
}
|
|
273
|
-
|
|
292
|
+
const record = normalized as Record<string, unknown>;
|
|
293
|
+
if ("findings" in record) return normalized;
|
|
294
|
+
const injected = { ...record, findings: reportFindings };
|
|
295
|
+
if (validator && !validator.validate(injected).success) return normalized;
|
|
296
|
+
return injected;
|
|
274
297
|
}
|
|
275
298
|
|
|
276
299
|
function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): { data: unknown } | null {
|
|
@@ -288,6 +311,15 @@ export interface YieldItem {
|
|
|
288
311
|
data?: unknown;
|
|
289
312
|
status?: "success" | "aborted";
|
|
290
313
|
error?: string;
|
|
314
|
+
/**
|
|
315
|
+
* Set by the in-tool yield validator when it exhausted its retry budget
|
|
316
|
+
* (MAX_SCHEMA_RETRIES) and accepted a schema-invalid payload anyway.
|
|
317
|
+
* `finalizeSubprocessOutput` honors this by serializing the payload and
|
|
318
|
+
* surfacing a stderr warning, instead of re-emitting `schema_violation`
|
|
319
|
+
* — which would silently swap the subagent's "accepted" view for a
|
|
320
|
+
* different, opaque error blob in the parent's view of the result.
|
|
321
|
+
*/
|
|
322
|
+
schemaOverridden?: boolean;
|
|
291
323
|
}
|
|
292
324
|
|
|
293
325
|
interface FinalizeSubprocessOutputArgs {
|
|
@@ -308,7 +340,8 @@ interface FinalizeSubprocessOutputResult {
|
|
|
308
340
|
abortedViaYield: boolean;
|
|
309
341
|
hasYield: boolean;
|
|
310
342
|
}
|
|
311
|
-
|
|
343
|
+
export const SUBAGENT_WARNING_SCHEMA_OVERRIDDEN =
|
|
344
|
+
"SYSTEM WARNING: Subagent exhausted schema-retry budget; result was accepted despite failing the output schema.";
|
|
312
345
|
export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yield with null data.";
|
|
313
346
|
export const SUBAGENT_WARNING_MISSING_YIELD =
|
|
314
347
|
"SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
|
|
@@ -360,30 +393,32 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
|
|
|
360
393
|
if (submitData === null || submitData === undefined) {
|
|
361
394
|
rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
|
|
362
395
|
} else {
|
|
363
|
-
const completeData = normalizeCompleteData(submitData, reportFindings);
|
|
364
396
|
const { validator, error: schemaError } = buildOutputValidator(outputSchema);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
397
|
+
const overridden = lastYield?.schemaOverridden === true;
|
|
398
|
+
const completeData = normalizeCompleteData(submitData, reportFindings, validator);
|
|
399
|
+
const result =
|
|
400
|
+
schemaError || overridden
|
|
401
|
+
? { success: true as const }
|
|
402
|
+
: (validator?.validate(completeData) ?? { success: true as const });
|
|
403
|
+
if (!result.success) {
|
|
404
|
+
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
|
|
405
|
+
const outcome = buildSchemaViolationOutcome(summary, completeData);
|
|
406
|
+
rawOutput = outcome.rawOutput;
|
|
407
|
+
stderr = outcome.stderr;
|
|
408
|
+
exitCode = outcome.exitCode;
|
|
369
409
|
} else {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
rawOutput =
|
|
375
|
-
stderr = outcome.stderr;
|
|
376
|
-
exitCode = outcome.exitCode;
|
|
377
|
-
} else {
|
|
378
|
-
try {
|
|
379
|
-
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
|
|
380
|
-
} catch (err) {
|
|
381
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
382
|
-
rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
|
|
383
|
-
}
|
|
384
|
-
exitCode = 0;
|
|
385
|
-
stderr = "";
|
|
410
|
+
try {
|
|
411
|
+
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
|
|
412
|
+
} catch (err) {
|
|
413
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
414
|
+
rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
|
|
386
415
|
}
|
|
416
|
+
exitCode = 0;
|
|
417
|
+
stderr = overridden
|
|
418
|
+
? SUBAGENT_WARNING_SCHEMA_OVERRIDDEN
|
|
419
|
+
: schemaError
|
|
420
|
+
? `invalid output schema: ${schemaError}`
|
|
421
|
+
: "";
|
|
387
422
|
}
|
|
388
423
|
}
|
|
389
424
|
}
|
|
@@ -393,8 +428,8 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
|
|
|
393
428
|
const hasOutputSchema = normalizedSchema !== undefined && !schemaError;
|
|
394
429
|
const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
|
|
395
430
|
if (fallback) {
|
|
396
|
-
const completeData = normalizeCompleteData(fallback.data, reportFindings);
|
|
397
431
|
const { validator } = buildOutputValidator(outputSchema);
|
|
432
|
+
const completeData = normalizeCompleteData(fallback.data, reportFindings, validator);
|
|
398
433
|
const result = validator?.validate(completeData) ?? { success: true as const };
|
|
399
434
|
if (!result.success) {
|
|
400
435
|
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
|
|
@@ -1466,6 +1501,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1466
1501
|
await awaitAbortable(
|
|
1467
1502
|
session.prompt(reminder, {
|
|
1468
1503
|
attribution: "agent",
|
|
1504
|
+
synthetic: true,
|
|
1469
1505
|
...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
|
|
1470
1506
|
}),
|
|
1471
1507
|
);
|
package/src/task/render.ts
CHANGED
|
@@ -633,12 +633,11 @@ function renderAgentProgress(
|
|
|
633
633
|
let statusLine: string;
|
|
634
634
|
if (progress.status === "running") {
|
|
635
635
|
const bullet = theme.fg("accent", "•");
|
|
636
|
-
const name =
|
|
637
|
-
? shimmerText(displayId, theme)
|
|
638
|
-
: theme.fg("accent", description ? theme.bold(displayId) : displayId);
|
|
636
|
+
const name = theme.fg("accent", description ? theme.bold(displayId) : displayId);
|
|
639
637
|
statusLine = `${indent}${bullet} ${name}`;
|
|
640
638
|
if (description) {
|
|
641
|
-
|
|
639
|
+
const desc = shimmerEnabled() ? shimmerText(description, theme) : theme.fg("accent", description);
|
|
640
|
+
statusLine += `${theme.fg("accent", ":")} ${desc}`;
|
|
642
641
|
}
|
|
643
642
|
} else {
|
|
644
643
|
statusLine = `${indent}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
|
package/src/tiny/title-client.ts
CHANGED
|
@@ -39,7 +39,12 @@ export interface TinyTitleDownloadOptions {
|
|
|
39
39
|
onProgress?: (event: TinyTitleProgressEvent) => void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
// Cold-starting the worker subprocess from a compiled binary (decompress + module
|
|
43
|
+
// graph load) is slow on contended CI runners — the macos-15-intel release smoke
|
|
44
|
+
// blew past 5s while arm64/linux/win passed. The probe only needs to prove the
|
|
45
|
+
// worker spawns and ponges at all (a dead worker never ponges regardless), so a
|
|
46
|
+
// generous bound removes the flake without weakening the check.
|
|
47
|
+
const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
50
|
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
|
package/src/tools/bash.ts
CHANGED
|
@@ -14,7 +14,6 @@ import { type BashResult, executeBash } from "../exec/bash-executor";
|
|
|
14
14
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
15
15
|
import { InternalUrlRouter } from "../internal-urls";
|
|
16
16
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
17
|
-
import { shimmerEnabled } from "../modes/theme/shimmer";
|
|
18
17
|
import { highlightCode, type Theme } from "../modes/theme/theme";
|
|
19
18
|
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
|
|
20
19
|
import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
|
|
@@ -29,6 +28,7 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera
|
|
|
29
28
|
import { checkBashInterception } from "./bash-interceptor";
|
|
30
29
|
import { canUseInteractiveBashPty } from "./bash-pty-selection";
|
|
31
30
|
import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
|
|
31
|
+
import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
|
|
32
32
|
import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
|
|
33
33
|
import { resolveToCwd } from "./path-utils";
|
|
34
34
|
import { capPreviewLines, formatToolWorkingDirectory, replaceTabs } from "./render-utils";
|
|
@@ -721,6 +721,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
721
721
|
cwd = await expandInternalUrls(cwd, { ...internalUrlOptions, noEscape: true });
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
// Best-effort cache invalidation: drop github-cache rows for any issue/PR
|
|
725
|
+
// number touched by a mutating `gh` subcommand inside this bash call so
|
|
726
|
+
// subsequent issue:// / pr:// reads pick up the post-mutation state
|
|
727
|
+
// instead of the cached pre-mutation snapshot.
|
|
728
|
+
invalidateGithubCacheForBashCommand(command);
|
|
729
|
+
|
|
724
730
|
const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
|
|
725
731
|
let cwdStat: fs.Stats;
|
|
726
732
|
try {
|
|
@@ -1123,7 +1129,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1123
1129
|
state: "pending",
|
|
1124
1130
|
sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
|
|
1125
1131
|
width,
|
|
1126
|
-
animate: true,
|
|
1127
1132
|
},
|
|
1128
1133
|
uiTheme,
|
|
1129
1134
|
),
|
|
@@ -1254,11 +1259,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1254
1259
|
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
|
|
1255
1260
|
],
|
|
1256
1261
|
width,
|
|
1257
|
-
// Don't animate once the command has been backgrounded: the block
|
|
1258
|
-
// gets committed to scrollback and finalizes later via the async
|
|
1259
|
-
// update path, so a mid-sweep frame would freeze a stray dark
|
|
1260
|
-
// border segment.
|
|
1261
|
-
animate: options.isPartial && shimmerEnabled() && details?.async?.state !== "running",
|
|
1262
1262
|
},
|
|
1263
1263
|
uiTheme,
|
|
1264
1264
|
);
|
|
@@ -101,11 +101,23 @@ export async function acquireTab(
|
|
|
101
101
|
if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
|
|
102
102
|
await releaseTab(name, { kill: false });
|
|
103
103
|
} else {
|
|
104
|
+
const reuseSteps: string[] = [];
|
|
105
|
+
if (opts.viewport) {
|
|
106
|
+
const dsf = opts.viewport.deviceScaleFactor;
|
|
107
|
+
reuseSteps.push(
|
|
108
|
+
`await page.setViewport({ width: ${opts.viewport.width}, height: ${opts.viewport.height}, deviceScaleFactor: ${dsf === undefined ? "undefined" : String(dsf)} });`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
104
111
|
if (opts.url) {
|
|
112
|
+
reuseSteps.push(
|
|
113
|
+
`await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "load")} });`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (reuseSteps.length) {
|
|
105
117
|
await runInTabWithSnapshot(
|
|
106
118
|
name,
|
|
107
119
|
{
|
|
108
|
-
code:
|
|
120
|
+
code: reuseSteps.join("\n"),
|
|
109
121
|
timeoutMs: opts.timeoutMs,
|
|
110
122
|
signal: opts.signal,
|
|
111
123
|
},
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
DEFAULT_VIEWPORT,
|
|
28
28
|
loadPuppeteerInWorker,
|
|
29
29
|
} from "./launch";
|
|
30
|
-
import { extractReadableFromHtml, type ReadableFormat
|
|
30
|
+
import { extractReadableFromHtml, type ReadableFormat } from "./readable";
|
|
31
31
|
import type {
|
|
32
32
|
Observation,
|
|
33
33
|
ObservationEntry,
|
|
@@ -97,7 +97,7 @@ interface TabApi {
|
|
|
97
97
|
): Promise<void>;
|
|
98
98
|
observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
|
|
99
99
|
screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
|
|
100
|
-
extract(format?: ReadableFormat): Promise<
|
|
100
|
+
extract(format?: ReadableFormat): Promise<string>;
|
|
101
101
|
click(selector: string): Promise<void>;
|
|
102
102
|
type(selector: string, text: string): Promise<void>;
|
|
103
103
|
fill(selector: string, value: string): Promise<void>;
|
|
@@ -167,6 +167,25 @@ function cloneSafe(value: unknown): unknown {
|
|
|
167
167
|
return String(value);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Strip `user:pass@` from a URL before surfacing it in tool outputs / details
|
|
172
|
+
* so Basic Auth credentials don't leak into transcripts. Returns the original
|
|
173
|
+
* string verbatim when it doesn't parse as a URL or when there are no
|
|
174
|
+
* credentials to redact.
|
|
175
|
+
*/
|
|
176
|
+
function redactUrlCredentials(url: string): string {
|
|
177
|
+
if (!url || (!url.includes("@") && !url.includes("//"))) return url;
|
|
178
|
+
try {
|
|
179
|
+
const parsed = new URL(url);
|
|
180
|
+
if (!parsed.username && !parsed.password) return url;
|
|
181
|
+
parsed.username = "";
|
|
182
|
+
parsed.password = "";
|
|
183
|
+
return parsed.toString();
|
|
184
|
+
} catch {
|
|
185
|
+
return url;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
function errorPayload(error: unknown): RunErrorPayload {
|
|
171
190
|
if (error instanceof ToolAbortError) {
|
|
172
191
|
return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: true };
|
|
@@ -491,7 +510,7 @@ export class WorkerCore {
|
|
|
491
510
|
const targetId = this.#targetId ?? (await targetIdForPage(page));
|
|
492
511
|
this.#targetId = targetId;
|
|
493
512
|
return {
|
|
494
|
-
url: page.url(),
|
|
513
|
+
url: redactUrlCredentials(page.url()),
|
|
495
514
|
title: await page.title().catch(() => undefined),
|
|
496
515
|
viewport: page.viewport() ?? DEFAULT_VIEWPORT,
|
|
497
516
|
targetId,
|
|
@@ -677,7 +696,17 @@ export class WorkerCore {
|
|
|
677
696
|
screenshot: async opts => await this.#captureScreenshot(session, displays, screenshots, signal, opts),
|
|
678
697
|
extract: async (format = "markdown") => {
|
|
679
698
|
const html = (await untilAborted(signal, () => page.content())) as string;
|
|
680
|
-
|
|
699
|
+
const result = await extractReadableFromHtml(html, page.url(), format);
|
|
700
|
+
if (!result) {
|
|
701
|
+
throw new ToolError(`tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`);
|
|
702
|
+
}
|
|
703
|
+
const content = format === "markdown" ? result.markdown : result.text;
|
|
704
|
+
if (!content) {
|
|
705
|
+
throw new ToolError(
|
|
706
|
+
`tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
return content;
|
|
681
710
|
},
|
|
682
711
|
click: async selector => {
|
|
683
712
|
const resolved = normalizeSelector(selector);
|