@selesai/code 0.13.10 → 0.13.12

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,21 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.12] - 2026-09-03
6
+
7
+ ### Added
8
+ - **Auto-continue after output token limit.** When the model stops because it reached the maximum output token limit (`stopReason: "length"`) mid-response, the agent now queues a follow-up "continue from where you left off" message and re-prompts automatically instead of stopping with the "response may be incomplete" error. Truncated tool calls were already re-issued by the agent loop; this covers truncated text. The continuation is capped at 3 consecutive length stops (the counter resets on any normal stop) so a model that keeps hitting the cap doesn't loop forever, and context-pressure length stops still trigger compaction first.
9
+
10
+ ## [0.13.11] - 2026-09-02
11
+
12
+ ### Added
13
+ - **RPC rev-4 `get_skills` command.** New RPC command returning the resolved skill catalog (one entry per discovered skill with its effective enablement) plus the raw merged `global + project` `settings.skills` patterns, so hosts can mirror the TUI `/skills` toggle surface without reimplementing `isEnabledByOverrides` semantics. Exposed on the typed client as `RpcClient.getSkills()`.
14
+ - **RPC `working` loader parity.** `ctx.ui.setWorkingMessage()`, `setWorkingVisible()`, and `setWorkingIndicator()` now emit a fire-and-forget `extension_ui_request {method: "working"}` (message/visible/frames/intervalMs patch fields) instead of no-oping in RPC mode, so the host can render the streaming loader row.
15
+ - **Herdr agent-state extension.** New bundled extension that reports session/lifecycle state (`working`/`blocked`/`idle`) plus session references to the Herdr pane over `HERDR_SOCKET_PATH`. Instance state lives inside the factory so successor sessions after `/new`, fork, resume, or reload keep reporting; only the monotonic `reportSeq` stays module-level to satisfy Herdr's per-source sequence guard.
16
+
17
+ ### Fixed
18
+ - **handoff-new no longer fails session creation.** The `setup` callback read the session name from the (already replaced) extension ctx, throwing the stale-ctx error and aborting `newSession` with "Failed to create session". The name is now captured before `newSession()` and passed into `setup` as a plain value; the session name still carries over as before.
19
+
5
20
  ## [0.13.10] - 2026-09-02
6
21
 
