privateer-agent 0.12.21 → 0.12.23

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.
@@ -405,11 +405,11 @@ else {
405
405
  const signedIn = fs.existsSync(CRED);
406
406
  // Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
407
407
  // file carries the measurements behind the choice.
408
- const ACCOUNT_MODEL = "privateer/tinfoil/gpt-oss-120b";
408
+ const ACCOUNT_MODEL = "privateer/tinfoil/gemma4-31b";
409
409
  const MODEL = process.env.PRIVATEER_MODEL
410
410
  ? process.env.PRIVATEER_MODEL
411
411
  : haveTinfoilKey()
412
- ? "tinfoil/gpt-oss-120b"
412
+ ? "tinfoil/gemma4-31b"
413
413
  : signedIn
414
414
  ? ACCOUNT_MODEL
415
415
  : haveKey("ANTHROPIC_API_KEY")
@@ -24,19 +24,20 @@
24
24
  // display/resolution + routing list — posture and attestation are dispatcher-bound and
25
25
  // unaffected by the model set.
26
26
  import { registerAccountModels } from "../src/providers/account.ts";
27
+ import { visionInput } from "../src/providers/vision.ts";
27
28
  import { privacyExtension } from "../src/config/privacyPolicy.ts";
28
29
 
29
- // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), kimi-k2-6 first — the
30
- // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
30
+ // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), gemma4-31b first — the
31
+ // launcher's default, and the only one here that can see an image. Non-chat endpoints (embeddings, tts, whisper, websearch,
31
32
  // doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
32
33
  // models; this static list just needs to cover what we default to and commonly pick.
33
34
  const TINFOIL_MODELS = [
35
+ "gemma4-31b",
34
36
  "kimi-k2-6",
35
37
  "glm-5-2",
36
38
  "deepseek-v4-pro",
37
39
  "gpt-oss-120b",
38
40
  "gpt-oss-safeguard-120b",
39
- "gemma4-31b",
40
41
  "llama3-3-70b",
41
42
  ];
42
43
 
@@ -45,7 +46,10 @@ function tinfoilModel(id: string) {
45
46
  id,
46
47
  name: id,
47
48
  reasoning: false,
48
- input: ["text"] as ("text" | "image")[],
49
+ // Tinfoil ids are bare here (`gemma4-31b`), so scope it before asking — the
50
+ // allowlist is written against full `provider/model` ids. Getting this wrong is
51
+ // not cosmetic: `input` is what Pi checks before it will send an image at all.
52
+ input: visionInput(`tinfoil/${id}`),
49
53
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
50
54
  contextWindow: 128000,
51
55
  maxTokens: 4096,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.21",
3
+ "version": "0.12.23",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,10 +68,10 @@ index 4600b23..075ecae 100644
68
68
  export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
69
69
  export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
70
70
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
71
- index ce8a9a2..b7b339e 100644
71
+ index ce8a9a2..679e584 100644
72
72
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
73
73
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
74
- @@ -38,6 +38,70 @@ import { createLocalBashOperations } from "./tools/bash.js";
74
+ @@ -38,6 +38,111 @@ import { createLocalBashOperations } from "./tools/bash.js";
75
75
  import { createAllToolDefinitions } from "./tools/index.js";
76
76
  import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
77
77
  import { addUsageToTotals, createUsageTotals } from "./usage-totals.js";
@@ -138,11 +138,61 @@ index ce8a9a2..b7b339e 100644
138
138
  + return false;
139
139
  + const status = Number(m[1]);
140
140
  + return status >= 400 && status < 500 && !PV_TRANSIENT_CLIENT_STATUS.has(status);
141
+ +}
142
+ +// Privateer patch: throttle handling. Mirrors isThrottleFailure / retryAfterMs /
143
+ +// retryDelayMs in src/engine/errors.ts — see the incident note there.
144
+ +export function isThrottleFailure(text) {
145
+ + return /^\s*429\b/.test(typeof text === "string" ? text : "");
146
+ +}
147
+ +const PV_MAX_RETRY_DELAY_MS = 60_000;
148
+ +const PV_RETRY_AFTER_PATTERNS = [
149
+ + /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
150
+ + /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
151
+ + /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
152
+ +];
153
+ +export function retryAfterMs(text) {
154
+ + const s = typeof text === "string" ? text : "";
155
+ + for (const re of PV_RETRY_AFTER_PATTERNS) {
156
+ + const m = re.exec(s);
157
+ + if (!m)
158
+ + continue;
159
+ + const value = Number.parseFloat(m[1]);
160
+ + if (!Number.isFinite(value) || value <= 0)
161
+ + continue;
162
+ + const unit = (m[2] ?? "").toLowerCase();
163
+ + const ms = unit === "ms" || /retry-after-ms/i.test(m[0])
164
+ + ? value
165
+ + : unit.startsWith("m") && unit !== "ms"
166
+ + ? value * 60_000
167
+ + : value * 1000;
168
+ + return Math.min(Math.max(Math.round(ms), 1_000), PV_MAX_RETRY_DELAY_MS);
169
+ + }
170
+ + return null;
171
+ +}
172
+ +// A server-stated delay wins outright — it is the only number here that is a fact.
173
+ +// The jitter is the point of the rest: without it every parallel request that tripped
174
+ +// the same limit wakes at the identical millisecond and trips it again.
175
+ +export function retryDelayMs(errorText, attempt, baseDelayMs) {
176
+ + const stated = retryAfterMs(errorText);
177
+ + if (stated != null)
178
+ + return stated;
179
+ + const n = Math.max(1, Math.floor(attempt));
180
+ + const backoff = Math.min(baseDelayMs * 2 ** (n - 1), PV_MAX_RETRY_DELAY_MS);
181
+ + return Math.round(backoff * (1 - Math.random() * 0.25));
141
182
  +}
142
183
  /**
143
184
  * Parse a skill block from message text.
144
185
  * Returns null if the text doesn't contain a skill block.
145
- @@ -186,6 +250,14 @@ export class AgentSession {
186
+ @@ -102,6 +207,8 @@ export class AgentSession {
187
+ // Retry state
188
+ _retryAbortController = undefined;
189
+ _retryAttempt = 0;
190
+ + // Privateer patch: when the provider last exhausted the retry budget on a 429.
191
+ + _pvThrottledAt = 0;
192
+ // Bash execution state
193
+ _bashAbortControllers = new Set();
194
+ _pendingBashMessages = [];
195
+ @@ -186,6 +293,14 @@ export class AgentSession {
146
196
  }
147
197
  const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
148
198
  if (isOAuth) {
@@ -157,7 +207,7 @@ index ce8a9a2..b7b339e 100644
157
207
  throw new Error(`Authentication failed for "${model.provider}". ` +
158
208
  `Credentials may have expired or network is unavailable. ` +
159
209
  `Run '/login ${model.provider}' to re-authenticate.`);
160
- @@ -360,6 +432,16 @@ export class AgentSession {
210
+ @@ -360,6 +475,16 @@ export class AgentSession {
161
211
  }
162
212
  }
163
213
  }
@@ -174,10 +224,18 @@ index ce8a9a2..b7b339e 100644
174
224
  // Emit to extensions first
175
225
  await this._emitExtensionEvent(event);
176
226
  // Notify all listeners
177
- @@ -772,6 +854,25 @@ export class AgentSession {
227
+ @@ -772,6 +897,33 @@ export class AgentSession {
178
228
  finalError: msg.errorMessage,
179
229
  });
180
230
  this._retryAttempt = 0;
231
+ + // Privateer patch: remember that the endpoint is throttling us. The
232
+ + // pre-prompt compaction in prompt() is an LLM call to this same endpoint,
233
+ + // and firing it now just spends the summarizer budget on another 429 —
234
+ + // which is what answered the user's next message with "Auto-compaction
235
+ + // cancelled" instead of a reply. See the guard in prompt().
236
+ + if (isThrottleFailure(msg.errorMessage)) {
237
+ + this._pvThrottledAt = Date.now();
238
+ + }
181
239
  + // Privateer patch: a transient error that survived the full retry budget
182
240
  + // is terminal. Ending the turn here (instead of falling through to
183
241
  + // compaction / queued-message continuation) prevents the agent loop from
@@ -200,7 +258,7 @@ index ce8a9a2..b7b339e 100644
200
258
  }
201
259
  if (await this._checkCompaction(msg)) {
202
260
  return true;
203
- @@ -852,6 +953,14 @@ export class AgentSession {
261
+ @@ -852,6 +1004,14 @@ export class AgentSession {
204
262
  if (!hasConfiguredAuth) {
205
263
  const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
206
264
  if (isOAuth) {
@@ -215,7 +273,22 @@ index ce8a9a2..b7b339e 100644
215
273
  throw new Error(`Authentication failed for "${this.model.provider}". ` +
216
274
  `Credentials may have expired or network is unavailable. ` +
217
275
  `Run '/login ${this.model.provider}' to re-authenticate.`);
218
- @@ -2084,6 +2193,27 @@ export class AgentSession {
276
+ @@ -861,7 +1021,13 @@ export class AgentSession {
277
+ // Check if we need to compact before sending (catches aborted responses).
278
+ // The user's new prompt is sent below, so do not call agent.continue() here.
279
+ const lastAssistant = this._findLastAssistantMessage();
280
+ - if (lastAssistant) {
281
+ + // Privateer patch: skip this while the provider is still throttling us.
282
+ + // Compaction here is another LLM call to the endpoint that just spent our
283
+ + // whole retry budget on 429s; it cannot succeed, and its failure is reported
284
+ + // as "Auto-compaction cancelled" — burying the user's actual prompt. One
285
+ + // rate-limit window of patience, then we try again as normal. The context is
286
+ + // not lost: the next turn re-checks compaction once the window has passed.
287
+ + if (lastAssistant && Date.now() - this._pvThrottledAt >= PV_MAX_RETRY_DELAY_MS) {
288
+ await this._checkCompaction(lastAssistant, false);
289
+ }
290
+ // Build messages array (custom message if any, then user message)
291
+ @@ -2084,6 +2250,27 @@ export class AgentSession {
219
292
  // Context overflow is handled by compaction, not retry.
220
293
  if (isContextOverflow(message, this.model?.contextWindow ?? 0))
221
294
  return false;
@@ -243,6 +316,19 @@ index ce8a9a2..b7b339e 100644
243
316
  return isRetryableAssistantError(message);
244
317
  }
245
318
  /**
319
+ @@ -2129,7 +2316,11 @@ export class AgentSession {
320
+ this._retryAttempt--;
321
+ return false;
322
+ }
323
+ - const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
324
+ + // Privateer patch: honour a server-stated `retry-after` and jitter the fallback.
325
+ + // Stock Pi's fixed 2s/4s/8s ladder spends its whole budget inside a rate-limit
326
+ + // window it was never long enough to outlast, and fires every parallel retry at
327
+ + // the same instant. See retryDelayMs above.
328
+ + const delayMs = retryDelayMs(message.errorMessage, this._retryAttempt, settings.baseDelayMs);
329
+ this._emit({
330
+ type: "auto_retry_start",
331
+ attempt: this._retryAttempt,
246
332
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
247
333
  index 197bccc..bc9ac3f 100644
248
334
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
@@ -681,7 +767,7 @@ index 97af6cd..74d5d52 100644
681
767
  }
682
768
  const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
683
769
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
684
- index cb06c10..930a0de 100644
770
+ index cb06c10..41fb90c 100644
685
771
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
686
772
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
687
773
  @@ -2,7 +2,7 @@ import { randomUUID } from "crypto";
@@ -822,6 +908,23 @@ index cb06c10..930a0de 100644
822
908
  const dir = dirname(path);
823
909
  let release;
824
910
  try {
911
+ @@ -568,7 +672,15 @@ export class SettingsManager {
912
+ getProviderRetrySettings() {
913
+ return {
914
+ timeoutMs: this.settings.retry?.provider?.timeoutMs,
915
+ - maxRetries: this.settings.retry?.provider?.maxRetries,
916
+ + // Privateer patch: give the provider-level retry a real default. pi-ai's
917
+ + // retryProviderRequest is the only layer that reads `retry-after` and jitters,
918
+ + // but it takes `options.maxRetries ?? 0` and stock Pi hands it `undefined` — so
919
+ + // it ran ZERO retries and every throttle fell through to the session-level loop
920
+ + // (3 attempts, fixed 2s/4s/8s, no retry-after), which cannot outlast a 60s rate
921
+ + // limit window. A bare `429` from the account edge exhausted the budget in ~14s.
922
+ + // 2 here is deliberately small: it is a *per-attempt* budget that nests inside
923
+ + // the session retry, and each of its waits already honours the server's number.
924
+ + maxRetries: this.settings.retry?.provider?.maxRetries ?? 2,
925
+ maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000,
926
+ };
927
+ }
825
928
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js
826
929
  index 4e7e784..7383a2b 100644
827
930
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js
@@ -1307,7 +1410,7 @@ index 1d9f046..c0326e3 100644
1307
1410
  return lines;
1308
1411
  }
1309
1412
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
1310
- index 3f93cc6..bbe2c10 100644
1413
+ index 3f93cc6..50a2a99 100644
1311
1414
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
1312
1415
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
1313
1416
  @@ -1,8 +1,35 @@
@@ -1347,7 +1450,23 @@ index 3f93cc6..bbe2c10 100644
1347
1450
  export class ToolExecutionComponent extends Container {
1348
1451
  contentBox;
1349
1452
  contentText;
1350
- @@ -106,6 +133,13 @@ export class ToolExecutionComponent extends Container {
1453
+ @@ -43,8 +70,13 @@ export class ToolExecutionComponent extends Container {
1454
+ // Always create all shell variants. contentBox is used for default renderer-based composition.
1455
+ // selfRenderContainer is used when the tool renders its own framing.
1456
+ // contentText is reserved for generic fallback rendering when no tool definition exists.
1457
+ - this.contentBox = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
1458
+ - this.contentText = new Text("", 1, 1, (text) => theme.bg("toolPendingBg", text));
1459
+ + // Privateer: paddingY is 0 because we flatten the tool-box washes (dark.json /
1460
+ + // light.json). With no background to fill, upstream's paddingY=1 reads as a blank
1461
+ + // line above AND below every call, stacking with the Spacer(1) into three empty
1462
+ + // rows between consecutive tools. 0 leaves exactly the one Spacer row, which also
1463
+ + // matches what the self-render path above emits (a single lines.push("")).
1464
+ + this.contentBox = new Box(1, 0, (text) => theme.bg("toolPendingBg", text));
1465
+ + this.contentText = new Text("", 1, 0, (text) => theme.bg("toolPendingBg", text));
1466
+ this.selfRenderContainer = new Container();
1467
+ if (this.hasRendererDefinition()) {
1468
+ this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox);
1469
+ @@ -106,6 +138,13 @@ export class ToolExecutionComponent extends Container {
1351
1470
  createCallFallback() {
1352
1471
  return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
1353
1472
  }
@@ -1361,7 +1480,7 @@ index 3f93cc6..bbe2c10 100644
1361
1480
  createResultFallback() {
1362
1481
  const output = this.getTextOutput();
1363
1482
  if (!output) {
1364
- @@ -218,19 +252,22 @@ export class ToolExecutionComponent extends Container {
1483
+ @@ -218,19 +257,22 @@ export class ToolExecutionComponent extends Container {
1365
1484
  renderContainer.clear();
1366
1485
  const callRenderer = this.getCallRenderer();
1367
1486
  if (!callRenderer) {
@@ -1387,7 +1506,7 @@ index 3f93cc6..bbe2c10 100644
1387
1506
  hasContent = true;
1388
1507
  }
1389
1508
  }
1390
- @@ -302,7 +339,8 @@ export class ToolExecutionComponent extends Container {
1509
+ @@ -302,7 +344,8 @@ export class ToolExecutionComponent extends Container {
1391
1510
  return getRenderedTextOutput(this.result, this.showImages);
1392
1511
  }
1393
1512
  formatToolExecution() {
@@ -1398,7 +1517,7 @@ index 3f93cc6..bbe2c10 100644
1398
1517
  if (content) {
1399
1518
  text += `\n\n${content}`;
1400
1519
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1401
- index 42e655d..d00bec9 100644
1520
+ index 42e655d..606c7cc 100644
1402
1521
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1403
1522
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1404
1523
  @@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
@@ -1424,7 +1543,62 @@ index 42e655d..d00bec9 100644
1424
1543
  if (!sessionManager.usesDefaultSessionDir()) {
1425
1544
  args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
1426
1545
  }
1427
- @@ -405,9 +410,15 @@ export class InteractiveMode {
1546
+ @@ -207,6 +212,54 @@ export function createInteractiveTuiReference(getTui) {
1547
+ getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
1548
+ });
1549
+ }
1550
+ +// Privateer patch: describe an error we only have the TEXT of. Mirrors
1551
+ +// describeErrorText in src/engine/errors.ts — see the incident note there.
1552
+ +const PV_RETRY_AFTER_PATTERNS = [
1553
+ + /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
1554
+ + /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
1555
+ + /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
1556
+ +];
1557
+ +function pvRetryAfterMs(text) {
1558
+ + const s = typeof text === "string" ? text : "";
1559
+ + for (const re of PV_RETRY_AFTER_PATTERNS) {
1560
+ + const m = re.exec(s);
1561
+ + if (!m)
1562
+ + continue;
1563
+ + const value = Number.parseFloat(m[1]);
1564
+ + if (!Number.isFinite(value) || value <= 0)
1565
+ + continue;
1566
+ + const unit = (m[2] ?? "").toLowerCase();
1567
+ + const ms = unit === "ms" || /retry-after-ms/i.test(m[0])
1568
+ + ? value
1569
+ + : unit.startsWith("m") && unit !== "ms"
1570
+ + ? value * 60_000
1571
+ + : value * 1000;
1572
+ + return Math.min(Math.max(Math.round(ms), 1_000), 60_000);
1573
+ + }
1574
+ + return null;
1575
+ +}
1576
+ +function pvDescribeErrorText(text) {
1577
+ + const s = typeof text === "string" ? text : "";
1578
+ + const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
1579
+ + if (!Number.isFinite(status))
1580
+ + return null;
1581
+ + if (status === 429) {
1582
+ + const stated = pvRetryAfterMs(s);
1583
+ + return {
1584
+ + message: "Rate limited (429).",
1585
+ + hint: stated
1586
+ + ? `The provider asked for ${Math.ceil(stated / 1000)}s. Wait that long and send it again.`
1587
+ + : "Wait a moment and send it again — or run /model to switch to another provider.",
1588
+ + };
1589
+ + }
1590
+ + if (status === 401 || status === 403)
1591
+ + return { message: s, hint: "Check your credentials — run /login to re-authenticate." };
1592
+ + if (status === 404)
1593
+ + return { message: s, hint: "Check the model id — run /model to switch." };
1594
+ + if (status >= 500)
1595
+ + return { message: s, hint: "Usually transient — retry shortly." };
1596
+ + return null;
1597
+ +}
1598
+ export class InteractiveMode {
1599
+ runtimeHost;
1600
+ renderer;
1601
+ @@ -405,9 +458,15 @@ export class InteractiveMode {
1428
1602
  }
1429
1603
  getBuiltInCommandConflictDiagnostics(extensionRunner) {
1430
1604
  const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
@@ -1441,7 +1615,7 @@ index 42e655d..d00bec9 100644
1441
1615
  .map((command) => ({
1442
1616
  type: "warning",
1443
1617
  message: command.invocationName === command.name
1444
- @@ -754,20 +765,18 @@ export class InteractiveMode {
1618
+ @@ -754,20 +813,18 @@ export class InteractiveMode {
1445
1619
  this.showNewVersionNotification(newRelease);
1446
1620
  }
1447
1621
  });
@@ -1474,7 +1648,7 @@ index 42e655d..d00bec9 100644
1474
1648
  // Check tmux keyboard setup asynchronously
1475
1649
  this.checkTmuxKeyboardSetup().then((warning) => {
1476
1650
  if (warning) {
1477
- @@ -2310,7 +2319,17 @@ export class InteractiveMode {
1651
+ @@ -2310,7 +2367,17 @@ export class InteractiveMode {
1478
1652
  if (text === "/model" || text.startsWith("/model ")) {
1479
1653
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
1480
1654
  this.editor.setText("");
@@ -1493,7 +1667,7 @@ index 42e655d..d00bec9 100644
1493
1667
  return;
1494
1668
  }
1495
1669
  if (text === "/export" || text.startsWith("/export ")) {
1496
- @@ -2376,12 +2395,42 @@ export class InteractiveMode {
1670
+ @@ -2376,12 +2443,42 @@ export class InteractiveMode {
1497
1671
  if (text === "/login" || text.startsWith("/login ")) {
1498
1672
  const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
1499
1673
  this.editor.setText("");
@@ -1538,7 +1712,7 @@ index 42e655d..d00bec9 100644
1538
1712
  return;
1539
1713
  }
1540
1714
  if (text === "/new") {
1541
- @@ -3024,7 +3073,7 @@ export class InteractiveMode {
1715
+ @@ -3024,7 +3121,7 @@ export class InteractiveMode {
1542
1716
  if (this.chatContainer.children.length > 0) {
1543
1717
  this.chatContainer.addChild(new Spacer(1));
1544
1718
  }
@@ -1547,6 +1721,25 @@ index 42e655d..d00bec9 100644
1547
1721
  }
1548
1722
  async getUserInput() {
1549
1723
  const queuedInput = this.pendingUserInputs.shift();
1724
+ @@ -3363,7 +3460,17 @@ export class InteractiveMode {
1725
+ }
1726
+ showError(errorMessage) {
1727
+ this.chatContainer.addChild(new Spacer(1));
1728
+ - this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0));
1729
+ + // Privateer patch: by the time an error reaches here it is only a string — the
1730
+ + // structured fields describeError() reads are long gone, so the user was shown
1731
+ + // the SDK's own words ("429 status code (no body)"), which say nothing about
1732
+ + // what to do. The status is still readable at the front of that string; recover
1733
+ + // it and say what describeError would. Mirrors describeErrorText in
1734
+ + // src/engine/errors.ts. Anything without a leading status prints unchanged.
1735
+ + const described = pvDescribeErrorText(errorMessage);
1736
+ + this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${described?.message ?? errorMessage}`), this.outputPad, 0));
1737
+ + if (described?.hint) {
1738
+ + this.chatContainer.addChild(new Text(theme.fg("muted", described.hint), this.outputPad, 0));
1739
+ + }
1740
+ this.ui.requestRender();
1741
+ }
1742
+ showWarning(warningMessage) {
1550
1743
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
1551
1744
  index 9db9cbd..b370180 100644
1552
1745
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
@@ -45,6 +45,7 @@ import type { GateController } from "../ext/permissionGate.ts";
45
45
  import type { SendFileBridge } from "../tools/sendFile.ts";
46
46
  import type { CargoSaveBridge } from "../tools/cargo.ts";
47
47
  import type { ChartOpBridge } from "../tools/charts.ts";
48
+ import type { LibrarySaveBridge } from "../tools/saveToLibrary.ts";
48
49
  import type { AttachmentStore } from "../util/attachmentStore.ts";
49
50
 
50
51
  /** A Pi extension factory, as DefaultResourceLoader takes them. */
@@ -73,7 +74,10 @@ export interface MoatOptions {
73
74
  * its module-level bridge and stands them down inside the daemon, so a live spawn's own
74
75
  * pair is what the model gets (see tools/relayFileTools.ts).
75
76
  */
76
- relayFiles?: { bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge; attachments: AttachmentStore };
77
+ relayFiles?: {
78
+ bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge & LibrarySaveBridge;
79
+ attachments: AttachmentStore;
80
+ };
77
81
  /**
78
82
  * THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
79
83
  * a path whose result reaches the app's Inbox — a scheduled routine, a submitted
@@ -348,3 +348,133 @@ export function describeError(err: unknown): DescribedError {
348
348
  // arrives, so it goes through the compactor first.
349
349
  return out({ message: compactProviderError(text) });
350
350
  }
351
+
352
+ // ── Throttles: waiting the right amount, and only once ───────────────────────
353
+ //
354
+ // The incident this exists for: the account channel's edge answered a burst of
355
+ // turns with a bare `429` — no body, no JSON, just the status. Two retry layers
356
+ // exist and the wrong one was live. pi-ai's `retryProviderRequest` is the good
357
+ // one (it reads `retry-after`, jitters, caps at 60s) but it takes `maxRetries ??
358
+ // 0` and Pi's settings gave it no default, so it ran ZERO retries. All the
359
+ // patience came from the session-level loop: 3 attempts at 2s/4s/8s, no
360
+ // `retry-after`, no jitter — ~14 seconds against a window that is conventionally
361
+ // 60. Exhaustion was arithmetic, not bad luck. Then the pre-prompt compaction
362
+ // fired the summarizer into the same throttled endpoint and died there too, so
363
+ // the user's next message was answered with "Auto-compaction cancelled".
364
+ //
365
+ // The fix is three-part and mirrored into the Pi patch: give the provider layer a
366
+ // real retry budget, make the session layer's backoff honour a server-stated
367
+ // delay and jitter, and don't summarise into an endpoint that just throttled us.
368
+
369
+ /** A throttle can clear on its own — but only after the server's stated window. */
370
+ export function isThrottleFailure(text: string | null | undefined): boolean {
371
+ return /^\s*429\b/.test(typeof text === "string" ? text : "");
372
+ }
373
+
374
+ /** Longest we will sit on a single backoff. Past this the user deserves the turn back. */
375
+ export const MAX_RETRY_DELAY_MS = 60_000;
376
+
377
+ // A bare 429 carries nothing, but a provider that bothers to explain itself puts the
378
+ // number in the text one of a few ways. Read it where it is offered; guess otherwise.
379
+ const RETRY_AFTER_PATTERNS = [
380
+ /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
381
+ /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
382
+ /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
383
+ ];
384
+
385
+ /**
386
+ * The server's requested delay in ms, or null when it did not state one.
387
+ *
388
+ * `retry-after` is seconds by convention and `retry-after-ms` is milliseconds; a
389
+ * prose "try again in 30 seconds" carries its own unit. Values are clamped rather
390
+ * than rejected — a provider asking for an hour gets our ceiling, not a crash.
391
+ */
392
+ export function retryAfterMs(text: string | null | undefined): number | null {
393
+ const s = typeof text === "string" ? text : "";
394
+ for (const re of RETRY_AFTER_PATTERNS) {
395
+ const m = re.exec(s);
396
+ if (!m) continue;
397
+ const value = Number.parseFloat(m[1]);
398
+ if (!Number.isFinite(value) || value <= 0) continue;
399
+ const unit = (m[2] ?? "").toLowerCase();
400
+ const ms =
401
+ unit === "ms" || /retry-after-ms/i.test(m[0])
402
+ ? value
403
+ : unit.startsWith("m") && unit !== "ms"
404
+ ? value * 60_000
405
+ : value * 1000;
406
+ return Math.min(Math.max(Math.round(ms), 1_000), MAX_RETRY_DELAY_MS);
407
+ }
408
+ return null;
409
+ }
410
+
411
+ /**
412
+ * How long the session-level retry should wait before attempt `attempt` (1-based).
413
+ *
414
+ * A server-stated delay wins outright — it is the only number here that is a fact.
415
+ * Otherwise back off exponentially from `baseDelayMs`, capped, and then jitter DOWN
416
+ * by up to 25%. The jitter is the point of the exercise: without it every parallel
417
+ * request that tripped the same limit wakes at the identical millisecond and trips
418
+ * it again, which is how one throttled turn becomes a throttled session.
419
+ *
420
+ * `rand` is injectable so the schedule can be asserted in a test.
421
+ */
422
+ export function retryDelayMs(
423
+ errorText: string | null | undefined,
424
+ attempt: number,
425
+ baseDelayMs: number,
426
+ rand: () => number = Math.random,
427
+ ): number {
428
+ const stated = retryAfterMs(errorText);
429
+ if (stated != null) return stated;
430
+ const n = Math.max(1, Math.floor(attempt));
431
+ const backoff = Math.min(baseDelayMs * 2 ** (n - 1), MAX_RETRY_DELAY_MS);
432
+ return Math.round(backoff * (1 - rand() * 0.25));
433
+ }
434
+
435
+ /**
436
+ * Describe an error we only have the TEXT of, for the one place that has nothing else.
437
+ *
438
+ * `describeError` above reads structured fields off the error object; by the time a
439
+ * message reaches the TUI's `showError` those are gone and all that survives is a
440
+ * string the SDK built status-first ("429 status code (no body)"). That string is
441
+ * what the user was shown six times during the incident. Recover the status from it
442
+ * and say the same thing `describeError` would. Returns null when there is no leading
443
+ * status to read, so ordinary messages print unchanged.
444
+ */
445
+ export function describeErrorText(text: string | null | undefined): DescribedError | null {
446
+ const s = typeof text === "string" ? text : "";
447
+ const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
448
+ if (!Number.isFinite(status)) return null;
449
+
450
+ if (status === 429) {
451
+ const stated = retryAfterMs(s);
452
+ return {
453
+ message: redactText(`Rate limited (429).`),
454
+ hint: stated
455
+ ? `The provider asked for ${Math.ceil(stated / 1000)}s. Wait that long and send it again.`
456
+ : "Wait a moment and send it again — or run /model to switch to another provider.",
457
+ retryable: true,
458
+ };
459
+ }
460
+ if (status === 401 || status === 403) {
461
+ return {
462
+ message: redactText(compactProviderError(s)),
463
+ hint: "Check your credentials — run /login to re-authenticate.",
464
+ };
465
+ }
466
+ if (status === 404) {
467
+ return {
468
+ message: redactText(compactProviderError(s)),
469
+ hint: "Check the model id — run /model to switch.",
470
+ };
471
+ }
472
+ if (status >= 500) {
473
+ return {
474
+ message: redactText(compactProviderError(s)),
475
+ hint: "Usually transient — retry shortly.",
476
+ retryable: true,
477
+ };
478
+ }
479
+ return null;
480
+ }
@@ -175,6 +175,7 @@ const MEDIA_TOOLS = new Set([
175
175
  "generate_image",
176
176
  "generate_video",
177
177
  "generate_model",
178
+ "generate_sprite",
178
179
  "generate_speech",
179
180
  "generate_music",
180
181
  "generate_sfx",
@@ -199,6 +200,7 @@ export const BILLED_MEDIA_TOOLS: ReadonlySet<string> = new Set([
199
200
  "generate_image",
200
201
  "generate_video",
201
202
  "generate_model",
203
+ "generate_sprite",
202
204
  "generate_speech",
203
205
  "generate_music",
204
206
  "generate_sfx",
@@ -210,6 +212,11 @@ const MEDIA_TITLES: Record<string, string> = {
210
212
  // options, so the title says so out loud rather than leaving the human to
211
213
  // work it out from a JSON blob of flags.
212
214
  generate_model: "Generate a 3D model (billed; $0.14-$2.41 a mesh depending on the model)",
215
+ // The one whose price is a MULTIPLE rather than a rate: it renders a video per
216
+ // facing, so approving it can be approving five video generations at once. The
217
+ // title says the multiplier out loud, because "generate a sprite" reads like
218
+ // one cheap call and it is not.
219
+ generate_sprite: "Generate a sprite animation (billed; 1, 3 or 5 video generations depending on facings)",
213
220
  generate_speech: "Generate speech (billed to your Privateer account)",
214
221
  generate_music: "Generate music (billed; music prompts have no zero-retention option)",
215
222
  // Cheap per call and therefore the one most likely to be called twenty times in a
@@ -410,7 +417,13 @@ export function classifyToolCall(
410
417
  const outsideInputs = resolvedInputs.filter((a) => isOutsideScope(scope, a));
411
418
  const protectedInputs = resolvedInputs.filter((a) => isProtectedPath(a));
412
419
 
413
- const outPath = str(obj.path ?? obj.output);
420
+ // `dir` is generate_sprite's output: it unpacks a whole bundle (sheet, frames
421
+ // and the .tres) into a DIRECTORY rather than writing one named file. Without
422
+ // it here the gate finds no output path and fails safe — which reads to the
423
+ // user as an unexplained denial on a tool that is in fact just writing where
424
+ // they asked. The directory is the right thing to show and to judge for
425
+ // scope: everything the tool writes lands inside it.
426
+ const outPath = str(obj.path ?? obj.output ?? obj.dir);
414
427
  // `probe` reads and writes nothing; so does any composition call with no output
415
428
  // (which the tool itself rejects). Gate those only when they touch a sensitive input.
416
429
  if (!outPath) {