@psnext/slingcli 2.5.20260722-2 → 2.5.20260722-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js +9 -7
  2. package/node_modules/@earendil-works/pi-agent-core/dist/agent.js +25 -23
  3. package/node_modules/@earendil-works/pi-agent-core/dist/harness/agent-harness.js +32 -19
  4. package/node_modules/@earendil-works/pi-agent-core/dist/harness/compaction/branch-summarization.js +6 -7
  5. package/node_modules/@earendil-works/pi-agent-core/dist/harness/compaction/compaction.js +75 -26
  6. package/node_modules/@earendil-works/pi-agent-core/dist/harness/compaction/utils.js +6 -17
  7. package/node_modules/@earendil-works/pi-agent-core/dist/harness/session/jsonl-storage.js +56 -4
  8. package/node_modules/@earendil-works/pi-agent-core/dist/harness/session/memory-storage.js +56 -4
  9. package/node_modules/@earendil-works/pi-agent-core/dist/harness/session/repo-utils.js +2 -2
  10. package/node_modules/@earendil-works/pi-agent-core/dist/harness/session/session.js +30 -14
  11. package/node_modules/@earendil-works/pi-agent-core/dist/index.js +4 -2
  12. package/node_modules/@earendil-works/pi-agent-core/dist/stream-fn.js +17 -0
  13. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  14. package/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js +2 -7
  15. package/node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js +14 -2
  16. package/node_modules/@earendil-works/pi-ai/dist/api/pi-messages.js +2 -4
  17. package/node_modules/@earendil-works/pi-ai/dist/auth/helpers.js +3 -2
  18. package/node_modules/@earendil-works/pi-ai/dist/auth/oauth/kimi-coding.js +249 -0
  19. package/node_modules/@earendil-works/pi-ai/dist/auth/oauth/load.js +5 -0
  20. package/node_modules/@earendil-works/pi-ai/dist/bun-oauth.js +2 -0
  21. package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.js +2 -0
  22. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js +60 -0
  23. package/node_modules/@earendil-works/pi-ai/dist/index.js +2 -0
  24. package/node_modules/@earendil-works/pi-ai/dist/models.generated.js +4 -0
  25. package/node_modules/@earendil-works/pi-ai/dist/providers/all.js +8 -0
  26. package/node_modules/@earendil-works/pi-ai/dist/providers/amazon-bedrock.js +3 -2
  27. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -0
  28. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cerebras.json +1 -1
  29. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  30. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-workers-ai.json +1 -1
  31. package/node_modules/@earendil-works/pi-ai/dist/providers/data/fireworks.json +1 -1
  32. package/node_modules/@earendil-works/pi-ai/dist/providers/data/github-copilot.json +1 -1
  33. package/node_modules/@earendil-works/pi-ai/dist/providers/data/groq.json +1 -1
  34. package/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
  35. package/node_modules/@earendil-works/pi-ai/dist/providers/data/kimi-coding.json +1 -1
  36. package/node_modules/@earendil-works/pi-ai/dist/providers/data/moonshotai-cn.json +1 -1
  37. package/node_modules/@earendil-works/pi-ai/dist/providers/data/moonshotai.json +1 -1
  38. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openai-codex.json +1 -1
  39. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openai.json +1 -1
  40. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
  41. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode.json +1 -1
  42. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  43. package/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan-cn.json +1 -0
  44. package/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan.json +1 -0
  45. package/node_modules/@earendil-works/pi-ai/dist/providers/data/together.json +1 -1
  46. package/node_modules/@earendil-works/pi-ai/dist/providers/data/xai.json +1 -1
  47. package/node_modules/@earendil-works/pi-ai/dist/providers/kimi-coding.js +10 -2
  48. package/node_modules/@earendil-works/pi-ai/dist/providers/opencode-go.js +2 -0
  49. package/node_modules/@earendil-works/pi-ai/dist/providers/qwen-token-plan-cn.js +15 -0
  50. package/node_modules/@earendil-works/pi-ai/dist/providers/qwen-token-plan-cn.models.js +5 -0
  51. package/node_modules/@earendil-works/pi-ai/dist/providers/qwen-token-plan.js +15 -0
  52. package/node_modules/@earendil-works/pi-ai/dist/providers/qwen-token-plan.models.js +5 -0
  53. package/node_modules/@earendil-works/pi-ai/dist/providers/radius-config.js +18 -14
  54. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js +3 -0
  55. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.js +79 -0
  56. package/node_modules/@earendil-works/pi-ai/dist/utils/text.js +10 -0
  57. package/node_modules/@earendil-works/{pi-agent-core/dist/harness/session → pi-ai/dist/utils}/uuid.js +4 -8
  58. package/node_modules/@earendil-works/pi-ai/package.json +8 -5
  59. package/node_modules/@earendil-works/pi-coding-agent/dist/cli/args.js +2 -0
  60. package/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js +72 -57
  61. package/node_modules/@earendil-works/pi-coding-agent/dist/core/compaction/branch-summarization.js +8 -11
  62. package/node_modules/@earendil-works/pi-coding-agent/dist/core/compaction/compaction.js +63 -26
  63. package/node_modules/@earendil-works/pi-coding-agent/dist/core/compaction/utils.js +6 -17
  64. package/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/runner.js +5 -0
  65. package/node_modules/@earendil-works/pi-coding-agent/dist/core/model-resolver.js +2 -0
  66. package/node_modules/@earendil-works/pi-coding-agent/dist/core/model-runtime.js +18 -15
  67. package/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js +1 -1
  68. package/node_modules/@earendil-works/pi-coding-agent/dist/core/remote-catalog-provider.js +26 -7
  69. package/node_modules/@earendil-works/pi-coding-agent/dist/core/sdk.js +6 -2
  70. package/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js +100 -24
  71. package/node_modules/@earendil-works/pi-coding-agent/dist/core/usage-totals.js +52 -0
  72. package/node_modules/@earendil-works/pi-coding-agent/dist/extensions/llama/client.js +3 -1
  73. package/node_modules/@earendil-works/pi-coding-agent/dist/extensions/llama/index.js +3 -3
  74. package/node_modules/@earendil-works/pi-coding-agent/dist/index.js +1 -1
  75. package/node_modules/@earendil-works/pi-coding-agent/dist/main.js +5 -1
  76. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js +20 -21
  77. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +69 -25
  78. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-client.js +7 -0
  79. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.js +4 -0
  80. package/node_modules/@earendil-works/pi-coding-agent/npm-shrinkwrap.json +16 -31
  81. package/node_modules/@earendil-works/pi-coding-agent/package.json +4 -4
  82. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/cli.js +5 -5
  83. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/config.js +9 -9
  84. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/ipc/client.js +1 -1
  85. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/ipc/server.js +1 -1
  86. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/radius.js +8 -8
  87. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/rpc-process.js +1 -1
  88. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/serve.js +3 -3
  89. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/storage.js +7 -7
  90. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/supervisor.js +2 -2
  91. package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/package.json +9 -6
  92. package/node_modules/@earendil-works/pi-tui/dist/components/editor.js +16 -10
  93. package/node_modules/@earendil-works/pi-tui/dist/tui.js +4 -2
  94. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  95. package/package.json +7 -7
  96. package/slingshot/index.js +253 -250
  97. /package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/handler.js +0 -0
  98. /package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/index.js +0 -0
  99. /package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/ipc/protocol.js +0 -0
  100. /package/node_modules/@earendil-works/{pi-orchestrator → pi-server}/dist/types.js +0 -0
