@selesai/code 0.13.38 → 0.13.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.39] - 2026-10-02
6
+
7
+ ### Changed
8
+ - **`/tokenin usage` shows the account balance.** It queries `/tokenin/account/me/balance` and shows available credit (plan, PAYG, debt), active plan credit used, and plan expiry; a 404 falls back to the LiteLLM `/key/info` quota view.
9
+ - **The `researcher` subagent uses `web_explore`** from the bundled `pi-web-agent` extension instead of `pi-web-access` tools; `pi-web-agent` registers a builtin-agent augmentation so foreground `researcher` children load it.
10
+ - **Graft CLI floor raised to 0.21** (`MIN_GRAFT_VERSION` 0.21.0, install spec `@nanonets/graft@^0.21`).
11
+
5
12
  ## [0.13.38] - 2026-10-01
6
13
 
7
14
  ### Changed
@@ -68,8 +68,8 @@ describe("installGraft", () => {
68
68
  it("runs the compatible global npm install without a shell off Windows", async () => {
69
69
  const { pi } = makePi(async () => execReturning(OK));
70
70
  const run = await installGraft(pi.exec as never, "/repo");
71
- expect(GRAFT_INSTALL_SPEC).toBe("@nanonets/graft@^0.18");
72
- expect(INSTALL_COMMAND).toBe("npm install -g @nanonets/graft@^0.18");
71
+ expect(GRAFT_INSTALL_SPEC).toBe("@nanonets/graft@^0.21");
72
+ expect(INSTALL_COMMAND).toBe("npm install -g @nanonets/graft@^0.21");
73
73
  const [command, args, options] = pi.exec.mock.calls[0] as [string, string[], { cwd: string; timeout: number }];
74
74
  expect(command).toBe(process.platform === "win32" ? "cmd" : "npm");
75
75
  expect(args).toContain("install");
@@ -118,7 +118,7 @@ describe("version handling", () => {
118
118
 
119
119
  it("accepts the verified range and rejects the boundaries", () => {
120
120
  expect(isCompatibleVersion(MIN_GRAFT_VERSION)).toBe(true);
121
- expect(isCompatibleVersion("0.18.4")).toBe(true);
121
+ expect(isCompatibleVersion("0.21.1")).toBe(true);
122
122
  expect(isCompatibleVersion("0.99.0")).toBe(true);
123
123
  expect(isCompatibleVersion("0.17.9")).toBe(false);
124
124
  expect(isCompatibleVersion(MAX_GRAFT_VERSION_EXCLUSIVE)).toBe(false);
@@ -128,9 +128,9 @@ describe("version handling", () => {
128
128
 
129
129
  describe("probeGraft", () => {
130
130
  it("accepts a supported version and reports it", async () => {
131
- const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.18.0\n" }));
131
+ const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.21.1\n" }));
132
132
  const probe = await probeGraft(pi.exec as never, "/repo");
133
- expect(probe).toMatchObject({ kind: "ok", version: "0.18.0" });
133
+ expect(probe).toMatchObject({ kind: "ok", version: "0.21.1" });
134
134
  if (probe.kind === "ok") expect(probe.argv.at(-1)).toBe("--version");
135
135
  });
136
136
 
@@ -15,7 +15,7 @@
15
15
  * Selesai model for deep enrichment, otherwise stay structural and local.
16
16
  * - Extension-spawned commands default to telemetry opt-out (`DO_NOT_TRACK=1`).
17
17
  *
18
- * Verified against @nanonets/graft 0.18.0 (see {@link MIN_GRAFT_VERSION}).
18
+ * Verified against @nanonets/graft 0.21.1 (see {@link MIN_GRAFT_VERSION}).
19
19
  */
20
20
 
21
21
  import { truncateHead, type ExecOptions, type ExecResult } from "@selesai/code";
@@ -27,8 +27,13 @@ export const GRAFT_PACKAGE = "@nanonets/graft";
27
27
  * Lowest Graft CLI whose documented surface this adapter was verified against:
28
28
  * `ask --source/--json`, `skeleton`, `callers --direction/-d`, `grep --in/-i/--fixed`,
29
29
  * `map --max-dirs`, `check --json`, `build [--deep]`, and `--version`.
30
+ *
31
+ * The floor sits on the 0.21 minor line this adapter is verified against
32
+ * (0.21.1). Upgrades are deliberate: an executable below the floor reports
33
+ * `incompatible`, and the adapter never overwrites it — the user upgrades with
34
+ * {@link INSTALL_COMMAND}.
30
35
  */
31
- export const MIN_GRAFT_VERSION = "0.18.0";
36
+ export const MIN_GRAFT_VERSION = "0.21.0";
32
37
  /** Upper bound is exclusive: a 1.x CLI is allowed to change its contract. */
33
38
  export const MAX_GRAFT_VERSION_EXCLUSIVE = "1.0.0";
34
39
 
@@ -45,8 +50,8 @@ export const DEEP_BUILD_TIMEOUT_MS = 1_800_000;
45
50
  /** Global npm installs can be slow on a cold cache. */
46
51
  export const INSTALL_TIMEOUT_MS = 180_000;
47
52
 
48
- /** Kept on the verified 0.18 minor line; never install a floating latest CLI. */
49
- export const GRAFT_INSTALL_SPEC = `${GRAFT_PACKAGE}@^0.18`;
53
+ /** Kept on the verified 0.21 minor line; never install a floating latest CLI. */
54
+ export const GRAFT_INSTALL_SPEC = `${GRAFT_PACKAGE}@^0.21`;
50
55
  export const INSTALL_COMMAND = `npm install -g ${GRAFT_INSTALL_SPEC}`;
51
56
  export const TELEMETRY_ENV = "DO_NOT_TRACK";
52
57
 
@@ -399,7 +404,7 @@ export interface AskHit {
399
404
  }
400
405
 
401
406
  /**
402
- * `graft ask --json` result, as emitted by `AskResult` upstream (0.18.0).
407
+ * `graft ask --json` result, as emitted by `AskResult` upstream (0.21.1).
403
408
  *
404
409
  * Every field is optional on read: a CLI that changes shape degrades to raw
405
410
  * text rather than to a wrong summary, and callers must never treat the parse
@@ -15,11 +15,11 @@ import {
15
15
  import { MODE_ENTRY_TYPE, type GraftState } from "./state.ts";
16
16
  import { notified, makeCtx, makePi, type CtxStub } from "./test-support.ts";
17
17
 
18
- const HEALTHY: GraftState = { name: "fresh-structural", version: "0.18.0" };
18
+ const HEALTHY: GraftState = { name: "fresh-structural", version: "0.21.1" };
19
19
 
20
20
  function installOf(overrides: Partial<GraftInstallRun> = {}): GraftInstallRun {
21
21
  return {
22
- argv: ["npm", "install", "-g", "@nanonets/graft@^0.18"],
22
+ argv: ["npm", "install", "-g", "@nanonets/graft@^0.21"],
23
23
  cwd: "/repo",
24
24
  code: 0,
25
25
  stdout: "",
@@ -75,7 +75,7 @@ function makeRuntime(
75
75
  const recheck = vi.fn(async () => {});
76
76
  const installCli = vi.fn(async () => {
77
77
  const result = overrides.installResult ?? installOf();
78
- if (result.code === 0 && !result.killed) state = overrides.postInstallState ?? { name: "unbuilt", version: "0.18.0" };
78
+ if (result.code === 0 && !result.killed) state = overrides.postInstallState ?? { name: "unbuilt", version: "0.21.1" };
79
79
  return result;
80
80
  });
81
81
  const runBuild = vi.fn(async () => overrides.buildResult ?? runOf());
@@ -134,7 +134,7 @@ describe("formatDoctor", () => {
134
134
  it("reports repository, executable, graph, state, mode, telemetry and provider boundary", () => {
135
135
  const text = formatDoctor(base);
136
136
  expect(text).toContain("/repo (git ✓)");
137
- expect(text).toContain("graft 0.18.0");
137
+ expect(text).toContain("graft 0.21.1");
138
138
  expect(text).toContain("structural only");
139
139
  expect(text).toContain("fresh-structural");
140
140
  expect(text).toContain("nothing is injected");
@@ -188,7 +188,7 @@ describe("formatStatus", () => {
188
188
  providerEnv: [],
189
189
  });
190
190
  expect(text.split("\n")).toHaveLength(4);
191
- expect(text).toContain("graft: ● structural v0.18.0");
191
+ expect(text).toContain("graft: ● structural v0.21.1");
192
192
  expect(text).toContain("hybrid");
193
193
  expect(text).toContain("root: /repo");
194
194
  });
@@ -6,7 +6,7 @@
6
6
  * adapter against the real executable, and it skips itself when no compatible
7
7
  * Graft CLI is on PATH — which is the state of ordinary CI.
8
8
  *
9
- * To run it for real: `npm install -g @nanonets/graft@^0.18` then
9
+ * To run it for real: `npm install -g @nanonets/graft@^0.21` then
10
10
  * npx vitest run src/extensions/pi-graft/graft.integration.test.ts
11
11
  *
12
12
  * A structural build is tree-sitter only: no model, no API key, no network. The
@@ -39,7 +39,7 @@ function writeProjectSettings(value: unknown): void {
39
39
  function exec(graftOutput = ASK_JSON) {
40
40
  return async (command: string, args: string[]) => {
41
41
  if (command === "git" && args.includes("--show-toplevel")) return { ...OK, stdout: `${repo}\n` };
42
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
42
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
43
43
  return { ...OK, stdout: graftOutput };
44
44
  };
45
45
  }
@@ -88,7 +88,7 @@ describe("session start", () => {
88
88
  it("resolves the Git working tree, probes the executable, and publishes a status line", async () => {
89
89
  const session = await bootSession();
90
90
  expect(session.commandsRun()).toEqual(["git rev-parse --show-toplevel", "graft --version"]);
91
- expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0");
91
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1");
92
92
  await session.reset();
93
93
  });
94
94
 
@@ -163,7 +163,7 @@ describe("session start", () => {
163
163
  }
164
164
  if (args.includes("--version")) {
165
165
  if (!installed) throw new Error("spawn graft ENOENT");
166
- return { ...OK, stdout: "0.18.0\n" };
166
+ return { ...OK, stdout: "0.21.1\n" };
167
167
  }
168
168
  return { ...OK };
169
169
  });
@@ -171,8 +171,8 @@ describe("session start", () => {
171
171
  const ctx = makeCtx({ cwd: repo });
172
172
  const start = handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>;
173
173
  await start({ type: "session_start", reason: "startup" }, ctx);
174
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
175
- expect(harness.commandsRun()).toContain("npm install -g @nanonets/graft@^0.18");
174
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1"));
175
+ expect(harness.commandsRun()).toContain("npm install -g @nanonets/graft@^0.21");
176
176
 
177
177
  await start({ type: "session_start", reason: "reload" }, ctx);
178
178
  expect(harness.commandsRun().filter((command) => command.startsWith("npm install"))).toHaveLength(1);
@@ -202,7 +202,7 @@ describe("session start", () => {
202
202
  writeProjectSettings({});
203
203
  const harness = makePi(async (command: string) => {
204
204
  if (command === "git") return { code: 128, stdout: "", stderr: "not a git repository", killed: false };
205
- return { ...OK, stdout: "0.18.0\n" };
205
+ return { ...OK, stdout: "0.21.1\n" };
206
206
  });
207
207
  graftExtension(harness.pi as never);
208
208
  const ctx = makeCtx({ cwd: repo });
@@ -233,14 +233,14 @@ describe("mutation awareness", () => {
233
233
  it("marks the graph changed after a successful edit", async () => {
234
234
  const session = await bootSession();
235
235
  await onToolResult(session, "edit", { path: "src/a.ts" });
236
- expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.18.0");
236
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.21.1");
237
237
  await session.reset();
238
238
  });
239
239
 
240
240
  it("marks the graph changed after a clearly mutating bash command", async () => {
241
241
  const session = await bootSession();
242
242
  await onToolResult(session, "bash", { command: "rm -rf dist" });
243
- expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.18.0");
243
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.21.1");
244
244
  await session.reset();
245
245
  });
246
246
 
@@ -249,7 +249,7 @@ describe("mutation awareness", () => {
249
249
  await onToolResult(session, "read", { path: "src/a.ts" });
250
250
  await onToolResult(session, "bash", { command: "git status" });
251
251
  await onToolResult(session, "edit", { path: "src/a.ts" }, true);
252
- expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0");
252
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1");
253
253
  await session.reset();
254
254
  });
255
255
  });
@@ -270,7 +270,7 @@ describe("proactive refresh", () => {
270
270
  writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
271
271
  const harness = makePi(async (command: string, args: string[]) => {
272
272
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
273
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
273
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
274
274
  if (args.includes("build")) {
275
275
  mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
276
276
  writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
@@ -283,7 +283,7 @@ describe("proactive refresh", () => {
283
283
  { type: "session_start", reason: "startup" },
284
284
  ctx,
285
285
  );
286
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
286
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1"));
287
287
  expect(harness.commandsRun()).toContain("graft build");
288
288
  });
289
289
 
@@ -294,7 +294,7 @@ describe("proactive refresh", () => {
294
294
  writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
295
295
  const harness = makePi(async (command: string, args: string[]) => {
296
296
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
297
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
297
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
298
298
  if (args.includes("build")) {
299
299
  mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
300
300
  writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
@@ -314,7 +314,7 @@ describe("proactive refresh", () => {
314
314
  { type: "session_start", reason: "startup" },
315
315
  ctx,
316
316
  );
317
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
317
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.21.1"));
318
318
  const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
319
319
  expect(build[1]).toEqual(["build", "--deep"]);
320
320
  expect(build[2]).toMatchObject({
@@ -335,7 +335,7 @@ describe("proactive refresh", () => {
335
335
  const models: string[] = [];
336
336
  const harness = makePi(async (command: string, args: string[], options?: unknown) => {
337
337
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
338
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
338
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
339
339
  if (args.includes("build")) {
340
340
  const model = (options as { env?: Record<string, string> }).env?.GRAFT_MODEL ?? "";
341
341
  models.push(model);
@@ -359,7 +359,7 @@ describe("proactive refresh", () => {
359
359
  { type: "session_start", reason: "startup" },
360
360
  ctx,
361
361
  );
362
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
362
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.21.1"));
363
363
  expect(models).toEqual(["deepseek-v4.1-flash", "glm-5.3-flash"]);
364
364
  expect(notified(ctx)).toContain("retrying the deep pass");
365
365
  });
@@ -371,7 +371,7 @@ describe("proactive refresh", () => {
371
371
  writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
372
372
  const harness = makePi(async (command: string, args: string[]) => {
373
373
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
374
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
374
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
375
375
  if (args.includes("build")) {
376
376
  mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
377
377
  writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
@@ -388,7 +388,7 @@ describe("proactive refresh", () => {
388
388
  { type: "session_start", reason: "startup" },
389
389
  ctx,
390
390
  );
391
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
391
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1"));
392
392
  const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
393
393
  expect(build[1]).toEqual(["build"]);
394
394
  });
@@ -403,11 +403,11 @@ describe("proactive refresh", () => {
403
403
  { type: "tool_result", toolName: "write", toolCallId: "c1", input: {}, content: [], isError: false },
404
404
  session.ctx,
405
405
  );
406
- expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.18.0");
406
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ◐ changed v0.21.1");
407
407
 
408
408
  await (handlerFor(session, "agent_end") as (e: unknown, c: unknown) => Promise<void>)({}, session.ctx);
409
- expect(session.ctx.statuses.get("graft")).toBe("graft: ⚙ syncing v0.18.0");
410
- await vi.waitFor(() => expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
409
+ expect(session.ctx.statuses.get("graft")).toBe("graft: ⚙ syncing v0.21.1");
410
+ await vi.waitFor(() => expect(session.ctx.statuses.get("graft")).toBe("graft: ● structural v0.21.1"));
411
411
  expect(session.commandsRun()).toContain("graft build");
412
412
  await session.reset();
413
413
  });
@@ -434,7 +434,7 @@ describe("proactive refresh", () => {
434
434
  writeProjectSettings({ refreshDebounceSeconds: 0 });
435
435
  const harness = makePi(async (command: string, args: string[]) => {
436
436
  if (command === "git") return { ...OK, stdout: `${repo}\n` };
437
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
437
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
438
438
  if (args.includes("build")) return { code: 1, stdout: "", stderr: "✗ index failed", killed: false };
439
439
  return { ...OK, stdout: ASK_JSON };
440
440
  });
@@ -449,7 +449,7 @@ describe("proactive refresh", () => {
449
449
  ctx,
450
450
  );
451
451
  await (handlerFor(harness, "agent_end") as (e: unknown, c: unknown) => Promise<void>)({}, ctx);
452
- await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ✕ failed v0.18.0"));
452
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ✕ failed v0.21.1"));
453
453
  });
454
454
  });
455
455
 
@@ -541,7 +541,7 @@ describe("retrieval strategy", () => {
541
541
  writeProjectSettings({ mode: "push" });
542
542
  const harness = makePi(async (command: string, args: string[]) => {
543
543
  if (command === "git") return { ...OK, stdout: `${repo}\n` };
544
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
544
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
545
545
  return { code: 1, stdout: "", stderr: "✗ graph missing", killed: false };
546
546
  });
547
547
  graftExtension(harness.pi as never);
@@ -565,7 +565,7 @@ describe("retrieval strategy", () => {
565
565
  writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: { mode: "push" } }), "utf-8");
566
566
  const harness = makePi(async (command: string, args: string[]) => {
567
567
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
568
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
568
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
569
569
  if (args.includes("build")) {
570
570
  mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
571
571
  writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
@@ -642,7 +642,7 @@ describe("tool readiness", () => {
642
642
  writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
643
643
  const harness = makePi(async (command: string, args: string[]) => {
644
644
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
645
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
645
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
646
646
  if (args.includes("build")) {
647
647
  mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
648
648
  writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
@@ -671,7 +671,7 @@ describe("tool readiness", () => {
671
671
  gitCalls += 1;
672
672
  return { code: 128, stdout: "", stderr: "not a git repository", killed: false };
673
673
  }
674
- if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
674
+ if (args.includes("--version")) return { ...OK, stdout: "0.21.1\n" };
675
675
  return { ...OK, stdout: ASK_JSON };
676
676
  });
677
677
  graftExtension(harness.pi as never);
@@ -13,7 +13,7 @@ import {
13
13
  } from "./prompt.ts";
14
14
  import { DEFAULT_MIN_COVERAGE, type GraftState } from "./state.ts";
15
15
 
16
- const FRESH: GraftState = { name: "fresh-structural", version: "0.18.0" };
16
+ const FRESH: GraftState = { name: "fresh-structural", version: "0.21.1" };
17
17
 
18
18
  function payload(overrides: Partial<AskPayload> = {}): AskPayload {
19
19
  return {
@@ -22,8 +22,8 @@ import {
22
22
  } from "./state.ts";
23
23
 
24
24
  const UNBUILT: GraftState = { name: "unbuilt", detail: "no graph yet" };
25
- const FRESH: GraftState = { name: "fresh-structural", version: "0.18.0", deep: false };
26
- const DEEP: GraftState = { name: "fresh-deep", version: "0.18.0", deep: true };
25
+ const FRESH: GraftState = { name: "fresh-structural", version: "0.21.1", deep: false };
26
+ const DEEP: GraftState = { name: "fresh-deep", version: "0.21.1", deep: true };
27
27
 
28
28
  describe("reduceGraftState", () => {
29
29
  it("only claims a repository once root resolution says there is a Git working tree", () => {
@@ -36,16 +36,16 @@ describe("reduceGraftState", () => {
36
36
  });
37
37
 
38
38
  it("derives built/fresh/deep from what is actually on disk", () => {
39
- expect(reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: ABSENT_GRAPH })).toEqual({
39
+ expect(reduceGraftState(gitState(), { type: "probe-ok", version: "0.21.1", graph: ABSENT_GRAPH })).toEqual({
40
40
  name: "unbuilt",
41
- version: "0.18.0",
41
+ version: "0.21.1",
42
42
  detail: expect.stringContaining("/graft build"),
43
43
  });
44
44
  expect(
45
- reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: { built: true, deep: false } }),
45
+ reduceGraftState(gitState(), { type: "probe-ok", version: "0.21.1", graph: { built: true, deep: false } }),
46
46
  ).toEqual(FRESH);
47
47
  expect(
48
- reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: { built: true, deep: true } }),
48
+ reduceGraftState(gitState(), { type: "probe-ok", version: "0.21.1", graph: { built: true, deep: true } }),
49
49
  ).toMatchObject({ name: "fresh-deep", deep: true });
50
50
  });
51
51
 
@@ -91,7 +91,7 @@ describe("reduceGraftState", () => {
91
91
  expect(reduceGraftState(FRESH, { type: "build-started", deep: true })).toMatchObject({
92
92
  name: "building",
93
93
  deep: true,
94
- version: "0.18.0",
94
+ version: "0.21.1",
95
95
  });
96
96
  });
97
97
 
@@ -140,8 +140,8 @@ describe("state predicates and rendering", () => {
140
140
  });
141
141
 
142
142
  it("renders a compact status line with the version when it is known", () => {
143
- expect(statusText(FRESH)).toBe("graft: ● structural v0.18.0");
144
- expect(statusText(DEEP)).toBe("graft: ● deep v0.18.0");
143
+ expect(statusText(FRESH)).toBe("graft: ● structural v0.21.1");
144
+ expect(statusText(DEEP)).toBe("graft: ● deep v0.21.1");
145
145
  expect(statusText({ name: "unavailable" })).toBe("graft: ✕ no cli");
146
146
  expect(statusText({ name: "changed" })).toBe("graft: ◐ changed");
147
147
  });
@@ -190,7 +190,7 @@ export function makeCtx(
190
190
  */
191
191
  export function healthyExec(overrides: { gitRoot?: string; version?: string; queryOutput?: string } = {}): ExecImpl {
192
192
  const gitRoot = overrides.gitRoot ?? "/repo";
193
- const version = overrides.version ?? "0.18.0";
193
+ const version = overrides.version ?? "0.21.1";
194
194
  return async (command, args) => {
195
195
  if (command === "git" && args.includes("--show-toplevel")) return { ...OK, stdout: `${gitRoot}\n` };
196
196
  if (args.includes("--version")) return { ...OK, stdout: `${version}\n` };
@@ -64,7 +64,7 @@ function makeHarness(
64
64
  const runtime: GraftToolRuntime = {
65
65
  run: run as never,
66
66
  settings: () => options.settings ?? {},
67
- state: () => options.state ?? { name: "fresh-structural", version: "0.18.0" },
67
+ state: () => options.state ?? { name: "fresh-structural", version: "0.21.1" },
68
68
  noteQuery: ((_ctx: unknown, result: GraftRun) => {
69
69
  noted.push(result);
70
70
  }) as never,
@@ -791,9 +791,141 @@ export function formatTokenInUsage(usage: TokenInUsage): string {
791
791
  ].join("\n");
792
792
  }
793
793
 
794
+ export interface TokenInBalanceBucket {
795
+ kind: string;
796
+ planId?: string;
797
+ amountUsd: number;
798
+ usedUsd: number;
799
+ remainingUsd: number;
800
+ state: "active" | "upcoming" | "expired";
801
+ expiresAt?: string;
802
+ }
803
+
804
+ export interface TokenInBalance {
805
+ availableUsd: number;
806
+ fixedAvailableUsd: number;
807
+ paygAvailableUsd: number;
808
+ debtUsd: number;
809
+ asOf?: string;
810
+ buckets: TokenInBalanceBucket[];
811
+ }
812
+
813
+ const finiteNumber = (value: unknown): number | undefined => {
814
+ const n = typeof value === "number" ? value : Number.NaN;
815
+ return Number.isFinite(n) ? n : undefined;
816
+ };
817
+
818
+ /** Parse a /tokenin/account/me/balance payload. Exported for tests. */
819
+ export function parseTokenInBalance(payload: unknown): TokenInBalance | undefined {
820
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
821
+ const obj = payload as Record<string, unknown>;
822
+ const availableUsd = finiteNumber(obj.available_usd);
823
+ if (availableUsd === undefined || !Array.isArray(obj.buckets)) return undefined;
824
+ const buckets: TokenInBalanceBucket[] = [];
825
+ for (const raw of obj.buckets) {
826
+ if (!raw || typeof raw !== "object") continue;
827
+ const b = raw as Record<string, unknown>;
828
+ const state = b.state;
829
+ if (state !== "active" && state !== "upcoming" && state !== "expired") continue;
830
+ const bucket: TokenInBalanceBucket = {
831
+ kind: typeof b.kind === "string" ? b.kind : "unknown",
832
+ amountUsd: finiteNumber(b.amount_usd) ?? 0,
833
+ usedUsd: finiteNumber(b.used_usd) ?? 0,
834
+ remainingUsd: finiteNumber(b.remaining_usd) ?? 0,
835
+ state,
836
+ };
837
+ if (typeof b.plan_id === "string") bucket.planId = b.plan_id;
838
+ if (typeof b.expires_at === "string") bucket.expiresAt = b.expires_at;
839
+ buckets.push(bucket);
840
+ }
841
+ const balance: TokenInBalance = {
842
+ availableUsd,
843
+ fixedAvailableUsd: finiteNumber(obj.fixed_available_usd) ?? 0,
844
+ paygAvailableUsd: finiteNumber(obj.payg_available_usd) ?? 0,
845
+ debtUsd: finiteNumber(obj.debt_usd) ?? 0,
846
+ buckets,
847
+ };
848
+ if (typeof obj.as_of === "string") balance.asOf = obj.as_of;
849
+ return balance;
850
+ }
851
+
852
+ function formatTimeUntil(iso: string): string | undefined {
853
+ const d = new Date(iso);
854
+ if (Number.isNaN(d.getTime())) return undefined;
855
+ const ms = d.getTime() - Date.now();
856
+ if (ms <= 0) return undefined;
857
+ const days = Math.floor(ms / 86_400_000);
858
+ const hours = Math.floor((ms % 86_400_000) / 3_600_000);
859
+ return days > 0 ? `${days}d ${hours}h` : `${Math.max(1, Math.ceil(ms / 3_600_000))}h`;
860
+ }
861
+
862
+ /** Format an account balance as a boxed display. Exported for tests. */
863
+ export function formatTokenInBalance(balance: TokenInBalance): string {
864
+ const money = (v: number) => `$${v.toFixed(2)}`;
865
+ const active = balance.buckets.filter((b) => b.state === "active");
866
+ const total = active.reduce((sum, b) => sum + b.amountUsd, 0);
867
+ const used = active.reduce((sum, b) => sum + b.usedUsd, 0);
868
+ const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
869
+ const barWidth = 40;
870
+ const filled = total > 0 ? Math.min(barWidth, Math.round((pct / 100) * barWidth)) : 0;
871
+ const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
872
+ const pctText = total > 0 ? `${pct.toFixed(1)}%` : "—";
873
+
874
+ const summary = `Available ${money(balance.availableUsd)}`;
875
+ let split = `Plan ${money(balance.fixedAvailableUsd)} · PAYG ${money(balance.paygAvailableUsd)}`;
876
+ if (balance.debtUsd > 0) split += ` · Debt ${money(balance.debtUsd)}`;
877
+ const usedLine = total > 0 ? `Used ${money(used)} of ${money(total)} active credit` : "No active credit";
878
+
879
+ const expiries = active
880
+ .map((b) => b.expiresAt)
881
+ .filter((v): v is string => typeof v === "string")
882
+ .sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
883
+ const until = expiries.length > 0 ? formatTimeUntil(expiries[0]) : undefined;
884
+ const footer = until ? `Plan credit expires in ${until}` : "";
885
+
886
+ const W = 50;
887
+ const inner = W - 4;
888
+ const pad = (s: string) => s + " ".repeat(Math.max(0, inner - s.length));
889
+ const title = "╭─ Token-In balance ";
890
+ const top = `${title}${"─".repeat(W - title.length - 1)}╮`;
891
+ const bottom = `╰${"─".repeat(W - 2)}╯`;
892
+ const lines = [top, `│ ${pad(summary)} │`, `│ ${pad(split)} │`, `│ ${pad(usedLine)} │`, `│ ${bar} ${pctText.padStart(5)} │`];
893
+ if (footer) lines.push(`│ ${pad(footer)} │`);
894
+ lines.push(bottom);
895
+ return lines.join("\n");
896
+ }
897
+
898
+ function tokenInProxyRoot(baseUrl: string): string {
899
+ return baseUrl.replace(/\/v1\/?$/, "");
900
+ }
901
+
902
+ /**
903
+ * Query the self-scoped account balance with the user's own key. Returns undefined when
904
+ * the gateway has no account balance for this key (404: older gateway, accounts disabled,
905
+ * or a legacy key) or answers with something that is not a balance, so callers can fall
906
+ * back to /key/info. Other errors throw. Exported for tests.
907
+ */
908
+ export async function fetchTokenInBalance(apiKey: string, baseUrl: string): Promise<TokenInBalance | undefined> {
909
+ const response = await fetch(`${tokenInProxyRoot(baseUrl)}/tokenin/account/me/balance`, {
910
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
911
+ signal: AbortSignal.timeout(5_000),
912
+ });
913
+ if (response.status === 404) return undefined;
914
+ if (!response.ok) {
915
+ throw new Error(`Token-In usage endpoint returned HTTP ${response.status}`);
916
+ }
917
+ let payload: unknown;
918
+ try {
919
+ payload = await response.json();
920
+ } catch {
921
+ return undefined;
922
+ }
923
+ return parseTokenInBalance(payload);
924
+ }
925
+
794
926
  /** Query LiteLLM /key/info at the proxy root with the account key. Exported for tests. */
795
927
  export async function fetchTokenInUsage(apiKey: string, baseUrl: string): Promise<TokenInUsage> {
796
- const root = baseUrl.replace(/\/v1\/?$/, "");
928
+ const root = tokenInProxyRoot(baseUrl);
797
929
  const response = await fetch(`${root}/key/info`, {
798
930
  headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
799
931
  signal: AbortSignal.timeout(5_000),
@@ -906,6 +1038,11 @@ async function handleTokenInUsage(ctx: ExtensionCommandContext): Promise<void> {
906
1038
  const account = auth.accounts.find((a) => a.id === activeId);
907
1039
  const baseUrl = account?.baseUrl ?? getTokenInBaseUrl(readModelsJson()) ?? TOKEN_IN_DEFAULT_BASE_URL;
908
1040
  try {
1041
+ const balance = await fetchTokenInBalance(apiKey, baseUrl);
1042
+ if (balance) {
1043
+ ctx.ui.notify(formatTokenInBalance(balance), "info");
1044
+ return;
1045
+ }
909
1046
  const usage = await fetchTokenInUsage(apiKey, baseUrl);
910
1047
  ctx.ui.notify(formatTokenInUsage(usage), "info");
911
1048
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.38",
3
+ "version": "0.13.39",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "engines": {