privateer-agent 0.12.19 → 0.12.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.19",
3
+ "version": "0.12.21",
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,7 +68,7 @@ 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..c52ea80 100644
71
+ index ce8a9a2..b7b339e 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
74
  @@ -38,6 +38,70 @@ import { createLocalBashOperations } from "./tools/bash.js";
@@ -174,7 +174,7 @@ index ce8a9a2..c52ea80 100644
174
174
  // Emit to extensions first
175
175
  await this._emitExtensionEvent(event);
176
176
  // Notify all listeners
177
- @@ -772,6 +854,15 @@ export class AgentSession {
177
+ @@ -772,6 +854,25 @@ export class AgentSession {
178
178
  finalError: msg.errorMessage,
179
179
  });
180
180
  this._retryAttempt = 0;
@@ -186,11 +186,21 @@ index ce8a9a2..c52ea80 100644
186
186
  + // That re-entry is what produced the endless "retrying 1/3…2/3…3/3" loop
187
187
  + // on a persistently-failing provider/tool. Context-overflow errors never
188
188
  + // reach this branch (_isRetryableError → false), so compaction is untouched.
189
+ + return false;
190
+ + }
191
+ + // Privateer patch: a first-attempt hard 4xx (a WAF 403, a 401, a 404) is also
192
+ + // terminal. Stock Pi still falls through to compaction here because _retryAttempt
193
+ + // is 0, and _checkCompaction will summarise a session that already has usage.
194
+ + // That summary is another LLM call to the SAME blocked endpoint; retryAssistantCall
195
+ + // classifies the raw HTML body as transient (it contains "500"/"502"), so the
196
+ + // agent looks hung — retrying a compaction that can never succeed — until the
197
+ + // summarizer budget burns. Context overflow never matches isHardHttpFailure.
198
+ + if (msg.stopReason === "error" && isHardHttpFailure(msg.errorMessage)) {
189
199
  + return false;
190
200
  }