@@ -32,7 +32,7 @@ export class ModelRuntime {
32
32
  extensionProviders = new Map();
33
33
  compositionErrors = new Map();
34
34
  modelsPath;
35
- allowModelNetwork;
35
+ modelNetworkEnabled;
36
36
  config;
37
37
  snapshot = {
38
38
  all: [],
@@ -43,11 +43,11 @@ export class ModelRuntime {
43
43
  };
44
44
  availabilityRefresh;
45
45
  availabilityError;
46
- constructor(credentials, config, modelsPath, modelsStore, providers, allowModelNetwork) {
46
+ constructor(credentials, config, modelsPath, modelsStore, providers, modelNetworkEnabled) {
47
47
  this.credentials = credentials;
48
48
  this.config = config;
49
49
  this.modelsPath = modelsPath;
50
- this.allowModelNetwork = allowModelNetwork;
50
+ this.modelNetworkEnabled = modelNetworkEnabled;
51
51
  this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
52
52
  for (const [providerId, provider] of this.defaultBuiltins)
53
53
  this.builtins.set(providerId, provider);
@@ -64,16 +64,19 @@ export class ModelRuntime {
64
64
  : new InMemoryCodingAgentModelsStore());
65
65
  const providers = builtinProviderCatalog
66
66
  .builtinProviders()
67
- .map((provider) => provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl));
68
- const runtime = new ModelRuntime(credentials, config, modelsPath, modelsStore, providers, options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined);
67
+ .map((provider) => provider.id === "radius"
68
+ ? provider
69
+ : withRemoteCatalog(provider, options.catalogBaseUrl, builtinProviderCatalog.getBuiltinModelDataUrl(provider.id)));
70
+ const runtime = new ModelRuntime(credentials, config, modelsPath, modelsStore, providers, process.env.PI_OFFLINE === undefined);
69
71
  runtime.configureRadiusProviders();
70
72
  runtime.rebuildProviders();
71
- const controller = new AbortController();
72
- const timeout = runtime.allowModelNetwork
73
+ const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true;
74
+ const controller = refreshFromNetwork ? new AbortController() : undefined;
75
+ const timeout = controller
73
76
  ? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000)
74
77
  : undefined;
75
78
  try {
76
- await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal });
79
+ await runtime.refresh({ allowNetwork: refreshFromNetwork, signal: controller?.signal });
77
80
  }
