@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.11

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.
@@ -64,4 +64,33 @@ export interface BuiltSkillPromptMessage {
64
64
  details: SkillPromptDetails;
65
65
  }
66
66
  export declare function getSkillSlashCommandName(skill: Pick<Skill, "name">): string;
67
+ /**
68
+ * Parsed `/skill:<name>` invocation: either at the start of the draft (the
69
+ * traditional slash-command position) or as a `/skill:<name>` token embedded
70
+ * mid-prompt. For the mid-prompt form the surrounding prose is threaded
71
+ * through as `args` so the skill sees the full user request.
72
+ */
73
+ export interface ParsedSkillInvocation {
74
+ /** Bare skill name without the leading `skill:` prefix. */
75
+ name: string;
76
+ /** User-supplied arguments (everything outside the `/skill:<name>` token). */
77
+ args: string;
78
+ }
79
+ /**
80
+ * Detect a `/skill:<name>` invocation in a user draft.
81
+ *
82
+ * Returns `undefined` when the text contains no skill token. Otherwise:
83
+ * - Leading form (`/skill:foo bar baz`): name=`foo`, args=`bar baz`.
84
+ * - Mid-prompt form (`fix the bug /skill:foo focus on auth`): name=`foo`,
85
+ * args=`fix the bug focus on auth` — the surrounding prose collapsed
86
+ * into a single args string.
87
+ *
88
+ * Mid-prompt detection is disabled when the draft itself starts with a
89
+ * different slash command (e.g. `/compact /skill:foo`) or a local-execution
90
+ * sigil — `!cmd` / `!!cmd` for the bash tool and `$ cmd` / `$$ cmd` for the
91
+ * python tool. Those handlers run after the skill-command dispatcher and
92
+ * their bodies routinely contain `/skill:<name>` references that are not
93
+ * meant as skill invocations.
94
+ */
95
+ export declare function parseSkillInvocation(text: string): ParsedSkillInvocation | undefined;
67
96
  export declare function buildSkillPromptMessage(skill: Pick<Skill, "name" | "filePath">, args: string): Promise<BuiltSkillPromptMessage>;
@@ -1,7 +1,9 @@
1
1
  import { Container } from "@oh-my-pi/pi-tui";
2
2
  import type { TodoItem } from "../../tools/todo";
3
3
  /**
4
- * Component that renders a todo completion reminder notification.
4
+ * Component that renders a todo completion reminder notification, committed into
5
+ * the transcript like a TTSR notification so it stays anchored in history rather
6
+ * than floating above the editor.
5
7
  * Shows when the agent stops with incomplete todos.
6
8
  */
