@selesai/code 0.13.24 → 0.13.26

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,25 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.26] - 2026-09-18
6
+
7
+ ### Added
8
+ - **Goal-preserving automatic handoff.** The automatic handoff now runs with the same goal text as an explicit `/handoff-new`, so the continuation prompt keeps the overarching objective, success criteria, key decisions, reference files, and the next concrete action. `AUTO_HANDOFF_GOAL` is exported from the core handoff module.
9
+
10
+ ### Changed
11
+ - **Failed handoffs retry instead of silently stopping.** A failed or empty handoff generation now surfaces the underlying error (previously reported as "Cancelled") and rejects the command, and the automatic handoff retries on the next settled turn while the session stays above the threshold.
12
+ - **RPC handoff keeps the session name.** `handoff_new` carries the current session name into the replacement session, matching `/handoff-new`.
13
+ - **Default model catalog refreshed.** Added `glm-5.3` (1M context, text+image), `kimi-k3` (512K), and `gemini-3.7-flash`; `celestial-pro` now accepts images; `deepseek-v4.1-flash` uses 64K max output tokens.
14
+ - **Retired default models.** `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `kimi-k2.7-code`, and `glm-5.2` are no longer in the bundled Token-In catalog (`glm-5.3` supersedes `glm-5.2`). User-level `models.json` entries are unaffected.
15
+
16
+ ## [0.13.25] - 2026-09-14
17
+
18
+ ### Added
19
+ - **Graft deep-build model override.** Set `graft.deepModel` to pin a model on the active provider for Graft deep builds.
20
+
21
+ ### Changed
22
+ - **Graft deep-build fallback.** When the active model returns no usable Graft summaries, the deep build retries up to two other models scoped to that provider before reporting the deep-build failure.
23
+
5
24
  ## [0.13.24] - 2026-09-13
6
25
 
7
26
  ### Added
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { Type } from "typebox";
3
3
  import { AgentSession } from "./agent-session.js";
4
+ import { AUTO_HANDOFF_GOAL } from "./handoff.js";
4
5
  import { SettingsManager } from "./settings-manager.js";
5
6
  function createMockSession({ enabled, threshold = 128_000, tokens, mode = "tui", command, customTools, allowedToolNames, }) {
6
7
  const settings = SettingsManager.inMemory({
@@ -78,6 +79,11 @@ describe("AgentSession tools", () => {
78
79
  });
79
80
  });
80
81
  describe("AgentSession auto handoff", () => {
82
+ it("uses a goal that preserves the overall objective and continuation details", () => {
83
+ expect(AUTO_HANDOFF_GOAL).toMatch(/overall objective/i);
84
+ expect(AUTO_HANDOFF_GOAL).toMatch(/next concrete action/i);
85
+ expect(AUTO_HANDOFF_GOAL).toMatch(/reference files/i);
86
+ });
81
87
  it("invokes handoff-new once when enabled and threshold reached in tui mode", async () => {
82
88
  const command = { handler: vi.fn() };
83
89
  const { session, getCommand, createCommandContext, emitError } = createMockSession({
@@ -90,7 +96,7 @@ describe("AgentSession auto handoff", () => {
90
96
  expect(getCommand).toHaveBeenCalledWith("handoff-new");
91
97
  expect(createCommandContext).toHaveBeenCalled();
92
98
  expect(command.handler).toHaveBeenCalledTimes(1);
93
- expect(command.handler).toHaveBeenCalledWith("", expect.anything());
99
+ expect(command.handler).toHaveBeenCalledWith(AUTO_HANDOFF_GOAL, expect.anything());
94
100
  expect(emitError).not.toHaveBeenCalled();
95
101
  });
96
102
  it("dispatches auto handoff via _emitAgentSettled in tui mode", async () => {
@@ -156,13 +162,16 @@ describe("AgentSession auto handoff", () => {
156
162
  await session._checkAutoHandoff();
157
163
  expect(getCommand).toHaveBeenCalledWith("handoff-new");
158
164
  });
159
- it("emits error when handoff-new throws but does not rethrow", async () => {
165
+ it("emits an error and retries on the next check when handoff-new fails", async () => {
160
166
  const command = {
161
167
  handler: vi.fn(() => Promise.reject(new Error("boom"))),
162
168
  };
163
169
  const { session, emitError } = createMockSession({ enabled: true, tokens: 200_000, command });
164
170
  await expect(session._checkAutoHandoff()).resolves.toBeUndefined();
165
- expect(emitError).toHaveBeenCalledWith({
171
+ await expect(session._checkAutoHandoff()).resolves.toBeUndefined();
172
+ expect(command.handler).toHaveBeenCalledTimes(2);
173
+ expect(emitError).toHaveBeenCalledTimes(2);
174
+ expect(emitError).toHaveBeenLastCalledWith({
166
175
  extensionPath: "command:handoff-new",
167
176
  event: "auto-handoff",
168
177
  error: "boom",
@@ -27,6 +27,7 @@ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estima
27
27
  import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.js";
28
28
  import { exportSessionToHtml } from "./export-html/index.js";
29
29
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
30
+ import { AUTO_HANDOFF_GOAL } from "./handoff.js";
30
31
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
31
32
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
32
33
  import { ModelRegistry } from "./model-registry.js";
@@ -375,7 +376,6 @@ export class AgentSession {
375
376
  }
376
377
  if (this._autoHandoffTriggered)
377
378
  return;
378
- this._autoHandoffTriggered = true;
379
379
  // Auto-handoff is TUI-only: it runs at agent_settled, and headless/print
380
380
  // modes should not churn sessions on their own. The handoff-new command
381
381
  // itself now also works in non-TUI modes (direct generation, no loader).
@@ -384,11 +384,14 @@ export class AgentSession {
384
384
  const command = this._extensionRunner.getCommand("handoff-new");
385
385
  if (!command)
386
386
  return;
387
+ this._autoHandoffTriggered = true;
387
388
  const ctx = this._extensionRunner.createCommandContext();
388
389
  try {
389
- await command.handler("", ctx);
390
+ await command.handler(AUTO_HANDOFF_GOAL, ctx);
390
391
  }
391
392
  catch (err) {
393
+ // Retry on the next settled turn while the session remains above the threshold.
394
+ this._autoHandoffTriggered = false;
392
395
  this._extensionRunner.emitError({
393
396
  extensionPath: "command:handoff-new",
394
397
  event: "auto-handoff",
@@ -3,6 +3,7 @@ import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
3
3
  import type { Model } from "@earendil-works/pi-ai";
4
4
  import type { SessionEntry } from "./session-manager.ts";
5
5
  export declare const DEFAULT_HANDOFF_GOAL = "Continue the previous session from this handoff.";
6
+ export declare const AUTO_HANDOFF_GOAL = "Continue the previous session without losing its direction. Preserve the big picture: the overall objective, why the work matters, the intended outcome and success criteria, the current approach and key decisions, relevant constraints, exact reference files or artifacts, completed and remaining work, known failures or test results, and the next concrete action. Make clear how the immediate next step contributes to the overall goal.";
6
7
  export declare function entryToMessage(entry: SessionEntry): AgentMessage | undefined;
7
8
  export declare function getHandoffMessages(branch: SessionEntry[]): AgentMessage[];
8
9
  export declare function buildAiContext(conversationText: string, goal: string): Context;
@@ -1,6 +1,7 @@
1
1
  import { serializeConversation } from "./compaction/utils.js";
2
2
  import { convertToLlm } from "./messages.js";
3
3
  export const DEFAULT_HANDOFF_GOAL = "Continue the previous session from this handoff.";
4
+ export const AUTO_HANDOFF_GOAL = `Continue the previous session without losing its direction. Preserve the big picture: the overall objective, why the work matters, the intended outcome and success criteria, the current approach and key decisions, relevant constraints, exact reference files or artifacts, completed and remaining work, known failures or test results, and the next concrete action. Make clear how the immediate next step contributes to the overall goal.`;
4
5
  const SYSTEM_PROMPT = `Write a handoff document for a fresh agent to continue the current conversation. Return only the handoff document text; do not save a file or describe saving one. No question, no fluff, just write the handoff. Do not reproduce, quote, or reformat the conversation history or its tool calls — distill it into what matters; never emit transcript-style markup or raw tool-call text.
