@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.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 +150 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +423 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/custom-editor.ts +53 -51
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +48 -29
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +20 -20
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +436 -86
- package/src/session/messages.ts +23 -0
- package/src/session/session-manager.ts +97 -31
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
- package/src/utils/image-input.ts +11 -1
- package/src/web/search/providers/codex.ts +10 -3
package/src/session/messages.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
8
8
|
import type {
|
|
9
|
+
AssistantMessage,
|
|
9
10
|
ImageContent,
|
|
10
11
|
Message,
|
|
11
12
|
MessageAttribution,
|
|
@@ -213,6 +214,28 @@ export function createCompactionSummaryMessage(
|
|
|
213
214
|
};
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage {
|
|
218
|
+
if (message.providerPayload?.type !== "openaiResponsesHistory") {
|
|
219
|
+
return message;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let didSanitize = false;
|
|
223
|
+
const sanitizedContent = message.content.map(block => {
|
|
224
|
+
if (block.type !== "thinking" || block.thinkingSignature === undefined) {
|
|
225
|
+
return block;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
didSanitize = true;
|
|
229
|
+
return { ...block, thinkingSignature: undefined };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!didSanitize) {
|
|
233
|
+
return message;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { ...message, content: sanitizedContent };
|
|
237
|
+
}
|
|
238
|
+
|
|
216
239
|
/** Convert CustomMessageEntry to AgentMessage format */
|
|
217
240
|
export function createCustomMessage(
|
|
218
241
|
customType: string,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type FileMentionMessage,
|
|
47
47
|
type HookMessage,
|
|
48
48
|
type PythonExecutionMessage,
|
|
49
|
+
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
49
50
|
} from "./messages";
|
|
50
51
|
import type { SessionStorage, SessionStorageWriter } from "./session-storage";
|
|
51
52
|
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
@@ -1302,21 +1303,19 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
|
|
|
1302
1303
|
}
|
|
1303
1304
|
}
|
|
1304
1305
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1306
|
+
const stats = storage.statSync(file);
|
|
1307
|
+
sessions.push({
|
|
1308
|
+
path: file,
|
|
1309
|
+
id: header.id,
|
|
1310
|
+
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
1311
|
+
title: header.title ?? shortSummary,
|
|
1312
|
+
parentSessionPath: (header as SessionHeader).parentSession,
|
|
1313
|
+
created: new Date(header.timestamp),
|
|
1314
|
+
modified: stats.mtime,
|
|
1315
|
+
messageCount,
|
|
1316
|
+
firstMessage: firstMessage || "(no messages)",
|
|
1317
|
+
allMessagesText: allMessages.join(" "),
|
|
1318
|
+
});
|
|
1320
1319
|
} catch {}
|
|
1321
1320
|
}),
|
|
1322
1321
|
);
|
|
@@ -1376,11 +1375,21 @@ export async function resolveResumableSession(
|
|
|
1376
1375
|
|
|
1377
1376
|
return { session: globalMatch, scope: "global" };
|
|
1378
1377
|
}
|
|
1378
|
+
interface SessionManagerStateSnapshot {
|
|
1379
|
+
sessionId: string;
|
|
1380
|
+
sessionName: string | undefined;
|
|
1381
|
+
sessionFile: string | undefined;
|
|
1382
|
+
flushed: boolean;
|
|
1383
|
+
needsFullRewriteOnNextPersist: boolean;
|
|
1384
|
+
fileEntries: FileEntry[];
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1379
1387
|
export class SessionManager {
|
|
1380
1388
|
#sessionId: string = "";
|
|
1381
1389
|
#sessionName: string | undefined;
|
|
1382
1390
|
#sessionFile: string | undefined;
|
|
1383
1391
|
#flushed: boolean = false;
|
|
1392
|
+
#needsFullRewriteOnNextPersist: boolean = false;
|
|
1384
1393
|
#fileEntries: FileEntry[] = [];
|
|
1385
1394
|
#byId: Map<string, SessionEntry> = new Map();
|
|
1386
1395
|
#labelsById: Map<string, string> = new Map();
|
|
@@ -1420,6 +1429,39 @@ export class SessionManager {
|
|
|
1420
1429
|
return this.#blobStore.put(data);
|
|
1421
1430
|
}
|
|
1422
1431
|
|
|
1432
|
+
captureState(): SessionManagerStateSnapshot {
|
|
1433
|
+
return {
|
|
1434
|
+
sessionId: this.#sessionId,
|
|
1435
|
+
sessionName: this.#sessionName,
|
|
1436
|
+
sessionFile: this.#sessionFile,
|
|
1437
|
+
flushed: this.#flushed,
|
|
1438
|
+
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
|
|
1439
|
+
// Snapshot entry objects by reference: switch/reload replaces the active entry array,
|
|
1440
|
+
// so rollback does not need structured cloning of extension/custom details.
|
|
1441
|
+
fileEntries: [...this.#fileEntries],
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
1446
|
+
this.#sessionId = snapshot.sessionId;
|
|
1447
|
+
this.#sessionName = snapshot.sessionName;
|
|
1448
|
+
this.#sessionFile = snapshot.sessionFile;
|
|
1449
|
+
this.#flushed = snapshot.flushed;
|
|
1450
|
+
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
1451
|
+
this.#fileEntries = [...snapshot.fileEntries];
|
|
1452
|
+
this.#persistWriter = undefined;
|
|
1453
|
+
this.#persistWriterPath = undefined;
|
|
1454
|
+
this.#persistChain = Promise.resolve();
|
|
1455
|
+
this.#persistError = undefined;
|
|
1456
|
+
this.#persistErrorReported = false;
|
|
1457
|
+
this.#artifactManager = null;
|
|
1458
|
+
this.#artifactManagerSessionFile = null;
|
|
1459
|
+
this.#buildIndex();
|
|
1460
|
+
if (this.#sessionFile) {
|
|
1461
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1423
1465
|
/** Initialize with a specific session file (used by factory methods) */
|
|
1424
1466
|
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
1425
1467
|
await this.setSessionFile(sessionFile);
|
|
@@ -1443,11 +1485,10 @@ export class SessionManager {
|
|
|
1443
1485
|
this.#sessionId = header?.id ?? Snowflake.next();
|
|
1444
1486
|
this.#sessionName = header?.title;
|
|
1445
1487
|
|
|
1446
|
-
|
|
1447
|
-
await this.#rewriteFile();
|
|
1448
|
-
}
|
|
1488
|
+
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
1449
1489
|
|
|
1450
1490
|
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
|
|
1491
|
+
this.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
1451
1492
|
|
|
1452
1493
|
this.#buildIndex();
|
|
1453
1494
|
this.#flushed = true;
|
|
@@ -1632,6 +1673,7 @@ export class SessionManager {
|
|
|
1632
1673
|
this.#labelsById.clear();
|
|
1633
1674
|
this.#leafId = null;
|
|
1634
1675
|
this.#flushed = false;
|
|
1676
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
1635
1677
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
1636
1678
|
|
|
1637
1679
|
if (this.persist) {
|
|
@@ -1774,6 +1816,7 @@ export class SessionManager {
|
|
|
1774
1816
|
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
1775
1817
|
);
|
|
1776
1818
|
await this.#writeEntriesAtomically(entries);
|
|
1819
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
1777
1820
|
this.#flushed = true;
|
|
1778
1821
|
});
|
|
1779
1822
|
}
|
|
@@ -1782,6 +1825,16 @@ export class SessionManager {
|
|
|
1782
1825
|
return this.persist;
|
|
1783
1826
|
}
|
|
1784
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* Force-persist all current entries to disk, even when no assistant message exists yet.
|
|
1830
|
+
* Used by ACP mode where session/new must create a discoverable session immediately.
|
|
1831
|
+
*/
|
|
1832
|
+
async ensureOnDisk(): Promise<void> {
|
|
1833
|
+
if (!this.persist || !this.#sessionFile) return;
|
|
1834
|
+
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
|
|
1835
|
+
await this.#rewriteFile();
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1785
1838
|
/** Flush pending writes to disk. Call before switching sessions or on shutdown. */
|
|
1786
1839
|
async flush(): Promise<void> {
|
|
1787
1840
|
await this.#queuePersistTask(async () => {
|
|
@@ -1911,23 +1964,15 @@ export class SessionManager {
|
|
|
1911
1964
|
|
|
1912
1965
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
1913
1966
|
if (!hasAssistant) {
|
|
1914
|
-
// Mark as not flushed so when assistant arrives, all entries get written
|
|
1967
|
+
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
1915
1968
|
this.#flushed = false;
|
|
1916
1969
|
return;
|
|
1917
1970
|
}
|
|
1918
1971
|
|
|
1919
|
-
if (!this.#flushed) {
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
if (!writer) return;
|
|
1924
|
-
const entries = await Promise.all(
|
|
1925
|
-
this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)),
|
|
1926
|
-
);
|
|
1927
|
-
for (const persistedEntry of entries) {
|
|
1928
|
-
await writer.write(persistedEntry);
|
|
1929
|
-
}
|
|
1930
|
-
});
|
|
1972
|
+
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
1973
|
+
// Full flush: rewrite the entire file atomically to avoid
|
|
1974
|
+
// duplicating entries if the file already exists (e.g. from ensureOnDisk).
|
|
1975
|
+
void this.#rewriteFile();
|
|
1931
1976
|
} else {
|
|
1932
1977
|
void this.#queuePersistTask(async () => {
|
|
1933
1978
|
const writer = this.#ensurePersistWriter();
|
|
@@ -2299,6 +2344,26 @@ export class SessionManager {
|
|
|
2299
2344
|
return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
|
|
2300
2345
|
}
|
|
2301
2346
|
|
|
2347
|
+
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
|
|
2348
|
+
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
2349
|
+
let didSanitize = false;
|
|
2350
|
+
for (const entry of this.#fileEntries) {
|
|
2351
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
|
|
2356
|
+
if (sanitizedMessage === entry.message) {
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
entry.message = sanitizedMessage;
|
|
2361
|
+
didSanitize = true;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
return didSanitize;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2302
2367
|
/**
|
|
2303
2368
|
* Get session header.
|
|
2304
2369
|
*/
|
|
@@ -2547,6 +2612,7 @@ export class SessionManager {
|
|
|
2547
2612
|
newHeader.title = sourceHeader?.title;
|
|
2548
2613
|
manager.#fileEntries = [newHeader, ...historyEntries];
|
|
2549
2614
|
manager.#sessionName = newHeader.title;
|
|
2615
|
+
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
2550
2616
|
manager.#buildIndex();
|
|
2551
2617
|
await manager.#rewriteFile();
|
|
2552
2618
|
return manager;
|
package/src/tools/ask.ts
CHANGED
|
@@ -16,13 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
19
|
-
import type
|
|
20
|
-
import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
19
|
+
import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
21
20
|
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
22
21
|
import { type Static, Type } from "@sinclair/typebox";
|
|
23
22
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
24
23
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
25
|
-
import { type Theme, theme } from "../modes/theme/theme";
|
|
24
|
+
import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
|
|
26
25
|
import askDescription from "../prompts/tools/ask.md" with { type: "text" };
|
|
27
26
|
import { renderStatusLine } from "../tui";
|
|
28
27
|
import type { ToolSession } from ".";
|
|
@@ -574,10 +573,13 @@ interface AskRenderArgs {
|
|
|
574
573
|
export const askToolRenderer = {
|
|
575
574
|
renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
576
575
|
const label = formatTitle("Ask", uiTheme);
|
|
576
|
+
const mdTheme = getMarkdownTheme();
|
|
577
|
+
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
|
|
577
578
|
|
|
578
579
|
// Multi-part questions
|
|
579
580
|
if (args.questions && args.questions.length > 0) {
|
|
580
|
-
|
|
581
|
+
const container = new Container();
|
|
582
|
+
container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
|
|
581
583
|
|
|
582
584
|
for (let i = 0; i < args.questions.length; i++) {
|
|
583
585
|
const q = args.questions[i];
|
|
@@ -585,25 +587,29 @@ export const askToolRenderer = {
|
|
|
585
587
|
const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
|
|
586
588
|
const continuation = isLastQ ? " " : uiTheme.tree.vertical;
|
|
587
589
|
|
|
588
|
-
// Question line with metadata
|
|
589
590
|
const meta: string[] = [];
|
|
590
591
|
if (q.multi) meta.push("multi");
|
|
591
592
|
if (q.options?.length) meta.push(`options:${q.options.length}`);
|
|
592
593
|
const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
|
|
593
594
|
|
|
594
|
-
|
|
595
|
+
container.addChild(
|
|
596
|
+
new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
|
|
597
|
+
);
|
|
598
|
+
container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
|
|
595
599
|
|
|
596
|
-
// Options under question
|
|
597
600
|
if (q.options?.length) {
|
|
601
|
+
let optText = "";
|
|
598
602
|
for (let j = 0; j < q.options.length; j++) {
|
|
599
603
|
const opt = q.options[j];
|
|
600
604
|
const isLastOpt = j === q.options.length - 1;
|
|
601
605
|
const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
|
|
602
|
-
|
|
606
|
+
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
|
|
607
|
+
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
|
|
603
608
|
}
|
|
609
|
+
container.addChild(new Text(optText, 0, 0));
|
|
604
610
|
}
|
|
605
611
|
}
|
|
606
|
-
return
|
|
612
|
+
return container;
|
|
607
613
|
}
|
|
608
614
|
|
|
609
615
|
// Single question
|
|
@@ -611,22 +617,26 @@ export const askToolRenderer = {
|
|
|
611
617
|
return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
|
|
612
618
|
}
|
|
613
619
|
|
|
614
|
-
|
|
620
|
+
const container = new Container();
|
|
615
621
|
const meta: string[] = [];
|
|
616
622
|
if (args.multi) meta.push("multi");
|
|
617
623
|
if (args.options?.length) meta.push(`options:${args.options.length}`);
|
|
618
|
-
|
|
624
|
+
container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
|
|
625
|
+
container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
|
|
619
626
|
|
|
620
627
|
if (args.options?.length) {
|
|
628
|
+
let optText = "";
|
|
621
629
|
for (let i = 0; i < args.options.length; i++) {
|
|
622
630
|
const opt = args.options[i];
|
|
623
631
|
const isLast = i === args.options.length - 1;
|
|
624
632
|
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
|
625
|
-
|
|
633
|
+
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
|
|
634
|
+
optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
|
|
626
635
|
}
|
|
636
|
+
container.addChild(new Text(optText, 0, 0));
|
|
627
637
|
}
|
|
628
638
|
|
|
629
|
-
return
|
|
639
|
+
return container;
|
|
630
640
|
},
|
|
631
641
|
|
|
632
642
|
renderResult(
|
|
@@ -635,6 +645,9 @@ export const askToolRenderer = {
|
|
|
635
645
|
uiTheme: Theme,
|
|
636
646
|
): Component {
|
|
637
647
|
const { details } = result;
|
|
648
|
+
const mdTheme = getMarkdownTheme();
|
|
649
|
+
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
|
|
650
|
+
|
|
638
651
|
if (!details) {
|
|
639
652
|
const txt = result.content[0];
|
|
640
653
|
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
|
|
@@ -655,7 +668,8 @@ export const askToolRenderer = {
|
|
|
655
668
|
},
|
|
656
669
|
uiTheme,
|
|
657
670
|
);
|
|
658
|
-
|
|
671
|
+
const container = new Container();
|
|
672
|
+
container.addChild(new Text(header, 0, 0));
|
|
659
673
|
|
|
660
674
|
for (let i = 0; i < details.results.length; i++) {
|
|
661
675
|
const r = details.results[i];
|
|
@@ -667,22 +681,31 @@ export const askToolRenderer = {
|
|
|
667
681
|
? uiTheme.styledSymbol("status.success", "success")
|
|
668
682
|
: uiTheme.styledSymbol("status.warning", "warning");
|
|
669
683
|
|
|
670
|
-
|
|
684
|
+
container.addChild(
|
|
685
|
+
new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
|
|
686
|
+
);
|
|
687
|
+
container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
|
|
671
688
|
|
|
689
|
+
let answerText = "";
|
|
672
690
|
if (r.customInput) {
|
|
673
|
-
|
|
691
|
+
answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
|
|
674
692
|
} else if (r.selectedOptions.length > 0) {
|
|
675
693
|
for (let j = 0; j < r.selectedOptions.length; j++) {
|
|
676
694
|
const isLast = j === r.selectedOptions.length - 1;
|
|
677
695
|
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
|
678
|
-
|
|
696
|
+
const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
|
|
697
|
+
uiTheme.fg("toolOutput", t),
|
|
698
|
+
);
|
|
699
|
+
answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
|
|
679
700
|
}
|
|
680
701
|
} else {
|
|
681
|
-
|
|
702
|
+
answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
|
703
|
+
}
|
|
704
|
+
if (answerText) {
|
|
705
|
+
container.addChild(new Text(answerText, 0, 0));
|
|
682
706
|
}
|
|
683
707
|
}
|
|
684
|
-
|
|
685
|
-
return new Text(text, 0, 0);
|
|
708
|
+
return container;
|
|
686
709
|
}
|
|
687
710
|
|
|
688
711
|
// Single question result
|
|
@@ -693,25 +716,28 @@ export const askToolRenderer = {
|
|
|
693
716
|
}
|
|
694
717
|
|
|
695
718
|
const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
|
|
696
|
-
const header = renderStatusLine(
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
);
|
|
700
|
-
|
|
701
|
-
let text = header;
|
|
719
|
+
const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
|
|
720
|
+
const container = new Container();
|
|
721
|
+
container.addChild(new Text(header, 0, 0));
|
|
722
|
+
container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
|
|
702
723
|
|
|
724
|
+
let answerText = "";
|
|
703
725
|
if (details.customInput) {
|
|
704
|
-
|
|
726
|
+
answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
|
|
705
727
|
} else if (details.selectedOptions && details.selectedOptions.length > 0) {
|
|
706
728
|
for (let i = 0; i < details.selectedOptions.length; i++) {
|
|
707
729
|
const isLast = i === details.selectedOptions.length - 1;
|
|
708
730
|
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
|
709
|
-
|
|
731
|
+
const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
|
|
732
|
+
uiTheme.fg("toolOutput", t),
|
|
733
|
+
);
|
|
734
|
+
answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
|
|
710
735
|
}
|
|
711
736
|
} else {
|
|
712
|
-
|
|
737
|
+
answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
|
713
738
|
}
|
|
739
|
+
container.addChild(new Text(answerText, 0, 0));
|
|
714
740
|
|
|
715
|
-
return
|
|
741
|
+
return container;
|
|
716
742
|
},
|
|
717
743
|
};
|
|
@@ -5,45 +5,7 @@
|
|
|
5
5
|
* this interceptor provides helpful error messages directing them to use
|
|
6
6
|
* the specialized tools instead.
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
|
|
11
|
-
{
|
|
12
|
-
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
|
|
13
|
-
tool: "read",
|
|
14
|
-
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
|
|
18
|
-
tool: "grep",
|
|
19
|
-
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
|
|
23
|
-
tool: "find",
|
|
24
|
-
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
pattern: "^\\s*sed\\s+(-i|--in-place)",
|
|
28
|
-
tool: "edit",
|
|
29
|
-
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
pattern: "^\\s*perl\\s+.*-[pn]?i",
|
|
33
|
-
tool: "edit",
|
|
34
|
-
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
|
|
38
|
-
tool: "edit",
|
|
39
|
-
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
|
|
43
|
-
tool: "write",
|
|
44
|
-
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
|
|
45
|
-
},
|
|
46
|
-
];
|
|
8
|
+
import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
|
|
47
9
|
|
|
48
10
|
export interface InterceptionResult {
|
|
49
11
|
/** If true, the bash command should be blocked */
|
|
@@ -131,7 +131,7 @@ async function resolveInternalUrlToPath(
|
|
|
131
131
|
return resolvedLocalPath;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
if (!internalRouter
|
|
134
|
+
if (!internalRouter?.canHandle(url)) {
|
|
135
135
|
throw new ToolError(
|
|
136
136
|
`Cannot resolve ${scheme}:// URL in bash command: ${url}\n` +
|
|
137
137
|
"Internal URL router is unavailable for this protocol in the current session.",
|
package/src/tools/browser.ts
CHANGED
|
@@ -564,7 +564,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
564
564
|
if (this.#page && !this.#page.isClosed()) {
|
|
565
565
|
return this.#page;
|
|
566
566
|
}
|
|
567
|
-
if (!this.#browser
|
|
567
|
+
if (!this.#browser?.isConnected()) {
|
|
568
568
|
return this.#resetBrowser(params);
|
|
569
569
|
}
|
|
570
570
|
this.#page = await this.#browser.newPage();
|
|
@@ -287,7 +287,7 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
|
|
|
287
287
|
throw new Error(`Image download failed (${response.status}): ${rawText}`);
|
|
288
288
|
}
|
|
289
289
|
const contentType = response.headers.get("content-type")?.split(";")[0];
|
|
290
|
-
if (!contentType
|
|
290
|
+
if (!contentType?.startsWith("image/")) {
|
|
291
291
|
throw new Error(`Unsupported image type from URL: ${imageUrl}`);
|
|
292
292
|
}
|
|
293
293
|
const buffer = await response.bytes();
|
package/src/tools/resolve.ts
CHANGED
|
@@ -54,7 +54,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
|
|
54
54
|
): Promise<AgentToolResult<ResolveToolDetails>> {
|
|
55
55
|
return untilAborted(signal, async () => {
|
|
56
56
|
const store = this.session.pendingActionStore;
|
|
57
|
-
if (!store
|
|
57
|
+
if (!store?.hasPending) {
|
|
58
58
|
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const EXIT_STDIO_GRACE_MS = 100;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wait for a child process to terminate without hanging on inherited stdio handles.
|
|
7
|
+
*
|
|
8
|
+
* Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
|
|
9
|
+
* case the child emits `exit`, but `close` can hang forever even though the original
|
|
10
|
+
* process is already gone. We wait briefly for stdio to end, then forcibly stop
|
|
11
|
+
* tracking the inherited handles.
|
|
12
|
+
*/
|
|
13
|
+
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
|
14
|
+
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
|
|
15
|
+
|
|
16
|
+
let settled = false;
|
|
17
|
+
let exited = false;
|
|
18
|
+
let exitCode: number | null = null;
|
|
19
|
+
let postExitTimer: NodeJS.Timeout | undefined;
|
|
20
|
+
let stdoutEnded = child.stdout === null;
|
|
21
|
+
let stderrEnded = child.stderr === null;
|
|
22
|
+
|
|
23
|
+
const cleanup = () => {
|
|
24
|
+
if (postExitTimer) {
|
|
25
|
+
clearTimeout(postExitTimer);
|
|
26
|
+
postExitTimer = undefined;
|
|
27
|
+
}
|
|
28
|
+
child.removeListener("error", onError);
|
|
29
|
+
child.removeListener("exit", onExit);
|
|
30
|
+
child.removeListener("close", onClose);
|
|
31
|
+
child.stdout?.removeListener("end", onStdoutEnd);
|
|
32
|
+
child.stderr?.removeListener("end", onStderrEnd);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const finalize = (code: number | null) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
cleanup();
|
|
39
|
+
child.stdout?.destroy();
|
|
40
|
+
child.stderr?.destroy();
|
|
41
|
+
resolve(code);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const maybeFinalizeAfterExit = () => {
|
|
45
|
+
if (!exited || settled) return;
|
|
46
|
+
if (stdoutEnded && stderrEnded) {
|
|
47
|
+
finalize(exitCode);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const onStdoutEnd = () => {
|
|
52
|
+
stdoutEnded = true;
|
|
53
|
+
maybeFinalizeAfterExit();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const onStderrEnd = () => {
|
|
57
|
+
stderrEnded = true;
|
|
58
|
+
maybeFinalizeAfterExit();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onError = (err: Error) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
cleanup();
|
|
65
|
+
reject(err);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const onExit = (code: number | null) => {
|
|
69
|
+
exited = true;
|
|
70
|
+
exitCode = code;
|
|
71
|
+
maybeFinalizeAfterExit();
|
|
72
|
+
if (!settled) {
|
|
73
|
+
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const onClose = (code: number | null) => {
|
|
78
|
+
finalize(code);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
child.stdout?.once("end", onStdoutEnd);
|
|
82
|
+
child.stderr?.once("end", onStderrEnd);
|
|
83
|
+
child.once("error", onError);
|
|
84
|
+
child.once("exit", onExit);
|
|
85
|
+
child.once("close", onClose);
|
|
86
|
+
|
|
87
|
+
return promise;
|
|
88
|
+
}
|
package/src/utils/image-input.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
3
|
import { formatBytes } from "@oh-my-pi/pi-utils";
|
|
3
4
|
import { resolveReadPath } from "../tools/path-utils";
|
|
5
|
+
import { convertToPng } from "./image-convert";
|
|
4
6
|
import { formatDimensionNote, resizeImage } from "./image-resize";
|
|
5
7
|
import { detectSupportedImageMimeTypeFromFile } from "./mime";
|
|
6
8
|
|
|
7
9
|
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
|
8
10
|
const MAX_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
|
9
|
-
|
|
11
|
+
export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
10
12
|
export interface ImageMetadata {
|
|
11
13
|
mimeType: string;
|
|
12
14
|
bytes: number;
|
|
@@ -25,6 +27,14 @@ export interface LoadedImageInput {
|
|
|
25
27
|
bytes: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
|
|
31
|
+
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
|
|
32
|
+
return image;
|
|
33
|
+
}
|
|
34
|
+
const converted = await convertToPng(image.data, image.mimeType);
|
|
35
|
+
return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
export interface ReadImageMetadataOptions {
|
|
29
39
|
path: string;
|
|
30
40
|
cwd: string;
|