@oyasmi/pipiclaw 0.2.0 → 0.2.1

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/dist/agent.d.ts CHANGED
@@ -13,8 +13,4 @@ export interface AgentRunner {
13
13
  abort(): void;
14
14
  }
15
15
  export declare function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner;
16
- /**
17
- * Translate container path back to host path for file operations
18
- */
19
- export declare function translateToHostPath(containerPath: string, channelDir: string, workspacePath: string, channelId: string): string;
20
16
  //# sourceMappingURL=agent.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAEvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/C,MAAM,WAAW,WAAW;IAC3B,GAAG,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvG,oBAAoB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,KAAK,IAAI,IAAI,CAAC;CACd;AAqfD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH;AAqnBD;;GAEG;AACH,wBAAgB,mBAAmB,CAClC,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GACf,MAAM,CAWR","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport { type Api, getModel, type Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tformatSkillsForPrompt,\n\tloadSkillsFromDir,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { PipiclawSettingsManager, syncLogToSessionManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// Default model - will be overridden by ModelRegistry if custom models are configured\nconst defaultModel = getModel(\"anthropic\", \"claude-sonnet-4-5\");\n\nexport interface AgentRunner {\n\trun(ctx: DingTalkContext, store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }>;\n\thandleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;\n\tqueueSteer(text: string): Promise<void>;\n\tqueueFollowUp(text: string): Promise<void>;\n\tabort(): void;\n}\n\ntype FinalOutcome = { kind: \"none\" } | { kind: \"silent\" } | { kind: \"final\"; text: string };\n\nfunction isSilentOutcome(outcome: FinalOutcome): outcome is { kind: \"silent\" } {\n\treturn outcome.kind === \"silent\";\n}\n\nfunction isFinalOutcome(outcome: FinalOutcome): outcome is { kind: \"final\"; text: string } {\n\treturn outcome.kind === \"final\";\n}\n\nfunction getFinalOutcomeText(outcome: FinalOutcome): string | null {\n\treturn isFinalOutcome(outcome) ? outcome.text : null;\n}\n\nfunction formatModelReference(model: Model<Api>): string {\n\treturn `${model.provider}/${model.id}`;\n}\n\nfunction findExactModelReferenceMatch(\n\tmodelReference: string,\n\tavailableModels: Model<Api>[],\n): { match?: Model<Api>; ambiguous: boolean } {\n\tconst trimmedReference = modelReference.trim();\n\tif (!trimmedReference) {\n\t\treturn { ambiguous: false };\n\t}\n\n\tconst normalizedReference = trimmedReference.toLowerCase();\n\n\tconst canonicalMatches = availableModels.filter(\n\t\t(model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,\n\t);\n\tif (canonicalMatches.length === 1) {\n\t\treturn { match: canonicalMatches[0], ambiguous: false };\n\t}\n\tif (canonicalMatches.length > 1) {\n\t\treturn { ambiguous: true };\n\t}\n\n\tconst slashIndex = trimmedReference.indexOf(\"/\");\n\tif (slashIndex !== -1) {\n\t\tconst provider = trimmedReference.substring(0, slashIndex).trim();\n\t\tconst modelId = trimmedReference.substring(slashIndex + 1).trim();\n\t\tif (provider && modelId) {\n\t\t\tconst providerMatches = availableModels.filter(\n\t\t\t\t(model) =>\n\t\t\t\t\tmodel.provider.toLowerCase() === provider.toLowerCase() &&\n\t\t\t\t\tmodel.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatches.length === 1) {\n\t\t\t\treturn { match: providerMatches[0], ambiguous: false };\n\t\t\t}\n\t\t\tif (providerMatches.length > 1) {\n\t\t\t\treturn { ambiguous: true };\n\t\t\t}\n\t\t}\n\t}\n\n\tconst idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);\n\tif (idMatches.length === 1) {\n\t\treturn { match: idMatches[0], ambiguous: false };\n\t}\n\n\treturn { ambiguous: idMatches.length > 1 };\n}\n\nfunction formatModelList(models: Model<Api>[], currentModel: Model<Api> | undefined, limit: number = 20): string {\n\tconst refs = models\n\t\t.slice()\n\t\t.sort((a, b) => formatModelReference(a).localeCompare(formatModelReference(b)))\n\t\t.map((model) => {\n\t\t\tconst ref = formatModelReference(model);\n\t\t\tconst marker =\n\t\t\t\tcurrentModel && currentModel.provider === model.provider && currentModel.id === model.id\n\t\t\t\t\t? \" (current)\"\n\t\t\t\t\t: \"\";\n\t\t\treturn `- \\`${ref}\\`${marker}`;\n\t\t});\n\n\tif (refs.length <= limit) {\n\t\treturn refs.join(\"\\n\");\n\t}\n\n\treturn `${refs.slice(0, limit).join(\"\\n\")}\\n- ... and ${refs.length - limit} more`;\n}\n\nfunction resolveInitialModel(modelRegistry: ModelRegistry, settingsManager: PipiclawSettingsManager): Model<Api> {\n\tconst savedProvider = settingsManager.getDefaultProvider();\n\tconst savedModelId = settingsManager.getDefaultModel();\n\tconst availableModels = modelRegistry.getAvailable();\n\tif (savedProvider && savedModelId) {\n\t\tconst savedModel = modelRegistry.find(savedProvider, savedModelId);\n\t\tif (\n\t\t\tsavedModel &&\n\t\t\tavailableModels.some((model) => model.provider === savedModel.provider && model.id === savedModel.id)\n\t\t) {\n\t\t\treturn savedModel;\n\t\t}\n\t}\n\n\tif (availableModels.length > 0) {\n\t\treturn availableModels[0];\n\t}\n\n\treturn defaultModel;\n}\n\nasync function getApiKeyForModel(modelRegistry: ModelRegistry, model: any): Promise<string> {\n\tconst key = await modelRegistry.getApiKeyForProvider(model.provider);\n\tif (key) return key;\n\t// Fallback: try anthropic env var\n\tconst envKey = process.env.ANTHROPIC_API_KEY;\n\tif (envKey) return envKey;\n\tthrow new Error(\n\t\t`No API key found for provider: ${model.provider}.\\n\\n` +\n\t\t\t\"Configure API key in ~/.pi/agent/models.json or set ANTHROPIC_API_KEY environment variable.\",\n\t);\n}\n\n// ============================================================================\n// Configuration file loaders: SOUL.md, AGENTS.md, MEMORY.md\n// ============================================================================\n\n/**\n * Load SOUL.md — defines the agent's identity, personality, and communication style.\n * Only loaded from workspace root (global).\n */\nfunction getSoul(workspaceDir: string): string {\n\tconst soulPath = join(workspaceDir, \"SOUL.md\");\n\tif (existsSync(soulPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(soulPath, \"utf-8\").trim();\n\t\t\tif (content) return content;\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read SOUL.md\", `${soulPath}: ${error}`);\n\t\t}\n\t}\n\treturn \"\";\n}\n\n/**\n * Load AGENTS.md — defines the agent's behavior instructions, capabilities, and constraints.\n * Supports both global (workspace root) and channel-level override.\n */\nfunction getAgentConfig(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level AGENTS.md (global)\n\tconst workspaceAgentPath = join(channelDir, \"..\", \"AGENTS.md\");\n\tif (existsSync(workspaceAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace AGENTS.md\", `${workspaceAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific AGENTS.md (overrides/extends global)\n\tconst channelAgentPath = join(channelDir, \"AGENTS.md\");\n\tif (existsSync(channelAgentPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelAgentPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(content);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel AGENTS.md\", `${channelAgentPath}: ${error}`);\n\t\t}\n\t}\n\n\treturn parts.join(\"\\n\\n\");\n}\n\nfunction getMemory(channelDir: string): string {\n\tconst parts: string[] = [];\n\n\t// Read workspace-level memory (shared across all channels)\n\tconst workspaceMemoryPath = join(channelDir, \"..\", \"MEMORY.md\");\n\tif (existsSync(workspaceMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(workspaceMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Global Workspace Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read workspace memory\", `${workspaceMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\t// Read channel-specific memory\n\tconst channelMemoryPath = join(channelDir, \"MEMORY.md\");\n\tif (existsSync(channelMemoryPath)) {\n\t\ttry {\n\t\t\tconst content = readFileSync(channelMemoryPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tparts.push(`### Channel-Specific Memory\\n${content}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to read channel memory\", `${channelMemoryPath}: ${error}`);\n\t\t}\n\t}\n\n\tif (parts.length === 0) {\n\t\treturn \"(no working memory yet)\";\n\t}\n\n\tconst combined = parts.join(\"\\n\\n\");\n\n\t// Warn if memory is getting too large (consumes system prompt token budget)\n\tif (combined.length > 5000) {\n\t\treturn `\\u26a0\\ufe0f Memory is large (${combined.length} chars). Consolidate: remove outdated entries, merge duplicates, tighten descriptions.\\n\\n${combined}`;\n\t}\n\n\treturn combined;\n}\n\nfunction loadPipiclawSkills(channelDir: string, workspacePath: string): Skill[] {\n\tconst skillMap = new Map<string, Skill>();\n\tconst hostWorkspacePath = join(channelDir, \"..\");\n\n\tconst translatePath = (hostPath: string): string => {\n\t\tif (hostPath.startsWith(hostWorkspacePath)) {\n\t\t\treturn workspacePath + hostPath.slice(hostWorkspacePath.length);\n\t\t}\n\t\treturn hostPath;\n\t};\n\n\t// Load workspace-level skills (global)\n\tconst workspaceSkillsDir = join(hostWorkspacePath, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: \"workspace\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\t// Load channel-specific skills\n\tconst channelSkillsDir = join(channelDir, \"skills\");\n\tfor (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: \"channel\" }).skills) {\n\t\tskill.filePath = translatePath(skill.filePath);\n\t\tskill.baseDir = translatePath(skill.baseDir);\n\t\tskillMap.set(skill.name, skill);\n\t}\n\n\treturn Array.from(skillMap.values());\n}\n\n// ============================================================================\n// System Prompt Builder\n// ============================================================================\n\nfunction buildSystemPrompt(\n\tworkspacePath: string,\n\tchannelId: string,\n\tsoul: string,\n\tagentConfig: string,\n\tmemory: string,\n\tsandboxConfig: SandboxConfig,\n\tskills: Skill[],\n): string {\n\tconst channelPath = `${workspacePath}/${channelId}`;\n\tconst isDocker = sandboxConfig.type === \"docker\";\n\n\tconst envDescription = isDocker\n\t\t? `You are running inside a Docker container (Alpine Linux).\n- Bash working directory: / (use cd or absolute paths)\n- Install tools with: apk add <package>\n- Your changes persist across sessions`\n\t\t: `You are running directly on the host machine.\n- Bash working directory: ${process.cwd()}\n- Be careful with system modifications`;\n\n\t// Build system prompt with configuration file layering:\n\t// 1. SOUL.md (identity/personality)\n\t// 2. Core instructions\n\t// 3. AGENTS.md (behavior instructions)\n\t// 4. Skills, Events, Memory\n\n\tconst sections: string[] = [];\n\n\t// 1. SOUL.md — Agent identity\n\tif (soul) {\n\t\tsections.push(soul);\n\t} else {\n\t\tsections.push(\"You are a DingTalk bot assistant. Be concise and helpful.\");\n\t}\n\n\t// 2. Core instructions\n\tsections.push(`## Context\n- For current date/time, use: date\n- You have access to previous conversation context including tool results from prior turns.\n- For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).\n\n## Formatting\nUse Markdown for formatting. DingTalk AI Card supports basic Markdown:\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`code\\`\\`\\`, Links: [text](url)\n\n## Environment\n${envDescription}\n\n## Workspace Layout\n${workspacePath}/\n├── SOUL.md # Your identity/personality (read-only)\n├── AGENTS.md # Custom behavior instructions (read-only)\n├── MEMORY.md # Global memory (all channels, you can read/write)\n├── skills/ # Global CLI tools you create\n├── events/ # Scheduled events\n└── ${channelId}/ # This channel\n ├── AGENTS.md # Channel-specific instructions (read-only)\n ├── MEMORY.md # Channel-specific memory (you can read/write)\n ├── log.jsonl # Message history (no tool results)\n ├── scratch/ # Your working directory\n └── skills/ # Channel-specific tools`);\n\n\t// 3. AGENTS.md — User-defined instructions\n\tif (agentConfig) {\n\t\tsections.push(`## Agent Instructions\\n${agentConfig}`);\n\t}\n\n\t// 4. Skills\n\tsections.push(`## Skills (Custom CLI Tools)\nYou can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).\n\n### Creating Skills\nStore in \\`${workspacePath}/skills/<name>/\\` (global) or \\`${channelPath}/skills/<name>/\\` (channel-specific).\nEach skill directory needs a \\`SKILL.md\\` with YAML frontmatter:\n\n\\`\\`\\`markdown\n---\nname: skill-name\ndescription: Short description of what this skill does\n---\n\n# Skill Name\n\nUsage instructions, examples, etc.\nScripts are in: {baseDir}/\n\\`\\`\\`\n\n\\`name\\` and \\`description\\` are required. Use \\`{baseDir}\\` as placeholder for the skill's directory path.\n\n### Available Skills\n${skills.length > 0 ? formatSkillsForPrompt(skills) : \"(no skills installed yet)\"}`);\n\n\t// 5. Events\n\tsections.push(`## Events\nYou can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \\`${workspacePath}/events/\\`.\n\n### Event Types\n\n**Immediate** - Triggers as soon as harness sees the file.\n\\`\\`\\`json\n{\"type\": \"immediate\", \"channelId\": \"${channelId}\", \"text\": \"New event occurred\"}\n\\`\\`\\`\n\n**One-shot** - Triggers once at a specific time.\n\\`\\`\\`json\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder\", \"at\": \"2025-12-15T09:00:00+08:00\"}\n\\`\\`\\`\n\n**Periodic** - Triggers on a cron schedule.\n\\`\\`\\`json\n{\"type\": \"periodic\", \"channelId\": \"${channelId}\", \"text\": \"Check inbox\", \"schedule\": \"0 9 * * 1-5\", \"timezone\": \"${Intl.DateTimeFormat().resolvedOptions().timeZone}\"}\n\\`\\`\\`\n\n### Cron Format\n\\`minute hour day-of-month month day-of-week\\`\n\n### Creating Events\n\\`\\`\\`bash\ncat > ${workspacePath}/events/reminder-$(date +%s).json << 'EOF'\n{\"type\": \"one-shot\", \"channelId\": \"${channelId}\", \"text\": \"Reminder text\", \"at\": \"2025-12-14T09:00:00+08:00\"}\nEOF\n\\`\\`\\`\n\n### Silent Completion\nFor periodic events where there's nothing to report, respond with just \\`[SILENT]\\`. This deletes the status message. Use this to avoid spam when periodic checks find nothing.\n\n### Limits\nMaximum 5 events can be queued.`);\n\n\t// 6. Memory\n\tsections.push(`## Memory\nWrite to MEMORY.md files to persist context across conversations.\n- Global (${workspacePath}/MEMORY.md): skills, preferences, project info\n- Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work\n\n### Guidelines\n- Keep each MEMORY.md concise (target: under 50 lines)\n- Use clear headers to organize entries (## Preferences, ## Projects, etc.)\n- Remove outdated entries when they are no longer relevant\n- Merge duplicate or redundant items\n- Prefer structured formats (lists, key-value pairs) over prose\n- Update when you learn something important or when asked to remember something\n\n### Current Memory\n${memory}`);\n\n\t// 7. System Configuration Log\n\tsections.push(`## System Configuration Log\nMaintain ${workspacePath}/SYSTEM.md to log all environment modifications:\n- Installed packages (apk add, npm install, pip install)\n- Environment variables set\n- Config files modified\n- Skill dependencies installed\n\nUpdate this file whenever you modify the environment.`);\n\n\t// 8. Tools\n\tsections.push(`## Tools\n- bash: Run shell commands (primary tool). Install packages as needed.\n- read: Read files\n- write: Create/overwrite files\n- edit: Surgical file edits\n- attach: Share files (note: DingTalk file sharing is limited, output as text when possible)\n\nEach tool requires a \"label\" parameter (shown to user).`);\n\n\t// 9. Log Queries\n\tsections.push(`## Log Queries (for older history)\nFormat: \\`{\"date\":\"...\",\"ts\":\"...\",\"user\":\"...\",\"userName\":\"...\",\"text\":\"...\",\"isBot\":false}\\`\nThe log contains user messages and your final responses (not tool calls/results).\n${isDocker ? \"Install jq: apk add jq\" : \"\"}\n\n\\`\\`\\`bash\n# Recent messages\ntail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Search for specific topic\ngrep -i \"topic\" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\\`\\`\\``);\n\n\treturn sections.join(\"\\n\\n\");\n}\n\n// ============================================================================\n// Agent Runner\n// ============================================================================\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn `${text.substring(0, maxLen - 3)}...`;\n}\n\nfunction sanitizeProgressText(text: string): string {\n\treturn text\n\t\t.replace(/\\uFFFC/g, \"\")\n\t\t.replace(/\\r/g, \"\")\n\t\t.trim();\n}\n\nfunction formatProgressEntry(kind: \"tool\" | \"thinking\" | \"error\" | \"assistant\", text: string): string {\n\tconst cleaned = sanitizeProgressText(text);\n\tif (!cleaned) return \"\";\n\n\tconst normalized = cleaned.replace(/\\n+/g, \" \").trim();\n\tswitch (kind) {\n\t\tcase \"tool\":\n\t\t\treturn `Running: ${normalized}`;\n\t\tcase \"thinking\":\n\t\t\treturn `Thinking: ${normalized}`;\n\t\tcase \"error\":\n\t\t\treturn `Error: ${normalized}`;\n\t\tcase \"assistant\":\n\t\t\treturn normalized;\n\t}\n}\n\nfunction extractToolResultText(result: unknown): string {\n\tif (typeof result === \"string\") {\n\t\treturn result;\n\t}\n\n\tif (\n\t\tresult &&\n\t\ttypeof result === \"object\" &&\n\t\t\"content\" in result &&\n\t\tArray.isArray((result as { content: unknown }).content)\n\t) {\n\t\tconst content = (result as { content: Array<{ type: string; text?: string }> }).content;\n\t\tconst textParts: string[] = [];\n\t\tfor (const part of content) {\n\t\t\tif (part.type === \"text\" && part.text) {\n\t\t\t\ttextParts.push(part.text);\n\t\t\t}\n\t\t}\n\t\tif (textParts.length > 0) {\n\t\t\treturn textParts.join(\"\\n\");\n\t\t}\n\t}\n\n\treturn JSON.stringify(result);\n}\n\n// Cache runners per channel\nconst channelRunners = new Map<string, AgentRunner>();\n\nexport function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst existing = channelRunners.get(channelId);\n\tif (existing) return existing;\n\n\tconst runner = createRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n\nfunction createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst executor = createExecutor(sandboxConfig);\n\tconst workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\tconst workspaceDir = join(channelDir, \"..\");\n\n\t// Create tools\n\tconst tools = createPipiclawTools(executor);\n\n\t// Initial system prompt\n\tconst soul = getSoul(workspaceDir);\n\tconst agentConfig = getAgentConfig(channelDir);\n\tconst memory = getMemory(channelDir);\n\tconst initialSkills = loadPipiclawSkills(channelDir, workspacePath);\n\tlet currentSkills = initialSkills;\n\tconst systemPrompt = buildSystemPrompt(\n\t\tworkspacePath,\n\t\tchannelId,\n\t\tsoul,\n\t\tagentConfig,\n\t\tmemory,\n\t\tsandboxConfig,\n\t\tinitialSkills,\n\t);\n\n\t// Create session manager\n\tconst contextFile = join(channelDir, \"context.jsonl\");\n\tconst sessionManager = SessionManager.open(contextFile, channelDir);\n\tconst settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t// Create AuthStorage and ModelRegistry\n\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\tconst modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t// Resolve model: prefer saved global default, fall back to first available model\n\tlet activeModel = resolveInitialModel(modelRegistry, settingsManager);\n\tlog.logInfo(`Using model: ${activeModel.provider}/${activeModel.id} (${activeModel.name})`);\n\n\t// Create agent\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: activeModel,\n\t\t\tthinkingLevel: \"off\",\n\t\t\ttools,\n\t\t},\n\t\tconvertToLlm,\n\t\tgetApiKey: async () => getApiKeyForModel(modelRegistry, activeModel),\n\t});\n\n\t// Load existing messages\n\tconst loadedSession = sessionManager.buildSessionContext();\n\tif (loadedSession.messages.length > 0) {\n\t\tagent.replaceMessages(loadedSession.messages);\n\t\tlog.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);\n\t}\n\n\tconst resourceLoader = new DefaultResourceLoader({\n\t\tcwd: process.cwd(),\n\t\tagentDir: APP_HOME_DIR,\n\t\tsettingsManager: settingsManager as any,\n\t\tskillsOverride: (base) => ({\n\t\t\tskills: [...base.skills, ...currentSkills],\n\t\t\tdiagnostics: base.diagnostics,\n\t\t}),\n\t});\n\n\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t// Create AgentSession\n\tconst session = new AgentSession({\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager: settingsManager as any,\n\t\tcwd: process.cwd(),\n\t\tmodelRegistry,\n\t\tresourceLoader,\n\t\tbaseToolsOverride,\n\t});\n\n\t// Mutable per-run state\n\tconst runState: {\n\t\tctx: DingTalkContext | null;\n\t\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\t\tqueue: {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null;\n\t\tpendingTools: Map<string, { toolName: string; args: unknown; startTime: number }>;\n\t\ttotalUsage: {\n\t\t\tinput: number;\n\t\t\toutput: number;\n\t\t\tcacheRead: number;\n\t\t\tcacheWrite: number;\n\t\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t\t};\n\t\tstopReason: string;\n\t\terrorMessage: string | undefined;\n\t\tfinalOutcome: FinalOutcome;\n\t\tfinalResponseDelivered: boolean;\n\t} = {\n\t\tctx: null as DingTalkContext | null,\n\t\tlogCtx: null as { channelId: string; userName?: string; channelName?: string } | null,\n\t\tqueue: null as {\n\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n\t\t} | null,\n\t\tpendingTools: new Map<string, { toolName: string; args: unknown; startTime: number }>(),\n\t\ttotalUsage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\tstopReason: \"stop\",\n\t\terrorMessage: undefined as string | undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n\n\tconst sendCommandReply = async (ctx: DingTalkContext, text: string): Promise<void> => {\n\t\tconst delivered = await ctx.respondPlain(text);\n\t\tif (!delivered) {\n\t\t\tawait ctx.replaceMessage(text);\n\t\t\tawait ctx.flush();\n\t\t}\n\t};\n\n\tconst requireQueuedMessage = (text: string, commandName: \"steer\" | \"followup\"): string => {\n\t\tconst trimmedText = text.trim();\n\t\tif (!trimmedText) {\n\t\t\tthrow new Error(`/${commandName} requires a message.`);\n\t\t}\n\t\treturn trimmedText;\n\t};\n\n\tconst queueBusyMessage = async (delivery: \"steer\" | \"followUp\", text: string): Promise<void> => {\n\t\tif (!session.isStreaming) {\n\t\t\tthrow new Error(\"No task is currently running.\");\n\t\t}\n\n\t\tif (delivery === \"followUp\") {\n\t\t\tawait session.followUp(text);\n\t\t} else {\n\t\t\tawait session.steer(text);\n\t\t}\n\t};\n\n\tconst handleModelBuiltinCommand = async (ctx: DingTalkContext, args: string): Promise<void> => {\n\t\tmodelRegistry.refresh();\n\t\tconst availableModels = await modelRegistry.getAvailable();\n\t\tconst currentModel = session.model;\n\n\t\tif (!args.trim()) {\n\t\t\tconst current = currentModel ? `\\`${formatModelReference(currentModel)}\\`` : \"(none)\";\n\t\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : \"- (none)\";\n\t\t\tawait sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`# Model\n\nCurrent model: ${current}\n\nUse \\`/model <provider/modelId>\\` or \\`/model <modelId>\\` to switch. Bare model IDs must resolve uniquely.\n\nAvailable models:\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst match = findExactModelReferenceMatch(args, availableModels);\n\t\tif (match.match) {\n\t\t\tawait session.setModel(match.match);\n\t\t\tactiveModel = match.match;\n\t\t\tawait sendCommandReply(ctx, `已切换模型到 \\`${formatModelReference(match.match)}\\`。`);\n\t\t\treturn;\n\t\t}\n\n\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : \"- (none)\";\n\t\tif (match.ambiguous) {\n\t\t\tawait sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`未切换模型:\\`${args.trim()}\\` 匹配到多个模型。请改用精确的 \\`provider/modelId\\` 形式。\n\nAvailable models:\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait sendCommandReply(\n\t\t\tctx,\n\t\t\t`未找到模型 \\`${args.trim()}\\`。请使用精确的 \\`provider/modelId\\` 或唯一的 \\`modelId\\`。\n\nAvailable models:\n${available}`,\n\t\t);\n\t};\n\n\tconst handleBuiltInCommand = async (ctx: DingTalkContext, command: BuiltInCommand): Promise<void> => {\n\t\ttry {\n\t\t\tswitch (command.name) {\n\t\t\t\tcase \"help\":\n\t\t\t\t\tawait sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"new\": {\n\t\t\t\t\tconst completed = await session.newSession();\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tcompleted\n\t\t\t\t\t\t\t? `已开启新会话。\n\nSession ID: \\`${session.sessionId}\\``\n\t\t\t\t\t\t\t: \"新会话已取消。\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"compact\": {\n\t\t\t\t\tconst result = await session.compact(command.args || undefined);\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`已压缩当前会话上下文。\n\n- Tokens before compaction: \\`${result.tokensBefore}\\`\n- Summary:\n\n\\`\\`\\`text\n${result.summary}\n\\`\\`\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"session\": {\n\t\t\t\t\tconst stats = session.getSessionStats();\n\t\t\t\t\tconst currentModel = session.model ? `\\`${formatModelReference(session.model)}\\`` : \"(none)\";\n\t\t\t\t\tconst sessionFile = stats.sessionFile ? `\\`${basename(stats.sessionFile)}\\`` : \"(none)\";\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`# Session\n\n- Session ID: \\`${stats.sessionId}\\`\n- Session file: ${sessionFile}\n- Model: ${currentModel}\n- Thinking level: \\`${session.thinkingLevel}\\`\n- User messages: \\`${stats.userMessages}\\`\n- Assistant messages: \\`${stats.assistantMessages}\\`\n- Tool calls: \\`${stats.toolCalls}\\`\n- Tool results: \\`${stats.toolResults}\\`\n- Total messages: \\`${stats.totalMessages}\\`\n- Tokens: \\`${stats.tokens.total}\\` (input \\`${stats.tokens.input}\\`, output \\`${stats.tokens.output}\\`, cache read \\`${stats.tokens.cacheRead}\\`, cache write \\`${stats.tokens.cacheWrite}\\`)\n- Cost: \\`$${stats.cost.toFixed(4)}\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"model\":\n\t\t\t\t\tawait handleModelBuiltinCommand(ctx, command.args);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"stop\":\n\t\t\t\t\tawait sendCommandReply(ctx, \"No task is running. Use `/stop` only while a task is running.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"steer\":\n\t\t\t\t\trequireQueuedMessage(command.args, \"steer\");\n\t\t\t\t\tawait sendCommandReply(ctx, \"No task is running. Send the message directly instead of using `/steer`.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"followup\":\n\t\t\t\t\trequireQueuedMessage(command.args, \"followup\");\n\t\t\t\t\tawait sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly now, or use `/followup` while a task is running.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${channelId}] Built-in command failed`, errMsg);\n\t\t\tawait sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t};\n\n\t// Subscribe to events ONCE\n\tsession.subscribe(async (event: any) => {\n\t\tif (!runState.ctx || !runState.logCtx || !runState.queue) return;\n\n\t\tconst { ctx, logCtx, queue, pendingTools } = runState;\n\n\t\tif (event.type === \"tool_execution_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\targs: agentEvent.args,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t});\n\n\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t} else {\n\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t}\n\n\t\t\tif (agentEvent.isError) {\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\"tool error\",\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (event.type === \"message_start\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t}\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\trunState.stopReason = assistantMsg.stopReason;\n\t\t\t\t}\n\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\trunState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t}\n\n\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\trunState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\trunState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\trunState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\trunState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\trunState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\trunState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\trunState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\trunState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t}\n\n\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tlet hasToolCalls = false;\n\t\t\t\tfor (const part of content) {\n\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t}\n\n\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tconst turnEvent = event as any & {\n\t\t\t\ttype: \"turn_end\";\n\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\ttoolResults: unknown[];\n\t\t\t};\n\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\trunState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (runState.finalOutcome.kind === \"final\" && runState.finalOutcome.text.trim() === trimmedFinalText) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\trunState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\trunState.finalResponseDelivered = true;\n\t\t\t\t\t}\n\t\t\t\t}, \"final response\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\tqueue.enqueue(\n\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\"compaction start\",\n\t\t\t);\n\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\tconst compEvent = event as any;\n\t\t\tif (compEvent.result) {\n\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t} else if (compEvent.aborted) {\n\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t}\n\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\tconst retryEvent = event as any;\n\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\tqueue.enqueue(\n\t\t\t\t() =>\n\t\t\t\t\tctx.respond(\n\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\tfalse,\n\t\t\t\t\t),\n\t\t\t\t\"retry\",\n\t\t\t);\n\t\t}\n\t});\n\n\treturn {\n\t\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\t\tawait handleBuiltInCommand(ctx, command);\n\t\t},\n\n\t\tasync queueSteer(text: string): Promise<void> {\n\t\t\tawait queueBusyMessage(\"steer\", requireQueuedMessage(text, \"steer\"));\n\t\t},\n\n\t\tasync queueFollowUp(text: string): Promise<void> {\n\t\t\tawait queueBusyMessage(\"followUp\", requireQueuedMessage(text, \"followup\"));\n\t\t},\n\n\t\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\t\t// Reset per-run state\n\t\t\trunState.ctx = ctx;\n\t\t\trunState.logCtx = {\n\t\t\t\tchannelId: ctx.message.channel,\n\t\t\t\tuserName: ctx.message.userName,\n\t\t\t\tchannelName: ctx.channelName,\n\t\t\t};\n\t\t\trunState.pendingTools.clear();\n\t\t\trunState.totalUsage = {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t};\n\t\t\trunState.stopReason = \"stop\";\n\t\t\trunState.errorMessage = undefined;\n\t\t\trunState.finalOutcome = { kind: \"none\" };\n\t\t\trunState.finalResponseDelivered = false;\n\n\t\t\t// Create queue for this run\n\t\t\tlet queueChain = Promise.resolve();\n\t\t\trunState.queue = {\n\t\t\t\tenqueue(fn: () => Promise<void>, errorContext: string): void {\n\t\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait fn();\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\t\tthis.enqueue(\n\t\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\t\terrorContext,\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\t// Ensure channel directory exists\n\t\t\t\tawait mkdir(channelDir, { recursive: true });\n\n\t\t\t\t// Update system prompt and runtime resources with fresh config\n\t\t\t\tconst soul = getSoul(workspaceDir);\n\t\t\t\tconst agentConfig = getAgentConfig(channelDir);\n\t\t\t\tconst memory = getMemory(channelDir);\n\t\t\t\tconst skills = loadPipiclawSkills(channelDir, workspacePath);\n\t\t\t\tcurrentSkills = skills;\n\t\t\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\t\t\tworkspacePath,\n\t\t\t\t\tchannelId,\n\t\t\t\t\tsoul,\n\t\t\t\t\tagentConfig,\n\t\t\t\t\tmemory,\n\t\t\t\t\tsandboxConfig,\n\t\t\t\t\tskills,\n\t\t\t\t);\n\t\t\t\tsession.agent.setSystemPrompt(systemPrompt);\n\t\t\t\tawait session.reload();\n\n\t\t\t\t// Sync messages from log.jsonl\n\t\t\t\tconst syncedCount = syncLogToSessionManager(sessionManager, channelDir, ctx.message.ts);\n\t\t\t\tif (syncedCount > 0) {\n\t\t\t\t\tlog.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);\n\t\t\t\t}\n\n\t\t\t\t// Reload messages from context.jsonl\n\t\t\t\tconst reloadedSession = sessionManager.buildSessionContext();\n\t\t\t\tif (reloadedSession.messages.length > 0) {\n\t\t\t\t\tagent.replaceMessages(reloadedSession.messages);\n\t\t\t\t\tlog.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);\n\t\t\t\t}\n\n\t\t\t\t// Log context info\n\t\t\t\tlog.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);\n\n\t\t\t\t// Build user message with timestamp and username prefix\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\t\t\tconst offset = -now.getTimezoneOffset();\n\t\t\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\t\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\t\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\t\t\tconst timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;\n\t\t\t\tconst userMessage = `[${timestamp}] [${ctx.message.userName || \"unknown\"}]: ${ctx.message.text}`;\n\n\t\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\t\tconst debugContext = {\n\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\tmessages: session.messages,\n\t\t\t\t\t\tnewUserMessage: userMessage,\n\t\t\t\t\t};\n\t\t\t\t\tawait writeFile(join(channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t\t}\n\n\t\t\t\tawait session.prompt(userMessage);\n\t\t\t} catch (err) {\n\t\t\t\trunState.stopReason = \"error\";\n\t\t\t\trunState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\t\tlog.logWarning(`[${channelId}] Runner failed`, runState.errorMessage);\n\t\t\t} finally {\n\t\t\t\tawait queueChain;\n\t\t\t\tconst finalOutcome = runState.finalOutcome;\n\t\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\t\ttry {\n\t\t\t\t\tif (runState.stopReason === \"error\" && runState.errorMessage && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (finalOutcomeText && !runState.finalResponseDelivered) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tawait ctx.flush();\n\t\t\t\t} finally {\n\t\t\t\t\tawait ctx.close();\n\t\t\t\t}\n\n\t\t\t\t// Log usage summary\n\t\t\t\tif (runState.totalUsage.cost.total > 0) {\n\t\t\t\t\tconst messages = session.messages;\n\t\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t\t.slice()\n\t\t\t\t\t\t.reverse()\n\t\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t\t: 0;\n\t\t\t\t\tconst currentRunModel = session.model ?? activeModel;\n\t\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\t\tlog.logUsageSummary(runState.logCtx!, runState.totalUsage, contextTokens, contextWindow);\n\t\t\t\t}\n\n\t\t\t\t// Clear run state\n\t\t\t\trunState.ctx = null;\n\t\t\t\trunState.logCtx = null;\n\t\t\t\trunState.queue = null;\n\t\t\t}\n\n\t\t\treturn { stopReason: runState.stopReason, errorMessage: runState.errorMessage };\n\t\t},\n\n\t\tabort(): void {\n\t\t\tsession.abort();\n\t\t},\n\t};\n}\n\n/**\n * Translate container path back to host path for file operations\n */\nexport function translateToHostPath(\n\tcontainerPath: string,\n\tchannelDir: string,\n\tworkspacePath: string,\n\tchannelId: string,\n): string {\n\tif (workspacePath === \"/workspace\") {\n\t\tconst prefix = `/workspace/${channelId}/`;\n\t\tif (containerPath.startsWith(prefix)) {\n\t\t\treturn join(channelDir, containerPath.slice(prefix.length));\n\t\t}\n\t\tif (containerPath.startsWith(\"/workspace/\")) {\n\t\t\treturn join(channelDir, \"..\", containerPath.slice(\"/workspace/\".length));\n\t\t}\n\t}\n\treturn containerPath;\n}\n"]}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AAGvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAUrD,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAO/C,MAAM,WAAW,WAAW;IAC3B,GAAG,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvG,oBAAoB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,KAAK,IAAI,IAAI,CAAC;CACd;AAitBD,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAOlH","sourcesContent":["import { Agent } from \"@mariozechner/pi-agent-core\";\nimport type { Api, Model } from \"@mariozechner/pi-ai\";\nimport {\n\tAgentSession,\n\tAuthStorage,\n\tconvertToLlm,\n\tDefaultResourceLoader,\n\tModelRegistry,\n\tSessionManager,\n\ttype Skill,\n} from \"@mariozechner/pi-coding-agent\";\nimport { mkdir, writeFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport { type BuiltInCommand, renderBuiltInHelp } from \"./commands.js\";\nimport { getAgentConfig, getApiKeyForModel, getMemory, getSoul, loadPipiclawSkills } from \"./config-loader.js\";\nimport { PipiclawSettingsManager, syncLogToSessionManager } from \"./context.js\";\nimport type { DingTalkContext } from \"./dingtalk.js\";\nimport * as log from \"./log.js\";\nimport {\n\tfindExactModelReferenceMatch,\n\tformatModelList,\n\tformatModelReference,\n\tresolveInitialModel,\n} from \"./model-utils.js\";\nimport { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from \"./paths.js\";\nimport { buildSystemPrompt } from \"./prompt-builder.js\";\nimport { createExecutor, type SandboxConfig } from \"./sandbox.js\";\nimport type { ChannelStore } from \"./store.js\";\nimport { createPipiclawTools } from \"./tools/index.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AgentRunner {\n\trun(ctx: DingTalkContext, store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }>;\n\thandleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void>;\n\tqueueSteer(text: string): Promise<void>;\n\tqueueFollowUp(text: string): Promise<void>;\n\tabort(): void;\n}\n\ntype FinalOutcome = { kind: \"none\" } | { kind: \"silent\" } | { kind: \"final\"; text: string };\n\nfunction isSilentOutcome(outcome: FinalOutcome): outcome is { kind: \"silent\" } {\n\treturn outcome.kind === \"silent\";\n}\n\nfunction isFinalOutcome(outcome: FinalOutcome): outcome is { kind: \"final\"; text: string } {\n\treturn outcome.kind === \"final\";\n}\n\nfunction getFinalOutcomeText(outcome: FinalOutcome): string | null {\n\treturn isFinalOutcome(outcome) ? outcome.text : null;\n}\n\n// ============================================================================\n// Text helpers\n// ============================================================================\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn `${text.substring(0, maxLen - 3)}...`;\n}\n\nfunction sanitizeProgressText(text: string): string {\n\treturn text\n\t\t.replace(/\\uFFFC/g, \"\")\n\t\t.replace(/\\r/g, \"\")\n\t\t.trim();\n}\n\nfunction formatProgressEntry(kind: \"tool\" | \"thinking\" | \"error\" | \"assistant\", text: string): string {\n\tconst cleaned = sanitizeProgressText(text);\n\tif (!cleaned) return \"\";\n\n\tconst normalized = cleaned.replace(/\\n+/g, \" \").trim();\n\tswitch (kind) {\n\t\tcase \"tool\":\n\t\t\treturn `Running: ${normalized}`;\n\t\tcase \"thinking\":\n\t\t\treturn `Thinking: ${normalized}`;\n\t\tcase \"error\":\n\t\t\treturn `Error: ${normalized}`;\n\t\tcase \"assistant\":\n\t\t\treturn normalized;\n\t}\n}\n\nfunction extractToolResultText(result: unknown): string {\n\tif (typeof result === \"string\") {\n\t\treturn result;\n\t}\n\n\tif (\n\t\tresult &&\n\t\ttypeof result === \"object\" &&\n\t\t\"content\" in result &&\n\t\tArray.isArray((result as { content: unknown }).content)\n\t) {\n\t\tconst content = (result as { content: Array<{ type: string; text?: string }> }).content;\n\t\tconst textParts: string[] = [];\n\t\tfor (const part of content) {\n\t\t\tif (part.type === \"text\" && part.text) {\n\t\t\t\ttextParts.push(part.text);\n\t\t\t}\n\t\t}\n\t\tif (textParts.length > 0) {\n\t\t\treturn textParts.join(\"\\n\");\n\t\t}\n\t}\n\n\treturn JSON.stringify(result);\n}\n\n// ============================================================================\n// Run State\n// ============================================================================\n\ninterface PendingTool {\n\ttoolName: string;\n\targs: unknown;\n\tstartTime: number;\n}\n\ninterface UsageTotals {\n\tinput: number;\n\toutput: number;\n\tcacheRead: number;\n\tcacheWrite: number;\n\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n}\n\ninterface RunQueue {\n\tenqueue(fn: () => Promise<void>, errorContext: string): void;\n\tenqueueMessage(text: string, target: \"main\" | \"thread\", errorContext: string, doLog?: boolean): void;\n}\n\ninterface RunState {\n\tctx: DingTalkContext | null;\n\tlogCtx: { channelId: string; userName?: string; channelName?: string } | null;\n\tqueue: RunQueue | null;\n\tpendingTools: Map<string, PendingTool>;\n\ttotalUsage: UsageTotals;\n\tstopReason: string;\n\terrorMessage: string | undefined;\n\tfinalOutcome: FinalOutcome;\n\tfinalResponseDelivered: boolean;\n}\n\nfunction createEmptyRunState(): RunState {\n\treturn {\n\t\tctx: null,\n\t\tlogCtx: null,\n\t\tqueue: null,\n\t\tpendingTools: new Map(),\n\t\ttotalUsage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\tstopReason: \"stop\",\n\t\terrorMessage: undefined,\n\t\tfinalOutcome: { kind: \"none\" },\n\t\tfinalResponseDelivered: false,\n\t};\n}\n\n// ============================================================================\n// ChannelRunner\n// ============================================================================\n\nclass ChannelRunner implements AgentRunner {\n\t// --- Constructed once ---\n\tprivate readonly sandboxConfig: SandboxConfig;\n\tprivate readonly channelId: string;\n\tprivate readonly channelDir: string;\n\tprivate readonly workspacePath: string;\n\tprivate readonly workspaceDir: string;\n\tprivate readonly session: AgentSession;\n\tprivate readonly agent: Agent;\n\tprivate readonly sessionManager: SessionManager;\n\tprivate readonly settingsManager: PipiclawSettingsManager;\n\tprivate readonly modelRegistry: ModelRegistry;\n\n\t// --- Mutable across runs ---\n\tprivate activeModel: Model<Api>;\n\tprivate currentSkills: Skill[];\n\n\t// --- Per run ---\n\tprivate runState: RunState = createEmptyRunState();\n\n\tconstructor(sandboxConfig: SandboxConfig, channelId: string, channelDir: string) {\n\t\tthis.sandboxConfig = sandboxConfig;\n\t\tthis.channelId = channelId;\n\t\tthis.channelDir = channelDir;\n\n\t\tconst executor = createExecutor(sandboxConfig);\n\t\tthis.workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, \"\"));\n\t\tthis.workspaceDir = join(channelDir, \"..\");\n\n\t\t// Create tools\n\t\tconst tools = createPipiclawTools(executor);\n\n\t\t// Initial system prompt\n\t\tconst soul = getSoul(this.workspaceDir);\n\t\tconst agentConfig = getAgentConfig(channelDir);\n\t\tconst memory = getMemory(channelDir);\n\t\tconst initialSkills = loadPipiclawSkills(channelDir, this.workspacePath);\n\t\tthis.currentSkills = initialSkills;\n\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\tthis.workspacePath,\n\t\t\tchannelId,\n\t\t\tsoul,\n\t\t\tagentConfig,\n\t\t\tmemory,\n\t\t\tsandboxConfig,\n\t\t\tinitialSkills,\n\t\t);\n\n\t\t// Create session manager\n\t\tconst contextFile = join(channelDir, \"context.jsonl\");\n\t\tthis.sessionManager = SessionManager.open(contextFile, channelDir);\n\t\tthis.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);\n\n\t\t// Create AuthStorage and ModelRegistry\n\t\tconst authStorage = AuthStorage.create(AUTH_CONFIG_PATH);\n\t\tthis.modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);\n\n\t\t// Resolve model: prefer saved global default, fall back to first available model\n\t\tthis.activeModel = resolveInitialModel(this.modelRegistry, this.settingsManager);\n\t\tlog.logInfo(`Using model: ${this.activeModel.provider}/${this.activeModel.id} (${this.activeModel.name})`);\n\n\t\t// Create agent\n\t\tthis.agent = new Agent({\n\t\t\tinitialState: {\n\t\t\t\tsystemPrompt,\n\t\t\t\tmodel: this.activeModel,\n\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\ttools,\n\t\t\t},\n\t\t\tconvertToLlm,\n\t\t\tgetApiKey: async () => getApiKeyForModel(this.modelRegistry, this.activeModel),\n\t\t});\n\n\t\t// Load existing messages\n\t\tconst loadedSession = this.sessionManager.buildSessionContext();\n\t\tif (loadedSession.messages.length > 0) {\n\t\t\tthis.agent.replaceMessages(loadedSession.messages);\n\t\t\tlog.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);\n\t\t}\n\n\t\tconst resourceLoader = new DefaultResourceLoader({\n\t\t\tcwd: process.cwd(),\n\t\t\tagentDir: APP_HOME_DIR,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\tskillsOverride: (base) => ({\n\t\t\t\tskills: [...base.skills, ...this.currentSkills],\n\t\t\t\tdiagnostics: base.diagnostics,\n\t\t\t}),\n\t\t});\n\n\t\tconst baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n\n\t\t// Create AgentSession\n\t\tthis.session = new AgentSession({\n\t\t\tagent: this.agent,\n\t\t\tsessionManager: this.sessionManager,\n\t\t\tsettingsManager: this.settingsManager as any,\n\t\t\tcwd: process.cwd(),\n\t\t\tmodelRegistry: this.modelRegistry,\n\t\t\tresourceLoader,\n\t\t\tbaseToolsOverride,\n\t\t});\n\n\t\t// Subscribe to session events\n\t\tthis.subscribeToSessionEvents();\n\t}\n\n\t// === Public API ===\n\n\tasync run(ctx: DingTalkContext, _store: ChannelStore): Promise<{ stopReason: string; errorMessage?: string }> {\n\t\tthis.resetRunState(ctx);\n\n\t\t// Create queue for this run\n\t\tlet queueChain = Promise.resolve();\n\t\tthis.runState.queue = {\n\t\t\tenqueue: (fn: () => Promise<void>, errorContext: string): void => {\n\t\t\t\tqueueChain = queueChain.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait fn();\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(`DingTalk API error (${errorContext})`, errMsg);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t},\n\t\t\tenqueueMessage: function (text: string, target: \"main\" | \"thread\", errorContext: string, doLog = true): void {\n\t\t\t\tthis.enqueue(\n\t\t\t\t\t() => (target === \"main\" ? ctx.respond(text, doLog) : ctx.respondInThread(text)),\n\t\t\t\t\terrorContext,\n\t\t\t\t);\n\t\t\t},\n\t\t};\n\n\t\ttry {\n\t\t\t// Ensure channel directory exists\n\t\t\tawait mkdir(this.channelDir, { recursive: true });\n\n\t\t\t// Update system prompt and runtime resources with fresh config\n\t\t\tconst soul = getSoul(this.workspaceDir);\n\t\t\tconst agentConfig = getAgentConfig(this.channelDir);\n\t\t\tconst memory = getMemory(this.channelDir);\n\t\t\tconst skills = loadPipiclawSkills(this.channelDir, this.workspacePath);\n\t\t\tthis.currentSkills = skills;\n\t\t\tconst systemPrompt = buildSystemPrompt(\n\t\t\t\tthis.workspacePath,\n\t\t\t\tthis.channelId,\n\t\t\t\tsoul,\n\t\t\t\tagentConfig,\n\t\t\t\tmemory,\n\t\t\t\tthis.sandboxConfig,\n\t\t\t\tskills,\n\t\t\t);\n\t\t\tthis.session.agent.setSystemPrompt(systemPrompt);\n\t\t\tawait this.session.reload();\n\n\t\t\t// Sync messages from log.jsonl\n\t\t\tconst syncedCount = syncLogToSessionManager(this.sessionManager, this.channelDir, ctx.message.ts);\n\t\t\tif (syncedCount > 0) {\n\t\t\t\tlog.logInfo(`[${this.channelId}] Synced ${syncedCount} messages from log.jsonl`);\n\t\t\t}\n\n\t\t\t// Reload messages from context.jsonl\n\t\t\tconst reloadedSession = this.sessionManager.buildSessionContext();\n\t\t\tif (reloadedSession.messages.length > 0) {\n\t\t\t\tthis.agent.replaceMessages(reloadedSession.messages);\n\t\t\t\tlog.logInfo(`[${this.channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);\n\t\t\t}\n\n\t\t\t// Log context info\n\t\t\tlog.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);\n\n\t\t\t// Build user message with timestamp and username prefix\n\t\t\tconst now = new Date();\n\t\t\tconst pad = (n: number) => n.toString().padStart(2, \"0\");\n\t\t\tconst offset = -now.getTimezoneOffset();\n\t\t\tconst offsetSign = offset >= 0 ? \"+\" : \"-\";\n\t\t\tconst offsetHours = pad(Math.floor(Math.abs(offset) / 60));\n\t\t\tconst offsetMins = pad(Math.abs(offset) % 60);\n\t\t\tconst timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;\n\t\t\tconst userMessage = `[${timestamp}] [${ctx.message.userName || \"unknown\"}]: ${ctx.message.text}`;\n\n\t\t\t// Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)\n\t\t\tif (process.env.PIPICLAW_DEBUG) {\n\t\t\t\tconst debugContext = {\n\t\t\t\t\tsystemPrompt,\n\t\t\t\t\tmessages: this.session.messages,\n\t\t\t\t\tnewUserMessage: userMessage,\n\t\t\t\t};\n\t\t\t\tawait writeFile(join(this.channelDir, \"last_prompt.json\"), JSON.stringify(debugContext, null, 2));\n\t\t\t}\n\n\t\t\tawait this.session.prompt(userMessage);\n\t\t} catch (err) {\n\t\t\tthis.runState.stopReason = \"error\";\n\t\t\tthis.runState.errorMessage = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Runner failed`, this.runState.errorMessage);\n\t\t} finally {\n\t\t\tawait queueChain;\n\t\t\tconst finalOutcome = this.runState.finalOutcome;\n\t\t\tconst finalOutcomeText = getFinalOutcomeText(finalOutcome);\n\n\t\t\ttry {\n\t\t\t\tif (\n\t\t\t\t\tthis.runState.stopReason === \"error\" &&\n\t\t\t\t\tthis.runState.errorMessage &&\n\t\t\t\t\t!this.runState.finalResponseDelivered\n\t\t\t\t) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(\"_Sorry, something went wrong_\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to post error message\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (isSilentOutcome(finalOutcome)) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.deleteMessage();\n\t\t\t\t\t\tlog.logInfo(\"Silent response - deleted message\");\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to delete message for silent response\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t} else if (finalOutcomeText && !this.runState.finalResponseDelivered) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait ctx.replaceMessage(finalOutcomeText);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tlog.logWarning(\"Failed to replace message with final text\", errMsg);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tawait ctx.flush();\n\t\t\t} finally {\n\t\t\t\tawait ctx.close();\n\t\t\t}\n\n\t\t\t// Log usage summary\n\t\t\tif (this.runState.totalUsage.cost.total > 0) {\n\t\t\t\tconst messages = this.session.messages;\n\t\t\t\tconst lastAssistantMessage = messages\n\t\t\t\t\t.slice()\n\t\t\t\t\t.reverse()\n\t\t\t\t\t.find((m: any) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as any;\n\n\t\t\t\tconst contextTokens = lastAssistantMessage\n\t\t\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t\t\t: 0;\n\t\t\t\tconst currentRunModel = this.session.model ?? this.activeModel;\n\t\t\t\tconst contextWindow = currentRunModel.contextWindow || 200000;\n\n\t\t\t\tlog.logUsageSummary(this.runState.logCtx!, this.runState.totalUsage, contextTokens, contextWindow);\n\t\t\t}\n\n\t\t\t// Clear run state\n\t\t\tthis.runState.ctx = null;\n\t\t\tthis.runState.logCtx = null;\n\t\t\tthis.runState.queue = null;\n\t\t}\n\n\t\treturn { stopReason: this.runState.stopReason, errorMessage: this.runState.errorMessage };\n\t}\n\n\tasync handleBuiltinCommand(ctx: DingTalkContext, command: BuiltInCommand): Promise<void> {\n\t\ttry {\n\t\t\tswitch (command.name) {\n\t\t\t\tcase \"help\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, renderBuiltInHelp());\n\t\t\t\t\treturn;\n\t\t\t\tcase \"new\": {\n\t\t\t\t\tconst completed = await this.session.newSession();\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tcompleted ? `已开启新会话。\\n\\nSession ID: \\`${this.session.sessionId}\\`` : \"新会话已取消。\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"compact\": {\n\t\t\t\t\tconst result = await this.session.compact(command.args || undefined);\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`已压缩当前会话上下文。\\n\\n- Tokens before compaction: \\`${result.tokensBefore}\\`\\n- Summary:\\n\\n\\`\\`\\`text\\n${result.summary}\\n\\`\\`\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"session\": {\n\t\t\t\t\tconst stats = this.session.getSessionStats();\n\t\t\t\t\tconst currentModel = this.session.model ? `\\`${formatModelReference(this.session.model)}\\`` : \"(none)\";\n\t\t\t\t\tconst sessionFile = stats.sessionFile ? `\\`${basename(stats.sessionFile)}\\`` : \"(none)\";\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t`# Session\\n\\n- Session ID: \\`${stats.sessionId}\\`\\n- Session file: ${sessionFile}\\n- Model: ${currentModel}\\n- Thinking level: \\`${this.session.thinkingLevel}\\`\\n- User messages: \\`${stats.userMessages}\\`\\n- Assistant messages: \\`${stats.assistantMessages}\\`\\n- Tool calls: \\`${stats.toolCalls}\\`\\n- Tool results: \\`${stats.toolResults}\\`\\n- Total messages: \\`${stats.totalMessages}\\`\\n- Tokens: \\`${stats.tokens.total}\\` (input \\`${stats.tokens.input}\\`, output \\`${stats.tokens.output}\\`, cache read \\`${stats.tokens.cacheRead}\\`, cache write \\`${stats.tokens.cacheWrite}\\`)\\n- Cost: \\`$${stats.cost.toFixed(4)}\\``,\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcase \"model\":\n\t\t\t\t\tawait this.handleModelCommand(ctx, command.args);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"stop\":\n\t\t\t\t\tawait this.sendCommandReply(ctx, \"No task is running. Use `/stop` only while a task is running.\");\n\t\t\t\t\treturn;\n\t\t\t\tcase \"steer\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"steer\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly instead of using `/steer`.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t\tcase \"followup\":\n\t\t\t\t\tthis.requireQueuedMessage(command.args, \"followup\");\n\t\t\t\t\tawait this.sendCommandReply(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\t\"No task is running. Send the message directly now, or use `/followup` while a task is running.\",\n\t\t\t\t\t);\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\tlog.logWarning(`[${this.channelId}] Built-in command failed`, errMsg);\n\t\t\tawait this.sendCommandReply(ctx, `命令执行失败:${errMsg}`);\n\t\t}\n\t}\n\n\tasync queueSteer(text: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"steer\", this.requireQueuedMessage(text, \"steer\"));\n\t}\n\n\tasync queueFollowUp(text: string): Promise<void> {\n\t\tawait this.queueBusyMessage(\"followUp\", this.requireQueuedMessage(text, \"followup\"));\n\t}\n\n\tabort(): void {\n\t\tthis.session.abort();\n\t}\n\n\t// === Private helpers ===\n\n\tprivate async sendCommandReply(ctx: DingTalkContext, text: string): Promise<void> {\n\t\tconst delivered = await ctx.respondPlain(text);\n\t\tif (!delivered) {\n\t\t\tawait ctx.replaceMessage(text);\n\t\t\tawait ctx.flush();\n\t\t}\n\t}\n\n\tprivate requireQueuedMessage(text: string, commandName: \"steer\" | \"followup\"): string {\n\t\tconst trimmedText = text.trim();\n\t\tif (!trimmedText) {\n\t\t\tthrow new Error(`/${commandName} requires a message.`);\n\t\t}\n\t\treturn trimmedText;\n\t}\n\n\tprivate async queueBusyMessage(delivery: \"steer\" | \"followUp\", text: string): Promise<void> {\n\t\tif (!this.session.isStreaming) {\n\t\t\tthrow new Error(\"No task is currently running.\");\n\t\t}\n\n\t\tif (delivery === \"followUp\") {\n\t\t\tawait this.session.followUp(text);\n\t\t} else {\n\t\t\tawait this.session.steer(text);\n\t\t}\n\t}\n\n\tprivate async handleModelCommand(ctx: DingTalkContext, args: string): Promise<void> {\n\t\tthis.modelRegistry.refresh();\n\t\tconst availableModels = await this.modelRegistry.getAvailable();\n\t\tconst currentModel = this.session.model;\n\n\t\tif (!args.trim()) {\n\t\t\tconst current = currentModel ? `\\`${formatModelReference(currentModel)}\\`` : \"(none)\";\n\t\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : \"- (none)\";\n\t\t\tawait this.sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`# Model\\n\\nCurrent model: ${current}\\n\\nUse \\`/model <provider/modelId>\\` or \\`/model <modelId>\\` to switch. Bare model IDs must resolve uniquely.\\n\\nAvailable models:\\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tconst match = findExactModelReferenceMatch(args, availableModels);\n\t\tif (match.match) {\n\t\t\tawait this.session.setModel(match.match);\n\t\t\tthis.activeModel = match.match;\n\t\t\tawait this.sendCommandReply(ctx, `已切换模型到 \\`${formatModelReference(match.match)}\\`。`);\n\t\t\treturn;\n\t\t}\n\n\t\tconst available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : \"- (none)\";\n\t\tif (match.ambiguous) {\n\t\t\tawait this.sendCommandReply(\n\t\t\t\tctx,\n\t\t\t\t`未切换模型:\\`${args.trim()}\\` 匹配到多个模型。请改用精确的 \\`provider/modelId\\` 形式。\\n\\nAvailable models:\\n${available}`,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.sendCommandReply(\n\t\t\tctx,\n\t\t\t`未找到模型 \\`${args.trim()}\\`。请使用精确的 \\`provider/modelId\\` 或唯一的 \\`modelId\\`。\\n\\nAvailable models:\\n${available}`,\n\t\t);\n\t}\n\n\tprivate resetRunState(ctx: DingTalkContext): void {\n\t\tthis.runState = createEmptyRunState();\n\t\tthis.runState.ctx = ctx;\n\t\tthis.runState.logCtx = {\n\t\t\tchannelId: ctx.message.channel,\n\t\t\tuserName: ctx.message.userName,\n\t\t\tchannelName: ctx.channelName,\n\t\t};\n\t}\n\n\t// === Session event subscription ===\n\n\tprivate subscribeToSessionEvents(): void {\n\t\tthis.session.subscribe(async (event: any) => {\n\t\t\tif (!this.runState.ctx || !this.runState.logCtx || !this.runState.queue) return;\n\n\t\t\tconst { ctx, logCtx, queue, pendingTools } = this.runState;\n\n\t\t\tif (event.type === \"tool_execution_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_start\" };\n\t\t\t\tconst args = agentEvent.args as { label?: string };\n\t\t\t\tconst label = args.label || agentEvent.toolName;\n\n\t\t\t\tpendingTools.set(agentEvent.toolCallId, {\n\t\t\t\t\ttoolName: agentEvent.toolName,\n\t\t\t\t\targs: agentEvent.args,\n\t\t\t\t\tstartTime: Date.now(),\n\t\t\t\t});\n\n\t\t\t\tlog.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args as Record<string, unknown>);\n\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"tool\", label), false), \"tool label\");\n\t\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"tool_execution_end\" };\n\t\t\t\tconst resultStr = extractToolResultText(agentEvent.result);\n\t\t\t\tconst pending = pendingTools.get(agentEvent.toolCallId);\n\t\t\t\tpendingTools.delete(agentEvent.toolCallId);\n\n\t\t\t\tconst durationMs = pending ? Date.now() - pending.startTime : 0;\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tlog.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t} else {\n\t\t\t\t\tlog.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);\n\t\t\t\t}\n\n\t\t\t\tif (agentEvent.isError) {\n\t\t\t\t\tqueue.enqueue(\n\t\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"error\", truncate(resultStr, 200)), false),\n\t\t\t\t\t\t\"tool error\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_start\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_start\" };\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tlog.logResponseStart(logCtx);\n\t\t\t\t}\n\t\t\t} else if (event.type === \"message_end\") {\n\t\t\t\tconst agentEvent = event as any & { type: \"message_end\" };\n\t\t\t\tif (agentEvent.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = agentEvent.message as any;\n\n\t\t\t\t\tif (assistantMsg.stopReason) {\n\t\t\t\t\t\tthis.runState.stopReason = assistantMsg.stopReason;\n\t\t\t\t\t}\n\t\t\t\t\tif (assistantMsg.errorMessage) {\n\t\t\t\t\t\tthis.runState.errorMessage = assistantMsg.errorMessage;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (assistantMsg.usage) {\n\t\t\t\t\t\tthis.runState.totalUsage.input += assistantMsg.usage.input;\n\t\t\t\t\t\tthis.runState.totalUsage.output += assistantMsg.usage.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n\t\t\t\t\t\tthis.runState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst content = agentEvent.message.content;\n\t\t\t\t\tconst thinkingParts: string[] = [];\n\t\t\t\t\tconst textParts: string[] = [];\n\t\t\t\t\tlet hasToolCalls = false;\n\t\t\t\t\tfor (const part of content) {\n\t\t\t\t\t\tif (part.type === \"thinking\") {\n\t\t\t\t\t\t\tthinkingParts.push((part as any).thinking);\n\t\t\t\t\t\t} else if (part.type === \"text\") {\n\t\t\t\t\t\t\ttextParts.push((part as any).text);\n\t\t\t\t\t\t} else if (part.type === \"toolCall\") {\n\t\t\t\t\t\t\thasToolCalls = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst text = textParts.join(\"\\n\");\n\n\t\t\t\t\tfor (const thinking of thinkingParts) {\n\t\t\t\t\t\tlog.logThinking(logCtx, thinking);\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"thinking\", thinking), false), \"thinking\");\n\t\t\t\t\t}\n\n\t\t\t\t\tif (hasToolCalls && text.trim()) {\n\t\t\t\t\t\tqueue.enqueue(() => ctx.respond(formatProgressEntry(\"assistant\", text), false), \"assistant progress\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (event.type === \"turn_end\") {\n\t\t\t\tconst turnEvent = event as any & {\n\t\t\t\t\ttype: \"turn_end\";\n\t\t\t\t\tmessage: { role: string; stopReason?: string; content: Array<{ type: string; text?: string }> };\n\t\t\t\t\ttoolResults: unknown[];\n\t\t\t\t};\n\t\t\t\tif (turnEvent.message.role === \"assistant\" && turnEvent.toolResults.length === 0) {\n\t\t\t\t\tif (turnEvent.message.stopReason === \"error\" || turnEvent.message.stopReason === \"aborted\") {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst finalContent = turnEvent.message.content as Array<{ type: string; text?: string }>;\n\t\t\t\t\tconst finalText = finalContent\n\t\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\" && !!part.text)\n\t\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\t\tconst trimmedFinalText = finalText.trim();\n\t\t\t\t\tif (!trimmedFinalText) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (trimmedFinalText === \"[SILENT]\" || trimmedFinalText.startsWith(\"[SILENT]\")) {\n\t\t\t\t\t\tthis.runState.finalOutcome = { kind: \"silent\" };\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.runState.finalOutcome.kind === \"final\" &&\n\t\t\t\t\t\tthis.runState.finalOutcome.text.trim() === trimmedFinalText\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.runState.finalOutcome = { kind: \"final\", text: finalText };\n\t\t\t\t\tlog.logResponse(logCtx, finalText);\n\t\t\t\t\tqueue.enqueue(async () => {\n\t\t\t\t\t\tconst delivered = await ctx.respondPlain(finalText);\n\t\t\t\t\t\tif (delivered) {\n\t\t\t\t\t\t\tthis.runState.finalResponseDelivered = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}, \"final response\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_compaction_start\") {\n\t\t\t\tlog.logInfo(`Auto-compaction started (reason: ${(event as any).reason})`);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() => ctx.respond(formatProgressEntry(\"assistant\", \"Compacting context...\"), false),\n\t\t\t\t\t\"compaction start\",\n\t\t\t\t);\n\t\t\t} else if (event.type === \"auto_compaction_end\") {\n\t\t\t\tconst compEvent = event as any;\n\t\t\t\tif (compEvent.result) {\n\t\t\t\t\tlog.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);\n\t\t\t\t} else if (compEvent.aborted) {\n\t\t\t\t\tlog.logInfo(\"Auto-compaction aborted\");\n\t\t\t\t}\n\t\t\t} else if (event.type === \"auto_retry_start\") {\n\t\t\t\tconst retryEvent = event as any;\n\t\t\t\tlog.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);\n\t\t\t\tqueue.enqueue(\n\t\t\t\t\t() =>\n\t\t\t\t\t\tctx.respond(\n\t\t\t\t\t\t\tformatProgressEntry(\"assistant\", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`),\n\t\t\t\t\t\t\tfalse,\n\t\t\t\t\t\t),\n\t\t\t\t\t\"retry\",\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\t}\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nconst channelRunners = new Map<string, AgentRunner>();\n\nexport function getOrCreateRunner(sandboxConfig: SandboxConfig, channelId: string, channelDir: string): AgentRunner {\n\tconst existing = channelRunners.get(channelId);\n\tif (existing) return existing;\n\n\tconst runner = new ChannelRunner(sandboxConfig, channelId, channelDir);\n\tchannelRunners.set(channelId, runner);\n\treturn runner;\n}\n"]}