5
6
 
6
7
  Include a "suggested skills" section in the document, which suggests skills that the agent should invoke.
@@ -14,7 +14,7 @@
14
14
  "id": "celestial-pro",
15
15
  "name": "Celestial Pro",
16
16
  "reasoning": true,
17
- "input": ["text"],
17
+ "input": ["text", "image"],
18
18
  "contextWindow": 393216,
19
19
  "maxTokens": 64000,
20
20
  "thinkingLevelMap": {
@@ -67,51 +67,13 @@
67
67
  "requiresReasoningContentOnAssistantMessages": true
68
68
  }
69
69
  },
70
- {
71
- "id": "deepseek-v4-flash",
72
- "name": "DeepSeek V4 Flash",
73
- "reasoning": true,
74
- "input": ["text"],
75
- "contextWindow": 512000,
76
- "maxTokens": 64000,
77
- "thinkingLevelMap": {
78
- "minimal": null,
79
- "low": null,
80
- "medium": null,
81
- "high": "high",
82
- "xhigh": null,
83
- "max": "max"
84
- },
85
- "compat": {
86
- "requiresReasoningContentOnAssistantMessages": true
87
- }
88
- },
89
- {
90
- "id": "deepseek-v4-flash-vision-exp",
91
- "name": "DeepSeek V4 Flash Vision Exp",
92
- "reasoning": true,
93
- "input": ["text", "image"],
94
- "contextWindow": 512000,
95
- "maxTokens": 128000,
96
- "thinkingLevelMap": {
97
- "minimal": null,
98
- "low": null,
99
- "medium": null,
100
- "high": "high",
101
- "xhigh": null,
102
- "max": "max"
103
- },
104
- "compat": {
105
- "requiresReasoningContentOnAssistantMessages": true
106
- }
107
- },
108
70
  {
109
71
  "id": "deepseek-v4.1-flash",
110
72
  "name": "DeepSeek V4.1 Flash",
111
73
  "reasoning": true,
112
74
  "input": ["text", "image"],
113
75
  "contextWindow": 512000,
114
- "maxTokens": 128000,
76
+ "maxTokens": 64000,
115
77
  "thinkingLevelMap": {
116
78
  "minimal": null,
117
79
  "low": null,
@@ -207,38 +169,6 @@
207
169
  "max": "max"
208
170
  }
209
171
  },
