@oh-my-pi/pi-coding-agent 14.6.4 → 14.6.6

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.6.6] - 2026-05-04
6
+
7
+ ### Added
8
+
9
+ - Added Ctrl+D draft persistence: pressing Ctrl+D with text in the editor now exits the app and saves the unsent text as a per-session draft. Resuming the same session (e.g. via `--resume`) restores the draft into the editor (one-shot, removed after restore).
10
+
5
11
  ## [14.6.4] - 2026-05-03
6
12
  ### Added
7
13
 
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": "14.6.4",
4
+ "version": "14.6.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.6.4",
50
- "@oh-my-pi/pi-agent-core": "14.6.4",
51
- "@oh-my-pi/pi-ai": "14.6.4",
52
- "@oh-my-pi/pi-natives": "14.6.4",
53
- "@oh-my-pi/pi-tui": "14.6.4",
54
- "@oh-my-pi/pi-utils": "14.6.4",
49
+ "@oh-my-pi/omp-stats": "14.6.6",
50
+ "@oh-my-pi/pi-agent-core": "14.6.6",
51
+ "@oh-my-pi/pi-ai": "14.6.6",
52
+ "@oh-my-pi/pi-natives": "14.6.6",
53
+ "@oh-my-pi/pi-tui": "14.6.6",
54
+ "@oh-my-pi/pi-utils": "14.6.6",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -1486,7 +1486,9 @@ async function executeHashlineSection(
1486
1486
  export async function executeHashlineSingle(
1487
1487
  options: ExecuteHashlineSingleOptions,
1488
1488
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1489
- const sections = splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path });
1489
+ const sections = mergeSamePathSections(
1490
+ splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
1491
+ );
1490
1492
 
1491
1493
  // Fast path: a single section needs no preflight pass.
1492
1494
  if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
@@ -1518,3 +1520,20 @@ export async function executeHashlineSingle(
1518
1520
  },
1519
1521
  };
1520
1522
  }
1523
+
1524
+ /**
1525
+ * Collapse consecutive or interleaved sections targeting the same path into a
1526
+ * single section with concatenated diffs. Anchors authored against the same
1527
+ * file snapshot must be applied as one batch; otherwise the first sub-edit
1528
+ * shifts line numbers out from under the second's anchors and rebase fails.
1529
+ * Path order is preserved by first occurrence.
1530
+ */
1531
+ function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
1532
+ const byPath = new Map<string, string[]>();
1533
+ for (const section of sections) {
1534
+ const existing = byPath.get(section.path);
1535
+ if (existing) existing.push(section.diff);
1536
+ else byPath.set(section.path, [section.diff]);
1537
+ }
1538
+ return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
1539
+ }
@@ -197,12 +197,11 @@ export class CustomEditor extends Editor {
197
197
  return;
198
198
  }
199
199
 
200
- // Intercept configured exit shortcut (only when editor is empty)
200
+ // Intercept configured exit shortcut. Always consume the shortcut so it
201
+ // never reaches the parent handler; firing onExit is the controller's
202
+ // chance to snapshot the current text as a draft before shutting down.
201
203
  if (this.#matchesAction(data, "app.exit")) {
202
- if (this.getText().length === 0 && this.onExit) {
203
- this.onExit();
204
- }
205
- // Always consume exit shortcut (don't pass to parent)
204
+ this.onExit?.();
206
205
  return;
207
206
  }
208
207
 
