@selesai/code 0.13.25 → 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,17 @@
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
+
5
16
  ## [0.13.25] - 2026-09-14
6
17
 
7
18
  ### 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
@@ -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.25",
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": {