@oh-my-pi/pi-coding-agent 3.34.0 → 3.36.0

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,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.36.0] - 2026-01-10
6
+ ### Added
7
+
8
+ - Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals
9
+ - Added support for multiple API credentials per provider with round-robin distribution across sessions
10
+ - Added file locking for auth.json to prevent concurrent write corruption
11
+ - Added clickable OAuth login URL display in terminal
12
+ - Added `workdir` parameter to bash tool to execute commands in a specific directory without requiring `cd` commands
13
+
14
+ ### Changed
15
+
16
+ - Updated bash tool rendering to display working directory context when `workdir` parameter is used
17
+
18
+ ### Fixed
19
+
20
+ - Fixed completion notification to only send when interactive mode is in foreground
21
+ - Improved completion notification message to include session title when available
22
+
23
+ ## [3.35.0] - 2026-01-09
24
+ ### Added
25
+
26
+ - Added retry logic with exponential backoff for auto-compaction failures
27
+ - Added fallback to alternative models when auto-compaction fails with the primary model
28
+ - Added support for `pi/<role>` model aliases in task tool (e.g., `pi/slow`, `pi/default`)
29
+ - Added visual cycle indicator when switching between role models showing available roles
30
+ - Added automatic model inheritance for subtasks when parent uses default model
31
+ - Added `--` separator in grep tool to prevent pattern interpretation as flags
32
+
33
+ ### Changed
34
+
35
+ - Changed role model cycling to remember last selected role instead of matching current model
36
+ - Changed edit tool to merge call and result displays into single block
37
+ - Changed model override behavior to persist in settings when explicitly set via CLI
38
+
39
+ ### Fixed
40
+
41
+ - Fixed retry-after parsing from error messages supporting multiple header formats (retry-after, retry-after-ms, x-ratelimit-reset)
42
+ - Fixed image attachments being dropped when steering/follow-up messages are queued during streaming
43
+ - Fixed image auto-resize not applying to clipboard images before sending
44
+ - Fixed clipboard image attachments being dropped when steering/follow-up messages are queued while streaming
45
+ - Fixed clipboard image attachments ignoring the auto-resize setting before sending
46
+
5
47
  ## [3.34.0] - 2026-01-09
6
48
 
7
49
  ### Added
@@ -1954,4 +1996,4 @@ Initial public release.
1954
1996
  - Git branch display in footer
1955
1997
  - Message queueing during streaming responses
1956
1998
  - OAuth integration for Gmail and Google Calendar access
1957
- - HTML export with syntax highlighting and collapsible sections
1999
+ - HTML export with syntax highlighting and collapsible sections
package/README.md CHANGED
@@ -114,10 +114,15 @@ Add API keys to `~/.omp/agent/auth.json`:
114
114
 
115
115
  ```json
116
116
  {
117
- "anthropic": { "type": "api_key", "key": "sk-ant-..." },
117
+ "anthropic": [
118
+ { "type": "api_key", "key": "sk-ant-..." },
119
+ { "type": "api_key", "key": "sk-ant-..." }
120
+ ],
118
121
  "openai": { "type": "api_key", "key": "sk-..." },
119
122
  "google": { "type": "api_key", "key": "..." }
120
123
  }
124
+
125
+ If a provider has multiple credentials, new sessions round robin across them and stay sticky per session.
121
126
  ```
122
127
 
123
128
  **Option 2: Environment variables**
@@ -152,7 +157,7 @@ omp
152
157
  /login # Select provider, authorize in browser
