@oh-my-pi/pi-coding-agent 16.0.7 → 16.0.9
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 +41 -0
- package/dist/cli.js +4817 -12449
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/update-cli.d.ts +11 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/debug/remote-debugger.d.ts +45 -0
- package/dist/types/goals/runtime.d.ts +4 -1
- package/dist/types/internal-urls/docs-index.d.ts +19 -0
- package/dist/types/markit/converters/docx.d.ts +6 -0
- package/dist/types/markit/converters/epub.d.ts +15 -0
- package/dist/types/markit/converters/pdf/columns.d.ts +35 -0
- package/dist/types/markit/converters/pdf/extract.d.ts +10 -0
- package/dist/types/markit/converters/pdf/grid.d.ts +25 -0
- package/dist/types/markit/converters/pdf/headers.d.ts +24 -0
- package/dist/types/markit/converters/pdf/index.d.ts +6 -0
- package/dist/types/markit/converters/pdf/render.d.ts +24 -0
- package/dist/types/markit/converters/pdf/types.d.ts +75 -0
- package/dist/types/markit/converters/pptx.d.ts +57 -0
- package/dist/types/markit/converters/xlsx.d.ts +25 -0
- package/dist/types/markit/index.d.ts +2 -0
- package/dist/types/markit/registry.d.ts +16 -0
- package/dist/types/markit/types.d.ts +30 -0
- package/dist/types/modes/print-mode.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +7 -8
- package/dist/types/session/auth-storage.d.ts +3 -2
- package/dist/types/session/yield-queue.d.ts +3 -1
- package/dist/types/tools/browser/attach.d.ts +1 -1
- package/dist/types/utils/markit.d.ts +0 -8
- package/dist/types/utils/mupdf-wasm-embed.d.ts +1 -0
- package/dist/types/utils/turndown.d.ts +15 -0
- package/dist/types/utils/zip.d.ts +119 -0
- package/package.json +20 -18
- package/scripts/build-binary.ts +7 -3
- package/scripts/bundle-dist.ts +28 -12
- package/scripts/embed-mupdf-wasm.ts +67 -0
- package/scripts/generate-docs-index.ts +48 -32
- package/scripts/omp +1 -1
- package/src/advisor/__tests__/advisor.test.ts +83 -0
- package/src/advisor/runtime.ts +16 -1
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -3
- package/src/cli/auth-gateway-cli.ts +2 -5
- package/src/cli/flag-tables.ts +1 -0
- package/src/cli/update-cli.ts +63 -3
- package/src/commands/launch.ts +3 -0
- package/src/config/model-discovery.ts +20 -8
- package/src/config/models-config-schema.ts +8 -1
- package/src/debug/index.ts +44 -0
- package/src/debug/remote-debugger.ts +151 -0
- package/src/debug/report-bundle.ts +2 -1
- package/src/goals/runtime.ts +19 -7
- package/src/internal-urls/docs-index.generated.txt +2 -0
- package/src/internal-urls/docs-index.ts +102 -0
- package/src/internal-urls/omp-protocol.ts +10 -9
- package/src/main.ts +8 -0
- package/src/markit/NOTICE +32 -0
- package/src/markit/converters/docx.ts +56 -0
- package/src/markit/converters/epub.ts +136 -0
- package/src/markit/converters/mammoth.d.ts +24 -0
- package/src/markit/converters/pdf/columns.ts +103 -0
- package/src/markit/converters/pdf/extract.ts +574 -0
- package/src/markit/converters/pdf/grid.ts +780 -0
- package/src/markit/converters/pdf/headers.ts +106 -0
- package/src/markit/converters/pdf/index.ts +146 -0
- package/src/markit/converters/pdf/render.ts +501 -0
- package/src/markit/converters/pdf/types.ts +84 -0
- package/src/markit/converters/pptx.ts +325 -0
- package/src/markit/converters/xlsx.ts +173 -0
- package/src/markit/index.ts +2 -0
- package/src/markit/registry.ts +59 -0
- package/src/markit/types.ts +35 -0
- package/src/modes/components/snapcompact-shape-preview-doc.md +14 -7
- package/src/modes/components/snapcompact-shape-preview.ts +2 -2
- package/src/modes/controllers/input-controller.ts +29 -8
- package/src/modes/interactive-mode.ts +33 -12
- package/src/modes/print-mode.ts +5 -1
- package/src/prompts/advisor/advise-tool.md +3 -1
- package/src/prompts/advisor/system.md +55 -11
- package/src/sdk.ts +5 -9
- package/src/session/agent-session.ts +72 -42
- package/src/session/auth-storage.ts +2 -11
- package/src/session/yield-queue.ts +7 -1
- package/src/tools/browser/attach.ts +2 -2
- package/src/tools/fetch.ts +25 -60
- package/src/tools/read.ts +1 -1
- package/src/tools/search.ts +1 -6
- package/src/tools/write.ts +25 -65
- package/src/utils/markit.ts +25 -9
- package/src/utils/mupdf-wasm-embed.ts +12 -0
- package/src/utils/tools-manager.ts +2 -11
- package/src/utils/turndown.ts +83 -0
- package/src/{tools/archive-reader.ts → utils/zip.ts} +453 -83
- package/src/web/scrapers/types.ts +3 -46
- package/dist/types/internal-urls/docs-index.generated.d.ts +0 -2
- package/dist/types/tools/archive-reader.d.ts +0 -49
- package/src/internal-urls/docs-index.generated.ts +0 -120
package/src/cli/flag-tables.ts
CHANGED
package/src/cli/update-cli.ts
CHANGED
|
@@ -370,13 +370,64 @@ async function unlinkIfExists(filePath: string): Promise<void> {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Remove a backup binary without letting the removal abort a completed update.
|
|
375
|
+
*
|
|
376
|
+
* On Windows the executable that was just moved aside is still mapped as the
|
|
377
|
+
* running process image, so unlinking it fails with EPERM/EACCES until this
|
|
378
|
+
* process exits (issue #845). The replacement and verification already
|
|
379
|
+
* succeeded by the time we get here, so every error is swallowed; the leftover
|
|
380
|
+
* is reclaimed by {@link sweepStaleBackups} on the next update once it is no
|
|
381
|
+
* longer in use. Returns whether the file is gone.
|
|
382
|
+
*/
|
|
383
|
+
async function removeBackupBestEffort(filePath: string): Promise<boolean> {
|
|
384
|
+
try {
|
|
385
|
+
await fs.promises.unlink(filePath);
|
|
386
|
+
return true;
|
|
387
|
+
} catch (err) {
|
|
388
|
+
return isEnoent(err);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Best-effort removal of binary-update backups left by earlier runs.
|
|
394
|
+
*
|
|
395
|
+
* Each self-update moves the previous executable to `<binary>.<timestamp>.<pid>.bak`
|
|
396
|
+
* before swapping the new one in. On Windows that backup cannot be deleted
|
|
397
|
+
* while the updating process is alive, so it is left for a later run to reclaim
|
|
398
|
+
* once its owning process has exited. Also matches the legacy fixed
|
|
399
|
+
* `<binary>.bak` name produced before backups were timestamped, so users
|
|
400
|
+
* upgrading from a buggy release get the orphaned file cleaned up.
|
|
401
|
+
*/
|
|
402
|
+
export async function sweepStaleBackups(targetPath: string): Promise<void> {
|
|
403
|
+
const dir = path.dirname(targetPath);
|
|
404
|
+
const base = path.basename(targetPath);
|
|
405
|
+
let entries: string[];
|
|
406
|
+
try {
|
|
407
|
+
entries = await fs.promises.readdir(dir);
|
|
408
|
+
} catch {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
if (!entry.startsWith(`${base}.`) || !entry.endsWith(".bak")) continue;
|
|
413
|
+
// Legacy "<base>.bak" → empty middle; new "<base>.<timestamp>.<pid>.bak"
|
|
414
|
+
// → dot-separated numeric run. Anything else is an unrelated *.bak file.
|
|
415
|
+
const middle = entry.slice(base.length + 1, entry.length - ".bak".length);
|
|
416
|
+
if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue;
|
|
417
|
+
await removeBackupBestEffort(path.join(dir, entry));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
373
421
|
/**
|
|
374
422
|
* Atomically replace the installed binary and roll back if version verification fails.
|
|
375
423
|
*/
|
|
376
424
|
export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
|
|
377
425
|
let backupReady = false;
|
|
378
426
|
try {
|
|
379
|
-
|
|
427
|
+
// `backupPath` is unique per attempt (see updateViaBinaryAt), so this rename
|
|
428
|
+
// never has to overwrite — or unlink — a possibly-locked leftover from an
|
|
429
|
+
// earlier run. Renaming the running executable itself is permitted on
|
|
430
|
+
// Windows; only deleting its still-mapped image is not.
|
|
380
431
|
await fs.promises.rename(options.targetPath, options.backupPath);
|
|
381
432
|
backupReady = true;
|
|
382
433
|
await fs.promises.rename(options.tempPath, options.targetPath);
|
|
@@ -389,7 +440,10 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
|
|
|
389
440
|
}
|
|
390
441
|
|
|
391
442
|
backupReady = false;
|
|
392
|
-
|
|
443
|
+
// Swap done and verified. On Windows the backup is still the running
|
|
444
|
+
// process image and cannot be unlinked until this process exits, so a
|
|
445
|
+
// failure here must NOT fail an otherwise-successful update.
|
|
446
|
+
await removeBackupBestEffort(options.backupPath);
|
|
393
447
|
return verification;
|
|
394
448
|
} catch (err) {
|
|
395
449
|
if (backupReady) {
|
|
@@ -517,7 +571,11 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
|
|
|
517
571
|
const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
|
|
518
572
|
|
|
519
573
|
const tempPath = `${targetPath}.new`;
|
|
520
|
-
|
|
574
|
+
// Unique per attempt: a stale backup from an earlier update may still be
|
|
575
|
+
// locked (it is the previous process image on Windows), and a fixed name
|
|
576
|
+
// would force the move-aside rename to overwrite it. pid + timestamp keeps
|
|
577
|
+
// two forced updates in the same millisecond from colliding.
|
|
578
|
+
const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
|
|
521
579
|
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
|
522
580
|
|
|
523
581
|
const response = await fetch(url, { redirect: "follow" });
|
|
@@ -535,6 +593,8 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
|
|
|
535
593
|
expectedVersion,
|
|
536
594
|
verifyInstalledVersion,
|
|
537
595
|
});
|
|
596
|
+
// Reclaim backups from earlier updates whose owning process has since exited.
|
|
597
|
+
await sweepStaleBackups(targetPath);
|
|
538
598
|
printVerifiedVersion(expectedVersion);
|
|
539
599
|
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
540
600
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -136,6 +136,9 @@ export default class Index extends Command {
|
|
|
136
136
|
"no-title": Flags.boolean({
|
|
137
137
|
description: "Disable title auto-generation",
|
|
138
138
|
}),
|
|
139
|
+
"print-thoughts": Flags.boolean({
|
|
140
|
+
description: "Include thinking blocks in print mode text output",
|
|
141
|
+
}),
|
|
139
142
|
"max-time": Flags.string({
|
|
140
143
|
description: "Stop the session after this many seconds",
|
|
141
144
|
}),
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveModelReference,
|
|
14
14
|
stripBracketedModelIdAffixes,
|
|
15
15
|
} from "@oh-my-pi/pi-catalog/identity";
|
|
16
|
+
import { fetchLmStudioNativeModelMetadata } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
|
|
16
17
|
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
|
|
17
18
|
import { isRecord } from "@oh-my-pi/pi-utils";
|
|
18
19
|
import type { ProviderDiscovery } from "./models-config-schema";
|
|
@@ -379,18 +380,25 @@ export async function discoverOpenAIModelsList(
|
|
|
379
380
|
const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
|
|
380
381
|
let headers = baseHeaders;
|
|
381
382
|
const attempt = async (h: Record<string, string>) => {
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
const nativeMetadataPromise =
|
|
384
|
+
providerConfig.discovery.type === "lm-studio"
|
|
385
|
+
? fetchLmStudioNativeModelMetadata(baseUrl, ctx.fetch, { headers: h })
|
|
386
|
+
: Promise.resolve(null);
|
|
387
|
+
const [res, nativeMetadata] = await Promise.all([
|
|
388
|
+
ctx.fetch(modelsUrl, {
|
|
389
|
+
headers: h,
|
|
390
|
+
signal: AbortSignal.timeout(10_000),
|
|
391
|
+
}),
|
|
392
|
+
nativeMetadataPromise,
|
|
393
|
+
]);
|
|
386
394
|
if (!res.ok) {
|
|
387
395
|
throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
|
|
388
396
|
}
|
|
389
397
|
headers = h;
|
|
390
|
-
return res;
|
|
398
|
+
return [res, nativeMetadata] as const;
|
|
391
399
|
};
|
|
392
400
|
const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
|
|
393
|
-
const response = apiKey
|
|
401
|
+
const [response, nativeMetadata] = apiKey
|
|
394
402
|
? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
|
|
395
403
|
: await attempt(baseHeaders);
|
|
396
404
|
const payload = (await response.json()) as {
|
|
@@ -401,8 +409,12 @@ export async function discoverOpenAIModelsList(
|
|
|
401
409
|
for (const item of models) {
|
|
402
410
|
const id = item.id;
|
|
403
411
|
if (!id) continue;
|
|
412
|
+
const nativeMetadataForModel = nativeMetadata?.get(id);
|
|
404
413
|
const contextWindow =
|
|
405
|
-
toPositiveNumberOrUndefined(item.max_model_len) ??
|
|
414
|
+
toPositiveNumberOrUndefined(item.max_model_len) ??
|
|
415
|
+
toPositiveNumberOrUndefined(item.context_length) ??
|
|
416
|
+
nativeMetadataForModel?.contextWindow ??
|
|
417
|
+
128000;
|
|
406
418
|
discovered.push(
|
|
407
419
|
buildModel({
|
|
408
420
|
id,
|
|
@@ -411,7 +423,7 @@ export async function discoverOpenAIModelsList(
|
|
|
411
423
|
provider: providerConfig.provider,
|
|
412
424
|
baseUrl,
|
|
413
425
|
reasoning: false,
|
|
414
|
-
input: ["text"],
|
|
426
|
+
input: nativeMetadataForModel?.input ?? ["text"],
|
|
415
427
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
416
428
|
contextWindow,
|
|
417
429
|
maxTokens: Math.min(contextWindow, discoveryDefaultMaxTokens(providerConfig.api)),
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scope } from "arktype";
|
|
2
|
+
|
|
3
|
+
// Config schemas validate at most a handful of times per process (on config
|
|
4
|
+
// load), so the eager JIT codegen ArkType runs at definition time is pure
|
|
5
|
+
// startup tax. A local jitless scope skips that codegen and falls back to
|
|
6
|
+
// interpreted traversal — ~65% cheaper to construct, validation correctness
|
|
7
|
+
// unchanged. (No `name`: duplicate module instances would collide.)
|
|
8
|
+
const { type } = scope({}, { jitless: true });
|
|
2
9
|
|
|
3
10
|
const OpenRouterRoutingSchema = type({
|
|
4
11
|
"only?": "string[]",
|
package/src/debug/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from
|
|
|
29
29
|
import { buildSampleImage, ProtocolProbeComponent } from "./protocol-probe";
|
|
30
30
|
import { RawSseViewerComponent } from "./raw-sse";
|
|
31
31
|
import { resolveRawSseDebugBuffer } from "./raw-sse-buffer";
|
|
32
|
+
import { getRemoteDebugger, type RemoteDebuggerInfo, startRemoteDebuggerServer } from "./remote-debugger";
|
|
32
33
|
import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
|
|
33
34
|
import { collectSystemInfo, formatSystemInfo } from "./system-info";
|
|
34
35
|
import { collectTerminalState, formatTerminalState } from "./terminal-info";
|
|
@@ -49,6 +50,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
|
|
|
49
50
|
description: "Styling, links, text sizing, graphics, notify",
|
|
50
51
|
},
|
|
51
52
|
{ value: "raw-sse", label: "View: raw SSE stream", description: "Show live provider SSE frames" },
|
|
53
|
+
{
|
|
54
|
+
value: "remote-debugger",
|
|
55
|
+
label: "Start: JS remote debugger",
|
|
56
|
+
description: "Expose JavaScriptCore inspector socket (experimental)",
|
|
57
|
+
},
|
|
52
58
|
{
|
|
53
59
|
value: "transcript",
|
|
54
60
|
label: "Export: TUI transcript",
|
|
@@ -122,6 +128,9 @@ export class DebugSelectorComponent extends Container {
|
|
|
122
128
|
case "raw-sse":
|
|
123
129
|
await this.#handleViewRawSse();
|
|
124
130
|
break;
|
|
131
|
+
case "remote-debugger":
|
|
132
|
+
await this.#handleStartRemoteDebugger();
|
|
133
|
+
break;
|
|
125
134
|
case "system":
|
|
126
135
|
await this.#handleViewSystemInfo();
|
|
127
136
|
break;
|
|
@@ -353,6 +362,41 @@ export class DebugSelectorComponent extends Container {
|
|
|
353
362
|
this.ctx.ui.requestRender();
|
|
354
363
|
}
|
|
355
364
|
|
|
365
|
+
async #handleStartRemoteDebugger(): Promise<void> {
|
|
366
|
+
const existing = getRemoteDebugger();
|
|
367
|
+
let info: RemoteDebuggerInfo;
|
|
368
|
+
try {
|
|
369
|
+
info = existing ?? (await startRemoteDebuggerServer());
|
|
370
|
+
} catch (err) {
|
|
371
|
+
this.ctx.showError(`Failed to start remote debugger: ${err instanceof Error ? err.message : String(err)}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const block = new TranscriptBlock();
|
|
376
|
+
block.addChild(
|
|
377
|
+
new Text(
|
|
378
|
+
theme.fg(
|
|
379
|
+
"success",
|
|
380
|
+
`${theme.status.success} JavaScriptCore remote inspector ${existing ? "already running" : "started"}`,
|
|
381
|
+
),
|
|
382
|
+
1,
|
|
383
|
+
0,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
block.addChild(new Text(theme.fg("dim", `Listening on ${info.host}:${info.port}`), 1, 0));
|
|
387
|
+
block.addChild(
|
|
388
|
+
new Text(
|
|
389
|
+
theme.fg(
|
|
390
|
+
"muted",
|
|
391
|
+
"Experimental WebKit RemoteInspectorServer socket (Bun marks it untested on macOS). One-way for this process — there is no stop. Attach a compatible WebKit/Safari Web Inspector client.",
|
|
392
|
+
),
|
|
393
|
+
1,
|
|
394
|
+
0,
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
this.ctx.present(block);
|
|
398
|
+
}
|
|
399
|
+
|
|
356
400
|
async #handleViewSystemInfo(): Promise<void> {
|
|
357
401
|
try {
|
|
358
402
|
const info = await collectSystemInfo();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun JavaScriptCore remote inspector control.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `bun:jsc`'s `startRemoteDebugger`, which exposes JavaScriptCore's
|
|
5
|
+
* built-in WebKit RemoteInspectorServer over a raw socket. The API is one-shot
|
|
6
|
+
* and rough around the edges (Bun documents it as untested, "may not be
|
|
7
|
+
* supported yet on macOS"):
|
|
8
|
+
* - it returns `void` and has no stop handle, so we track the live endpoint
|
|
9
|
+
* at module scope and make starting idempotent;
|
|
10
|
+
* - it rejects port `0`, so "let the OS pick" is implemented by reserving a
|
|
11
|
+
* free port via `node:net` and handing the concrete number to Bun;
|
|
12
|
+
* - on macOS (Bun 1.3.x) it throws a spurious "port already in use" error
|
|
13
|
+
* even when the server binds fine, so success is decided by a loopback
|
|
14
|
+
* probe rather than by whether the call threw.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { startRemoteDebugger } from "bun:jsc";
|
|
18
|
+
import * as net from "node:net";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
21
|
+
/** How long to keep probing for the inspector socket before giving up. */
|
|
22
|
+
const PROBE_DEADLINE_MS = 1000;
|
|
23
|
+
const PROBE_INTERVAL_MS = 50;
|
|
24
|
+
|
|
25
|
+
export interface RemoteDebuggerInfo {
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let active: RemoteDebuggerInfo | null = null;
|
|
31
|
+
/** In-flight start, shared so concurrent callers coalesce onto one launch. */
|
|
32
|
+
let starting: Promise<RemoteDebuggerInfo> | null = null;
|
|
33
|
+
|
|
34
|
+
/** Underlying starter signature; tests inject a disposable listener in its place. */
|
|
35
|
+
export type RemoteDebuggerStarter = (host: string, port: number) => void;
|
|
36
|
+
|
|
37
|
+
export interface StartRemoteDebuggerOptions {
|
|
38
|
+
/** Explicit port; when omitted a free port is reserved automatically. */
|
|
39
|
+
port?: number;
|
|
40
|
+
/** Override the JSC starter. Defaults to `bun:jsc`'s `startRemoteDebugger`. */
|
|
41
|
+
start?: RemoteDebuggerStarter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The live inspector endpoint for this process, or `null` if not started. */
|
|
45
|
+
export function getRemoteDebugger(): RemoteDebuggerInfo | null {
|
|
46
|
+
return active;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reserve a free TCP port on `host` by binding to `0`, then releasing it. */
|
|
50
|
+
async function reserveFreePort(host: string): Promise<number> {
|
|
51
|
+
const server = net.createServer();
|
|
52
|
+
const listening = Promise.withResolvers<number>();
|
|
53
|
+
server.once("error", listening.reject);
|
|
54
|
+
server.listen(0, host, () => {
|
|
55
|
+
const addr = server.address();
|
|
56
|
+
if (addr && typeof addr === "object") listening.resolve(addr.port);
|
|
57
|
+
else listening.reject(new Error("Failed to reserve a debugger port"));
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
return await listening.promise;
|
|
61
|
+
} finally {
|
|
62
|
+
const closed = Promise.withResolvers<void>();
|
|
63
|
+
server.close(() => closed.resolve());
|
|
64
|
+
await closed.promise;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve once `host:port` accepts a TCP connection (within one attempt). */
|
|
69
|
+
function tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
|
70
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
71
|
+
const socket = net.createConnection({ host, port });
|
|
72
|
+
let settled = false;
|
|
73
|
+
const finish = (ok: boolean) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
socket.destroy();
|
|
77
|
+
resolve(ok);
|
|
78
|
+
};
|
|
79
|
+
socket.setTimeout(timeoutMs);
|
|
80
|
+
socket.once("connect", () => finish(true));
|
|
81
|
+
socket.once("timeout", () => finish(false));
|
|
82
|
+
socket.once("error", () => finish(false));
|
|
83
|
+
return promise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Poll the inspector socket until it accepts a connection or the deadline passes. */
|
|
87
|
+
async function waitForListening(host: string, port: number): Promise<boolean> {
|
|
88
|
+
const deadline = Date.now() + PROBE_DEADLINE_MS;
|
|
89
|
+
do {
|
|
90
|
+
if (await tryConnect(host, port, PROBE_INTERVAL_MS)) return true;
|
|
91
|
+
await Bun.sleep(PROBE_INTERVAL_MS);
|
|
92
|
+
} while (Date.now() < deadline);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the JavaScriptCore remote inspector for this process and return its
|
|
98
|
+
* endpoint. Idempotent: the underlying API cannot be stopped or rebound, so a
|
|
99
|
+
* second call returns the existing endpoint instead of starting again. When
|
|
100
|
+
* `port` is omitted a free port is reserved automatically.
|
|
101
|
+
*
|
|
102
|
+
* Throws only when the socket never comes up; Bun's spurious bind error is
|
|
103
|
+
* swallowed and overridden by the loopback probe.
|
|
104
|
+
*/
|
|
105
|
+
export async function startRemoteDebuggerServer(options: StartRemoteDebuggerOptions = {}): Promise<RemoteDebuggerInfo> {
|
|
106
|
+
if (active) return active;
|
|
107
|
+
starting ??= launch(options);
|
|
108
|
+
try {
|
|
109
|
+
return await starting;
|
|
110
|
+
} finally {
|
|
111
|
+
starting = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function launch({ port, start = startRemoteDebugger }: StartRemoteDebuggerOptions): Promise<RemoteDebuggerInfo> {
|
|
116
|
+
const host = DEFAULT_HOST;
|
|
117
|
+
const chosen = port ?? (await reserveFreePort(host));
|
|
118
|
+
|
|
119
|
+
// Something already on this port? Refuse up front: otherwise Bun throws a
|
|
120
|
+
// real bind error and our success probe would connect to that unrelated
|
|
121
|
+
// service, marking a bogus endpoint as the debugger.
|
|
122
|
+
if (await tryConnect(host, chosen, PROBE_INTERVAL_MS)) {
|
|
123
|
+
throw new Error(`Port ${host}:${chosen} is already in use; cannot start remote debugger there.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let thrown: unknown;
|
|
127
|
+
try {
|
|
128
|
+
start(host, chosen);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Bun's startRemoteDebugger throws a spurious bind error even on success,
|
|
131
|
+
// so defer the verdict to the loopback probe below.
|
|
132
|
+
thrown = err;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (await waitForListening(host, chosen)) {
|
|
136
|
+
active = { host, port: chosen };
|
|
137
|
+
return active;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw thrown instanceof Error ? thrown : new Error(`Remote debugger socket never came up on ${host}:${chosen}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Test-only: forget the tracked endpoint so a fresh start can be exercised.
|
|
145
|
+
* Does not (and cannot) stop a real JSC inspector — callers in tests own the
|
|
146
|
+
* disposable listener they injected.
|
|
147
|
+
*/
|
|
148
|
+
export function __resetRemoteDebuggerForTests(): void {
|
|
149
|
+
active = null;
|
|
150
|
+
starting = null;
|
|
151
|
+
}
|
|
@@ -7,6 +7,7 @@ import * as fs from "node:fs/promises";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import type { WorkProfile } from "@oh-my-pi/pi-natives";
|
|
9
9
|
import { APP_NAME, getLogPath, getLogsDir, getReportsDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|
10
|
+
import { writeArchive } from "../utils/zip";
|
|
10
11
|
import type { CpuProfile, HeapSnapshot } from "./profiler";
|
|
11
12
|
import { collectSystemInfo, sanitizeEnv } from "./system-info";
|
|
12
13
|
|
|
@@ -165,7 +166,7 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
// Write archive
|
|
168
|
-
await
|
|
169
|
+
await writeArchive(outputPath, "tar.gz", Object.entries(data));
|
|
169
170
|
|
|
170
171
|
return { path: outputPath, files };
|
|
171
172
|
}
|
package/src/goals/runtime.ts
CHANGED
|
@@ -178,8 +178,8 @@ export class GoalRuntime {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
#markActiveAccounting(goal: Goal): void {
|
|
182
|
-
if (this.#wallClock.activeGoalId !== goal.id) {
|
|
181
|
+
#markActiveAccounting(goal: Goal, resetWallClock = false): void {
|
|
182
|
+
if (resetWallClock || this.#wallClock.activeGoalId !== goal.id) {
|
|
183
183
|
this.#wallClock = { lastAccountedAt: this.#now(), activeGoalId: goal.id };
|
|
184
184
|
}
|
|
185
185
|
if (this.#turnSnapshot) {
|
|
@@ -195,6 +195,12 @@ export class GoalRuntime {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
clearAccounting(): void {
|
|
199
|
+
this.#turnSnapshot = undefined;
|
|
200
|
+
this.#clearActiveAccounting();
|
|
201
|
+
this.#budgetReportedFor = undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
198
204
|
onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void {
|
|
199
205
|
this.#turnSnapshot = { turnId, baselineUsage: { ...baselineUsage } };
|
|
200
206
|
const state = this.#host.getState();
|
|
@@ -235,7 +241,7 @@ export class GoalRuntime {
|
|
|
235
241
|
return;
|
|
236
242
|
}
|
|
237
243
|
await this.#withAccounting(async () => {
|
|
238
|
-
await this.#flushUsageLocked("suppressed");
|
|
244
|
+
await this.#flushUsageLocked("suppressed", undefined, options?.reason === "internal");
|
|
239
245
|
this.#turnSnapshot = undefined;
|
|
240
246
|
if (options?.reason !== "interrupted") return;
|
|
241
247
|
const cloned = this.#getStateClone();
|
|
@@ -249,9 +255,14 @@ export class GoalRuntime {
|
|
|
249
255
|
});
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
async onThreadResumed(): Promise<GoalModeState | undefined> {
|
|
258
|
+
async onThreadResumed(options?: { preserveActiveGoal?: boolean }): Promise<GoalModeState | undefined> {
|
|
253
259
|
const state = this.#getStateClone();
|
|
254
260
|
if (!state) return undefined;
|
|
261
|
+
if (options?.preserveActiveGoal && state.enabled && state.goal.status === "active") {
|
|
262
|
+
this.#markActiveAccounting(state.goal, true);
|
|
263
|
+
await this.#commitState(state, { emit: true });
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
255
266
|
if (state.goal.status === "active") {
|
|
256
267
|
state.enabled = false;
|
|
257
268
|
state.goal.status = "paused";
|
|
@@ -301,6 +312,7 @@ export class GoalRuntime {
|
|
|
301
312
|
async #flushUsageLocked(
|
|
302
313
|
steering: GoalBudgetSteering,
|
|
303
314
|
currentUsage: GoalTokenUsage = this.#host.getCurrentUsage(),
|
|
315
|
+
persistWallClock = false,
|
|
304
316
|
): Promise<void> {
|
|
305
317
|
const state = this.#getStateClone();
|
|
306
318
|
if (!state?.enabled || !isAccountingStatus(state.goal)) return;
|
|
@@ -333,10 +345,10 @@ export class GoalRuntime {
|
|
|
333
345
|
if (this.#wallClock.activeGoalId === state.goal.id && wallSeconds > 0) {
|
|
334
346
|
this.#wallClock.lastAccountedAt += wallSeconds * 1000;
|
|
335
347
|
}
|
|
336
|
-
|
|
337
348
|
// Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full
|
|
338
|
-
// objective snapshots. Keep
|
|
339
|
-
|
|
349
|
+
// objective snapshots. Keep normal tool flushes in memory/UI only, but make wall-clock
|
|
350
|
+
// usage durable before internal session switches because the active runtime is leaving.
|
|
351
|
+
const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited || (persistWallClock && wallSeconds > 0);
|
|
340
352
|
await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
|
|
341
353
|
|
|
342
354
|
if (state.goal.status !== "budget-limited") {
|