mixdog 0.9.0 → 0.9.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/package.json +3 -3
- package/scripts/session-ingest-smoke.mjs +2 -2
- package/src/headless-role.mjs +1 -1
- package/src/lib/mixdog-debug.cjs +0 -22
- package/src/lib/plugin-paths.cjs +1 -7
- package/src/lib/rules-builder.cjs +2 -2
- package/src/mixdog-session-runtime.mjs +0 -1
- package/src/repl.mjs +0 -2
- package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +27 -49
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +8 -27
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +52 -182
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +8 -30
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +258 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
- package/src/runtime/channels/index.mjs +0 -30
- package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
- package/src/runtime/channels/lib/config.mjs +0 -1
- package/src/runtime/channels/lib/drop-trace.mjs +1 -1
- package/src/runtime/channels/lib/executor.mjs +0 -3
- package/src/runtime/channels/lib/memory-client.mjs +0 -38
- package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
- package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
- package/src/runtime/channels/lib/session-discovery.mjs +0 -4
- package/src/runtime/channels/lib/tool-format.mjs +0 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +1 -10
- package/src/runtime/lib/keychain-cjs.cjs +0 -1
- package/src/runtime/memory/data/runtime-manifest.json +6 -7
- package/src/runtime/memory/index.mjs +3 -24
- package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
- package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
- package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
- package/src/runtime/memory/lib/session-ingest.mjs +9 -7
- package/src/runtime/search/index.mjs +2 -7
- package/src/runtime/search/lib/config.mjs +0 -4
- package/src/runtime/search/lib/state.mjs +1 -15
- package/src/runtime/search/lib/web-tools.mjs +0 -1
- package/src/runtime/shared/child-spawn-gate.mjs +0 -6
- package/src/standalone/seeds.mjs +1 -11
- package/src/tui/App.jsx +35 -12
- package/src/tui/components/PromptInput.jsx +63 -2
- package/src/tui/components/ToolExecution.jsx +7 -2
- package/src/tui/components/tool-output-format.mjs +156 -22
- package/src/tui/components/tool-output-format.test.mjs +93 -1
- package/src/tui/dist/index.mjs +473 -116
- package/src/tui/markdown/format-token.mjs +267 -108
- package/src/tui/markdown/format-token.test.mjs +105 -9
- package/src/tui/theme.mjs +10 -0
- package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
- package/vendor/ink/build/ink.js +54 -8
- package/src/hooks/lib/permission-rules.cjs +0 -170
- package/src/hooks/lib/settings-loader.cjs +0 -112
- package/src/lib/hook-pipe-path.cjs +0 -10
- package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
|
@@ -1,671 +0,0 @@
|
|
|
1
|
-
// Hook IPC daemon — Windows named-pipe server consumed by mixdog-shim.exe.
|
|
2
|
-
//
|
|
3
|
-
// Replaces the per-spawn cold-start cost of `bun hooks/*.cjs` (≈86ms) with a
|
|
4
|
-
// single long-lived listener inside the channels worker. The shim is a tiny
|
|
5
|
-
// Rust .exe (~111KB, ~5-10ms cold) that connects, writes one JSON line, reads
|
|
6
|
-
// one JSON line back, and exits.
|
|
7
|
-
//
|
|
8
|
-
// Protocol (line-delimited JSON):
|
|
9
|
-
// client → server : <Mixdog hook payload>\n
|
|
10
|
-
// server → client : <decision-json or "null">\n
|
|
11
|
-
//
|
|
12
|
-
// Each connection is handled independently. Long-running handlers (Discord
|
|
13
|
-
// permission polling, up to 2 minutes) do not block other connections.
|
|
14
|
-
//
|
|
15
|
-
// Failure model: dispatch errors emit "null" (fail-open). The shim itself
|
|
16
|
-
// also fails open when the pipe is unreachable.
|
|
17
|
-
|
|
18
|
-
import { createServer, createConnection } from 'node:net'
|
|
19
|
-
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs'
|
|
20
|
-
import { appendFile } from 'node:fs/promises'
|
|
21
|
-
import { join, resolve as pathResolve } from 'node:path'
|
|
22
|
-
import { tmpdir } from 'node:os'
|
|
23
|
-
import { randomBytes } from 'node:crypto'
|
|
24
|
-
import { request as httpsRequest } from 'node:https'
|
|
25
|
-
import { createRequire } from 'node:module'
|
|
26
|
-
import { resolvePluginData } from '../../shared/plugin-paths.mjs'
|
|
27
|
-
|
|
28
|
-
const moduleRequire = createRequire(import.meta.url)
|
|
29
|
-
const {
|
|
30
|
-
isMixdogDebugEnabled,
|
|
31
|
-
pruneStalePluginDataLogSiblings,
|
|
32
|
-
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
33
|
-
} = moduleRequire('../../../lib/mixdog-debug.cjs')
|
|
34
|
-
|
|
35
|
-
// IPC transport path. Windows uses a named pipe (`\\.\pipe\…`); Unix uses a
|
|
36
|
-
// Unix domain socket under XDG_RUNTIME_DIR (or /tmp as fallback). Node's
|
|
37
|
-
// net.createServer().listen() accepts both transparently.
|
|
38
|
-
const PIPE_PATH = moduleRequire('../../../lib/hook-pipe-path.cjs')()
|
|
39
|
-
|
|
40
|
-
// Honor MIXDOG_RUNTIME_ROOT consistently with runtime-paths.mjs (the consumer
|
|
41
|
-
// of these tool-exec signals): when the override is set, the signal PRODUCER
|
|
42
|
-
// here must write into the same root the channels worker watches, or signals
|
|
43
|
-
// are silently dropped. Default stays tmpdir()/mixdog so non-override installs
|
|
44
|
-
// are unchanged.
|
|
45
|
-
const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
46
|
-
? pathResolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
47
|
-
: join(tmpdir(), 'mixdog')
|
|
48
|
-
const SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-consumer')
|
|
49
|
-
const SUBAGENT_SIGNAL_CONSUMER_MARKER = join(RUNTIME_ROOT, '.tool-exec-subagent-consumer')
|
|
50
|
-
const SIGNAL_RE_GENERIC = /^tool-exec-\d+-[0-9a-f]+\.signal$/
|
|
51
|
-
const SIGNAL_RE_CAPTURE = /^tool-exec-(\d+)-[0-9a-f]+\.signal$/
|
|
52
|
-
const SWEEP_MARKER = join(RUNTIME_ROOT, '.tool-exec-sweep')
|
|
53
|
-
const SWEEP_INTERVAL_MS = 30_000
|
|
54
|
-
const SIGNAL_TTL_MS = 60_000
|
|
55
|
-
// Marketplace installs use two naming shapes for the MCP server name —
|
|
56
|
-
// `plugin_mixdog_mixdog__` (legacy / mixdog marketplace) and
|
|
57
|
-
// `plugin_mixdog_trib-plugin__` (trib-plugin marketplace). PreToolUse
|
|
58
|
-
// sandbox checks must recognise both or sandbox evaluation silently
|
|
59
|
-
// misses MCP tool names from the other install layout.
|
|
60
|
-
const MCP_PREFIXES = [
|
|
61
|
-
'mcp__plugin_mixdog_mixdog__',
|
|
62
|
-
'mcp__plugin_mixdog_trib-plugin__',
|
|
63
|
-
]
|
|
64
|
-
const NATIVE_FILE_LOOKUP_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Search', 'LS'])
|
|
65
|
-
function isMcpToolName(name) {
|
|
66
|
-
if (!name) return false
|
|
67
|
-
return MCP_PREFIXES.some(p => name.startsWith(p))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const POLL_INTERVAL_MS = 2000
|
|
71
|
-
const SUBAGENT_TIMEOUT_MS = 120_000
|
|
72
|
-
const DEFAULT_DISPATCH_TIMEOUT_MS = 15_000
|
|
73
|
-
const SESSION_START_MEMORY_DISPATCH_TIMEOUT_MS = 125_000
|
|
74
|
-
const MIXDOG_DEBUG_ENABLED = isMixdogDebugEnabled()
|
|
75
|
-
let _hookPipeLogsPruned = false
|
|
76
|
-
|
|
77
|
-
function hookPipeDebugStderr(line) {
|
|
78
|
-
if (!MIXDOG_DEBUG_ENABLED) return
|
|
79
|
-
try { process.stderr.write(line) } catch {}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
let _started = false
|
|
83
|
-
let _server = null
|
|
84
|
-
let _subagentSignalConsumers = 0
|
|
85
|
-
|
|
86
|
-
function refreshSubagentSignalConsumerMarker() {
|
|
87
|
-
try {
|
|
88
|
-
if (_subagentSignalConsumers > 0) {
|
|
89
|
-
try { mkdirSync(RUNTIME_ROOT, { recursive: true }) } catch {}
|
|
90
|
-
writeFileSync(SUBAGENT_SIGNAL_CONSUMER_MARKER, String(Date.now()))
|
|
91
|
-
} else {
|
|
92
|
-
try { unlinkSync(SUBAGENT_SIGNAL_CONSUMER_MARKER) } catch {}
|
|
93
|
-
}
|
|
94
|
-
} catch {}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function formatError(err) {
|
|
98
|
-
const msg = (err && (err.stack || err.message)) || err
|
|
99
|
-
return String(msg || 'unknown').replace(/\s+/g, ' ').slice(0, 2000)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function traceSessionStart(message) {
|
|
103
|
-
if (!MIXDOG_DEBUG_ENABLED) return
|
|
104
|
-
const line = `[${new Date().toISOString()}] [hook-pipe][session-start] ${message}\n`
|
|
105
|
-
try { process.stderr.write(line) } catch {}
|
|
106
|
-
try {
|
|
107
|
-
const dataDir = resolvePluginData()
|
|
108
|
-
mkdirSync(dataDir, { recursive: true })
|
|
109
|
-
if (!_hookPipeLogsPruned) {
|
|
110
|
-
_hookPipeLogsPruned = true
|
|
111
|
-
pruneStalePluginDataLogSiblings(dataDir, DEFAULT_STALE_LOG_SIBLING_MAX)
|
|
112
|
-
}
|
|
113
|
-
void appendFile(join(dataDir, 'session-start.log'), line).catch(() => {})
|
|
114
|
-
} catch {}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function dispatchTimeoutMsForPayload(payload) {
|
|
118
|
-
const event = payload?.hook_event_name || payload?.hookEventName || ''
|
|
119
|
-
if (event !== 'SessionStart') return DEFAULT_DISPATCH_TIMEOUT_MS
|
|
120
|
-
const argsArr = payload?._args || []
|
|
121
|
-
const partArg = argsArr.find(a => a.startsWith('--part='))
|
|
122
|
-
const part = partArg ? partArg.slice('--part='.length) : ''
|
|
123
|
-
return (part === 'core' || part === 'recap')
|
|
124
|
-
? SESSION_START_MEMORY_DISPATCH_TIMEOUT_MS
|
|
125
|
-
: DEFAULT_DISPATCH_TIMEOUT_MS
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── post-tool-use handler ────────────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
function sweepStaleSignalsThrottled(now = Date.now()) {
|
|
131
|
-
try {
|
|
132
|
-
let lastSweep = 0
|
|
133
|
-
try { lastSweep = statSync(SWEEP_MARKER).mtimeMs } catch {}
|
|
134
|
-
if (now - lastSweep < SWEEP_INTERVAL_MS) return
|
|
135
|
-
try { writeFileSync(SWEEP_MARKER, String(now)) } catch {}
|
|
136
|
-
const entries = readdirSync(RUNTIME_ROOT)
|
|
137
|
-
for (const name of entries) {
|
|
138
|
-
if (!SIGNAL_RE_GENERIC.test(name)) continue
|
|
139
|
-
const p = join(RUNTIME_ROOT, name)
|
|
140
|
-
try {
|
|
141
|
-
const st = statSync(p)
|
|
142
|
-
if (now - st.mtimeMs > SIGNAL_TTL_MS) unlinkSync(p)
|
|
143
|
-
} catch {}
|
|
144
|
-
}
|
|
145
|
-
} catch {}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function handlePostToolUse(payload) {
|
|
149
|
-
const toolName = payload?.tool_name || payload?.toolName || ''
|
|
150
|
-
if (!toolName) return null
|
|
151
|
-
if (_subagentSignalConsumers <= 0 &&
|
|
152
|
-
!existsSync(SIGNAL_CONSUMER_MARKER) &&
|
|
153
|
-
!existsSync(SUBAGENT_SIGNAL_CONSUMER_MARKER)) {
|
|
154
|
-
return null
|
|
155
|
-
}
|
|
156
|
-
const filePath = payload?.tool_input?.file_path || payload?.toolInput?.file_path || ''
|
|
157
|
-
const toolUseId = payload?.tool_use_id || payload?.toolUseId || ''
|
|
158
|
-
|
|
159
|
-
try { if (!existsSync(RUNTIME_ROOT)) mkdirSync(RUNTIME_ROOT, { recursive: true }) } catch {}
|
|
160
|
-
sweepStaleSignalsThrottled()
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
const rand = randomBytes(4).toString('hex')
|
|
164
|
-
const signalFile = join(RUNTIME_ROOT, `tool-exec-${Date.now()}-${rand}.signal`)
|
|
165
|
-
writeFileSync(signalFile, JSON.stringify({ toolName, filePath, toolUseId, ts: Date.now() }))
|
|
166
|
-
} catch (err) {
|
|
167
|
-
process.stderr.write(`[hook-pipe] post-tool-use signal write failed: ${err?.message || err}\n`)
|
|
168
|
-
}
|
|
169
|
-
return null
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── pre-mcp-sandbox handler ──────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
function handlePreMcpSandbox(payload) {
|
|
175
|
-
const toolName = payload?.tool_name || payload?.toolName || ''
|
|
176
|
-
if (!isMcpToolName(toolName)) return null
|
|
177
|
-
|
|
178
|
-
const toolInput = payload?.tool_input ?? payload?.toolInput ?? {}
|
|
179
|
-
|
|
180
|
-
let userCwdRaw = payload?.cwd || ''
|
|
181
|
-
if (!userCwdRaw) {
|
|
182
|
-
try { userCwdRaw = readFileSync(join(resolvePluginData(), 'user-cwd.txt'), 'utf8').trim() } catch {}
|
|
183
|
-
}
|
|
184
|
-
if (!userCwdRaw) userCwdRaw = process.cwd()
|
|
185
|
-
|
|
186
|
-
const userCwd = pathResolve(userCwdRaw)
|
|
187
|
-
const projectDir = payload?.projectDir || payload?.project_dir ||
|
|
188
|
-
process.env.MIXDOG_PROJECT_DIR || userCwd
|
|
189
|
-
const permissionMode = payload?.permissionMode || payload?.permission_mode || undefined
|
|
190
|
-
|
|
191
|
-
let settingsPerms, evaluatePermission
|
|
192
|
-
try {
|
|
193
|
-
const settingsLoader = moduleRequire('../../../hooks/lib/settings-loader.cjs')
|
|
194
|
-
settingsPerms = settingsLoader.loadPermissions(projectDir)
|
|
195
|
-
} catch (err) {
|
|
196
|
-
process.stderr.write(`[hook-pipe] pre-mcp-sandbox settings-loader unavailable: ${err?.message || err}\n`)
|
|
197
|
-
return null
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
const ev = moduleRequire('../../../hooks/lib/permission-evaluator.cjs')
|
|
201
|
-
evaluatePermission = ev.evaluatePermission
|
|
202
|
-
} catch (err) {
|
|
203
|
-
process.stderr.write(`[hook-pipe] pre-mcp-sandbox evaluator unavailable: ${err?.message || err}\n`)
|
|
204
|
-
return null
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const evalResult = evaluatePermission({ toolName, toolInput, permissionMode, projectDir, userCwd, permissions: settingsPerms })
|
|
208
|
-
const { decision, reason } = evalResult
|
|
209
|
-
|
|
210
|
-
// Pi-like practical: no permission prompts. Only hard-deny and explicit
|
|
211
|
-
// user deny rules block; every other evaluator result is allowed.
|
|
212
|
-
if (decision === 'deny') return makeDecision('deny', reason)
|
|
213
|
-
return null
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function handleNativeFileLookup(payload) {
|
|
217
|
-
const toolName = payload?.tool_name || payload?.toolName || ''
|
|
218
|
-
if (!NATIVE_FILE_LOOKUP_TOOLS.has(toolName)) return null
|
|
219
|
-
return makeDecision(
|
|
220
|
-
'deny',
|
|
221
|
-
`Native ${toolName} is disabled by Mixdog. Use the Mixdog MCP read/grep/glob/list tools instead.`
|
|
222
|
-
)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function makeDecision(decision, reason, updatedInput) {
|
|
226
|
-
const out = {
|
|
227
|
-
hookSpecificOutput: {
|
|
228
|
-
hookEventName: 'PreToolUse',
|
|
229
|
-
permissionDecision: decision,
|
|
230
|
-
permissionDecisionReason: reason,
|
|
231
|
-
},
|
|
232
|
-
}
|
|
233
|
-
if (updatedInput !== undefined) out.hookSpecificOutput.updatedInput = updatedInput
|
|
234
|
-
return out
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── pre-tool-subagent handler (Discord permission flow, async) ───────────────
|
|
238
|
-
|
|
239
|
-
function sanitize(value) {
|
|
240
|
-
return String(value).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function readDiscordConfig() {
|
|
244
|
-
try {
|
|
245
|
-
const { readSection } = moduleRequire('../../../lib/config-cjs.cjs')
|
|
246
|
-
return readSection('channels')
|
|
247
|
-
} catch { return {} }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function isProtectedPath(filePath, cwd) {
|
|
251
|
-
if (!filePath) return false
|
|
252
|
-
const norm = pathResolve(filePath).replace(/\\/g, '/').toLowerCase()
|
|
253
|
-
const cwdNorm = (cwd || process.cwd()).replace(/\\/g, '/').toLowerCase()
|
|
254
|
-
const insideCwd = cwdNorm && (norm === cwdNorm || norm.startsWith(cwdNorm.endsWith('/') ? cwdNorm : cwdNorm + '/'))
|
|
255
|
-
return !insideCwd
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function findAndClaimSignal(toolName, filePath, toolUseId, hookStartedAt) {
|
|
259
|
-
let entries
|
|
260
|
-
try { entries = readdirSync(RUNTIME_ROOT) } catch { return null }
|
|
261
|
-
for (const name of entries) {
|
|
262
|
-
const m = SIGNAL_RE_CAPTURE.exec(name)
|
|
263
|
-
if (!m) continue
|
|
264
|
-
const ts = Number(m[1])
|
|
265
|
-
if (!Number.isFinite(ts) || ts < hookStartedAt) continue
|
|
266
|
-
const p = join(RUNTIME_ROOT, name)
|
|
267
|
-
let raw
|
|
268
|
-
try { raw = readFileSync(p, 'utf8') } catch { continue }
|
|
269
|
-
let parsed
|
|
270
|
-
try { parsed = JSON.parse(raw) } catch { continue }
|
|
271
|
-
if (parsed?.toolName !== toolName) continue
|
|
272
|
-
if (parsed?.filePath !== filePath) continue
|
|
273
|
-
if (toolUseId && parsed?.toolUseId !== toolUseId) continue
|
|
274
|
-
try { unlinkSync(p) } catch {}
|
|
275
|
-
return p
|
|
276
|
-
}
|
|
277
|
-
return null
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function discordApi(method, apiPath, token, body) {
|
|
281
|
-
return new Promise((resolve, reject) => {
|
|
282
|
-
const data = body ? JSON.stringify(body) : ''
|
|
283
|
-
const headers = { 'Authorization': 'Bot ' + token, 'Content-Type': 'application/json' }
|
|
284
|
-
if (data) headers['Content-Length'] = Buffer.byteLength(data)
|
|
285
|
-
const req = httpsRequest({ hostname: 'discord.com', path: apiPath, method, headers },
|
|
286
|
-
res => { let out = ''; res.on('data', d => { out += d }); res.on('end', () => { try { resolve(JSON.parse(out)) } catch { resolve({}) } }) })
|
|
287
|
-
req.setTimeout(10_000, () => req.destroy())
|
|
288
|
-
req.on('error', reject)
|
|
289
|
-
if (data) req.write(data)
|
|
290
|
-
req.end()
|
|
291
|
-
})
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
295
|
-
|
|
296
|
-
async function handlePreToolSubagent(payload) {
|
|
297
|
-
if (process.env.MIXDOG_CHANNELS_NO_CONNECT) return null
|
|
298
|
-
// Pi-like practical: Mixdog no longer opens Discord permission popups for
|
|
299
|
-
// subagent Edit/Write outside cwd. Native/role/tool guards still apply in
|
|
300
|
-
// their own layers; this hook simply stops adding an approval workflow.
|
|
301
|
-
return null
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// ── statusline handler (via dynamic ESM import) ──────────────────────────────
|
|
305
|
-
|
|
306
|
-
let _statusLineMod = null
|
|
307
|
-
let _statusLineLoadPromise = null
|
|
308
|
-
let _statusLineModMtimeMs = 0
|
|
309
|
-
|
|
310
|
-
async function ensureStatusLineMod() {
|
|
311
|
-
let mtimeMs = 0
|
|
312
|
-
try { mtimeMs = statSync(new URL('../../../bin/statusline-lib.mjs', import.meta.url)).mtimeMs } catch {}
|
|
313
|
-
if (_statusLineMod && mtimeMs && mtimeMs === _statusLineModMtimeMs) return _statusLineMod
|
|
314
|
-
if (_statusLineLoadPromise) return _statusLineLoadPromise
|
|
315
|
-
_statusLineLoadPromise = import(`../../../bin/statusline-lib.mjs?mtime=${encodeURIComponent(String(mtimeMs || Date.now()))}`)
|
|
316
|
-
.then(mod => { _statusLineMod = mod; _statusLineModMtimeMs = mtimeMs; _statusLineLoadPromise = null; return mod })
|
|
317
|
-
.catch(err => {
|
|
318
|
-
process.stderr.write(`[hook-pipe] statusline-lib import failed: ${err?.message || err}\n`)
|
|
319
|
-
_statusLineLoadPromise = null
|
|
320
|
-
return null
|
|
321
|
-
})
|
|
322
|
-
return _statusLineLoadPromise
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async function handleStatusLine(payload) {
|
|
326
|
-
const mod = await ensureStatusLineMod()
|
|
327
|
-
if (!mod || typeof mod.renderStatusLine !== 'function') return null
|
|
328
|
-
try {
|
|
329
|
-
return await mod.renderStatusLine(JSON.stringify(payload || {}))
|
|
330
|
-
} catch (err) {
|
|
331
|
-
process.stderr.write(`[hook-pipe] statusline render failed: ${err?.message || err}\n`)
|
|
332
|
-
return null
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ── SessionStart: rules/core/recap handlers (via require'd cjs) ──────────────
|
|
337
|
-
//
|
|
338
|
-
// session-start.cjs accesses fd 0 at the top level — we gate that behind
|
|
339
|
-
// MIXDOG_SKIP_TOP_STDIN so it doesn't consume the daemon's MCP stdio pipe.
|
|
340
|
-
// Each SessionStart slot gets a fresh CJS module instance. That keeps the
|
|
341
|
-
// module-globals (_event, PART, _emitSink) isolated, so rules/core/recap can
|
|
342
|
-
// run concurrently without a daemon-wide lock.
|
|
343
|
-
function loadSessionStartMod() {
|
|
344
|
-
const prev = process.env.MIXDOG_SKIP_TOP_STDIN
|
|
345
|
-
process.env.MIXDOG_SKIP_TOP_STDIN = '1'
|
|
346
|
-
const moduleId = moduleRequire.resolve('../../../hooks/session-start.cjs')
|
|
347
|
-
delete moduleRequire.cache[moduleId]
|
|
348
|
-
traceSessionStart('fresh require start path=../../../hooks/session-start.cjs')
|
|
349
|
-
try {
|
|
350
|
-
const mod = moduleRequire(moduleId)
|
|
351
|
-
delete moduleRequire.cache[moduleId]
|
|
352
|
-
traceSessionStart(`fresh require ok exports=${Object.keys(mod || {}).join(',')}`)
|
|
353
|
-
return mod
|
|
354
|
-
} catch (err) {
|
|
355
|
-
process.stderr.write(`[hook-pipe] session-start.cjs require failed: ${err?.message || err}\n`)
|
|
356
|
-
traceSessionStart(`require failed err=${formatError(err)}`)
|
|
357
|
-
return null
|
|
358
|
-
} finally {
|
|
359
|
-
if (prev === undefined) delete process.env.MIXDOG_SKIP_TOP_STDIN
|
|
360
|
-
else process.env.MIXDOG_SKIP_TOP_STDIN = prev
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async function handleSessionStartPart(args, payload) {
|
|
365
|
-
if (payload?.isSidechain || payload?.is_sidechain) {
|
|
366
|
-
traceSessionStart(`skip reason=sidechain source=${payload?.source || ''}`)
|
|
367
|
-
return null
|
|
368
|
-
}
|
|
369
|
-
if (payload?.agentId || payload?.agent_id) {
|
|
370
|
-
traceSessionStart(`skip reason=agent source=${payload?.source || ''} agent=${payload?.agentId || payload?.agent_id || ''}`)
|
|
371
|
-
return null
|
|
372
|
-
}
|
|
373
|
-
if (payload?.kind && payload.kind !== 'interactive') {
|
|
374
|
-
traceSessionStart(`skip reason=kind source=${payload?.source || ''} kind=${payload.kind}`)
|
|
375
|
-
return null
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const partArg = (args || []).find(a => a.startsWith('--part='))
|
|
379
|
-
const part = partArg ? partArg.slice('--part='.length) : null
|
|
380
|
-
if (!part || (part !== 'rules' && part !== 'core' && part !== 'recap')) {
|
|
381
|
-
traceSessionStart(`skip reason=invalid-part source=${payload?.source || ''} args=${JSON.stringify(args || [])}`)
|
|
382
|
-
return null
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const mod = loadSessionStartMod()
|
|
386
|
-
if (!mod) {
|
|
387
|
-
traceSessionStart(`skip reason=require-null part=${part} source=${payload?.source || ''}`)
|
|
388
|
-
return null
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
let buf = ''
|
|
392
|
-
let failed = false
|
|
393
|
-
const t0 = Date.now()
|
|
394
|
-
try {
|
|
395
|
-
traceSessionStart(
|
|
396
|
-
`run start part=${part} source=${payload?.source || ''} cwd=${payload?.cwd || ''} ` +
|
|
397
|
-
`sessionId=${payload?.session_id || payload?.sessionId || ''}`
|
|
398
|
-
)
|
|
399
|
-
try { mod.setEvent(payload || {}) } catch (err) {
|
|
400
|
-
failed = true
|
|
401
|
-
traceSessionStart(`setEvent failed part=${part} err=${formatError(err)}`)
|
|
402
|
-
}
|
|
403
|
-
try {
|
|
404
|
-
if (typeof mod.setPart === 'function') mod.setPart(part)
|
|
405
|
-
else traceSessionStart(`setPart unavailable part=${part}`)
|
|
406
|
-
} catch (err) {
|
|
407
|
-
failed = true
|
|
408
|
-
traceSessionStart(`setPart failed part=${part} err=${formatError(err)}`)
|
|
409
|
-
}
|
|
410
|
-
try { mod.setEmitSink(s => { buf += String(s) }) } catch (err) {
|
|
411
|
-
failed = true
|
|
412
|
-
traceSessionStart(`setEmitSink failed part=${part} err=${formatError(err)}`)
|
|
413
|
-
}
|
|
414
|
-
if (part === 'rules') await mod.runRulesPart()
|
|
415
|
-
else if (part === 'core') await mod.runCorePart()
|
|
416
|
-
else if (part === 'recap') await mod.runRecapPart()
|
|
417
|
-
} catch (err) {
|
|
418
|
-
failed = true
|
|
419
|
-
process.stderr.write(`[hook-pipe] session-start ${part} failed: ${err?.message || err}\n`)
|
|
420
|
-
traceSessionStart(`run failed part=${part} err=${formatError(err)}`)
|
|
421
|
-
} finally {
|
|
422
|
-
try { mod.setEmitSink(null) } catch (err) {
|
|
423
|
-
failed = true
|
|
424
|
-
traceSessionStart(`clearEmitSink failed part=${part} err=${formatError(err)}`)
|
|
425
|
-
}
|
|
426
|
-
traceSessionStart(
|
|
427
|
-
`run done part=${part} source=${payload?.source || ''} ` +
|
|
428
|
-
`bytes=${Buffer.byteLength(buf, 'utf8')} elapsed=${Date.now() - t0}ms failed=${failed}`
|
|
429
|
-
)
|
|
430
|
-
}
|
|
431
|
-
return buf || null
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ── SessionStart: clear-active-session handler ───────────────────────────────
|
|
435
|
-
|
|
436
|
-
function handleSessionStartClear() {
|
|
437
|
-
// Clear the active orchestrator session pointer so each Mixdog session
|
|
438
|
-
// starts fresh. Stored sessions on disk are NOT deleted — only the pointer.
|
|
439
|
-
try {
|
|
440
|
-
const dataDir = resolvePluginData()
|
|
441
|
-
const target = join(dataDir, 'active-session.txt')
|
|
442
|
-
try { unlinkSync(target) } catch {}
|
|
443
|
-
} catch (err) {
|
|
444
|
-
process.stderr.write(`[hook-pipe] session-start clear failed: ${err?.message || err}\n`)
|
|
445
|
-
}
|
|
446
|
-
return null
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
450
|
-
|
|
451
|
-
async function dispatch(payload) {
|
|
452
|
-
const event = payload?.hook_event_name || payload?.hookEventName || ''
|
|
453
|
-
const tool = payload?.tool_name || payload?.toolName || ''
|
|
454
|
-
const argsArr = payload?._args || []
|
|
455
|
-
|
|
456
|
-
// CLI-arg-driven routing (statusline + future entry points without a
|
|
457
|
-
// hook_event_name field).
|
|
458
|
-
const kindArg = argsArr.find(a => a.startsWith('--kind='))
|
|
459
|
-
if (kindArg) {
|
|
460
|
-
const kind = kindArg.slice('--kind='.length)
|
|
461
|
-
if (kind === 'statusline') return await handleStatusLine(payload)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
try {
|
|
465
|
-
if (event === 'PreToolUse') {
|
|
466
|
-
if (NATIVE_FILE_LOOKUP_TOOLS.has(tool)) {
|
|
467
|
-
return handleNativeFileLookup(payload)
|
|
468
|
-
}
|
|
469
|
-
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
|
|
470
|
-
return await handlePreToolSubagent(payload)
|
|
471
|
-
}
|
|
472
|
-
if (isMcpToolName(tool)) {
|
|
473
|
-
return handlePreMcpSandbox(payload)
|
|
474
|
-
}
|
|
475
|
-
} else if (event === 'PostToolUse') {
|
|
476
|
-
return handlePostToolUse(payload)
|
|
477
|
-
} else if (event === 'SessionStart') {
|
|
478
|
-
const argsArr = payload?._args || []
|
|
479
|
-
const hasPart = argsArr.some(a => a.startsWith('--part='))
|
|
480
|
-
if (hasPart) {
|
|
481
|
-
return await handleSessionStartPart(argsArr, payload)
|
|
482
|
-
}
|
|
483
|
-
// No --part: clear-active-session entry.
|
|
484
|
-
return handleSessionStartClear()
|
|
485
|
-
}
|
|
486
|
-
} catch (err) {
|
|
487
|
-
process.stderr.write(`[hook-pipe] dispatch error: ${err?.message || err}\n`)
|
|
488
|
-
}
|
|
489
|
-
return null
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// ── server ───────────────────────────────────────────────────────────────────
|
|
493
|
-
|
|
494
|
-
export function startHookPipeServer() {
|
|
495
|
-
if (_server) return _server
|
|
496
|
-
|
|
497
|
-
_server = createServer((socket) => {
|
|
498
|
-
let buf = ''
|
|
499
|
-
let handled = false
|
|
500
|
-
// Resource guards: a connection that never sends a newline-terminated
|
|
501
|
-
// payload would otherwise grow buf unbounded and hold the socket open
|
|
502
|
-
// forever. Cap the buffered bytes and idle-close a stalled connection.
|
|
503
|
-
const MAX_BUF_BYTES = 1 << 20 // 1 MiB
|
|
504
|
-
const IDLE_TIMEOUT_MS = 30_000
|
|
505
|
-
socket.setTimeout(IDLE_TIMEOUT_MS, () => {
|
|
506
|
-
if (!handled) { try { socket.destroy() } catch {} }
|
|
507
|
-
})
|
|
508
|
-
socket.on('data', async (chunk) => {
|
|
509
|
-
if (handled) return
|
|
510
|
-
buf += chunk.toString('utf8')
|
|
511
|
-
if (Buffer.byteLength(buf, 'utf8') > MAX_BUF_BYTES) {
|
|
512
|
-
handled = true
|
|
513
|
-
process.stderr.write(`[hook-pipe] payload exceeded ${MAX_BUF_BYTES} bytes without newline; dropping connection\n`)
|
|
514
|
-
try { socket.destroy() } catch {}
|
|
515
|
-
return
|
|
516
|
-
}
|
|
517
|
-
const firstNl = buf.indexOf('\n')
|
|
518
|
-
if (firstNl < 0) return
|
|
519
|
-
const firstLine = buf.slice(0, firstNl)
|
|
520
|
-
|
|
521
|
-
// Optional `args=` prefix line. When present, the actual payload is the
|
|
522
|
-
// second line; otherwise the first line IS the payload.
|
|
523
|
-
let args = []
|
|
524
|
-
let payloadLine
|
|
525
|
-
if (firstLine.startsWith('args=')) {
|
|
526
|
-
const secondNl = buf.indexOf('\n', firstNl + 1)
|
|
527
|
-
if (secondNl < 0) return // wait for more
|
|
528
|
-
args = firstLine.slice(5).split(' ').filter(Boolean)
|
|
529
|
-
payloadLine = buf.slice(firstNl + 1, secondNl)
|
|
530
|
-
} else {
|
|
531
|
-
payloadLine = firstLine
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
handled = true
|
|
535
|
-
let payload = null
|
|
536
|
-
try { payload = payloadLine ? JSON.parse(payloadLine) : null } catch {}
|
|
537
|
-
if (payload && args.length > 0) payload._args = args
|
|
538
|
-
|
|
539
|
-
// Per-request deadline: a hung handler would otherwise hold the hook
|
|
540
|
-
// client waiting for EOF forever, stalling the hook step. Race
|
|
541
|
-
// dispatch against a real timer; on timeout, write the no-op fallback and
|
|
542
|
-
// end the socket so the client unblocks.
|
|
543
|
-
const dispatchTimeoutMs = payload ? dispatchTimeoutMsForPayload(payload) : DEFAULT_DISPATCH_TIMEOUT_MS
|
|
544
|
-
let timedOut = false
|
|
545
|
-
let deadlineTimer = null
|
|
546
|
-
let reply = null
|
|
547
|
-
try {
|
|
548
|
-
if (payload) {
|
|
549
|
-
reply = await new Promise((resolve, reject) => {
|
|
550
|
-
deadlineTimer = setTimeout(() => {
|
|
551
|
-
timedOut = true
|
|
552
|
-
reject(new Error(`dispatch exceeded ${dispatchTimeoutMs}ms`))
|
|
553
|
-
}, dispatchTimeoutMs)
|
|
554
|
-
dispatch(payload).then(resolve, reject)
|
|
555
|
-
})
|
|
556
|
-
}
|
|
557
|
-
} catch (err) {
|
|
558
|
-
if (timedOut) {
|
|
559
|
-
process.stderr.write(`[hook-pipe] dispatch timed out after ${dispatchTimeoutMs}ms; writing no-op fallback\n`)
|
|
560
|
-
try { socket.write('null\n') } catch {}
|
|
561
|
-
try { socket.end() } catch {}
|
|
562
|
-
return
|
|
563
|
-
}
|
|
564
|
-
process.stderr.write(`[hook-pipe] handler threw: ${err?.message || err}\n`)
|
|
565
|
-
} finally {
|
|
566
|
-
if (deadlineTimer) { clearTimeout(deadlineTimer); deadlineTimer = null }
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Response shape:
|
|
570
|
-
// • object → JSON-stringified single line (legacy decision protocol)
|
|
571
|
-
// • string → raw text (multi-line session-start / statusline output)
|
|
572
|
-
// • null/undefined → "null" (no-op marker)
|
|
573
|
-
let out
|
|
574
|
-
if (reply == null) out = 'null'
|
|
575
|
-
else if (typeof reply === 'string') out = reply
|
|
576
|
-
else out = JSON.stringify(reply)
|
|
577
|
-
|
|
578
|
-
try { socket.write(out) } catch {}
|
|
579
|
-
if (!out.endsWith('\n')) { try { socket.write('\n') } catch {} }
|
|
580
|
-
try { socket.end() } catch {}
|
|
581
|
-
})
|
|
582
|
-
socket.on('error', () => {})
|
|
583
|
-
})
|
|
584
|
-
_server.on('error', (err) => {
|
|
585
|
-
const msg = String(err?.message || err || '')
|
|
586
|
-
if (err?.code === 'EADDRINUSE' || msg.includes('EADDRINUSE') || msg.includes('Failed to listen')) {
|
|
587
|
-
hookPipeDebugStderr(`[hook-pipe] ${PIPE_PATH} already owned by a peer daemon; standby for hook IPC\n`)
|
|
588
|
-
_server = null
|
|
589
|
-
_started = false
|
|
590
|
-
return
|
|
591
|
-
}
|
|
592
|
-
process.stderr.write(`[hook-pipe] server error: ${err?.message || err}\n`)
|
|
593
|
-
})
|
|
594
|
-
|
|
595
|
-
const beginListen = () => {
|
|
596
|
-
try {
|
|
597
|
-
_server.listen(PIPE_PATH, () => {
|
|
598
|
-
_started = true
|
|
599
|
-
hookPipeDebugStderr(`[hook-pipe] listening on ${PIPE_PATH}\n`)
|
|
600
|
-
})
|
|
601
|
-
} catch (err) {
|
|
602
|
-
process.stderr.write(`[hook-pipe] listen failed: ${err?.message || err}\n`)
|
|
603
|
-
_server = null
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
if (process.platform === 'win32') {
|
|
608
|
-
// Windows named pipes refuse a second listener with EADDRINUSE on their
|
|
609
|
-
// own, so no pre-listen probe is needed.
|
|
610
|
-
beginListen()
|
|
611
|
-
} else {
|
|
612
|
-
// Unix: a leftover socket file from a crashed prior daemon would make
|
|
613
|
-
// listen() fail with EADDRINUSE. But blindly unlinking would also steal
|
|
614
|
-
// the socket from a live sibling daemon, leaving it orphaned. Probe the
|
|
615
|
-
// path first — only unlink when nothing answers.
|
|
616
|
-
probeUnixSocketAlive(PIPE_PATH).then((alive) => {
|
|
617
|
-
if (alive) {
|
|
618
|
-
process.stderr.write(
|
|
619
|
-
`[hook-pipe] another mixdog daemon is already listening on ${PIPE_PATH}; refusing to start a second instance\n`
|
|
620
|
-
)
|
|
621
|
-
_server = null
|
|
622
|
-
return
|
|
623
|
-
}
|
|
624
|
-
try { unlinkSync(PIPE_PATH) } catch {}
|
|
625
|
-
beginListen()
|
|
626
|
-
})
|
|
627
|
-
}
|
|
628
|
-
return _server
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Best-effort liveness check for a Unix socket path. Resolves true when
|
|
632
|
-
// something is listening (connect succeeds), false when the path is dead
|
|
633
|
-
// (ECONNREFUSED) or absent (ENOENT). Other errors / timeout resolve true so
|
|
634
|
-
// we err on the side of NOT stealing a possibly-live peer's socket.
|
|
635
|
-
function probeUnixSocketAlive(socketPath) {
|
|
636
|
-
return new Promise((resolve) => {
|
|
637
|
-
let done = false
|
|
638
|
-
const finish = (alive) => {
|
|
639
|
-
if (done) return
|
|
640
|
-
done = true
|
|
641
|
-
try { client.destroy() } catch {}
|
|
642
|
-
clearTimeout(timer)
|
|
643
|
-
resolve(alive)
|
|
644
|
-
}
|
|
645
|
-
let client
|
|
646
|
-
try {
|
|
647
|
-
client = createConnection(socketPath)
|
|
648
|
-
} catch {
|
|
649
|
-
resolve(false)
|
|
650
|
-
return
|
|
651
|
-
}
|
|
652
|
-
const timer = setTimeout(() => finish(true), 300)
|
|
653
|
-
client.once('connect', () => finish(true))
|
|
654
|
-
client.once('error', (err) => {
|
|
655
|
-
const code = err && err.code
|
|
656
|
-
finish(!(code === 'ECONNREFUSED' || code === 'ENOENT'))
|
|
657
|
-
})
|
|
658
|
-
})
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
export function stopHookPipeServer() {
|
|
662
|
-
if (_server) {
|
|
663
|
-
try { _server.close() } catch {}
|
|
664
|
-
_server = null
|
|
665
|
-
_started = false
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
export function isHookPipeServerStarted() {
|
|
670
|
-
return _started
|
|
671
|
-
}
|