@@ -344,8 +344,11 @@ export class InputController {
344
344
  // (a user-role `message_start` event) leaves any draft the user has
345
345
  // typed since queuing intact. Same protection as #783, applied to
346
346
  // the streaming/queue path.
347
- this.ctx.locallySubmittedUserSignatures.add(`${text}\u0000${images?.length ?? 0}`);
348
- await this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
347
+ await this.ctx.withLocalSubmission(
348
+ text,
349
+ () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
350
+ { imageCount: images?.length ?? 0 },
351
+ );
349
352
  this.ctx.updatePendingMessagesDisplay();
350
353
  this.ctx.ui.requestRender();
351
354
  return;
@@ -400,7 +403,9 @@ export class InputController {
400
403
  }
401
404
 
402
405
  handleCtrlD(): void {
403
- // Only called when editor is empty (enforced by CustomEditor)
406
+ // Editor text (if any) is snapshotted at the start of shutdown() and
407
+ // persisted as a draft for the next resume. Empty text is also fine —
408
+ // shutdown clears any stale sidecar in that case.
404
409
  void this.ctx.shutdown();
405
410
  }
406
411
 
@@ -440,7 +445,9 @@ export class InputController {
440
445
  if (this.ctx.session.isStreaming) {
441
446
  this.ctx.editor.addToHistory(text);
442
447
  this.ctx.editor.setText("");
443
- await this.ctx.session.prompt(text, { streamingBehavior: "followUp" });
448
+ await this.ctx.withLocalSubmission(text, () =>
449
+ this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
450
+ );
444
451
  this.ctx.updatePendingMessagesDisplay();
445
452
  this.ctx.ui.requestRender();
446
453
  return;
@@ -449,7 +456,7 @@ export class InputController {
449
456
  // Not streaming — just submit normally
450
457
  this.ctx.editor.addToHistory(text);
451
458
  this.ctx.editor.setText("");
452
- await this.ctx.session.prompt(text);
459
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text));
453
460
  }
454
461
 
455
462
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
@@ -183,6 +183,7 @@ export class InteractiveMode implements InteractiveModeContext {
183
183
  optimisticUserMessageSignature: string | undefined = undefined;
184
184
  locallySubmittedUserSignatures: Set<string> = new Set();
185
185
  #pendingSubmittedInput: SubmittedUserInput | undefined;
186
+ #pendingSubmissionDispose: (() => void) | undefined;
186
187
  lastSigintTime = 0;
187
188
  lastEscapeTime = 0;
188
189
  shutdownRequested = false;
@@ -444,6 +445,20 @@ export class InteractiveMode implements InteractiveModeContext {
444
445
  // Restore mode from session (e.g. plan mode on resume)
445
446
  await this.#restoreModeFromSession();
446
447
 
448
+ // Restore unsent editor draft from previous session shutdown (Ctrl+D).
449
+ // One-shot: consumeDraft removes the sidecar after read so the next
450
+ // resume does not re-restore the same text.
451
+ try {
452
+ const draft = await this.sessionManager.consumeDraft();
453
+ if (draft && !this.editor.getText()) {
454
+ this.editor.setText(draft);
455
+ this.updateEditorBorderColor();
456
+ this.ui.requestRender();
457
+ }
458
+ } catch (err) {
459
+ logger.warn("Failed to restore session draft", { error: String(err) });
460
+ }
461
+
447
462
  // Subscribe to agent events
448
463
  this.#subscribeToAgent();
449
464
 
@@ -567,6 +582,30 @@ export class InteractiveMode implements InteractiveModeContext {
567
582
  );
568
583
  }
569
584
 
585
+ recordLocalSubmission(text: string, imageCount = 0): () => void {
586
+ if (this.isKnownSlashCommand(text)) {
587
+ return () => {};
588
+ }
589
+ const signature = `${text}\u0000${imageCount}`;
590
+ this.locallySubmittedUserSignatures.add(signature);
591
+ let disposed = false;
592
+ return () => {
593
+ if (disposed) return;
594
+ disposed = true;
595
+ this.locallySubmittedUserSignatures.delete(signature);
596
+ };
597
+ }
598
+
599
+ async withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T> {
600
+ const dispose = this.recordLocalSubmission(text, options?.imageCount ?? 0);
601
+ try {
602
+ return await fn();
603
+ } catch (err) {
604
+ dispose();
605
+ throw err;
606
+ }
607
+ }
608
+
570
609
  startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
571
610
  const submission: SubmittedUserInput = {
572
611
  text: input.text,
@@ -575,8 +614,9 @@ export class InteractiveMode implements InteractiveModeContext {
575
614
  started: false,
576
615
  };
577
616
  this.#pendingSubmittedInput = submission;
578
- this.optimisticUserMessageSignature = `${submission.text}\u0000${submission.images?.length ?? 0}`;
579
- this.locallySubmittedUserSignatures.add(this.optimisticUserMessageSignature);
617
+ const imageCount = submission.images?.length ?? 0;
618
+ this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
619
+ this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
580
620
  this.addMessageToChat({
581
621
  role: "user",
582
622
  content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
@@ -598,7 +638,8 @@ export class InteractiveMode implements InteractiveModeContext {
598
638
  submission.cancelled = true;
599
639
  this.#pendingSubmittedInput = undefined;
600
640
  this.optimisticUserMessageSignature = undefined;
601
- this.locallySubmittedUserSignatures.delete(`${submission.text}\u0000${submission.images?.length ?? 0}`);
641
+ this.#pendingSubmissionDispose?.();
642
+ this.#pendingSubmissionDispose = undefined;
602
643
  this.#pendingWorkingMessage = undefined;
603
644
  if (this.loadingAnimation) {
604
645
  this.loadingAnimation.stop();
@@ -624,6 +665,7 @@ export class InteractiveMode implements InteractiveModeContext {
624
665
  finishPendingSubmission(input: SubmittedUserInput): void {
625
666
  if (this.#pendingSubmittedInput === input) {
626
667
  this.#pendingSubmittedInput = undefined;
668
+ this.#pendingSubmissionDispose = undefined;
627
669
  }
628
670
  }
629
671
 
@@ -1161,8 +1203,18 @@ export class InteractiveMode implements InteractiveModeContext {
1161
1203
  if (this.#isShuttingDown) return;
1162
1204
  this.#isShuttingDown = true;
1163
1205
 
1206
+ // Snapshot the editor before any teardown empties it. Persisting the draft
1207
+ // here covers Ctrl+D shutdown with non-empty text; for /exit the editor is
1208
+ // already cleared so saveDraft("") just removes any stale sidecar.
1209
+ const draftText = this.editor.getText();
1210
+
1164
1211
  // Flush pending session writes before shutdown
1165
1212
  await this.sessionManager.flush();
1213
+ try {
1214
+ await this.sessionManager.saveDraft(draftText);
1215
+ } catch (err) {
1216
+ logger.warn("Failed to save session draft", { error: String(err) });
1217
+ }
1166
1218
  this.#btwController.dispose();
1167
1219
 
1168
1220
  // Emit shutdown event to hooks
@@ -1223,6 +1275,8 @@ export class InteractiveMode implements InteractiveModeContext {
1223
1275
  showError(message: string): void {
1224
1276
  this.#pendingSubmittedInput = undefined;
1225
1277
  this.optimisticUserMessageSignature = undefined;
1278
+ this.#pendingSubmissionDispose?.();
1279
+ this.#pendingSubmissionDispose = undefined;
1226
1280
  this.#pendingWorkingMessage = undefined;
1227
1281
  if (this.loadingAnimation) {
1228
1282
  this.loadingAnimation.stop();
@@ -151,6 +151,19 @@ export interface InteractiveModeContext {
151
151
  cancelPendingSubmission(): boolean;
152
152
  markPendingSubmissionStarted(input: SubmittedUserInput): boolean;
153
153
  finishPendingSubmission(input: SubmittedUserInput): void;
154
+ /**
155
+ * Marks a locally-initiated user submission so the eventual `message_start`
156
+ * event for that user message does not clobber the editor draft (see #783).
157
+ * Returns a dispose function that removes the signature; call it on
158
+ * delivery failure so a retry can be re-marked cleanly.
159
+ */
160
+ recordLocalSubmission(text: string, imageCount?: number): () => void;
161
+ /**
162
+ * Wraps `fn` in a `recordLocalSubmission` marker that is automatically
163
+ * removed if `fn` rejects. Use this for the common case where a thrown
164
+ * delivery error should leave the signature set untouched.
165
+ */
166
+ withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
154
167
  isKnownSlashCommand(text: string): boolean;
155
168
  addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
156
169
  renderSessionContext(
@@ -534,6 +534,16 @@ export class UiHelpers {
534
534
  this.ctx.showStatus("Queued message for after compaction");
535
535
  }
536
536
 
537
+ async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise<void> {
538
+ if (this.ctx.isKnownSlashCommand(message.text)) {
539
+ await this.ctx.session.prompt(message.text);
540
+ return;
541
+ }
542
+ await this.ctx.withLocalSubmission(message.text, () =>
543
+ message.mode === "followUp" ? this.ctx.session.followUp(message.text) : this.ctx.session.steer(message.text),
544
+ );
545
+ }
546
+
537
547
  isKnownSlashCommand(text: string): boolean {
538
548
  if (!text.startsWith("/")) return false;
539
549
  const spaceIndex = text.indexOf(" ");
@@ -576,13 +586,7 @@ export class UiHelpers {
576
586
  try {
577
587
  if (options?.willRetry) {
578
588
  for (const message of queuedMessages) {
579
- if (this.ctx.isKnownSlashCommand(message.text)) {
580
- await this.ctx.session.prompt(message.text);
581
- } else if (message.mode === "followUp") {
582
- await this.ctx.session.followUp(message.text);
583
- } else {
584
- await this.ctx.session.steer(message.text);
585
- }
589
+ await this.#deliverQueuedMessage(message);
586
590
  }
587
591
  this.ctx.updatePendingMessagesDisplay();
588
592
  return;
@@ -607,7 +611,10 @@ export class UiHelpers {
607
611
  const rest = queuedMessages.slice(firstPromptIndex + 1);
608
612
 
609
613
  for (const message of preCommands) {
610
- await this.ctx.session.prompt(message.text);
614
+ // preCommands are all slash commands; #deliverQueuedMessage handles
615
+ // that branch (no local-submission marking needed since slash
616
+ // commands don't generate a matching user message_start).
617
+ await this.#deliverQueuedMessage(message);
611
618
  }
612
619
 
613
620
  // Pass streamingBehavior so that if the session is still streaming when
@@ -619,22 +626,22 @@ export class UiHelpers {
619
626
  // deferred, the message lands in the same queue every other consumer
620
627
  // (Alt+Up dequeue, post-stream drain) already drains, instead of being
621
628
  // stranded in compactionQueuedMessages with no drainer.
629
+ //
630
+ // firstPrompt is fire-and-forget — its rejection is funneled through
631
+ // `restoreQueue` rather than rethrown, so we use the primitive
632
+ // recordLocalSubmission and dispose manually in the catch.
633
+ const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text);
622
634
  const promptPromise = this.ctx.session
623
635
  .prompt(firstPrompt.text, {
624
636
  streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer",
625
637
  })
626
638
  .catch((error: unknown) => {
639
+ disposeFirstPrompt();
627
640
  restoreQueue(error);
628
641
  });
629
642
 
630
643
  for (const message of rest) {
631
- if (this.ctx.isKnownSlashCommand(message.text)) {
632
- await this.ctx.session.prompt(message.text);
633
- } else if (message.mode === "followUp") {
634
- await this.ctx.session.followUp(message.text);
635
- } else {
636
- await this.ctx.session.steer(message.text);
637
- }
644
+ await this.#deliverQueuedMessage(message);
638
645
  }
639
646
  this.ctx.updatePendingMessagesDisplay();
640
647
  void promptPromise;
@@ -8,8 +8,8 @@ This format is purely textual. The tool has NO awareness of language, indentatio
8
8
 
9
9
  <ops>
10
10
  @PATH header: subsequent ops apply to PATH
11
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
12
11
  + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
12
+ < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
13
13
  - A..B delete the line range (inclusive); `- A` for one line
14
14
  = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows
15
15
  </ops>
@@ -20,6 +20,7 @@ This format is purely textual. The tool has NO awareness of language, indentatio
20
20
  - `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append.
21
21
  - `= A..B` replaces the inclusive range with the following payload lines. `= A` (or `= A..B`) with no payload blanks the range to a single empty line.
22
22
  - `- A..B` deletes the inclusive range; omit `..B` for one line.
23
+ - Pick the smallest op for the change: pure addition → `+`/`<`; pure deletion → `-`; `= A..B` ONLY when content inside `A..B` is actually being modified or removed.
23
24
  </rules>
24
25
 
25
26
  <case file="a.ts">
@@ -39,11 +40,9 @@ This format is purely textual. The tool has NO awareness of language, indentatio
39
40
 
40
41
  # Replace a contiguous range with multiple lines
41
42
  @a.ts
42
- = {{hrefr 3}}..{{hrefr 6}}
43
- {{hsep}}export function label(name: string): string {
43
+ = {{hrefr 4}}..{{hrefr 5}}
44
44
  {{hsep}} const clean = (name || DEF).trim();
45
45
  {{hsep}} return clean.length === 0 ? DEF : clean.toUpperCase();
46
- {{hsep}}}
47
46
 
48
47
  # Insert BEFORE a line
49
48
  @a.ts
@@ -73,11 +72,30 @@ This format is purely textual. The tool has NO awareness of language, indentatio
73
72
  = {{hrefr 2}}
74
73
  </examples>
75
74
 
75
+ <anti-pattern>
76
+ # WRONG — replaces 6 lines just to add one. Use `+` at the boundary instead.
77
+ @a.ts
78
+ = {{hrefr 1}}..{{hrefr 6}}
79
+ {{hsep}}const DEF = "guest";
80
+ {{hsep}}const DEBUG = false;
81
+ {{hsep}}
82
+ {{hsep}}export function label(name) {
83
+ {{hsep}} const clean = name || DEF;
84
+ {{hsep}} return clean.trim();
85
+ {{hsep}}}
86
+
87
+ # RIGHT — same effect, one-line insert
88
+ @a.ts
89
+ + {{hrefr 1}}
90
+ {{hsep}}const DEBUG = false;
91
+
92
+ If your replacement payload would render with even one unchanged line in the diff, you have the wrong op or the wrong range. Stop and rewrite as `+`/`<`/`-` plus a narrower `=`.
93
+ </anti-pattern>
94
+
76
95
  <critical>
77
96
  - Always copy anchors exactly from tool output, but **NEVER** include line content after the `{{hsep}}` separator in the op line.
78
- - Only emit changed lines. Do not restate unchanged context as payload.
79
97
  - Every inserted/replacement content line **MUST** start with `{{hsep}}`; raw content lines are invalid.
80
98
  - Do not write unified diff syntax (`@@`, `-OLD`, `+NEW`).
81
- - To replace a block, use one `= A..B` op followed by all replacement `{{hsep}}TEXT` payload lines.
82
99
  - `= A..B` deletes the range; payload is what's written. If a payload edge line already exists immediately outside `A..B`, widen the range to cover it — otherwise it duplicates.
100
+ - Multiple ops in one patch are cheap. Prefer two narrow ops over one wide `=`.
83
101
  </critical>
@@ -2182,6 +2182,63 @@ export class SessionManager {
2182
2182
  return manager.getPath(id);
2183
2183
  }
2184
2184
 
2185
+ /**
2186
+ * Path to the unsent-input draft sidecar for the current session. Lives inside
2187
+ * the artifacts directory so it is removed together with the session on
2188
+ * `dropSession`. Returns null when the session has no on-disk identity.
2189
+ */
2190
+ #getDraftPath(): string | null {
2191
+ const dir = this.getArtifactsDir();
2192
+ return dir ? path.join(dir, "draft.txt") : null;
2193
+ }
2194
+
2195
+ /**
2196
+ * Persist (or clear) the current editor draft so the next resume of this
2197
+ * session can restore it. Empty text deletes any stale draft. No-op when the
2198
+ * session is not persisted.
2199
+ */
2200
+ async saveDraft(text: string): Promise<void> {
2201
+ const draftPath = this.#getDraftPath();
2202
+ if (!draftPath || !this.persist) return;
2203
+ if (text.length === 0) {
2204
+ try {
2205
+ await this.storage.unlink(draftPath);
2206
+ } catch (err) {
2207
+ if (!isEnoent(err)) throw err;
2208
+ }
2209
+ return;
2210
+ }
2211
+ // Force the session header onto disk so resume can find the file we are
2212
+ // attaching this draft to. Without this, a session whose first message
2213
+ // never produced an assistant reply would persist a draft next to a
2214
+ // session file that does not exist on disk.
2215
+ await this.ensureOnDisk();
2216
+ await this.storage.writeText(draftPath, text);
2217
+ }
2218
+
2219
+ /**
2220
+ * Read and remove the saved draft. Returns the previously-saved text, or
2221
+ * null when no draft is pending. Single-shot: a successful read removes the
2222
+ * sidecar so a subsequent resume does not re-restore the same text.
2223
+ */
2224
+ async consumeDraft(): Promise<string | null> {
2225
+ const draftPath = this.#getDraftPath();
2226
+ if (!draftPath) return null;
2227
+ let text: string;
2228
+ try {
2229
+ text = await this.storage.readText(draftPath);
2230
+ } catch (err) {
2231
+ if (isEnoent(err)) return null;
2232
+ throw err;
2233
+ }
2234
+ try {
2235
+ await this.storage.unlink(draftPath);
2236
+ } catch (err) {
2237
+ if (!isEnoent(err)) throw err;
2238
+ }
2239
+ return text;
2240
+ }
2241
+
2185
2242
  /** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */
2186
2243
  get titleSource(): "auto" | "user" | undefined {
2187
2244
  return this.#titleSource;
@@ -1125,7 +1125,9 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1125
1125
  headers: {
1126
1126
  "Content-Type": "application/json",
1127
1127
  Authorization: `Bearer ${apiKey.apiKey}`,
1128
- "X-Title": "Oh-My-Pi",
1128
+ "HTTP-Referer": "https://github.com/can1357/oh-my-pi",
1129
+ "X-OpenRouter-Title": "Oh-My-Pi",
1130
+ "X-OpenRouter-Categories": "cli-agent",
1129
1131
  },
1130
1132
  body: JSON.stringify(requestBody),
1131
1133
  signal: requestSignal,