78
81
  finally {
79
82
  if (timeout)
@@ -270,7 +273,7 @@ export class ModelRuntime {
270
273
  },
271
274
  };
272
275
  }
273
- async setRuntimeApiKey(providerId, apiKey) {
276
+ async setRuntimeApiKey(providerId, apiKey, refreshOptions = {}) {
274
277
  this.credentials.setRuntimeApiKey(providerId, apiKey);
275
278
  const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" });
276
279
  const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
@@ -282,11 +285,11 @@ export class ModelRuntime {
282
285
  storedProviders,
283
286
  available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
284
287
  };
285
- await this.refresh({ allowNetwork: this.allowModelNetwork });
288
+ await this.refresh(refreshOptions);
286
289
  }
287
290
  async removeRuntimeApiKey(providerId) {
288
291
  this.credentials.removeRuntimeApiKey(providerId);
289
- await this.refresh({ allowNetwork: this.allowModelNetwork });
292
+ await this.refresh({ allowNetwork: this.modelNetworkEnabled });
290
293
  }
291
294
  listCredentials() {
292
295
  return this.credentials.list();
@@ -347,25 +350,25 @@ export class ModelRuntime {
347
350
  }
348
351
  async login(providerId, type, interaction) {
349
352
  const credential = await this.models.login(providerId, type, interaction);
350
- await this.refresh({ allowNetwork: this.allowModelNetwork });
353
+ await this.refresh({ allowNetwork: this.modelNetworkEnabled });
351
354
  return credential;
352
355
  }
353
356
  async logout(providerId) {
354
357
  await this.models.logout(providerId);
355
358
  // Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh.
356
359
  this.recomposeProvider(providerId);
357
- await this.refresh({ allowNetwork: this.allowModelNetwork });
360
+ await this.refresh({ allowNetwork: this.modelNetworkEnabled });
358
361
  }
359
362
  async reloadConfig() {
360
363
  this.config = await ModelConfig.load(this.modelsPath);
361
364
  this.configureRadiusProviders();
362
365
  this.rebuildProviders();
363
- await this.refresh({ allowNetwork: this.allowModelNetwork });
366
+ await this.refresh({ allowNetwork: this.modelNetworkEnabled });
364
367
  }
365
368
  async refresh(options = {}) {
366
369
  const refreshOptions = {
367
370
  ...options,
368
- allowNetwork: options.allowNetwork ?? this.allowModelNetwork,
371
+ allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled,
369
372
  };
370
373
  // Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
371
374
  // The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
@@ -2,7 +2,7 @@ import { CONFIG_DIR_NAME } from "../config.js";
2
2
  import { emitProjectTrustEvent } from "./extensions/runner.js";
3
3
  import { getProjectTrustOptions, hasTrustRequiringProjectResources, } from "./trust-manager.js";
4
4
  function formatProjectTrustPrompt(cwd) {
5
- return `Trust project folder?\n${cwd}\n\nThis allows sling to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
5
+ return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
6
6
  }
7
7
  async function selectProjectTrustOption(cwd, ctx) {
8
8
  const options = getProjectTrustOptions(cwd, { includeSessionOnly: true });
@@ -1,3 +1,4 @@
1
+ import { stat } from "node:fs/promises";
1
2
  import { VERSION } from "../config.js";
2
3
  import { getPiUserAgent } from "../utils/pi-user-agent.js";
3
4
  const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
@@ -27,8 +28,17 @@ function parseCatalog(providerId, value) {
27
28
  .filter((entry) => typeof entry === "object" && entry !== null && "id" in entry)
28
29
  .map((model) => ({ ...model, provider: providerId }));
29
30
  }
31
+ function remoteModels(entry, localLastModified) {
32
+ if (!entry)
33
+ return [];
34
+ if (localLastModified !== undefined &&
35
+ (entry.lastModified === undefined || entry.lastModified <= localLastModified)) {
36
+ return [];
37
+ }
38
+ return entry.models;
39
+ }
30
40
  /** Add a persisted pi.dev catalog overlay to a static built-in provider. */
31
- export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BASE_URL) {
41
+ export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BASE_URL, localCatalogUrl) {
32
42
  let dynamicModels = [];
33
43
  let inflightRefresh;
34
44
  return {
@@ -37,13 +47,16 @@ export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BAS
37
47
  refreshModels: (context) => {
38
48
  inflightRefresh ??= (async () => {
39
49
  try {
50
+ const localLastModified = localCatalogUrl
51
+ ? await stat(localCatalogUrl).then((value) => value.mtimeMs, () => undefined)
52
+ : undefined;
40
53
  const stored = await context.store.read();
41
- if (stored)
42
- dynamicModels = stored.models.filter((model) => model.provider === provider.id);
54
+ dynamicModels = remoteModels(stored, localLastModified).filter((model) => model.provider === provider.id);
43
55
  if (!context.allowNetwork || context.signal?.aborted)
44
56
  return;
45
57
  if (!context.force &&
46
58
  stored?.checkedAt !== undefined &&
59
+ stored.lastModified !== undefined &&
47
60
  Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS) {
48
61
  return;
49
62
  }
@@ -59,18 +72,24 @@ export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BAS
59
72
  return;
60
73
  const checkedAt = Date.now();
61
74
  if (response.status === 404 || response.status === 501) {
62
- await context.store.write({ models: dynamicModels, checkedAt });
75
+ await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
63
76
  return;
64
77
  }
65
78
  if (!response.ok) {
66
- await context.store.write({ models: dynamicModels, checkedAt });
79
+ await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
67
80
  throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
68
81
  }
69
82
  const refreshed = parseCatalog(provider.id, await response.json());
83
+ const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
70
84
  if (context.signal?.aborted)
71
85
  return;
72
- dynamicModels = refreshed;
73
- await context.store.write({ models: refreshed, checkedAt });
86
+ const entry = {
87
+ models: refreshed,
88
+ checkedAt,
89
+ lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
90
+ };
91
+ dynamicModels = remoteModels(entry, localLastModified);
92
+ await context.store.write(entry);
74
93
  }
75
94
  finally {
76
95
  inflightRefresh = undefined;
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
- import { Agent } from "@earendil-works/pi-agent-core";
3
- import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
2
+ import { Agent, setDefaultStreamFn } from "@earendil-works/pi-agent-core";
3
+ import { clampThinkingLevel, streamSimple } from "@earendil-works/pi-ai/compat";
4
4
  import { getAgentDir } from "../config.js";
5
5
  import { resolvePath } from "../utils/paths.js";
6
6
  import { AgentSession } from "./agent-session.js";
@@ -15,6 +15,10 @@ import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
15
15
  import { SettingsManager } from "./settings-manager.js";
16
16
  import { time } from "./timings.js";
17
17
  import { createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, withFileMutationQueue, } from "./tools/index.js";
18
+ // Preserve the pre-0.81 fallback for extensions that construct Agent instances
19
+ // or invoke low-level agent loops without supplying streamFn. Agent core remains
20
+ // provider-agnostic and does not import pi-ai/compat itself.
21
+ setDefaultStreamFn(streamSimple);
18
22
  // Re-exports
19
23
  export * from "./agent-session-runtime.js";
20
24
  export { withFileMutationQueue,
@@ -1,4 +1,4 @@
1
- import { uuidv7 } from "@earendil-works/pi-agent-core";
1
+ import { uuidv7 } from "@earendil-works/pi-ai";
2
2
  import { randomUUID } from "crypto";
3
3
  import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
4
4
  import { readdir, stat } from "fs/promises";
@@ -253,6 +253,15 @@ export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) {
253
253
  return sessionDir;
254
254
  }
255
255
  const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
256
+ const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
257
+ /** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
258
+ const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
259
+ class SessionHeaderScanLimitError extends Error {
260
+ constructor(filePath) {
261
+ super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
262
+ this.name = "SessionHeaderScanLimitError";
263
+ }
264
+ }
256
265
  function parseSessionEntryLine(line) {
257
266
  if (!line.trim())
258
267
  return null;
@@ -308,22 +317,70 @@ export function loadEntriesFromFile(filePath) {
308
317
  }
309
318
  return entries;
310
319
  }
320
+ /**
321
+ * Inspect a physical line while searching for the first parsed session entry.
322
+ * Blank and malformed lines are skipped to match loadEntriesFromFile().
323
+ * Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
324
+ */
325
+ function parseSessionHeaderCandidate(line) {
326
+ if (!line.trim())
327
+ return undefined;
328
+ const entry = parseSessionEntryLine(line);
329
+ if (!entry)
330
+ return undefined;
331
+ if (entry.type !== "session" || typeof entry.id !== "string")
332
+ return null;
333
+ return entry;
334
+ }
311
335
  function readSessionHeader(filePath) {
336
+ const fd = openSync(filePath, "r");
312
337
  try {
313
- const fd = openSync(filePath, "r");
314
- const buffer = Buffer.alloc(512);
315
- const bytesRead = readSync(fd, buffer, 0, 512, 0);
316
- closeSync(fd);
317
- const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
318
- if (!firstLine)
319
- return null;
320
- const header = JSON.parse(firstLine);
321
- if (header.type !== "session" || typeof header.id !== "string") {
322
- return null;
338
+ const decoder = new StringDecoder("utf8");
339
+ const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
340
+ const lineChunks = [];
341
+ let scannedBytes = 0;
342
+ while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
343
+ const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
344
+ const bytesRead = readSync(fd, buffer, 0, readLength, null);
345
+ if (bytesRead === 0) {
346
+ lineChunks.push(decoder.end());
347
+ return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
348
+ }
349
+ scannedBytes += bytesRead;
350
+ const chunk = decoder.write(buffer.subarray(0, bytesRead));
351
+ let lineStart = 0;
352
+ let newlineIndex = chunk.indexOf("\n", lineStart);
353
+ while (newlineIndex !== -1) {
354
+ lineChunks.push(chunk.slice(lineStart, newlineIndex));
355
+ const header = parseSessionHeaderCandidate(lineChunks.join(""));
356
+ if (header !== undefined)
357
+ return header;
358
+ lineChunks.length = 0;
359
+ lineStart = newlineIndex + 1;
360
+ newlineIndex = chunk.indexOf("\n", lineStart);
361
+ }
362
+ lineChunks.push(chunk.slice(lineStart));
323
363
  }
324
- return header;
364
+ // Probe for EOF so a final header without a newline is allowed when it ends
365
+ // exactly at the scan limit. Any additional byte exceeds the bounded scan.
366
+ const probe = Buffer.allocUnsafe(1);
367
+ if (readSync(fd, probe, 0, probe.length, null) === 0) {
368
+ lineChunks.push(decoder.end());
369
+ return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
370
+ }
371
+ throw new SessionHeaderScanLimitError(filePath);
372
+ }
373
+ finally {
374
+ closeSync(fd);
375
+ }
376
+ }
377
+ function readSessionHeaderForDiscovery(filePath) {
378
+ try {
379
+ return readSessionHeader(filePath);
325
380
  }
326
381
  catch {
382
+ // Discovery is best-effort: unreadable or oversized files are not sessions,
383
+ // and one corrupt file must not prevent other sessions from being found.
327
384
  return null;
328
385
  }
329
386
  }
@@ -342,7 +399,7 @@ export function findMostRecentSession(sessionDir, cwd) {
342
399
  const files = readdirSync(resolvedSessionDir)
343
400
  .filter((f) => f.endsWith(".jsonl"))
344
401
  .map((f) => join(resolvedSessionDir, f))
345
- .map((path) => ({ path, header: readSessionHeader(path) }))
402
+ .map((path) => ({ path, header: readSessionHeaderForDiscovery(path) }))
346
403
  .filter((file) => file.header !== null &&
347
404
  (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)))
348
405
  .map(({ path }) => ({ path, mtime: statSync(path).mtime }))
@@ -350,6 +407,7 @@ export function findMostRecentSession(sessionDir, cwd) {
350
407
  return files[0]?.path || null;
351
408
  }
352
409
  catch {
410
+ // Directory access and stat races make recent-session discovery unavailable.
353
411
  return null;
354
412
  }
355
413
  }
@@ -535,7 +593,7 @@ export class SessionManager {
535
593
  labelsById = new Map();
536
594
  labelTimestampsById = new Map();
537
595
  leafId = null;
538
- constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions) {
596
+ constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
539
597
  this.cwd = resolvePath(cwd);
540
598
  this.sessionDir = normalizePath(sessionDir);
541
599
  this.persist = persist;
@@ -543,7 +601,7 @@ export class SessionManager {
543
601
  mkdirSync(this.sessionDir, { recursive: true });
544
602
  }
545
603
  if (sessionFile) {
546
- this.setSessionFile(sessionFile);
604
+ this._setSessionFile(sessionFile, preloadedFileEntries);
547
605
  }
548
606
  else {
549
607
  this.newSession(newSessionOptions);
@@ -551,15 +609,18 @@ export class SessionManager {
551
609
  }
552
610
  /** Switch to a different session file (used for resume and branching) */
553
611
  setSessionFile(sessionFile) {
612
+ this._setSessionFile(sessionFile);
613
+ }
614
+ _setSessionFile(sessionFile, preloadedFileEntries) {
554
615
  this.sessionFile = resolvePath(sessionFile);
555
616
  if (existsSync(this.sessionFile)) {
556
- this.fileEntries = loadEntriesFromFile(this.sessionFile);
617
+ this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
557
618
  // If file was empty, initialize it with a valid session header. If it was
558
619
  // non-empty but did not parse as a pi session, fail without modifying it.
559
620
  if (this.fileEntries.length === 0) {
560
621
  const explicitPath = this.sessionFile;
561
622
  if (statSync(explicitPath).size > 0) {
562
- throw new Error(`Session file is not a valid sling session: ${explicitPath}`);
623
+ throw new Error(`Session file is not a valid pi session: ${explicitPath}`);
563
624
  }
564
625
  this.newSession();
565
626
  this.sessionFile = explicitPath;
@@ -739,7 +800,7 @@ export class SessionManager {
739
800
  return entry.id;
740
801
  }
741
802
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
742
- appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) {
803
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
743
804
  const entry = {
744
805
  type: "compaction",
745
806
  id: generateId(this.byId),
@@ -749,6 +810,7 @@ export class SessionManager {
749
810
  firstKeptEntryId,
750
811
  tokensBefore,
751
812
  details,
813
+ usage,
752
814
  fromHook,
753
815
  };
754
816
  this._appendEntry(entry);
@@ -988,7 +1050,7 @@ export class SessionManager {
988
1050
  * Same as branch(), but also appends a branch_summary entry that captures
989
1051
  * context from the abandoned conversation path.
990
1052
  */
991
- branchWithSummary(branchFromId, summary, details, fromHook) {
1053
+ branchWithSummary(branchFromId, summary, details, fromHook, usage) {
992
1054
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
993
1055
  throw new Error(`Entry ${branchFromId} not found`);
994
1056
  }
@@ -1001,6 +1063,7 @@ export class SessionManager {
1001
1063
  fromId: branchFromId ?? "root",
1002
1064
  summary,
1003
1065
  details,
1066
+ usage,
1004
1067
  fromHook,
1005
1068
  };
1006
1069
  this._appendEntry(entry);
@@ -1122,13 +1185,26 @@ export class SessionManager {
1122
1185
  */
1123
1186
  static open(path, sessionDir, cwdOverride) {
1124
1187
  const resolvedPath = resolvePath(path);
1125
- // Extract cwd from session header if possible, otherwise use process.cwd()
1126
- const entries = loadEntriesFromFile(resolvedPath);
1127
- const header = entries.find((e) => e.type === "session");
1128
- const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
1188
+ let header = null;
1189
+ let preloadedFileEntries;
1190
+ if (cwdOverride === undefined && existsSync(resolvedPath)) {
1191
+ try {
1192
+ header = readSessionHeader(resolvedPath);
1193
+ }
1194
+ catch (error) {
1195
+ if (!(error instanceof SessionHeaderScanLimitError))
1196
+ throw error;
1197
+ // The bounded scan is only a discovery optimization. A full load remains
1198
+ // authoritative for legacy files with very large headers or prefixes.
1199
+ preloadedFileEntries = loadEntriesFromFile(resolvedPath);
1200
+ const firstEntry = preloadedFileEntries[0];
1201
+ header = firstEntry?.type === "session" ? firstEntry : null;
1202
+ }
1203
+ }
1204
+ const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd();
1129
1205
  // If no sessionDir provided, derive from file's parent directory
1130
1206
  const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
1131
- return new SessionManager(cwd, dir, resolvedPath, true);
1207
+ return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries);
1132
1208
  }
1133
1209
  /**
1134
1210
  * Continue the most recent session, or create new if none.
@@ -0,0 +1,52 @@
1
+ export function createUsageTotals() {
2
+ return {
3
+ input: 0,
4
+ output: 0,
5
+ cacheRead: 0,
6
+ cacheWrite: 0,
7
+ cost: 0,
8
+ };
9
+ }
10
+ export function addUsageToTotals(totals, usage) {
11
+ totals.input += usage.input;
12
+ totals.output += usage.output;
13
+ totals.cacheRead += usage.cacheRead;
14
+ totals.cacheWrite += usage.cacheWrite;
15
+ totals.cost += usage.cost.total;
16
+ }
17
+ /** Group attributable assistant usage by model and all other usage into a separate bucket. */
18
+ export function getUsageCostBreakdown(entries) {
19
+ const totalsByKey = new Map();
20
+ for (const entry of entries) {
21
+ let key;
22
+ let usage;
23
+ if (entry.type === "message" && entry.message.role === "assistant") {
24
+ key = `${entry.message.provider}/${entry.message.responseModel ?? entry.message.model}`;
25
+ usage = entry.message.usage;
26
+ }
27
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
28
+ key = "Tools/summaries";
29
+ usage = entry.message.usage;
30
+ }
31
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
32
+ key = "Tools/summaries";
33
+ usage = entry.usage;
34
+ }
35
+ if (!key || !usage)
36
+ continue;
37
+ let totals = totalsByKey.get(key);
38
+ if (!totals) {
39
+ totals = createUsageTotals();
40
+ totalsByKey.set(key, totals);
41
+ }
42
+ addUsageToTotals(totals, usage);
43
+ }
44
+ return Array.from(totalsByKey, ([key, totals]) => ({
45
+ key,
46
+ cost: totals.cost,
47
+ tokens: totals.input + totals.output + totals.cacheRead + totals.cacheWrite,
48
+ }))
49
+ .filter((entry) => entry.cost > 0 || entry.tokens > 0)
50
+ .sort((a, b) => b.cost - a.cost);
51
+ }
52
+ //# sourceMappingURL=usage-totals.js.map
@@ -67,9 +67,11 @@ function parseLoadProgress(data) {
67
67
  function parseDownloadProgress(data) {
68
68
  if (typeof data !== "object" || data === null)
69
69
  return undefined;
70
+ const nested = data.progress;
71
+ const files = typeof nested === "object" && nested !== null ? nested : data;
70
72
  let done = 0;
71
73
  let total = 0;
72
- for (const value of Object.values(data)) {
74
+ for (const value of Object.values(files)) {
73
75
  if (typeof value !== "object" || value === null)
74
76
  continue;
75
77
  const entry = value;
@@ -70,7 +70,7 @@ export default function llamaExtension(pi) {
70
70
  model: target.id,
71
71
  initialMessage: "Starting…",
72
72
  cancelTitle: "Stop loading?",
73
- cancelMessage: `Stop loading ${target.id}?`,
73
+ cancelMessage: target.id,
74
74
  run: (signal, update) => client.loadAndWait(target.id, update, signal),
75
75
  cancel: () => client.unload(target.id),
76
76
  });
@@ -96,7 +96,7 @@ export default function llamaExtension(pi) {
96
96
  }
97
97
  };
98
98
  const unloadModel = async (ctx, ui, client, model) => {
99
- if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`)))
99
+ if (!(await ui.confirm("Unload model?", model.id)))
100
100
  return;
101
101
  await client.unloadAndWait(model.id);
102
102
  await syncCatalog(ctx, client);
@@ -140,7 +140,7 @@ export default function llamaExtension(pi) {
140
140
  model,
141
141
  initialMessage: "Starting…",
142
142
  cancelTitle: "Stop download?",
143
- cancelMessage: `Stop downloading ${model}?`,
143
+ cancelMessage: model,
144
144
  run: (signal, update) => client.downloadAndWait(model, update, signal),
145
145
  cancel: () => client.unload(model),
146
146
  });