7
22
  ### Added
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { AgentSession } from "./agent-session.js";
3
+ import { SettingsManager } from "./settings-manager.js";
4
+ function createMockSession() {
5
+ const settings = SettingsManager.inMemory({});
6
+ const extensionRunner = {
7
+ getCommand: vi.fn(),
8
+ createCommandContext: vi.fn(),
9
+ emitError: vi.fn(),
10
+ emit: vi.fn(),
11
+ emitMessageEnd: vi.fn(),
12
+ hasHandlers: vi.fn(() => false),
13
+ };
14
+ const resourceLoader = {
15
+ getExtensions: () => ({ extensions: [], errors: [], runtime: { flagValues: new Map(), pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [], assertActive: () => { } } }),
16
+ getSkills: () => ({ skills: [], diagnostics: [] }),
17
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
18
+ getThemes: () => ({ themes: [], diagnostics: [] }),
19
+ getAgents: () => ({ agents: [], diagnostics: [] }),
20
+ getAgentsFiles: () => ({ agentsFiles: [] }),
21
+ getSystemPrompt: () => undefined,
22
+ getAppendSystemPrompt: () => [],
23
+ getExtensionDiagnostics: () => [],
24
+ extendResources: () => { },
25
+ reload: vi.fn(),
26
+ };
27
+ const modelRegistry = {
28
+ registerProvider: vi.fn(),
29
+ unregisterProvider: vi.fn(),
30
+ getProvider: vi.fn(),
31
+ getProviders: vi.fn(() => []),
32
+ getModels: vi.fn(() => []),
33
+ };
34
+ const followUp = vi.fn();
35
+ const hasQueuedMessages = vi.fn(() => false);
36
+ const agent = {
37
+ state: { messages: [], tools: [] },
38
+ subscribe: vi.fn(() => () => { }),
39
+ followUp,
40
+ hasQueuedMessages,
41
+ };
42
+ const session = new AgentSession({
43
+ agent: agent,
44
+ sessionManager: { getBranch: () => [], appendMessage: vi.fn() },
45
+ settingsManager: settings,
46
+ cwd: "/tmp",
47
+ resourceLoader: resourceLoader,
48
+ modelRuntime: modelRegistry,
49
+ });
50
+ session._extensionRunner = extensionRunner;
51
+ return { session, followUp, hasQueuedMessages };
52
+ }
53
+ function lengthStopMessage(text) {
54
+ return {
55
+ role: "assistant",
56
+ content: [{ type: "text", text }],
57
+ stopReason: "length",
58
+ timestamp: Date.now(),
59
+ usage: { input: 100, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 600, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
60
+ };
61
+ }
62
+ describe("AgentSession length-stop continuation", () => {
63
+ it("queues a follow-up continuation and returns true for a truncated text response", async () => {
64
+ const { session, followUp } = createMockSession();
65
+ session._lastAssistantMessage = lengthStopMessage("partial response...");
66
+ const result = await session._handlePostAgentRun();
67
+ expect(result).toBe(true);
68
+ expect(followUp).toHaveBeenCalledTimes(1);
69
+ const queued = followUp.mock.calls[0][0];
70
+ expect(queued.role).toBe("user");
71
+ expect(queued.content[0].text).toContain("Continue from where you left off");
72
+ });
73
+ it("does not continue when the truncated message contains tool calls", async () => {
74
+ const { session, followUp } = createMockSession();
75
+ session._lastAssistantMessage = {
76
+ role: "assistant",
77
+ content: [{ type: "toolCall", id: "tc_1", name: "bash", arguments: "{}" }],
78
+ stopReason: "length",
79
+ timestamp: Date.now(),
80
+ };
81
+ const result = await session._handlePostAgentRun();
82
+ expect(result).toBe(false);
83
+ expect(followUp).not.toHaveBeenCalled();
84
+ });
85
+ it("stops after MAX_LENGTH_CONTINUATIONS consecutive length stops", async () => {
86
+ const { session, followUp } = createMockSession();
87
+ session._lengthContinuations = 3;
88
+ session._lastAssistantMessage = lengthStopMessage("still truncated...");
89
+ const result = await session._handlePostAgentRun();
90
+ expect(result).toBe(false);
91
+ expect(followUp).not.toHaveBeenCalled();
92
+ });
93
+ it("resets the continuation counter on a non-length assistant stop", async () => {
94
+ const { session } = createMockSession();
95
+ session._lengthContinuations = 2;
96
+ await session._handleAgentEvent({
97
+ type: "message_end",
98
+ message: {
99
+ role: "assistant",
100
+ content: [{ type: "text", text: "done" }],
101
+ stopReason: "stop",
102
+ timestamp: Date.now(),
103
+ },
104
+ });
105
+ expect(session._lengthContinuations).toBe(0);
106
+ });
107
+ });
@@ -232,6 +232,7 @@ export declare class AgentSession {
232
232
  private _branchSummaryAbortController;
233
233
  private _retryAbortController;
234
234
  private _retryAttempt;
235
+ private _lengthContinuations;
235
236
  private readonly _bashAbortControllers;
236
237
  private _pendingBashMessages;
237
238
  private _extensionRunner;
@@ -82,6 +82,10 @@ function estimateMessagesTokens(messages) {
82
82
  // ============================================================================
83
83
  // Constants
84
84
  // ============================================================================
85
+ /** Max consecutive auto-continuations after a length stop (output token limit). */
86
+ const MAX_LENGTH_CONTINUATIONS = 3;
87
+ /** Follow-up message queued when the model hits the output token limit. */
88
+ const LENGTH_CONTINUATION_MESSAGE = "Your previous response was cut off because it reached the maximum output token limit. Continue from where you left off. Do not repeat content you already produced.";
85
89
  // ============================================================================
86
90
  // AgentSession Class
87
91
  // ============================================================================
@@ -113,6 +117,8 @@ export class AgentSession {
113
117
  // Retry state
114
118
  _retryAbortController = undefined;
115
119
  _retryAttempt = 0;
120
+ // Length-stop continuation state (consecutive output-token-limit continuations)
121
+ _lengthContinuations = 0;
116
122
  // Bash execution state
117
123
  _bashAbortControllers = new Set();
118
124
  _pendingBashMessages = [];
@@ -441,6 +447,11 @@ export class AgentSession {
441
447
  if (assistantMsg.stopReason !== "error") {
442
448
  this._overflowRecoveryAttempted = false;
443
449
  }
450
+ // Reset the length-continuation counter on any non-length stop so the
451
+ // cap only bounds consecutive output-token-limit continuations.
452
+ if (assistantMsg.stopReason !== "length") {
453
+ this._lengthContinuations = 0;
454
+ }
444
455
  // Reset retry counter immediately on successful assistant response
445
456
  // This prevents accumulation across multiple LLM calls within a turn
446
457
  if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
@@ -863,6 +874,18 @@ export class AgentSession {
863
874
  if (await this._checkCompaction(msg)) {
864
875
  return true;
865
876
  }
877
+ // A length stop means the model hit the output token limit mid-response.
878
+ // The agent loop already re-prompts when truncated tool calls are present;
879
+ // here the loop stopped on truncated text, so queue a follow-up to continue.
880
+ // Compaction runs first: a length stop caused by context pressure (input
881
+ // filling the window) should compact instead of blindly continuing.
882
+ if (msg.stopReason === "length" &&
883
+ !msg.content.some((c) => c.type === "toolCall") &&
884
+ this._lengthContinuations < MAX_LENGTH_CONTINUATIONS) {
885
+ this._lengthContinuations++;
886
+ await this._queueFollowUp(LENGTH_CONTINUATION_MESSAGE);
887
+ return true;
888
+ }
866
889
  // The agent loop drains both queues before emitting agent_end. Any messages
867
890
  // here were queued by agent_end extension handlers and need a continuation.
868
891
  return this.agent.hasQueuedMessages();
@@ -108,12 +108,15 @@ async function handoffNew(args: string, ctx: ExtensionCommandContext) {
108
108
  return;
109
109
  }
110
110
 
111
+ // Read the name before newSession: the original ctx is invalidated once
112
+ // the session is replaced, so it cannot be read inside setup().
113
+ const sessionName = ctx.sessionManager.getSessionName();
114
+
111
115
  const newSessionResult = await ctx.newSession({
112
116
  parentSession: currentSessionFile,
113
117
  // Carry the current session's name over to the new session as the starting point.
114
118
  setup: (replacementManager) => {
115
- const name = ctx.sessionManager.getSessionName();
116
- if (name) replacementManager.appendSessionInfo(name);
119
+ if (sessionName) replacementManager.appendSessionInfo(sessionName);
117
120
  },
118
121
  withSession: async (replacementCtx) => {
119
122
  await replacementCtx.sendUserMessage(result);
@@ -0,0 +1,310 @@
1
+ // adapted from herdr's pi integration (herdr/src/integration/assets/pi/herdr-agent-state.ts)
2
+ // reports lifecycle state for selesai instead of pi: agent=selesai, source=herdr:selesai
3
+ // HERDR_INTEGRATION_ID=selesai
4
+ // HERDR_INTEGRATION_VERSION=2
5
+ // @ts-nocheck
6
+ //
7
+ // Instance state lives inside the factory, not at module scope. Selesai caches
8
+ // the extension module per cwd and re-invokes the factory for every session, so
9
+ // a module-scoped `released`/`queuedState`/`sendInFlight` from a previous
10
+ // session (handoff-new, auto-handoff, /new, resume, fork, reload) would make
11
+ // every successor instance silently drop its state reports and leave the pane
12
+ // stuck on the last reported state. Only the monotonic reportSeq stays
13
+ // module-level: Herdr guards pane.report_agent per source by seq and drops
14
+ // lower-seq reports, so the successor must never restart the counter below the
15
+ // previous instance's high-water mark.
16
+
17
+ import net from "node:net";
18
+
19
+ const HERDR_ENV = process.env.HERDR_ENV;
20
+ const socketPath = process.env.HERDR_SOCKET_PATH;
21
+ const socketEndpoint =
22
+ process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
23
+ const paneId = process.env.HERDR_PANE_ID;
24
+ const source = "herdr:selesai";
25
+
26
+ function enabled() {
27
+ return HERDR_ENV === "1" && !!socketPath && !!paneId;
28
+ }
29
+
30
+ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
31
+ if (!enabled()) {
32
+ return Promise.resolve(true);
33
+ }
34
+
35
+ return new Promise((resolve) => {
36
+ let done = false;
37
+ let timeout: ReturnType<typeof setTimeout> | undefined;
38
+ const finish = (delivered: boolean) => {
39
+ if (done) return;
40
+ done = true;
41
+ if (timeout) {
42
+ clearTimeout(timeout);
43
+ }
44
+ socket.destroy();
45
+ resolve(delivered);
46
+ };
47
+
48
+ const socket = net.createConnection(socketEndpoint!);
49
+ socket.on("error", () => finish(false));
50
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
51
+ socket.on("data", () => finish(true));
52
+ socket.on("end", () => finish(false));
53
+ timeout = setTimeout(() => finish(false), timeoutMs);
54
+ timeout.unref?.();
55
+ });
56
+ }
57
+
58
+ async function sendRequest(request: unknown): Promise<void> {
59
+ if (await sendRequestAttempt(request, 500)) {
60
+ return;
61
+ }
62
+ await sendRequestAttempt(request, 1500);
63
+ }
64
+
65
+ type AgentState = "working" | "blocked" | "idle";
66
+
67
+ type QueuedState = {
68
+ state: AgentState;
69
+ message?: string;
70
+ seq: number;
71
+ };
72
+
73
+ // Monotonic across all extension instances in this process. A per-session
74
+ // restart below the previous instance's high-water mark would be dropped by
75
+ // Herdr's per-source seq guard.
76
+ let reportSeq = Date.now() * 1000;
77
+
78
+ function nextReportSeq(): number {
79
+ reportSeq = Math.max(reportSeq + 1, Date.now() * 1000);
80
+ return reportSeq;
81
+ }
82
+
83
+ export default function (pi) {
84
+ if (!enabled()) {
85
+ return;
86
+ }
87
+
88
+ let currentAgentSessionId: string | undefined;
89
+ let currentAgentSessionPath: string | undefined;
90
+ let sendInFlight = false;
91
+ let queuedState: QueuedState | undefined;
92
+ let activeDrain: Promise<void> = Promise.resolve();
93
+ let released = false;
94
+ let agentActive = false;
95
+ let blockedCount = 0;
96
+ let blockedMessage: string | undefined;
97
+ let lastState: AgentState | undefined;
98
+ let lastMessage: string | undefined;
99
+ let rootSession = false;
100
+
101
+ function updateSessionRef(ctx: any): void {
102
+ try {
103
+ const file = ctx?.sessionManager?.getSessionFile?.();
104
+ currentAgentSessionPath =
105
+ typeof file === "string" && file.startsWith("/") ? file : undefined;
106
+ } catch {
107
+ currentAgentSessionPath = undefined;
108
+ }
109
+
110
+ try {
111
+ const id = ctx?.sessionManager?.getSessionId?.();
112
+ currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
113
+ } catch {
114
+ currentAgentSessionId = undefined;
115
+ }
116
+ }
117
+
118
+ function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
119
+ if (currentAgentSessionPath) {
120
+ return { ...params, agent_session_path: currentAgentSessionPath };
121
+ }
122
+ if (currentAgentSessionId) {
123
+ return { ...params, agent_session_id: currentAgentSessionId };
124
+ }
125
+ return params;
126
+ }
127
+
128
+ function currentSessionRef(): Record<string, unknown> | undefined {
129
+ if (currentAgentSessionPath) {
130
+ return { agent_session_path: currentAgentSessionPath };
131
+ }
132
+ if (currentAgentSessionId) {
133
+ return { agent_session_id: currentAgentSessionId };
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function reportSession(sessionStartSource?: string): Promise<void> {
139
+ const sessionRef = currentSessionRef();
140
+ if (!sessionRef) {
141
+ return Promise.resolve();
142
+ }
143
+
144
+ return sendRequest({
145
+ id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
146
+ method: "pane.report_agent_session",
147
+ params: {
148
+ pane_id: paneId,
149
+ source,
150
+ agent: "selesai",
151
+ seq: nextReportSeq(),
152
+ session_start_source: sessionStartSource,
153
+ ...sessionRef,
154
+ },
155
+ });
156
+ }
157
+
158
+ function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
159
+ return sendRequest({
160
+ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
161
+ method: "pane.report_agent",
162
+ params: withSessionRef({
163
+ pane_id: paneId,
164
+ source,
165
+ agent: "selesai",
166
+ state,
167
+ message,
168
+ seq,
169
+ }),
170
+ });
171
+ }
172
+
173
+ function queueState(state: AgentState, message?: string): void {
174
+ if (released) {
175
+ // The pane was released on quit; a late report would reclaim it and
176
+ // leave Herdr showing an agent that already exited.
177
+ return;
178
+ }
179
+ queuedState = { state, message, seq: nextReportSeq() };
180
+ if (!sendInFlight) {
181
+ activeDrain = drainStateQueue();
182
+ }
183
+ }
184
+
185
+ async function drainStateQueue(): Promise<void> {
186
+ if (sendInFlight) {
187
+ return;
188
+ }
189
+
190
+ sendInFlight = true;
191
+ try {
192
+ while (queuedState) {
193
+ const next = queuedState;
194
+ queuedState = undefined;
195
+ await sendState(next.state, next.message, next.seq);
196
+ }
197
+ } finally {
198
+ sendInFlight = false;
199
+ if (queuedState) {
200
+ activeDrain = drainStateQueue();
201
+ }
202
+ }
203
+ }
204
+
205
+ async function releaseAgent(): Promise<void> {
206
+ // Stop new reports, drop anything still queued, and wait for the in-flight
207
+ // send to finish so the release is the last write on the wire; a report
208
+ // landing after the release would reclaim the pane.
209
+ released = true;
210
+ queuedState = undefined;
211
+ await activeDrain.catch(() => undefined);
212
+ return sendRequest({
213
+ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
214
+ method: "pane.release_agent",
215
+ params: {
216
+ pane_id: paneId,
217
+ source,
218
+ agent: "selesai",
219
+ seq: nextReportSeq(),
220
+ },
221
+ });
222
+ }
223
+
224
+ function desiredState() {
225
+ if (blockedCount > 0) {
226
+ return { state: "blocked" as const, message: blockedMessage };
227
+ }
228
+ if (agentActive) {
229
+ return { state: "working" as const, message: undefined };
230
+ }
231
+ return { state: "idle" as const, message: undefined };
232
+ }
233
+
234
+ function publishState(force = false) {
235
+ const next = desiredState();
236
+ if (!force && next.state === lastState && next.message === lastMessage) {
237
+ return;
238
+ }
239
+ lastState = next.state;
240
+ lastMessage = next.message;
241
+ queueState(next.state, next.message);
242
+ }
243
+
244
+ pi.events.on("herdr:blocked", (data) => {
245
+ if (!rootSession) {
246
+ return;
247
+ }
248
+ if (!data?.active) {
249
+ blockedCount = Math.max(0, blockedCount - 1);
250
+ if (blockedCount === 0) {
251
+ blockedMessage = undefined;
252
+ }
253
+ publishState();
254
+ return;
255
+ }
256
+
257
+ blockedCount += 1;
258
+ blockedMessage = data.label;
259
+ publishState();
260
+ });
261
+
262
+ pi.on("session_start", async (event, ctx) => {
263
+ // TUI only: RPC/JSON/print modes are headless (no PTY herdr can display),
264
+ // and RPC still reports hasUI=true, so mode is the reliable gate.
265
+ if (ctx?.mode !== "tui") {
266
+ return;
267
+ }
268
+ rootSession = true;
269
+ updateSessionRef(ctx);
270
+ await reportSession(event?.reason);
271
+ // A reload can replace this extension mid-run without emitting another agent_start.
272
+ agentActive = ctx?.isIdle?.() === false;
273
+ publishState(true);
274
+ });
275
+
276
+ pi.on("agent_start", (_event, ctx) => {
277
+ if (!rootSession) {
278
+ return;
279
+ }
280
+ updateSessionRef(ctx);
281
+ void reportSession();
282
+ agentActive = true;
283
+ publishState();
284
+ });
285
+
286
+ pi.on("agent_settled", (_event, ctx) => {
287
+ if (!rootSession || ctx?.isIdle?.() !== true) {
288
+ return;
289
+ }
290
+
291
+ agentActive = false;
292
+ publishState();
293
+ });
294
+
295
+ pi.on("session_shutdown", async (event) => {
296
+ if (!rootSession) {
297
+ return;
298
+ }
299
+ if (event?.reason !== "quit") {
300
+ // new/resume/fork/reload: a successor instance in this same pane
301
+ // re-reports immediately. Releasing here races that report, and a
302
+ // release landing after the successor's report clears the pane.
303
+ // Silence this instance so no stale queued report lands around it.
304
+ released = true;
305
+ queuedState = undefined;
306
+ return;
307
+ }
308
+ await releaseAgent();
309
+ });
310
+ }
@@ -15,6 +15,7 @@
15
15
  "./question",
16
16
  "./grep-app",
17
17
  "./handoff-new.ts",
18
+ "./herdr-agent-state.ts",
18
19
  "./inline-skills.ts",
19
20
  "./rtk.ts",
20
21
  "./tokenin-onboarding.ts",
@@ -24,7 +24,7 @@
24
24
 
25
25
  import * as path from "node:path";
26
26
  import * as fs from "node:fs";
27
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
28
  import { MemoryStore } from "./store/memory-store.js";
29
29
  import { SkillStore } from "./store/skill-store.js";
30
30
  import { DatabaseManager } from "./store/db.js";
@@ -106,6 +106,10 @@ export default function (pi: ExtensionAPI) {
106
106
 
107
107
  const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
108
108
  let persistenceInitialized = false;
109
+ // Captured at session_start: the extension factory runs with no session, but
110
+ // the auto-consolidator needs model access to use the fast in-process direct
111
+ // transport instead of always spawning a slow `pi -p` subprocess agent.
112
+ let sessionModelCtx: Pick<ExtensionContext, "model" | "modelRegistry"> | null = null;
109
113
 
110
114
  const store = new MemoryStore({ ...config, memoryDir: globalDir });
111
115
  // Factory may run with no session (Pi public contract). Do not snapshot
@@ -182,6 +186,7 @@ export default function (pi: ExtensionAPI) {
182
186
 
183
187
  // ── 1. Load memory from disk on session start ──
184
188
  pi.on("session_start", async (_event, ctx) => {
189
+ sessionModelCtx = { model: ctx.model, modelRegistry: ctx.modelRegistry };
185
190
  if (!persistenceInitialized) {
186
191
  try {
187
192
  await measureLifecycle("session-start.persistence-sync", async () => {
@@ -286,6 +291,7 @@ export default function (pi: ExtensionAPI) {
286
291
  config.consolidationTimeoutMs,
287
292
  toolTarget,
288
293
  config,
294
+ sessionModelCtx,
289
295
  );
290
296
  if (result.deferred) {
291
297
  console.info(`⏳ Auto-consolidation for '${toolTarget}' deferred: ${result.error ?? "another session holds the consolidation lock"}`);
@@ -17,7 +17,7 @@ export type FirecrawlOptions = {
17
17
  };
18
18
 
19
19
  export type SearchBackendConfig = {
20
- provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
20
+ provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin';
21
21
  baseUrl?: string;
22
22
  fallback?: 'duckduckgo';
23
23
  options?: SearxngOptions;
@@ -54,7 +54,7 @@ export type BackendConfigFile = {
54
54
  };
55
55
 
56
56
  export const DEFAULT_BACKEND_CONFIG: BackendConfig = {
57
- search: { provider: 'duckduckgo' },
57
+ search: { provider: 'tokenin', fallback: 'duckduckgo' },
58
58
  fetch: { provider: 'http' },
59
59
  headless: { provider: 'local-browser' }
60
60
  };
@@ -86,7 +86,7 @@ function extractFirecrawlOptions(value: unknown): FirecrawlOptions | undefined {
86
86
  return Object.keys(options).length > 0 ? options : undefined;
87
87
  }
88
88
 
89
- const PROVIDER_NAMES: SearchProviderName[] = ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily'];
89
+ const PROVIDER_NAMES: SearchProviderName[] = ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily', 'tokenin'];
90
90
 
91
91
  export function usableSearchProviders(
92
92
  search: SearchBackendConfig,
@@ -99,6 +99,7 @@ export function usableSearchProviders(
99
99
  if (env.YDC_API_KEY?.trim()) usable.push('youcom');
100
100
  if (env.EXA_API_KEY?.trim()) usable.push('exa');
101
101
  if (env.TAVILY_API_KEY?.trim()) usable.push('tavily');
102
+ if (hasActiveTokenInAccount()) usable.push('tokenin');
102
103
  return usable;
103
104
  }
104
105
 
@@ -129,7 +130,8 @@ export function extractBackendConfigOverride(
129
130
  backends?.search?.provider === 'brave' ||
130
131
  backends?.search?.provider === 'youcom' ||
131
132
  backends?.search?.provider === 'exa' ||
132
- backends?.search?.provider === 'tavily'
133
+ backends?.search?.provider === 'tavily' ||
134
+ backends?.search?.provider === 'tokenin'
133
135
  ) {
134
136
  override.search = { provider: backends.search.provider };
135
137
  if (backends.search.provider === 'searxng' && typeof backends.search.baseUrl === 'string') {
@@ -186,8 +188,8 @@ export function validateBackendConfig(config: BackendConfig): string[] {
186
188
  issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
187
189
  }
188
190
 
189
- if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily') {
190
- issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, or tavily');
191
+ if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily' && config.search.provider !== 'tokenin') {
192
+ issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, tavily, or tokenin');
191
193
  }
192
194
 
193
195
  if (config.fetch.fallback === 'http' && config.fetch.provider !== 'firecrawl') {
@@ -3,6 +3,7 @@ import { createBraveSearchTool } from '../search/brave.js';
3
3
  import { createYouComSearchTool } from '../search/youcom.js';
4
4
  import { createExaSearchTool } from '../search/exa.js';
5
5
  import { createTavilySearchTool } from '../search/tavily.js';
6
+ import { createTokenInSearchTool } from '../search/tokenin.js';
6
7
  import { createSearxngSearchTool } from '../search/searxng.js';
7
8
  import { createFanoutSearch } from '../search/fanout.js';
8
9
  import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
@@ -31,6 +32,7 @@ export type BackendFactoryDeps = {
31
32
  createYouComSearch?: typeof createYouComSearchTool;
32
33
  createExaSearch?: typeof createExaSearchTool;
33
34
  createTavilySearch?: typeof createTavilySearchTool;
35
+ createTokenInSearch?: typeof createTokenInSearchTool;
34
36
  createHttpFetch?: typeof createWebFetchTool;
35
37
  createFirecrawlFetch?: typeof createFirecrawlFetcher;
36
38
  createHeadlessFetch?: typeof createWebFetchHeadlessTool;
@@ -71,7 +73,7 @@ function invalidFirecrawlFetch() {
71
73
  function withSearchFallback(
72
74
  primary: BackendSet['search'],
73
75
  fallback: BackendSet['search'],
74
- fallbackFrom: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo'
76
+ fallbackFrom: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin' | 'duckduckgo'
75
77
  ): BackendSet['search'] {
76
78
  return async (input) => {
77
79
  const first = await primary(input);
@@ -124,6 +126,7 @@ export function createBackendSet(
124
126
  const createYouComSearch = deps.createYouComSearch ?? createYouComSearchTool;
125
127
  const createExaSearch = deps.createExaSearch ?? createExaSearchTool;
126
128
  const createTavilySearch = deps.createTavilySearch ?? createTavilySearchTool;
129
+ const createTokenInSearch = deps.createTokenInSearch ?? createTokenInSearchTool;
127
130
  const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
128
131
  const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
129
132
  const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
@@ -142,6 +145,8 @@ export function createBackendSet(
142
145
  return createExaSearch({ apiKey: process.env.EXA_API_KEY });
143
146
  case 'tavily':
144
147
  return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
148
+ case 'tokenin':
149
+ return createTokenInSearch();
145
150
  case 'duckduckgo':
146
151
  default:
147
152
  return createDuckDuckGoSearch();
@@ -160,7 +165,9 @@ export function createBackendSet(
160
165
  ? createExaSearch({ apiKey: process.env.EXA_API_KEY })
161
166
  : config.search.provider === 'tavily'
162
167
  ? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY })
163
- : createDuckDuckGoSearch();
168
+ : config.search.provider === 'tokenin'
169
+ ? createTokenInSearch()
170
+ : createDuckDuckGoSearch();
164
171
 
165
172
  if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
166
173
  search = withSearchFallback(search, createDuckDuckGoSearch(), 'searxng');
@@ -182,6 +189,10 @@ export function createBackendSet(
182
189
  search = withSearchFallback(search, createDuckDuckGoSearch(), 'tavily');
183
190
  }
184
191
 
192
+ if (config.search.provider === 'tokenin' && config.search.fallback === 'duckduckgo') {
193
+ search = withSearchFallback(search, createDuckDuckGoSearch(), 'tokenin');
194
+ }
195
+
185
196
  const fanoutConfig = config.search.fanout;
186
197
  if (fanoutConfig && fanoutConfig.mode !== 'off') {
187
198
  const baseNames =
@@ -4,7 +4,28 @@
4
4
  // minimal settings.json read. Ceiling: if selesai moves web search config elsewhere, this
5
5
  // helper needs updating; upgrade path = upstream pi-web-agent adopting a settings hook.
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
- import { getSettingsPath } from '@selesai/code';
7
+ import { join } from 'node:path';
8
+ import { getAgentDir, getSettingsPath } from '@selesai/code';
9
+
10
+ /**
11
+ * True when tokenin-auth.json (written by the tokenin-onboarding extension)
12
+ * has an active account. Used to decide whether the tokenin search provider is
13
+ * usable. Never throws.
14
+ */
15
+ export function hasActiveTokenInAccount(authPath: string = join(getAgentDir(), 'tokenin-auth.json')): boolean {
16
+ try {
17
+ if (!existsSync(authPath)) return false;
18
+ const parsed = JSON.parse(readFileSync(authPath, 'utf-8')) as unknown;
19
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
20
+ const auth = parsed as { accounts?: unknown; activeId?: unknown };
21
+ if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return false;
22
+ return auth.accounts.some(
23
+ (a) => a && typeof a === 'object' && !Array.isArray(a) && (a as { id?: unknown }).id === auth.activeId
24
+ );
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
8
29
 
9
30
  /**
10
31
  * Read the Brave API key from selesai settings.json (webAgent.braveApiKey).
@@ -278,7 +278,7 @@ function buildBackendSettingsItems(
278
278
  id: 'backend:search:provider',
279
279
  label: 'Search backend',
280
280
  currentValue: backends.search.provider,
281
- values: ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily']
281
+ values: ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily', 'tokenin']
282
282
  },
283
283
  {
284
284
  id: 'backend:search:baseUrl',
@@ -289,8 +289,8 @@ function buildBackendSettingsItems(
289
289
  {
290
290
  id: 'backend:search:fallback',
291
291
  label: 'Search fallback',
292
- currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? backends.search.fallback ?? 'off' : 'off',
293
- values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? ['off', 'duckduckgo'] : ['off']
292
+ currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' || backends.search.provider === 'tokenin' ? backends.search.fallback ?? 'off' : 'off',
293
+ values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' || backends.search.provider === 'tokenin' ? ['off', 'duckduckgo'] : ['off']
294
294
  },
295
295
  {
296
296
  id: 'backend:search:fanout:mode',
@@ -488,7 +488,7 @@ export function applySettingsValue(
488
488
  currentDraft.tools = nextTools;
489
489
  }
490
490
 
491
- if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave' || newValue === 'youcom' || newValue === 'exa' || newValue === 'tavily')) {
491
+ if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave' || newValue === 'youcom' || newValue === 'exa' || newValue === 'tavily' || newValue === 'tokenin')) {
492
492
  currentBackends.search.provider = newValue;
493
493
  if (newValue !== 'searxng') {
494
494
  delete currentBackends.search.baseUrl;
@@ -500,7 +500,7 @@ export function applySettingsValue(
500
500
  }
501
501
 
502
502
  if (id === 'backend:search:fallback') {
503
- if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave' || currentBackends.search.provider === 'youcom' || currentBackends.search.provider === 'exa' || currentBackends.search.provider === 'tavily')) {
503
+ if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave' || currentBackends.search.provider === 'youcom' || currentBackends.search.provider === 'exa' || currentBackends.search.provider === 'tavily' || currentBackends.search.provider === 'tokenin')) {
504
504
  currentBackends.search.fallback = 'duckduckgo';
505
505
  } else {
506
506
  delete currentBackends.search.fallback;
@@ -0,0 +1,130 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getAgentDir } from '@selesai/code';
4
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
5
+ import type { SearchResult, WebSearchResponse } from '../types.js';
6
+
7
+ export const TOKENIN_SEARCH_TOOL_NAME = 'firecrawl';
8
+ export const TOKENIN_DEFAULT_BASE_URL = 'https://lite.andlet.me/v1';
9
+
10
+ type TokenInAccount = { id: string; label: string; apiKey: string; baseUrl?: string };
11
+ type TokenInAuth = { accounts: TokenInAccount[]; activeId: string | null };
12
+
13
+ type LiteLLMSearchResult = { title?: unknown; url?: unknown; snippet?: unknown };
14
+ type LiteLLMSearchResponse = { results?: LiteLLMSearchResult[] };
15
+
16
+ /**
17
+ * Read the active Token-In account from tokenin-auth.json (written by the
18
+ * tokenin-onboarding extension). Returns undefined when no account is saved.
19
+ * Never throws.
20
+ */
21
+ export function readActiveTokenInAccount(
22
+ authPath: string = join(getAgentDir(), 'tokenin-auth.json')
23
+ ): TokenInAccount | undefined {
24
+ try {
25
+ if (!existsSync(authPath)) return undefined;
26
+ const parsed = JSON.parse(readFileSync(authPath, 'utf-8')) as unknown;
27
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
28
+ const auth = parsed as TokenInAuth;
29
+ if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return undefined;
30
+ return auth.accounts.find((a) => a && typeof a === 'object' && a.id === auth.activeId && typeof a.apiKey === 'string');
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function resultWithPresentation(result: WebSearchResponse): WebSearchResponse {
37
+ return { ...result, presentation: buildSearchPresentation(result) };
38
+ }
39
+
40
+ function normalizeResults(response: LiteLLMSearchResponse): SearchResult[] {
41
+ return (response.results ?? []).flatMap((item) => {
42
+ if (typeof item.title !== 'string' || typeof item.url !== 'string') return [];
43
+ return [
44
+ {
45
+ title: item.title,
46
+ url: item.url,
47
+ snippet: typeof item.snippet === 'string' ? item.snippet : ''
48
+ }
49
+ ];
50
+ });
51
+ }
52
+
53
+ export function createTokenInSearchTool({
54
+ readAccount = readActiveTokenInAccount,
55
+ fetchImpl = fetch
56
+ }: {
57
+ readAccount?: () => TokenInAccount | undefined;
58
+ fetchImpl?: typeof fetch;
59
+ } = {}) {
60
+ return async function tokenInSearch({ query }: { query: string }): Promise<WebSearchResponse> {
61
+ const normalizedQuery = query.trim();
62
+
63
+ if (!normalizedQuery) {
64
+ return resultWithPresentation({
65
+ status: 'error',
66
+ results: [],
67
+ metadata: { backend: 'tokenin', cacheHit: false },
68
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
69
+ });
70
+ }
71
+
72
+ const account = readAccount();
73
+ if (!account) {
74
+ return resultWithPresentation({
75
+ status: 'error',
76
+ results: [],
77
+ metadata: { backend: 'tokenin', cacheHit: false },
78
+ error: {
79
+ code: 'BACKEND_CONFIG_INVALID',
80
+ message: 'Token-In search requires an active Token-In account. Run /tokenin add first.'
81
+ }
82
+ });
83
+ }
84
+
85
+ const baseUrl = (account.baseUrl ?? TOKENIN_DEFAULT_BASE_URL).replace(/\/+$/, '');
86
+ const url = `${baseUrl}/v1/search/${TOKENIN_SEARCH_TOOL_NAME}`;
87
+
88
+ try {
89
+ const response = await fetchImpl(url, {
90
+ method: 'POST',
91
+ headers: {
92
+ Accept: 'application/json',
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${account.apiKey}`
95
+ },
96
+ body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(`HTTP ${response.status}`);
101
+ }
102
+
103
+ const parsed = (await response.json()) as LiteLLMSearchResponse;
104
+ const results = normalizeResults(parsed);
105
+
106
+ if (results.length === 0) {
107
+ return resultWithPresentation({
108
+ status: 'error',
109
+ results: [],
110
+ metadata: { backend: 'tokenin', cacheHit: false },
111
+ error: { code: 'NO_RESULTS', message: 'Token-In search returned no usable results for this query.' }
112
+ });
113
+ }
114
+
115
+ return resultWithPresentation({
116
+ status: 'ok',
117
+ results,
118
+ metadata: { backend: 'tokenin', cacheHit: false }
119
+ });
120
+ } catch (error) {
121
+ const rawMessage = error instanceof Error ? error.message : String(error);
122
+ return resultWithPresentation({
123
+ status: 'error',
124
+ results: [],
125
+ metadata: { backend: 'tokenin', cacheHit: false },
126
+ error: { code: 'FETCH_FAILED', message: `Token-In search request failed: ${rawMessage}` }
127
+ });
128
+ }
129
+ };
130
+ }
@@ -16,7 +16,7 @@ export type SearchResult = {
16
16
  snippet: string;
17
17
  };
18
18
 
19
- export type SearchProviderName = 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
19
+ export type SearchProviderName = 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin';
20
20
 
21
21
  export type FanoutMode = 'off' | 'on' | 'auto';
22
22
 
@@ -32,9 +32,9 @@ export type ToolError = {
32
32
  };
33
33
 
34
34
  export type SearchMetadata = {
35
- backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
35
+ backend: SearchProviderName;
36
36
  cacheHit: boolean;
37
- fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
37
+ fallbackFrom?: Exclude<SearchProviderName, 'tokenin'>;
38
38
  fallbackReason?: string;
39
39
  fanout?: FanoutMetadata;
40
40
  };
@@ -12,7 +12,7 @@ import type { BashResult } from "../../core/bash-executor.ts";
12
12
  import type { CompactionResult } from "../../core/compaction/index.ts";
13
13
  import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
14
14
  import type { ChangelogEntry } from "../../utils/changelog.ts";
15
- import type { RpcSessionInfo, RpcSessionState, RpcSlashCommand } from "./rpc-types.ts";
15
+ import type { RpcSessionInfo, RpcSessionState, RpcSkillInfo, RpcSlashCommand } from "./rpc-types.ts";
16
16
  export interface RpcClientOptions {
17
17
  /** Path to the CLI entry point (default: searches for dist/cli.js) */
18
18
  cliPath?: string;
@@ -286,6 +286,14 @@ export declare class RpcClient {
286
286
  * Get available commands (extension commands, prompt templates, builtins).
287
287
  */
288
288
  getCommands(): Promise<RpcSlashCommand[]>;
289
+ /**
290
+ * Get the resolved skill catalog (per-skill enabled state) plus the raw
291
+ * global+project merged settings.skills patterns for round-trip toggling.
292
+ */
293
+ getSkills(): Promise<{
294
+ skills: RpcSkillInfo[];
295
+ patterns: string[];
296
+ }>;
289
297
  /**
290
298
  * Get settings for a scope ("global" | "project" | "effective", default "effective").
291
299
  */
@@ -411,6 +411,14 @@ export class RpcClient {
411
411
  const response = await this.send({ type: "get_commands" });
412
412
  return this.getData(response).commands;
413
413
  }
414
+ /**
415
+ * Get the resolved skill catalog (per-skill enabled state) plus the raw
416
+ * global+project merged settings.skills patterns for round-trip toggling.
417
+ */
418
+ async getSkills() {
419
+ const response = await this.send({ type: "get_skills" });
420
+ return this.getData(response);
421
+ }
414
422
  // =========================================================================
415
423
  // Settings
416
424
  // =========================================================================
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import * as crypto from "node:crypto";
14
14
  import { spawnSync } from "node:child_process";
15
- import { chmodSync, copyFileSync, existsSync, unlinkSync } from "node:fs";
15
+ import { chmodSync, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
- import { join } from "node:path";
17
+ import { basename, dirname, join, relative } from "node:path";
18
18
  import { generateHandoff } from "../../core/handoff.js";
19
19
  import { buildGitCommandPrompt } from "../../core/git-command.js";
20
20
  import { KeybindingsManager } from "../../core/keybindings.js";
@@ -27,7 +27,8 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
27
27
  import { createSyntheticSourceInfo } from "../../core/source-info.js";
28
28
  import { killTrackedDetachedChildren } from "../../utils/shell.js";
29
29
  import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
30
- import { VERSION, getAgentDir, getBundledDefaultsDir, getShareViewerUrl } from "../../config.js";
30
+ import { parseFrontmatter } from "../../utils/frontmatter.js";
31
+ import { VERSION, CONFIG_DIR_NAME, getAgentDir, getBundledDefaultsDir, getShareViewerUrl } from "../../config.js";
31
32
  import { openBrowser } from "../../utils/open-browser.js";
32
33
  import { theme } from "../interactive/theme/theme.js";
33
34
  import { getAvailableThemesWithPaths, setRegisteredThemes } from "../interactive/theme/theme.js";
@@ -164,14 +165,34 @@ export async function runRpcMode(runtimeHost) {
164
165
  statusText: text,
165
166
  });
166
167
  },
167
- setWorkingMessage(_message) {
168
- // Working message not supported in RPC mode - requires TUI loader access
168
+ setWorkingMessage(message) {
169
+ // Fire and forget - host renders the working loader row
170
+ output({
171
+ type: "extension_ui_request",
172
+ id: crypto.randomUUID(),
173
+ method: "working",
174
+ ...(message !== undefined ? { message } : {}),
175
+ });
169
176
  },
170
- setWorkingVisible(_visible) {
171
- // Working visibility not supported in RPC mode - requires TUI loader access
177
+ setWorkingVisible(visible) {
178
+ // Fire and forget - host shows/hides the working loader row
179
+ output({
180
+ type: "extension_ui_request",
181
+ id: crypto.randomUUID(),
182
+ method: "working",
183
+ visible,
184
+ });
172
185
  },
173
- setWorkingIndicator(_options) {
174
- // Working indicator customization not supported in RPC mode - requires TUI loader access
186
+ setWorkingIndicator(options) {
187
+ // Fire and forget - host customizes the working indicator. Custom frames only
188
+ // affect the normal streaming loader; compaction/retry keep built-in styling.
189
+ output({
190
+ type: "extension_ui_request",
191
+ id: crypto.randomUUID(),
192
+ method: "working",
193
+ ...(options?.frames !== undefined ? { frames: options.frames } : {}),
194
+ ...(options?.intervalMs !== undefined ? { intervalMs: options.intervalMs } : {}),
195
+ });
175
196
  },
176
197
  setHiddenThinkingLabel(_label) {
177
198
  // Hidden thinking label not supported in RPC mode - requires TUI message rendering access
@@ -880,6 +901,57 @@ export async function runRpcMode(runtimeHost) {
880
901
  return success(id, "get_commands", { commands });
881
902
  }
882
903
  // =================================================================
904
+ // Skills (resolved catalog + raw toggle patterns)
905
+ // =================================================================
906
+ case "get_skills": {
907
+ const resolvedSkills = session.resourceLoader.getResolvedSkills();
908
+ const agentDir = getAgentDir();
909
+ const cwd = session.sessionManager.getCwd();
910
+ const skills = [];
911
+ // Mirror the TUI skill-toggle resolution (buildSkillToggleItems):
912
+ // name/pattern from the SKILL.md parent dir, scope from resource metadata,
913
+ // enabled per isEnabledByOverrides via the resource loader.
914
+ for (const resource of resolvedSkills) {
915
+ const pathStr = resource.path;
916
+ const isSkillFile = basename(pathStr) === "SKILL.md";
917
+ const name = isSkillFile ? basename(dirname(pathStr)) : basename(pathStr);
918
+ const scope = resource.metadata.scope === "project" ? "project" : "user";
919
+ const baseDir = resource.metadata.baseDir ??
920
+ (scope === "project" ? join(cwd, CONFIG_DIR_NAME) : agentDir);
921
+ const pattern = relative(baseDir, pathStr) || name;
922
+ let frontmatterName;
923
+ let description;
924
+ try {
925
+ const raw = readFileSync(pathStr, "utf-8");
926
+ const { frontmatter } = parseFrontmatter(raw);
927
+ if (typeof frontmatter.name === "string" && frontmatter.name.trim()) {
928
+ frontmatterName = frontmatter.name.trim();
929
+ }
930
+ if (typeof frontmatter.description === "string" && frontmatter.description.trim()) {
931
+ description = frontmatter.description.trim();
932
+ }
933
+ }
934
+ catch {
935
+ // Unreadable skill file: keep path-derived name, no description.
936
+ }
937
+ skills.push({
938
+ name: frontmatterName ?? name,
939
+ description,
940
+ scope: scope === "project" ? "project" : "global",
941
+ pattern,
942
+ enabled: resource.enabled,
943
+ });
944
+ }
945
+ skills.sort((a, b) => a.name.localeCompare(b.name));
946
+ // Raw global+project merged settings.skills patterns so the caller can
947
+ // round-trip the exact write shape via set_settings + reload.
948
+ const patterns = [
949
+ ...(session.settingsManager.getGlobalSettings().skills ?? []),
950
+ ...(session.settingsManager.getProjectSettings().skills ?? []),
951
+ ];
952
+ return success(id, "get_skills", { skills, patterns });
953
+ }
954
+ // =================================================================
883
955
  // Settings
884
956
  // =================================================================
885
957
  case "get_settings": {
@@ -257,6 +257,9 @@ export type RpcCommand = {
257
257
  } | {
258
258
  id?: string;
259
259
  type: "get_commands";
260
+ } | {
261
+ id?: string;
262
+ type: "get_skills";
260
263
  };
261
264
  /** A command available for invocation via prompt */
262
265
  export interface RpcSlashCommand {
@@ -271,6 +274,19 @@ export interface RpcSlashCommand {
271
274
  /** True for interactive-TUI-only builtins that cannot be invoked via prompt */
272
275
  interactiveOnly?: boolean;
273
276
  }
277
+ /** A resolved skill in the get_skills catalog (mirrors the TUI skill-toggle resolution). */
278
+ export interface RpcSkillInfo {
279
+ /** Skill name (frontmatter name, falling back to the SKILL.md parent directory name). */
280
+ name: string;
281
+ /** Skill description from frontmatter. */
282
+ description?: string;
283
+ /** "global" for user-level skills, "project" for project-level skills. */
284
+ scope: "global" | "project";
285
+ /** Toggle pattern relative to the skill base dir (same shape the TUI writes to settings.skills). */
286
+ pattern: string;
287
+ /** Resolved enabled state per isEnabledByOverrides semantics. */
288
+ enabled: boolean;
289
+ }
274
290
  /** Session listing entry (SessionInfo with transcript text stripped). */
275
291
  export interface RpcSessionInfo {
276
292
  path: string;
@@ -555,6 +571,15 @@ export type RpcResponse = {
555
571
  data: {
556
572
  commands: RpcSlashCommand[];
557
573
  };
574
+ } | {
575
+ id?: string;
576
+ type: "response";
577
+ command: "get_skills";
578
+ success: true;
579
+ data: {
580
+ skills: RpcSkillInfo[];
581
+ patterns: string[];
582
+ };
558
583
  } | {
559
584
  id?: string;
560
585
  type: "response";
@@ -843,6 +868,18 @@ export type RpcExtensionUIRequest = {
843
868
  id: string;
844
869
  method: "set_editor_text";
845
870
  text: string;
871
+ } | {
872
+ type: "extension_ui_request";
873
+ id: string;
874
+ method: "working";
875
+ /** Working/loading message shown during streaming. Omit to restore the default. */
876
+ message?: string;
877
+ /** Show or hide the built-in working loader row during streaming. */
878
+ visible?: boolean;
879
+ /** Custom animation frames for the normal streaming loader. Empty array hides the indicator; omit to restore the default spinner. */
880
+ frames?: string[];
881
+ /** Frame interval in milliseconds for animated indicators. */
882
+ intervalMs?: number;
846
883
  };
847
884
  /** Response to an extension UI request */
848
885
  export type RpcExtensionUIResponse = {
package/docs/rpc.md CHANGED
@@ -897,6 +897,38 @@ Each command has:
897
897
 
898
898
  **Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are included with `source: "builtin"` and `interactiveOnly: true`. They are display-only: they are handled only in interactive mode and would not execute if sent via `prompt`.
899
899
 
900
+ #### get_skills
901
+
902
+ Get the resolved skill catalog (one entry per discovered skill with its effective enablement) plus the raw `settings.skills` patterns for round-trip toggling. Enables the extension to mirror the TUI `/skills` toggle surface without reimplementing `isEnabledByOverrides` semantics.
903
+
904
+ ```json
905
+ {"type": "get_skills"}
906
+ ```
907
+
908
+ Response:
909
+ ```json
910
+ {
911
+ "type": "response",
912
+ "command": "get_skills",
913
+ "success": true,
914
+ "data": {
915
+ "skills": [
916
+ {"name": "fix-tests", "description": "Fix failing tests", "scope": "global", "pattern": "fix-tests", "enabled": true}
917
+ ],
918
+ "patterns": ["+fix-tests", "-legacy-skill"]
919
+ }
920
+ }
921
+ ```
922
+
923
+ Each skill entry has:
924
+ - `name`: Skill name (frontmatter `name`, falling back to the SKILL.md parent directory name)
925
+ - `description`: Frontmatter description (optional)
926
+ - `scope`: `"global"` (user-level) or `"project"` (project-level)
927
+ - `pattern`: Path relative to the skill base dir — the same pattern the TUI writes as `+pattern`/`-pattern` into `settings.skills`
928
+ - `enabled`: Resolved enablement per `isEnabledByOverrides` semantics (package-manager.ts)
929
+
930
+ `patterns` is the raw merged `global + project` `settings.skills` array. To toggle a skill, write `set_settings {scope, values:{skills:[...]}}` with `+pattern`/`-pattern` entries applied, then send `reload` (the TUI reloads after every toggle).
931
+
900
932
  ## Events
901
933
 
902
934
  Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.
@@ -1179,13 +1211,13 @@ Extensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm()
1179
1211
  There are two categories of extension UI methods:
1180
1212
 
1181
1213
  - **Dialog methods** (`select`, `multiselect`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`.
1182
- - **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it.
1214
+ - **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `working`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it.
1183
1215
 
1184
1216
  If a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts.
1185
1217
 
1186
1218
  Some `ExtensionUIContext` methods are not supported or degraded in RPC mode because they require direct TUI access:
1187
1219
  - `custom()` returns `undefined`
1188
- - `setWorkingMessage()`, `setWorkingIndicator()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
1220
+ - `setWorkingMessage()`, `setWorkingVisible()`, `setWorkingIndicator()` emit a `working` extension_ui_request (see below); `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
1189
1221
  - `getEditorText()` returns `""`
1190
1222
  - `getToolsExpanded()` returns `false`
1191
1223
  - `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling)
@@ -1357,6 +1389,28 @@ Set the text in the input editor. Fire-and-forget.
1357
1389
  }
1358
1390
  ```
1359
1391
 
1392
+ #### working
1393
+
1394
+ Control the streaming "working" loader row. Fire-and-forget; the host renders the loader. This mirrors the TUI's `setWorkingMessage()` / `setWorkingVisible()` / `setWorkingIndicator()` extension methods.
1395
+
1396
+ ```json
1397
+ {
1398
+ "type": "extension_ui_request",
1399
+ "id": "uuid-10",
1400
+ "method": "working",
1401
+ "message": "Analyzing repository...",
1402
+ "visible": true,
1403
+ "frames": ["⠋", "⠙", "⠹"],
1404
+ "intervalMs": 80
1405
+ }
1406
+ ```
1407
+
1408
+ Fields (all optional, applied as a patch):
1409
+ - `message`: phase text shown next to the loader. Omit to keep (or restore to default).
1410
+ - `visible`: show/hide the loader row.
1411
+ - `frames`: custom animation frames for the normal streaming loader. An empty array hides the indicator; omitting restores the default spinner. Compaction/retry loaders keep their built-in styling.
1412
+ - `intervalMs`: frame interval in milliseconds for animated indicators.
1413
+
1360
1414
  ### Extension UI Responses (stdin)
1361
1415
 
1362
1416
  Responses are sent for dialog methods only (`select`, `multiselect`, `confirm`, `input`, `editor`). The `id` must match the request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.10",
3
+ "version": "0.13.12",
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": {