210
- {
211
- "id": "kimi-k2.7-code",
212
- "name": "Kimi K2.7 Code",
213
- "reasoning": true,
214
- "input": ["text", "image"],
215
- "contextWindow": 262128,
216
- "maxTokens": 64000,
217
- "thinkingLevelMap": {
218
- "minimal": null,
219
- "low": "low",
220
- "medium": null,
221
- "high": "high",
222
- "xhigh": null,
223
- "max": "max"
224
- }
225
- },
226
- {
227
- "id": "glm-5.2",
228
- "name": "GLM-5.2",
229
- "reasoning": true,
230
- "input": ["text"],
231
- "contextWindow": 1000000,
232
- "maxTokens": 64000,
233
- "thinkingLevelMap": {
234
- "minimal": null,
235
- "low": "low",
236
- "medium": null,
237
- "high": "high",
238
- "xhigh": null,
239
- "max": null
240
- }
241
- },
242
172
  {
243
173
  "id": "glm-5.3-flash",
244
174
  "name": "GLM-5.3 Flash",
@@ -226,11 +226,11 @@ describe("handoff-new extension", () => {
226
226
  expect(calls.newSession).not.toBeNull();
227
227
  });
228
228
 
229
- it("non-tui mode generation error notifies and opens no session", async () => {
229
+ it("non-tui mode generation error notifies, rejects, and opens no session", async () => {
230
230
  (complete as any).mockRejectedValueOnce(new Error("api down"));
231
231
  const { commands } = createPiHarness();
232
232
  const { ctx, calls } = createCtx({ mode: "rpc" });
233
- await commands.get("handoff-new")!.handler("goal", ctx);
233
+ await expect(commands.get("handoff-new")!.handler("goal", ctx)).rejects.toThrow("api down");
234
234
  expect(calls.newSession).toBeNull();
235
235
  expect(calls.notify.some((n) => /api down/.test(n.msg))).toBe(true);
236
236
  });
@@ -259,10 +259,10 @@ describe("handoff-new extension", () => {
259
259
  expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
260
260
  });
261
261
 
262
- it("empty generated handoff does not open a blank session", async () => {
262
+ it("empty generated handoff notifies, rejects, and opens no blank session", async () => {
263
263
  const { commands } = createPiHarness();
264
264
  const { ctx, calls } = createCtx({ customResult: " " });
265
- await commands.get("handoff-new")!.handler("goal", ctx);
265
+ await expect(commands.get("handoff-new")!.handler("goal", ctx)).rejects.toThrow("returned no text");
266
266
  expect(calls.newSession).toBeNull();
267
267
  expect(calls.notify.some((n) => /returned no text/.test(n.msg))).toBe(true);
268
268
  });
@@ -358,29 +358,29 @@ describe("handoff-new extension", () => {
358
358
  expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
359
359
  });
360
360
 
361
- it("factory: missing API key throws and notifies Cancelled", async () => {
361
+ it("factory: missing API key notifies and rejects", async () => {
362
362
  const { commands } = createPiHarness();
363
363
  const { ctx, calls } = createFactoryCtx({ auth: { ok: true, error: "no key" } });
364
- await commands.get("handoff-new")!.handler("goal", ctx);
364
+ await expect(commands.get("handoff-new")!.handler("goal", ctx)).rejects.toThrow("No API key");
365
365
  expect(calls.newSession).toBeNull();
366
- expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
366
+ expect(calls.notify.some((n) => /No API key/.test(n.msg))).toBe(true);
367
367
  });
368
368
 
369
- it("factory: auth error throws and notifies Cancelled", async () => {
369
+ it("factory: auth error notifies and rejects", async () => {
370
370
  const { commands } = createPiHarness();
371
371
  const { ctx, calls } = createFactoryCtx({ auth: { ok: false, error: "auth failed" } });
372
- await commands.get("handoff-new")!.handler("goal", ctx);
372
+ await expect(commands.get("handoff-new")!.handler("goal", ctx)).rejects.toThrow("auth failed");
373
373
  expect(calls.newSession).toBeNull();
374
- expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
374
+ expect(calls.notify.some((n) => /auth failed/.test(n.msg))).toBe(true);
375
375
  });
376
376
 
377
- it("factory: complete rejection is caught and notifies Cancelled", async () => {
377
+ it("factory: complete rejection notifies and rejects", async () => {
378
378
  (complete as any).mockRejectedValueOnce(new Error("network down"));
379
379
  const { commands } = createPiHarness();
380
380
  const { ctx, calls } = createFactoryCtx({});
381
- await commands.get("handoff-new")!.handler("goal", ctx);
381
+ await expect(commands.get("handoff-new")!.handler("goal", ctx)).rejects.toThrow("network down");
382
382
  expect(calls.newSession).toBeNull();
383
- expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
383
+ expect(calls.notify.some((n) => /network down/.test(n.msg))).toBe(true);
384
384
  });
385
385
 
386
386
  it("factory: loader onAbort resolves null", async () => {
@@ -74,15 +74,20 @@ async function handoffNew(args: string, ctx: ExtensionCommandContext) {
74
74
  const aiContext = buildAiContext(conversationText, goal);
75
75
 
76
76
  let result: string | null;
77
+ let generationError: unknown;
77
78
  if (ctx.mode === "tui") {
79
+ let cancelled = false;
78
80
  result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
79
81
  const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
80
- loader.onAbort = () => done(null);
82
+ loader.onAbort = () => {
83
+ cancelled = true;
84
+ done(null);
85
+ };
81
86
 
82
87
  generateHandoffText(ctx, aiContext, loader.signal)
83
88
  .then(done)
84
89
  .catch((err) => {
85
- console.error("handoff-new generation failed:", err);
90
+ if (!cancelled) generationError = err;
86
91
  done(null);
87
92
  });
88
93
 
@@ -94,18 +99,24 @@ async function handoffNew(args: string, ctx: ExtensionCommandContext) {
94
99
  try {
95
100
  result = await generateHandoffText(ctx, aiContext, ctx.signal);
96
101
  } catch (err) {
97
- ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
98
- return;
102
+ generationError = err;
103
+ result = null;
99
104
  }
100
105
  }
101
106
 
107
+ if (generationError !== undefined) {
108
+ const error = generationError instanceof Error ? generationError : new Error(String(generationError));
109
+ ctx.ui.notify(error.message, "error");
110
+ throw error;
111
+ }
102
112
  if (result === null) {
103
113
  ctx.ui.notify("Cancelled", "info");
104
114
  return;
105
115
  }
106
116
  if (!result.trim()) {
107
- ctx.ui.notify("Handoff generation returned no text", "error");
108
- return;
117
+ const error = new Error("Handoff generation returned no text");
118
+ ctx.ui.notify(error.message, "error");
119
+ throw error;
109
120
  }
110
121
 
111
122
  // Read the name before newSession: the original ctx is invalidated once
@@ -387,7 +387,7 @@ describe("mutation-capable commands", () => {
387
387
  const [title, message] = ctx.ui.confirm.mock.calls[0] as [string, string];
388
388
  expect(title).toContain("provider-backed");
389
389
  expect(message).toContain("leaves this machine");
390
- expect(message).toContain("credential is passed only to this Graft child process");
390
+ expect(message).toContain("Credentials are passed only to this Graft child process");
391
391
  expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
392
392
  });
393
393
 
@@ -330,7 +330,7 @@ export function registerGraftCommands(pi: ExtensionAPI, runtime: GraftCommandRun
330
330
  ? "Set up Graft for this repository?"
331
331
  : "Build the Graft graph?",
332
332
  deep
333
- ? `This runs \`graft build --deep\` in ${repo}.\n\nThe structural graph stays local and deterministic. The deep pass summarizes each changed file and extracts per-symbol cruxes using Selesai's active compatible model, so source-derived content leaves this machine. Its credential is passed only to this Graft child process.\n\nFiles it can write:\n${buildEffects(true)}`
333
+ ? `This runs \`graft build --deep\` in ${repo}.\n\nThe structural graph stays local and deterministic. The deep pass summarizes each changed file and extracts per-symbol cruxes using Selesai's active compatible model, falling back to another model you scoped on the same provider when that model cannot produce Graft summaries. Source-derived content leaves this machine either way. Credentials are passed only to this Graft child process.\n\nFiles it can write:\n${buildEffects(true)}`
334
334
  : `This runs \`graft build\` in ${repo} and can write:\n\n${buildEffects(false)}`,
335
335
  manualCommand,
336
336
  );
@@ -183,32 +183,94 @@ const GRAFT_PROVIDER_FOR_SELESAI_PROVIDER: Record<string, "openai" | "anthropic"
183
183
  tokenin: "litellm",
184
184
  };
185
185
 
186
- /** Resolve the active Selesai model into the isolated environment Graft needs. */
187
- async function activeModelGraftEnvironment(ctx: ExtensionContext): Promise<NodeJS.ProcessEnv | undefined> {
186
+ /**
187
+ * The deep tier either has a plan, or a human-readable reason it does not.
188
+ * Automatic builds fall back to the structural tier on `ok: false`; the
189
+ * explicit `/graft deep` command reports the reason instead.
190
+ */
191
+ type DeepBuildPlan =
192
+ | { ok: true; env: NodeJS.ProcessEnv | undefined; models: readonly string[] }
193
+ | { ok: false; reason: string };
194
+
195
+ /** Graft's per-symbol summary pass is model-sensitive; this is its failure signature. */
196
+ const UNUSABLE_SUMMARIES = /no usable symbol summaries|empty-parsed/i;
197
+
198
+ /**
199
+ * Deep-build candidates in order: an explicit `graft.deepModel` pin, the active
200
+ * model, then the models this session scoped on the same provider. No new
201
+ * setting is needed — models the user already chose are tried before giving up
202
+ * on the deep tier.
203
+ */
204
+ function deepBuildModels(ctx: ExtensionContext, settings: { deepModel?: string }): readonly string[] {
205
+ const active = ctx.model;
206
+ const scoped = (ctx.scopedModels ?? [])
207
+ .map((entry) => entry.model)
208
+ .filter((candidate) => candidate.provider === active?.provider)
209
+ .map((candidate) => candidate.id);
210
+ const ordered = [settings.deepModel, active?.id, ...scoped].filter((id): id is string => Boolean(id));
211
+ return [...new Set(ordered)].slice(0, 3);
212
+ }
213
+
214
+ /**
215
+ * Resolve the active Selesai model into the isolated environment Graft needs.
216
+ *
217
+ * Credentials are read from Selesai's own auth storage — TokenIn keys live in
218
+ * `~/.selesai/agent`, not in the shell environment — and passed only to the
219
+ * Graft child process.
220
+ */
221
+ async function resolveDeepBuildPlan(
222
+ ctx: ExtensionContext,
223
+ settings: { deepModel?: string } = {},
224
+ ): Promise<DeepBuildPlan> {
225
+ const models = deepBuildModels(ctx, settings);
226
+ // A user who already configured Graft's own provider environment outranks us.
227
+ if (process.env.GRAFT_PROVIDER && process.env.GRAFT_API_KEY) return { ok: true, env: undefined, models };
228
+
188
229
  const model = ctx.model;
189
- const provider = model ? GRAFT_PROVIDER_FOR_SELESAI_PROVIDER[model.provider] : undefined;
190
- if (!provider) return undefined;
230
+ if (!model) return { ok: false, reason: "no active model is selected" };
231
+ const provider = GRAFT_PROVIDER_FOR_SELESAI_PROVIDER[model.provider];
232
+ if (!provider) {
233
+ return {
234
+ ok: false,
235
+ reason: `provider "${model.provider}" has no Graft wire format (use TokenIn, LiteLLM, OpenAI, Anthropic, or OrcaRouter)`,
236
+ };
237
+ }
191
238
 
192
- let auth: Awaited<ReturnType<typeof ctx.modelRegistry.getProviderAuth>>;
239
+ let apiKey: string | undefined;
240
+ let authBaseUrl: string | undefined;
193
241
  try {
194
- auth = await ctx.modelRegistry.getProviderAuth(model.provider);
242
+ const auth = await ctx.modelRegistry.getProviderAuth(model.provider);
243
+ apiKey = auth?.auth.apiKey;
244
+ authBaseUrl = auth?.auth.baseUrl;
195
245
  } catch {
196
- return undefined;
246
+ // Fall through to the missing-credential reason below.
247
+ }
248
+ if (!apiKey) return { ok: false, reason: `no credential is stored for "${model.provider}"` };
249
+
250
+ // The composed model carries the endpoint; auth only sometimes does.
251
+ const baseUrl = authBaseUrl || model.baseUrl || ctx.modelRegistry.getProvider(model.provider)?.baseUrl;
252
+ if (provider === "litellm" && !baseUrl) {
253
+ return { ok: false, reason: `"${model.provider}" has no base URL to send Graft to` };
197
254
  }
198
- const apiKey = auth?.auth.apiKey;
199
- const baseUrl = auth?.auth.baseUrl;
200
- if (!apiKey || (provider === "litellm" && !baseUrl)) return undefined;
201
255
 
202
- const { GRAFT_PROVIDER: _provider, GRAFT_MODEL: _model, GRAFT_API_KEY: _apiKey, GRAFT_BASE_URL: _baseUrl, ...env } = process.env;
256
+ const { GRAFT_PROVIDER: _p, GRAFT_MODEL: _m, GRAFT_API_KEY: _k, GRAFT_BASE_URL: _u, ...env } = process.env;
203
257
  return {
204
- ...env,
205
- GRAFT_PROVIDER: provider,
206
- GRAFT_MODEL: model.id,
207
- GRAFT_API_KEY: apiKey,
208
- ...(baseUrl ? { GRAFT_BASE_URL: baseUrl } : {}),
258
+ ok: true,
259
+ models,
260
+ env: {
261
+ ...env,
262
+ GRAFT_PROVIDER: provider,
263
+ GRAFT_API_KEY: apiKey,
264
+ ...(baseUrl ? { GRAFT_BASE_URL: baseUrl } : {}),
265
+ },
209
266
  };
210
267
  }
211
268
 
269
+ /** Graft's environment for one candidate model; undefined keeps the user's own GRAFT_* setup. */
270
+ function deepEnvFor(plan: Extract<DeepBuildPlan, { ok: true }>, modelId: string): NodeJS.ProcessEnv | undefined {
271
+ return plan.env ? { ...plan.env, GRAFT_MODEL: modelId } : undefined;
272
+ }
273
+
212
274
  export default function graftExtension(pi: ExtensionAPI): void {
213
275
  registerBuiltinAgentAugmentation(pi);
214
276
  const exec: ExecLike = (command, args, options) => pi.exec(command, args, options);
@@ -277,6 +339,48 @@ export default function graftExtension(pi: ExtensionAPI): void {
277
339
  }
278
340
  };
279
341
 
342
+ /**
343
+ * Build the graph: deep with a compatible active model, structural otherwise.
344
+ *
345
+ * A model that cannot produce Graft-parsable summaries is not fatal — the
346
+ * next candidate the session already offers is tried before downgrading.
347
+ */
348
+ const buildGraph = async (
349
+ ctx: ExtensionContext,
350
+ activeSession: GraftSession,
351
+ phase: "build" | "refresh" = "build",
352
+ ): Promise<GraftRun> => {
353
+ const repoRoot = activeSession.repoRoot!;
354
+ // A refresh has already announced its own "syncing" phase at the caller.
355
+ const started = (deep: boolean): void => {
356
+ if (phase === "refresh") return;
357
+ if (session === activeSession && !activeSession.disposed) applyEvent(ctx, { type: "build-started", deep });
358
+ };
359
+ const structural = async (): Promise<GraftRun> => {
360
+ started(false);
361
+ return runGraft(exec, repoRoot, { kind: "build", deep: false });
362
+ };
363
+
364
+ const plan = await resolveDeepBuildPlan(ctx, activeSession.settings);
365
+ if (!plan.ok) return structural();
366
+
367
+ // The user's own GRAFT_* environment names the model; never second-guess it.
368
+ const candidates = plan.env ? plan.models : plan.models.slice(0, 1);
369
+ let failed: GraftRun | undefined;
370
+ for (const [index, model] of candidates.entries()) {
371
+ started(true);
372
+ const run = await runGraft(exec, repoRoot, { kind: "build", deep: true }, { env: deepEnvFor(plan, model) });
373
+ if (run.code === 0 || !UNUSABLE_SUMMARIES.test(`${run.stderr}\n${run.stdout}`)) return run;
374
+ failed = run;
375
+ if (index === 0 && candidates.length > 1 && ctx.hasUI) {
376
+ ctx.ui.notify(`Graft cannot summarize with ${model}; retrying the deep pass with another scoped model.`, "info");
377
+ }
378
+ }
379
+ // Nothing left to try: report the last run rather than pretending the
380
+ // structural tier explained the failure.
381
+ return failed ?? (await structural());
382
+ };
383
+
280
384
  /**
281
385
  * Start the graph once. Compatible active Selesai models add the deep tier;
282
386
  * otherwise the free local structural tier remains usable.
@@ -290,11 +394,7 @@ export default function graftExtension(pi: ExtensionAPI): void {
290
394
  activeSession.state.name === "failed"
291
395
  ) return undefined;
292
396
 
293
- const pending = activeModelGraftEnvironment(ctx).then(async (env) => {
294
- const deep = env !== undefined;
295
- if (session === activeSession && !activeSession.disposed) applyEvent(ctx, { type: "build-started", deep });
296
- return runGraft(exec, activeSession.repoRoot!, { kind: "build", deep }, { env });
297
- });
397
+ const pending = buildGraph(ctx, activeSession);
298
398
  structuralBuildPromise = pending;
299
399
  structuralBuildSession = activeSession;
300
400
  void pending.then(
@@ -544,11 +644,9 @@ export default function graftExtension(pi: ExtensionAPI): void {
544
644
 
545
645
  void (async () => {
546
646
  try {
547
- const env = await activeModelGraftEnvironment(ctx);
548
- const deep = env !== undefined;
549
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
647
+ const run = await buildGraph(ctx, session, "refresh");
550
648
  if (session.disposed) return;
551
- recordBuild(ctx, run, deep);
649
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
552
650
  } catch (error) {
553
651
  if (session.disposed) return;
554
652
  applyEvent(ctx, {
@@ -584,24 +682,27 @@ export default function graftExtension(pi: ExtensionAPI): void {
584
682
  if (!deep) {
585
683
  const pending = autoBuild(ctx, session);
586
684
  if (pending) return pending;
685
+ applyEvent(ctx, { type: "build-started", deep: false });
686
+ const structural = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
687
+ recordBuild(ctx, structural, false);
688
+ return structural;
587
689
  }
588
- const env = deep ? await activeModelGraftEnvironment(ctx) : undefined;
589
- if (deep && !env) {
690
+ const plan = await resolveDeepBuildPlan(ctx, session.settings);
691
+ if (!plan.ok) {
590
692
  return {
591
- op: { kind: "build", deep },
693
+ op: { kind: "build", deep: true },
592
694
  argv: ["graft", "build", "--deep"],
593
695
  cwd: session.repoRoot!,
594
696
  code: 1,
595
697
  stdout: "",
596
- stderr: "The active Selesai model cannot be used for Graft deep builds.",
698
+ stderr: `No deep build is possible for the active model: ${plan.reason}.`,
597
699
  killed: false,
598
700
  cancelled: false,
599
701
  timedOut: false,
600
702
  };
601
703
  }
602
- applyEvent(ctx, { type: "build-started", deep });
603
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
604
- recordBuild(ctx, run, deep);
704
+ const run = await buildGraph(ctx, session);
705
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
605
706
  return run;
606
707
  },
607
708
  runRefresh: async (ctx) => {
@@ -609,10 +710,8 @@ export default function graftExtension(pi: ExtensionAPI): void {
609
710
  await ensureReady(ctx, { kind: "build", deep: false });
610
711
  session.lastRefreshAt = Date.now();
611
712
  applyEvent(ctx, { type: "refresh-started" });
612
- const env = await activeModelGraftEnvironment(ctx);
613
- const deep = env !== undefined;
614
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
615
- recordBuild(ctx, run, deep);
713
+ const run = await buildGraph(ctx, session, "refresh");
714
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
616
715
  return run;
617
716
  },
618
717
  providerEnvNames: () => PROVIDER_ENV_NAMES.filter((name) => Boolean(process.env[name])),
@@ -304,11 +304,11 @@ describe("proactive refresh", () => {
304
304
  });
305
305
  graftExtension(harness.pi as never);
306
306
  const ctx = makeCtx({ cwd: bare });
307
+ // Mirrors the real TokenIn shape: the key lives in ~/.selesai auth storage and
308
+ // the endpoint comes from the composed model, not from the auth resolution.
307
309
  Object.assign(ctx, {
308
- model: { provider: "tokenin", id: "deepseek-v4-flash" },
309
- modelRegistry: {
310
- getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token", baseUrl: "https://lite.andlet.me/v1" } })),
311
- },
310
+ model: { provider: "tokenin", id: "deepseek-v4.1-flash", baseUrl: "https://lite.andlet.me/v1" },
311
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token" } })) },
312
312
  });
313
313
  await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
314
314
  { type: "session_start", reason: "startup" },
@@ -317,7 +317,80 @@ describe("proactive refresh", () => {
317
317
  await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
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
- expect(build[2]).toMatchObject({ env: { GRAFT_PROVIDER: "litellm", GRAFT_MODEL: "deepseek-v4-flash" } });
320
+ expect(build[2]).toMatchObject({
321
+ env: {
322
+ GRAFT_PROVIDER: "litellm",
323
+ GRAFT_MODEL: "deepseek-v4.1-flash",
324
+ GRAFT_API_KEY: "test-token",
325
+ GRAFT_BASE_URL: "https://lite.andlet.me/v1",
326
+ },
327
+ });
328
+ });
329
+
330
+ it("retries the deep pass with another scoped model when the active one cannot summarize", async () => {
331
+ process.env.PI_CODING_AGENT_DIR = agentDir;
332
+ const bare = join(root, "retry-repo");
333
+ mkdirSync(join(bare, ".selesai"), { recursive: true });
334
+ writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
335
+ const models: string[] = [];
336
+ const harness = makePi(async (command: string, args: string[], options?: unknown) => {
337
+ if (command === "git") return { ...OK, stdout: `${bare}\n` };
338
+ if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
339
+ if (args.includes("build")) {
340
+ const model = (options as { env?: Record<string, string> }).env?.GRAFT_MODEL ?? "";
341
+ models.push(model);
342
+ if (model === "deepseek-v4.1-flash") {
343
+ return { ...OK, code: 1, stderr: "calc.ts: model returned no usable symbol summaries [empty-parsed]" };
344
+ }
345
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
346
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
347
+ writeFileSync(join(bare, "graft", "concept.md"), "# Concept", "utf-8");
348
+ }
349
+ return { ...OK, stdout: ASK_JSON };
350
+ });
351
+ graftExtension(harness.pi as never);
352
+ const ctx = makeCtx({ cwd: bare });
353
+ Object.assign(ctx, {
354
+ model: { provider: "tokenin", id: "deepseek-v4.1-flash", baseUrl: "https://lite.andlet.me/v1" },
355
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token" } })) },
356
+ scopedModels: [{ model: { provider: "tokenin", id: "glm-5.3-flash" } }],
357
+ });
358
+ await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
359
+ { type: "session_start", reason: "startup" },
360
+ ctx,
361
+ );
362
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
363
+ expect(models).toEqual(["deepseek-v4.1-flash", "glm-5.3-flash"]);
364
+ expect(notified(ctx)).toContain("retrying the deep pass");
365
+ });
366
+
367
+ it("falls back to the structural build when the active model cannot go deep", async () => {
368
+ process.env.PI_CODING_AGENT_DIR = agentDir;
369
+ const bare = join(root, "fallback-repo");
370
+ mkdirSync(join(bare, ".selesai"), { recursive: true });
371
+ writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
372
+ const harness = makePi(async (command: string, args: string[]) => {
373
+ if (command === "git") return { ...OK, stdout: `${bare}\n` };
374
+ if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
375
+ if (args.includes("build")) {
376
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
377
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
378
+ }
379
+ return { ...OK, stdout: ASK_JSON };
380
+ });
381
+ graftExtension(harness.pi as never);
382
+ const ctx = makeCtx({ cwd: bare });
383
+ Object.assign(ctx, {
384
+ model: { provider: "google", id: "gemini-3-pro" },
385
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-key" } })) },
386
+ });
387
+ await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
388
+ { type: "session_start", reason: "startup" },
389
+ ctx,
390
+ );
391
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
392
+ const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
393
+ expect(build[1]).toEqual(["build"]);
321
394
  });
322
395
 
323
396
  it("re-indexes after edits, shows the sync phase, and returns to fresh", async () => {
@@ -240,12 +240,18 @@ describe("readGraftSettings", () => {
240
240
  maxInjectionBytes: -1,
241
241
  maxResultBytes: 0,
242
242
  refreshDebounceSeconds: "60",
243
+ deepModel: " ",
243
244
  in: " ",
244
245
  },
245
246
  });
246
247
  expect(read(false)).toEqual({});
247
248
  });
248
249
 
250
+ it("keeps a deep-build model override for the active provider", () => {
251
+ write(join(agentDir, "settings.json"), { graft: { deepModel: " glm-5.3-flash " } });
252
+ expect(read(false)).toEqual({ deepModel: "glm-5.3-flash" });
253
+ });
254
+
249
255
  it("keeps a valid enabled flag and telemetry preference", () => {
250
256
  write(join(agentDir, "settings.json"), { graft: { enabled: false, telemetry: "inherit" } });
251
257
  expect(read(false)).toEqual({ enabled: false, telemetry: "inherit" });
@@ -57,6 +57,11 @@ export interface GraftSettings {
57
57
  in?: string;
58
58
  /** Seconds a mutation stays "changed" before a proactive refresh is attempted. */
59
59
  refreshDebounceSeconds?: number;
60
+ /**
61
+ * Model id for deep builds, on the active provider. Defaults to the active
62
+ * model; set it when that model cannot produce Graft-parsable summaries.
63
+ */
64
+ deepModel?: string;
60
65
  }
61
66
 
62
67
  export const SETTINGS_KEY = "graft";
@@ -97,6 +102,7 @@ function normalizeSettings(raw: unknown): GraftSettings {
97
102
  if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) settings[key] = candidate;
98
103
  }
99
104
  if (typeof value.in === "string" && value.in.trim()) settings.in = value.in.trim();
105
+ if (typeof value.deepModel === "string" && value.deepModel.trim()) settings.deepModel = value.deepModel.trim();
100
106
  return settings;
101
107
  }
102
108
 
@@ -466,8 +466,13 @@ export async function runRpcMode(runtimeHost) {
466
466
  return error(id, "handoff_new", "No model selected");
467
467
  try {
468
468
  const handoff = await generateHandoff(session.model, session.modelRuntime, session.sessionManager.getBranch(), command.goal);
469
+ const sessionName = session.sessionManager.getSessionName();
469
470
  const result = await runtimeHost.newSession({
470
471
  parentSession: session.sessionManager.getSessionFile(),
472
+ setup: async (replacementManager) => {
473
+ if (sessionName)
474
+ replacementManager.appendSessionInfo(sessionName);
475
+ },
471
476
  withSession: async (replacementSession) => replacementSession.sendUserMessage(handoff),
472
477
  });
473
478
  if (!result.cancelled)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.24",
3
+ "version": "0.13.26",
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
  "repository": {