@@ -5,7 +5,7 @@ export { CONFIG_DIR_NAME, getAgentDir, getDocsPath, getExamplesPath, getPackageD
5
5
  export { AgentSession, parseSkillBlock, } from "./core/agent-session.js";
6
6
  export { readStoredCredential } from "./core/auth-storage.js";
7
7
  // Compaction
8
- export { calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, findTurnStartIndex, generateBranchSummary, generateSummary, getLastAssistantUsage, prepareBranchEntries, serializeConversation, shouldCompact, } from "./core/compaction/index.js";
8
+ export { calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, findTurnStartIndex, generateBranchSummary, generateSummary, generateSummaryWithUsage, getLastAssistantUsage, prepareBranchEntries, serializeConversation, shouldCompact, } from "./core/compaction/index.js";
9
9
  export { createEventBus } from "./core/event-bus.js";
10
10
  export { createExtensionRuntime, defineTool, discoverAndLoadExtensions, ExtensionRunner, isBashToolResult, isEditToolResult, isFindToolResult, isGrepToolResult, isLsToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, wrapRegisteredTool, wrapRegisteredTools, } from "./core/extensions/index.js";
11
11
  export { convertToLlm } from "./core/messages.js";
@@ -563,7 +563,7 @@ export async function main(args, options) {
563
563
  });