153
158
  ```
154
159
 
155
- **Note:** `/login` replaces any existing API key for that provider with OAuth credentials in `auth.json`.
160
+ **Note:** `/login` replaces any existing API keys for that provider with OAuth credentials in `auth.json`. If OAuth credentials already exist, `/login` appends another entry.
156
161
 
157
162
  **GitHub Copilot notes:**
158
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.34.0",
3
+ "version": "3.36.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "3.34.0",
43
- "@oh-my-pi/pi-agent-core": "3.34.0",
44
- "@oh-my-pi/pi-git-tool": "3.34.0",
45
- "@oh-my-pi/pi-tui": "3.34.0",
42
+ "@oh-my-pi/pi-ai": "3.36.0",
43
+ "@oh-my-pi/pi-agent-core": "3.36.0",
44
+ "@oh-my-pi/pi-git-tool": "3.36.0",
45
+ "@oh-my-pi/pi-tui": "3.36.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -459,7 +459,10 @@ export class AgentSession {
459
459
  const content = message.content;
460
460
  if (typeof content === "string") return content;
461
461
  const textBlocks = content.filter((c) => c.type === "text");
462
- return textBlocks.map((c) => (c as TextContent).text).join("");
462
+ const text = textBlocks.map((c) => (c as TextContent).text).join("");
463
+ if (text.length > 0) return text;
464
+ const hasImages = content.some((c) => c.type === "image");
465
+ return hasImages ? "[Image]" : "";
463
466
  }
464
467
 
465
468
  /** Find the last assistant message in agent state (including aborted ones) */
@@ -722,9 +725,9 @@ export class AgentSession {
722
725
  );
723
726
  }
724
727
  if (options.streamingBehavior === "followUp") {
725
- await this._queueFollowUp(expandedText);
728
+ await this._queueFollowUp(expandedText, options?.images);
726
729
  } else {
727
- await this._queueSteer(expandedText);
730
+ await this._queueSteer(expandedText, options?.images);
728
731
  }
729
732
  return;
730
733
  }
@@ -742,7 +745,7 @@ export class AgentSession {
742
745
  }
743
746
 
744
747
  // Validate API key
745
- const apiKey = await this._modelRegistry.getApiKey(this.model);
748
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
746
749
  if (!apiKey) {
747
750
  throw new Error(
748
751
  `No API key found for ${this.model.provider}.\n\n` +
@@ -953,11 +956,16 @@ export class AgentSession {
953
956
  /**
954
957
  * Internal: Queue a steering message (already expanded, no extension command check).
955
958
  */
956
- private async _queueSteer(text: string): Promise<void> {
957
- this._steeringMessages.push(text);
959
+ private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
960
+ const displayText = text || (images && images.length > 0 ? "[Image]" : "");
961
+ this._steeringMessages.push(displayText);
962
+ const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
963
+ if (images && images.length > 0) {
964
+ content.push(...images);
965
+ }
958
966
  this.agent.steer({
959
967
  role: "user",
960
- content: [{ type: "text", text }],
968
+ content,
961
969
  timestamp: Date.now(),
962
970
  });
963
971
  }
@@ -965,11 +973,16 @@ export class AgentSession {
965
973
  /**
966
974
  * Internal: Queue a follow-up message (already expanded, no extension command check).
967
975
  */
968
- private async _queueFollowUp(text: string): Promise<void> {
969
- this._followUpMessages.push(text);
976
+ private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
977
+ const displayText = text || (images && images.length > 0 ? "[Image]" : "");
978
+ this._followUpMessages.push(displayText);
979
+ const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
980
+ if (images && images.length > 0) {
981
+ content.push(...images);
982
+ }
970
983
  this.agent.followUp({
971
984
  role: "user",
972
- content: [{ type: "text", text }],
985
+ content,
973
986
  timestamp: Date.now(),
974
987
  });
975
988
  }
@@ -1129,7 +1142,7 @@ export class AgentSession {
1129
1142
  * @throws Error if no API key available for the model
1130
1143
  */
1131
1144
  async setModel(model: Model<any>, role: string = "default"): Promise<void> {
1132
- const apiKey = await this._modelRegistry.getApiKey(model);
1145
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
1133
1146
  if (!apiKey) {
1134
1147
  throw new Error(`No API key for ${model.provider}/${model.id}`);
1135
1148
  }
@@ -1148,7 +1161,7 @@ export class AgentSession {
1148
1161
  * @throws Error if no API key available for the model
1149
1162
  */
1150
1163
  async setModelTemporary(model: Model<any>): Promise<void> {
1151
- const apiKey = await this._modelRegistry.getApiKey(model);
1164
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
1152
1165
  if (!apiKey) {
1153
1166
  throw new Error(`No API key for ${model.provider}/${model.id}`);
1154
1167
  }
@@ -1175,7 +1188,7 @@ export class AgentSession {
1175
1188
 
1176
1189
  /**
1177
1190
  * Cycle through configured role models in a fixed order.
1178
- * Skips missing roles and deduplicates models.
1191
+ * Skips missing roles.
1179
1192
  * @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
1180
1193
  * @param options - Optional settings: `temporary` to not persist to settings
1181
1194
  */
@@ -1189,7 +1202,6 @@ export class AgentSession {
1189
1202
  const currentModel = this.model;
1190
1203
  if (!currentModel) return undefined;
1191
1204
  const roleModels: Array<{ role: string; model: Model<any> }> = [];
1192
- const seen = new Set<string>();
1193
1205
 
1194
1206
  for (const role of roleOrder) {
1195
1207
  const roleModelStr =
@@ -1208,15 +1220,15 @@ export class AgentSession {
1208
1220
  }
1209
1221
  if (!match) continue;
1210
1222
 
1211
- const key = `${match.provider}/${match.id}`;
1212
- if (seen.has(key)) continue;
1213
- seen.add(key);
1214
1223
  roleModels.push({ role, model: match });
1215
1224
  }
1216
1225
 
1217
1226
  if (roleModels.length <= 1) return undefined;
1218
1227
 
1219
- let currentIndex = roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel));
1228
+ const lastRole = this.sessionManager.getLastModelChangeRole();
1229
+ let currentIndex = lastRole
1230
+ ? roleModels.findIndex((entry) => entry.role === lastRole)
1231
+ : roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel));
1220
1232
  if (currentIndex === -1) currentIndex = 0;
1221
1233
 
1222
1234
  const nextIndex = (currentIndex + 1) % roleModels.length;
@@ -1243,7 +1255,7 @@ export class AgentSession {
1243
1255
  const next = this._scopedModels[nextIndex];
1244
1256
 
1245
1257
  // Validate API key
1246
- const apiKey = await this._modelRegistry.getApiKey(next.model);
1258
+ const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
1247
1259
  if (!apiKey) {
1248
1260
  throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
1249
1261
  }
@@ -1271,7 +1283,7 @@ export class AgentSession {
1271
1283
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1272
1284
  const nextModel = availableModels[nextIndex];
1273
1285
 
1274
- const apiKey = await this._modelRegistry.getApiKey(nextModel);
1286
+ const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId);
1275
1287
  if (!apiKey) {
1276
1288
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
1277
1289
  }
@@ -1401,7 +1413,7 @@ export class AgentSession {
1401
1413
  throw new Error("No model selected");
1402
1414
  }
1403
1415
 
1404
- const apiKey = await this._modelRegistry.getApiKey(this.model);
1416
+ const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
1405
1417
  if (!apiKey) {
1406
1418
  throw new Error(`No API key for ${this.model.provider}`);
1407
1419
  }
@@ -1558,6 +1570,60 @@ export class AgentSession {
1558
1570
  }
1559
1571
  }
1560
1572
 
1573
+ private _getModelKey(model: Model<any>): string {
1574
+ return `${model.provider}/${model.id}`;
1575
+ }
1576
+
1577
+ private _resolveRoleModel(
1578
+ role: string,
1579
+ availableModels: Model<any>[],
1580
+ currentModel: Model<any> | undefined,
1581
+ ): Model<any> | undefined {
1582
+ const roleModelStr =
1583
+ role === "default"
1584
+ ? (this.settingsManager.getModelRole("default") ??
1585
+ (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
1586
+ : this.settingsManager.getModelRole(role);
1587
+
1588
+ if (!roleModelStr) return undefined;
1589
+
1590
+ const parsed = parseModelString(roleModelStr);
1591
+ if (parsed) {
1592
+ return availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
1593
+ }
1594
+ const roleLower = roleModelStr.toLowerCase();
1595
+ return availableModels.find((m) => m.id.toLowerCase() === roleLower);
1596
+ }
1597
+
1598
+ private _getCompactionModelCandidates(availableModels: Model<any>[]): Model<any>[] {
1599
+ const candidates: Model<any>[] = [];
1600
+ const seen = new Set<string>();
1601
+
1602
+ const addCandidate = (model: Model<any> | undefined): void => {
1603
+ if (!model) return;
1604
+ const key = this._getModelKey(model);
1605
+ if (seen.has(key)) return;
1606
+ seen.add(key);
1607
+ candidates.push(model);
1608
+ };
1609
+
1610
+ const currentModel = this.model;
1611
+ addCandidate(this._resolveRoleModel("default", availableModels, currentModel));
1612
+ addCandidate(this._resolveRoleModel("slow", availableModels, currentModel));
1613
+ addCandidate(this._resolveRoleModel("small", availableModels, currentModel));
1614
+ addCandidate(this._resolveRoleModel("smol", availableModels, currentModel));
1615
+
1616
+ const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
1617
+ for (const model of sortedByContext) {
1618
+ if (!seen.has(this._getModelKey(model))) {
1619
+ addCandidate(model);
1620
+ break;
1621
+ }
1622
+ }
1623
+
1624
+ return candidates;
1625
+ }
1626
+
1561
1627
  /**
1562
1628
  * Internal: Run auto-compaction with events.
1563
1629
  */
@@ -1577,8 +1643,8 @@ export class AgentSession {
1577
1643
  return;
1578
1644
  }
1579
1645
 
1580
- const apiKey = await this._modelRegistry.getApiKey(this.model);
1581
- if (!apiKey) {
1646
+ const availableModels = this._modelRegistry.getAvailable();
1647
+ if (availableModels.length === 0) {
1582
1648
  this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
1583
1649
  return;
1584
1650
  }
@@ -1626,14 +1692,68 @@ export class AgentSession {
1626
1692
  tokensBefore = hookCompaction.tokensBefore;
1627
1693
  details = hookCompaction.details;
1628
1694
  } else {
1629
- // Generate compaction result
1630
- const compactResult = await compact(
1631
- preparation,
1632
- this.model,
1633
- apiKey,
1634
- undefined,
1635
- this._autoCompactionAbortController.signal,
1636
- );
1695
+ const candidates = this._getCompactionModelCandidates(availableModels);
1696
+ const retrySettings = this.settingsManager.getRetrySettings();
1697
+ let compactResult: CompactionResult | undefined;
1698
+ let lastError: unknown;
1699
+
1700
+ for (const candidate of candidates) {
1701
+ const apiKey = await this._modelRegistry.getApiKey(candidate, this.sessionId);
1702
+ if (!apiKey) continue;
1703
+
1704
+ let attempt = 0;
1705
+ while (true) {
1706
+ try {
1707
+ compactResult = await compact(
1708
+ preparation,
1709
+ candidate,
1710
+ apiKey,
1711
+ undefined,
1712
+ this._autoCompactionAbortController.signal,
1713
+ );
1714
+ break;
1715
+ } catch (error) {
1716
+ if (this._autoCompactionAbortController.signal.aborted) {
1717
+ throw error;
1718
+ }
1719
+
1720
+ const message = error instanceof Error ? error.message : String(error);
1721
+ const retryAfterMs = this._parseRetryAfterMsFromError(message);
1722
+ const shouldRetry =
1723
+ retrySettings.enabled &&
1724
+ attempt < retrySettings.maxRetries &&
1725
+ (retryAfterMs !== undefined || this._isRetryableErrorMessage(message));
1726
+ if (!shouldRetry) {
1727
+ lastError = error;
1728
+ break;
1729
+ }
1730
+
1731
+ const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
1732
+ const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
1733
+ attempt++;
1734
+ logger.warn("Auto-compaction failed, retrying", {
1735
+ attempt,
1736
+ maxRetries: retrySettings.maxRetries,
1737
+ delayMs,
1738
+ retryAfterMs,
1739
+ error: message,
1740
+ });
1741
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1742
+ }
1743
+ }
1744
+
1745
+ if (compactResult) {
1746
+ break;
1747
+ }
1748
+ }
1749
+
1750
+ if (!compactResult) {
1751
+ if (lastError) {
1752
+ throw lastError;
1753
+ }
1754
+ throw new Error("Compaction failed: no available model");
1755
+ }
1756
+
1637
1757
  summary = compactResult.summary;
1638
1758
  firstKeptEntryId = compactResult.firstKeptEntryId;
1639
1759
  tokensBefore = compactResult.tokensBefore;
@@ -1725,12 +1845,61 @@ export class AgentSession {
1725
1845
  if (isContextOverflow(message, contextWindow)) return false;
1726
1846
 
1727
1847
  const err = message.errorMessage;
1848
+ return this._isRetryableErrorMessage(err);
1849
+ }
1850
+
1851
+ private _isRetryableErrorMessage(errorMessage: string): boolean {
1728
1852
  // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
1729
1853
  return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
1730
- err,
1854
+ errorMessage,
1731
1855
  );
1732
1856
  }
1733
1857
 
1858
+ private _parseRetryAfterMsFromError(errorMessage: string): number | undefined {
1859
+ const now = Date.now();
1860
+ const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
1861
+ if (retryAfterMsMatch) {
1862
+ return Math.max(0, Number(retryAfterMsMatch[1]));
1863
+ }
1864
+
1865
+ const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
1866
+ if (retryAfterMatch) {
1867
+ const value = retryAfterMatch[1];
1868
+ const seconds = Number(value);
1869
+ if (!Number.isNaN(seconds)) {
1870
+ return Math.max(0, seconds * 1000);
1871
+ }
1872
+ const dateMs = Date.parse(value);
1873
+ if (!Number.isNaN(dateMs)) {
1874
+ return Math.max(0, dateMs - now);
1875
+ }
1876
+ }
1877
+
1878
+ const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
1879
+ if (resetMsMatch) {
1880
+ const resetMs = Number(resetMsMatch[1]);
1881
+ if (!Number.isNaN(resetMs)) {
1882
+ if (resetMs > 1_000_000_000_000) {
1883
+ return Math.max(0, resetMs - now);
1884
+ }
1885
+ return Math.max(0, resetMs);
1886
+ }
1887
+ }
1888
+
1889
+ const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
1890
+ if (resetMatch) {
1891
+ const resetSeconds = Number(resetMatch[1]);
1892
+ if (!Number.isNaN(resetSeconds)) {
1893
+ if (resetSeconds > 1_000_000_000) {
1894
+ return Math.max(0, resetSeconds * 1000 - now);
1895
+ }
1896
+ return Math.max(0, resetSeconds * 1000);
1897
+ }
1898
+ }
1899
+
1900
+ return undefined;
1901
+ }
1902
+
1734
1903
  /**
1735
1904
  * Handle retryable errors with exponential backoff.
1736
1905
  * @returns true if retry was initiated, false if max retries exceeded or disabled
@@ -2175,7 +2344,7 @@ export class AgentSession {
2175
2344
  let summaryDetails: unknown;
2176
2345
  if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
2177
2346
  const model = this.model!;
2178
- const apiKey = await this._modelRegistry.getApiKey(model);
2347
+ const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
2179
2348
  if (!apiKey) {
2180
2349
  throw new Error(`No API key for ${model.provider}`);
2181
2350
  }