7
9
  export declare class TodoReminderComponent extends Container {
@@ -36,6 +36,11 @@ export declare class ToolArgsRevealController {
36
36
  setTarget(id: string, partialJson: string, target: ToolArgsRevealTarget): Record<string, unknown>;
37
37
  /** Attach the component future ticks push frames into. */
38
38
  bind(id: string, component: ToolArgsRevealComponent): void;
39
+ /** Migrate a live reveal entry from a placeholder key onto the real
40
+ * tool-call id once the owned-dialect parser assigns it. No-op when no
41
+ * entry exists under `from` (smoothing disabled, or the JSON already
42
+ * closed and `finish` cleared it). */
43
+ rekey(from: string, to: string): void;
39
44
  /** Final arguments arrived (the JSON closed): drop the reveal so the
40
45
  * caller's final-args render wins immediately, mirroring how assistant
41
46
  * text snaps to the full message at message_end. */
@@ -83,7 +83,6 @@ export declare class InteractiveMode implements InteractiveModeContext {
83
83
  chatContainer: TranscriptContainer;
84
84
  pendingMessagesContainer: Container;
85
85
  statusContainer: Container;
86
- todoReminderContainer: Container;
87
86
  todoContainer: Container;
88
87
  subagentContainer: Container;
89
88
  btwContainer: Container;
@@ -23,7 +23,7 @@ export interface BuiltSkillCommandPrompt {
23
23
  message: SkillPromptMessage;
24
24
  options: SkillPromptOptions;
25
25
  }
26
- /** Return true when `text` names a registered `/skill:<name>` command. */
26
+ /** Return true when `text` invokes a registered `/skill:<name>` command. */
27
27
  export declare function isKnownSkillCommand(ctx: SkillCommandHost, text: string): boolean;
28
28
  /** Build the user-attributed custom message for a registered `/skill:<name>` command. */
29
29
  export declare function buildSkillCommandPrompt(ctx: SkillCommandHost, text: string, streamingBehavior: "steer" | "followUp", images?: ImageContent[]): Promise<BuiltSkillCommandPrompt | undefined>;
@@ -81,7 +81,6 @@ export interface InteractiveModeContext {
81
81
  chatContainer: TranscriptContainer;
82
82
  pendingMessagesContainer: Container;
83
83
  statusContainer: Container;
84
- todoReminderContainer: Container;
85
84
  todoContainer: Container;
86
85
  subagentContainer: Container;
87
86
  btwContainer: Container;
@@ -1,4 +1,4 @@
1
- import { type SpawnedSubprocess, type WorkerHandle } from "../subprocess/worker-client";
1
+ import { type RefCountedWorkerHandle, type SpawnedSubprocess } from "../subprocess/worker-client";
2
2
  import type { SttProgressEvent, SttWorkerInbound, SttWorkerOutbound } from "./asr-protocol";
3
3
  import type { SttModelKey } from "./models";
4
4
  export interface SttTranscribeOptions {
@@ -9,6 +9,10 @@ export interface SttDownloadOptions {
9
9
  signal?: AbortSignal;
10
10
  onProgress?: (event: SttProgressEvent) => void;
11
11
  }
12
+ export interface SttDownloadResult {
13
+ ok: boolean;
14
+ error?: string;
15
+ }
12
16
  /** Live streaming session handle returned by {@link SttClient.startStream}. */
13
17
  export interface SttStreamHandle {
14
18
  /** Feed 16 kHz mono float samples as the recorder produces them. */
@@ -38,7 +42,7 @@ export declare const STT_WORKER_ARG = "__omp_worker_stt";
38
42
  export declare function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound>;
39
43
  export declare class SttClient {
40
44
  #private;
41
- constructor(spawnWorker?: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound>);
45
+ constructor(spawnWorker?: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound>);
42
46
  onProgress(listener: (event: SttProgressEvent) => void): () => void;
43
47
  /**
44
48
  * Transcribe 16 kHz mono audio on the warm worker. Rejects with the worker
@@ -54,7 +58,7 @@ export declare class SttClient {
54
58
  * an aborted signal) tears the session down and resolves `stop()` with "".
55
59
  */
56
60
  startStream(modelKey: SttModelKey, options?: SttStreamOptions): SttStreamHandle;
57
- downloadModel(modelKey: SttModelKey, options?: SttDownloadOptions): Promise<boolean>;
61
+ downloadModel(modelKey: SttModelKey, options?: SttDownloadOptions): Promise<SttDownloadResult>;
58
62
  terminate(): Promise<void>;
59
63
  }
60
64
  export declare const sttClient: SttClient;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.2.9",
4
+ "version": "16.2.11",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "bun scripts/build-binary.ts",
35
- "check": "biome check . && bun run check:types",
35
+ "check": "biome check . && bun run check:docs && bun run check:types",
36
+ "check:docs": "bun scripts/generate-docs-index.ts --check",
36
37
  "check:types": "tsgo -p tsconfig.json --noEmit",
37
38
  "lint": "biome lint .",
38
39
  "test": "bun ../../scripts/ci-test-ts.ts coding-agent-heavy --full",
@@ -55,17 +56,17 @@
55
56
  "@agentclientprotocol/sdk": "0.25.0",
56
57
  "@babel/parser": "^7.29.7",
57
58
  "@mozilla/readability": "^0.6.0",
58
- "@oh-my-pi/hashline": "16.2.9",
59
- "@oh-my-pi/omp-stats": "16.2.9",
60
- "@oh-my-pi/pi-agent-core": "16.2.9",
61
- "@oh-my-pi/pi-ai": "16.2.9",
62
- "@oh-my-pi/pi-catalog": "16.2.9",
63
- "@oh-my-pi/pi-mnemopi": "16.2.9",
64
- "@oh-my-pi/pi-natives": "16.2.9",
65
- "@oh-my-pi/pi-tui": "16.2.9",
66
- "@oh-my-pi/pi-utils": "16.2.9",
67
- "@oh-my-pi/pi-wire": "16.2.9",
68
- "@oh-my-pi/snapcompact": "16.2.9",
59
+ "@oh-my-pi/hashline": "16.2.11",
60
+ "@oh-my-pi/omp-stats": "16.2.11",
61
+ "@oh-my-pi/pi-agent-core": "16.2.11",
62
+ "@oh-my-pi/pi-ai": "16.2.11",
63
+ "@oh-my-pi/pi-catalog": "16.2.11",
64
+ "@oh-my-pi/pi-mnemopi": "16.2.11",
65
+ "@oh-my-pi/pi-natives": "16.2.11",
66
+ "@oh-my-pi/pi-tui": "16.2.11",
67
+ "@oh-my-pi/pi-utils": "16.2.11",
68
+ "@oh-my-pi/pi-wire": "16.2.11",
69
+ "@oh-my-pi/snapcompact": "16.2.11",
69
70
  "@opentelemetry/api": "^1.9.1",
70
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
71
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -3,6 +3,7 @@
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import { assertDocsIndexFresh, buildDocsIndexPayload } from "./generate-docs-index";
6
7
 
7
8
  const packageDir = path.join(import.meta.dir, "..");
8
9
  const outDir = path.join(packageDir, "dist");
@@ -72,13 +73,31 @@ async function cleanBundleOutputs(): Promise<void> {
72
73
  );
73
74
  }
74
75
 
76
+ async function assertDocsEmbedPopulated(): Promise<void> {
77
+ // bundle-dist runs from prepack (which calls `gen:docs` first) or directly.
78
+ // Direct invocations must fail — the tarball ships src/, and an empty embed
79
+ // would make src/internal-urls/docs-index.ts fall through to the missing
80
+ // repo `docs/` tree at runtime in published packages (codex review, PR #3941).
81
+ const embedPath = path.join(packageDir, "src/internal-urls/docs-index.generated.txt");
82
+ const embed = await Bun.file(embedPath).text();
83
+ if (embed.length === 0) {
84
+ throw new Error(
85
+ "docs-index embed is empty. Run `bun run gen:docs` before `bun run gen:bundle`, or use `bun pm pack` which runs the prepack chain.",
86
+ );
87
+ }
88
+ const expected = await buildDocsIndexPayload();
89
+ assertDocsIndexFresh(embed, expected);
90
+ }
91
+
75
92
  async function main(): Promise<void> {
76
93
  const start = Bun.nanoseconds();
77
94
  await cleanBundleOutputs();
78
- // The npm bundle ships no stats dashboard sources or prebuilt dist/client,
79
- // so embed the dashboard archive the same way compiled binaries do
80
- // (scripts/build-binary.ts). Reset afterwards to keep the checked-in
81
- // placeholder empty.
95
+ await assertDocsEmbedPopulated();
96
+ // The npm bundle ships no stats dashboard sources, so embed the dashboard
97
+ // archive the same way compiled binaries do (scripts/build-binary.ts). Reset
98
+ // afterwards to keep the checked-in placeholder empty. The docs embed stays
99
+ // populated on disk — postpack owns its reset so `bun pm pack` can pack a
100
+ // tarball whose src copy is still valid for subpath imports.
82
101
  await runCommand(["bun", "--cwd=../stats", "run", "gen:stats"]);
83
102
  try {
84
103
  await runCommand([
@@ -1,56 +1,148 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Populate (or reset) the embedded harness documentation index for `omp://`.
4
+ * Populate, check, or reset the embedded harness documentation index for `omp://`.
5
5
  *
6
6
  * `--generate` writes `src/internal-urls/docs-index.generated.txt` as two lines:
7
7
  * a plain JSON array of the sorted `docs/**\/*.md` file names, then a base64
8
- * gzip blob of the index-aligned doc bodies (`string[]`). Keeping the filename
9
- * list out of the blob lets the loader list docs without inflating it.
10
- * Compiled binaries and the prepacked npm bundle inline this (~0.5MB) instead of
11
- * the ~1.6MB raw map; `--reset` restores the checked-in empty placeholder so the
8
+ * gzip blob of the index-aligned doc bodies (`string[]`). `--check` rebuilds
9
+ * that payload from the real docs corpus and compares it to the embed when
10
+ * present; the checked-in empty placeholder is accepted after verifying that a
11
+ * fresh generated payload round-trips. `--reset` restores the placeholder so the
12
12
  * dev tree reads `docs/` from disk. Mirrors the stats / model-catalog embeds.
13
13
  */
14
14
 
15
15
  import * as path from "node:path";
16
- import { gzipSync } from "node:zlib";
16
+ import { gunzipSync, gzipSync } from "node:zlib";
17
17
  import { Glob } from "bun";
18
18
 
19
19
  const docsDir = path.resolve(import.meta.dir, "../../../docs");
20
20
  const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.txt");
21
21
  const GENERATE_FLAG = "--generate";
22
22
  const RESET_FLAG = "--reset";
23
+ const CHECK_FLAG = "--check";
24
+
25
+ export interface DocsIndexPayload {
26
+ /** Sorted `docs/**\/*.md` file names plus index-aligned bodies and embed text. */
27
+ readonly files: readonly string[];
28
+ readonly bodies: readonly string[];
29
+ readonly payload: string;
30
+ }
31
+
32
+ export interface DecodedDocsIndexPayload {
33
+ /** Sorted `docs/**\/*.md` file names decoded from an embed payload. */
34
+ readonly files: readonly string[];
35
+ /** Index-aligned Markdown bodies decoded from an embed payload. */
36
+ readonly bodies: readonly string[];
37
+ }
38
+
39
+ function isStringArray(value: unknown): value is string[] {
40
+ return Array.isArray(value) && value.every(item => typeof item === "string");
41
+ }
42
+
43
+ /** Build the exact two-line `omp://` docs embed from the source `docs/**\/*.md` corpus. */
44
+ export async function buildDocsIndexPayload(): Promise<DocsIndexPayload> {
45
+ const glob = new Glob("**/*.md");
46
+ const files: string[] = [];
47
+ for await (const relativePath of glob.scan(docsDir)) {
48
+ files.push(relativePath.split(path.sep).join("/"));
49
+ }
50
+ files.sort();
51
+
52
+ const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text()));
53
+ const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64");
54
+ return {
55
+ files,
56
+ bodies,
57
+ payload: `${JSON.stringify(files)}\n${bodiesB64}`,
58
+ };
59
+ }
60
+
61
+ /** Decode a populated docs embed payload into filenames and index-aligned Markdown bodies. */
62
+ export function decodeDocsIndexPayload(embed: string): DecodedDocsIndexPayload | null {
63
+ const newline = embed.indexOf("\n");
64
+ if (newline === -1) return null;
65
+
66
+ const filenames: unknown = JSON.parse(embed.slice(0, newline));
67
+ if (!isStringArray(filenames)) {
68
+ throw new Error("Embedded docs index filename line is not a JSON string array.");
69
+ }
70
+
71
+ const inflated = gunzipSync(Buffer.from(embed.slice(newline + 1), "base64"));
72
+ const bodies: unknown = JSON.parse(inflated.toString("utf8"));
73
+ if (!isStringArray(bodies)) {
74
+ throw new Error("Embedded docs index body blob is not a JSON string array.");
75
+ }
76
+
77
+ return { files: filenames, bodies };
78
+ }
79
+
80
+ /**
81
+ * Assert that an embed payload is fresh against the current source docs payload.
82
+ * An empty placeholder is accepted by round-tripping the expected payload (the
83
+ * dev tree and post-build reset state both checked-in placeholders).
84
+ */
85
+ export function assertDocsIndexFresh(embed: string, expected: DecodedDocsIndexPayload): void {
86
+ const source =
87
+ embed.length > 0
88
+ ? embed
89
+ : `${JSON.stringify(expected.files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(expected.bodies)), { level: 9 })).toString("base64")}`;
90
+ const decoded = decodeDocsIndexPayload(source);
91
+ if (decoded === null) {
92
+ throw new Error("Embedded docs index is malformed: missing newline separator.");
93
+ }
94
+ if (decoded.files.length !== expected.files.length) {
95
+ throw new Error(
96
+ `Embedded docs index has ${decoded.files.length} docs; source corpus has ${expected.files.length}.`,
97
+ );
98
+ }
99
+ if (decoded.bodies.length !== expected.bodies.length) {
100
+ throw new Error(
101
+ `Embedded docs index has ${decoded.bodies.length} bodies; source corpus has ${expected.bodies.length}.`,
102
+ );
103
+ }
104
+ for (let i = 0; i < expected.files.length; i++) {
105
+ if (decoded.files[i] !== expected.files[i]) {
106
+ throw new Error(
107
+ `Embedded docs index filename mismatch at ${i}: ${decoded.files[i] ?? "<missing>"} !== ${expected.files[i]}.`,
108
+ );
109
+ }
110
+ if (decoded.bodies[i] !== expected.bodies[i]) {
111
+ throw new Error(`Embedded docs index body mismatch for ${expected.files[i]}. Run \`bun run gen:docs\`.`);
112
+ }
113
+ }
114
+ }
23
115
 
24
116
  async function main(): Promise<void> {
25
117
  const rel = path.relative(process.cwd(), outputPath);
26
118
 
27
119
  if (process.argv.includes(RESET_FLAG)) {
28
120
  await Bun.write(outputPath, "");
29
- console.log(`Reset ${rel}`);
121
+ process.stdout.write(`Reset ${rel}\n`);
30
122
  return;
31
123
  }
32
124
 
33
- if (!process.argv.includes(GENERATE_FLAG)) {
34
- console.log(`Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)`);
125
+ if (process.argv.includes(CHECK_FLAG)) {
126
+ const current = await buildDocsIndexPayload();
127
+ const embed = await Bun.file(outputPath).text();
128
+ assertDocsIndexFresh(embed, current);
129
+ process.stdout.write(`Docs index fresh for ${current.files.length} docs (${rel})\n`);
35
130
  return;
36
131
  }
37
132
 
38
- const glob = new Glob("**/*.md");
39
- const files: string[] = [];
40
- for await (const relativePath of glob.scan(docsDir)) {
41
- files.push(relativePath.split(path.sep).join("/"));
133
+ if (!process.argv.includes(GENERATE_FLAG)) {
134
+ process.stdout.write(
135
+ `Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)\n`,
136
+ );
137
+ return;
42
138
  }
43
- files.sort();
44
-
45
- // Index-aligned bodies (Promise.all preserves order), kept separate from the
46
- // filename list so the loader can list docs without inflating the blob.
47
- const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text()));
48
139
 
49
- const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64");
50
- // Two lines: plain filename array, then the base64 gzip blob.
51
- const payload = `${JSON.stringify(files)}\n${bodiesB64}`;
52
- await Bun.write(outputPath, payload);
53
- console.log(`Generated ${rel} (${files.length} docs, ${payload.length} bytes)`);
140
+ const current = await buildDocsIndexPayload();
141
+ assertDocsIndexFresh(current.payload, current);
142
+ await Bun.write(outputPath, current.payload);
143
+ process.stdout.write(`Generated ${rel} (${current.files.length} docs, ${current.payload.length} bytes)\n`);
54
144
  }
55
145
 
56
- await main();
146
+ if (import.meta.main) {
147
+ await main();
148
+ }
@@ -397,6 +397,27 @@ export class AsyncJobManager {
397
397
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
398
398
  }
399
399
 
400
+ async #waitForAllUntil(deadline: number): Promise<boolean> {
401
+ const promises = Array.from(this.#jobs.values()).map(job => job.promise);
402
+ if (promises.length === 0) return true;
403
+ if (deadline === Number.POSITIVE_INFINITY) {
404
+ await Promise.all(promises);
405
+ return true;
406
+ }
407
+ const remainingMs = deadline - Date.now();
408
+ if (remainingMs <= 0) return false;
409
+
410
+ const timeout = Promise.withResolvers<"timeout">();
411
+ const timer = setTimeout(() => timeout.resolve("timeout"), remainingMs);
412
+ timer.unref();
413
+ try {
414
+ const result = await Promise.race([Promise.all(promises).then(() => "settled" as const), timeout.promise]);
415
+ return result === "settled";
416
+ } finally {
417
+ clearTimeout(timer);
418
+ }
419
+ }
420
+
400
421
  async drainDeliveries(options?: { timeoutMs?: number; filter?: AsyncJobFilter }): Promise<boolean> {
401
422
  const timeoutMs = options?.timeoutMs;
402
423
  const filter = options?.filter;
@@ -445,8 +466,10 @@ export class AsyncJobManager {
445
466
  this.#disposed = true;
446
467
  this.#clearEvictionTimers();
447
468
  this.cancelAll();
448
- await this.waitForAll();
449
- const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
469
+ const timeoutMs = Math.max(options?.timeoutMs ?? 3_000, 0);
470
+ const deadline = Date.now() + timeoutMs;
471
+ const jobsSettled = await this.#waitForAllUntil(deadline);
472
+ const drained = await this.drainDeliveries({ timeoutMs: Math.max(deadline - Date.now(), 0) });
450
473
  this.#clearEvictionTimers();
451
474
  this.#jobs.clear();
452
475
  this.#deliveries.length = 0;
@@ -454,7 +477,7 @@ export class AsyncJobManager {
454
477
  this.#suppressedDeliveries.clear();
455
478
  this.#watchedJobs.clear();
456
479
  this.#pollEscalation.clear();
457
- return drained;
480
+ return jobsSettled && drained;
458
481
  }
459
482
 
460
483
  #resolveJobId(preferredId?: string): string {
@@ -483,6 +506,7 @@ export class AsyncJobManager {
483
506
  }
484
507
 
485
508
  #scheduleEviction(jobId: string): void {
509
+ if (this.#disposed) return;
486
510
  if (this.#retentionMs <= 0) {
487
511
  this.#jobs.delete(jobId);
488
512
  this.#suppressedDeliveries.delete(jobId);
@@ -150,7 +150,7 @@ ${chalk.bold("Options:")}
150
150
  --no-gitignore Include files excluded by .gitignore
151
151
 
152
152
  ${chalk.bold("Environment:")}
153
- PI_GREP_WORKERS=N Set filesystem walker workers (default 4, 0 = auto)
153
+ PI_WALK_WORKERS=N Set filesystem walker workers (default 4, 0 = auto)
154
154
 
155
155
  ${chalk.bold("Examples:")}
156
156
  ${APP_NAME} grep "import" src/