564
564
  }
565
565
  else {
566
- await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
566
+ await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey, { allowNetwork: false });
567
567
  await services.modelRuntime.getAvailable();
568
568
  }
569
569
  }
@@ -647,6 +647,10 @@ export async function main(args, options) {
647
647
  console.error(chalk.red("Error: PI_STARTUP_BENCHMARK only supports interactive mode"));
648
648
  process.exit(1);
649
649
  }
650
+ // RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization.
651
+ if (!offlineMode && appMode === "rpc") {
652
+ void modelRuntime.refresh().catch(() => { });
653
+ }
650
654
  if (appMode === "rpc") {
651
655
  printTimings();
652
656
  await runRpcMode(runtime);
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
4
+ import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js";
4
5
  import { theme } from "../theme/theme.js";
5
6
  /**
6
7
  * Sanitize text for display in a single-line status.
@@ -74,23 +75,21 @@ export class FooterComponent {
74
75
  render(width) {
75
76
  const state = this.session.state;
76
77
  // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
77
- let totalInput = 0;
78
- let totalOutput = 0;
79
- let totalCacheRead = 0;
80
- let totalCacheWrite = 0;
81
- let totalCost = 0;
78
+ const usageTotals = createUsageTotals();
82
79
  let latestCacheHitRate;
83
80
  for (const entry of this.session.sessionManager.getEntries()) {
84
81
  if (entry.type === "message" && entry.message.role === "assistant") {
85
- totalInput += entry.message.usage.input;
86
- totalOutput += entry.message.usage.output;
87
- totalCacheRead += entry.message.usage.cacheRead;
88
- totalCacheWrite += entry.message.usage.cacheWrite;
89
- totalCost += entry.message.usage.cost.total;
82
+ addUsageToTotals(usageTotals, entry.message.usage);
90
83
  const latestPromptTokens = entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
91
84
  latestCacheHitRate =
92
85
  latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
93
86
  }
87
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
88
+ addUsageToTotals(usageTotals, entry.message.usage);
89
+ }
90
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
91
+ addUsageToTotals(usageTotals, entry.usage);
92
+ }
94
93
  }
95
94
  // Calculate context usage from session (handles compaction correctly).
96
95
  // After compaction, tokens are unknown until the next LLM response.
@@ -112,23 +111,23 @@ export class FooterComponent {
112
111
  }
113
112
  // Build stats line
114
113
  const statsParts = [];
115
- if (totalInput)
116
- statsParts.push(`↑${formatTokens(totalInput)}`);
117
- if (totalOutput)
118
- statsParts.push(`↓${formatTokens(totalOutput)}`);
119
- if (totalCacheRead)
120
- statsParts.push(`R${formatTokens(totalCacheRead)}`);
121
- if (totalCacheWrite)
122
- statsParts.push(`W${formatTokens(totalCacheWrite)}`);
123
- if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) {
114
+ if (usageTotals.input)
115
+ statsParts.push(`↑${formatTokens(usageTotals.input)}`);
116
+ if (usageTotals.output)
117
+ statsParts.push(`↓${formatTokens(usageTotals.output)}`);
118
+ if (usageTotals.cacheRead)
119
+ statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`);
120
+ if (usageTotals.cacheWrite)
121
+ statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`);
122
+ if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
124
123
  statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
125
124
  }
126
125
  // Kimi Coding is subscription-backed despite using API-key authentication.
127
126
  const usingSubscription = state.model
128
127
  ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingOAuth(state.model.provider)
129
128
  : false;
130
- if (totalCost || usingSubscription) {
131
- const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
129
+ if (usageTotals.cost || usingSubscription) {
130
+ const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
132
131
  statsParts.push(costStr);
133
132
  }
134
133
  // Colorize context percentage based on usage