@oh-my-pi/pi-coding-agent 16.3.8 → 16.3.10

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli.js +3050 -3048
  3. package/dist/types/commands/say.d.ts +6 -1
  4. package/dist/types/commit/agentic/lock-files.d.ts +36 -0
  5. package/dist/types/config/model-discovery.d.ts +3 -2
  6. package/dist/types/modes/interactive-mode.d.ts +5 -3
  7. package/dist/types/modes/session-observer-registry.d.ts +3 -1
  8. package/dist/types/session/agent-session.d.ts +11 -7
  9. package/dist/types/task/renderer.d.ts +0 -1
  10. package/dist/types/tools/browser/run-cancellation.d.ts +11 -0
  11. package/dist/types/tools/browser/tab-protocol.d.ts +1 -0
  12. package/dist/types/tools/tool-errors.d.ts +1 -1
  13. package/package.json +12 -12
  14. package/src/commands/say.ts +73 -30
  15. package/src/commit/agentic/index.ts +3 -1
  16. package/src/commit/agentic/lock-files.ts +107 -0
  17. package/src/commit/agentic/tools/git-overview.ts +1 -20
  18. package/src/config/model-discovery.ts +6 -3
  19. package/src/config/model-registry.ts +18 -7
  20. package/src/discovery/claude.ts +12 -8
  21. package/src/extensibility/skills.ts +8 -5
  22. package/src/modes/components/__tests__/skill-message.test.ts +2 -0
  23. package/src/modes/components/agent-hub.ts +17 -2
  24. package/src/modes/components/skill-message.ts +1 -1
  25. package/src/modes/index.ts +0 -7
  26. package/src/modes/interactive-mode.ts +54 -18
  27. package/src/modes/session-observer-registry.ts +11 -8
  28. package/src/sdk.ts +18 -0
  29. package/src/session/agent-session.ts +25 -13
  30. package/src/task/render.test.ts +134 -1
  31. package/src/task/render.ts +24 -6
  32. package/src/task/renderer.ts +0 -1
  33. package/src/tools/browser/cmux/cmux-tab.ts +7 -3
  34. package/src/tools/browser/run-cancellation.ts +28 -8
  35. package/src/tools/browser/tab-protocol.ts +1 -1
  36. package/src/tools/browser/tab-supervisor.ts +4 -4
  37. package/src/tools/browser/tab-worker.ts +19 -7
  38. package/src/tools/irc.ts +18 -2
  39. package/src/tools/tool-errors.ts +3 -3
  40. package/src/tts/speakable.ts +19 -9
@@ -1,19 +1,24 @@
1
1
  import { Command } from "@oh-my-pi/pi-utils/cli";
2
2
  export default class Say extends Command {
3
+ #private;
3
4
  static description: string;
4
5
  static args: {
5
6
  text: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
6
- required: true;
7
7
  description: string;
8
8
  };
9
9
  };
10
10
  static flags: {
11
11
  voice: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
12
12
  description: string;
13
+ options: readonly string[];
13
14
  };
14
15
  model: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
15
16
  description: string;
16
17
  };
18
+ file: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
19
+ char: string;
20
+ description: string;
21
+ };
17
22
  out: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
18
23
  char: string;
19
24
  description: string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Lock-file handling for the split-commit workflow.
