@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.0
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 +146 -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 +5 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/config/settings.d.ts +13 -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/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
- package/dist/types/main.d.ts +5 -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/utils/clipboard.d.ts +4 -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 +6 -0
- package/src/config/settings-schema.ts +2 -2
- package/src/config/settings.ts +23 -0
- package/src/discovery/claude-plugins.ts +7 -9
- 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/extensibility/plugins/legacy-pi-compat.ts +115 -131
- package/src/extensibility/skills.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +92 -24
- 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/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.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 +62 -46
- 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.ts +2 -27
- package/src/tools/sqlite-reader.ts +92 -9
- package/src/utils/clipboard.ts +38 -1
- 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
|
@@ -62,6 +62,7 @@ import type {
|
|
|
62
62
|
Message,
|
|
63
63
|
MessageAttribution,
|
|
64
64
|
Model,
|
|
65
|
+
ProviderResponseMetadata,
|
|
65
66
|
ProviderSessionState,
|
|
66
67
|
ServiceTier,
|
|
67
68
|
SimpleStreamOptions,
|
|
@@ -87,6 +88,7 @@ import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
|
|
87
88
|
import {
|
|
88
89
|
extractRetryHint,
|
|
89
90
|
getAgentDbPath,
|
|
91
|
+
getInstallId,
|
|
90
92
|
isEnoent,
|
|
91
93
|
isUnexpectedSocketCloseMessage,
|
|
92
94
|
logger,
|
|
@@ -227,7 +229,7 @@ import type {
|
|
|
227
229
|
SessionContext,
|
|
228
230
|
SessionManager,
|
|
229
231
|
} from "./session-manager";
|
|
230
|
-
import { getLatestCompactionEntry } from "./session-manager";
|
|
232
|
+
import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
|
|
231
233
|
import type { ShakeMode, ShakeResult } from "./shake-types";
|
|
232
234
|
import { ToolChoiceQueue } from "./tool-choice-queue";
|
|
233
235
|
import { YieldQueue } from "./yield-queue";
|
|
@@ -358,7 +360,7 @@ export interface AgentSessionConfig {
|
|
|
358
360
|
* **MUST NOT** dispose it on their own teardown.
|
|
359
361
|
*/
|
|
360
362
|
ownedAsyncJobManager?: AsyncJobManager;
|
|
361
|
-
/** Agent identity (registry id like "
|
|
363
|
+
/** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
|
|
362
364
|
agentId?: string;
|
|
363
365
|
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
|
|
364
366
|
agentRegistry?: AgentRegistry;
|
|
@@ -581,15 +583,14 @@ function buildSessionMetadata(
|
|
|
581
583
|
const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
|
|
582
584
|
if (typeof accountUuid === "string" && accountUuid.length > 0) {
|
|
583
585
|
userId.account_uuid = accountUuid;
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
|
|
586
|
+
// Claude Code's `device_id` is a stable 64-hex install identifier. Use
|
|
587
|
+
// omp's persistent install id as the root instead of deriving it from
|
|
588
|
+
// `account_uuid`: logging into a different Claude account on the same
|
|
589
|
+
// install should not make the device look new.
|
|
590
|
+
userId.device_id = crypto
|
|
591
|
+
.createHash("sha256")
|
|
592
|
+
.update(`omp-claude-device-id-v1:${getInstallId()}`)
|
|
593
|
+
.digest("hex");
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
return { user_id: JSON.stringify(userId) };
|
|
@@ -1104,10 +1105,12 @@ export class AgentSession {
|
|
|
1104
1105
|
this.#onResponse = configuredOnResponse
|
|
1105
1106
|
? async (response, model) => {
|
|
1106
1107
|
this.rawSseDebugBuffer.recordResponse(response, model);
|
|
1108
|
+
this.#ingestProviderUsageHeaders(response, model);
|
|
1107
1109
|
await configuredOnResponse(response, model);
|
|
1108
1110
|
}
|
|
1109
1111
|
: (response, model) => {
|
|
1110
1112
|
this.rawSseDebugBuffer.recordResponse(response, model);
|
|
1113
|
+
this.#ingestProviderUsageHeaders(response, model);
|
|
1111
1114
|
};
|
|
1112
1115
|
const configuredOnSseEvent = config.onSseEvent;
|
|
1113
1116
|
this.#onSseEvent = configuredOnSseEvent
|
|
@@ -2827,13 +2830,14 @@ export class AgentSession {
|
|
|
2827
2830
|
|
|
2828
2831
|
/**
|
|
2829
2832
|
* Set agent.sessionId from the session manager and install a dynamic
|
|
2830
|
-
* metadata resolver so every API request carries
|
|
2831
|
-
* like real Claude Code's `getAPIMetadata` output:
|
|
2832
|
-
*
|
|
2833
|
-
* a known account UUID is loaded
|
|
2834
|
-
*
|
|
2835
|
-
*
|
|
2836
|
-
*
|
|
2833
|
+
* metadata resolver so every Anthropic API request carries
|
|
2834
|
+
* `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
|
|
2835
|
+
* `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
|
|
2836
|
+
* when an Anthropic OAuth credential with a known account UUID is loaded;
|
|
2837
|
+
* `device_id` is derived from the persistent omp install id. Resolving live
|
|
2838
|
+
* keeps the value in sync with auth-state changes (login/logout, token
|
|
2839
|
+
* refresh that surfaces a new account uuid) without needing to re-call
|
|
2840
|
+
* `#syncAgentSessionId()` on every such event.
|
|
2837
2841
|
*/
|
|
2838
2842
|
#syncAgentSessionId(sessionId?: string): void {
|
|
2839
2843
|
const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
|
|
@@ -8723,28 +8727,34 @@ export class AgentSession {
|
|
|
8723
8727
|
}
|
|
8724
8728
|
|
|
8725
8729
|
// Restore model if saved
|
|
8726
|
-
const
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8730
|
+
const targetModelStrings = getRestorableSessionModels(
|
|
8731
|
+
sessionContext.models,
|
|
8732
|
+
this.sessionManager.getLastModelChangeRole(),
|
|
8733
|
+
);
|
|
8734
|
+
if (targetModelStrings.length > 0) {
|
|
8735
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
8736
|
+
let match: Model | undefined;
|
|
8737
|
+
for (const targetModelStr of targetModelStrings) {
|
|
8738
|
+
const slashIdx = targetModelStr.indexOf("/");
|
|
8739
|
+
if (slashIdx <= 0) continue;
|
|
8740
|
+
const provider = targetModelStr.slice(0, slashIdx);
|
|
8741
|
+
const modelId = targetModelStr.slice(slashIdx + 1);
|
|
8742
|
+
match = availableModels.find(m => m.provider === provider && m.id === modelId);
|
|
8743
|
+
if (match) break;
|
|
8744
|
+
}
|
|
8745
|
+
if (match) {
|
|
8746
|
+
const currentModel = this.model;
|
|
8747
|
+
const shouldResetProviderState =
|
|
8748
|
+
switchingToDifferentSession ||
|
|
8749
|
+
(currentModel !== undefined &&
|
|
8750
|
+
(currentModel.provider !== match.provider ||
|
|
8751
|
+
currentModel.id !== match.id ||
|
|
8752
|
+
currentModel.api !== match.api));
|
|
8753
|
+
if (shouldResetProviderState) {
|
|
8754
|
+
this.#setModelWithProviderSessionReset(match);
|
|
8755
|
+
} else {
|
|
8756
|
+
this.agent.setModel(match);
|
|
8757
|
+
this.#syncToolCallBatchCap(match);
|
|
8748
8758
|
}
|
|
8749
8759
|
}
|
|
8750
8760
|
}
|
|
@@ -9202,12 +9212,10 @@ export class AgentSession {
|
|
|
9202
9212
|
* Uses the last assistant message's usage data when available,
|
|
9203
9213
|
* otherwise estimates tokens for all messages.
|
|
9204
9214
|
*/
|
|
9205
|
-
getContextUsage(): ContextUsage | undefined {
|
|
9215
|
+
getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
|
|
9206
9216
|
const model = this.model;
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
const contextWindow = model.contextWindow ?? 0;
|
|
9210
|
-
if (contextWindow <= 0) return undefined;
|
|
9217
|
+
const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
|
|
9218
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
|
|
9211
9219
|
|
|
9212
9220
|
// After compaction, the last assistant usage reflects pre-compaction context size.
|
|
9213
9221
|
// We can only trust usage from an assistant that responded after the latest compaction.
|
|
@@ -9248,6 +9256,14 @@ export class AgentSession {
|
|
|
9248
9256
|
};
|
|
9249
9257
|
}
|
|
9250
9258
|
|
|
9259
|
+
#ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
|
|
9260
|
+
if (model?.provider !== "anthropic") return;
|
|
9261
|
+
this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
|
|
9262
|
+
sessionId: this.agent.sessionId,
|
|
9263
|
+
baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
|
|
9264
|
+
});
|
|
9265
|
+
}
|
|
9266
|
+
|
|
9251
9267
|
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
9252
9268
|
const authStorage = this.#modelRegistry.authStorage;
|
|
9253
9269
|
if (!authStorage.fetchUsageReports) return null;
|
|
@@ -9265,7 +9281,7 @@ export class AgentSession {
|
|
|
9265
9281
|
} {
|
|
9266
9282
|
const messages = this.messages;
|
|
9267
9283
|
|
|
9268
|
-
// Find last assistant message with usage
|
|
9284
|
+
// Find last assistant message with valid usage.
|
|
9269
9285
|
let lastUsageIndex: number | null = null;
|
|
9270
9286
|
let lastUsage: Usage | undefined;
|
|
9271
9287
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -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) {
|