@oh-my-pi/pi-coding-agent 17.1.3 → 17.1.4
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 +54 -0
- package/dist/cli.js +3759 -3751
- package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/cli/update-cli.d.ts +41 -0
- package/dist/types/cli/usage-cli.d.ts +4 -2
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +19 -0
- package/dist/types/config/settings-schema.d.ts +30 -7
- package/dist/types/cursor.d.ts +47 -1
- package/dist/types/eval/js/process-entry.d.ts +2 -1
- package/dist/types/eval/js/worker-core.d.ts +4 -1
- package/dist/types/extensibility/extensions/runner.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +9 -0
- package/dist/types/modes/components/custom-editor.d.ts +12 -8
- package/dist/types/plan-mode/approved-plan.d.ts +3 -2
- package/dist/types/secrets/obfuscator.d.ts +7 -31
- package/dist/types/session/agent-session.d.ts +19 -4
- package/dist/types/session/prewalk.d.ts +2 -1
- package/dist/types/session/session-advisors.d.ts +8 -0
- package/dist/types/session/session-metadata.d.ts +29 -0
- package/dist/types/session/turn-recovery.d.ts +5 -5
- package/dist/types/stt/sherpa-runtime.d.ts +38 -0
- package/dist/types/thinking.d.ts +24 -0
- package/dist/types/tools/bash.d.ts +5 -4
- package/dist/types/tools/computer/exposure.d.ts +8 -0
- package/dist/types/tools/computer/supervisor.d.ts +1 -1
- package/dist/types/tools/computer/worker-entry.d.ts +1 -1
- package/dist/types/tools/computer/worker.d.ts +1 -1
- package/dist/types/tools/computer.d.ts +6 -0
- package/dist/types/tools/shell-tokenize.d.ts +14 -0
- package/dist/types/tools/todo.d.ts +7 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +80 -10
- package/src/advisor/runtime.ts +27 -1
- package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
- package/src/cli/auth-gateway-cli.ts +63 -16
- package/src/cli/config-cli.ts +25 -7
- package/src/cli/update-cli.ts +229 -29
- package/src/cli/usage-cli.ts +144 -15
- package/src/cli.ts +21 -15
- package/src/commands/update.ts +6 -0
- package/src/config/__tests__/model-registry.test.ts +42 -7
- package/src/config/model-registry.ts +67 -4
- package/src/config/settings-schema.ts +39 -8
- package/src/config/settings.ts +24 -2
- package/src/cursor.ts +153 -0
- package/src/dap/session.ts +70 -11
- package/src/eval/__tests__/js-context-manager.test.ts +9 -1
- package/src/eval/__tests__/process-entry-import.test.ts +111 -1
- package/src/eval/js/process-entry.ts +9 -5
- package/src/eval/js/worker-core.ts +6 -2
- package/src/exec/bash-executor.ts +4 -2
- package/src/extensibility/extensions/runner.ts +23 -8
- package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
- package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/main.ts +22 -1
- package/src/modes/acp/acp-agent.ts +6 -0
- package/src/modes/components/assistant-message.ts +88 -11
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor.test.ts +170 -0
- package/src/modes/components/custom-editor.ts +79 -29
- package/src/modes/components/settings-defs.ts +5 -1
- package/src/modes/controllers/event-controller.ts +96 -4
- package/src/modes/controllers/selector-controller.ts +14 -0
- package/src/modes/interactive-mode.ts +34 -8
- package/src/modes/utils/context-usage.ts +19 -2
- package/src/modes/utils/interactive-context-helpers.ts +1 -0
- package/src/plan-mode/approved-plan.ts +18 -10
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/sdk.ts +4 -0
- package/src/secrets/obfuscator.ts +36 -126
- package/src/session/agent-session.ts +69 -60
- package/src/session/prewalk.ts +8 -3
- package/src/session/session-advisors.ts +67 -3
- package/src/session/session-metadata.ts +53 -0
- package/src/session/session-provider-boundary.ts +0 -1
- package/src/session/session-tools.ts +35 -1
- package/src/session/turn-recovery.ts +36 -19
- package/src/slash-commands/builtin-registry.ts +49 -7
- package/src/stt/asr-worker.ts +2 -37
- package/src/stt/sherpa-runtime.ts +71 -0
- package/src/task/executor.ts +6 -5
- package/src/thinking.ts +39 -0
- package/src/tools/bash.ts +43 -15
- package/src/tools/computer/exposure.ts +38 -0
- package/src/tools/computer/supervisor.ts +61 -13
- package/src/tools/computer/worker-entry.ts +28 -19
- package/src/tools/computer/worker.ts +3 -7
- package/src/tools/computer.ts +65 -10
- package/src/tools/gh-cache-invalidation.ts +2 -82
- package/src/tools/shell-tokenize.ts +83 -0
- package/src/tools/todo.ts +44 -17
|
@@ -234,38 +234,50 @@ export type AnyUiMetadata = UiBase & {
|
|
|
234
234
|
ordered?: boolean;
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Marks a setting whose value is a credential.
|
|
239
|
+
*
|
|
240
|
+
* Lives at the top level rather than inside `ui` so it can also describe a
|
|
241
|
+
* setting the settings panel never shows and therefore cannot carry
|
|
242
|
+
* `ui.secret`. Read it through `isCredential`, which is the single accessor
|
|
243
|
+
* both the CLI and the settings panel consult.
|
|
244
|
+
*/
|
|
245
|
+
interface CredentialMarker {
|
|
246
|
+
credential?: true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface BooleanDef extends CredentialMarker {
|
|
238
250
|
type: "boolean";
|
|
239
251
|
default: boolean | undefined;
|
|
240
252
|
ui?: UiBoolean;
|
|
241
253
|
}
|
|
242
254
|
|
|
243
|
-
interface StringDef {
|
|
255
|
+
interface StringDef extends CredentialMarker {
|
|
244
256
|
type: "string";
|
|
245
257
|
default: string | undefined;
|
|
246
258
|
ui?: UiString;
|
|
247
259
|
}
|
|
248
260
|
|
|
249
|
-
interface NumberDef {
|
|
261
|
+
interface NumberDef extends CredentialMarker {
|
|
250
262
|
type: "number";
|
|
251
263
|
default: number | undefined;
|
|
252
264
|
ui?: UiNumber;
|
|
253
265
|
}
|
|
254
266
|
|
|
255
|
-
interface EnumDef<T extends readonly string[]> {
|
|
267
|
+
interface EnumDef<T extends readonly string[]> extends CredentialMarker {
|
|
256
268
|
type: "enum";
|
|
257
269
|
values: T;
|
|
258
270
|
default: T[number];
|
|
259
271
|
ui?: UiEnum<T>;
|
|
260
272
|
}
|
|
261
273
|
|
|
262
|
-
interface ArrayDef<T> {
|
|
274
|
+
interface ArrayDef<T> extends CredentialMarker {
|
|
263
275
|
type: "array";
|
|
264
276
|
default: T[];
|
|
265
277
|
ui?: UiArray;
|
|
266
278
|
}
|
|
267
279
|
|
|
268
|
-
interface RecordDef<T> {
|
|
280
|
+
interface RecordDef<T> extends CredentialMarker {
|
|
269
281
|
type: "record";
|
|
270
282
|
default: Record<string, T>;
|
|
271
283
|
ui?: UiBase;
|
|
@@ -378,7 +390,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
378
390
|
// Env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) takes precedence so
|
|
379
391
|
// per-machine overrides remain trivial.
|
|
380
392
|
"auth.broker.url": { type: "string", default: undefined },
|
|
381
|
-
"auth.broker.token": { type: "string", default: undefined },
|
|
393
|
+
"auth.broker.token": { type: "string", default: undefined, credential: true },
|
|
382
394
|
|
|
383
395
|
autoResume: {
|
|
384
396
|
type: "boolean",
|
|
@@ -2776,6 +2788,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2776
2788
|
},
|
|
2777
2789
|
"mnemopi.embeddingApiKey": {
|
|
2778
2790
|
type: "string",
|
|
2791
|
+
credential: true,
|
|
2779
2792
|
default: undefined,
|
|
2780
2793
|
ui: {
|
|
2781
2794
|
tab: "memory",
|
|
@@ -2820,6 +2833,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2820
2833
|
},
|
|
2821
2834
|
"mnemopi.llmApiKey": {
|
|
2822
2835
|
type: "string",
|
|
2836
|
+
credential: true,
|
|
2823
2837
|
default: undefined,
|
|
2824
2838
|
ui: {
|
|
2825
2839
|
tab: "memory",
|
|
@@ -2862,6 +2876,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2862
2876
|
|
|
2863
2877
|
"hindsight.apiToken": {
|
|
2864
2878
|
type: "string",
|
|
2879
|
+
credential: true,
|
|
2865
2880
|
default: undefined,
|
|
2866
2881
|
ui: {
|
|
2867
2882
|
tab: "memory",
|
|
@@ -2869,7 +2884,6 @@ export const SETTINGS_SCHEMA = {
|
|
|
2869
2884
|
label: "Hindsight API Token",
|
|
2870
2885
|
description: "Bearer token for authenticated Hindsight servers",
|
|
2871
2886
|
condition: "hindsightActive",
|
|
2872
|
-
secret: true,
|
|
2873
2887
|
},
|
|
2874
2888
|
},
|
|
2875
2889
|
|
|
@@ -5249,6 +5263,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
5249
5263
|
"searxng.token": {
|
|
5250
5264
|
type: "string",
|
|
5251
5265
|
default: undefined,
|
|
5266
|
+
credential: true,
|
|
5252
5267
|
},
|
|
5253
5268
|
|
|
5254
5269
|
"searxng.basicUsername": {
|
|
@@ -5259,6 +5274,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
5259
5274
|
"searxng.basicPassword": {
|
|
5260
5275
|
type: "string",
|
|
5261
5276
|
default: undefined,
|
|
5277
|
+
credential: true,
|
|
5262
5278
|
},
|
|
5263
5279
|
|
|
5264
5280
|
"searxng.categories": {
|
|
@@ -5314,6 +5330,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
5314
5330
|
"dev.autoqaPush.token": {
|
|
5315
5331
|
type: "string",
|
|
5316
5332
|
default: undefined,
|
|
5333
|
+
credential: true,
|
|
5317
5334
|
},
|
|
5318
5335
|
|
|
5319
5336
|
/**
|
|
@@ -5400,6 +5417,20 @@ export function hasUi(path: SettingPath): boolean {
|
|
|
5400
5417
|
return "ui" in SETTINGS_SCHEMA[path];
|
|
5401
5418
|
}
|
|
5402
5419
|
|
|
5420
|
+
/**
|
|
5421
|
+
* Whether a setting holds a credential and must never be printed or exported
|
|
5422
|
+
* without an explicit request. Drives both CLI redaction and settings-panel
|
|
5423
|
+
* masking, so the two cannot disagree.
|
|
5424
|
+
*/
|
|
5425
|
+
export function isCredential(path: SettingPath): boolean {
|
|
5426
|
+
const def = SETTINGS_SCHEMA[path];
|
|
5427
|
+
if ("credential" in def && def.credential === true) return true;
|
|
5428
|
+
// `ui.secret` predates this marker and still means "never display". Reading
|
|
5429
|
+
// both here keeps ONE accessor, so the two spellings cannot produce
|
|
5430
|
+
// different behaviour on different surfaces.
|
|
5431
|
+
return getUi(path)?.secret === true;
|
|
5432
|
+
}
|
|
5433
|
+
|
|
5403
5434
|
/** Get UI metadata for a path (undefined if no UI) */
|
|
5404
5435
|
export function getUi(path: SettingPath): AnyUiMetadata | undefined {
|
|
5405
5436
|
const def = SETTINGS_SCHEMA[path];
|
package/src/config/settings.ts
CHANGED
|
@@ -321,6 +321,10 @@ export class Settings {
|
|
|
321
321
|
#project: RawSettings = {};
|
|
322
322
|
/** Extra config.yml-style overlays passed by CLI */
|
|
323
323
|
#configOverlay: RawSettings = {};
|
|
324
|
+
/** Project settings file that most recently supplied shellPath. */
|
|
325
|
+
#projectShellPathSource: string | undefined;
|
|
326
|
+
/** Explicit config overlay that most recently supplied shellPath. */
|
|
327
|
+
#overlayShellPathSource: string | undefined;
|
|
324
328
|
/** Runtime overrides (not persisted) */
|
|
325
329
|
#overrides: RawSettings = {};
|
|
326
330
|
/** Merged view (global + project + overrides) */
|
|
@@ -592,8 +596,10 @@ export class Settings {
|
|
|
592
596
|
cloned.#configPath = this.#configPath;
|
|
593
597
|
cloned.#global = structuredClone(this.#global);
|
|
594
598
|
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
|
599
|
+
if (!this.#persist) cloned.#projectShellPathSource = this.#projectShellPathSource;
|
|
595
600
|
cloned.#configFiles = [...this.#configFiles];
|
|
596
601
|
cloned.#configOverlay = structuredClone(this.#configOverlay);
|
|
602
|
+
cloned.#overlayShellPathSource = this.#overlayShellPathSource;
|
|
597
603
|
cloned.#overrides = this.#buildOriginalOverrides();
|
|
598
604
|
cloned.#rebuildMerged();
|
|
599
605
|
cloned.#fireAllHooks();
|
|
@@ -652,7 +658,17 @@ export class Settings {
|
|
|
652
658
|
*/
|
|
653
659
|
getShellConfig() {
|
|
654
660
|
const shell = this.get("shellPath");
|
|
655
|
-
|
|
661
|
+
let configSource = this.#configPath ?? path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
|
|
662
|
+
if (Object.hasOwn(this.#project, "shellPath")) {
|
|
663
|
+
configSource = this.#projectShellPathSource ?? "the active project configuration";
|
|
664
|
+
}
|
|
665
|
+
if (Object.hasOwn(this.#configOverlay, "shellPath")) {
|
|
666
|
+
configSource = this.#overlayShellPathSource ?? "the active config overlay";
|
|
667
|
+
}
|
|
668
|
+
if (Object.hasOwn(this.#overrides, "shellPath")) {
|
|
669
|
+
configSource = "the runtime settings override";
|
|
670
|
+
}
|
|
671
|
+
return procmgr.getShellConfig(shell, { configSource });
|
|
656
672
|
}
|
|
657
673
|
|
|
658
674
|
/**
|
|
@@ -1089,12 +1105,14 @@ export class Settings {
|
|
|
1089
1105
|
}
|
|
1090
1106
|
|
|
1091
1107
|
async #loadProjectSettings(): Promise<RawSettings> {
|
|
1108
|
+
this.#projectShellPathSource = undefined;
|
|
1092
1109
|
try {
|
|
1093
1110
|
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
|
|
1094
1111
|
let merged: RawSettings = {};
|
|
1095
1112
|
for (const item of result.items as SettingsCapabilityItem[]) {
|
|
1096
1113
|
if (item.level === "project") {
|
|
1097
1114
|
merged = this.#deepMerge(merged, item.data as RawSettings);
|
|
1115
|
+
if (Object.hasOwn(item.data, "shellPath")) this.#projectShellPathSource = item.path;
|
|
1098
1116
|
}
|
|
1099
1117
|
}
|
|
1100
1118
|
const nativeProject = await this.#loadYaml(path.join(this.#cwd, ".omp", "config.yml"));
|
|
@@ -1104,14 +1122,18 @@ export class Settings {
|
|
|
1104
1122
|
}
|
|
1105
1123
|
return this.#migrateRawSettings(merged);
|
|
1106
1124
|
} catch {
|
|
1125
|
+
this.#projectShellPathSource = undefined;
|
|
1107
1126
|
return {};
|
|
1108
1127
|
}
|
|
1109
1128
|
}
|
|
1110
1129
|
|
|
1111
1130
|
async #loadConfigOverlays(): Promise<RawSettings> {
|
|
1131
|
+
this.#overlayShellPathSource = undefined;
|
|
1112
1132
|
let merged: RawSettings = {};
|
|
1113
1133
|
for (const filePath of this.#configFiles) {
|
|
1114
|
-
|
|
1134
|
+
const overlay = await this.#loadOverlayYaml(filePath);
|
|
1135
|
+
merged = this.#deepMerge(merged, overlay);
|
|
1136
|
+
if (Object.hasOwn(overlay, "shellPath")) this.#overlayShellPathSource = filePath;
|
|
1115
1137
|
}
|
|
1116
1138
|
return merged;
|
|
1117
1139
|
}
|
package/src/cursor.ts
CHANGED
|
@@ -10,11 +10,16 @@ import type {
|
|
|
10
10
|
import type {
|
|
11
11
|
CursorMcpCall,
|
|
12
12
|
CursorShellStreamCallbacks,
|
|
13
|
+
CursorTodoSnapshot,
|
|
13
14
|
CursorExecHandlers as ICursorExecHandlers,
|
|
14
15
|
ToolResultMessage,
|
|
15
16
|
} from "@oh-my-pi/pi-ai";
|
|
16
17
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
17
18
|
import { resolveToCwd } from "./tools/path-utils";
|
|
19
|
+
import type { TodoPhase, TodoStatus } from "./tools/todo";
|
|
20
|
+
|
|
21
|
+
/** Phase used for Cursor-owned tasks with no local phase grouping. */
|
|
22
|
+
const CURSOR_TODO_PHASE = "Tasks";
|
|
18
23
|
|
|
19
24
|
interface CursorExecBridgeOptions {
|
|
20
25
|
cwd: string;
|
|
@@ -32,6 +37,18 @@ interface CursorExecBridgeOptions {
|
|
|
32
37
|
* callers with a restricted tool set (advisors) opt out.
|
|
33
38
|
*/
|
|
34
39
|
allowNativeDelete?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Mirror Cursor's server-owned todo list into local session state. Cursor
|
|
42
|
+
* resolves `update_todos` / `read_todos` remotely, so without this bridge
|
|
43
|
+
* the provider's list and the local `todo` state diverge silently.
|
|
44
|
+
*/
|
|
45
|
+
setTodoPhases?: (phases: TodoPhase[]) => void;
|
|
46
|
+
getTodoPhases?: () => TodoPhase[];
|
|
47
|
+
/**
|
|
48
|
+
* Persist the mirrored list to the session branch so it survives reloads.
|
|
49
|
+
* Cursor emits no local `todo` toolResult, so nothing else records it.
|
|
50
|
+
*/
|
|
51
|
+
persistTodoPhases?: (phases: TodoPhase[]) => void;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
function createToolResultMessage(
|
|
@@ -177,6 +194,49 @@ function formatMcpToolErrorMessage(toolName: string, availableTools: string[]):
|
|
|
177
194
|
return `MCP tool "${toolName}" not found. Available tools: ${list}`;
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
/**
|
|
198
|
+
* One-line summary for the synthesized todo result. Cursor's server-resolved
|
|
199
|
+
* call produces no local tool output, but the transcript entry still needs
|
|
200
|
+
* text content alongside the phases the UI renders.
|
|
201
|
+
*/
|
|
202
|
+
function formatTodoSyncSummary(phases: TodoPhase[]): string {
|
|
203
|
+
const tasks = phases.flatMap(phase => phase.tasks);
|
|
204
|
+
if (tasks.length === 0) return "No todos";
|
|
205
|
+
const done = tasks.filter(task => task.status === "completed").length;
|
|
206
|
+
return `${done}/${tasks.length} tasks completed`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Persisted result for a server-resolved todo call.
|
|
211
|
+
*
|
|
212
|
+
* `details` is only attached for an authoritative snapshot, and then
|
|
213
|
+
* `details.phases` is load-bearing rather than decoration: `todoToolRenderer`
|
|
214
|
+
* rebuilds the rendered list exclusively from it, so a mirrored update that
|
|
215
|
+
* omitted it would replay as `Todo 0 tasks` after a reload.
|
|
216
|
+
*
|
|
217
|
+
* A refusal or a server error carries no `details`. Echoing the current phases
|
|
218
|
+
* there would replay a call that changed nothing as if it had re-asserted the
|
|
219
|
+
* whole list — and `event-controller` feeds `details.phases` straight into
|
|
220
|
+
* `setTodos`, so a refused `read_todos` would overwrite live UI state.
|
|
221
|
+
*/
|
|
222
|
+
function buildTodoSyncResult(
|
|
223
|
+
toolCallId: string,
|
|
224
|
+
phases: TodoPhase[] | undefined,
|
|
225
|
+
error: string | null,
|
|
226
|
+
): ToolResultMessage {
|
|
227
|
+
return {
|
|
228
|
+
role: "toolResult",
|
|
229
|
+
toolCallId,
|
|
230
|
+
toolName: "todo",
|
|
231
|
+
content: [
|
|
232
|
+
{ type: "text", text: error ?? (phases ? formatTodoSyncSummary(phases) : "Todo snapshot not mirrored") },
|
|
233
|
+
],
|
|
234
|
+
details: phases ? { phases, storage: "session" } : undefined,
|
|
235
|
+
isError: error !== null,
|
|
236
|
+
timestamp: Date.now(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
180
240
|
export class CursorExecHandlers implements ICursorExecHandlers {
|
|
181
241
|
constructor(private options: CursorExecBridgeOptions) {}
|
|
182
242
|
|
|
@@ -341,6 +401,99 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
341
401
|
return toolResultMessage;
|
|
342
402
|
}
|
|
343
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Settle a completed native Cursor todo call, mirroring its list when the
|
|
406
|
+
* server supplied an authoritative one.
|
|
407
|
+
*
|
|
408
|
+
* Cursor's snapshot is a flat list, so tasks already known locally keep
|
|
409
|
+
* their phase and only their status is updated; unknown tasks land in a
|
|
410
|
+
* single fallback phase. Statuses come straight from the server snapshot —
|
|
411
|
+
* no local normalization, or an all-pending remote list would gain a
|
|
412
|
+
* phantom in-progress task the remote list does not have.
|
|
413
|
+
*
|
|
414
|
+
* The snapshot is also persisted to the session branch. Every other
|
|
415
|
+
* provider's todo state survives a reload because `todo` runs locally and
|
|
416
|
+
* its `toolResult` (carrying `details.phases`) lands in the branch, which
|
|
417
|
+
* `#syncTodoPhasesFromBranch` replays. Cursor resolves the tool remotely and
|
|
418
|
+
* emits no such result, so without an explicit entry the list is in-memory
|
|
419
|
+
* only and every reload, rewind, compaction, or session switch drops it.
|
|
420
|
+
*
|
|
421
|
+
* This ALWAYS settles the call and returns the result to persist, even when
|
|
422
|
+
* nothing is mirrored. Two reasons it cannot bail out early:
|
|
423
|
+
*
|
|
424
|
+
* - the interactive card leaves `pendingTools` only on a matching
|
|
425
|
+
* `tool_execution_end`, so staying silent leaves it animating forever;
|
|
426
|
+
* - an unpaired `toolCall` is stripped as dangling by `buildSessionContext`,
|
|
427
|
+
* erasing the interaction from every rebuilt transcript.
|
|
428
|
+
*
|
|
429
|
+
* A `null` snapshot means nothing may be mirrored — a server `error`, or a
|
|
430
|
+
* benign refusal: a filtered, truncated, or empty read, or a snapshot the
|
|
431
|
+
* local model cannot represent. Local state is left untouched, and the result
|
|
432
|
+
* carries no `details` (text `"Todo snapshot not mirrored"`): `event-controller`
|
|
433
|
+
* feeds `details.phases` straight into `setTodos`, so echoing the current list
|
|
434
|
+
* back would let a call that changed nothing overwrite live UI state.
|
|
435
|
+
*/
|
|
436
|
+
todoSync(snapshot: CursorTodoSnapshot | null, toolCallId: string, error: string | null = null): ToolResultMessage {
|
|
437
|
+
const setPhases = this.options.setTodoPhases;
|
|
438
|
+
const existing = this.options.getTodoPhases?.() ?? [];
|
|
439
|
+
|
|
440
|
+
// Mirroring is gated on having both a snapshot and somewhere to put it.
|
|
441
|
+
// Settling the call is NOT: the interactive card leaves `pendingTools`
|
|
442
|
+
// only on a matching `tool_execution_end`, so a refusal, a server error,
|
|
443
|
+
// or a host with no local todo state must still resolve it.
|
|
444
|
+
let phases: TodoPhase[] | undefined;
|
|
445
|
+
if (snapshot && setPhases) {
|
|
446
|
+
const phaseByContent = new Map<string, string>();
|
|
447
|
+
for (const phase of existing) {
|
|
448
|
+
for (const task of phase.tasks) phaseByContent.set(task.content, phase.name);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const grouped = new Map<string, TodoPhase["tasks"]>();
|
|
452
|
+
for (const todo of snapshot.todos) {
|
|
453
|
+
const name = phaseByContent.get(todo.content) ?? CURSOR_TODO_PHASE;
|
|
454
|
+
let tasks = grouped.get(name);
|
|
455
|
+
if (!tasks) {
|
|
456
|
+
tasks = [];
|
|
457
|
+
grouped.set(name, tasks);
|
|
458
|
+
}
|
|
459
|
+
tasks.push({ content: todo.content, status: todo.status as TodoStatus });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Preserve the local phase order; phases new to this snapshot append.
|
|
463
|
+
const next: TodoPhase[] = [];
|
|
464
|
+
for (const phase of existing) {
|
|
465
|
+
const tasks = grouped.get(phase.name);
|
|
466
|
+
if (!tasks) continue;
|
|
467
|
+
next.push({ name: phase.name, tasks });
|
|
468
|
+
grouped.delete(phase.name);
|
|
469
|
+
}
|
|
470
|
+
for (const [name, tasks] of grouped) next.push({ name, tasks });
|
|
471
|
+
setPhases(next);
|
|
472
|
+
this.options.persistTodoPhases?.(next);
|
|
473
|
+
phases = next;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const result = buildTodoSyncResult(toolCallId, phases, error);
|
|
477
|
+
// This completion is emitted synchronously mid-parse, while the streamed
|
|
478
|
+
// `toolcall_start` that creates the visible card rides
|
|
479
|
+
// `AssistantMessageEventStream` and lands a microtask later. When Cursor
|
|
480
|
+
// packs start and completion into one HTTP/2 chunk the completion arrives
|
|
481
|
+
// first; the interactive controller holds it as an orphan and replays it
|
|
482
|
+
// once the streamed block creates the card (`event-controller.ts`,
|
|
483
|
+
// `#orphanedToolCompletions`). Emitting a synthetic `tool_execution_start`
|
|
484
|
+
// here instead was measured and rejected: settling deletes the pending
|
|
485
|
+
// entry, and the next cumulative `message_update` re-creates the card —
|
|
486
|
+
// one settled card plus one stuck forever.
|
|
487
|
+
this.options.emitEvent?.({
|
|
488
|
+
type: "tool_execution_end",
|
|
489
|
+
toolCallId,
|
|
490
|
+
toolName: "todo",
|
|
491
|
+
result: { content: result.content, details: result.details },
|
|
492
|
+
isError: error !== null,
|
|
493
|
+
});
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
|
|
344
497
|
async mcp(call: CursorMcpCall) {
|
|
345
498
|
const toolName = call.toolName || call.name;
|
|
346
499
|
const toolCallId = decodeToolCallId(call.toolCallId);
|
package/src/dap/session.ts
CHANGED
|
@@ -960,16 +960,50 @@ export class DapSessionManager {
|
|
|
960
960
|
signal?: AbortSignal,
|
|
961
961
|
timeoutMs: number = 30_000,
|
|
962
962
|
): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> {
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
);
|
|
971
|
-
|
|
972
|
-
|
|
963
|
+
const anchor = this.#touchActiveSession();
|
|
964
|
+
// A js-debug launch is a session tree: the root may be a threadless
|
|
965
|
+
// launcher while each real thread lives in a child (main script,
|
|
966
|
+
// `[worker N]`, …), and other adapters keep every thread on the root.
|
|
967
|
+
// Querying only the active session would surface just one session's
|
|
968
|
+
// threads, so aggregate across the whole live tree. No topology guess:
|
|
969
|
+
// a threadless launcher simply returns no threads (or an error we skip).
|
|
970
|
+
const targets = this.#liveTreeSessions(anchor);
|
|
971
|
+
const merged: DapThread[] = [];
|
|
972
|
+
const seen = new Set<string>();
|
|
973
|
+
for (const target of targets) {
|
|
974
|
+
let threads: DapThread[];
|
|
975
|
+
try {
|
|
976
|
+
const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
|
|
977
|
+
target,
|
|
978
|
+
"threads",
|
|
979
|
+
undefined,
|
|
980
|
+
signal,
|
|
981
|
+
timeoutMs,
|
|
982
|
+
);
|
|
983
|
+
threads = response?.threads ?? [];
|
|
984
|
+
} catch (error) {
|
|
985
|
+
// Caller cancellation is not an adapter failure: propagate it instead
|
|
986
|
+
// of degrading a cancelled call into a successful partial result.
|
|
987
|
+
if (signal?.aborted) throw error;
|
|
988
|
+
logger.warn("Failed to list threads for debug session", {
|
|
989
|
+
sessionId: target.id,
|
|
990
|
+
error: toErrorMessage(error),
|
|
991
|
+
});
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
target.threads = threads;
|
|
995
|
+
// DAP thread IDs are scoped per client session, so identical IDs from
|
|
996
|
+
// different sessions (e.g. two identical worker scripts) are distinct
|
|
997
|
+
// live threads and MUST be preserved; only collapse an exact repeat
|
|
998
|
+
// within a single session's response.
|
|
999
|
+
for (const thread of threads) {
|
|
1000
|
+
const key = `${target.id}\0${thread.id}`;
|
|
1001
|
+
if (seen.has(key)) continue;
|
|
1002
|
+
seen.add(key);
|
|
1003
|
+
merged.push(thread);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return { snapshot: buildSummary(anchor), threads: merged };
|
|
973
1007
|
}
|
|
974
1008
|
|
|
975
1009
|
async stackTrace(
|
|
@@ -1371,7 +1405,13 @@ export class DapSessionManager {
|
|
|
1371
1405
|
if (parentSessionId) {
|
|
1372
1406
|
this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id);
|
|
1373
1407
|
}
|
|
1374
|
-
|
|
1408
|
+
// Focus follows stops, not registrations: a lazily-attached child (e.g. a
|
|
1409
|
+
// js-debug `[worker N]` session) must not steal focus from a sibling that
|
|
1410
|
+
// is already stopped at a breakpoint / entry. Only claim focus when no
|
|
1411
|
+
// live, stopped session currently holds it.
|
|
1412
|
+
if (!this.#hasLiveStoppedActiveSession()) {
|
|
1413
|
+
this.#activeSessionId = session.id;
|
|
1414
|
+
}
|
|
1375
1415
|
const heartbeat = setInterval(() => {
|
|
1376
1416
|
if (!client.isAlive()) {
|
|
1377
1417
|
session.status = "terminated";
|
|
@@ -1696,6 +1736,12 @@ export class DapSessionManager {
|
|
|
1696
1736
|
return session;
|
|
1697
1737
|
}
|
|
1698
1738
|
|
|
1739
|
+
/** True when the current active session is live and paused at a stop. */
|
|
1740
|
+
#hasLiveStoppedActiveSession(): boolean {
|
|
1741
|
+
const active = this.#getActiveSessionOrNull();
|
|
1742
|
+
return active !== null && active.status === "stopped" && active.client.isAlive();
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1699
1745
|
#getActiveSessionOrThrow(): DapSession {
|
|
1700
1746
|
const session = this.#getActiveSessionOrNull();
|
|
1701
1747
|
if (!session) {
|
|
@@ -1729,6 +1775,19 @@ export class DapSessionManager {
|
|
|
1729
1775
|
return sessions;
|
|
1730
1776
|
}
|
|
1731
1777
|
|
|
1778
|
+
/**
|
|
1779
|
+
* Live (non-terminated, connected) sessions in `session`'s tree, or the
|
|
1780
|
+
* session itself when the tree has collapsed. Used to fan `threads` out
|
|
1781
|
+
* across the whole tree; a threadless session just reports no threads, so
|
|
1782
|
+
* this makes no assumption about which node owns them.
|
|
1783
|
+
*/
|
|
1784
|
+
#liveTreeSessions(session: DapSession): DapSession[] {
|
|
1785
|
+
const live = this.#getTreeSessions(session).filter(
|
|
1786
|
+
candidate => candidate.status !== "terminated" && candidate.client.isAlive(),
|
|
1787
|
+
);
|
|
1788
|
+
return live.length > 0 ? live : [session];
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1732
1791
|
#touchSessionAndAncestors(session: DapSession): void {
|
|
1733
1792
|
const now = Date.now();
|
|
1734
1793
|
let current: DapSession | undefined = session;
|
|
@@ -426,10 +426,18 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation
|
|
|
426
426
|
expect(result.output.trim()).toBe("42");
|
|
427
427
|
});
|
|
428
428
|
|
|
429
|
-
it("keeps the isolated process alive after
|
|
429
|
+
it("keeps the isolated process alive after handled and stackless floated rejections", async () => {
|
|
430
430
|
using tempDir = TempDir.createSync("@omp-js-process-rejection-");
|
|
431
431
|
const session = makeSession(tempDir.path());
|
|
432
432
|
const evalSessionId = `js-rejection:${crypto.randomUUID()}`;
|
|
433
|
+
const handled = await executeJs('await Promise.reject("handled rejection").catch(() => undefined); return 42;', {
|
|
434
|
+
cwd: tempDir.path(),
|
|
435
|
+
sessionId: evalSessionId,
|
|
436
|
+
session,
|
|
437
|
+
});
|
|
438
|
+
expect(handled.exitCode).toBe(0);
|
|
439
|
+
expect(handled.output.trim()).toBe("42");
|
|
440
|
+
|
|
433
441
|
const rejected = await executeJs(
|
|
434
442
|
'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);',
|
|
435
443
|
{ cwd: tempDir.path(), sessionId: evalSessionId, session },
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
4
5
|
|
|
5
|
-
it("imports the
|
|
6
|
+
it("imports the CLI entry graph without loading dotenv before profile bootstrap", async () => {
|
|
6
7
|
using tempDir = TempDir.createSync("@omp-js-process-import-");
|
|
7
8
|
await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
|
|
8
9
|
const env = Object.fromEntries(
|
|
@@ -25,3 +26,112 @@ it("imports the JS process entry without loading dotenv before profile bootstrap
|
|
|
25
26
|
expect(stdout).toBe("");
|
|
26
27
|
expect(stderr).toBe("");
|
|
27
28
|
});
|
|
29
|
+
|
|
30
|
+
async function pingComputerWorker(
|
|
31
|
+
entry: string,
|
|
32
|
+
id: string,
|
|
33
|
+
argv: string[] = ["__omp_worker_computer"],
|
|
34
|
+
): Promise<unknown> {
|
|
35
|
+
const worker = new Worker(entry, {
|
|
36
|
+
type: "module",
|
|
37
|
+
argv,
|
|
38
|
+
});
|
|
39
|
+
const response = Promise.withResolvers<unknown>();
|
|
40
|
+
worker.addEventListener("message", event => response.resolve(event.data));
|
|
41
|
+
worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message)));
|
|
42
|
+
worker.postMessage({ type: "ping", id });
|
|
43
|
+
try {
|
|
44
|
+
return await response.promise;
|
|
45
|
+
} finally {
|
|
46
|
+
worker.terminate();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it("starts ordinary CLI paths without loading the native computer addon", async () => {
|
|
51
|
+
const cliPath = path.resolve(import.meta.dir, "../../cli.ts");
|
|
52
|
+
for (const args of [
|
|
53
|
+
["--no-addons", cliPath, "--version"],
|
|
54
|
+
[cliPath, "--help"],
|
|
55
|
+
]) {
|
|
56
|
+
const proc = Bun.spawn([process.execPath, ...args], {
|
|
57
|
+
stdout: "pipe",
|
|
58
|
+
stderr: "pipe",
|
|
59
|
+
});
|
|
60
|
+
const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
61
|
+
expect(exitCode, `${args.at(-1)}: ${stderr}`).toBe(0);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("dispatches the computer worker through the CLI host selector in a child process", async () => {
|
|
66
|
+
const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/computer-worker-cli-selector.ts");
|
|
67
|
+
const proc = Bun.spawn([process.execPath, "--no-addons", fixture], {
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
});
|
|
71
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
72
|
+
proc.exited,
|
|
73
|
+
new Response(proc.stdout).text(),
|
|
74
|
+
new Response(proc.stderr).text(),
|
|
75
|
+
]);
|
|
76
|
+
expect(exitCode, stderr).toBe(0);
|
|
77
|
+
expect(stdout).toBe('{"type":"pong","id":"computer-cli-selector"}\n');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("loads the computer worker module directly outside a declared CLI host", async () => {
|
|
81
|
+
const entry = new URL("../../tools/computer/worker-entry.ts", import.meta.url).href;
|
|
82
|
+
const response = await pingComputerWorker(entry, "computer-direct-module", []);
|
|
83
|
+
expect(response).toEqual({ type: "pong", id: "computer-direct-module" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("dispatches the computer worker from a single npm-style host bundle", async () => {
|
|
87
|
+
const packageDir = path.resolve(import.meta.dir, "../../..");
|
|
88
|
+
const outDir = fs.mkdtempSync(path.join(packageDir, ".computer-worker-bundle-"));
|
|
89
|
+
try {
|
|
90
|
+
const output = await Bun.build({
|
|
91
|
+
entrypoints: [path.join(packageDir, "test/fixtures/computer-worker-bundled-host.ts")],
|
|
92
|
+
outdir: outDir,
|
|
93
|
+
naming: "cli.js",
|
|
94
|
+
target: "bun",
|
|
95
|
+
external: ["@oh-my-pi/pi-natives"],
|
|
96
|
+
define: { "process.env.PI_BUNDLED": JSON.stringify("true") },
|
|
97
|
+
throw: false,
|
|
98
|
+
});
|
|
99
|
+
expect(output.logs).toEqual([]);
|
|
100
|
+
expect(output.outputs.map(file => path.basename(file.path))).toEqual(["cli.js"]);
|
|
101
|
+
const response = await pingComputerWorker(output.outputs[0]!.path, "computer-npm-bundle");
|
|
102
|
+
expect(response).toEqual({ type: "pong", id: "computer-npm-bundle" });
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps non-computer selectors isolated in a compiled single-entry worker host", async () => {
|
|
109
|
+
using tempDir = TempDir.createSync("@omp-compiled-worker-selector-");
|
|
110
|
+
const packageDir = path.resolve(import.meta.dir, "../../..");
|
|
111
|
+
const outfile = path.join(tempDir.path(), process.platform === "win32" ? "worker-host.exe" : "worker-host");
|
|
112
|
+
const build = Bun.spawn(
|
|
113
|
+
[
|
|
114
|
+
process.execPath,
|
|
115
|
+
"build",
|
|
116
|
+
"--compile",
|
|
117
|
+
"--target=bun",
|
|
118
|
+
`--outfile=${outfile}`,
|
|
119
|
+
path.join(packageDir, "test/fixtures/compiled-worker-selector-host.ts"),
|
|
120
|
+
],
|
|
121
|
+
{ cwd: packageDir, stdout: "pipe", stderr: "pipe" },
|
|
122
|
+
);
|
|
123
|
+
const [buildExitCode, buildStderr] = await Promise.all([build.exited, new Response(build.stderr).text()]);
|
|
124
|
+
expect(buildExitCode, buildStderr).toBe(0);
|
|
125
|
+
const proc = Bun.spawn([outfile], {
|
|
126
|
+
cwd: packageDir,
|
|
127
|
+
stdout: "pipe",
|
|
128
|
+
stderr: "pipe",
|
|
129
|
+
});
|
|
130
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
131
|
+
proc.exited,
|
|
132
|
+
new Response(proc.stdout).text(),
|
|
133
|
+
new Response(proc.stderr).text(),
|
|
134
|
+
]);
|
|
135
|
+
expect(exitCode, stderr).toBe(0);
|
|
136
|
+
expect(stdout).toBe('{"ok":true,"kind":"pong"}\n');
|
|
137
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { WorkerCore } from "./worker-core";
|
|
1
|
+
import { type RejectionInterceptor, WorkerCore } from "./worker-core";
|
|
2
2
|
import type { WorkerInbound, WorkerOutbound } from "./worker-protocol";
|
|
3
3
|
|
|
4
4
|
/** Start the JavaScript evaluator inside a subprocess IPC transport. */
|
|
5
|
-
export function startJsEvalProcess(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
export function startJsEvalProcess(
|
|
6
|
+
transport: {
|
|
7
|
+
send(message: WorkerOutbound): void;
|
|
8
|
+
onMessage(handler: (message: WorkerInbound) => void): () => void;
|
|
9
|
+
},
|
|
10
|
+
interceptUnhandledRejections: RejectionInterceptor,
|
|
11
|
+
): void {
|
|
9
12
|
new WorkerCore(
|
|
10
13
|
{
|
|
11
14
|
send: message => transport.send(message),
|
|
@@ -22,6 +25,7 @@ export function startJsEvalProcess(transport: {
|
|
|
22
25
|
// the project instead of the install dir. Worker threads cannot pass
|
|
23
26
|
// this — `process.chdir` is unavailable there.
|
|
24
27
|
chdir: cwd => process.chdir(cwd),
|
|
28
|
+
interceptUnhandledRejections,
|
|
25
29
|
},
|
|
26
30
|
);
|
|
27
31
|
}
|