@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -40
- package/dist/cli.js +4559 -4543
- package/dist/types/advisor/config.d.ts +14 -6
- package/dist/types/advisor/runtime.d.ts +20 -11
- package/dist/types/autolearn/controller.d.ts +1 -0
- package/dist/types/config/model-discovery.d.ts +17 -0
- package/dist/types/config/model-resolver.d.ts +6 -2
- package/dist/types/config/settings-schema.d.ts +39 -10
- package/dist/types/config/settings.d.ts +50 -0
- package/dist/types/cursor.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +5 -2
- package/dist/types/eval/agent-bridge.d.ts +7 -19
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +18 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/extensibility/utils.d.ts +2 -2
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +3 -0
- package/dist/types/mcp/transports/stdio.d.ts +42 -1
- package/dist/types/modes/components/advisor-config.d.ts +8 -1
- package/dist/types/modes/components/agent-hub.d.ts +15 -0
- package/dist/types/modes/components/hook-editor.d.ts +7 -0
- package/dist/types/modes/components/model-hub.d.ts +5 -2
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +8 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/print-mode.d.ts +1 -1
- package/dist/types/modes/warp-events.d.ts +24 -0
- package/dist/types/modes/warp-events.test.d.ts +1 -0
- package/dist/types/plan-mode/model-transition.d.ts +47 -0
- package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
- package/dist/types/registry/agent-lifecycle.d.ts +26 -1
- package/dist/types/registry/persisted-agents.d.ts +3 -0
- package/dist/types/sdk.d.ts +27 -5
- package/dist/types/session/agent-session.d.ts +34 -10
- package/dist/types/session/session-entries.d.ts +6 -1
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-manager.d.ts +19 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -1
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/label.d.ts +1 -1
- package/dist/types/task/parallel.d.ts +14 -0
- package/dist/types/task/structured-subagent.d.ts +111 -0
- package/dist/types/task/types.d.ts +51 -0
- package/dist/types/telemetry-export.d.ts +34 -9
- package/dist/types/tools/approval.d.ts +8 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/essential-tools.d.ts +29 -0
- package/dist/types/tools/fetch.d.ts +4 -5
- package/dist/types/tools/hub/messaging.d.ts +5 -1
- package/dist/types/tools/image-gen.d.ts +2 -1
- package/dist/types/tools/index.d.ts +17 -1
- package/dist/types/tools/todo.d.ts +31 -0
- package/dist/types/tools/xdev.d.ts +11 -2
- package/dist/types/tui/tree-list.d.ts +7 -0
- package/dist/types/utils/markit.d.ts +11 -0
- package/dist/types/utils/title-generator.d.ts +2 -1
- package/dist/types/web/search/providers/kimi.d.ts +4 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +21 -16
- package/src/advisor/__tests__/advisor.test.ts +1304 -42
- package/src/advisor/__tests__/config.test.ts +58 -2
- package/src/advisor/config.ts +76 -24
- package/src/advisor/runtime.ts +445 -92
- package/src/autolearn/controller.ts +23 -28
- package/src/cli/file-processor.ts +1 -2
- package/src/cli.ts +5 -1
- package/src/config/model-discovery.ts +81 -21
- package/src/config/model-registry.ts +25 -6
- package/src/config/model-resolver.ts +14 -7
- package/src/config/settings-schema.ts +46 -9
- package/src/config/settings.ts +405 -25
- package/src/cursor.ts +20 -3
- package/src/debug/report-bundle.ts +40 -4
- package/src/discovery/helpers.ts +28 -5
- package/src/eval/__tests__/agent-bridge.test.ts +133 -47
- package/src/eval/__tests__/prelude-agent.test.ts +29 -0
- package/src/eval/agent-bridge.ts +104 -477
- package/src/eval/jl/prelude.jl +7 -6
- package/src/eval/js/shared/prelude.txt +5 -4
- package/src/eval/py/prelude.py +11 -39
- package/src/eval/rb/prelude.rb +5 -6
- package/src/exec/bash-executor.ts +14 -5
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/extensibility/extensions/loader.ts +3 -3
- package/src/extensibility/extensions/managed-timers.ts +83 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/types.ts +18 -0
- package/src/extensibility/extensions/wrapper.ts +2 -1
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
- package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
- package/src/extensibility/plugins/manager.ts +2 -2
- package/src/extensibility/shared-events.ts +6 -0
- package/src/extensibility/utils.ts +91 -25
- package/src/irc/bus.ts +22 -3
- package/src/launch/broker.ts +3 -2
- package/src/launch/client.ts +2 -2
- package/src/launch/presence.ts +2 -2
- package/src/lsp/client.ts +58 -1
- package/src/lsp/index.ts +62 -6
- package/src/lsp/types.ts +3 -0
- package/src/main.ts +11 -8
- package/src/mcp/manager.ts +9 -3
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/mcp/transports/stdio.test.ts +269 -1
- package/src/mcp/transports/stdio.ts +255 -24
- package/src/modes/components/advisor-config.ts +65 -3
- package/src/modes/components/agent-hub.ts +1 -72
- package/src/modes/components/ask-dialog.ts +1 -1
- package/src/modes/components/bash-execution.ts +7 -3
- package/src/modes/components/eval-execution.ts +3 -1
- package/src/modes/components/hook-editor.ts +18 -3
- package/src/modes/components/model-hub.ts +138 -42
- package/src/modes/components/session-selector.ts +4 -0
- package/src/modes/components/status-line/component.test.ts +1 -0
- package/src/modes/components/status-line/segments.ts +21 -6
- package/src/modes/controllers/command-controller.ts +167 -47
- package/src/modes/controllers/event-controller.ts +5 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -22
- package/src/modes/controllers/input-controller.ts +12 -12
- package/src/modes/controllers/selector-controller.ts +191 -31
- package/src/modes/interactive-mode.ts +169 -62
- package/src/modes/print-mode.ts +3 -3
- package/src/modes/rpc/host-tools.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +19 -4
- package/src/modes/warp-events.test.ts +794 -0
- package/src/modes/warp-events.ts +232 -0
- package/src/plan-mode/model-transition.test.ts +60 -0
- package/src/plan-mode/model-transition.ts +51 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/task.md +12 -8
- package/src/prompts/tools/write.md +1 -1
- package/src/registry/agent-lifecycle.ts +133 -18
- package/src/registry/persisted-agents.ts +74 -0
- package/src/sdk.ts +343 -122
- package/src/session/agent-session.ts +1359 -368
- package/src/session/session-entries.ts +6 -1
- package/src/session/session-history-format.ts +20 -5
- package/src/session/session-manager.ts +57 -0
- package/src/session/streaming-output.ts +41 -1
- package/src/slash-commands/builtin-registry.ts +7 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +100 -22
- package/src/task/index.ts +258 -429
- package/src/task/label.ts +2 -0
- package/src/task/parallel.ts +43 -0
- package/src/task/persisted-revive.ts +19 -7
- package/src/task/structured-subagent.ts +642 -0
- package/src/task/types.ts +58 -0
- package/src/telemetry-export.ts +453 -97
- package/src/tools/__tests__/eval-description.test.ts +1 -1
- package/src/tools/approval.ts +11 -0
- package/src/tools/bash.ts +71 -38
- package/src/tools/essential-tools.ts +45 -0
- package/src/tools/fetch.ts +28 -105
- package/src/tools/gh.ts +169 -2
- package/src/tools/hub/messaging.ts +16 -3
- package/src/tools/image-gen.ts +69 -7
- package/src/tools/index.ts +50 -15
- package/src/tools/path-utils.ts +1 -0
- package/src/tools/read.ts +62 -29
- package/src/tools/todo.ts +126 -13
- package/src/tools/write.ts +22 -4
- package/src/tools/xdev.ts +14 -3
- package/src/tui/tree-list.ts +39 -0
- package/src/utils/markit.ts +12 -0
- package/src/utils/title-generator.ts +15 -4
- package/src/utils/zip.ts +16 -1
- package/src/web/search/providers/kimi.ts +18 -12
- package/src/web/search/types.ts +6 -1
package/src/config/settings.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
setWorktreesDir,
|
|
28
28
|
} from "@oh-my-pi/pi-utils";
|
|
29
29
|
import { JSONC, YAML } from "bun";
|
|
30
|
+
import { invalidate as invalidateCapabilityFsCache } from "../capability/fs";
|
|
30
31
|
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
|
|
31
32
|
import type { ModelRole } from "../config/model-roles";
|
|
32
33
|
import { loadCapability } from "../discovery";
|
|
@@ -172,6 +173,83 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
172
173
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Migrate a v17 leaf rename that used to nest under a boolean parent path
|
|
178
|
+
* (`dev.autoqa.consent` → `dev.autoqaConsent`, `todo.reminders.max` →
|
|
179
|
+
* `todo.remindersMax`). Pre-rename configs left the leaf beneath the parent,
|
|
180
|
+
* so the parent path resolved to an object and truthy checks like
|
|
181
|
+
* `isAutoQaEnabled` treated a consent-only container as "enabled".
|
|
182
|
+
*
|
|
183
|
+
* Handles nested (`{ parent: { leaf } }`) and quoted-dotted (`"parent.leaf"`)
|
|
184
|
+
* legacy sources. An explicit new key always wins; a separately configured
|
|
185
|
+
* boolean parent is preserved; an irrecoverable object-valued parent (only ever
|
|
186
|
+
* a container for the old leaf) is dropped so the schema default applies.
|
|
187
|
+
*/
|
|
188
|
+
function migrateNestedLeafRename(
|
|
189
|
+
raw: RawSettings,
|
|
190
|
+
root: string,
|
|
191
|
+
parent: string,
|
|
192
|
+
oldLeaf: string,
|
|
193
|
+
newLeaf: string,
|
|
194
|
+
isLeafValue: (value: unknown) => boolean,
|
|
195
|
+
): void {
|
|
196
|
+
const rootObj = isRecord(raw[root]) ? (raw[root] as Record<string, unknown>) : undefined;
|
|
197
|
+
const nestedParent = rootObj?.[parent];
|
|
198
|
+
const flatParent = raw[`${root}.${parent}`];
|
|
199
|
+
const oldParentPath = `${root}.${parent}`;
|
|
200
|
+
|
|
201
|
+
const candidates = [
|
|
202
|
+
rootObj?.[newLeaf],
|
|
203
|
+
raw[`${root}.${newLeaf}`],
|
|
204
|
+
isRecord(nestedParent) ? nestedParent[oldLeaf] : undefined,
|
|
205
|
+
raw[`${oldParentPath}.${oldLeaf}`],
|
|
206
|
+
];
|
|
207
|
+
const resolvedLeaf = candidates.find(isLeafValue);
|
|
208
|
+
|
|
209
|
+
const recoveredParent =
|
|
210
|
+
typeof nestedParent === "boolean" ? nestedParent : typeof flatParent === "boolean" ? flatParent : undefined;
|
|
211
|
+
|
|
212
|
+
const ensureRoot = (): Record<string, unknown> => {
|
|
213
|
+
const current = raw[root];
|
|
214
|
+
if (isRecord(current)) return current;
|
|
215
|
+
const created: Record<string, unknown> = {};
|
|
216
|
+
raw[root] = created;
|
|
217
|
+
return created;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (resolvedLeaf !== undefined) {
|
|
221
|
+
const target = ensureRoot();
|
|
222
|
+
if (!isLeafValue(target[newLeaf])) {
|
|
223
|
+
target[newLeaf] = resolvedLeaf;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Strip legacy leaf sources (nested + flat dotted).
|
|
228
|
+
delete raw[`${oldParentPath}.${oldLeaf}`];
|
|
229
|
+
delete raw[`${root}.${newLeaf}`];
|
|
230
|
+
if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
|
|
231
|
+
const parentObj = (raw[root] as Record<string, unknown>)[parent] as Record<string, unknown>;
|
|
232
|
+
delete parentObj[oldLeaf];
|
|
233
|
+
if (Object.keys(parentObj).length === 0) {
|
|
234
|
+
delete (raw[root] as Record<string, unknown>)[parent];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The parent path must be a boolean or absent — never a leftover object.
|
|
239
|
+
if (recoveredParent !== undefined) {
|
|
240
|
+
const target = ensureRoot();
|
|
241
|
+
if (typeof target[parent] !== "boolean") {
|
|
242
|
+
target[parent] = recoveredParent;
|
|
243
|
+
}
|
|
244
|
+
} else if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
|
|
245
|
+
delete (raw[root] as Record<string, unknown>)[parent];
|
|
246
|
+
}
|
|
247
|
+
delete raw[oldParentPath];
|
|
248
|
+
if (isRecord(raw[root]) && Object.keys(raw[root] as Record<string, unknown>).length === 0) {
|
|
249
|
+
delete raw[root];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
175
253
|
function modelRoleValueFromUnknown(value: unknown): string | undefined {
|
|
176
254
|
if (typeof value === "string") return value;
|
|
177
255
|
if (!Array.isArray(value)) return undefined;
|
|
@@ -250,6 +328,16 @@ export class Settings {
|
|
|
250
328
|
|
|
251
329
|
/** Paths modified during this session (for partial save) */
|
|
252
330
|
#modified = new Set<string>();
|
|
331
|
+
/** Individual project model roles modified during this session */
|
|
332
|
+
#modifiedProjectModelRoles = new Set<string>();
|
|
333
|
+
/**
|
|
334
|
+
* Original process-wide model-role overrides captured before a project edit
|
|
335
|
+
* temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
|
|
336
|
+
* on `reloadForCwd` / `cloneForCwd` so destination projects never inherit the
|
|
337
|
+
* source-project value. Maps role → original override value (`undefined`
|
|
338
|
+
* when the role had no runtime override).
|
|
339
|
+
*/
|
|
340
|
+
#savedRuntimeModelRoleOverrides = new Map<string, string | undefined>();
|
|
253
341
|
|
|
254
342
|
/** Legacy `lastChangelogVersion` captured from config.yml during migration (now a marker file). */
|
|
255
343
|
#legacyLastChangelogVersion?: string;
|
|
@@ -257,6 +345,8 @@ export class Settings {
|
|
|
257
345
|
/** Pending save (debounced) */
|
|
258
346
|
#saveTimer?: NodeJS.Timeout;
|
|
259
347
|
#savePromise?: Promise<void>;
|
|
348
|
+
#projectSaveTimer?: NodeJS.Timeout;
|
|
349
|
+
#projectSavePromise?: Promise<void>;
|
|
260
350
|
|
|
261
351
|
/** Whether to persist changes */
|
|
262
352
|
#persist: boolean;
|
|
@@ -400,6 +490,9 @@ export class Settings {
|
|
|
400
490
|
* Apply runtime overrides (not persisted).
|
|
401
491
|
*/
|
|
402
492
|
override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
|
|
493
|
+
if (path === "modelRoles") {
|
|
494
|
+
this.#savedRuntimeModelRoleOverrides.clear();
|
|
495
|
+
}
|
|
403
496
|
const prev = this.get(path);
|
|
404
497
|
const segments = path.split(".");
|
|
405
498
|
setByPath(this.#overrides, segments, value);
|
|
@@ -411,6 +504,9 @@ export class Settings {
|
|
|
411
504
|
* Clear a runtime override.
|
|
412
505
|
*/
|
|
413
506
|
clearOverride(path: SettingPath): void {
|
|
507
|
+
if (path === "modelRoles") {
|
|
508
|
+
this.#savedRuntimeModelRoleOverrides.clear();
|
|
509
|
+
}
|
|
414
510
|
const prev = this.get(path);
|
|
415
511
|
const segments = path.split(".");
|
|
416
512
|
let current = this.#overrides;
|
|
@@ -443,12 +539,22 @@ export class Settings {
|
|
|
443
539
|
clearTimeout(this.#saveTimer);
|
|
444
540
|
this.#saveTimer = undefined;
|
|
445
541
|
}
|
|
542
|
+
if (this.#projectSaveTimer) {
|
|
543
|
+
clearTimeout(this.#projectSaveTimer);
|
|
544
|
+
this.#projectSaveTimer = undefined;
|
|
545
|
+
}
|
|
446
546
|
if (this.#savePromise) {
|
|
447
547
|
await this.#savePromise;
|
|
448
548
|
}
|
|
549
|
+
if (this.#projectSavePromise) {
|
|
550
|
+
await this.#projectSavePromise;
|
|
551
|
+
}
|
|
449
552
|
if (this.#modified.size > 0) {
|
|
450
553
|
await this.#saveNow();
|
|
451
554
|
}
|
|
555
|
+
if (this.#modifiedProjectModelRoles.size > 0) {
|
|
556
|
+
await this.#saveProjectNow();
|
|
557
|
+
}
|
|
452
558
|
}
|
|
453
559
|
|
|
454
560
|
async cloneForCwd(cwd: string): Promise<Settings> {
|
|
@@ -463,7 +569,7 @@ export class Settings {
|
|
|
463
569
|
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
|
464
570
|
cloned.#configFiles = [...this.#configFiles];
|
|
465
571
|
cloned.#configOverlay = structuredClone(this.#configOverlay);
|
|
466
|
-
cloned.#overrides =
|
|
572
|
+
cloned.#overrides = this.#buildOriginalOverrides();
|
|
467
573
|
cloned.#rebuildMerged();
|
|
468
574
|
cloned.#fireAllHooks();
|
|
469
575
|
return cloned;
|
|
@@ -484,6 +590,8 @@ export class Settings {
|
|
|
484
590
|
async reloadForCwd(cwd: string): Promise<void> {
|
|
485
591
|
const normalized = path.normalize(cwd);
|
|
486
592
|
if (normalized === this.#cwd) return;
|
|
593
|
+
await this.flush();
|
|
594
|
+
this.#restoreRuntimeModelRoleOverrides();
|
|
487
595
|
const prevModelRoles = this.get("modelRoles");
|
|
488
596
|
this.#cwd = normalized;
|
|
489
597
|
if (this.#persist) {
|
|
@@ -602,35 +710,168 @@ export class Settings {
|
|
|
602
710
|
return roles;
|
|
603
711
|
}
|
|
604
712
|
|
|
713
|
+
#modelRoleLayerOwns(layer: RawSettings, role: ModelRole | string): boolean {
|
|
714
|
+
const value = getByPath(layer, ["modelRoles"]);
|
|
715
|
+
if (!isRecord(value)) return false;
|
|
716
|
+
return Object.hasOwn(value, role);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Set the full `modelRoles` map on the runtime override layer without
|
|
721
|
+
* routing through the public {@link override} method. Internal callers
|
|
722
|
+
* (project edits, global fallback updates) use this so they can control
|
|
723
|
+
* capture invalidation independently of the whole-map replacement
|
|
724
|
+
* semantics that `override("modelRoles", …)` carries.
|
|
725
|
+
*/
|
|
726
|
+
#setRuntimeModelRoleOverrides(next: Record<string, string>): void {
|
|
727
|
+
const prev = this.get("modelRoles");
|
|
728
|
+
setByPath(this.#overrides, ["modelRoles"], next);
|
|
729
|
+
this.#rebuildMerged();
|
|
730
|
+
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#updateRuntimeModelRoleOverride(role: ModelRole | string, modelId: string | undefined): void {
|
|
734
|
+
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
|
|
735
|
+
if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
|
|
736
|
+
|
|
737
|
+
const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
|
|
738
|
+
if (modelId === undefined) {
|
|
739
|
+
delete nextRuntimeOverride[role];
|
|
740
|
+
} else {
|
|
741
|
+
nextRuntimeOverride[role] = modelId;
|
|
742
|
+
}
|
|
743
|
+
this.#setRuntimeModelRoleOverrides(nextRuntimeOverride);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Capture the original process-wide override for `role` the first time a
|
|
748
|
+
* project edit temporarily replaces it, so the original can be restored on
|
|
749
|
+
* cwd changes. Subsequent edits in the same cwd must not overwrite the
|
|
750
|
+
* first captured value.
|
|
751
|
+
*/
|
|
752
|
+
#captureRuntimeModelRoleOverride(role: ModelRole | string): void {
|
|
753
|
+
if (this.#savedRuntimeModelRoleOverrides.has(role)) return;
|
|
754
|
+
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
|
|
755
|
+
if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
|
|
756
|
+
this.#savedRuntimeModelRoleOverrides.set(role, this.#modelRolesFromLayer(this.#overrides)[role]);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Restore original process-wide model-role overrides that were temporarily
|
|
761
|
+
* replaced by project edits, mutating `#overrides` in place without
|
|
762
|
+
* rebuilding. All remaining captures are valid because superseding
|
|
763
|
+
* operations (late `overrideModelRoles`, global-mode `setModelRole`,
|
|
764
|
+
* whole-map `override`/`clearOverride`) invalidate the affected captures
|
|
765
|
+
* at the point of supersession. Caller is responsible for `#rebuildMerged()`.
|
|
766
|
+
*/
|
|
767
|
+
#restoreRuntimeModelRoleOverrides(): void {
|
|
768
|
+
if (this.#savedRuntimeModelRoleOverrides.size === 0) return;
|
|
769
|
+
const runtimeRoles = getByPath(this.#overrides, ["modelRoles"]);
|
|
770
|
+
if (!isRecord(runtimeRoles)) {
|
|
771
|
+
this.#savedRuntimeModelRoleOverrides.clear();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
|
|
775
|
+
if (originalValue === undefined) {
|
|
776
|
+
delete runtimeRoles[role];
|
|
777
|
+
} else {
|
|
778
|
+
runtimeRoles[role] = originalValue;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
this.#savedRuntimeModelRoleOverrides.clear();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Produce a deep copy of `#overrides` with original process-wide model-role
|
|
786
|
+
* overrides restored, for use by {@link cloneForCwd}. All remaining
|
|
787
|
+
* captures are valid (see {@link #restoreRuntimeModelRoleOverrides}).
|
|
788
|
+
* Does not mutate the current instance's `#overrides`.
|
|
789
|
+
*/
|
|
790
|
+
#buildOriginalOverrides(): RawSettings {
|
|
791
|
+
if (this.#savedRuntimeModelRoleOverrides.size === 0) {
|
|
792
|
+
return structuredClone(this.#overrides);
|
|
793
|
+
}
|
|
794
|
+
const overrides = structuredClone(this.#overrides);
|
|
795
|
+
const runtimeRoles = getByPath(overrides, ["modelRoles"]);
|
|
796
|
+
if (!isRecord(runtimeRoles)) return overrides;
|
|
797
|
+
for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
|
|
798
|
+
if (originalValue === undefined) {
|
|
799
|
+
delete runtimeRoles[role];
|
|
800
|
+
} else {
|
|
801
|
+
runtimeRoles[role] = originalValue;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return overrides;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
#setProjectModelRoleValue(role: ModelRole | string, modelId: string | null): void {
|
|
808
|
+
const prev = this.get("modelRoles");
|
|
809
|
+
const projectRoles = getByPath(this.#project, ["modelRoles"]);
|
|
810
|
+
const current: Record<string, unknown> = isRecord(projectRoles) ? { ...projectRoles } : {};
|
|
811
|
+
current[role] = modelId;
|
|
812
|
+
setByPath(this.#project, ["modelRoles"], current);
|
|
813
|
+
this.#modifiedProjectModelRoles.add(role);
|
|
814
|
+
this.#rebuildMerged();
|
|
815
|
+
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
|
|
816
|
+
this.#queueProjectSave();
|
|
817
|
+
}
|
|
818
|
+
|
|
605
819
|
/**
|
|
606
820
|
* Set a model role (helper for modelRoles record). Passing `undefined`
|
|
607
821
|
* clears the role from the persisted record and any runtime override.
|
|
822
|
+
*
|
|
823
|
+
* In project storage mode, when a project edit has temporarily replaced
|
|
824
|
+
* the process-wide runtime override for `role` and that override is still
|
|
825
|
+
* active (the runtime slot currently matches the project value), the
|
|
826
|
+
* global-layer write must not rewrite that runtime slot — otherwise the
|
|
827
|
+
* global fallback would immediately shadow the still-configured project
|
|
828
|
+
* role. The global layer is still persisted; only the runtime override is
|
|
829
|
+
* left untouched. The guard is precise so that a later clear, a late
|
|
830
|
+
* `overrideModelRoles`, or a storage-mode transition does not leave a
|
|
831
|
+
* stale skip in place.
|
|
608
832
|
*/
|
|
609
833
|
setModelRole(role: ModelRole | string, modelId: string | undefined): void {
|
|
610
834
|
const current = this.#modelRolesFromLayer(this.#global);
|
|
611
|
-
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
|
|
612
|
-
const updateRuntimeOverride =
|
|
613
|
-
!!runtimeOverrides &&
|
|
614
|
-
typeof runtimeOverrides === "object" &&
|
|
615
|
-
!Array.isArray(runtimeOverrides) &&
|
|
616
|
-
Object.hasOwn(runtimeOverrides, role);
|
|
617
|
-
|
|
618
835
|
if (modelId === undefined) {
|
|
619
836
|
delete current[role];
|
|
620
837
|
} else {
|
|
621
838
|
current[role] = modelId;
|
|
622
839
|
}
|
|
623
840
|
this.set("modelRoles", current);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
|
|
627
|
-
if (modelId === undefined) {
|
|
628
|
-
delete nextRuntimeOverride[role];
|
|
629
|
-
} else {
|
|
630
|
-
nextRuntimeOverride[role] = modelId;
|
|
631
|
-
}
|
|
632
|
-
this.override("modelRoles", nextRuntimeOverride);
|
|
841
|
+
if (this.isProjectModelRoleRuntimeOverrideActive(role)) {
|
|
842
|
+
return;
|
|
633
843
|
}
|
|
844
|
+
this.#savedRuntimeModelRoleOverrides.delete(role);
|
|
845
|
+
this.#updateRuntimeModelRoleOverride(role, modelId);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Whether `role`'s runtime override slot currently holds the temporary
|
|
850
|
+
* project-scoped value installed by a prior `setProjectModelRole`. Returns
|
|
851
|
+
* `false` when storage is not project-mode, no capture exists, or the
|
|
852
|
+
* project role was cleared. With explicit provenance invalidation, a
|
|
853
|
+
* surviving capture implies no external supersession occurred.
|
|
854
|
+
*/
|
|
855
|
+
isProjectModelRoleRuntimeOverrideActive(role: ModelRole | string): boolean {
|
|
856
|
+
if (this.get("modelRoleStorage") !== "project") return false;
|
|
857
|
+
if (!this.#savedRuntimeModelRoleOverrides.has(role)) return false;
|
|
858
|
+
return !!this.getProjectModelRole(role);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Set a model role in the current project's settings layer.
|
|
862
|
+
*/
|
|
863
|
+
setProjectModelRole(role: ModelRole | string, modelId: string): void {
|
|
864
|
+
this.#setProjectModelRoleValue(role, modelId);
|
|
865
|
+
this.#captureRuntimeModelRoleOverride(role);
|
|
866
|
+
this.#updateRuntimeModelRoleOverride(role, modelId);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Clear a model role from the current project's settings layer.
|
|
870
|
+
*/
|
|
871
|
+
clearProjectModelRole(role: ModelRole | string): void {
|
|
872
|
+
this.#setProjectModelRoleValue(role, null);
|
|
873
|
+
this.#captureRuntimeModelRoleOverride(role);
|
|
874
|
+
this.#updateRuntimeModelRoleOverride(role, undefined);
|
|
634
875
|
}
|
|
635
876
|
|
|
636
877
|
/**
|
|
@@ -641,6 +882,49 @@ export class Settings {
|
|
|
641
882
|
if (!isRecord(roles)) return undefined;
|
|
642
883
|
return modelRoleValueFromUnknown(roles[role]);
|
|
643
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* Get a model role from only the global settings layer.
|
|
887
|
+
*/
|
|
888
|
+
getGlobalModelRole(role: ModelRole | string): string | undefined {
|
|
889
|
+
const modelId = this.#modelRolesFromLayer(this.#global)[role];
|
|
890
|
+
return modelId || undefined;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Get a model role from only the current project settings layer.
|
|
895
|
+
*/
|
|
896
|
+
getProjectModelRole(role: ModelRole | string): string | undefined {
|
|
897
|
+
const modelId = this.#modelRolesFromLayer(this.#project)[role];
|
|
898
|
+
return modelId || undefined;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Report which layer actually supplies the effective model role across
|
|
903
|
+
* full merge precedence (runtime override → config overlay → project →
|
|
904
|
+
* global → default). Unlike {@link getModelRoleSource}, this accounts
|
|
905
|
+
* for runtime and config-overlay layers and detects ownership by key
|
|
906
|
+
* presence rather than normalized value, so a `null` tombstone in the
|
|
907
|
+
* overlay or runtime layer correctly blocks lower layers. The project
|
|
908
|
+
* layer is checked through {@link #projectSettingsForMerge} because a
|
|
909
|
+
* project null is a cleared value (falls back to global), not a
|
|
910
|
+
* tombstone.
|
|
911
|
+
*/
|
|
912
|
+
getModelRoleProvenance(role: ModelRole | string): "runtime" | "overlay" | "project" | "global" | "default" {
|
|
913
|
+
if (this.#modelRoleLayerOwns(this.#overrides, role)) return "runtime";
|
|
914
|
+
if (this.#modelRoleLayerOwns(this.#configOverlay, role)) return "overlay";
|
|
915
|
+
if (this.#modelRoleLayerOwns(this.#projectSettingsForMerge(), role)) return "project";
|
|
916
|
+
if (this.#modelRoleLayerOwns(this.#global, role)) return "global";
|
|
917
|
+
return "default";
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Get the persisted layer supplying a model role (project/global/default only).
|
|
922
|
+
*/
|
|
923
|
+
getModelRoleSource(role: ModelRole | string): "project" | "global" | "default" {
|
|
924
|
+
if (this.getProjectModelRole(role)) return "project";
|
|
925
|
+
if (this.getGlobalModelRole(role)) return "global";
|
|
926
|
+
return "default";
|
|
927
|
+
}
|
|
644
928
|
|
|
645
929
|
/**
|
|
646
930
|
* Get all model roles (helper for modelRoles record).
|
|
@@ -668,9 +952,10 @@ export class Settings {
|
|
|
668
952
|
for (const [role, modelId] of Object.entries(roles)) {
|
|
669
953
|
if (modelId) {
|
|
670
954
|
next[role] = modelId;
|
|
955
|
+
this.#savedRuntimeModelRoleOverrides.delete(role);
|
|
671
956
|
}
|
|
672
957
|
}
|
|
673
|
-
this
|
|
958
|
+
this.#setRuntimeModelRoleOverrides(next);
|
|
674
959
|
}
|
|
675
960
|
|
|
676
961
|
/**
|
|
@@ -778,6 +1063,11 @@ export class Settings {
|
|
|
778
1063
|
merged = this.#deepMerge(merged, item.data as RawSettings);
|
|
779
1064
|
}
|
|
780
1065
|
}
|
|
1066
|
+
const nativeProject = await this.#loadYaml(path.join(this.#cwd, ".omp", "config.yml"));
|
|
1067
|
+
const nativeModelRoles = getByPath(nativeProject, ["modelRoles"]);
|
|
1068
|
+
if (nativeModelRoles !== undefined) {
|
|
1069
|
+
merged = this.#deepMerge(merged, { modelRoles: nativeModelRoles });
|
|
1070
|
+
}
|
|
781
1071
|
return this.#migrateRawSettings(merged);
|
|
782
1072
|
} catch {
|
|
783
1073
|
return {};
|
|
@@ -1253,6 +1543,26 @@ export class Settings {
|
|
|
1253
1543
|
if (tierTouched) raw.tier = tierObj;
|
|
1254
1544
|
delete raw.fastModeScope;
|
|
1255
1545
|
|
|
1546
|
+
// v17 renames that used to nest under a boolean parent path:
|
|
1547
|
+
// dev.autoqa.consent -> dev.autoqaConsent
|
|
1548
|
+
// todo.reminders.max -> todo.remindersMax
|
|
1549
|
+
migrateNestedLeafRename(
|
|
1550
|
+
raw,
|
|
1551
|
+
"dev",
|
|
1552
|
+
"autoqa",
|
|
1553
|
+
"consent",
|
|
1554
|
+
"autoqaConsent",
|
|
1555
|
+
value => value === "unset" || value === "granted" || value === "denied",
|
|
1556
|
+
);
|
|
1557
|
+
migrateNestedLeafRename(
|
|
1558
|
+
raw,
|
|
1559
|
+
"todo",
|
|
1560
|
+
"reminders",
|
|
1561
|
+
"max",
|
|
1562
|
+
"remindersMax",
|
|
1563
|
+
value => typeof value === "number" && Number.isFinite(value),
|
|
1564
|
+
);
|
|
1565
|
+
|
|
1256
1566
|
// BM25 tool discovery removal: tools.discoveryMode / tools.essentialOverride /
|
|
1257
1567
|
// mcp.discoveryMode / mcp.discoveryDefaultServers are gone with no
|
|
1258
1568
|
// replacement (`tools.xdev` stays at its own default). Dead keys are
|
|
@@ -1304,14 +1614,20 @@ export class Settings {
|
|
|
1304
1614
|
if (!this.#persist || !this.#configPath) return;
|
|
1305
1615
|
|
|
1306
1616
|
// Debounce: wait 100ms for more changes
|
|
1307
|
-
|
|
1308
|
-
clearTimeout(this.#saveTimer);
|
|
1309
|
-
}
|
|
1617
|
+
clearTimeout(this.#saveTimer);
|
|
1310
1618
|
this.#saveTimer = setTimeout(() => {
|
|
1311
1619
|
this.#saveTimer = undefined;
|
|
1312
|
-
this.#saveNow()
|
|
1313
|
-
|
|
1314
|
-
|
|
1620
|
+
const savePromise = this.#saveNow();
|
|
1621
|
+
this.#savePromise = savePromise;
|
|
1622
|
+
savePromise
|
|
1623
|
+
.catch(err => {
|
|
1624
|
+
logger.warn("Settings: background save failed", { error: String(err) });
|
|
1625
|
+
})
|
|
1626
|
+
.finally(() => {
|
|
1627
|
+
if (this.#savePromise === savePromise) {
|
|
1628
|
+
this.#savePromise = undefined;
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1315
1631
|
}, 100);
|
|
1316
1632
|
}
|
|
1317
1633
|
|
|
@@ -1348,13 +1664,77 @@ export class Settings {
|
|
|
1348
1664
|
|
|
1349
1665
|
this.#rebuildMerged();
|
|
1350
1666
|
}
|
|
1667
|
+
#queueProjectSave(): void {
|
|
1668
|
+
if (!this.#persist) return;
|
|
1669
|
+
|
|
1670
|
+
clearTimeout(this.#projectSaveTimer);
|
|
1671
|
+
this.#projectSaveTimer = setTimeout(() => {
|
|
1672
|
+
this.#projectSaveTimer = undefined;
|
|
1673
|
+
const savePromise = this.#saveProjectNow();
|
|
1674
|
+
this.#projectSavePromise = savePromise;
|
|
1675
|
+
savePromise
|
|
1676
|
+
.catch(err => {
|
|
1677
|
+
logger.warn("Settings: background project save failed", { error: String(err) });
|
|
1678
|
+
})
|
|
1679
|
+
.finally(() => {
|
|
1680
|
+
if (this.#projectSavePromise === savePromise) {
|
|
1681
|
+
this.#projectSavePromise = undefined;
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
}, 100);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async #saveProjectNow(): Promise<void> {
|
|
1688
|
+
if (!this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
|
|
1689
|
+
|
|
1690
|
+
const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
|
|
1691
|
+
const modifiedModelRoles = [...this.#modifiedProjectModelRoles];
|
|
1692
|
+
this.#modifiedProjectModelRoles.clear();
|
|
1693
|
+
|
|
1694
|
+
try {
|
|
1695
|
+
await fs.promises.mkdir(path.dirname(projectConfigPath), { recursive: true });
|
|
1696
|
+
await withFileLock(projectConfigPath, async () => {
|
|
1697
|
+
const projectSettings = await this.#loadYaml(projectConfigPath);
|
|
1698
|
+
|
|
1699
|
+
const projectRoles = getByPath(this.#project, ["modelRoles"]);
|
|
1700
|
+
for (const role of modifiedModelRoles) {
|
|
1701
|
+
const value = isRecord(projectRoles) ? projectRoles[role] : undefined;
|
|
1702
|
+
setByPath(projectSettings, ["modelRoles", role], value);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
await Bun.write(projectConfigPath, YAML.stringify(projectSettings, null, 2));
|
|
1706
|
+
});
|
|
1707
|
+
invalidateCapabilityFsCache(projectConfigPath);
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
for (const role of modifiedModelRoles) {
|
|
1710
|
+
this.#modifiedProjectModelRoles.add(role);
|
|
1711
|
+
}
|
|
1712
|
+
throw error;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
this.#rebuildMerged();
|
|
1716
|
+
}
|
|
1351
1717
|
|
|
1352
1718
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1353
1719
|
// Utilities
|
|
1354
1720
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1355
1721
|
|
|
1722
|
+
#projectSettingsForMerge(): RawSettings {
|
|
1723
|
+
const projectRoles = getByPath(this.#project, ["modelRoles"]);
|
|
1724
|
+
if (!isRecord(projectRoles)) return this.#project;
|
|
1725
|
+
|
|
1726
|
+
let filteredRoles: Record<string, unknown> | undefined;
|
|
1727
|
+
for (const role in projectRoles) {
|
|
1728
|
+
if (!Object.hasOwn(projectRoles, role) || modelRoleValueFromUnknown(projectRoles[role]) !== undefined)
|
|
1729
|
+
continue;
|
|
1730
|
+
filteredRoles ??= { ...projectRoles };
|
|
1731
|
+
delete filteredRoles[role];
|
|
1732
|
+
}
|
|
1733
|
+
return filteredRoles ? { ...this.#project, modelRoles: filteredRoles } : this.#project;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1356
1736
|
#rebuildMerged(): void {
|
|
1357
|
-
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#
|
|
1737
|
+
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#projectSettingsForMerge());
|
|
1358
1738
|
this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
|
|
1359
1739
|
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
|
|
1360
1740
|
this.#resolvedCache.clear();
|
package/src/cursor.ts
CHANGED
|
@@ -18,9 +18,20 @@ import { resolveToCwd } from "./tools/path-utils";
|
|
|
18
18
|
|
|
19
19
|
interface CursorExecBridgeOptions {
|
|
20
20
|
cwd: string;
|
|
21
|
+
getCwd?: () => string;
|
|
21
22
|
tools: Map<string, AgentTool>;
|
|
23
|
+
getTool?: (name: string) => AgentTool | undefined;
|
|
22
24
|
getToolContext?: () => AgentToolContext | undefined;
|
|
23
25
|
emitEvent?: (event: AgentEvent) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the Cursor native `delete` frame may remove files. Unlike every
|
|
28
|
+
* other exec handler, `executeDelete` mutates the filesystem directly instead
|
|
29
|
+
* of consulting {@link tools}, so a background read-only advisor could delete
|
|
30
|
+
* workspace files it was never granted a mutating tool for (issue #5680
|
|
31
|
+
* review). Defaults to allowed to preserve the primary agent's behavior;
|
|
32
|
+
* callers with a restricted tool set (advisors) opt out.
|
|
33
|
+
*/
|
|
34
|
+
allowNativeDelete?: boolean;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
function createToolResultMessage(
|
|
@@ -53,7 +64,7 @@ async function executeTool(
|
|
|
53
64
|
toolCallId: string,
|
|
54
65
|
args: Record<string, unknown>,
|
|
55
66
|
): Promise<ToolResultMessage> {
|
|
56
|
-
const tool = options.tools.get(toolName);
|
|
67
|
+
const tool = options.tools.get(toolName) ?? options.getTool?.(toolName);
|
|
57
68
|
if (!tool) {
|
|
58
69
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
59
70
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
@@ -106,9 +117,15 @@ async function executeTool(
|
|
|
106
117
|
|
|
107
118
|
async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
|
|
108
119
|
const toolName = "delete";
|
|
120
|
+
|
|
121
|
+
if (options.allowNativeDelete === false) {
|
|
122
|
+
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
123
|
+
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
|
|
110
127
|
|
|
111
|
-
const absolutePath = resolveToCwd(pathArg, options.cwd);
|
|
128
|
+
const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd);
|
|
112
129
|
let isError = false;
|
|
113
130
|
let result: AgentToolResult<unknown>;
|
|
114
131
|
|
|
@@ -327,7 +344,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
327
344
|
async mcp(call: CursorMcpCall) {
|
|
328
345
|
const toolName = call.toolName || call.name;
|
|
329
346
|
const toolCallId = decodeToolCallId(call.toolCallId);
|
|
330
|
-
const tool = this.options.tools.get(toolName);
|
|
347
|
+
const tool = this.options.tools.get(toolName) ?? this.options.getTool?.(toolName);
|
|
331
348
|
if (!tool) {
|
|
332
349
|
const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
|
|
333
350
|
const message = formatMcpToolErrorMessage(toolName, availableTools);
|
|
@@ -105,9 +105,10 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
|
|
105
105
|
files.push("config.json");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Recent logs (last 1000 lines)
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// Recent logs (last 1000 lines) across every same-day process. PID-qualified
|
|
109
|
+
// filenames mean a report generated from a later invocation must still gather
|
|
110
|
+
// the crashed process's log, so read all of today's files, not just our own.
|
|
111
|
+
const logs = await collectSameDayLogs(1000);
|
|
111
112
|
if (logs) {
|
|
112
113
|
data["logs.txt"] = logs;
|
|
113
114
|
files.push("logs.txt");
|
|
@@ -241,7 +242,42 @@ export async function getLogText(): Promise<string> {
|
|
|
241
242
|
return readLastLines(getLogPath(), MAX_LOG_LINES);
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Concatenate the tail of every same-day process log so a report generated
|
|
247
|
+
* after a crash still captures the fatal PID's `omp.<date>.<pid>.log`. Files
|
|
248
|
+
* are ordered oldest-first by mtime and separated by a filename header.
|
|
249
|
+
*/
|
|
250
|
+
async function collectSameDayLogs(linesPerFile: number): Promise<string> {
|
|
251
|
+
const logsDir = getLogsDir();
|
|
252
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
253
|
+
const sameDay: Array<{ name: string; mtimeMs: number }> = [];
|
|
254
|
+
try {
|
|
255
|
+
const entries = await fs.readdir(logsDir, { withFileTypes: true });
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
if (!entry.isFile()) continue;
|
|
258
|
+
const match = LOG_FILE_PATTERN.exec(entry.name);
|
|
259
|
+
if (!match || match[1] !== today) continue;
|
|
260
|
+
try {
|
|
261
|
+
const stat = await fs.stat(path.join(logsDir, entry.name));
|
|
262
|
+
sameDay.push({ name: entry.name, mtimeMs: stat.mtimeMs });
|
|
263
|
+
} catch {
|
|
264
|
+
// File may have rotated away between readdir and stat.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
sameDay.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
271
|
+
|
|
272
|
+
const chunks: string[] = [];
|
|
273
|
+
for (const { name } of sameDay) {
|
|
274
|
+
const text = await readLastLines(path.join(logsDir, name), linesPerFile);
|
|
275
|
+
if (text) chunks.push(`===== ${name} =====\n${text}`);
|
|
276
|
+
}
|
|
277
|
+
return chunks.join("\n\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.\\d+\\.log(?:\\.\\d+)?$`);
|
|
245
281
|
|
|
246
282
|
export async function createDebugLogSource(): Promise<DebugLogSource> {
|
|
247
283
|
const logsDir = getLogsDir();
|