typeclaw 0.21.0 → 0.23.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.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/session-origin.ts +41 -2
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +34 -12
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +8 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/slack-bot.ts +112 -5
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +100 -15
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +27 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect-controller.ts +92 -0
- package/src/cli/inspect.ts +21 -123
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/inspect/index.ts +8 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- package/typeclaw.schema.json +82 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { McpServer } from '@/config/config'
|
|
2
|
+
|
|
3
|
+
import { connectMcpServer, type McpConnection } from './client'
|
|
4
|
+
|
|
5
|
+
const TOOL_NAMESPACE_SEPARATOR = '__'
|
|
6
|
+
|
|
7
|
+
export type McpConnectResult =
|
|
8
|
+
| { ok: true; name: string; connection: McpConnection; toolCount: number }
|
|
9
|
+
| { ok: false; name: string; error: Error }
|
|
10
|
+
|
|
11
|
+
export type McpRefreshResult = { ok: true; name: string; toolCount: number } | { ok: false; name: string; error: Error }
|
|
12
|
+
|
|
13
|
+
export type McpManager = {
|
|
14
|
+
connectAll(opts?: { signal?: AbortSignal }): Promise<McpConnectResult[]>
|
|
15
|
+
getConnection(name: string): McpConnection | undefined
|
|
16
|
+
listServers(): { name: string; description?: string; connected: boolean; toolCount?: number }[]
|
|
17
|
+
refresh(): Promise<McpRefreshResult[]>
|
|
18
|
+
closeAll(): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ConnectMcpServerFn = (
|
|
22
|
+
server: McpServer,
|
|
23
|
+
opts: { env: NodeJS.ProcessEnv; signal?: AbortSignal },
|
|
24
|
+
) => Promise<McpConnection>
|
|
25
|
+
|
|
26
|
+
export function createMcpManager(
|
|
27
|
+
servers: McpServer[],
|
|
28
|
+
opts: { env: NodeJS.ProcessEnv; connect?: ConnectMcpServerFn },
|
|
29
|
+
): McpManager {
|
|
30
|
+
const activeServers = servers.filter((server) => server.enabled)
|
|
31
|
+
const connect = opts.connect ?? connectMcpServer
|
|
32
|
+
const connections = new Map<string, McpConnection>()
|
|
33
|
+
const toolCounts = new Map<string, number>()
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
async connectAll(connectOpts: { signal?: AbortSignal } = {}): Promise<McpConnectResult[]> {
|
|
37
|
+
const firstIndexByName = new Map<string, number>()
|
|
38
|
+
const results = await Promise.all(
|
|
39
|
+
activeServers.map((server, index): Promise<McpConnectResult> => {
|
|
40
|
+
// The name is the tool namespace and the connections key, so a second
|
|
41
|
+
// server sharing a name would silently shadow the first. Fail the
|
|
42
|
+
// duplicate fast instead of connecting it, keeping routing unambiguous.
|
|
43
|
+
const firstIndex = firstIndexByName.get(server.name)
|
|
44
|
+
if (firstIndex !== undefined) {
|
|
45
|
+
return Promise.resolve({
|
|
46
|
+
ok: false,
|
|
47
|
+
name: server.name,
|
|
48
|
+
error: new Error(
|
|
49
|
+
`mcpServers[${index}].name duplicates mcpServers[${firstIndex}].name ('${server.name}')`,
|
|
50
|
+
),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
firstIndexByName.set(server.name, index)
|
|
54
|
+
return connectOne(server, opts.env, connect, connectOpts.signal)
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
for (const result of results) {
|
|
58
|
+
if (!result.ok) continue
|
|
59
|
+
connections.set(result.name, result.connection)
|
|
60
|
+
toolCounts.set(result.name, result.toolCount)
|
|
61
|
+
}
|
|
62
|
+
return results
|
|
63
|
+
},
|
|
64
|
+
getConnection(name: string): McpConnection | undefined {
|
|
65
|
+
return connections.get(name)
|
|
66
|
+
},
|
|
67
|
+
listServers(): { name: string; description?: string; connected: boolean; toolCount?: number }[] {
|
|
68
|
+
return activeServers.map((server) => {
|
|
69
|
+
const toolCount = toolCounts.get(server.name)
|
|
70
|
+
return {
|
|
71
|
+
name: server.name,
|
|
72
|
+
connected: connections.has(server.name),
|
|
73
|
+
...(server.description === undefined ? {} : { description: server.description }),
|
|
74
|
+
...(toolCount === undefined ? {} : { toolCount }),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
async refresh(): Promise<McpRefreshResult[]> {
|
|
79
|
+
// One unhealthy connection must not discard healthy servers' tool-count
|
|
80
|
+
// updates, so each refresh is isolated and reported per-server instead of
|
|
81
|
+
// letting a single rejection fail the whole batch.
|
|
82
|
+
return Promise.all(
|
|
83
|
+
[...connections.entries()].map(async ([name, connection]): Promise<McpRefreshResult> => {
|
|
84
|
+
try {
|
|
85
|
+
const tools = await connection.refresh()
|
|
86
|
+
toolCounts.set(name, tools.length)
|
|
87
|
+
return { ok: true, name, toolCount: tools.length }
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
return { ok: false, name, error: normalizeError(cause) }
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
},
|
|
94
|
+
async closeAll(): Promise<void> {
|
|
95
|
+
await Promise.allSettled([...connections.values()].map((connection) => connection.close()))
|
|
96
|
+
connections.clear()
|
|
97
|
+
toolCounts.clear()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function namespaceToolName(server: string, tool: string): string {
|
|
103
|
+
return `${server}${TOOL_NAMESPACE_SEPARATOR}${tool}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseNamespacedTool(namespaced: string): { server: string; tool: string } | undefined {
|
|
107
|
+
// Config validation reserves `__` out of server names; splitting on the first
|
|
108
|
+
// separator is therefore unambiguous even when MCP tool names contain it.
|
|
109
|
+
const separatorIndex = namespaced.indexOf(TOOL_NAMESPACE_SEPARATOR)
|
|
110
|
+
if (separatorIndex <= 0) return undefined
|
|
111
|
+
|
|
112
|
+
const toolStart = separatorIndex + TOOL_NAMESPACE_SEPARATOR.length
|
|
113
|
+
if (toolStart >= namespaced.length) return undefined
|
|
114
|
+
|
|
115
|
+
return { server: namespaced.slice(0, separatorIndex), tool: namespaced.slice(toolStart) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function connectOne(
|
|
119
|
+
server: McpServer,
|
|
120
|
+
env: NodeJS.ProcessEnv,
|
|
121
|
+
connect: ConnectMcpServerFn,
|
|
122
|
+
signal: AbortSignal | undefined,
|
|
123
|
+
): Promise<McpConnectResult> {
|
|
124
|
+
let connection: McpConnection | undefined
|
|
125
|
+
try {
|
|
126
|
+
connection = await connect(server, { env, signal })
|
|
127
|
+
const tools = await connection.listTools()
|
|
128
|
+
return { ok: true, name: server.name, connection, toolCount: tools.length }
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
const closeError = connection === undefined ? undefined : await closeAfterFailedCatalog(connection)
|
|
131
|
+
if (closeError !== undefined) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
name: server.name,
|
|
135
|
+
error: new Error(`MCP server "${server.name}" failed to connect and then failed to close`, {
|
|
136
|
+
cause: { original: cause, close: closeError },
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, name: server.name, error: normalizeError(cause) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function closeAfterFailedCatalog(connection: McpConnection): Promise<Error | undefined> {
|
|
145
|
+
try {
|
|
146
|
+
await connection.close()
|
|
147
|
+
return undefined
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
return normalizeError(cause)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeError(cause: unknown): Error {
|
|
154
|
+
if (cause instanceof Error) return cause
|
|
155
|
+
return new Error(String(cause))
|
|
156
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/reload/index.ts
CHANGED
|
@@ -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
|
})
|
package/src/server/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from
|
|
|
19
19
|
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
20
20
|
import type { ChannelRouter } from '@/channels/router'
|
|
21
21
|
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
22
|
+
import type { McpManager } from '@/mcp'
|
|
22
23
|
import type { HookBus } from '@/plugin'
|
|
23
24
|
import type { BrokerWsData, ContainerBroker } from '@/portbroker'
|
|
24
25
|
import type { ReloadAllResult, ReloadRegistry } from '@/reload'
|
|
@@ -61,6 +62,7 @@ export type ServerOptions = {
|
|
|
61
62
|
sessionFactory?: SessionFactory
|
|
62
63
|
stream?: Stream
|
|
63
64
|
channelRouter?: ChannelRouter
|
|
65
|
+
mcpManager?: McpManager
|
|
64
66
|
agentDir?: string
|
|
65
67
|
pluginRuntime?: PluginRuntime
|
|
66
68
|
containerName?: string
|
|
@@ -221,6 +223,7 @@ export function createServer({
|
|
|
221
223
|
sessionFactory,
|
|
222
224
|
stream,
|
|
223
225
|
channelRouter,
|
|
226
|
+
mcpManager,
|
|
224
227
|
agentDir,
|
|
225
228
|
pluginRuntime,
|
|
226
229
|
containerName,
|
|
@@ -471,6 +474,7 @@ export function createServer({
|
|
|
471
474
|
origin,
|
|
472
475
|
...(stream ? { stream } : {}),
|
|
473
476
|
...(channelRouter ? { channelRouter } : {}),
|
|
477
|
+
...(mcpManager ? { mcpManager } : {}),
|
|
474
478
|
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
475
479
|
...(containerName !== undefined ? { containerName } : {}),
|
|
476
480
|
...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
|