@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.2
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 +172 -198
- package/dist/types/async/job-manager.d.ts +3 -3
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/claude-trace-cli.d.ts +54 -0
- package/dist/types/cli/session-picker.d.ts +10 -3
- package/dist/types/cli/update-cli.d.ts +17 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +10 -1
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/config/settings.d.ts +13 -0
- package/dist/types/edit/index.d.ts +6 -0
- package/dist/types/edit/streaming.d.ts +8 -0
- package/dist/types/eval/concurrency-bridge.d.ts +26 -0
- package/dist/types/eval/js/tool-bridge.d.ts +2 -1
- package/dist/types/export/ttsr.d.ts +9 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
- package/dist/types/main.d.ts +5 -0
- package/dist/types/mcp/transports/stdio.d.ts +19 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/hook-selector.d.ts +3 -0
- package/dist/types/modes/components/session-selector.d.ts +32 -5
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +9 -2
- package/dist/types/modes/types.d.ts +4 -2
- package/dist/types/registry/agent-registry.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/history-storage.d.ts +16 -1
- package/dist/types/session/session-manager.d.ts +4 -0
- package/dist/types/task/output-manager.d.ts +6 -15
- package/dist/types/tools/find.d.ts +0 -9
- package/dist/types/tools/index.d.ts +1 -1
- package/dist/types/tools/path-utils.d.ts +16 -0
- package/dist/types/tools/sqlite-reader.d.ts +25 -8
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/clipboard.d.ts +4 -0
- package/dist/types/utils/jj.d.ts +49 -0
- package/dist/types/web/kagi.d.ts +76 -0
- package/dist/types/web/search/providers/exa.d.ts +7 -1
- package/dist/types/web/search/providers/kagi.d.ts +1 -0
- package/package.json +9 -9
- package/src/async/job-manager.ts +3 -3
- package/src/cli/args.ts +6 -2
- package/src/cli/claude-trace-cli.ts +783 -0
- package/src/cli/session-picker.ts +36 -10
- package/src/cli/update-cli.ts +35 -2
- package/src/commands/launch.ts +3 -0
- package/src/config/keybindings.ts +14 -1
- package/src/config/model-registry.ts +18 -7
- package/src/config/settings-schema.ts +2 -2
- package/src/config/settings.ts +23 -0
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
- package/src/discovery/claude-plugins.ts +7 -9
- package/src/edit/index.ts +10 -0
- package/src/edit/streaming.ts +65 -0
- package/src/eval/__tests__/agent-bridge.test.ts +58 -4
- package/src/eval/concurrency-bridge.ts +34 -0
- package/src/eval/js/shared/prelude.txt +20 -17
- package/src/eval/js/tool-bridge.ts +5 -0
- package/src/eval/py/prelude.py +23 -15
- package/src/export/ttsr.ts +18 -1
- package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
- package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
- package/src/extensibility/skills.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +12 -11
- package/src/main.ts +92 -24
- package/src/mcp/transports/stdio.ts +55 -22
- package/src/modes/acp/acp-event-mapper.ts +54 -4
- package/src/modes/components/custom-editor.ts +10 -0
- package/src/modes/components/hook-selector.ts +89 -31
- package/src/modes/components/oauth-selector.ts +12 -6
- package/src/modes/components/session-selector.ts +179 -24
- package/src/modes/components/tool-execution.ts +16 -3
- package/src/modes/controllers/command-controller.ts +2 -11
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +19 -1
- package/src/modes/controllers/selector-controller.ts +61 -21
- package/src/modes/interactive-mode.ts +125 -15
- package/src/modes/types.ts +5 -2
- package/src/prompts/agents/reviewer.md +2 -2
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/empty-stop-retry.md +6 -0
- package/src/prompts/system/orchestrate-notice.md +5 -3
- package/src/prompts/system/workflow-notice.md +2 -2
- package/src/prompts/tools/eval.md +5 -5
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/irc.md +6 -6
- package/src/prompts/tools/search-tool-bm25.md +9 -2
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +1 -1
- package/src/registry/agent-registry.ts +1 -1
- package/src/sdk.ts +85 -31
- package/src/session/agent-session.ts +209 -54
- package/src/session/history-storage.ts +56 -12
- package/src/session/session-manager.ts +34 -0
- package/src/task/output-manager.ts +40 -48
- package/src/task/render.ts +3 -8
- package/src/tools/browser/tab-worker.ts +8 -5
- package/src/tools/find.ts +5 -29
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +144 -1
- package/src/tools/read.ts +47 -0
- package/src/tools/search-tool-bm25.ts +7 -1
- package/src/tools/search.ts +2 -27
- package/src/tools/sqlite-reader.ts +92 -9
- package/src/tools/write.ts +6 -0
- package/src/utils/clipboard.ts +38 -1
- package/src/utils/git.ts +19 -23
- package/src/utils/jj.ts +225 -0
- package/src/utils/open.ts +37 -2
- package/src/web/kagi.ts +168 -49
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/web/search/providers/exa.ts +20 -86
- package/src/web/search/providers/kagi.ts +4 -0
|
@@ -8,6 +8,8 @@ export interface HistoryEntry {
|
|
|
8
8
|
prompt: string;
|
|
9
9
|
created_at: number;
|
|
10
10
|
cwd?: string;
|
|
11
|
+
/** ID of the session the prompt was submitted from, if known. */
|
|
12
|
+
sessionId?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
type HistoryRow = {
|
|
@@ -15,6 +17,7 @@ type HistoryRow = {
|
|
|
15
17
|
prompt: string;
|
|
16
18
|
created_at: number;
|
|
17
19
|
cwd: string | null;
|
|
20
|
+
session_id: string | null;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
@@ -62,7 +65,8 @@ class AsyncDrain<T> {
|
|
|
62
65
|
export class HistoryStorage {
|
|
63
66
|
#db: Database;
|
|
64
67
|
static #instance?: HistoryStorage;
|
|
65
|
-
#drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd">>(100);
|
|
68
|
+
#drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>(100);
|
|
69
|
+
#sessionResolver?: () => string | undefined;
|
|
66
70
|
|
|
67
71
|
// Prepared statements
|
|
68
72
|
#insertRowStmt: Statement;
|
|
@@ -91,7 +95,8 @@ CREATE TABLE IF NOT EXISTS history (
|
|
|
91
95
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
96
|
prompt TEXT NOT NULL,
|
|
93
97
|
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
94
|
-
cwd TEXT
|
|
98
|
+
cwd TEXT,
|
|
99
|
+
session_id TEXT
|
|
95
100
|
);
|
|
96
101
|
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
|
97
102
|
|
|
@@ -106,6 +111,10 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
|
|
106
111
|
this.#migrateHistorySchema();
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
if (!this.#historySchemaHasColumn("session_id")) {
|
|
115
|
+
this.#db.run("ALTER TABLE history ADD COLUMN session_id TEXT");
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
if (!hasFts) {
|
|
110
119
|
try {
|
|
111
120
|
this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
|
|
@@ -115,14 +124,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
this.#recentStmt = this.#db.prepare(
|
|
118
|
-
"SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
127
|
+
"SELECT id, prompt, created_at, cwd, session_id FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
119
128
|
);
|
|
120
129
|
this.#searchStmt = this.#db.prepare(
|
|
121
|
-
"SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
|
|
130
|
+
"SELECT h.id, h.prompt, h.created_at, h.cwd, h.session_id FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
|
|
122
131
|
);
|
|
123
132
|
this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1");
|
|
124
133
|
|
|
125
|
-
this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)");
|
|
134
|
+
this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd, session_id) VALUES (?, ?, ?)");
|
|
126
135
|
|
|
127
136
|
const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined;
|
|
128
137
|
this.#lastPromptCache = last?.prompt ?? null;
|
|
@@ -140,20 +149,30 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
|
|
140
149
|
HistoryStorage.#instance = undefined;
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
#insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>): void {
|
|
144
|
-
this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>) => {
|
|
152
|
+
#insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>): void {
|
|
153
|
+
this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>) => {
|
|
145
154
|
for (const row of rows) {
|
|
146
|
-
this.#insertRowStmt.run(row.prompt, row.cwd ?? null);
|
|
155
|
+
this.#insertRowStmt.run(row.prompt, row.cwd ?? null, row.sessionId ?? null);
|
|
147
156
|
}
|
|
148
157
|
})(rows);
|
|
149
158
|
}
|
|
150
159
|
|
|
151
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Register a resolver that supplies the current session ID for prompts added
|
|
162
|
+
* without an explicit `sessionId`. Evaluated synchronously at `add()` time so
|
|
163
|
+
* batched writes capture the session active when the prompt was submitted.
|
|
164
|
+
*/
|
|
165
|
+
setSessionResolver(resolver: () => string | undefined): void {
|
|
166
|
+
this.#sessionResolver = resolver;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
add(prompt: string, cwd?: string, sessionId?: string): Promise<void> {
|
|
152
170
|
const trimmed = prompt.trim();
|
|
153
171
|
if (!trimmed) return Promise.resolve();
|
|
154
172
|
if (this.#lastPromptCache === trimmed) return Promise.resolve();
|
|
155
173
|
this.#lastPromptCache = trimmed;
|
|
156
|
-
|
|
174
|
+
const session = sessionId ?? this.#sessionResolver?.();
|
|
175
|
+
return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined, sessionId: session || undefined }, rows => {
|
|
157
176
|
this.#insertBatch(rows);
|
|
158
177
|
});
|
|
159
178
|
}
|
|
@@ -224,6 +243,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
|
|
224
243
|
return merged;
|
|
225
244
|
}
|
|
226
245
|
|
|
246
|
+
/**
|
|
247
|
+
* IDs of the sessions whose stored prompts match `query`, ordered by match
|
|
248
|
+
* relevance (most relevant/recent first) and de-duplicated. Prompts with no
|
|
249
|
+
* recorded session are skipped. Used to augment session ranking in the
|
|
250
|
+
* resume picker with prompts that the 4KB session-list prefix never sees.
|
|
251
|
+
*/
|
|
252
|
+
matchingSessionIds(query: string, limit = 500): string[] {
|
|
253
|
+
const seen = new Set<string>();
|
|
254
|
+
const ids: string[] = [];
|
|
255
|
+
for (const entry of this.search(query, limit)) {
|
|
256
|
+
const id = entry.sessionId;
|
|
257
|
+
if (!id || seen.has(id)) continue;
|
|
258
|
+
seen.add(id);
|
|
259
|
+
ids.push(id);
|
|
260
|
+
}
|
|
261
|
+
return ids;
|
|
262
|
+
}
|
|
263
|
+
|
|
227
264
|
#ensureDir(dbPath: string): void {
|
|
228
265
|
const dir = path.dirname(dbPath);
|
|
229
266
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -236,6 +273,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
|
|
236
273
|
return row?.sql?.includes("unixepoch(") ?? false;
|
|
237
274
|
}
|
|
238
275
|
|
|
276
|
+
#historySchemaHasColumn(column: string): boolean {
|
|
277
|
+
const columns = this.#db.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>;
|
|
278
|
+
return columns.some(col => col.name === column);
|
|
279
|
+
}
|
|
280
|
+
|
|
239
281
|
#migrateHistorySchema(): void {
|
|
240
282
|
const migrate = this.#db.transaction(() => {
|
|
241
283
|
this.#db.run("ALTER TABLE history RENAME TO history_legacy");
|
|
@@ -247,7 +289,8 @@ CREATE TABLE history (
|
|
|
247
289
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
248
290
|
prompt TEXT NOT NULL,
|
|
249
291
|
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
250
|
-
cwd TEXT
|
|
292
|
+
cwd TEXT,
|
|
293
|
+
session_id TEXT
|
|
251
294
|
);
|
|
252
295
|
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
|
253
296
|
INSERT INTO history (id, prompt, created_at, cwd)
|
|
@@ -294,7 +337,7 @@ END;
|
|
|
294
337
|
if (stmt) return stmt;
|
|
295
338
|
const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND ");
|
|
296
339
|
stmt = this.#db.prepare(
|
|
297
|
-
`SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
340
|
+
`SELECT id, prompt, created_at, cwd, session_id FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
298
341
|
);
|
|
299
342
|
this.#substringStmts.set(tokenCount, stmt);
|
|
300
343
|
return stmt;
|
|
@@ -306,6 +349,7 @@ END;
|
|
|
306
349
|
prompt: row.prompt,
|
|
307
350
|
created_at: row.created_at,
|
|
308
351
|
cwd: row.cwd ?? undefined,
|
|
352
|
+
sessionId: row.session_id ?? undefined,
|
|
309
353
|
};
|
|
310
354
|
}
|
|
311
355
|
}
|
|
@@ -254,6 +254,22 @@ export interface SessionContext {
|
|
|
254
254
|
modeData?: Record<string, unknown>;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/** Lists session model strings to try when restoring, in fallback order. */
|
|
258
|
+
export function getRestorableSessionModels(
|
|
259
|
+
models: Readonly<Record<string, string>>,
|
|
260
|
+
lastModelChangeRole: string | undefined,
|
|
261
|
+
): string[] {
|
|
262
|
+
const defaultModel = models.default;
|
|
263
|
+
if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") {
|
|
264
|
+
return defaultModel ? [defaultModel] : [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const roleModel = models[lastModelChangeRole];
|
|
268
|
+
if (!roleModel) return defaultModel ? [defaultModel] : [];
|
|
269
|
+
if (!defaultModel || roleModel === defaultModel) return [roleModel];
|
|
270
|
+
return [roleModel, defaultModel];
|
|
271
|
+
}
|
|
272
|
+
|
|
257
273
|
export interface SessionInfo {
|
|
258
274
|
path: string;
|
|
259
275
|
id: string;
|
|
@@ -1808,6 +1824,8 @@ export async function resolveResumableSession(
|
|
|
1808
1824
|
return { session: globalMatch, scope: "global" };
|
|
1809
1825
|
}
|
|
1810
1826
|
interface SessionManagerStateSnapshot {
|
|
1827
|
+
cwd: string;
|
|
1828
|
+
sessionDir: string;
|
|
1811
1829
|
sessionId: string;
|
|
1812
1830
|
sessionName: string | undefined;
|
|
1813
1831
|
titleSource: "auto" | "user" | undefined;
|
|
@@ -1880,6 +1898,8 @@ export class SessionManager {
|
|
|
1880
1898
|
|
|
1881
1899
|
captureState(): SessionManagerStateSnapshot {
|
|
1882
1900
|
return {
|
|
1901
|
+
cwd: this.cwd,
|
|
1902
|
+
sessionDir: this.sessionDir,
|
|
1883
1903
|
sessionId: this.#sessionId,
|
|
1884
1904
|
sessionName: this.#sessionName,
|
|
1885
1905
|
titleSource: this.#titleSource,
|
|
@@ -1893,6 +1913,8 @@ export class SessionManager {
|
|
|
1893
1913
|
}
|
|
1894
1914
|
|
|
1895
1915
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
1916
|
+
this.cwd = snapshot.cwd;
|
|
1917
|
+
this.sessionDir = snapshot.sessionDir;
|
|
1896
1918
|
this.#sessionId = snapshot.sessionId;
|
|
1897
1919
|
this.#sessionName = snapshot.sessionName;
|
|
1898
1920
|
this.#titleSource = snapshot.titleSource;
|
|
@@ -1938,6 +1960,18 @@ export class SessionManager {
|
|
|
1938
1960
|
this.#sessionName = header?.title;
|
|
1939
1961
|
this.#titleSource = header?.titleSource;
|
|
1940
1962
|
|
|
1963
|
+
// Adopt the loaded session's own working directory. Sessions are stored in
|
|
1964
|
+
// a directory keyed by their cwd, so resuming a session from another
|
|
1965
|
+
// project (e.g. global review in the picker) must re-point cwd/sessionDir
|
|
1966
|
+
// at that project. Same-cwd resumes and in-place reloads are a no-op; old
|
|
1967
|
+
// sessions with no recorded cwd keep the current cwd.
|
|
1968
|
+
const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
|
|
1969
|
+
if (headerCwd && headerCwd !== this.cwd) {
|
|
1970
|
+
this.cwd = headerCwd;
|
|
1971
|
+
this.sessionDir = path.resolve(this.#sessionFile, "..");
|
|
1972
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1941
1975
|
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
1942
1976
|
|
|
1943
1977
|
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
|
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-scoped manager for agent output IDs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Keeps every subagent output id unique within a session without polluting the
|
|
5
|
+
* common case with bookkeeping. A requested name is used verbatim the first
|
|
6
|
+
* time it appears; only a *repeated* name gets a numeric suffix to disambiguate
|
|
7
|
+
* it (e.g. "Anna", "Anna-2", "Anna-3"). When a parent prefix is configured, ids
|
|
8
|
+
* are nested under it (e.g. "Anna.Bob") so hierarchical outputs stay grouped.
|
|
7
9
|
*
|
|
8
|
-
* This enables reliable agent:// URL resolution and prevents artifact
|
|
10
|
+
* This enables reliable agent:// URL resolution and prevents artifact
|
|
11
|
+
* collisions across repeated or nested task invocations.
|
|
9
12
|
*/
|
|
10
13
|
import * as fs from "node:fs/promises";
|
|
11
14
|
|
|
12
|
-
function escapeRegExp(value: string): string {
|
|
13
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
15
|
/**
|
|
17
16
|
* Manages agent output ID allocation to ensure uniqueness.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* On resume, scans existing files to find the next available index.
|
|
18
|
+
* The first allocation of a given name keeps the name as-is; subsequent
|
|
19
|
+
* allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans
|
|
20
|
+
* existing output files so previously written outputs are never overwritten.
|
|
23
21
|
*/
|
|
24
22
|
export class AgentOutputManager {
|
|
25
|
-
#nextId = 0;
|
|
26
23
|
#initialized = false;
|
|
24
|
+
/** Final ids already handed out, relative to this manager's scope. */
|
|
25
|
+
readonly #taken = new Set<string>();
|
|
27
26
|
readonly #getArtifactsDir: () => string | null;
|
|
28
27
|
readonly #parentPrefix: string | undefined;
|
|
29
28
|
|
|
@@ -33,8 +32,8 @@ export class AgentOutputManager {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Seed the taken-id set from output files already on disk so a resumed
|
|
36
|
+
* session never reuses a name that would clobber a prior subagent's output.
|
|
38
37
|
*/
|
|
39
38
|
async #ensureInitialized(): Promise<void> {
|
|
40
39
|
if (this.#initialized) return;
|
|
@@ -50,31 +49,41 @@ export class AgentOutputManager {
|
|
|
50
49
|
return; // Directory doesn't exist yet
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
const
|
|
54
|
-
? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
|
|
55
|
-
: /^(\d+)-.*\.md$/;
|
|
56
|
-
|
|
57
|
-
let maxId = -1;
|
|
52
|
+
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
|
|
58
53
|
for (const file of files) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
54
|
+
if (!file.endsWith(".md")) continue;
|
|
55
|
+
let rest = file.slice(0, -3); // drop ".md"
|
|
56
|
+
if (prefix) {
|
|
57
|
+
if (!rest.startsWith(prefix)) continue;
|
|
58
|
+
rest = rest.slice(prefix.length);
|
|
63
59
|
}
|
|
60
|
+
// Requested ids never contain "."; a dot marks a nested child, so this
|
|
61
|
+
// manager only owns the first segment of whatever remains.
|
|
62
|
+
const dot = rest.indexOf(".");
|
|
63
|
+
const segment = dot === -1 ? rest : rest.slice(0, dot);
|
|
64
|
+
if (segment) this.#taken.add(segment);
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pick the first free name (base, then `base-2`, `base-3`, …) and reserve it. */
|
|
69
|
+
#allocateUnique(id: string): string {
|
|
70
|
+
let candidate = id;
|
|
71
|
+
for (let n = 2; this.#taken.has(candidate); n++) {
|
|
72
|
+
candidate = `${id}-${n}`;
|
|
73
|
+
}
|
|
74
|
+
this.#taken.add(candidate);
|
|
75
|
+
return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/**
|
|
69
|
-
* Allocate a unique ID
|
|
79
|
+
* Allocate a unique ID.
|
|
70
80
|
*
|
|
71
|
-
* @param id Requested ID (e.g., "
|
|
72
|
-
* @returns Unique ID
|
|
81
|
+
* @param id Requested ID (e.g., "Anna")
|
|
82
|
+
* @returns Unique ID ("Anna" first, then "Anna-2", "Anna-3", …)
|
|
73
83
|
*/
|
|
74
84
|
async allocate(id: string): Promise<string> {
|
|
75
85
|
await this.#ensureInitialized();
|
|
76
|
-
|
|
77
|
-
return `${prefix}${this.#nextId++}-${id}`;
|
|
86
|
+
return this.#allocateUnique(id);
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/**
|
|
@@ -85,23 +94,6 @@ export class AgentOutputManager {
|
|
|
85
94
|
*/
|
|
86
95
|
async allocateBatch(ids: string[]): Promise<string[]> {
|
|
87
96
|
await this.#ensureInitialized();
|
|
88
|
-
|
|
89
|
-
return ids.map(id => `${prefix}${this.#nextId++}-${id}`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Get the next ID that would be allocated (without allocating).
|
|
94
|
-
*/
|
|
95
|
-
async peekNextIndex(): Promise<number> {
|
|
96
|
-
await this.#ensureInitialized();
|
|
97
|
-
return this.#nextId;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Reset state (primarily for testing).
|
|
102
|
-
*/
|
|
103
|
-
reset(): void {
|
|
104
|
-
this.#nextId = 0;
|
|
105
|
-
this.#initialized = false;
|
|
97
|
+
return ids.map(id => this.#allocateUnique(id));
|
|
106
98
|
}
|
|
107
99
|
}
|
package/src/task/render.ts
CHANGED
|
@@ -128,15 +128,10 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
function formatTaskId(id: string): string {
|
|
131
|
+
// Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
|
|
132
|
+
// (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
|
|
131
133
|
const segments = id.split(".");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/));
|
|
135
|
-
if (parsed.some(match => !match)) return id;
|
|
136
|
-
|
|
137
|
-
const indices = parsed.map(match => match![1]).join(".");
|
|
138
|
-
const labels = parsed.map(match => match![2]).join(">");
|
|
139
|
-
return `${indices} ${labels}`;
|
|
134
|
+
return segments.length < 2 ? id : segments.join(">");
|
|
140
135
|
}
|
|
141
136
|
|
|
142
137
|
const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
|
|
@@ -815,18 +815,21 @@ export class WorkerCore {
|
|
|
815
815
|
{ maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
|
|
816
816
|
);
|
|
817
817
|
const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
|
|
818
|
+
const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
|
|
819
|
+
const savedBuffer = saveFullRes ? buffer : resized.buffer;
|
|
820
|
+
const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
|
|
821
|
+
// Auto-generated names must match the bytes we actually write: full-res is always
|
|
822
|
+
// PNG, but the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
|
|
823
|
+
const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
|
|
818
824
|
const dest =
|
|
819
825
|
explicitPath ??
|
|
820
826
|
(session.browserScreenshotDir
|
|
821
827
|
? path.join(
|
|
822
828
|
session.browserScreenshotDir,
|
|
823
|
-
`screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}
|
|
829
|
+
`screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
|
|
824
830
|
)
|
|
825
|
-
: path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}
|
|
831
|
+
: path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`));
|
|
826
832
|
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
827
|
-
const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
|
|
828
|
-
const savedBuffer = saveFullRes ? buffer : resized.buffer;
|
|
829
|
-
const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
|
|
830
833
|
await Bun.write(dest, savedBuffer);
|
|
831
834
|
const info: ScreenshotResult = {
|
|
832
835
|
dest,
|
package/src/tools/find.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { ToolSession } from ".";
|
|
|
16
16
|
import { applyListLimit } from "./list-limit";
|
|
17
17
|
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
|
18
18
|
import {
|
|
19
|
+
expandDelimitedPathEntries,
|
|
19
20
|
formatPathRelativeToCwd,
|
|
20
21
|
hasGlobPathChars,
|
|
21
22
|
normalizePathLikeInput,
|
|
@@ -52,33 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
|
|
|
52
53
|
const MIN_GLOB_TIMEOUT_MS = 500;
|
|
53
54
|
const MAX_GLOB_TIMEOUT_MS = 60_000;
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Reject comma-separated path lists packed into a single array element
|
|
57
|
-
* (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
|
|
58
|
-
* single comma-joined element get silent no-matches otherwise.
|
|
59
|
-
*
|
|
60
|
-
* Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and
|
|
61
|
-
* must pass through.
|
|
62
|
-
*/
|
|
63
|
-
export function validateFindPathInputs(paths: readonly string[]): void {
|
|
64
|
-
for (const entry of paths) {
|
|
65
|
-
let braceDepth = 0;
|
|
66
|
-
for (let i = 0; i < entry.length; i++) {
|
|
67
|
-
const ch = entry.charCodeAt(i);
|
|
68
|
-
if (ch === 0x5c /* \ */ && i + 1 < entry.length) {
|
|
69
|
-
i++;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (ch === 0x7b /* { */) braceDepth++;
|
|
73
|
-
else if (ch === 0x7d /* } */) {
|
|
74
|
-
if (braceDepth > 0) braceDepth--;
|
|
75
|
-
} else if (ch === 0x2c /* , */ && braceDepth === 0) {
|
|
76
|
-
throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
56
|
/**
|
|
83
57
|
* Group find matches by their directory so the model doesn't pay repeated
|
|
84
58
|
* tokens for shared path prefixes. Preserves the input order: groups appear in
|
|
@@ -180,8 +154,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
180
154
|
|
|
181
155
|
return untilAborted(signal, async () => {
|
|
182
156
|
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
|
183
|
-
|
|
184
|
-
|
|
157
|
+
const rawPatternInputs = this.#customOps
|
|
158
|
+
? paths
|
|
159
|
+
: await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
|
|
160
|
+
const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
|
|
185
161
|
const internalRouter = InternalUrlRouter.instance();
|
|
186
162
|
const normalizedPatterns: string[] = [];
|
|
187
163
|
for (const rawPattern of rawPatterns) {
|
package/src/tools/index.ts
CHANGED
|
@@ -159,7 +159,7 @@ export interface ToolSession {
|
|
|
159
159
|
getHindsightSessionState?: () => HindsightSessionState | undefined;
|
|
160
160
|
/** Get Mnemopi runtime state for this agent session. */
|
|
161
161
|
getMnemopiSessionState?: () => MnemopiSessionState | undefined;
|
|
162
|
-
/** Agent identity used for IRC routing. Returns the registry id (e.g. "
|
|
162
|
+
/** Agent identity used for IRC routing. Returns the registry id (e.g. "Main", "AuthLoader"). */
|
|
163
163
|
getAgentId?: () => string | null;
|
|
164
164
|
/** Look up a registered tool by name (used by the eval js backend's tool bridge). */
|
|
165
165
|
getToolByName?: (name: string) => AgentTool | undefined;
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -379,6 +379,145 @@ export function hasGlobPathChars(filePath: string): boolean {
|
|
|
379
379
|
return GLOB_PATH_CHARS.some(char => filePath.includes(char));
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
type PathEntrySplitter = (item: string) => { basePath: string };
|
|
383
|
+
|
|
384
|
+
const TOP_LEVEL_WHITESPACE_RE = /\s/;
|
|
385
|
+
|
|
386
|
+
type DelimitedPathSplitMode = "comma" | "semicolon" | "whitespace" | "mixed";
|
|
387
|
+
|
|
388
|
+
function isDelimitedPathSeparator(ch: string, mode: DelimitedPathSplitMode): boolean {
|
|
389
|
+
if (mode === "comma") return ch === ",";
|
|
390
|
+
if (mode === "semicolon") return ch === ";";
|
|
391
|
+
if (mode === "whitespace") return TOP_LEVEL_WHITESPACE_RE.test(ch);
|
|
392
|
+
return ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function hasTopLevelPathDelimiter(entry: string): boolean {
|
|
396
|
+
let braceDepth = 0;
|
|
397
|
+
for (let i = 0; i < entry.length; i++) {
|
|
398
|
+
const ch = entry[i];
|
|
399
|
+
if (ch === "\\" && i + 1 < entry.length) {
|
|
400
|
+
i++;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (ch === "{") {
|
|
404
|
+
braceDepth++;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (ch === "}") {
|
|
408
|
+
if (braceDepth > 0) braceDepth--;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (braceDepth === 0 && (ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch))) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function splitTopLevelDelimitedPath(entry: string, mode: DelimitedPathSplitMode): string[] {
|
|
419
|
+
const parts: string[] = [];
|
|
420
|
+
let braceDepth = 0;
|
|
421
|
+
let start = 0;
|
|
422
|
+
for (let i = 0; i < entry.length; i++) {
|
|
423
|
+
const ch = entry[i];
|
|
424
|
+
if (ch === "\\" && i + 1 < entry.length) {
|
|
425
|
+
i++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === "{") {
|
|
429
|
+
braceDepth++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === "}") {
|
|
433
|
+
if (braceDepth > 0) braceDepth--;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (braceDepth !== 0 || !isDelimitedPathSeparator(ch, mode)) continue;
|
|
437
|
+
parts.push(entry.slice(start, i));
|
|
438
|
+
start = i + 1;
|
|
439
|
+
}
|
|
440
|
+
parts.push(entry.slice(start));
|
|
441
|
+
return parts;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function delimitedPathPartResolves(entry: string, cwd: string, splitter: PathEntrySplitter): Promise<boolean> {
|
|
445
|
+
if (isInternalUrlPath(entry)) return true;
|
|
446
|
+
const peeled = splitPathAndSel(entry).path;
|
|
447
|
+
const { basePath } = splitter(peeled);
|
|
448
|
+
const absoluteBasePath = resolveToCwd(basePath, cwd);
|
|
449
|
+
try {
|
|
450
|
+
await fs.promises.stat(absoluteBasePath);
|
|
451
|
+
return true;
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (isEnoent(err)) return false;
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function tryDelimitedPathSplit(
|
|
459
|
+
entry: string,
|
|
460
|
+
cwd: string,
|
|
461
|
+
splitter: PathEntrySplitter,
|
|
462
|
+
mode: DelimitedPathSplitMode,
|
|
463
|
+
requireAllParts: boolean,
|
|
464
|
+
): Promise<string[] | null> {
|
|
465
|
+
const rawParts = splitTopLevelDelimitedPath(entry, mode);
|
|
466
|
+
if (rawParts.length < 2) return null;
|
|
467
|
+
|
|
468
|
+
const parts = rawParts.map(normalizePathLikeInput).filter(part => part.length > 0);
|
|
469
|
+
if (parts.length === 0) return null;
|
|
470
|
+
if (parts.length < 2 && rawParts.length === parts.length) return null;
|
|
471
|
+
|
|
472
|
+
const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter)));
|
|
473
|
+
const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean);
|
|
474
|
+
return valid ? parts : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Split one path-like entry whose multiple targets were flattened into one
|
|
479
|
+
* string. Existing paths are kept intact, so real filenames containing spaces,
|
|
480
|
+
* commas, or semicolons win over delimiter recovery.
|
|
481
|
+
*/
|
|
482
|
+
export async function splitDelimitedPathEntry(
|
|
483
|
+
entry: string,
|
|
484
|
+
cwd: string,
|
|
485
|
+
options: { splitter?: PathEntrySplitter } = {},
|
|
486
|
+
): Promise<string[] | null> {
|
|
487
|
+
const normalizedEntry = normalizePathLikeInput(entry);
|
|
488
|
+
if (!hasTopLevelPathDelimiter(normalizedEntry)) return null;
|
|
489
|
+
if (isInternalUrlPath(normalizedEntry)) return null;
|
|
490
|
+
|
|
491
|
+
const splitter = options.splitter ?? parseSearchPath;
|
|
492
|
+
const peeledEntry = splitPathAndSel(normalizedEntry).path;
|
|
493
|
+
if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
(await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ??
|
|
499
|
+
(await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ??
|
|
500
|
+
(await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ??
|
|
501
|
+
(await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true))
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Expand delimited entries in-place while preserving unsplit entries. */
|
|
506
|
+
export async function expandDelimitedPathEntries(
|
|
507
|
+
entries: readonly string[],
|
|
508
|
+
cwd: string,
|
|
509
|
+
options: { splitter?: PathEntrySplitter } = {},
|
|
510
|
+
): Promise<string[]> {
|
|
511
|
+
const expanded: string[] = [];
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const normalizedEntry = normalizePathLikeInput(entry);
|
|
514
|
+
const split = await splitDelimitedPathEntry(normalizedEntry, cwd, options);
|
|
515
|
+
if (split) expanded.push(...split);
|
|
516
|
+
else expanded.push(normalizedEntry);
|
|
517
|
+
}
|
|
518
|
+
return expanded;
|
|
519
|
+
}
|
|
520
|
+
|
|
382
521
|
export interface ParsedSearchPath {
|
|
383
522
|
basePath: string;
|
|
384
523
|
glob?: string;
|
|
@@ -769,7 +908,11 @@ export interface ToolScopeResolution {
|
|
|
769
908
|
*/
|
|
770
909
|
export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
|
|
771
910
|
const { rawPaths: inputs, cwd, internalUrlAction } = opts;
|
|
772
|
-
const
|
|
911
|
+
const normalizedRawPaths = inputs.map(normalizePathLikeInput);
|
|
912
|
+
if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) {
|
|
913
|
+
throw new ToolError("`paths` must contain non-empty paths or globs");
|
|
914
|
+
}
|
|
915
|
+
const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd);
|
|
773
916
|
if (rawPaths.some(rawPath => rawPath.length === 0)) {
|
|
774
917
|
throw new ToolError("`paths` must contain non-empty paths or globs");
|
|
775
918
|
}
|