@pencil-agent/nano-pencil 1.11.13 → 1.11.15

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.
@@ -195,7 +195,10 @@ export class ExtensionRunner {
195
195
  return this.uiContext;
196
196
  }
197
197
  hasUI() {
198
- return this.uiContext !== noOpUIContext;
198
+ if (this.uiContext === noOpUIContext) {
199
+ return false;
200
+ }
201
+ return this.uiContext.__nonInteractive !== true;
199
202
  }
200
203
  getExtensionPaths() {
201
204
  return this.extensions.map((e) => e.path);
@@ -9,13 +9,14 @@ import { existsSync, readFileSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { getAgentDir } from "../../config.js";
11
11
  import { AuthStorage } from "../config/auth-storage.js";
12
+ import { getMCPConfigPath } from "./mcp-config.js";
12
13
  // Log level control: DEBUG shows all MCP messages, RELEASE only shows summary
13
14
  // Check if running from installed location (production) vs development
14
15
  const isProductionBuild = typeof import.meta.url === "string" && import.meta.url.includes("node_modules");
15
16
  const isDebugMode = process.env.NODE_ENV === "development" || (process.env.NODE_ENV !== "production" && !isProductionBuild);
16
17
  function mcpLog(...args) {
17
18
  if (isDebugMode) {
18
- console.log(...args);
19
+ console.error(...args);
19
20
  }
20
21
  }
21
22
  function mcpWarn(...args) {
@@ -45,8 +46,7 @@ export class MCPClient {
45
46
  * Load MCP server configurations from config file
46
47
  */
47
48
  loadServersFromConfig() {
48
- const configDir = getAgentDir();
49
- const configPath = join(configDir, "mcp.json");
49
+ const configPath = getMCPConfigPath();
50
50
  if (!existsSync(configPath)) {
51
51
  return;
52
52
  }
@@ -21,7 +21,7 @@ export function buildSystemPrompt(options = {}) {
21
21
  second: "2-digit",
22
22
  timeZoneName: "short",
23
23
  });
24
- const timeReasoningInstruction = "\nFor exact current time or date-sensitive reasoning, use the `time` tool instead of relying only on this prompt timestamp.";
24
+ const timeReasoningInstruction = "\nFor exact current time or any date-sensitive reasoning, you must use the `time` tool before answering. This includes questions about the current time, current date, today, tomorrow, yesterday, this week, deadlines, elapsed time, or anything that depends on the real system clock. Do not rely only on this prompt timestamp for those answers.";
25
25
  const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
26
26
  const contextFiles = providedContextFiles ?? [];
27
27
  const skills = providedSkills ?? [];
@@ -26,6 +26,7 @@ import { type PromptTemplate } from "../prompt/prompt-templates.js";
26
26
  import type { ResourceLoader } from "../config/resource-loader.js";
27
27
  import type { BranchSummaryEntry, SessionManager } from "../session/session-manager.js";
28
28
  import type { SettingsManager } from "../config/settings-manager.js";
29
+ import { type SlashCommandInfo } from "../slash-commands.js";
29
30
  import type { BashOperations } from "../tools/bash.js";
30
31
  /** Parsed skill block from a user message */
31
32
  export interface ParsedSkillBlock {
@@ -138,6 +139,11 @@ export interface SessionStats {
138
139
  };
139
140
  cost: number;
140
141
  }
142
+ export interface SessionSlashCommandDescriptor {
143
+ name: string;
144
+ description?: string;
145
+ source: "builtin" | SlashCommandInfo["source"];
146
+ }
141
147
  export declare class AgentSession {
142
148
  readonly agent: Agent;
143
149
  readonly sessionManager: SessionManager;
@@ -211,6 +217,11 @@ export declare class AgentSession {
211
217
  /** Model registry for API key resolution and model discovery */
212
218
  get modelRegistry(): ModelRegistry;
213
219
  get cwd(): string;
220
+ /**
221
+ * Return all currently available slash-like commands for the session.
222
+ * Includes built-in commands, extension commands, prompt templates, and skills.
223
+ */
224
+ getSlashCommands(): SessionSlashCommandDescriptor[];
214
225
  /** Emit an event to all listeners */
215
226
  private _emit;
216
227
  private _lastAssistantMessage;
@@ -219,6 +219,44 @@ export class AgentSession {
219
219
  get cwd() {
220
220
  return this._cwd;
221
221
  }
222
+ /**
223
+ * Return all currently available slash-like commands for the session.
224
+ * Includes built-in commands, extension commands, prompt templates, and skills.
225
+ */
226
+ getSlashCommands() {
227
+ const builtins = BUILTIN_SLASH_COMMANDS.map((command) => ({
228
+ name: command.name,
229
+ description: command.description,
230
+ source: "builtin",
231
+ }));
232
+ const reservedBuiltins = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
233
+ const extensionCommands = this._extensionRunner
234
+ ?.getRegisteredCommandsWithPaths()
235
+ .filter(({ command }) => !reservedBuiltins.has(command.name))
236
+ .map(({ command }) => ({
237
+ name: command.name,
238
+ description: command.description,
239
+ source: "extension",
240
+ })) ?? [];
241
+ const promptCommands = this.promptTemplates.map((template) => ({
242
+ name: template.name,
243
+ description: template.description,
244
+ source: "prompt",
245
+ }));
246
+ const skillCommands = this._resourceLoader
247
+ .getSkills()
248
+ .skills.map((skill) => ({
249
+ name: `skill:${skill.name}`,
250
+ description: skill.description,
251
+ source: "skill",
252
+ }));
253
+ return [
254
+ ...builtins,
255
+ ...extensionCommands,
256
+ ...promptCommands,
257
+ ...skillCommands,
258
+ ];
259
+ }
222
260
  // =========================================================================
223
261
  // Event Subscription
224
262
  // =========================================================================
@@ -80,7 +80,7 @@ export const toolGuidance = {
80
80
  ls: "列出目录内容。",
81
81
  };
82
82
  toolGuidance.time =
83
- "Get the real current system time. Use this for time-sensitive confirmations like current date/time, today/tomorrow, deadlines, and temporal reasoning.";
83
+ "Get the real current system time. You must use this for current time/date questions, today/tomorrow/yesterday, deadlines, schedules, or any temporal reasoning that depends on the live system clock.";
84
84
  /**
85
85
  * Get guidance for a specific tool
86
86
  */
@@ -34,7 +34,7 @@ export function createTimeTool() {
34
34
  return {
35
35
  name: "time",
36
36
  label: "time",
37
- description: "Get the current system time. Use this for time-sensitive questions such as 'what time is it', 'today', 'tomorrow', deadlines, or when you need to confirm the real current date/time instead of relying on prompt context.",
37
+ description: "Get the current system time. You must use this for time-sensitive questions such as 'what time is it', 'what day is it', 'today', 'tomorrow', 'yesterday', deadlines, schedules, elapsed time, or any request that depends on the real current date/time instead of prompt context.",
38
38
  parameters: timeSchema,
39
39
  execute: async (_toolCallId, { timeZone, locale }) => {
40
40
  return {
@@ -266,7 +266,7 @@ export default async function soulExtension(pi) {
266
266
  console.warn("[soul] Failed to initialize Soul manager.");
267
267
  return;
268
268
  }
269
- console.log("[soul] Soul extension loaded successfully.");
269
+ console.error("[soul] Soul extension loaded successfully.");
270
270
  // Register event handlers
271
271
  // agent_start: Initialize personality
272
272
  pi.on("agent_start", async (_event, _ctx) => {
@@ -243,9 +243,9 @@ export default async function exportHtmlExtension(pi) {
243
243
  // Export the session (use default output path)
244
244
  // Pass undefined for state since we don't have access to it here
245
245
  const filePath = await extExportSessionToHtml(sessionManager, undefined);
246
- console.log(`Session exported to: ${filePath}`);
246
+ console.error(`Session exported to: ${filePath}`);
247
247
  },
248
248
  });
249
- console.log("[export-html] Extension loaded");
249
+ console.error("[export-html] Extension loaded");
250
250
  }
251
251
  //# sourceMappingURL=index.js.map
package/dist/main.js CHANGED
@@ -695,7 +695,53 @@ export async function main(args) {
695
695
  }
696
696
  if (parsed.acp) {
697
697
  const { runAcpMode } = await import("./modes/acp/acp-mode.js");
698
- await runAcpMode(session);
698
+ const createAcpSessionForCwd = async (workspaceCwd) => {
699
+ const resolvedWorkspaceCwd = resolveWorkingDirectory(workspaceCwd);
700
+ const workspaceSettingsManager = SettingsManager.create(resolvedWorkspaceCwd, agentDir);
701
+ reportSettingsErrors(workspaceSettingsManager, "acp startup");
702
+ const workspaceResourceLoader = new DefaultResourceLoader({
703
+ cwd: resolvedWorkspaceCwd,
704
+ agentDir,
705
+ settingsManager: workspaceSettingsManager,
706
+ additionalExtensionPaths: [...defaultExtPaths, ...(parsed.extensions ?? [])],
707
+ additionalSkillPaths: parsed.skills,
708
+ additionalPromptTemplatePaths: parsed.promptTemplates,
709
+ additionalThemePaths: parsed.themes,
710
+ noExtensions: parsed.noExtensions,
711
+ noSkills: parsed.noSkills,
712
+ noPromptTemplates: parsed.noPromptTemplates,
713
+ noThemes: parsed.noThemes,
714
+ systemPrompt: parsed.systemPrompt,
715
+ appendSystemPrompt: parsed.appendSystemPrompt,
716
+ });
717
+ await workspaceResourceLoader.reload();
718
+ const workspaceExtensionsResult = workspaceResourceLoader.getExtensions();
719
+ for (const { path, error } of workspaceExtensionsResult.errors) {
720
+ console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
721
+ }
722
+ for (const { name, config } of workspaceExtensionsResult.runtime.pendingProviderRegistrations) {
723
+ modelRegistry.registerProvider(name, config);
724
+ }
725
+ workspaceExtensionsResult.runtime.pendingProviderRegistrations = [];
726
+ let workspaceScopedModels = [];
727
+ if (modelPatterns && modelPatterns.length > 0) {
728
+ workspaceScopedModels = await resolveModelScope(modelPatterns, modelRegistry);
729
+ }
730
+ const workspaceSessionManager = parsed.noSession
731
+ ? SessionManager.inMemory(resolvedWorkspaceCwd)
732
+ : SessionManager.create(resolvedWorkspaceCwd, parsed.sessionDir);
733
+ const { options: workspaceSessionOptions } = buildSessionOptions(parsed, workspaceScopedModels, workspaceSessionManager, modelRegistry, workspaceSettingsManager);
734
+ workspaceSessionOptions.cwd = resolvedWorkspaceCwd;
735
+ workspaceSessionOptions.authStorage = authStorage;
736
+ workspaceSessionOptions.modelRegistry = modelRegistry;
737
+ workspaceSessionOptions.resourceLoader = workspaceResourceLoader;
738
+ workspaceSessionOptions.settingsManager = workspaceSettingsManager;
739
+ workspaceSessionOptions.enableMCP = sessionOptions.enableMCP;
740
+ workspaceSessionOptions.enableSoul = sessionOptions.enableSoul;
741
+ const { session: workspaceSession } = await createAgentSession(workspaceSessionOptions);
742
+ return workspaceSession;
743
+ };
744
+ await runAcpMode(session, { createSessionForCwd: createAcpSessionForCwd });
699
745
  }
700
746
  else if (mode === "rpc") {
701
747
  await runRpcMode(session);
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * Used for integrating with ACP-compatible editors like Zed and JetBrains.
5
5
  * Communication via stdin/stdout using JSON-RPC 2.0 messages.
6
- *
7
- * Protocol:
8
- * - Client → Agent: initialize, session/new, session/prompt, session/cancel
9
- * - Agent → Client: session/update (streaming events), session/request_permission
10
6
  */
11
7
  import type { AgentSession } from "../../core/runtime/agent-session.js";
8
+ interface AcpModeOptions {
9
+ createSessionForCwd?: (cwd: string) => Promise<AgentSession>;
10
+ }
12
11
  /**
13
12
  * Run in ACP mode.
14
13
  * Listens for JSON-RPC 2.0 messages on stdin, outputs JSON-RPC responses/events on stdout.
15
14
  */
16
- export declare function runAcpMode(session: AgentSession): Promise<never>;
15
+ export declare function runAcpMode(session: AgentSession, options?: AcpModeOptions): Promise<never>;
16
+ export {};
17
17
  //# sourceMappingURL=acp-mode.d.ts.map
@@ -3,14 +3,30 @@
3
3
  *
4
4
  * Used for integrating with ACP-compatible editors like Zed and JetBrains.
5
5
  * Communication via stdin/stdout using JSON-RPC 2.0 messages.
6
- *
7
- * Protocol:
8
- * - Client → Agent: initialize, session/new, session/prompt, session/cancel
9
- * - Agent → Client: session/update (streaming events), session/request_permission
10
6
  */
11
7
  import * as acp from "@agentclientprotocol/sdk";
8
+ import { SessionManager } from "../../core/session/session-manager.js";
9
+ import { randomUUID } from "node:crypto";
12
10
  import { Readable, Writable } from "node:stream";
13
11
  import { theme } from "../interactive/theme/theme.js";
12
+ const ACP_MODES = [
13
+ {
14
+ id: "ask",
15
+ name: "Ask before write",
16
+ description: "Request permission before mutating files or running commands.",
17
+ },
18
+ {
19
+ id: "read-only",
20
+ name: "Read-only",
21
+ description: "Disable editing tools and command execution.",
22
+ },
23
+ {
24
+ id: "bypass",
25
+ name: "Bypass permissions",
26
+ description: "Allow normal coding actions without permission prompts.",
27
+ },
28
+ ];
29
+ const MUTATING_TOOL_NAMES = new Set(["edit", "write", "bash"]);
14
30
  /**
15
31
  * Map nanoPencil tool names to ACP tool kinds.
16
32
  */
@@ -34,16 +50,78 @@ function mapToolKind(toolName) {
34
50
  return "other";
35
51
  }
36
52
  }
53
+ function createMessageId() {
54
+ return randomUUID();
55
+ }
56
+ function textToContent(text) {
57
+ return { type: "text", text };
58
+ }
59
+ function asText(value) {
60
+ if (typeof value === "string")
61
+ return value;
62
+ try {
63
+ return JSON.stringify(value, null, 2);
64
+ }
65
+ catch {
66
+ return String(value);
67
+ }
68
+ }
69
+ function createSlashCommandsUpdate(session) {
70
+ const commands = session.getSlashCommands();
71
+ const seen = new Set();
72
+ return commands
73
+ .filter((command) => {
74
+ const key = command.name.toLowerCase();
75
+ if (seen.has(key))
76
+ return false;
77
+ seen.add(key);
78
+ return true;
79
+ })
80
+ .map((command) => ({
81
+ name: `/${command.name}`,
82
+ description: command.description ?? `Run /${command.name}`,
83
+ input: { hint: "Enter command arguments" },
84
+ }));
85
+ }
86
+ function getMessageText(message) {
87
+ if (!("content" in message) || !Array.isArray(message.content)) {
88
+ return "";
89
+ }
90
+ return message.content
91
+ .map((block) => {
92
+ if (typeof block === "string")
93
+ return block;
94
+ if ("type" in block && block.type === "text")
95
+ return block.text;
96
+ return "";
97
+ })
98
+ .filter((part) => part.length > 0)
99
+ .join("\n");
100
+ }
101
+ function isMutatingTool(tool) {
102
+ if (MUTATING_TOOL_NAMES.has(tool.name))
103
+ return true;
104
+ if (/^mcp_.*(?:use_figma|generate_figma_design)$/i.test(tool.name))
105
+ return true;
106
+ return false;
107
+ }
108
+ function unsupportedAcpUi(feature) {
109
+ throw new Error(`${feature} is not available in ACP mode yet. In Zed or other ACP clients, use argument-driven commands when available, or run this command in the terminal nanoPencil UI.`);
110
+ }
111
+ function formatMoney(value) {
112
+ return value.toFixed(4);
113
+ }
37
114
  /**
38
115
  * Create an extension UI context for ACP mode.
39
116
  * Returns silent defaults since ACP mode has no interactive UI.
40
117
  */
41
118
  function createAcpExtensionUIContext() {
42
119
  return {
43
- select: async () => undefined,
44
- confirm: async () => false,
45
- input: async () => undefined,
46
- editor: async () => undefined,
120
+ __nonInteractive: true,
121
+ select: async () => unsupportedAcpUi("Interactive selection"),
122
+ confirm: async () => unsupportedAcpUi("Interactive confirmation"),
123
+ input: async () => unsupportedAcpUi("Interactive text input"),
124
+ editor: async () => unsupportedAcpUi("Interactive editor"),
47
125
  notify(message, type) {
48
126
  process.stderr.write(`[${type ?? "info"}] ${message}\n`);
49
127
  },
@@ -58,7 +136,7 @@ function createAcpExtensionUIContext() {
58
136
  setEditorText() { },
59
137
  getEditorText: () => "",
60
138
  async custom() {
61
- return undefined;
139
+ return unsupportedAcpUi("Custom interactive UI");
62
140
  },
63
141
  onTerminalInput() {
64
142
  return () => { };
@@ -90,58 +168,178 @@ function createAcpExtensionUIContext() {
90
168
  class NanoPencilAgent {
91
169
  connection;
92
170
  session;
93
- activeSessions;
94
- constructor(connection, session) {
171
+ sessions = new Map();
172
+ currentSessionId;
173
+ createSessionForCwd;
174
+ extensionBindings;
175
+ ready;
176
+ constructor(connection, session, options = {}) {
95
177
  this.connection = connection;
96
178
  this.session = session;
97
- this.activeSessions = new Map();
179
+ this.createSessionForCwd = options.createSessionForCwd;
180
+ this.extensionBindings = {
181
+ uiContext: createAcpExtensionUIContext(),
182
+ commandContextActions: {
183
+ waitForIdle: () => this.session.agent.waitForIdle(),
184
+ newSession: async (options) => {
185
+ const success = await this.session.newSession(options);
186
+ return { cancelled: !success };
187
+ },
188
+ fork: async (entryId) => {
189
+ const result = await this.session.fork(entryId);
190
+ return { cancelled: result.cancelled };
191
+ },
192
+ navigateTree: async (targetId, options) => {
193
+ const result = await this.session.navigateTree(targetId, options);
194
+ return { cancelled: result.cancelled };
195
+ },
196
+ switchSession: async (sessionPath) => {
197
+ const success = await this.session.switchSession(sessionPath);
198
+ return { cancelled: !success };
199
+ },
200
+ reload: async () => {
201
+ await this.session.reload();
202
+ },
203
+ },
204
+ shutdownHandler: () => {
205
+ process.exit(0);
206
+ },
207
+ onError: (err) => {
208
+ process.stderr.write(`[extension_error] ${err.extensionPath}: ${err.error}\n`);
209
+ },
210
+ };
211
+ this.ready = this.bindSession(this.session);
98
212
  }
99
- async initialize(params) {
213
+ async initialize(_params) {
214
+ await this.ready;
100
215
  return {
101
216
  protocolVersion: acp.PROTOCOL_VERSION,
217
+ agentInfo: {
218
+ name: "nanoPencil",
219
+ version: "acp",
220
+ },
102
221
  agentCapabilities: {
103
- loadSession: false,
222
+ loadSession: true,
223
+ sessionCapabilities: {
224
+ list: {},
225
+ },
104
226
  },
105
227
  };
106
228
  }
107
229
  async newSession(params) {
108
- const sessionId = `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
109
- this.activeSessions.set(sessionId, { abortController: null });
110
- return { sessionId };
230
+ await this.ready;
231
+ await this.ensureWorkspaceSession(params.cwd);
232
+ await this.session.newSession();
233
+ const sessionId = this.session.sessionManager.getSessionId();
234
+ const state = this.createStateFromCurrentSession(sessionId, params.cwd);
235
+ this.sessions.set(sessionId, state);
236
+ this.currentSessionId = sessionId;
237
+ await this.applySessionMode(state);
238
+ await this.emitSessionMetadata(state);
239
+ await this.emitAvailableCommands(sessionId);
240
+ return {
241
+ sessionId,
242
+ models: this.buildModelState(),
243
+ modes: this.buildModeState(state.modeId),
244
+ };
245
+ }
246
+ async loadSession(params) {
247
+ await this.ready;
248
+ const info = await this.findSessionInfo(params.sessionId, params.cwd);
249
+ if (!info) {
250
+ throw new Error(`Session ${params.sessionId} not found`);
251
+ }
252
+ await this.ensureWorkspaceSession(info.cwd || params.cwd);
253
+ const switched = await this.session.switchSession(info.path);
254
+ if (!switched) {
255
+ throw new Error(`Failed to load session ${params.sessionId}`);
256
+ }
257
+ const existing = this.sessions.get(params.sessionId);
258
+ const state = {
259
+ sessionId: params.sessionId,
260
+ sessionFile: info.path,
261
+ cwd: info.cwd || params.cwd,
262
+ title: info.name || info.firstMessage || existing?.title,
263
+ modeId: existing?.modeId ?? "ask",
264
+ abortController: null,
265
+ allowAllMutations: existing?.allowAllMutations ?? false,
266
+ rejectAllMutations: existing?.rejectAllMutations ?? false,
267
+ };
268
+ this.sessions.set(params.sessionId, state);
269
+ this.currentSessionId = params.sessionId;
270
+ await this.applySessionMode(state);
271
+ await this.emitSessionMetadata(state);
272
+ await this.emitAvailableCommands(params.sessionId);
273
+ await this.replayHistory(params.sessionId);
274
+ return {
275
+ models: this.buildModelState(),
276
+ modes: this.buildModeState(state.modeId),
277
+ };
111
278
  }
112
- async authenticate(params) {
113
- // Authentication not implemented
279
+ async listSessions(params) {
280
+ await this.ready;
281
+ const infos = params.cwd
282
+ ? await SessionManager.list(params.cwd)
283
+ : await SessionManager.listAll();
284
+ const merged = new Map();
285
+ for (const info of infos) {
286
+ merged.set(info.id, {
287
+ sessionId: info.id,
288
+ cwd: info.cwd,
289
+ title: info.name || info.firstMessage || undefined,
290
+ updatedAt: info.modified.toISOString(),
291
+ });
292
+ }
293
+ for (const state of this.sessions.values()) {
294
+ if (params.cwd && state.cwd !== params.cwd)
295
+ continue;
296
+ if (!merged.has(state.sessionId)) {
297
+ merged.set(state.sessionId, {
298
+ sessionId: state.sessionId,
299
+ cwd: state.cwd,
300
+ title: state.title,
301
+ updatedAt: new Date().toISOString(),
302
+ });
303
+ }
304
+ }
305
+ return {
306
+ sessions: Array.from(merged.values()),
307
+ };
308
+ }
309
+ async authenticate(_params) {
310
+ await this.ready;
114
311
  return;
115
312
  }
116
313
  async prompt(params) {
117
- const { sessionId, prompt } = params;
118
- const sessionState = this.activeSessions.get(sessionId);
119
- if (!sessionState) {
120
- throw new Error(`Session ${sessionId} not found`);
121
- }
122
- // Abort any previous prompt
314
+ await this.ready;
315
+ const sessionState = this.requireSession(params.sessionId);
316
+ await this.activateSession(sessionState);
123
317
  sessionState.abortController?.abort();
124
318
  sessionState.abortController = new AbortController();
125
- // Extract text from prompt content blocks
126
- const userText = prompt
319
+ const userText = params.prompt
127
320
  .filter((block) => "text" in block && typeof block.text === "string")
128
321
  .map((block) => block.text)
129
322
  .join("\n");
130
- // Subscribe to events and forward as ACP session/update
323
+ const builtinHandled = await this.handleBuiltinSlashCommand(params.sessionId, sessionState, userText);
324
+ if (builtinHandled) {
325
+ return { stopReason: "end_turn" };
326
+ }
131
327
  const unsubscribe = this.session.subscribe((event) => {
132
- this.mapEventToAcp(sessionId, event);
328
+ this.mapEventToAcp(params.sessionId, event);
133
329
  });
134
330
  try {
135
331
  // @ts-expect-error - source is for internal use
136
332
  await this.session.prompt(userText, { source: "acp" });
333
+ await this.emitSessionMetadata(sessionState);
137
334
  return { stopReason: "end_turn" };
138
335
  }
139
336
  catch (error) {
140
337
  if (sessionState.abortController.signal.aborted) {
141
338
  return { stopReason: "cancelled" };
142
339
  }
143
- // For errors, return end_turn - the client should detect errors via other means
144
- process.stderr.write(`[error] ${error instanceof Error ? error.message : String(error)}\n`);
340
+ const message = error instanceof Error ? error.message : String(error);
341
+ process.stderr.write(`[error] ${message}\n`);
342
+ await this.sendAssistantText(params.sessionId, `Command failed: ${message}`);
145
343
  return { stopReason: "end_turn" };
146
344
  }
147
345
  finally {
@@ -150,15 +348,440 @@ class NanoPencilAgent {
150
348
  }
151
349
  }
152
350
  async cancel(params) {
153
- const sessionState = this.activeSessions.get(params.sessionId);
351
+ await this.ready;
352
+ const sessionState = this.sessions.get(params.sessionId);
154
353
  if (sessionState) {
354
+ await this.activateSession(sessionState);
155
355
  sessionState.abortController?.abort();
156
356
  await this.session.abort();
157
357
  }
158
358
  }
159
359
  async setSessionMode(params) {
160
- // Session mode not implemented
161
- return;
360
+ await this.ready;
361
+ const sessionState = this.requireSession(params.sessionId);
362
+ if (!ACP_MODES.some((mode) => mode.id === params.modeId)) {
363
+ throw new Error(`Unknown ACP mode: ${params.modeId}`);
364
+ }
365
+ sessionState.modeId = params.modeId;
366
+ sessionState.allowAllMutations = false;
367
+ sessionState.rejectAllMutations = false;
368
+ await this.activateSession(sessionState);
369
+ await this.connection.sessionUpdate({
370
+ sessionId: params.sessionId,
371
+ update: {
372
+ sessionUpdate: "current_mode_update",
373
+ currentModeId: sessionState.modeId,
374
+ },
375
+ });
376
+ return {};
377
+ }
378
+ async unstable_setSessionModel(params) {
379
+ await this.ready;
380
+ const sessionState = this.requireSession(params.sessionId);
381
+ await this.activateSession(sessionState);
382
+ const model = this.parseAcpModelId(params.modelId);
383
+ if (!model) {
384
+ throw new Error(`Unknown model: ${params.modelId}`);
385
+ }
386
+ await this.session.setModel(model);
387
+ await this.emitSessionMetadata(sessionState);
388
+ return {};
389
+ }
390
+ requireSession(sessionId) {
391
+ const state = this.sessions.get(sessionId);
392
+ if (!state) {
393
+ throw new Error(`Session ${sessionId} not found`);
394
+ }
395
+ return state;
396
+ }
397
+ createStateFromCurrentSession(sessionId, cwd) {
398
+ const sessionFile = this.session.sessionManager.getSessionFile();
399
+ return {
400
+ sessionId,
401
+ sessionFile,
402
+ cwd,
403
+ title: this.getCurrentSessionTitle(),
404
+ modeId: "ask",
405
+ abortController: null,
406
+ allowAllMutations: false,
407
+ rejectAllMutations: false,
408
+ };
409
+ }
410
+ getCurrentSessionTitle() {
411
+ return (this.session.sessionManager.getSessionName() ||
412
+ this.session.agent.state.messages.find((message) => message.role === "user")
413
+ ? getMessageText(this.session.agent.state.messages.find((message) => message.role === "user")).slice(0, 80)
414
+ : undefined);
415
+ }
416
+ async findSessionInfo(sessionId, cwd) {
417
+ const existing = this.sessions.get(sessionId);
418
+ if (existing?.sessionFile) {
419
+ return {
420
+ id: existing.sessionId,
421
+ path: existing.sessionFile,
422
+ cwd: existing.cwd,
423
+ name: existing.title,
424
+ firstMessage: "",
425
+ };
426
+ }
427
+ const cwdSessions = await SessionManager.list(cwd);
428
+ const direct = cwdSessions.find((info) => info.id === sessionId);
429
+ if (direct)
430
+ return direct;
431
+ const allSessions = await SessionManager.listAll();
432
+ return allSessions.find((info) => info.id === sessionId);
433
+ }
434
+ async findSessionByQuery(query, cwd) {
435
+ const trimmed = query.trim().toLowerCase();
436
+ if (!trimmed)
437
+ return undefined;
438
+ const cwdSessions = await SessionManager.list(cwd);
439
+ const allSessions = await SessionManager.listAll();
440
+ const candidates = [...cwdSessions, ...allSessions].filter((info, index, array) => array.findIndex((other) => other.id === info.id) === index);
441
+ return candidates.find((info) => {
442
+ const title = (info.name || info.firstMessage || "").toLowerCase();
443
+ return (info.id.toLowerCase() === trimmed ||
444
+ info.id.toLowerCase().startsWith(trimmed) ||
445
+ title.includes(trimmed));
446
+ });
447
+ }
448
+ buildModeState(currentModeId) {
449
+ return {
450
+ availableModes: ACP_MODES,
451
+ currentModeId,
452
+ };
453
+ }
454
+ buildModelState() {
455
+ const models = this.session.modelRegistry.getAvailable();
456
+ const current = this.session.model;
457
+ const availableModels = models.map((model) => ({
458
+ modelId: this.toAcpModelId(model),
459
+ name: model.name || `${model.provider}/${model.id}`,
460
+ description: `${model.provider} / ${model.id}`,
461
+ }));
462
+ return {
463
+ availableModels,
464
+ currentModelId: current ? this.toAcpModelId(current) : availableModels[0]?.modelId ?? "unknown/unknown",
465
+ };
466
+ }
467
+ toAcpModelId(model) {
468
+ return `${model.provider}/${model.id}`;
469
+ }
470
+ parseAcpModelId(modelId) {
471
+ const slashIndex = modelId.indexOf("/");
472
+ if (slashIndex === -1)
473
+ return undefined;
474
+ const provider = modelId.slice(0, slashIndex);
475
+ const id = modelId.slice(slashIndex + 1);
476
+ return this.session.modelRegistry.find(provider, id);
477
+ }
478
+ async emitAvailableCommands(sessionId) {
479
+ await this.connection.sessionUpdate({
480
+ sessionId,
481
+ update: {
482
+ sessionUpdate: "available_commands_update",
483
+ availableCommands: createSlashCommandsUpdate(this.session),
484
+ },
485
+ });
486
+ }
487
+ async emitSessionMetadata(state) {
488
+ state.title = this.getCurrentSessionTitle() ?? state.title;
489
+ await this.connection.sessionUpdate({
490
+ sessionId: state.sessionId,
491
+ update: {
492
+ sessionUpdate: "session_info_update",
493
+ title: state.title ?? null,
494
+ updatedAt: new Date().toISOString(),
495
+ },
496
+ });
497
+ }
498
+ async replayHistory(sessionId) {
499
+ for (const message of this.session.agent.state.messages) {
500
+ if (message.role !== "user" && message.role !== "assistant")
501
+ continue;
502
+ const text = getMessageText(message);
503
+ if (!text.trim())
504
+ continue;
505
+ await this.connection.sessionUpdate({
506
+ sessionId,
507
+ update: {
508
+ sessionUpdate: message.role === "user" ? "user_message_chunk" : "agent_message_chunk",
509
+ content: textToContent(text),
510
+ messageId: createMessageId(),
511
+ },
512
+ });
513
+ }
514
+ }
515
+ async activateSession(state) {
516
+ await this.ensureWorkspaceSession(state.cwd);
517
+ const currentFile = this.session.sessionManager.getSessionFile();
518
+ if (state.sessionFile && state.sessionFile !== currentFile) {
519
+ const switched = await this.session.switchSession(state.sessionFile);
520
+ if (!switched) {
521
+ throw new Error(`Failed to switch to session ${state.sessionId}`);
522
+ }
523
+ }
524
+ this.currentSessionId = state.sessionId;
525
+ await this.applySessionMode(state);
526
+ }
527
+ async bindSession(session) {
528
+ await session.bindExtensions(this.extensionBindings);
529
+ }
530
+ async ensureWorkspaceSession(cwd) {
531
+ if (!cwd || this.session.cwd === cwd || !this.createSessionForCwd) {
532
+ return;
533
+ }
534
+ await this.session.extensionRunner?.emit({ type: "session_shutdown" });
535
+ const nextSession = await this.createSessionForCwd(cwd);
536
+ this.session = nextSession;
537
+ await this.bindSession(nextSession);
538
+ }
539
+ async handleBuiltinSlashCommand(sessionId, state, text) {
540
+ const trimmed = text.trim();
541
+ if (!trimmed.startsWith("/"))
542
+ return false;
543
+ if (trimmed === "/new") {
544
+ await this.session.newSession();
545
+ state.sessionFile = this.session.sessionManager.getSessionFile();
546
+ state.title = this.getCurrentSessionTitle();
547
+ await this.emitSessionMetadata(state);
548
+ await this.sendAssistantText(sessionId, "Started a new session.");
549
+ return true;
550
+ }
551
+ if (trimmed === "/reload") {
552
+ await this.session.reload();
553
+ await this.activateSession(state);
554
+ await this.emitAvailableCommands(sessionId);
555
+ await this.sendAssistantText(sessionId, "Reloaded extensions, skills, prompts, and themes.");
556
+ return true;
557
+ }
558
+ if (trimmed === "/session") {
559
+ const stats = this.session.getSessionStats();
560
+ const context = this.session.getContextUsage();
561
+ const current = this.session.model;
562
+ const lines = [
563
+ `Session ID: ${stats.sessionId}`,
564
+ `Session name: ${this.session.sessionManager.getSessionName() || "(unnamed)"}`,
565
+ `Session file: ${stats.sessionFile ?? "(in-memory)"}`,
566
+ `Model: ${current ? `${current.provider}/${current.id}` : "none"}`,
567
+ `Thinking: ${this.session.thinkingLevel}`,
568
+ `Messages: ${stats.totalMessages} total (${stats.userMessages} user, ${stats.assistantMessages} assistant, ${stats.toolResults} tool results)`,
569
+ `Tool calls: ${stats.toolCalls}`,
570
+ ];
571
+ if (context) {
572
+ lines.push(`Context: ${context.tokens ?? "unknown"} / ${context.contextWindow} tokens${context.percent != null ? ` (${context.percent.toFixed(1)}%)` : ""}`);
573
+ }
574
+ await this.sendAssistantText(sessionId, lines.join("\n"));
575
+ return true;
576
+ }
577
+ if (trimmed === "/usage") {
578
+ const stats = this.session.getSessionStats();
579
+ const lines = [
580
+ "Usage summary",
581
+ `Input tokens: ${stats.tokens.input}`,
582
+ `Output tokens: ${stats.tokens.output}`,
583
+ `Cache read: ${stats.tokens.cacheRead}`,
584
+ `Cache write: ${stats.tokens.cacheWrite}`,
585
+ `Total tokens: ${stats.tokens.total}`,
586
+ `Estimated cost: $${formatMoney(stats.cost)}`,
587
+ ];
588
+ await this.sendAssistantText(sessionId, lines.join("\n"));
589
+ return true;
590
+ }
591
+ if (trimmed === "/name" || trimmed.startsWith("/name ")) {
592
+ const arg = trimmed.slice("/name".length).trim();
593
+ if (!arg) {
594
+ await this.sendAssistantText(sessionId, `Current session name: ${this.session.sessionManager.getSessionName() || "(unnamed)"}`);
595
+ return true;
596
+ }
597
+ this.session.sessionManager.appendSessionInfo(arg);
598
+ state.title = arg;
599
+ await this.emitSessionMetadata(state);
600
+ await this.sendAssistantText(sessionId, `Session name set to "${arg}".`);
601
+ return true;
602
+ }
603
+ if (trimmed === "/resume" || trimmed.startsWith("/resume ")) {
604
+ const arg = trimmed.slice("/resume".length).trim();
605
+ if (!arg) {
606
+ const sessions = await SessionManager.list(state.cwd);
607
+ const summary = sessions
608
+ .slice(0, 10)
609
+ .map((info) => `- ${info.id} | ${info.name || info.firstMessage || "(untitled)"}`)
610
+ .join("\n");
611
+ await this.sendAssistantText(sessionId, summary
612
+ ? `Recent sessions for this workspace:\n${summary}\n\nUse /resume <session-id-or-title>.`
613
+ : "No saved sessions were found for this workspace.");
614
+ return true;
615
+ }
616
+ const target = await this.findSessionByQuery(arg, state.cwd);
617
+ if (!target) {
618
+ await this.sendAssistantText(sessionId, `No saved session matched "${arg}". Use /resume with no arguments to list recent sessions.`);
619
+ return true;
620
+ }
621
+ const switched = await this.session.switchSession(target.path);
622
+ if (!switched) {
623
+ await this.sendAssistantText(sessionId, `Failed to load session ${target.id}.`);
624
+ return true;
625
+ }
626
+ state.sessionFile = target.path;
627
+ state.cwd = target.cwd;
628
+ state.title = target.name || target.firstMessage || state.title;
629
+ await this.emitSessionMetadata(state);
630
+ await this.emitAvailableCommands(sessionId);
631
+ await this.sendAssistantText(sessionId, `Resumed session ${target.id}${state.title ? ` (${state.title})` : ""}. Future turns now use that saved conversation context.`);
632
+ return true;
633
+ }
634
+ if (trimmed === "/thinking" || trimmed.startsWith("/thinking ")) {
635
+ const arg = trimmed.slice("/thinking".length).trim().toLowerCase();
636
+ if (!arg) {
637
+ const levels = this.session.getAvailableThinkingLevels().join(", ");
638
+ await this.sendAssistantText(sessionId, `Current thinking level: ${this.session.thinkingLevel}\nAvailable levels: ${levels}`);
639
+ return true;
640
+ }
641
+ const levels = this.session.getAvailableThinkingLevels();
642
+ if (!levels.includes(arg)) {
643
+ await this.sendAssistantText(sessionId, `Unknown thinking level: ${arg}\nAvailable levels: ${levels.join(", ")}`);
644
+ return true;
645
+ }
646
+ this.session.setThinkingLevel(arg);
647
+ await this.sendAssistantText(sessionId, `Thinking level set to ${this.session.thinkingLevel}.`);
648
+ return true;
649
+ }
650
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
651
+ const arg = trimmed.slice("/model".length).trim();
652
+ if (!arg) {
653
+ const current = this.session.model;
654
+ const summary = this.session.modelRegistry
655
+ .getAvailable()
656
+ .slice(0, 20)
657
+ .map((model) => `- ${model.provider}/${model.id}`)
658
+ .join("\n");
659
+ await this.sendAssistantText(sessionId, `Current model: ${current ? `${current.provider}/${current.id}` : "none"}\nAvailable models:\n${summary}`);
660
+ return true;
661
+ }
662
+ const model = this.findExactModelMatch(arg);
663
+ if (!model) {
664
+ const matches = this.session.modelRegistry
665
+ .getAvailable()
666
+ .filter((candidate) => {
667
+ const full = `${candidate.provider}/${candidate.id}`.toLowerCase();
668
+ return full.includes(arg.toLowerCase()) || candidate.id.toLowerCase().includes(arg.toLowerCase());
669
+ })
670
+ .slice(0, 10)
671
+ .map((candidate) => `- ${candidate.provider}/${candidate.id}`)
672
+ .join("\n");
673
+ await this.sendAssistantText(sessionId, matches
674
+ ? `No exact model match for "${arg}". Closest matches:\n${matches}`
675
+ : `No model match for "${arg}".`);
676
+ return true;
677
+ }
678
+ await this.session.setModel(model);
679
+ await this.sendAssistantText(sessionId, `Model switched to ${model.provider}/${model.id}.`);
680
+ return true;
681
+ }
682
+ if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
683
+ const instructions = trimmed.slice("/compact".length).trim() || undefined;
684
+ const result = await this.session.compact(instructions);
685
+ await this.sendAssistantText(sessionId, `Compaction completed.\nFirst kept entry: ${result.firstKeptEntryId}\nTokens before: ${result.tokensBefore}`);
686
+ return true;
687
+ }
688
+ return false;
689
+ }
690
+ findExactModelMatch(searchTerm) {
691
+ const term = searchTerm.trim().toLowerCase();
692
+ if (!term)
693
+ return undefined;
694
+ let provider;
695
+ let modelId = term;
696
+ if (term.includes("/")) {
697
+ const [rawProvider, rawModelId] = term.split("/", 2);
698
+ provider = rawProvider?.trim();
699
+ modelId = rawModelId?.trim() ?? "";
700
+ }
701
+ if (!modelId)
702
+ return undefined;
703
+ return this.session.modelRegistry.getAvailable().find((model) => {
704
+ if (provider && model.provider.toLowerCase() !== provider)
705
+ return false;
706
+ return model.id.toLowerCase() === modelId;
707
+ });
708
+ }
709
+ async sendAssistantText(sessionId, text) {
710
+ await this.connection.sessionUpdate({
711
+ sessionId,
712
+ update: {
713
+ sessionUpdate: "agent_message_chunk",
714
+ content: textToContent(text),
715
+ messageId: createMessageId(),
716
+ },
717
+ });
718
+ }
719
+ async applySessionMode(state) {
720
+ const allToolNames = this.session.getAllTools().map((tool) => tool.name);
721
+ if (state.modeId === "read-only") {
722
+ const readOnlyToolNames = allToolNames.filter((name) => ["read", "grep", "find", "ls", "time"].includes(name));
723
+ this.session.setActiveToolsByName(readOnlyToolNames);
724
+ return;
725
+ }
726
+ this.session.setActiveToolsByName(allToolNames);
727
+ if (state.modeId === "ask") {
728
+ const wrapped = this.wrapToolsForAskMode(this.session.agent.state.tools, state);
729
+ this.session.agent.setTools(wrapped);
730
+ }
731
+ }
732
+ wrapToolsForAskMode(tools, state) {
733
+ return tools.map((tool) => {
734
+ if (!isMutatingTool(tool))
735
+ return tool;
736
+ return {
737
+ ...tool,
738
+ execute: async (toolCallId, params, signal, onUpdate) => {
739
+ await this.requestPermissionIfNeeded(state, tool, toolCallId, params);
740
+ return tool.execute(toolCallId, params, signal, onUpdate);
741
+ },
742
+ };
743
+ });
744
+ }
745
+ async requestPermissionIfNeeded(state, tool, toolCallId, params) {
746
+ if (state.allowAllMutations)
747
+ return;
748
+ if (state.rejectAllMutations) {
749
+ throw new Error("Permission denied for mutating tools in this session.");
750
+ }
751
+ const options = [
752
+ { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
753
+ { optionId: "allow_always", name: "Always allow", kind: "allow_always" },
754
+ { optionId: "reject_once", name: "Reject once", kind: "reject_once" },
755
+ { optionId: "reject_always", name: "Always reject", kind: "reject_always" },
756
+ ];
757
+ const response = await this.connection.requestPermission({
758
+ sessionId: state.sessionId,
759
+ options,
760
+ toolCall: {
761
+ toolCallId,
762
+ title: `Run ${tool.name}`,
763
+ kind: mapToolKind(tool.name),
764
+ status: "pending",
765
+ locations: [],
766
+ rawInput: params,
767
+ },
768
+ });
769
+ if (response.outcome.outcome === "cancelled") {
770
+ throw new Error("Permission request was cancelled.");
771
+ }
772
+ switch (response.outcome.optionId) {
773
+ case "allow_once":
774
+ return;
775
+ case "allow_always":
776
+ state.allowAllMutations = true;
777
+ return;
778
+ case "reject_always":
779
+ state.rejectAllMutations = true;
780
+ throw new Error("Permission denied for mutating tools in this session.");
781
+ case "reject_once":
782
+ default:
783
+ throw new Error("Permission denied for this tool call.");
784
+ }
162
785
  }
163
786
  /**
164
787
  * Map nanoPencil AgentSessionEvent to ACP session/update notifications.
@@ -169,29 +792,30 @@ class NanoPencilAgent {
169
792
  const sub = event.assistantMessageEvent;
170
793
  switch (sub.type) {
171
794
  case "text_delta":
172
- this.connection.sessionUpdate({
795
+ void this.connection.sessionUpdate({
173
796
  sessionId,
174
797
  update: {
175
798
  sessionUpdate: "agent_message_chunk",
176
- content: { type: "text", text: sub.delta },
799
+ content: textToContent(sub.delta),
800
+ messageId: createMessageId(),
177
801
  },
178
802
  });
179
803
  break;
180
804
  case "thinking_delta":
181
- this.connection.sessionUpdate({
805
+ void this.connection.sessionUpdate({
182
806
  sessionId,
183
807
  update: {
184
808
  sessionUpdate: "agent_thought_chunk",
185
- content: { type: "text", text: sub.delta },
809
+ content: textToContent(sub.delta),
810
+ messageId: createMessageId(),
186
811
  },
187
812
  });
188
813
  break;
189
- // toolcall_start, toolcall_end, etc. are handled by tool_execution_* events
190
814
  }
191
815
  break;
192
816
  }
193
817
  case "tool_execution_start":
194
- this.connection.sessionUpdate({
818
+ void this.connection.sessionUpdate({
195
819
  sessionId,
196
820
  update: {
197
821
  sessionUpdate: "tool_call",
@@ -200,11 +824,12 @@ class NanoPencilAgent {
200
824
  kind: mapToolKind(event.toolName),
201
825
  status: "pending",
202
826
  locations: [],
827
+ rawInput: event.args,
203
828
  },
204
829
  });
205
830
  break;
206
831
  case "tool_execution_end":
207
- this.connection.sessionUpdate({
832
+ void this.connection.sessionUpdate({
208
833
  sessionId,
209
834
  update: {
210
835
  sessionUpdate: "tool_call_update",
@@ -215,16 +840,14 @@ class NanoPencilAgent {
215
840
  type: "content",
216
841
  content: {
217
842
  type: "text",
218
- text: typeof event.result === "string"
219
- ? event.result
220
- : JSON.stringify(event.result, null, 2),
843
+ text: asText(event.result),
221
844
  },
222
845
  },
223
846
  ],
847
+ rawOutput: event.result,
224
848
  },
225
849
  });
226
850
  break;
227
- // agent_start, agent_end, turn_start, turn_end, etc. don't need mapping
228
851
  }
229
852
  }
230
853
  }
@@ -232,44 +855,12 @@ class NanoPencilAgent {
232
855
  * Run in ACP mode.
233
856
  * Listens for JSON-RPC 2.0 messages on stdin, outputs JSON-RPC responses/events on stdout.
234
857
  */
235
- export async function runAcpMode(session) {
236
- // Bind extensions with headless UI context
237
- await session.bindExtensions({
238
- uiContext: createAcpExtensionUIContext(),
239
- commandContextActions: {
240
- waitForIdle: () => session.agent.waitForIdle(),
241
- newSession: async (options) => {
242
- const success = await session.newSession(options);
243
- return { cancelled: !success };
244
- },
245
- fork: async (entryId) => {
246
- const result = await session.fork(entryId);
247
- return { cancelled: result.cancelled };
248
- },
249
- navigateTree: async (targetId, options) => {
250
- const result = await session.navigateTree(targetId, options);
251
- return { cancelled: result.cancelled };
252
- },
253
- switchSession: async (sessionPath) => {
254
- const success = await session.switchSession(sessionPath);
255
- return { cancelled: !success };
256
- },
257
- reload: async () => {
258
- await session.reload();
259
- },
260
- },
261
- shutdownHandler: () => {
262
- process.exit(0);
263
- },
264
- onError: (err) => {
265
- process.stderr.write(`[extension_error] ${err.extensionPath}: ${err.error}\n`);
266
- },
267
- });
858
+ export async function runAcpMode(session, options = {}) {
268
859
  // Set up ACP connection via stdin/stdout
269
860
  const input = Writable.toWeb(process.stdout);
270
861
  const output = Readable.toWeb(process.stdin);
271
862
  const stream = acp.ndJsonStream(input, output);
272
- new acp.AgentSideConnection((conn) => new NanoPencilAgent(conn, session), stream);
863
+ new acp.AgentSideConnection((conn) => new NanoPencilAgent(conn, session, options), stream);
273
864
  // Keep process alive
274
865
  return new Promise(() => { });
275
866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pencil-agent/nano-pencil",
3
- "version": "1.11.13",
3
+ "version": "1.11.15",
4
4
  "description": "CLI writing agent with read, bash, edit, write tools and session management. Based on pi; supports DashScope Coding Plan. Soul enabled by default for AI personality evolution.",
5
5
  "type": "module",
6
6
  "bin": {