191
201
  if (await this._checkCompaction(msg)) {
192
202
  return true;
193
- @@ -852,6 +943,14 @@ export class AgentSession {
203
+ @@ -852,6 +953,14 @@ export class AgentSession {
194
204
  if (!hasConfiguredAuth) {
195
205
  const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
196
206
  if (isOAuth) {
@@ -205,7 +215,7 @@ index ce8a9a2..c52ea80 100644
205
215
  throw new Error(`Authentication failed for "${this.model.provider}". ` +
206
216
  `Credentials may have expired or network is unavailable. ` +
207
217
  `Run '/login ${this.model.provider}' to re-authenticate.`);
208
- @@ -2084,6 +2183,27 @@ export class AgentSession {
218
+ @@ -2084,6 +2193,27 @@ export class AgentSession {
209
219
  // Context overflow is handled by compaction, not retry.
210
220
  if (isContextOverflow(message, this.model?.contextWindow ?? 0))
211
221
  return false;
@@ -1052,17 +1062,19 @@ index 6f7ed6a..b0ed0ae 100644
1052
1062
  getResourcePattern(item) {
1053
1063
  const scope = item.metadata.scope;
1054
1064
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1055
- index 1d9f046..2a1fba8 100644
1065
+ index 1d9f046..c0326e3 100644
1056
1066
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1057
1067
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1058
- @@ -1,5 +1,5 @@
1068
+ @@ -1,7 +1,6 @@
1059
1069
  import { isAbsolute, relative, resolve, sep } from "node:path";
1060
1070
  -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1061
1071
  +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1062
1072
  import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
1063
- import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js";
1073
+ -import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js";
1064
1074
  import { theme } from "../theme/theme.js";
1065
- @@ -14,6 +14,43 @@ function sanitizeStatusText(text) {
1075
+ /**
1076
+ * Sanitize text for display in a single-line status.
1077
+ @@ -14,6 +13,43 @@ function sanitizeStatusText(text) {
1066
1078
  .replace(/ +/g, " ")
1067
1079
  .trim();
1068
1080
  }
@@ -1106,7 +1118,182 @@ index 1d9f046..2a1fba8 100644
1106
1118
  /**
1107
1119
  * Format token counts for compact footer display.
1108
1120
  */
1109
- @@ -212,9 +249,9 @@ export class FooterComponent {
1121
+ @@ -41,8 +77,11 @@ export function formatCwdForFooter(cwd, home) {
1122
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
1123
+ }
1124
+ /**
1125
+ - * Footer component that shows pwd, token stats, and context usage.
1126
+ - * Computes token/context stats from session, gets git branch and extension statuses from provider.
1127
+ + * Footer component that shows pwd (with git branch) and the active model.
1128
+ + *
1129
+ + * Privateer trimmed this: upstream also drew a second row of cumulative token
1130
+ + * counters, cache-hit rate, cost and a context gauge. Gets git branch and
1131
+ + * extension statuses from provider.
1132
+ */
1133
+ export class FooterComponent {
1134
+ autoCompactEnabled = true;
1135
+ @@ -74,29 +113,9 @@ export class FooterComponent {
1136
+ }
1137
+ render(width) {
1138
+ const state = this.session.state;
1139
+ - // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
1140
+ - const usageTotals = createUsageTotals();
1141
+ - let latestCacheHitRate;
1142
+ - for (const entry of this.session.sessionManager.getEntries()) {
1143
+ - if (entry.type === "message" && entry.message.role === "assistant") {
1144
+ - addUsageToTotals(usageTotals, entry.message.usage);
1145
+ - const latestPromptTokens = entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
1146
+ - latestCacheHitRate =
1147
+ - latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
1148
+ - }
1149
+ - else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
1150
+ - addUsageToTotals(usageTotals, entry.message.usage);
1151
+ - }
1152
+ - else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
1153
+ - addUsageToTotals(usageTotals, entry.usage);
1154
+ - }
1155
+ - }
1156
+ - // Calculate context usage from session (handles compaction correctly).
1157
+ - // After compaction, tokens are unknown until the next LLM response.
1158
+ - const contextUsage = this.session.getContextUsage();
1159
+ - const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
1160
+ - const contextPercentValue = contextUsage?.percent ?? 0;
1161
+ - const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
1162
+ + // Privateer: no token counters, cache-hit rate, cost or context gauge in the
1163
+ + // footer — so the accumulation that fed them is gone too. Upstream walked EVERY
1164
+ + // session entry on EVERY render to build numbers we no longer draw.
1165
+ // Replace home directory with ~
1166
+ let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
1167
+ // Add git branch if available
1168
+ @@ -109,56 +128,24 @@ export class FooterComponent {
1169
+ if (sessionName) {
1170
+ pwd = `${pwd} • ${sessionName}`;
1171
+ }
1172
+ - // Build stats line
1173
+ - const statsParts = [];
1174
+ - if (usageTotals.input)
1175
+ - statsParts.push(`↑${formatTokens(usageTotals.input)}`);
1176
+ - if (usageTotals.output)
1177
+ - statsParts.push(`↓${formatTokens(usageTotals.output)}`);
1178
+ - if (usageTotals.cacheRead)
1179
+ - statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`);
1180
+ - if (usageTotals.cacheWrite)
1181
+ - statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`);
1182
+ - if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
1183
+ - statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
1184
+ - }
1185
+ - // Kimi Coding is subscription-backed despite using API-key authentication.
1186
+ - const usingSubscription = state.model
1187
+ - ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingSubscription(state.model.provider)
1188
+ - : false;
1189
+ - if (usageTotals.cost || usingSubscription) {
1190
+ - const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
1191
+ - statsParts.push(costStr);
1192
+ - }
1193
+ - // Colorize context percentage based on usage
1194
+ - let contextPercentStr;
1195
+ - const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
1196
+ - const contextPercentDisplay = contextPercent === "?"
1197
+ - ? `?/${formatTokens(contextWindow)}${autoIndicator}`
1198
+ - : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
1199
+ - if (contextPercentValue > 90) {
1200
+ - contextPercentStr = theme.fg("error", contextPercentDisplay);
1201
+ - }
1202
+ - else if (contextPercentValue > 70) {
1203
+ - contextPercentStr = theme.fg("warning", contextPercentDisplay);
1204
+ - }
1205
+ - else {
1206
+ - contextPercentStr = contextPercentDisplay;
1207
+ - }
1208
+ - statsParts.push(contextPercentStr);
1209
+ - if (areExperimentalFeaturesEnabled()) {
1210
+ - statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
1211
+ - }
1212
+ - let statsLeft = statsParts.join(" ");
1213
+ + // Privateer: ONE footer row — the working directory on the left, the model on
1214
+ + // the right. Upstream spent a second row on cumulative token counters, cache-hit
1215
+ + // rate and cost; those are session trivia that never change what you do next, and
1216
+ + // they made the busiest part of the screen the least useful. The `xp` flag stays:
1217
+ + // it warns that experimental features are live, which is not trivia.
1218
+ + const experimentalFlag = areExperimentalFeaturesEnabled()
1219
+ + ? ` ${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`
1220
+ + : "";
1221
+ + let left = pwd + experimentalFlag;
1222
+ // Add model name on the right side, plus thinking level if model supports it
1223
+ const modelName = state.model?.id || "no-model";
1224
+ - let statsLeftWidth = visibleWidth(statsLeft);
1225
+ - // If statsLeft is too wide, truncate it
1226
+ - if (statsLeftWidth > width) {
1227
+ - statsLeft = truncateToWidth(statsLeft, width, "...");
1228
+ - statsLeftWidth = visibleWidth(statsLeft);
1229
+ + let leftWidth = visibleWidth(left);
1230
+ + // If the left side is too wide, truncate it
1231
+ + if (leftWidth > width) {
1232
+ + left = truncateToWidth(left, width, "...");
1233
+ + leftWidth = visibleWidth(left);
1234
+ }
1235
+ - // Calculate available space for padding (minimum 2 spaces between stats and model)
1236
+ + // Calculate available space for padding (minimum 2 spaces between cwd and model)
1237
+ const minPadding = 2;
1238
+ // Add thinking level indicator if model supports reasoning
1239
+ let rightSideWithoutProvider = modelName;
1240
+ @@ -171,50 +158,48 @@ export class FooterComponent {
1241
+ let rightSide = rightSideWithoutProvider;
1242
+ if (this.footerData.getAvailableProviderCount() > 1 && state.model) {
1243
+ rightSide = `(${state.model.provider}) ${rightSideWithoutProvider}`;
1244
+ - if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
1245
+ + if (leftWidth + minPadding + visibleWidth(rightSide) > width) {
1246
+ // Too wide, fall back
1247
+ rightSide = rightSideWithoutProvider;
1248
+ }
1249
+ }
1250
+ const rightSideWidth = visibleWidth(rightSide);
1251
+ - const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
1252
+ - let statsLine;
1253
+ + const totalNeeded = leftWidth + minPadding + rightSideWidth;
1254
+ + let footerLine;
1255
+ if (totalNeeded <= width) {
1256
+ // Both fit - add padding to right-align model
1257
+ - const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
1258
+ - statsLine = statsLeft + padding + rightSide;
1259
+ + const padding = " ".repeat(width - leftWidth - rightSideWidth);
1260
+ + footerLine = left + padding + rightSide;
1261
+ }
1262
+ else {
1263
+ // Need to truncate right side
1264
+ - const availableForRight = width - statsLeftWidth - minPadding;
1265
+ + const availableForRight = width - leftWidth - minPadding;
1266
+ if (availableForRight > 0) {
1267
+ const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
1268
+ const truncatedRightWidth = visibleWidth(truncatedRight);
1269
+ - const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
1270
+ - statsLine = statsLeft + padding + truncatedRight;
1271
+ + const padding = " ".repeat(Math.max(0, width - leftWidth - truncatedRightWidth));
1272
+ + footerLine = left + padding + truncatedRight;
1273
+ }
1274
+ else {
1275
+ // Not enough space for right side at all
1276
+ - statsLine = statsLeft;
1277
+ + footerLine = left;
1278
+ }
1279
+ }
1280
+ - // Apply dim to each part separately. statsLeft may contain color codes (for context %)
1281
+ - // that end with a reset, which would clear an outer dim wrapper. So we dim the parts
1282
+ - // before and after the colored section independently.
1283
+ - const dimStatsLeft = theme.fg("dim", statsLeft);
1284
+ - const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
1285
+ - const dimRemainder = theme.fg("dim", remainder);
1286
+ - const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
1287
+ - const lines = [pwdLine, dimStatsLeft + dimRemainder];
1288
+ + // Apply dim to each part separately. `left` may carry the bold/warning-coloured
1289
+ + // xp flag, whose reset would clear an outer dim wrapper. So dim the parts before
1290
+ + // and after the coloured section independently.
1291
+ + const dimLeft = theme.fg("dim", left);
1292
+ + const remainder = footerLine.slice(left.length); // padding + rightSide
1293
+ + const lines = [dimLeft + theme.fg("dim", remainder)];
1294
+ // Add extension statuses on a single line, sorted by key alphabetically
1295
+ const extensionStatuses = this.footerData.getExtensionStatuses();
1296
+ if (extensionStatuses.size > 0) {
1110
1297
  const sortedStatuses = Array.from(extensionStatuses.entries())
1111
1298
  .sort(([a], [b]) => a.localeCompare(b))
1112
1299
  .map(([, text]) => sanitizeStatusText(text));
@@ -1119,6 +1306,97 @@ index 1d9f046..2a1fba8 100644
1119
1306
  }
1120
1307
  return lines;
1121
1308
  }
1309
+ 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
1311
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
1312
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
1313
+ @@ -1,8 +1,35 @@
1314
+ -import { Box, Container, getCapabilities, Image, Spacer, Text } from "@earendil-works/pi-tui";
1315
+ +import { Box, Container, getCapabilities, Image, Spacer, visibleWidth, Text } from "@earendil-works/pi-tui";
1316
+ import { createAllToolDefinitions } from "../../../core/tools/index.js";
1317
+ import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
1318
+ import { convertToPng } from "../../../utils/image-convert.js";
1319
+ import { theme } from "../theme/theme.js";
1320
+ +/**
1321
+ + * Privateer: mark a failed tool call inline on its title line.
1322
+ + *
1323
+ + * Upstream's ONLY failure signal is the full-width red `toolErrorBg` wash behind
1324
+ + * the whole block. We flatten those washes (see dark.json / light.json) because
1325
+ + * large blocks of colour read as noise — but flattening the error one alone would
1326
+ + * make a failure indistinguishable from a success, which is a real regression.
1327
+ + *
1328
+ + * So we put the signal back where it costs nothing: a red glyph prefixed to the
1329
+ + * first line of whatever the tool's own renderCall produced. Wrapping the CALL
1330
+ + * component (not the Box) means Box still owns padding and width, and the marker
1331
+ + * works for every tool without touching seven per-tool renderers. Subsequent
1332
+ + * lines get a blank hanging indent so the title stays visually aligned.
1333
+ + */
1334
+ +function markCallErrored(component, marker) {
1335
+ + const markerWidth = visibleWidth(marker);
1336
+ + const indent = " ".repeat(markerWidth);
1337
+ + return {
1338
+ + render(width) {
1339
+ + const lines = component.render(Math.max(1, width - markerWidth));
1340
+ + return lines.map((line, i) => (i === 0 ? marker + line : indent + line));
1341
+ + },
1342
+ + invalidate() {
1343
+ + component.invalidate?.();
1344
+ + },
1345
+ + };
1346
+ +}
1347
+ export class ToolExecutionComponent extends Container {
1348
+ contentBox;
1349
+ contentText;
1350
+ @@ -106,6 +133,13 @@ export class ToolExecutionComponent extends Container {
1351
+ createCallFallback() {
1352
+ return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
1353
+ }
1354
+ + /** Privateer: red-flag a failed call, pass a pending/succeeded one straight through. */
1355
+ + decorateCall(component) {
1356
+ + if (!this.result?.isError || this.isPartial) {
1357
+ + return component;
1358
+ + }
1359
+ + return markCallErrored(component, theme.fg("error", "\u2717 "));
1360
+ + }
1361
+ createResultFallback() {
1362
+ const output = this.getTextOutput();
1363
+ if (!output) {
1364
+ @@ -218,19 +252,22 @@ export class ToolExecutionComponent extends Container {
1365
+ renderContainer.clear();
1366
+ const callRenderer = this.getCallRenderer();
1367
+ if (!callRenderer) {
1368
+ - renderContainer.addChild(this.createCallFallback());
1369
+ + renderContainer.addChild(this.decorateCall(this.createCallFallback()));
1370
+ hasContent = true;
1371
+ }
1372
+ else {
1373
+ try {
1374
+ const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
1375
+ + // Store the RAW component: it comes back as `lastComponent` on the
1376
+ + // next render and the tool's renderer calls its own methods on it,
1377
+ + // so handing it a wrapper would break every stateful renderer.
1378
+ this.callRendererComponent = component;
1379
+ - renderContainer.addChild(component);
1380
+ + renderContainer.addChild(this.decorateCall(component));
1381
+ hasContent = true;
1382
+ }
1383
+ catch {
1384
+ this.callRendererComponent = undefined;
1385
+ - renderContainer.addChild(this.createCallFallback());
1386
+ + renderContainer.addChild(this.decorateCall(this.createCallFallback()));
1387
+ hasContent = true;
1388
+ }
1389
+ }
1390
+ @@ -302,7 +339,8 @@ export class ToolExecutionComponent extends Container {
1391
+ return getRenderedTextOutput(this.result, this.showImages);
1392
+ }
1393
+ formatToolExecution() {
1394
+ - let text = theme.fg("toolTitle", theme.bold(this.toolName));
1395
+ + const errorMark = this.result?.isError && !this.isPartial ? theme.fg("error", "\u2717 ") : "";
1396
+ + let text = errorMark + theme.fg("toolTitle", theme.bold(this.toolName));
1397
+ const content = JSON.stringify(this.args, null, 2);
1398
+ if (content) {
1399
+ text += `\n\n${content}`;
1122
1400
  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
1123
1401
  index 42e655d..d00bec9 100644
1124
1402
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
@@ -1269,6 +1547,40 @@ index 42e655d..d00bec9 100644
1269
1547
  }
1270
1548
  async getUserInput() {
1271
1549
  const queuedInput = this.pendingUserInputs.shift();
1550
+ 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
+ index 9db9cbd..b370180 100644
1552
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
1553
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
1554
+ @@ -39,9 +39,9 @@
1555
+ "customMessageBg": "customMsgBg",
1556
+ "customMessageText": "text",
1557
+ "customMessageLabel": "#9575cd",
1558
+ - "toolPendingBg": "toolPendingBg",
1559
+ - "toolSuccessBg": "toolSuccessBg",
1560
+ - "toolErrorBg": "toolErrorBg",
1561
+ + "toolPendingBg": "",
1562
+ + "toolSuccessBg": "",
1563
+ + "toolErrorBg": "",
1564
+ "toolTitle": "text",
1565
+ "toolOutput": "gray",
1566
+
1567
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
1568
+ index 74ef3d1..709a21d 100644
1569
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
1570
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
1571
+ @@ -38,9 +38,9 @@
1572
+ "customMessageBg": "customMsgBg",
1573
+ "customMessageText": "text",
1574
+ "customMessageLabel": "#7e57c2",
1575
+ - "toolPendingBg": "toolPendingBg",
1576
+ - "toolSuccessBg": "toolSuccessBg",
1577
+ - "toolErrorBg": "toolErrorBg",
1578
+ + "toolPendingBg": "",
1579
+ + "toolSuccessBg": "",
1580
+ + "toolErrorBg": "",
1581
+ "toolTitle": "text",
1582
+ "toolOutput": "mediumGray",
1583
+
1272
1584
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js b/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js
1273
1585
  index 4230b18..f47e80f 100644
1274
1586
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * buildMoat() — the one place that decides which extensions a session gets, in what order.
3
3
  *
4
- * WHAT THIS REPLACES. Five entry points each hand-assembled their own `extensionFactories`
4
+ * WHAT THIS REPLACES. Six entry points each hand-assembled their own `extensionFactories`
5
5
  * array: the harbor's sessions (routines, workflows, tasks), its live task spawns, the
6
- * channels runner, ACP, and the dev REPL. The five lists were the same list — same gate,
7
- * same privacy extension, same account provider, the same `webEnabled()`/`mediaEnabled()`
8
- * conditionals — copied with small deliberate differences and a large amount of duplicated
9
- * comment explaining them. Nothing held them together, so an extension added to one path
10
- * silently didn't exist on the other four (privateer-media had reached three of five), and
11
- * the ordering rule below lived as prose repeated in each copy.
6
+ * channels runner, ACP, the dev REPL, and the desktop app's per-window session. The lists
7
+ * were the same list — same gate, same privacy extension, same account provider, the same
8
+ * `webEnabled()`/`mediaEnabled()` conditionals — copied with small deliberate differences
9
+ * and a large amount of duplicated comment explaining them. Nothing held them together, so
10
+ * an extension added to one path silently didn't exist on the others (privateer-media had
11
+ * reached three of five), and the ordering rule below lived as prose repeated in each copy.
12
+ *
13
+ * THE DESKTOP IS WHY THIS NOTE IS NOW ABOUT SIX. It was left out of the first pass and
14
+ * proved the point within the month: the app's Super Computer had NO generation tools at
15
+ * all — no generate_image / _video / _model / _speech / _music / _sfx, no
16
+ * media_capabilities, and not even video_compose, which every other kind gets
17
+ * unconditionally because it is local ffmpeg work that costs nothing. Its MCP connectors
18
+ * worked, so Godot and Unreal drove the editor fine while the same window could not make
19
+ * a texture to put in it. The lesson is the one this module was written for: the fix is
20
+ * not "add the media factory to that array", it is "there are no arrays".
12
21
  *
13
22
  * ORDER MATTERS, ONCE. pi-privacy's own catalog registers a `privateer` provider (its
14
23
  * PUBLIC developer-key channel, one seed model), and Pi's registerProvider REPLACES a
@@ -51,7 +60,8 @@ export type MoatKind =
51
60
  | "live-task" // a drivable session the harbor spawns for the app
52
61
  | "channels" // Telegram / Slack / Discord / WhatsApp bridge sessions
53
62
  | "acp" // `privateer acp` — an ACP host (Zed, Buzz) drives
54
- | "repl"; // the lean dev REPL (npm run chat)
63
+ | "repl" // the lean dev REPL (npm run chat)
64
+ | "desktop"; // the desktop app's Super Computer — one session per window
55
65
 
56
66
  export interface MoatOptions {
57
67
  kind: MoatKind;
@@ -72,24 +82,83 @@ export interface MoatOptions {
72
82
  * the point: a run whose result goes to a webhook or a file has nothing to attach to.
73
83
  */
74
84
  resultMedia?: import("../routines/resultMedia.ts").ResultMedia;
85
+ /**
86
+ * What a SIGNED-OUT session hears when it calls web_search / web_fetch. Required by
87
+ * `web: "guarded"` and ignored otherwise. It lives on the caller because only the caller
88
+ * knows what the user can do about it — a desktop window can point at its account menu,
89
+ * a headless host has no menu to point at (src/tools/web.ts).
90
+ */
91
+ webHint?: string;
92
+ /**
93
+ * How to import a THIRD-PARTY dependency (today: pi-mcp-adapter). Defaults to a plain
94
+ * dynamic import, which is right for every process that runs from a normal Node
95
+ * resolution root.
96
+ *
97
+ * The desktop is not one. It runs the agent's copy of every shared package — one Pi
98
+ * instance, one captured-cert map — so it resolves from privateer-agent's own
99
+ * package.json rather than the app's, and it needs a fallback for adapters whose entry
100
+ * is an `index.ts` (under Electron, tsx's `register()` patches only the ESM loader, so
101
+ * a vanilla CJS resolve looks for index.js and reports MODULE_NOT_FOUND). Taking the
102
+ * resolver from the host keeps that knowledge in the host, where it belongs, instead of
103
+ * teaching this module about Electron.
104
+ */
105
+ hostImport?: (spec: string) => Promise<any>;
75
106
  }
76
107
 
77
108
  /**
78
109
  * Per-kind capabilities. `web` and `media` are the CEILING — each is still ANDed with its
79
110
  * runtime switch (webEnabled/mediaEnabled), so a false here means "never", not "by default".
80
111
  *
81
- * web is deliberately off for live-task and repl: makeWebTools() routes through the account
82
- * API precisely because an unattended run must not hold a search provider key, and both of
83
- * those paths have a human at the other end who can use their own provider (src/tools/web.ts).
84
- * compose is unconditional everywhere: local ffmpeg work, no account, no network, no spend —
85
- * so a run with generation off can still assemble media that already exists on disk.
112
+ * web is deliberately off for live-task and repl: the account form routes through the
113
+ * account API precisely because an unattended run must not hold a search provider key, and
114
+ * both of those paths have a human at the other end who can use their own provider
115
+ * (src/tools/web.ts). compose is unconditional everywhere: local ffmpeg work, no account,
116
+ * no network, no spend — so a run with generation off can still assemble media that
117
+ * already exists on disk.
118
+ *
119
+ * The three optional rows below are all FALSE for the unattended kinds, which is exactly
120
+ * what those five paths did before the desktop joined the table. They exist because the
121
+ * desktop is the first ATTENDED session built from here, and an attended session differs
122
+ * from a headless one in ways that are real rather than cosmetic — a project context file
123
+ * to load, a folder whose skills follow it, and a model PICKER whose registry has to be
124
+ * repaired. Leaving them off keeps every pre-existing kind byte-identical.
86
125
  */
87
- const CAPABILITIES: Record<MoatKind, { web: boolean; media: boolean; mcp: boolean }> = {
88
- "harbor-session": { web: true, media: true, mcp: true },
126
+ interface MoatCaps {
127
+ /**
128
+ * false — never; the tools do not exist for this kind.
129
+ * "account" — registered only once webEnabled() says there are credentials. The
130
+ * UNATTENDED shape: decide at build, because nothing will change mid-run.
131
+ * "guarded" — always registered, each call re-checks sign-in and answers with
132
+ * `webHint` when there is none. The ATTENDED shape: the session outlives
133
+ * `/signin`, so the question has to be asked when the tool RUNS
134
+ * (src/tools/web.ts spells out why these are two functions, not one).
135
+ */
136
+ web: false | "account" | "guarded";
137
+ media: boolean;
138
+ mcp: boolean;
139
+ /** PRIVATEER.md project context + /init (extensions/privateer-context.ts). */
140
+ context?: boolean;
141
+ /** The folder's own skills, contributed from ~/.privateer rather than the user's tree. */
142
+ spawnSkills?: boolean;
143
+ /**
144
+ * Load the privacy SHIM (extensions/privateer-privacy.ts) rather than the bare
145
+ * privacyExtension() below — the same configuration plus two provider REPAIRS.
146
+ * pi-privacy re-registers `tinfoil` with a one-model seed catalog and `privateer` with
147
+ * its public developer-key channel, and registerProvider REPLACES a provider's models.
148
+ * A kind that resolves ONE configured model never notices; a kind with a model picker
149
+ * does, loudly — every build throwing "Model tinfoil/… not found" on a machine with a
150
+ * working key. See the shim's header for the full account.
151
+ */
152
+ privacyRepairs?: boolean;
153
+ }
154
+
155
+ const CAPABILITIES: Record<MoatKind, MoatCaps> = {
156
+ "harbor-session": { web: "account", media: true, mcp: true },
89
157
  "live-task": { web: false, media: true, mcp: false },
90
- channels: { web: true, media: true, mcp: false },
91
- acp: { web: true, media: true, mcp: false },
158
+ channels: { web: "account", media: true, mcp: false },
159
+ acp: { web: "account", media: true, mcp: false },
92
160
  repl: { web: false, media: true, mcp: false },
161
+ desktop: { web: "guarded", media: true, mcp: true, context: true, spawnSkills: true, privacyRepairs: true },
93
162
  };
94
163
 
95
164
  /**
@@ -180,16 +249,32 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
180
249
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
181
250
  const { makeAccountProvider } = await import("../providers/account.ts");
182
251
  const { webEnabled, mediaEnabled } = await import("./hosted.ts");
183
- const { privacyExtension } = await import("./privacyPolicy.ts");
184
252
 
185
253
  const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
186
254
 
187
255
  // pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
188
256
  // extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
189
- // bugs that came of configuring it in two.
190
- factories.push(privacyExtension());
257
+ // bugs that came of configuring it in two. `privacyRepairs` picks which of the two
258
+ // ROUTES into that one configuration a kind takes, never which options it gets.
259
+ if (caps.privacyRepairs) {
260
+ const { default: privateerPrivacy } = await import("../../extensions/privateer-privacy.ts");
261
+ factories.push(privateerPrivacy);
262
+ } else {
263
+ const { privacyExtension } = await import("./privacyPolicy.ts");
264
+ factories.push(privacyExtension());
265
+ }
191
266
  factories.push(makeAccountProvider()); // must follow pi-privacy — see header
192
267
 
268
+ if (caps.context) {
269
+ const { default: privateerContext } = await import("../../extensions/privateer-context.ts");
270
+ factories.push(privateerContext);
271
+ }
272
+
273
+ if (caps.spawnSkills) {
274
+ const { default: privateerSpawnSkills } = await import("../../extensions/privateer-spawn-skills.ts");
275
+ factories.push(privateerSpawnSkills);
276
+ }
277
+
193
278
  if (opts.relayFiles) {
194
279
  const { makeRelayFileTools } = await import("../tools/relayFileTools.ts");
195
280
  factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
@@ -200,9 +285,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
200
285
  factories.push(makeAttachResultTools(opts.resultMedia));
201
286
  }
202
287
 
203
- if (caps.web && webEnabled()) {
288
+ if (caps.web === "account" && webEnabled()) {
204
289
  const { makeWebTools } = await import("../tools/web.ts");
205
290
  factories.push(makeWebTools());
291
+ } else if (caps.web === "guarded") {
292
+ if (!opts.webHint) throw new Error(`buildMoat: kind "${opts.kind}" needs a webHint for guarded web tools`);
293
+ const hint = opts.webHint;
294
+ const { guardedWebToolDefinitions } = await import("../tools/web.ts");
295
+ factories.push((pi: any) => {
296
+ for (const def of guardedWebToolDefinitions(hint)) pi.registerTool?.(def);
297
+ });
206
298
  }
207
299
 
208
300
  if (caps.media && mediaEnabled()) {
@@ -222,7 +314,8 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
222
314
  // (inside createAgentSessionServices), not when it is imported here, so the env has to
223
315
  // be set around session creation rather than around this call.
224
316
  const mcpAdapterSpec = "pi-mcp-adapter";
225
- const { default: mcpAdapter } = await import(mcpAdapterSpec);
317
+ const hostImport = opts.hostImport ?? ((spec: string) => import(spec));
318
+ const { default: mcpAdapter } = await hostImport(mcpAdapterSpec);
226
319
  factories.push(mcpAdapter);
227
320
  }
228
321
 
@@ -134,6 +134,13 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
134
134
  // patches/@earendil-works+pi-coding-agent+*.patch, which mirrors these two helpers):
135
135
  // squeeze the page down to the line a person can act on, and let the STATUS decide
136
136
  // retryability rather than a substring of the body.
137
+ //
138
+ // A first-attempt hard 4xx is also terminal for the post-run loop. Stock Pi still
139
+ // falls through to compaction when _retryAttempt is 0, and a session that already
140
+ // has usage will summarise — another LLM call to the same blocked endpoint. The
141
+ // summarizer retries on the raw HTML (it contains "500"/"502"), so the agent looks
142
+ // hung until that budget burns. The patch returns false from _handlePostAgentRun
143
+ // before _checkCompaction when isHardHttpFailure matches.
137
144
 
138
145
  /** Hard cap on an error message we display, persist, or classify. */
139
146
  export const MAX_ERROR_CHARS = 2_000;