3
+ *
4
+ * The commit agent hides these machine-generated files from analysis so the
5
+ * model does not waste tokens on them and does not treat them as evidence for
6
+ * commit boundaries. That leaves them staged but unseen: without deterministic
7
+ * post-plan placement the split validator rejects the plan with
8
+ * `Split commit plan missing staged files: <lockfile>`, and the executor
9
+ * (`git stage.reset` -> per-group `stage.hunks`) would silently drop the file
10
+ * if the validator were skipped. See issue #4632.
11
+ */
12
+ import type { SplitCommitPlan } from "./state.js";
13
+ /**
14
+ * Lock file basename -> ordered sibling manifests. Order matters: the first
15
+ * manifest present in a commit group's changes wins.
16
+ */
17
+ export declare const LOCK_FILE_MANIFESTS: Readonly<Record<string, readonly string[]>>;
18
+ /**
19
+ * Lock-file basenames the commit agent excludes from `git_overview` output and
20
+ * from split-commit validation. Derived from {@link LOCK_FILE_MANIFESTS} so a
21
+ * single edit keeps both the analysis filter and the post-plan pairing in sync.
22
+ */
23
+ export declare const EXCLUDED_LOCK_FILES: ReadonlySet<string>;
24
+ /**
25
+ * Attach staged lock files the model never saw to the split plan.
26
+ *
27
+ * Placement precedence per lock file:
28
+ * 1. commit group that touches a sibling manifest (same directory)
29
+ * 2. commit group that touches a manifest in any directory
30
+ * 3. last commit group (fallback)
31
+ *
32
+ * Mutates {@link plan} in place. No-ops on an empty plan, on lock files
33
+ * already present in some commit group, and on staged files that are not
34
+ * recognized lock files.
35
+ */
36
+ export declare function assignLockFilesToPlan(plan: SplitCommitPlan, stagedFiles: readonly string[]): void;
@@ -38,8 +38,9 @@ export interface DiscoveryContext {
38
38
  getBearerApiKeyResolver(provider: string): Promise<ApiKey | undefined>;
39
39
  }
40
40
  type LlamaCppDiscoveredModelRuntimeMetadata = {
41
- contextWindow: number;
42
- maxTokens: number;
41
+ contextWindow?: number;
42
+ maxTokens?: number;
43
+ input?: ("text" | "image")[];
43
44
  };
44
45
  export declare function discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext): Promise<Model<Api>[]>;
45
46
  export declare function discoverOllamaModels(providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext): Promise<Model<Api>[]>;
@@ -33,7 +33,7 @@ import { TranscriptContainer } from "./components/transcript-container.js";
33
33
  import { EventController } from "./controllers/event-controller.js";
34
34
  import { type LoopLimitRuntime } from "./loop-limit.js";
35
35
  import { OAuthManualInputManager } from "./oauth-manual-input.js";
36
- import type { ObservableSession } from "./session-observer-registry.js";
36
+ import { type ObservableSession } from "./session-observer-registry.js";
37
37
  import type { Theme } from "./theme/theme.js";
38
38
  import type { CompactionQueuedMessage, InteractiveModeContext, InteractiveModeInitOptions, InteractiveSelectorDialogOptions, SubmittedUserInput, TodoItem, TodoPhase } from "./types.js";
39
39
  /**
@@ -63,7 +63,7 @@ export interface InteractiveModeOptions {
63
63
  }
64
64
  /**
65
65
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
66
- * one tree row per running agent in the same `Id: description` shape the
66
+ * a bounded set of running-agent rows in the same `Id: description` shape the
67
67
  * inline task rows use (muted task preview when no description was given).
68
68
  * Layout mirrors the Todos HUD exactly: unindented header, then
69
69
  * `renderTreeList` rows (dim connectors) shifted right by one space.
@@ -217,7 +217,9 @@ export declare class InteractiveMode implements InteractiveModeContext {
217
217
  finishPendingSubmission(input: SubmittedUserInput): void;
218
218
  updateEditorBorderColor(): void;
219
219
  /** Refresh the running-subagents status badge from the active local or collab registry. */
220
- syncRunningSubagentBadge(): void;
220
+ syncRunningSubagentBadge(options?: {
221
+ requestRender?: boolean;
222
+ }): void;
221
223
  rebuildChatFromMessages(): void;
222
224
  /**
223
225
  * Render the ctrl+p model-role cycle chip track into its own anchored
@@ -21,10 +21,12 @@ export interface ObservableSession {
21
21
  /** Latest progress snapshot from the subagent executor */
22
22
  progress?: AgentProgress;
23
23
  }
