typeclaw 0.20.0 → 0.22.0

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.
Files changed (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -0,0 +1,190 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
2
+ import { z } from 'zod'
3
+
4
+ import { defineTool } from '@/plugin/define'
5
+ import type { ContentPart, Tool, ToolResult } from '@/plugin/types'
6
+
7
+ import type { McpConnection, McpToolInfo } from './client'
8
+ import type { McpManager } from './manager'
9
+ import { namespaceToolName, parseNamespacedTool } from './manager'
10
+
11
+ export const MCP_DISPATCHER_TOOL_NAMES = ['mcp_list_tools', 'mcp_describe', 'mcp_call'] as const
12
+
13
+ export type McpListToolsArgs = { server: string }
14
+ export type McpDescribeArgs = { server: string; tool: string }
15
+ export type McpCallArgs = { server: string; tool: string; args?: Record<string, unknown> }
16
+ export type McpDispatcherTool = Tool<McpListToolsArgs> | Tool<McpDescribeArgs> | Tool<McpCallArgs>
17
+ export type McpDispatcherTools = [Tool<McpListToolsArgs>, Tool<McpDescribeArgs>, Tool<McpCallArgs>]
18
+
19
+ export function createMcpDispatcherTools(manager: McpManager): McpDispatcherTools {
20
+ return [createListToolsTool(manager), createDescribeTool(manager), createCallTool(manager)]
21
+ }
22
+
23
+ function createListToolsTool(manager: McpManager): Tool<McpListToolsArgs> {
24
+ return defineTool<McpListToolsArgs>({
25
+ description: 'List the tools exposed by one connected MCP server. Returns namespaced tool ids and descriptions.',
26
+ parameters: z.object({
27
+ server: z.string().describe('The MCP server name from the system prompt catalog.'),
28
+ }),
29
+ async execute(args) {
30
+ const connection = manager.getConnection(args.server)
31
+ if (connection === undefined) return textResult(unknownServerMessage(manager, args.server))
32
+
33
+ const tools = await safeListTools(connection)
34
+ if (tools.length === 0) return textResult(`MCP server ${JSON.stringify(args.server)} exposes no tools.`)
35
+
36
+ const lines = tools.map((tool) => {
37
+ const description = tool.description.trim() === '' ? 'no description' : tool.description.trim()
38
+ return `- ${namespaceToolName(args.server, tool.name)} — ${description}`
39
+ })
40
+ return textResult(`Tools for MCP server ${JSON.stringify(args.server)}:\n${lines.join('\n')}`)
41
+ },
42
+ })
43
+ }
44
+
45
+ function createDescribeTool(manager: McpManager): Tool<McpDescribeArgs> {
46
+ return defineTool<McpDescribeArgs>({
47
+ description: 'Describe one MCP tool. Returns its description and full input JSON Schema.',
48
+ parameters: z.object({
49
+ server: z.string().describe('The MCP server name from the system prompt catalog.'),
50
+ tool: z.string().describe('The bare tool name or namespaced server__tool id.'),
51
+ }),
52
+ async execute(args) {
53
+ const resolved = resolveToolArgs(args.server, args.tool)
54
+ const connection = manager.getConnection(resolved.server)
55
+ if (connection === undefined) return textResult(unknownServerMessage(manager, resolved.server))
56
+
57
+ const tools = await safeListTools(connection)
58
+ const tool = tools.find((item) => item.name === resolved.tool)
59
+ if (tool === undefined) {
60
+ const available = tools.map((item) => namespaceToolName(resolved.server, item.name)).join(', ')
61
+ return textResult(
62
+ `Unknown MCP tool ${JSON.stringify(resolved.tool)} on server ${JSON.stringify(resolved.server)}. Available tools: ${available || 'none'}.`,
63
+ )
64
+ }
65
+
66
+ const description = tool.description.trim() === '' ? 'no description' : tool.description.trim()
67
+ return textResult(
68
+ [
69
+ `MCP tool ${namespaceToolName(resolved.server, tool.name)}`,
70
+ `Description: ${description}`,
71
+ '',
72
+ 'Input schema:',
73
+ '```json',
74
+ JSON.stringify(tool.inputSchema, null, 2),
75
+ '```',
76
+ ].join('\n'),
77
+ )
78
+ },
79
+ })
80
+ }
81
+
82
+ function createCallTool(manager: McpManager): Tool<McpCallArgs> {
83
+ return defineTool<McpCallArgs>({
84
+ description: 'Call an MCP tool on a connected server. Use mcp_describe first to learn the input schema.',
85
+ parameters: z.object({
86
+ server: z.string().describe('The MCP server name from the system prompt catalog.'),
87
+ tool: z.string().describe('The bare tool name or namespaced server__tool id.'),
88
+ args: z.record(z.string(), z.unknown()).optional().describe('Arguments matching the tool input schema.'),
89
+ }),
90
+ async execute(args) {
91
+ const resolved = resolveToolArgs(args.server, args.tool)
92
+ const connection = manager.getConnection(resolved.server)
93
+ if (connection === undefined) return textResult(unknownServerMessage(manager, resolved.server))
94
+
95
+ const result = await safeCallTool(connection, resolved.tool, args.args ?? {})
96
+ return mapCallToolResult(resolved.server, resolved.tool, result)
97
+ },
98
+ })
99
+ }
100
+
101
+ function resolveToolArgs(server: string, tool: string): { server: string; tool: string } {
102
+ const parsed = parseNamespacedTool(tool)
103
+ return parsed ?? { server, tool }
104
+ }
105
+
106
+ function unknownServerMessage(manager: McpManager, server: string): string {
107
+ const available = manager
108
+ .listServers()
109
+ .filter((entry) => entry.connected)
110
+ .map((entry) => entry.name)
111
+ .join(', ')
112
+ return `Unknown MCP server ${JSON.stringify(server)}. Available servers: ${available || 'none'}.`
113
+ }
114
+
115
+ async function safeListTools(connection: McpConnection): Promise<McpToolInfo[]> {
116
+ try {
117
+ return await connection.listTools()
118
+ } catch (cause) {
119
+ throw new Error(`MCP list tools failed: ${sanitizeMcpError(errorMessage(cause))}`)
120
+ }
121
+ }
122
+
123
+ async function safeCallTool(
124
+ connection: McpConnection,
125
+ tool: string,
126
+ args: Record<string, unknown>,
127
+ ): Promise<CallToolResult> {
128
+ try {
129
+ return await connection.callTool(tool, args)
130
+ } catch (cause) {
131
+ throw new Error(`MCP call failed: ${sanitizeMcpError(errorMessage(cause))}`)
132
+ }
133
+ }
134
+
135
+ function mapCallToolResult(server: string, tool: string, result: CallToolResult): ToolResult {
136
+ const content = result.content.map(mapMcpContentPart)
137
+ if (result.isError === true) {
138
+ return {
139
+ content: content.map((part) =>
140
+ part.type === 'text' ? { type: 'text' as const, text: `MCP tool error: ${sanitizeMcpError(part.text)}` } : part,
141
+ ),
142
+ details: { server, tool, isError: true },
143
+ }
144
+ }
145
+ return { content, details: { server, tool, isError: false } }
146
+ }
147
+
148
+ function mapMcpContentPart(part: CallToolResult['content'][number]): ContentPart {
149
+ if (part.type === 'text' && typeof part.text === 'string') return { type: 'text', text: part.text }
150
+ if (part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string') {
151
+ return { type: 'image', mimeType: part.mimeType, data: part.data }
152
+ }
153
+ return { type: 'text', text: summarizeUnsupportedPart(part) }
154
+ }
155
+
156
+ function summarizeUnsupportedPart(part: CallToolResult['content'][number]): string {
157
+ const type = typeof part.type === 'string' ? part.type : 'unknown'
158
+ const uri = readNestedString(part, 'resource', 'uri') ?? readString(part, 'uri')
159
+ if (uri !== undefined) return `[mcp:${type} ${uri}]`
160
+ const mimeType = readNestedString(part, 'resource', 'mimeType') ?? readString(part, 'mimeType')
161
+ if (mimeType !== undefined) return `[mcp:${type} ${mimeType}]`
162
+ return `[mcp:${type} omitted]`
163
+ }
164
+
165
+ export function sanitizeMcpError(raw: string): string {
166
+ const scrubbed = raw
167
+ .replace(/\b([A-Z_][A-Z0-9_]*)=\S+/g, '$1=<redacted>')
168
+ .replace(/(?:\/[\w.-]+)+/g, '<path>')
169
+ .replace(/\b[A-Za-z]:\\[^\s]+/g, '<path>')
170
+ return scrubbed.length <= 500 ? scrubbed : `${scrubbed.slice(0, 497)}...`
171
+ }
172
+
173
+ function errorMessage(cause: unknown): string {
174
+ return cause instanceof Error ? cause.message : String(cause)
175
+ }
176
+
177
+ function readString(value: unknown, key: string): string | undefined {
178
+ if (typeof value !== 'object' || value === null) return undefined
179
+ const found = (value as Record<string, unknown>)[key]
180
+ return typeof found === 'string' ? found : undefined
181
+ }
182
+
183
+ function readNestedString(value: unknown, key: string, nestedKey: string): string | undefined {
184
+ if (typeof value !== 'object' || value === null) return undefined
185
+ return readString((value as Record<string, unknown>)[key], nestedKey)
186
+ }
187
+
188
+ function textResult(text: string): ToolResult {
189
+ return { content: [{ type: 'text', text }] }
190
+ }
@@ -16,6 +16,13 @@ export const CORE_PERMISSIONS = {
16
16
  // an operator may grant guest channelRespond to let strangers drive masked
17
17
  // turns, but session lifecycle control stays member-and-up.
18
18
  sessionControl: 'session.control',
19
+ // Operate-the-agent tier: gates the /reload and /restart channel commands
20
+ // (both the text-prefix and native-slash paths in the router). Strictly
21
+ // above sessionControl — reloading config or restarting the container
22
+ // mutates global agent state and drops every in-flight session, so it is
23
+ // owner+trusted only (NOT member). A member who can /stop a turn must not
24
+ // be able to bounce the whole container.
25
+ sessionAdmin: 'session.admin',
19
26
  cronSchedule: 'cron.schedule',
20
27
  cronModify: 'cron.modify',
21
28
  subagentSpawn: 'subagent.spawn',
@@ -70,6 +77,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
70
77
  permissions: [
71
78
  CORE_PERMISSIONS.channelRespond,
72
79
  CORE_PERMISSIONS.sessionControl,
80
+ CORE_PERMISSIONS.sessionAdmin,
73
81
  CORE_PERMISSIONS.cronSchedule,
74
82
  CORE_PERMISSIONS.cronModify,
75
83
  CORE_PERMISSIONS.subagentSpawn,
@@ -89,6 +97,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
89
97
  permissions: [
90
98
  CORE_PERMISSIONS.channelRespond,
91
99
  CORE_PERMISSIONS.sessionControl,
100
+ CORE_PERMISSIONS.sessionAdmin,
92
101
  CORE_PERMISSIONS.cronSchedule,
93
102
  CORE_PERMISSIONS.subagentSpawn,
94
103
  CORE_PERMISSIONS.subagentCancel,
@@ -0,0 +1,14 @@
1
+ import type { ReloadResult } from './types'
2
+
3
+ // Human-facing /reload reply for Slack/Discord. Separate from reload-tool.ts's
4
+ // model-facing formatter on purpose — different audience, different shape.
5
+ export function formatChannelReloadSummary(results: readonly ReloadResult[]): string {
6
+ if (results.length === 0) return 'Nothing to reload.'
7
+ const failed = results.filter((r) => !r.ok).length
8
+ const header =
9
+ failed === 0
10
+ ? `Reloaded ${results.length} subsystem(s).`
11
+ : `Reloaded ${results.length} subsystem(s); ${failed} failed.`
12
+ const lines = results.map((r) => (r.ok ? `• ${r.scope}: ${r.summary}` : `• ${r.scope}: failed — ${r.reason}`))
13
+ return [header, ...lines].join('\n')
14
+ }
@@ -1,3 +1,4 @@
1
1
  export { requestReload, type RequestReloadOptions } from './client'
2
+ export { formatChannelReloadSummary } from './format'
2
3
  export { ReloadRegistry } from './registry'
3
4
  export type { Reloadable, ReloadAllResult, ReloadResult } from './types'
@@ -1,5 +1,6 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
+ import bunHygienePlugin from '@/bundled-plugins/bun-hygiene'
3
4
  import explorerPlugin from '@/bundled-plugins/explorer'
4
5
  import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
5
6
  import guardPlugin from '@/bundled-plugins/guard'
@@ -29,6 +30,11 @@ import type { ResolvedPlugin } from '@/plugin'
29
30
  // Reversing this order would make guard advise on the full oversized payload
30
31
  // and then tool-result-cap would clobber the advice text along with the rest.
31
32
  //
33
+ // `bun-hygiene` is registered after `guard` and guards a disjoint surface
34
+ // (package-manager bash commands: global installs and non-bun managers), so its
35
+ // position relative to security/guard only matters for precedence — keeping it
36
+ // after the two general guards means a security/guard block always wins first.
37
+ //
32
38
  // `github-cli-auth` is registered AFTER `security` so security's `tool.before`
33
39
  // runs its exfil/secret scanners on the bash command first. github-cli-auth
34
40
  // injects the minted token via an env overlay (TYPECLAW_INTERNAL_BASH_ENV), not
@@ -45,6 +51,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
45
51
  { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
46
52
  { name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
47
53
  { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
54
+ { name: 'bun-hygiene', version: undefined, source: '<bundled>', defined: bunHygienePlugin },
48
55
  { name: 'github-cli-auth', version: undefined, source: '<bundled>', defined: githubCliAuthPlugin },
49
56
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
50
57
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
@@ -7,6 +7,7 @@ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagen
7
7
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
8
8
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
9
9
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
10
+ import type { McpManager } from '@/mcp'
10
11
  import type { PermissionService, RolesConfig } from '@/permissions'
11
12
  import type { ReloadRegistry } from '@/reload'
12
13
  import type { SessionFactory } from '@/sessions'
@@ -35,6 +36,7 @@ export type BuildChannelSessionFactoryDeps = {
35
36
  // cycle while still ensuring the factory's sessions get the same router
36
37
  // their inbound messages came from.
37
38
  getChannelRouter: () => ChannelRouter
39
+ mcpManager?: McpManager
38
40
  containerName?: string
39
41
  runtimeVersion?: string
40
42
  // When set, rehydrating a session JSONL caps oversized tool results in the
@@ -113,6 +115,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
113
115
  sessionManager,
114
116
  stream: deps.stream,
115
117
  channelRouter: deps.getChannelRouter(),
118
+ ...(deps.mcpManager !== undefined ? { mcpManager: deps.mcpManager } : {}),
116
119
  origin,
117
120
  originRef,
118
121
  ...(snap.hasAnyPluginContent
package/src/run/index.ts CHANGED
@@ -3,6 +3,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
3
3
  import { createSession, createSessionWithDispose } from '@/agent'
4
4
  import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
+ import { requestContainerRestart } from '@/agent/restart'
6
7
  import type { SessionOrigin } from '@/agent/session-origin'
7
8
  import {
8
9
  awaitWithSubagentTimeout,
@@ -38,11 +39,12 @@ import {
38
39
  type Scheduler,
39
40
  } from '@/cron'
40
41
  import { CLI_VERSION } from '@/init/cli-version'
42
+ import { createMcpManager } from '@/mcp'
41
43
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
42
44
  import { createPluginLogger } from '@/plugin/context'
43
45
  import type { CronHandlerContext } from '@/plugin/types'
44
46
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
45
- import { ReloadRegistry } from '@/reload'
47
+ import { formatChannelReloadSummary, ReloadRegistry } from '@/reload'
46
48
  import { createClaimController } from '@/role-claim'
47
49
  import {
48
50
  exportClaudeCredentialsFileForAgent,
@@ -140,6 +142,15 @@ export async function startAgent({
140
142
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
141
143
  const cwdConfig = loadConfigSync(cwd)
142
144
  const githubTokenBridge = createGithubTokenBridge()
145
+ const mcpManager =
146
+ cwdConfig.mcpServers.length > 0 ? createMcpManager(cwdConfig.mcpServers, { env: process.env }) : null
147
+ if (mcpManager !== null) {
148
+ const results = await mcpManager.connectAll()
149
+ for (const result of results) {
150
+ if (!result.ok) console.warn(`[mcp] ${result.name} failed to connect: ${result.error.message}`)
151
+ }
152
+ }
153
+ const mcpManagerOpt = mcpManager !== null ? { mcpManager } : {}
143
154
  const pluginsLoaded = await loadPlugins({
144
155
  entries: cwdConfig.plugins,
145
156
  agentDir: cwd,
@@ -255,11 +266,32 @@ export async function startAgent({
255
266
  getCreateSessionForSubagent: () => createSessionForSubagent,
256
267
  ...containerNameOpt,
257
268
  ...runtimeVersionOpt,
269
+ ...mcpManagerOpt,
258
270
  }),
259
271
  permissions: pluginsLoaded.permissions,
260
272
  claimHandler: claimController.claimHandler,
261
273
  githubTokenBridge,
262
274
  stream,
275
+ onReload: async () => {
276
+ const { results } = await reloadRegistry.reloadAll()
277
+ return formatChannelReloadSummary(results)
278
+ },
279
+ // Always registered so /restart's presence in /help, the Slack manifest,
280
+ // and the Discord declarations is environment-independent. When there is no
281
+ // container to bounce (TYPECLAW_CONTAINER_NAME unset — tests, ad-hoc
282
+ // `typeclaw run` outside Docker), the handler reports that instead of the
283
+ // command resolving as unknown, which would make the advertised contract
284
+ // depend on the runtime environment.
285
+ onRestart: async (): Promise<string> => {
286
+ if (containerName === undefined) {
287
+ return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
288
+ }
289
+ // No originatingSessionId/stream/handoff: a channel-invoked restart must
290
+ // not write a resume hint or fire the "I'm back" broadcast that a TUI
291
+ // restart does (issue #291 scoping — only TUI origins resume).
292
+ const result = await requestContainerRestart({ containerName })
293
+ return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
294
+ },
263
295
  })
264
296
 
265
297
  const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
@@ -376,6 +408,7 @@ export async function startAgent({
376
408
  containerName: containerNameOpt.containerName,
377
409
  sessionFactory,
378
410
  channelRouter: channelManager.router,
411
+ ...mcpManagerOpt,
379
412
  }),
380
413
  subagent: (subName: string, payload?: unknown) =>
381
414
  dispatchSpawnSubagent(subName, payload, {
@@ -424,6 +457,7 @@ export async function startAgent({
424
457
  createSessionForSubagent,
425
458
  ...containerNameOpt,
426
459
  ...runtimeVersionOpt,
460
+ ...mcpManagerOpt,
427
461
  })
428
462
  liveSessionRegistry.register({ sessionId, session })
429
463
  return {
@@ -591,6 +625,7 @@ export async function startAgent({
591
625
  outbound,
592
626
  sessionFactory,
593
627
  channelRouter: channelManager.router,
628
+ ...mcpManagerOpt,
594
629
  })
595
630
 
596
631
  const server = createServer({
@@ -600,6 +635,7 @@ export async function startAgent({
600
635
  sessionFactory,
601
636
  stream,
602
637
  channelRouter: channelManager.router,
638
+ ...mcpManagerOpt,
603
639
  agentDir: cwd,
604
640
  pluginRuntime,
605
641
  claimController,
@@ -634,6 +670,7 @@ export async function startAgent({
634
670
  subagentCompletionBridge.stop()
635
671
  await tunnelManager.stop()
636
672
  await channelManager.stop()
673
+ await mcpManager?.closeAll()
637
674
  uninstallCodexFetchObserver()
638
675
  }
639
676
 
@@ -6,6 +6,7 @@ import {
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
8
  import type { ChannelRouter } from '@/channels/router'
9
+ import type { McpManager } from '@/mcp'
9
10
  import type { PermissionService } from '@/permissions'
10
11
  import type {
11
12
  CommandExecResult,
@@ -53,6 +54,7 @@ export type CommandRunnerOptions = {
53
54
  // `channelManager.router` via `createSessionForCron`; this is the matching
54
55
  // wire for the handler/command path.
55
56
  channelRouter: ChannelRouter | undefined
57
+ mcpManager?: McpManager
56
58
  }
57
59
 
58
60
  type CommandHandle = {
@@ -192,6 +194,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
192
194
  signal: abortController.signal,
193
195
  sessionFactory: opts.sessionFactory,
194
196
  channelRouter: opts.channelRouter,
197
+ ...(opts.mcpManager !== undefined ? { mcpManager: opts.mcpManager } : {}),
195
198
  }),
196
199
  subagent: (subName, payload) =>
197
200
  opts.spawnSubagent(subName, payload, {
@@ -376,6 +379,7 @@ export async function runPromptForCommand(args: {
376
379
  // See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
377
380
  // so the spawned session exposes `channel_send`.
378
381
  channelRouter?: ChannelRouter
382
+ mcpManager?: McpManager
379
383
  // Test seam for the agent-session boundary. Production passes the real
380
384
  // `createSessionWithDispose`; tests inject a fake to verify wiring
381
385
  // (specifically: the sessionManager handed off must be persisted, not
@@ -402,6 +406,7 @@ export async function runPromptForCommand(args: {
402
406
  agentDir: args.agentDir,
403
407
  },
404
408
  ...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
409
+ ...(args.mcpManager !== undefined ? { mcpManager: args.mcpManager } : {}),
405
410
  ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
406
411
  ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
407
412
  })
@@ -12,12 +12,14 @@ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
12
12
  import type { LiveSessionRegistry } from '@/agent/live-sessions'
13
13
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
14
14
  import { detectProviderError } from '@/agent/provider-error'
15
+ import { requestContainerRestart } from '@/agent/restart'
15
16
  import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-handoff'
16
17
  import type { SessionOrigin } from '@/agent/session-origin'
17
18
  import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
18
19
  import type { CreateSessionForSubagent } from '@/agent/subagents'
19
20
  import type { ChannelRouter } from '@/channels/router'
20
21
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
22
+ import type { McpManager } from '@/mcp'
21
23
  import type { HookBus } from '@/plugin'
22
24
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
23
25
  import type { ReloadAllResult, ReloadRegistry } from '@/reload'
@@ -60,6 +62,7 @@ export type ServerOptions = {
60
62
  sessionFactory?: SessionFactory
61
63
  stream?: Stream
62
64
  channelRouter?: ChannelRouter
65
+ mcpManager?: McpManager
63
66
  agentDir?: string
64
67
  pluginRuntime?: PluginRuntime
65
68
  containerName?: string
@@ -220,6 +223,7 @@ export function createServer({
220
223
  sessionFactory,
221
224
  stream,
222
225
  channelRouter,
226
+ mcpManager,
223
227
  agentDir,
224
228
  pluginRuntime,
225
229
  containerName,
@@ -470,6 +474,7 @@ export function createServer({
470
474
  origin,
471
475
  ...(stream ? { stream } : {}),
472
476
  ...(channelRouter ? { channelRouter } : {}),
477
+ ...(mcpManager ? { mcpManager } : {}),
473
478
  ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
474
479
  ...(containerName !== undefined ? { containerName } : {}),
475
480
  ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
@@ -652,6 +657,11 @@ export function createServer({
652
657
  return
653
658
  }
654
659
 
660
+ if (msg.type === 'restart') {
661
+ await handleRestart(ws, state, containerName, agentDir, stream)
662
+ return
663
+ }
664
+
655
665
  if (msg.type === 'doctor') {
656
666
  await handleDoctor(ws, msg.requestId, pluginRuntime, agentDir)
657
667
  return
@@ -1437,3 +1447,46 @@ async function handleReload(
1437
1447
  })
1438
1448
  }
1439
1449
  }
1450
+
1451
+ async function handleRestart(
1452
+ ws: Ws,
1453
+ state: SessionState | undefined,
1454
+ containerName: string | undefined,
1455
+ agentDir: string | undefined,
1456
+ stream: Stream | undefined,
1457
+ ): Promise<void> {
1458
+ if (containerName === undefined) {
1459
+ send(ws, {
1460
+ type: 'restart_result',
1461
+ status: 'failed',
1462
+ error: 'restart unavailable: no container name configured',
1463
+ })
1464
+ return
1465
+ }
1466
+
1467
+ // Pass stream so requestContainerRestart fans out the container-restarting
1468
+ // notice — the originating session's subscribeRestartNotice appends the
1469
+ // typeclaw.restart-self entry to its JSONL before the handoff is written, so
1470
+ // the rebooted container resumes with the "I'm back" instruction (same path
1471
+ // the agent restart tool uses).
1472
+ const originatingSessionFile = state?.sessionManager?.getSessionFile()
1473
+ const result = await requestContainerRestart({
1474
+ containerName,
1475
+ ...(agentDir !== undefined ? { agentDir } : {}),
1476
+ ...(state?.sessionFileId !== undefined ? { originatingSessionId: state.sessionFileId } : {}),
1477
+ ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
1478
+ ...(stream !== undefined ? { stream } : {}),
1479
+ })
1480
+ if (!result.ok) {
1481
+ send(ws, { type: 'restart_result', status: 'failed', error: result.reason })
1482
+ return
1483
+ }
1484
+
1485
+ // hostd's supervisor ACKs first, then runs stop+start in the background;
1486
+ // this process should not self-exit or it could race the daemon-owned stop.
1487
+ send(ws, {
1488
+ type: 'restart_result',
1489
+ status: 'accepted',
1490
+ message: 'restart scheduled; reconnecting when the new container is up',
1491
+ })
1492
+ }
@@ -130,6 +130,7 @@ export type InspectServerMessage =
130
130
  export type ClientMessage =
131
131
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
132
132
  | { type: 'reload'; scope?: string }
133
+ | { type: 'restart' }
133
134
  | { type: 'abort' }
134
135
  | { type: 'queue_cancel'; messageId: string }
135
136
  | { type: 'doctor'; requestId: DoctorRequestId }
@@ -213,6 +214,7 @@ export type ServerMessage =
213
214
  | { type: 'done' }
214
215
  | { type: 'error'; message: string }
215
216
  | { type: 'reload_result'; results: ReloadResultPayload[] }
217
+ | { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
216
218
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
217
219
  | { type: 'queue_state'; pending: QueueStateItem[] }
218
220
  | { type: 'prompt_started'; messageId: string; text: string }