24
+ /** Coarse source of an observer change; callers use it to separate lifecycle work from high-frequency progress. */
25
+ export type SessionObserverChangeKind = "main" | "reset" | "lifecycle" | "progress";
24
26
  export declare class SessionObserverRegistry {
25
27
  #private;
26
28
  /** Add a change listener. Returns unsubscribe function. */
27
- onChange(cb: () => void): () => void;
29
+ onChange(cb: (kind: SessionObserverChangeKind) => void): () => void;
28
30
  setMainSession(sessionFile?: string): void;
29
31
  getSessions(): ObservableSession[];
30
32
  getActiveSubagentCount(): number;
@@ -423,6 +423,7 @@ export interface SessionStats {
423
423
  };
424
424
  premiumRequests: number;
425
425
  cost: number;
426
+ contextUsage?: ContextUsage;
426
427
  }
427
428
  /** Advisor statistics for /advisor status command. */
428
429
  export interface AdvisorStats {
@@ -1160,15 +1161,18 @@ export declare class AgentSession {
1160
1161
  /** Whether there are pending Python messages waiting to be flushed */
1161
1162
  get hasPendingPythonMessages(): boolean;
1162
1163
  /**
1163
- * Surfaces (and consumes) pending IRC incoming records before the next model
1164
+ * Surfaces and consumes pending IRC incoming records before the next model
1164
1165
  * step can inject them automatically.
1165
1166
  *
1166
- * The inbox tool injects the formatted body into the tool result, so the
1167
- * model sees it once via the result. Leaving the record in either pending
1168
- * IRC queue would deliver it a second time at the next step boundary —
1169
- * including on `peek`, which is why peek also drains here.
1170
- */
1171
- drainPendingIrcInboxMessages(agentId: string): IrcMessage[];
1167
+ * Tool results already expose the formatted body to the model. Leaving the
1168
+ * same record in either pending IRC queue would deliver it a second time at
1169
+ * the next step boundary including on `peek`, which is why inbox peeks
1170
+ * also drain here.
1171
+ */
1172
+ drainPendingIrcInboxMessages(agentId: string, opts?: {
1173
+ from?: string;
1174
+ limit?: number;
1175
+ }): IrcMessage[];
1172
1176
  /**
1173
1177
  * Deliver an IRC message into this session (recipient side; called by the
1174
1178
  * IrcBus). Emits the `irc_message` session event for UI cards and injects
@@ -10,5 +10,4 @@ export declare const taskToolRenderer: {
10
10
  readonly renderCall: typeof renderCall;
11
11
  readonly renderResult: typeof renderResult;
12
12
  readonly mergeCallAndResult: true;
13
- readonly animatedPartialResult: true;
14
13
  };
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Marks a run-scoped promise as observed without changing its behavior for awaited callers.
3
+ *
4
+ * Browser run teardown aborts can reject promises created for evaluated code after user code
5
+ * has stopped observing them (for example fire-and-forget `wait()`/facade calls). In 16.3.0
6
+ * those zero-consumer rejections reached the process-level `unhandledRejection` handler and
7
+ * killed every subagent sharing the process (issues #4499/#4672). Attaching a no-op rejection
8
+ * handler at creation makes the promise observed while returning the original promise so callers
9
+ * that do await it still receive the rejection.
10
+ */
11
+ export declare function markHandled<T>(promise: Promise<T>): Promise<T>;
1
12
  /** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
2
13
  export declare function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void>;
3
14
  /** Binds a long-lived browser facade to one evaluated run's abort signal. */
@@ -80,6 +80,7 @@ export type WorkerInbound = {
80
80
  } | {
81
81
  type: "abort";
82
82
  id: string;
83
+ expectedCleanup?: boolean;
83
84
  } | {
84
85
  type: "tool-reply";
85
86
  id: string;
@@ -19,7 +19,7 @@ export declare class ToolError extends Error {
19
19
  */
20
20
  export declare class ToolAbortError extends Error {
21
21
  static readonly MESSAGE = "Operation aborted";
22
- constructor(message?: string);
22
+ constructor(message?: string, options?: ErrorOptions);
23
23
  }
24
24
  /**
25
25
  * Throw ToolAbortError if the signal is aborted.
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.3.8",
4
+ "version": "16.3.10",
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",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.8",
60
- "@oh-my-pi/omp-stats": "16.3.8",
61
- "@oh-my-pi/pi-agent-core": "16.3.8",
62
- "@oh-my-pi/pi-ai": "16.3.8",
63
- "@oh-my-pi/pi-catalog": "16.3.8",
64
- "@oh-my-pi/pi-mnemopi": "16.3.8",
65
- "@oh-my-pi/pi-natives": "16.3.8",
66
- "@oh-my-pi/pi-tui": "16.3.8",
67
- "@oh-my-pi/pi-utils": "16.3.8",
68
- "@oh-my-pi/pi-wire": "16.3.8",
69
- "@oh-my-pi/snapcompact": "16.3.8",
59
+ "@oh-my-pi/hashline": "16.3.10",
60
+ "@oh-my-pi/omp-stats": "16.3.10",
61
+ "@oh-my-pi/pi-agent-core": "16.3.10",
62
+ "@oh-my-pi/pi-ai": "16.3.10",
63
+ "@oh-my-pi/pi-catalog": "16.3.10",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.10",
65
+ "@oh-my-pi/pi-natives": "16.3.10",
66
+ "@oh-my-pi/pi-tui": "16.3.10",
67
+ "@oh-my-pi/pi-utils": "16.3.10",
68
+ "@oh-my-pi/pi-wire": "16.3.10",
69
+ "@oh-my-pi/snapcompact": "16.3.10",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Synthesize text with the local TTS engine and play it (or save it with --out).
3
3
  *
4
- * Demonstrates the on-device speech stack end to end: the first run downloads
5
- * the configured local model, synthesis happens in the TTS worker subprocess,
6
- * and the resulting WAV is either played through the speakers or written to disk.
4
+ * Text comes from the argument or --file. Input is segmented into
5
+ * sentence-sized chunks ({@link SpeakableStream}) and synthesized through the
6
+ * streaming TTS worker, so arbitrarily long text plays gaplessly instead of
7
+ * hitting Kokoro's single-call ~510-phoneme truncation. --out concatenates the
8
+ * streamed segments into one WAV. The first run downloads the configured local
9
+ * model into the worker's cache.
7
10
  */
8
- import * as os from "node:os";
9
- import * as path from "node:path";
10
- import { getProjectDir, Snowflake } from "@oh-my-pi/pi-utils";
11
+ import { getProjectDir } from "@oh-my-pi/pi-utils";
11
12
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
12
13
  import chalk from "chalk";
13
14
  import { Settings, settings } from "../config/settings";
14
- import { playAudioFile, removeTempFile } from "../tts/player";
15
+ import { TTS_LOCAL_VOICE_VALUES } from "../tts/models";
16
+ import { SpeakableStream } from "../tts/speakable";
17
+ import { StreamingAudioPlayer } from "../tts/streaming-player";
15
18
  import { shutdownTtsClient, ttsClient } from "../tts/tts-client";
16
19
  import { encodeWav } from "../tts/wav";
17
20
 
@@ -19,24 +22,28 @@ export default class Say extends Command {
19
22
  static description = "Synthesize text with the local TTS engine and play it through the speakers";
20
23
 
21
24
  static args = {
22
- text: Args.string({ required: true, description: "Text to speak" }),
25
+ text: Args.string({ description: "Text to speak (or use --file)" }),
23
26
  };
24
27
 
25
28
  static flags = {
26
- voice: Flags.string({ description: "Voice id" }),
29
+ voice: Flags.string({ description: "Voice id", options: TTS_LOCAL_VOICE_VALUES }),
27
30
  model: Flags.string({ description: "Local TTS model key" }),
31
+ file: Flags.string({ char: "f", description: "Read the text to speak from this file" }),
28
32
  out: Flags.string({ char: "o", description: "Write WAV to this path instead of playing" }),
29
33
  };
30
34
 
31
35
  static examples = [
32
36
  'omp say "hello world"',
37
+ "omp say --file notes.md --voice bm_fable",
33
38
  'omp say "hello world" --out /tmp/hello.wav',
34
- 'omp say "bonjour" --voice af_heart --model kokoro',
35
39
  ];
36
40
 
37
41
  async run(): Promise<void> {
38
42
  const { args, flags } = await this.parse(Say);
39
- const text = args.text ?? "";
43
+ if (args.text && flags.file) {
44
+ process.stderr.write(chalk.red("error: pass either text or --file, not both\n"));
45
+ process.exit(1);
46
+ }
40
47
 
41
48
  await Settings.init({ cwd: getProjectDir() });
42
49
  const model = flags.model ?? settings.get("tts.localModel");
@@ -55,23 +62,42 @@ export default class Say extends Command {
55
62
  });
56
63
 
57
64
  try {
58
- const audio = await ttsClient.synthesize(model, text, { voice });
59
- if (!audio) {
60
- process.stderr.write(
61
- chalk.red(
62
- `error: could not synthesize with local TTS model "${model}". ` +
63
- "Run `omp setup speech` to install it.\n",
64
- ),
65
- );
65
+ const text = flags.file ? await Bun.file(flags.file).text() : (args.text ?? "");
66
+ const splitter = new SpeakableStream();
67
+ const segments = [...splitter.push(text), ...splitter.flush()];
68
+ if (segments.length === 0) {
69
+ process.stderr.write(chalk.red("error: nothing speakable in the input\n"));
66
70
  exitCode = 1;
67
71
  return;
68
72
  }
69
73
 
70
- const wav = encodeWav(audio.pcm, audio.sampleRate);
71
- const durationSec = audio.pcm.length / audio.sampleRate;
74
+ const stream = ttsClient.synthesizeStream(model, { voice });
75
+ for (const segment of segments) stream.push(segment);
76
+ stream.end();
72
77
 
73
78
  if (flags.out) {
79
+ const pcms: Float32Array[] = [];
80
+ let total = 0;
81
+ let sampleRate = 0;
82
+ for await (const chunk of stream.chunks) {
83
+ pcms.push(chunk.pcm);
84
+ total += chunk.pcm.length;
85
+ sampleRate = chunk.sampleRate;
86
+ }
87
+ if (total === 0) {
88
+ this.#synthesisFailed(model);
89
+ exitCode = 1;
90
+ return;
91
+ }
92
+ const pcm = new Float32Array(total);
93
+ let offset = 0;
94
+ for (const part of pcms) {
95
+ pcm.set(part, offset);
96
+ offset += part.length;
97
+ }
98
+ const wav = encodeWav(pcm, sampleRate);
74
99
  await Bun.write(flags.out, wav);
100
+ const durationSec = total / sampleRate;
75
101
  process.stdout.write(
76
102
  `${chalk.green("saved")} ${flags.out} ` +
77
103
  `${chalk.dim(`(${voice}, ${model}, ${durationSec.toFixed(1)}s, ${wav.byteLength} bytes)`)}\n`,
@@ -79,16 +105,25 @@ export default class Say extends Command {
79
105
  return;
80
106
  }
81
107
 
82
- const tmp = path.join(os.tmpdir(), `omp-say-${Snowflake.next()}.wav`);
83
- await Bun.write(tmp, wav);
84
- try {
85
- await playAudioFile(tmp);
86
- process.stdout.write(
87
- `${chalk.green("spoke")} ${chalk.dim(`(${voice}, ${model}, ${durationSec.toFixed(1)}s)`)}\n`,
88
- );
89
- } finally {
90
- await removeTempFile(tmp);
108
+ const player = new StreamingAudioPlayer();
109
+ let spoken = 0;
110
+ let seconds = 0;
111
+ for await (const chunk of stream.chunks) {
112
+ player.start(chunk.sampleRate);
113
+ player.write(chunk.pcm);
114
+ spoken++;
115
+ seconds += chunk.pcm.length / chunk.sampleRate;
91
116
  }
117
+ if (spoken === 0) {
118
+ player.stop();
119
+ this.#synthesisFailed(model);
120
+ exitCode = 1;
121
+ return;
122
+ }
123
+ await player.end();
124
+ process.stdout.write(
125
+ `${chalk.green("spoke")} ${chalk.dim(`(${voice}, ${model}, ${seconds.toFixed(1)}s, ${spoken} segments)`)}\n`,
126
+ );
92
127
  } catch (err) {
93
128
  process.stderr.write(chalk.red(`error: ${err instanceof Error ? err.message : String(err)}\n`));
94
129
  exitCode = 1;
@@ -99,4 +134,12 @@ export default class Say extends Command {
99
134
 
100
135
  if (exitCode !== 0) process.exit(exitCode);
101
136
  }
137
+
138
+ #synthesisFailed(model: string): void {
139
+ process.stderr.write(
140
+ chalk.red(
141
+ `error: could not synthesize with local TTS model "${model}". Run \`omp setup speech\` to install it.\n`,
142
+ ),
143
+ );
144
+ }
102
145
  }
@@ -13,6 +13,7 @@ import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
13
13
  import * as git from "../../utils/git";
14
14
  import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
15
15
  import { generateFallbackProposal } from "./fallback";
16
+ import { assignLockFilesToPlan } from "./lock-files";
16
17
  import splitConfirmPrompt from "./prompts/split-confirm.md" with { type: "text" };
17
18
  import type { CommitAgentState, CommitProposal, HunkSelector, SplitCommitPlan } from "./state";
18
19
  import { computeDependencyOrder } from "./topo-sort";
@@ -230,6 +231,7 @@ async function runSplitCommit(
230
231
  appendFilesToLastCommit(plan, ctx.additionalFiles);
231
232
  }
232
233
  const stagedFiles = await git.diff.changedFiles(ctx.cwd, { cached: true });
234
+ assignLockFilesToPlan(plan, stagedFiles);
233
235
  const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
234
236
  const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file));
235
237
  if (missingFiles.length > 0) {
@@ -266,7 +268,7 @@ async function runSplitCommit(
266
268
  throw new Error(order.error);
267
269
  }
268
270
 
269
- const stagedDiff = await git.diff(ctx.cwd, { cached: true });
271
+ const stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true });
270
272
  await git.stage.reset(ctx.cwd);
271
273
  for (const commitIndex of order) {
272
274
  const commit = plan.commits[commitIndex];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Lock-file handling for the split-commit workflow.
3
+ *
4
+ * The commit agent hides these machine-generated files from analysis so the
5
+ * model does not waste tokens on them and does not treat them as evidence for
6
+ * commit boundaries. That leaves them staged but unseen: without deterministic
7
+ * post-plan placement the split validator rejects the plan with
8
+ * `Split commit plan missing staged files: <lockfile>`, and the executor
9
+ * (`git stage.reset` -> per-group `stage.hunks`) would silently drop the file
10
+ * if the validator were skipped. See issue #4632.
11
+ */
12
+
13
+ import type { SplitCommitPlan } from "./state";
14
+
15
+ /**
16
+ * Lock file basename -> ordered sibling manifests. Order matters: the first
17
+ * manifest present in a commit group's changes wins.
18
+ */
19
+ export const LOCK_FILE_MANIFESTS: Readonly<Record<string, readonly string[]>> = {
20
+ "Cargo.lock": ["Cargo.toml"],
21
+ "package-lock.json": ["package.json"],
22
+ "yarn.lock": ["package.json"],
23
+ "pnpm-lock.yaml": ["package.json"],
24
+ "bun.lock": ["package.json"],
25
+ "bun.lockb": ["package.json"],
26
+ "go.sum": ["go.mod"],
27
+ "poetry.lock": ["pyproject.toml"],
28
+ "Pipfile.lock": ["Pipfile"],
29
+ "uv.lock": ["pyproject.toml"],
30
+ "composer.lock": ["composer.json"],
31
+ "Gemfile.lock": ["Gemfile"],
32
+ "flake.lock": ["flake.nix"],
33
+ "pubspec.lock": ["pubspec.yaml"],
34
+ "Podfile.lock": ["Podfile"],
35
+ "mix.lock": ["mix.exs"],
36
+ "gradle.lockfile": ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"],
37
+ };
38
+
39
+ /**
40
+ * Lock-file basenames the commit agent excludes from `git_overview` output and
41
+ * from split-commit validation. Derived from {@link LOCK_FILE_MANIFESTS} so a
42
+ * single edit keeps both the analysis filter and the post-plan pairing in sync.
43
+ */
44
+ export const EXCLUDED_LOCK_FILES: ReadonlySet<string> = new Set(Object.keys(LOCK_FILE_MANIFESTS));
45
+
46
+ /**
47
+ * Attach staged lock files the model never saw to the split plan.
48
+ *
49
+ * Placement precedence per lock file:
50
+ * 1. commit group that touches a sibling manifest (same directory)
51
+ * 2. commit group that touches a manifest in any directory
52
+ * 3. last commit group (fallback)
53
+ *
54
+ * Mutates {@link plan} in place. No-ops on an empty plan, on lock files
55
+ * already present in some commit group, and on staged files that are not
56
+ * recognized lock files.
57
+ */
58
+ export function assignLockFilesToPlan(plan: SplitCommitPlan, stagedFiles: readonly string[]): void {
59
+ if (plan.commits.length === 0) return;
60
+
61
+ const planned = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
62
+ const orphanedLockFiles: string[] = [];
63
+ for (const file of stagedFiles) {
64
+ if (planned.has(file)) continue;
65
+ const parts = file.split("/");
66
+ const basename = parts[parts.length - 1];
67
+ if (EXCLUDED_LOCK_FILES.has(basename)) orphanedLockFiles.push(file);
68
+ }
69
+ if (orphanedLockFiles.length === 0) return;
70
+
71
+ for (const lockFile of orphanedLockFiles) {
72
+ const parts = lockFile.split("/");
73
+ const basename = parts[parts.length - 1];
74
+ const dir = parts.slice(0, -1).join("/");
75
+ const manifests = LOCK_FILE_MANIFESTS[basename] ?? [];
76
+ const targetIndex = findManifestCommitIndex(plan, dir, manifests);
77
+ plan.commits[targetIndex].changes.push({ path: lockFile, hunks: { type: "all" } });
78
+ planned.add(lockFile);
79
+ }
80
+ }
81
+
82
+ function findManifestCommitIndex(plan: SplitCommitPlan, lockDir: string, manifests: readonly string[]): number {
83
+ // Prefer a manifest in the same directory as the lock file — the strongest
84
+ // semantic signal (e.g. workspace-crate `Cargo.toml` next to `Cargo.lock`).
85
+ for (const manifestName of manifests) {
86
+ for (let i = 0; i < plan.commits.length; i++) {
87
+ for (const change of plan.commits[i].changes) {
88
+ const parts = change.path.split("/");
89
+ const basename = parts[parts.length - 1];
90
+ const dir = parts.slice(0, -1).join("/");
91
+ if (basename === manifestName && dir === lockDir) return i;
92
+ }
93
+ }
94
+ }
95
+ // Fall back to any matching manifest — a monorepo may lock at repo root
96
+ // while the manifest sits under a subpath.
97
+ for (const manifestName of manifests) {
98
+ for (let i = 0; i < plan.commits.length; i++) {
99
+ for (const change of plan.commits[i].changes) {
100
+ const parts = change.path.split("/");
101
+ if (parts[parts.length - 1] === manifestName) return i;
102
+ }
103
+ }
104
+ }
105
+ // Nothing matched: attach to the last commit so the file still ships.
106
+ return plan.commits.length - 1;
107
+ }
@@ -3,26 +3,7 @@ import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agen
3
3
  import { extractScopeCandidates } from "../../../commit/analysis/scope";
4
4
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
5
5
  import * as git from "../../../utils/git";
6
-
7
- const EXCLUDED_LOCK_FILES = new Set([
8
- "Cargo.lock",
9
- "package-lock.json",
10
- "yarn.lock",
11
- "pnpm-lock.yaml",
12
- "bun.lock",
13
- "bun.lockb",
14
- "go.sum",
15
- "poetry.lock",
16
- "Pipfile.lock",
17
- "uv.lock",
18
- "composer.lock",
19
- "Gemfile.lock",
20
- "flake.lock",
21
- "pubspec.lock",
22
- "Podfile.lock",
23
- "mix.lock",
24
- "gradle.lockfile",
25
- ]);
6
+ import { EXCLUDED_LOCK_FILES } from "../lock-files";
26
7
 
27
8
  function isExcludedFile(path: string): boolean {
28
9
  const basename = path.split("/").pop() ?? path;
@@ -151,8 +151,9 @@ type LlamaCppDiscoveredServerMetadata = {
151
151
  };
152
152
 
153
153
  type LlamaCppDiscoveredModelRuntimeMetadata = {
154
- contextWindow: number;
155
- maxTokens: number;
154
+ contextWindow?: number;
155
+ maxTokens?: number;
156
+ input?: ("text" | "image")[];
156
157
  };
157
158
 
158
159
  type LlamaCppModelListEntry = {
@@ -594,12 +595,14 @@ export async function discoverLlamaCppModelRuntimeMetadata(
594
595
  entry.configuredContextWindow ??
595
596
  serverMetadata?.contextWindow ??
596
597
  entry.trainingContextWindow;
598
+ const input = serverMetadata?.input;
597
599
  if (contextWindow === undefined) {
598
- return undefined;
600
+ return input === undefined ? undefined : { input };
599
601
  }
600
602
  return {
601
603
  contextWindow,
602
604
  maxTokens: resolveLlamaCppMaxTokens(contextWindow, serverMetadata?.maxTokens),
605
+ ...(input !== undefined ? { input } : {}),
603
606
  };
604
607
  };
605
608
  try {
@@ -866,12 +866,13 @@ export class ModelRegistry {
866
866
  if (runtimeMetadata === undefined) {
867
867
  return this.find(model.provider, model.id) ?? model;
868
868
  }
869
- const { contextWindow, maxTokens } = runtimeMetadata;
869
+ const { contextWindow, maxTokens, input } = runtimeMetadata;
870
870
  const current = this.find(model.provider, model.id) ?? model;
871
871
  const override = this.#resolveLiveModelOverride(current);
872
872
  const customModel = this.#resolveLiveCustomModelOverlay(current);
873
873
  const patch: ModelPatch = {};
874
874
  if (
875
+ contextWindow !== undefined &&
875
876
  override?.contextWindow === undefined &&
876
877
  customModel?.contextWindow === undefined &&
877
878
  current.contextWindow !== contextWindow
@@ -884,15 +885,25 @@ export class ModelRegistry {
884
885
  patch.contextWindow ??
885
886
  current.contextWindow ??
886
887
  contextWindow;
887
- const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
888
+ if (maxTokens !== undefined && effectiveContextWindow !== undefined) {
889
+ const effectiveMaxTokens = Math.min(maxTokens, effectiveContextWindow);
890
+ if (
891
+ override?.maxTokens === undefined &&
892
+ customModel?.maxTokens === undefined &&
893
+ current.maxTokens !== effectiveMaxTokens
894
+ ) {
895
+ patch.maxTokens = effectiveMaxTokens;
896
+ }
897
+ }
888
898
  if (
889
- override?.maxTokens === undefined &&
890
- customModel?.maxTokens === undefined &&
891
- current.maxTokens !== effectiveMaxTokens
899
+ input !== undefined &&
900
+ override?.input === undefined &&
901
+ customModel?.input === undefined &&
902
+ (current.input.length !== input.length || current.input.some((value, index) => value !== input[index]))
892
903
  ) {
893
- patch.maxTokens = effectiveMaxTokens;
904
+ patch.input = input;
894
905
  }
895
- if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
906
+ if (patch.contextWindow === undefined && patch.maxTokens === undefined && patch.input === undefined) {
896
907
  return current;
897
908
  }
898
909
  const patched = applyModelPatch(current, patch, "merge");