diff --git a/external_plugins/zcode-grokbot-dispatch/.mcp.json b/external_plugins/zcode-grokbot-dispatch/.mcp.json new file mode 100644 index 00000000..9a515559 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "dispatch": { + "type": "stdio", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/mcp/server.mjs"], + "timeoutMs": 60000 + } + } +} diff --git a/external_plugins/zcode-grokbot-dispatch/.zcode-plugin/plugin.json b/external_plugins/zcode-grokbot-dispatch/.zcode-plugin/plugin.json new file mode 100644 index 00000000..b0cc79dd --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/.zcode-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "zcode-grokbot-dispatch", + "version": "0.3.0", + "description": "Grok Bot Dispatch for ZCode: bidirectional task dispatch between ZCode desktop and Grok Bot / DSH ([Project: X] routing, persistent session<->job mapping, automatic result reporting back to your Grok Bot peer: direct HTTP by default, Yobiru optional).", + "description_i18n": { + "en": "Grok Bot Dispatch for ZCode: bidirectional task dispatch between ZCode desktop and Grok Bot / DSH ([Project: X] routing, persistent session<->job mapping, automatic result reporting back to your Grok Bot peer: direct HTTP by default, Yobiru optional).", + "zh-CN": "Grok Bot 派活插件(ZCode 版):ZCode 桌面版与 Grok Bot / DSH 双向派活([Project: X] 路由、会话↔job 持久映射、任务结果自动回报给 Grok Bot 对端,默认 HTTP 直连,Yobiru 可选)。" + }, + "author": { + "name": "semantic-craft", + "url": "https://github.com/semantic-craft/zcode-dispatch" + }, + "homepage": "https://github.com/semantic-craft/zcode-dispatch", + "repository": "https://github.com/semantic-craft/zcode-dispatch", + "license": "MIT", + "keywords": [ + "grok-bot", + "grokbot", + "dispatch", + "dsh", + "yobiru", + "hooks", + "mcp" + ], + "displayName": "Grok Bot Dispatch for ZCode" +} diff --git a/external_plugins/zcode-grokbot-dispatch/LICENSE b/external_plugins/zcode-grokbot-dispatch/LICENSE new file mode 100644 index 00000000..bab5612e --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 semantic-craft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/zcode-grokbot-dispatch/README.md b/external_plugins/zcode-grokbot-dispatch/README.md new file mode 100644 index 00000000..306218ed --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/README.md @@ -0,0 +1,34 @@ +# Grok Bot Dispatch for ZCode (zcode-grokbot-dispatch) + +> Plugin id: `zcode-grokbot-dispatch` (renamed from the pre-release id `zcode-dispatch`). Runtime paths are unchanged (`~/.config/zcode-dispatch/`, `~/.local/state/zcode-dispatch/`, `ZCODE_DISPATCH_*` env), so existing config keeps working; if you installed the old `zcode-dispatch` build, uninstall it and install `zcode-grokbot-dispatch`. + + +Bidirectional task dispatch between the ZCode desktop app and DSH (DeepSeek Harness) / Grok Bot, with `[Project: X]` routing, persistent session↔job mapping and automatic result reporting (direct HTTP by default, Yobiru optional). + +## Components +- **Commands**: `/dispatch-dsh `, `/dispatch-grok `, `/dispatch-status ` +- **MCP server** (stdio, `node mcp/server.mjs`): tools `dispatch_to_dsh`, `dispatch_to_grokbot`, `dispatch_status` +- **Hooks**: `UserPromptSubmit` (records session↔job mapping) and `Stop` (reports the finished session's result to the dispatching peer) + +## Install +1. ZCode → Settings → Plugins → `zcode-plugins-official` → install **zcode-grokbot-dispatch**. +2. Requires Node.js ≥ 22.5 on PATH. `/dispatch-dsh` also requires the `dsh` CLI (override with `DSH_BIN`). +3. Configure peers in `~/.config/zcode-dispatch/config.json` or env vars (env wins), e.g.: + ```json + { "transport": "direct", "peers": { "dsh": { "url": "http://:", "keyFile": "~/.config/zcode-dispatch/dsh.key" } } } + ``` + Relevant env: `ZCODE_DISPATCH_CONFIG`, `ZCODE_DISPATCH_TRANSPORT` (direct|yobiru), `ZCODE_DISPATCH_GROK_PEER`, `ZCODE_DISPATCH_PROJECTS_ROOT` (default `~/Projects`), `ZCODE_DISPATCH_DISCOVERY=0` (disable Tailscale discovery), `DSH_BIN`, `DSH_PROFILE`. + No endpoint is built in; nothing is sent until you configure a peer. + +## Side effects / privacy +- **Network**: the `Stop` hook and the dispatch tools send task text and up to ~3000 chars of the session's final result to the peer **you configured** — by HTTP (default, authenticated with your key file) or, if `transport` is `yobiru`, via the `yobiru` CLI (`yobiru send ...`). Sessions not started by a dispatch are not reported. +- **Commands executed**: `dsh --profile headless --json ` (for `/dispatch-dsh`), `yobiru` (only when transport is yobiru), and `tailscale status` for optional peer discovery (disable with `ZCODE_DISPATCH_DISCOVERY=0`). +- **Files written**: job/session mapping and reports under `~/.local/state/zcode-dispatch/` (and the plugin data dir), mode 0600/0700. Reads config/key files under `~/.config/zcode-dispatch/`. +- **Hooks**: runs on every `UserPromptSubmit` and `Stop` (timeouts 10s/20s); exits quietly when the session is not a dispatched job. +- No telemetry, no third-party services beyond the peer/Yobiru you configure. + +## Third-party code +None bundled; uses only Node.js built-in modules. + +## License +MIT © semantic-craft — source: https://github.com/semantic-craft/zcode-dispatch diff --git a/external_plugins/zcode-grokbot-dispatch/README_CN.md b/external_plugins/zcode-grokbot-dispatch/README_CN.md new file mode 100644 index 00000000..ee75de7a --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/README_CN.md @@ -0,0 +1,34 @@ +# Grok Bot Dispatch for ZCode(zcode-grokbot-dispatch,Grok Bot 双向派活) + +> 插件 id:`zcode-grokbot-dispatch`(由预发布 id `zcode-dispatch` 更名)。运行时路径不变(`~/.config/zcode-dispatch/`、`~/.local/state/zcode-dispatch/`、`ZCODE_DISPATCH_*` 环境变量),原配置继续可用;如装过旧的 `zcode-dispatch`,请先卸载再安装 `zcode-grokbot-dispatch`。 + + +ZCode 桌面版与 DSH(DeepSeek Harness)/ Grok Bot 之间的双向派活插件:按 `[Project: X]` 路由、会话↔job 持久映射、任务完成后自动回报结果(默认 HTTP 直连,Yobiru 可选)。 + +## 组件 +- **命令**:`/dispatch-dsh <项目> <任务>`、`/dispatch-grok <项目> <任务>`、`/dispatch-status ` +- **MCP 服务**(stdio,`node mcp/server.mjs`):工具 `dispatch_to_dsh`、`dispatch_to_grokbot`、`dispatch_status` +- **Hooks**:`UserPromptSubmit`(记录会话↔job 映射)、`Stop`(把派活会话的结果回报给派活方) + +## 安装 +1. ZCode → 设置 → Plugins → `zcode-plugins-official` → 安装 **zcode-grokbot-dispatch**。 +2. 需要 PATH 中有 Node.js ≥ 22.5;`/dispatch-dsh` 还需要 `dsh` CLI(可用 `DSH_BIN` 指定)。 +3. 在 `~/.config/zcode-dispatch/config.json` 或环境变量中配置对端(环境变量优先),例如: + ```json + { "transport": "direct", "peers": { "dsh": { "url": "http://<对端主机>:<端口>", "keyFile": "~/.config/zcode-dispatch/dsh.key" } } } + ``` + 相关环境变量:`ZCODE_DISPATCH_CONFIG`、`ZCODE_DISPATCH_TRANSPORT`(direct|yobiru)、`ZCODE_DISPATCH_GROK_PEER`、`ZCODE_DISPATCH_PROJECTS_ROOT`(默认 `~/Projects`)、`ZCODE_DISPATCH_DISCOVERY=0`(关闭 Tailscale 发现)、`DSH_BIN`、`DSH_PROFILE`。 + 插件不内置任何 endpoint,未配置对端时不会对外发送任何内容。 + +## 副作用与隐私 +- **网络**:`Stop` hook 与派活工具会把任务文本及会话最终结果(最多约 3000 字符)发送给**你自己配置的**对端——默认走 HTTP(用你的 key 文件鉴权);`transport` 设为 `yobiru` 时通过 `yobiru` CLI(`yobiru send ...`)发送。非派活产生的会话不会回报。 +- **执行命令**:`dsh --profile headless --json <任务>`(`/dispatch-dsh`)、`yobiru`(仅 yobiru 模式)、`tailscale status`(可选的对端发现,`ZCODE_DISPATCH_DISCOVERY=0` 关闭)。 +- **写入文件**:job/会话映射与回报记录写在 `~/.local/state/zcode-dispatch/`(及插件数据目录),权限 0600/0700;读取 `~/.config/zcode-dispatch/` 下的配置和 key。 +- **Hooks**:每次 `UserPromptSubmit` 与 `Stop` 触发(超时 10s/20s);非派活会话直接静默退出。 +- 无遥测;除你配置的对端/Yobiru 外不访问任何第三方服务。 + +## 第三方代码 +无,仅使用 Node.js 内置模块。 + +## 许可证 +MIT © semantic-craft —— 源码:https://github.com/semantic-craft/zcode-dispatch diff --git a/external_plugins/zcode-grokbot-dispatch/commands/dispatch-dsh.md b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-dsh.md new file mode 100644 index 00000000..38c2f50f --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-dsh.md @@ -0,0 +1,7 @@ +--- +description: 把任务派给 DSH(DeepSeek Harness,headless,按 [Project: X] 路由) +argument-hint: " <任务描述>" +--- +用 `dispatch_to_dsh` 工具把下面的任务派给 DSH。第一个参数是 ~/Projects 下的项目名($1),其余是任务正文;正文开头必须是 `[Project: $1]`(工具会自动补)。派完把返回的 job_id 告诉我,之后可用 `dispatch_status` 查结果;不要自己去做这个任务。 + +参数:$ARGUMENTS diff --git a/external_plugins/zcode-grokbot-dispatch/commands/dispatch-grok.md b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-grok.md new file mode 100644 index 00000000..77fb2b85 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-grok.md @@ -0,0 +1,7 @@ +--- +description: 把任务派给 Grok Bot(经 Yobiru,按 [Project: X] 路由) +argument-hint: " <任务描述>" +--- +用 `dispatch_to_grokbot` 工具把下面的任务派给 Grok Bot。第一个参数是 ~/Projects 下的项目名($1),其余是任务正文;正文开头必须是 `[Project: $1]`(工具会自动补)。派完把返回的 request_id 告诉我,不要自己去做这个任务。 + +参数:$ARGUMENTS diff --git a/external_plugins/zcode-grokbot-dispatch/commands/dispatch-status.md b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-status.md new file mode 100644 index 00000000..d8dd0235 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/commands/dispatch-status.md @@ -0,0 +1,5 @@ +--- +description: 查询派出去的任务状态(DSH job id 或 Yobiru request id) +argument-hint: "" +--- +用 `dispatch_status` 工具查询 id `$1` 的状态,并简要汇报 state 和结果。 diff --git a/external_plugins/zcode-grokbot-dispatch/hooks/hooks.json b/external_plugins/zcode-grokbot-dispatch/hooks/hooks.json new file mode 100644 index 00000000..9c5b45aa --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/hooks/hooks.json @@ -0,0 +1,31 @@ +{ + "description": "zcode-dispatch: bind session_id <-> job_id on UserPromptSubmit and auto-report dispatched-session results via Yobiru on Stop", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/stop-report.mjs"], + "timeoutMs": 10000, + "statusMessage": "zcode-dispatch: 记录会话↔job 映射…" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/stop-report.mjs"], + "timeoutMs": 20000, + "statusMessage": "zcode-dispatch: 自动回报任务结果…" + } + ] + } + ] + } +} diff --git a/external_plugins/zcode-grokbot-dispatch/hooks/stop-report.mjs b/external_plugins/zcode-grokbot-dispatch/hooks/stop-report.mjs new file mode 100644 index 00000000..fe61688b --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/hooks/stop-report.mjs @@ -0,0 +1,116 @@ +/** + * Hook handler for UserPromptSubmit and Stop. + * + * - On UserPromptSubmit: extracts `[job: <16-hex>]` from prompt and records `session_id <-> job_id` + * in the persistent mapping (`session-jobs.json` in ~/.local/state/zcode-dispatch and ZCODE_PLUGIN_DATA). + * - On Stop: resolves `job_id` for `session_id` (via persistent mapping, env, transcript, or tasks-index.sqlite), + * writes `~/.local/state/zcode-dispatch/reports/.json` + `reports.jsonl`, and automatically reports the + * result back to the dispatcher peer via Yobiru (`[ZCode report ] ...`) when transport is "yobiru". + * With the default direct transport the receiver pushes results itself (HTTP callback), so this hook only records. + * - Never blocks ZCode: stdout stays empty, exit 0. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveNetConfig } from '../lib/netconfig.mjs'; +import { JOB_ID_RE, defaultStateDir, isGrokDispatched, resolveJob, upsertJob } from '../lib/job-map.mjs'; +import { runYobiru, transportName } from '../lib/transport.mjs'; + +let raw = ''; +process.stdin.setEncoding('utf8'); +for await (const c of process.stdin) raw += c; + +let input = {}; +try { input = JSON.parse(raw); } catch {} + +const event = input.hook_event_name || input.hookEventName || 'Stop'; +const sessionId = input.session_id ?? input.sessionId ?? process.env.ZCODE_SESSION_ID ?? null; +const jobInfo = resolveJob({ + sessionId, + prompt: input.prompt, + transcriptPath: input.transcript_path ?? input.transcriptPath, + cwd: input.cwd, + env: process.env, +}); + +if (!jobInfo || !JOB_ID_RE.test(jobInfo.job_id || '')) process.exit(0); + +if (event === 'UserPromptSubmit' || event === 'SessionStart') { + process.stderr.write(`[zcode-dispatch] mapped session ${sessionId} <-> job ${jobInfo.job_id}\n`); + process.exit(0); +} + +const job = jobInfo.job_id; +const text = String(input.last_assistant_message ?? input.responseText ?? '').slice(0, 20000); +const notify = process.env.ZCODE_DISPATCH_NOTIFY ?? jobInfo.notify ?? null; +// Yobiru-sourced jobs are answered by the adapter via `request respond`; a Stop-hook `send` would +// duplicate the result even when the request came from Grok. +const viaAdapter = (jobInfo.source || process.env.ZCODE_DISPATCH_SOURCE) === 'yobiru'; +let viaYobiru = false; try { viaYobiru = transportName() === 'yobiru'; } catch {} +const shouldNotifyGrok = viaYobiru && !viaAdapter && isGrokDispatched(jobInfo, process.env); +const peer = process.env.ZCODE_DISPATCH_PEER || process.env.ZCODE_DISPATCH_GROK_PEER || jobInfo.notify_peer || resolveNetConfig().grokPeer; + +const rep = { + agent: 'zcode', + job_id: job, + source: jobInfo.source || process.env.ZCODE_DISPATCH_SOURCE || null, + project: jobInfo.project || null, + session_id: sessionId || jobInfo.session_id || null, + automation_id: jobInfo.automation_id || null, + cwd: input.cwd ?? jobInfo.cwd ?? null, + at: new Date().toISOString(), + text, + yobiru_reported: false, + yobiru_peer: null, + yobiru_receipt: null, +}; + +// Report once per dispatched job, and only when the job mapping indicates Grok dispatched it +// (inbound Yobiru from grok, or source==='grok'/'grok-bot' / notify==='grok' from Grok inbound). +// dsh, zcode, and local/manual sessions never notify Grok; ZCODE_DISPATCH_NOTIFY=none disables all. +const alreadyReported = Boolean(jobInfo.yobiru_reported); +if (!viaYobiru) { + rep.yobiru_skipped = 'transport-direct'; +} else if (viaAdapter) { + rep.yobiru_skipped = 'adapter-responds'; +} else if (!shouldNotifyGrok) { + rep.yobiru_skipped = notify === 'none' || notify === '0' || notify === 'false' ? 'notify-disabled' : 'non-grok-source'; +} +if (shouldNotifyGrok && !peer) rep.yobiru_skipped = 'grok-peer-not-configured'; +if (shouldNotifyGrok && peer && !alreadyReported) { + try { + const out = runYobiru(['send', peer, `[ZCode report ${job}] ${text.slice(0, 3000)}`, '--as', 'zcode', '--wake', 'none'], process.env); + rep.yobiru_reported = true; + rep.yobiru_peer = peer; + rep.yobiru_receipt = String(out || '').trim().slice(0, 500) || 'sent'; + } catch (e) { + rep.yobiru_error = String(e.message || e).slice(0, 500); + process.stderr.write(`[zcode-dispatch] yobiru notify failed for job ${job}: ${e.message}\n`); + } +} + +upsertJob({ + job_id: job, + session_id: rep.session_id || undefined, + cwd: rep.cwd || undefined, + reported_at: rep.at, + last_reported_text: text, + yobiru_reported: rep.yobiru_reported || Boolean(jobInfo.yobiru_reported), + yobiru_peer: rep.yobiru_peer || jobInfo.yobiru_peer || undefined, + yobiru_receipt: rep.yobiru_receipt || jobInfo.yobiru_receipt || undefined, + report_count: (jobInfo.report_count || 0) + 1, +}); + +const writeReportDir = (baseDir) => { + const dir = path.join(baseDir, 'reports'); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(dir, `${job}.json`), JSON.stringify(rep, null, 2), { mode: 0o600 }); + fs.appendFileSync(path.join(dir, 'reports.jsonl'), JSON.stringify(rep) + '\n', { mode: 0o600 }); +}; + +const stateDir = defaultStateDir(process.env); +writeReportDir(stateDir); +if (process.env.ZCODE_PLUGIN_DATA && path.resolve(process.env.ZCODE_PLUGIN_DATA) !== path.resolve(stateDir)) { + try { writeReportDir(process.env.ZCODE_PLUGIN_DATA); } catch {} +} + +process.stderr.write(`[zcode-dispatch] report written for job ${job} (session=${rep.session_id || 'none'}, yobiru=${rep.yobiru_reported})\n`); diff --git a/external_plugins/zcode-grokbot-dispatch/lib/dispatch.mjs b/external_plugins/zcode-grokbot-dispatch/lib/dispatch.mjs new file mode 100644 index 00000000..52a12fec --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/lib/dispatch.mjs @@ -0,0 +1,103 @@ +/** + * Outbound dispatch from ZCode, through transport.mjs: + * toGrokBot -> peer "grok-bot" (direct HTTP by default; yobiru: request send to grokPeer). + * toDsh -> "http" (POST to peers.dsh /v1/jobs, any machine) when peers.dsh is configured, + * else "local" (detached `dsh --profile headless` on this machine). Override: config "dshVia". + */ +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { JOB_ID_RE, defaultStateDir, loadMap } from './job-map.mjs'; +import { denyProjectsRe, projectsRoot, readConfigFile, resolveNetConfig } from './netconfig.mjs'; +import { directPeers, directSend, directStatus, expandHome, send, status as transportStatus, transportName, which } from './transport.mjs'; + +const HOME = os.homedir(); +export const CFG = { + get projectsRoot() { return projectsRoot(); }, + stateDir: path.join(HOME, '.local', 'state', 'zcode-dispatch', 'outgoing'), + // env ZCODE_DISPATCH_GROK_PEER > ~/.config/zcode-dispatch/config.json "grokPeer" (netconfig.mjs); null = unset + get grokPeer() { return resolveNetConfig().grokPeer; }, + // env DSH_BIN > config "dshBin" > `dsh` on PATH (null = not found) + get dsh() { return which(expandHome(process.env.DSH_BIN || readConfigFile().dshBin || 'dsh')); }, + get dshVia() { const v = readConfigFile().dshVia; return v === 'http' || v === 'local' ? v : (directPeers().dsh ? 'http' : 'local'); }, + dshProfile: process.env.DSH_PROFILE || 'headless', +}; +const TAG = /^\s*\[Project: ([^\]\n]+)\]/; +const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +/** Ensure text carries "[Project: X]" for project; returns {project, cwd, text}. */ +export function route(project, text) { + if (typeof text !== 'string' || !text.trim()) throw new Error('text required'); + const tag = TAG.exec(text)?.[1]; + project = project || tag; + if (!project || !PROJECT.test(project) || denyProjectsRe().test(project)) throw new Error(`project "${project ?? ''}" refused: must be a plain folder name under the projects root, not matching denyProjects (default finance)`); + if (tag && tag !== project) throw new Error(`text is tagged [Project: ${tag}] but project=${project}`); + const cwd = path.join(CFG.projectsRoot, project); + if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw new Error(`no such project folder ${cwd}`); + return { project, cwd, text: tag ? text : `[Project: ${project}] ${text}` }; +} + +export async function toGrokBot({ project, text, timeout_seconds = 1800 }) { + const r = route(project, text); + const out = await send('grok-bot', { project: r.project, text: r.text, timeoutSeconds: timeout_seconds }); + return { target: 'grok-bot', via: transportName(), project: r.project, request_id: out.id, output: out.output }; +} + +export async function toDsh({ project, text, timeout_seconds = 1800 }) { + const r = route(project, text); + fs.mkdirSync(CFG.stateDir, { recursive: true, mode: 0o700 }); + const id = randomBytes(8).toString('hex'); + const file = path.join(CFG.stateDir, `${id}.json`); + const job = { id, target: 'dsh', via: CFG.dshVia, project: r.project, state: 'running', created_at: new Date().toISOString(), text_preview: r.text.slice(0, 200) }; + if (job.via === 'http') { + // Cross-machine: DSH's own POST /v1/jobs; result comes back by callback (POST /v1/callbacks) or dispatch_status polling. + await directSend('dsh', { project: r.project, text: r.text, timeoutSeconds: timeout_seconds, jobId: id }); + fs.writeFileSync(file, JSON.stringify(job, null, 2), { mode: 0o600 }); + return { target: 'dsh', via: 'http', project: r.project, job_id: id, state: 'running' }; + } + if (!CFG.dsh) throw new Error('dsh not found: put dsh on PATH, set "dshBin" (or DSH_BIN), or configure peers.dsh for HTTP dispatch'); + fs.writeFileSync(file, JSON.stringify(job, null, 2), { mode: 0o600 }); + // Detached worker so the job outlives the MCP server / ZCode session. + const child = spawn(process.execPath, [new URL('./dsh-worker.mjs', import.meta.url).pathname, file, r.cwd, r.text, String(timeout_seconds)], + { detached: true, stdio: 'ignore', env: { ...process.env, DSH_BIN: CFG.dsh, DSH_PROFILE: CFG.dshProfile } }); + child.unref(); + return { target: 'dsh', via: 'local', project: r.project, job_id: id, state: 'running' }; +} + +export async function status({ id }) { + const rootState = defaultStateDir(); + if (typeof id === 'string' && id.startsWith('sess_')) { + const map = loadMap({ stateDir: rootState }); + const jid = map.sessions[id]; + const entry = jid ? map.jobs[jid] : null; + let report = null; + if (jid) { + try { report = JSON.parse(fs.readFileSync(path.join(rootState, 'reports', `${jid}.json`), 'utf8')); } catch {} + } + return { session_id: id, job_id: jid || null, mapping: entry, report }; + } + if (JOB_ID_RE.test(id || '')) { + const f = path.join(CFG.stateDir, `${id}.json`); + if (fs.existsSync(f)) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')); + // HTTP-dispatched DSH job still running locally: ask DSH (pull), unless a callback already landed. + if (j.via === 'http' && j.state === 'running') { + try { const remote = await directStatus('dsh', id); return { ...j, state: remote?.state ?? j.state, result: remote?.result ?? null, error: remote?.error ?? null, remote }; } + catch (e) { return { ...j, remote_error: e.message }; } + } + return j; + } + const map = loadMap({ stateDir: rootState }); + const repFile = path.join(rootState, 'reports', `${id}.json`); + if (fs.existsSync(repFile)) { + return { ...JSON.parse(fs.readFileSync(repFile, 'utf8')), mapping: map.jobs[id] || null }; + } + if (map.jobs[id]) { + return { state: 'running', ...map.jobs[id], job_id: id, mapping: map.jobs[id], report: null }; + } + return transportStatus('grok-bot', id); + } + throw new Error('id must be a 16-hex job/Yobiru request id or a sess_... session id'); +} diff --git a/external_plugins/zcode-grokbot-dispatch/lib/dsh-worker.mjs b/external_plugins/zcode-grokbot-dispatch/lib/dsh-worker.mjs new file mode 100644 index 00000000..b4127c54 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/lib/dsh-worker.mjs @@ -0,0 +1,21 @@ +/** Detached: run `dsh --profile headless --json ` in cwd, fold the result into the job file. */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +const [file, cwd, text, timeoutS] = process.argv.slice(2); +const job = JSON.parse(fs.readFileSync(file, 'utf8')); +const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access' }; delete env.DEEPSEEK_BASE_URL; +const child = spawn(process.env.DSH_BIN || 'dsh', ['--profile', process.env.DSH_PROFILE || 'headless', '--json', text], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); +let out = '', err = ''; +child.stdout.on('data', d => { if (out.length < 4e6) out += d; }); +child.stderr.on('data', d => { if (err.length < 2e5) err += d; }); +const timer = setTimeout(() => { job.timed_out = true; child.kill('SIGTERM'); }, Number(timeoutS) * 1000); +child.on('close', code => { + clearTimeout(timer); + let final = null, session = null, turnEnd = null; + for (const l of out.split('\n')) { let e; try { e = JSON.parse(l); } catch { continue; } + if (e.type === 'final') final = e.text; if (e.type === 'session') session = e.sessionId ?? e.session?.sessionId; if (e.type === 'status' && e.phase === 'turn_end') turnEnd = e.reason?.kind; } + const ok = code === 0 && !job.timed_out && (turnEnd == null || turnEnd === 'completed'); + Object.assign(job, { state: ok ? 'completed' : 'failed', exit_code: code, session_id: session, turn_end: turnEnd, result: final?.slice(0, 100000) ?? null, + error: ok ? null : job.timed_out ? 'timeout' : err.trim().split('\n').slice(-5).join('\n').slice(0, 2000) || `exit ${code}`, finished_at: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(job, null, 2), { mode: 0o600 }); +}); diff --git a/external_plugins/zcode-grokbot-dispatch/lib/job-map.mjs b/external_plugins/zcode-grokbot-dispatch/lib/job-map.mjs new file mode 100644 index 00000000..6e28034d --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/lib/job-map.mjs @@ -0,0 +1,353 @@ +/** + * Persistent session_id <-> job_id mapping and tag helpers for zcode-dispatch. + * + * Why this exists: ZCode.app's automation scheduler spawns desktop sessions inside the Host + * process, which does not inherit ZCODE_DISPATCH_JOB_ID / ZCODE_DISPATCH_SOURCE from the + * external runner. We embed `[job: <16-hex>]` into both the task prompt and session/automation + * title, and persist `session_id <-> job_id` in `session-jobs.json` (under ~/.local/state/zcode-dispatch + * and ZCODE_PLUGIN_DATA when set) so UserPromptSubmit / Stop hooks can always resolve the job id. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { peerHost, resolveNetConfig } from './netconfig.mjs'; +export { runYobiru } from './transport.mjs'; // moved: single place for Yobiru + +export const JOB_ID_RE = /^[0-9a-f]{16}$/; +export const PROJECT_TAG_RE = /^\s*\[Project:\s*([^\]\n]+)\]/; +export const SOURCE_TAG_RE = /\[来源:\s*([^\]\n]+)\]/; +export const JOB_TAG_RE = /\[job:\s*([0-9a-f]{16})\]/i; +export const TITLE_JOB_RE = /#([0-9a-f]{16})\b/i; + +export function defaultStateDir(env = process.env) { + return env.ZCODE_DISPATCH_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'zcode-dispatch'); +} + +export function defaultTasksDb(env = process.env) { + return env.ZCODE_TASKS_DB || path.join(os.homedir(), '.zcode', 'v2', 'tasks-index.sqlite'); +} + +const NON_GROK_SOURCES = new Set(['dsh', 'local', 'zcode', 'manual']); + +/** A peer is Grok if it targets the `grok` agent or is the configured grokPeer's node (netconfig.mjs). */ +export function isGrokPeer(peer, grokPeer = resolveNetConfig().grokPeer) { + if (typeof peer !== 'string') return false; + const p = peer.trim().toLowerCase().replace(/\.$/, ''); + if (/(^|\/)grok$/.test(p)) return true; + const node = String(peerHost(grokPeer) || '').toLowerCase().replace(/\.$/, ''); + return Boolean(node) && (p === node || p.startsWith(`${node}/`)); +} + +/** + * Only tasks dispatched by Grok Bot (source 'grok'/'grok-bot', inbound Yobiru from grok, + * or notify==='grok' recorded by a Grok inbound path) should auto-report to Grok. + * dsh, zcode, and local/manual sessions never report to Grok; ZCODE_DISPATCH_NOTIFY=none disables all. + */ +export function isGrokDispatched(jobInfo = {}, env = process.env) { + const envNotify = env?.ZCODE_DISPATCH_NOTIFY; + if (envNotify === 'none' || envNotify === '0' || envNotify === 'false') return false; + const jobNotify = jobInfo?.notify; + if (jobNotify === 'none' || jobNotify === '0' || jobNotify === 'false') return false; + const source = String(jobInfo?.source || env?.ZCODE_DISPATCH_SOURCE || '').trim().toLowerCase(); + if (NON_GROK_SOURCES.has(source)) return false; + if (source === 'grok' || source === 'grok-bot') return true; + const peer = jobInfo?.notify_peer || jobInfo?.notifyPeer || env?.ZCODE_DISPATCH_PEER || ''; + if (source === 'yobiru' && isGrokPeer(peer)) return true; + if (jobNotify === 'grok' || envNotify === 'grok') return true; + return false; +} + +export function extractTags(text) { + if (typeof text !== 'string' || !text) return { project: null, source: null, jobId: null }; + const project = PROJECT_TAG_RE.exec(text)?.[1]?.trim() || null; + const source = SOURCE_TAG_RE.exec(text)?.[1]?.trim() || null; + const jobId = (JOB_TAG_RE.exec(text)?.[1] || TITLE_JOB_RE.exec(text)?.[1] || '').toLowerCase() || null; + return { project, source, jobId: JOB_ID_RE.test(jobId || '') ? jobId : null }; +} + +/** + * "[Project: X] body" -> "[Project: X] [来源: src] [job: id] body" (session title = prompt head). + * Preserves backwards compatibility when jobId is omitted. + */ +export function withSource(text, source, jobId) { + if (typeof text !== 'string') return ''; + let out = text; + if (source && !/^\s*\[Project: [^\]]+\]\s*\[来源:/.test(out)) { + out = out.replace(/^(\s*\[Project: [^\]\n]+\])\s*/, `$1 [来源: ${source}] `); + } + const cleanJob = typeof jobId === 'string' && JOB_ID_RE.test(jobId) ? jobId : null; + if (cleanJob && !JOB_TAG_RE.test(out)) { + if (/^\s*\[Project: [^\]]+\]\s*\[来源:[^\]]+\]/.test(out)) { + out = out.replace(/^(\s*\[Project: [^\]\n]+\]\s*\[来源:[^\]\n]+\])\s*/, `$1 [job: ${cleanJob}] `); + } else if (/^\s*\[Project: [^\]]+\]/.test(out)) { + out = out.replace(/^(\s*\[Project: [^\]\n]+\])\s*/, `$1 [job: ${cleanJob}] `); + } else { + out = `[job: ${cleanJob}] ${out}`; + } + } + return out; +} + +export function summaryTitle(text, source, jobId) { + const body = String(text || '') + .replace(/^\s*\[Project: [^\]\n]+\]\s*(\[来源[^\]]*\]\s*)?(\[job:\s*[0-9a-f]{16}\]\s*)?/i, '') + .replace(/\s+/g, ' ') + .trim(); + const cleanJob = typeof jobId === 'string' && JOB_ID_RE.test(jobId) ? jobId : extractTags(text).jobId; + const tag = cleanJob ? `${source || 'dispatch'} #${cleanJob}` : `${source || 'dispatch'}`; + return `[${tag}] ${body.slice(0, 40)}${body.length > 40 ? '…' : ''}`; +} + +function mapFiles(opts = {}) { + const env = opts.env || process.env; + const stateDir = opts.stateDir || defaultStateDir(env); + const files = [path.join(stateDir, 'session-jobs.json')]; + const pluginData = opts.pluginDataDir ?? env.ZCODE_PLUGIN_DATA; + if (pluginData && path.resolve(pluginData) !== path.resolve(stateDir)) { + files.push(path.join(pluginData, 'session-jobs.json')); + } + return files; +} + +function readOneMap(file) { + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + return { + jobs: raw && typeof raw.jobs === 'object' ? raw.jobs : {}, + sessions: raw && typeof raw.sessions === 'object' ? raw.sessions : {}, + }; + } catch { + return { jobs: {}, sessions: {} }; + } +} + +export function loadMap(opts = {}) { + const merged = { jobs: {}, sessions: {} }; + for (const file of mapFiles(opts)) { + const m = readOneMap(file); + for (const [k, v] of Object.entries(m.jobs)) { + if (!JOB_ID_RE.test(k) || !v || typeof v !== 'object') continue; + merged.jobs[k] = { ...merged.jobs[k], ...v }; + } + for (const [sid, jid] of Object.entries(m.sessions)) { + if (sid && JOB_ID_RE.test(jid || '')) merged.sessions[sid] = jid; + } + } + return merged; +} + +const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4)); +function sleepSync(ms) { + try { Atomics.wait(SLEEP_BUF, 0, 0, ms); } catch {} +} + +function mapLockPath(opts = {}) { + const env = opts.env || process.env; + const stateDir = opts.stateDir || defaultStateDir(env); + return path.join(stateDir, 'session-jobs.lock'); +} + +export function withMapLock(opts = {}, fn) { + const lockPath = mapLockPath(opts); + fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }); + const staleMs = opts.lockStaleMs ?? 10000; + const timeoutMs = opts.lockTimeoutMs ?? 5000; + const deadline = Date.now() + timeoutMs; + let fd = null; + while (fd === null) { + try { + fd = fs.openSync(lockPath, 'wx', 0o600); + try { fs.writeFileSync(fd, `${process.pid} ${Date.now()}\n`); } catch {} + break; + } catch (err) { + if (err && err.code === 'EEXIST') { + try { + const st = fs.statSync(lockPath); + if (Date.now() - st.mtimeMs > staleMs) { + sleepSync(2 + Math.floor(Math.random() * 15)); + const st2 = fs.statSync(lockPath); + if (st2.ino === st.ino && Date.now() - st2.mtimeMs > staleMs) { + fs.unlinkSync(lockPath); + } + continue; + } + } catch {} + if (Date.now() < deadline) { + sleepSync(5 + Math.floor(Math.random() * 10)); + continue; + } + // Timed out behind a live holder: never delete another process's fresh lock (that would let + // two writers in at once). Degrade to an unlocked update so hooks never block the stop. + break; + } + break; + } + } + try { + return fn(); + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch {} + try { fs.unlinkSync(lockPath); } catch {} + } + } +} + +export function saveMap(map, opts = {}) { + const keys = Object.keys(map.jobs); + if (keys.length > 500) { + keys.sort((a, b) => String(map.jobs[a]?.updated_at || '').localeCompare(String(map.jobs[b]?.updated_at || ''))); + for (const k of keys.slice(0, keys.length - 500)) delete map.jobs[k]; + for (const [sid, jid] of Object.entries(map.sessions)) { + if (!map.jobs[jid]) delete map.sessions[sid]; + } + } + const payload = JSON.stringify(map, null, 2); + for (const file of mapFiles(opts)) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2, 8)}.tmp`; + fs.writeFileSync(tmp, payload, { mode: 0o600 }); + fs.renameSync(tmp, file); + } catch {} + } +} + +function applyUpsert(map, entry) { + const prev = map.jobs[entry.job_id] || { job_id: entry.job_id, created_at: new Date().toISOString() }; + const next = { ...prev }; + for (const [k, v] of Object.entries(entry)) { + if (v !== undefined && v !== null) next[k] = v; + } + next.updated_at = new Date().toISOString(); + map.jobs[entry.job_id] = next; + if (next.session_id) map.sessions[next.session_id] = entry.job_id; + return next; +} + +export function upsertJob(entry, opts = {}) { + if (!entry || !JOB_ID_RE.test(entry.job_id || '')) return null; + return withMapLock(opts, () => { + const map = loadMap(opts); + const next = applyUpsert(map, entry); + saveMap(map, opts); + return next; + }); +} + +export function bindSession(info, opts = {}) { + const { sessionId, jobId, automationId, source, project, cwd, notify, notifyPeer } = info || {}; + return withMapLock(opts, () => { + const map = loadMap(opts); + let targetJobId = JOB_ID_RE.test(jobId || '') ? jobId : (sessionId ? map.sessions[sessionId] : null); + if (!targetJobId && automationId) { + const found = Object.values(map.jobs).find(j => j.automation_id === automationId); + if (found) targetJobId = found.job_id; + } + if (!targetJobId || !JOB_ID_RE.test(targetJobId)) return null; + const next = applyUpsert(map, { + job_id: targetJobId, + session_id: sessionId || undefined, + automation_id: automationId || undefined, + source: source || undefined, + project: project || undefined, + cwd: cwd || undefined, + notify: notify || undefined, + notify_peer: notifyPeer || undefined, + }); + saveMap(map, opts); + return next; + }); +} + +function lookupDbForSession(sessionId, map, dbPath) { + if (!sessionId || !dbPath || !fs.existsSync(dbPath)) return null; + let db; + try { + db = new DatabaseSync(dbPath, { readOnly: true }); + db.exec('PRAGMA busy_timeout = 2000'); + const run = db.prepare('SELECT automation_id FROM automation_runs WHERE session_id=? ORDER BY created_at DESC LIMIT 1').get(sessionId); + if (run?.automation_id) { + const byAuto = Object.values(map.jobs).find(j => j.automation_id === run.automation_id); + if (byAuto) return { jobId: byAuto.job_id, automationId: run.automation_id, source: byAuto.source, project: byAuto.project }; + const autoRow = db.prepare('SELECT title, prompt FROM automations WHERE automation_id=?').get(run.automation_id); + const tags = extractTags(autoRow?.prompt || autoRow?.title || ''); + if (tags.jobId) return { ...tags, automationId: run.automation_id }; + } + const task = db.prepare('SELECT title, searchable_text FROM tasks WHERE task_id=?').get(sessionId); + if (task) { + const tags = extractTags(task.title || '') || {}; + if (tags.jobId) return tags; + const bodyTags = extractTags(task.searchable_text || ''); + if (bodyTags.jobId) return bodyTags; + } + } catch {} finally { + try { db?.close(); } catch {} + } + return null; +} + +/** + * Resolve job metadata for a session or hook invocation. + * Order: 1) env ZCODE_DISPATCH_JOB_ID -> 2) session-jobs.json by session_id -> + * 3) prompt / transcript tags -> 4) tasks-index.sqlite automation_runs/tasks. + */ +export function resolveJob(params = {}) { + const env = params.env || process.env; + const opts = { env, stateDir: params.stateDir, pluginDataDir: params.pluginDataDir }; + const sessionId = params.sessionId || env.ZCODE_SESSION_ID || env.CLAUDE_SESSION_ID || null; + const map = loadMap(opts); + + // 1. Direct env var (headless runs or explicit env) + const envJob = env.ZCODE_DISPATCH_JOB_ID; + if (JOB_ID_RE.test(envJob || '')) { + return upsertJob({ + job_id: envJob, + session_id: sessionId || undefined, + source: params.source || env.ZCODE_DISPATCH_SOURCE || undefined, + cwd: params.cwd || undefined, + notify: env.ZCODE_DISPATCH_NOTIFY || undefined, + notify_peer: env.ZCODE_DISPATCH_PEER || undefined, + }, opts); + } + + // 2. Persistent mapping by sessionId + if (sessionId && map.sessions[sessionId] && map.jobs[map.sessions[sessionId]]) { + const existing = map.jobs[map.sessions[sessionId]]; + if (params.cwd && !existing.cwd) return upsertJob({ job_id: existing.job_id, session_id: sessionId, cwd: params.cwd }, opts); + return existing; + } + + // 3. Prompt / text / transcript file tags + let candidateText = params.prompt || params.text || ''; + if (!candidateText && params.transcriptPath && fs.existsSync(params.transcriptPath)) { + try { candidateText = fs.readFileSync(params.transcriptPath, 'utf8').slice(0, 32768); } catch {} + } + const tags = extractTags(candidateText); + if (tags.jobId) { + return upsertJob({ + job_id: tags.jobId, + session_id: sessionId || undefined, + source: tags.source || params.source || undefined, + project: tags.project || undefined, + cwd: params.cwd || undefined, + }, opts); + } + + // 4. Fallback: read-only tasks-index.sqlite lookup by sessionId + const dbPath = params.dbPath ?? defaultTasksDb(env); + const fromDb = lookupDbForSession(sessionId, map, dbPath); + if (fromDb?.jobId) { + return upsertJob({ + job_id: fromDb.jobId, + session_id: sessionId || undefined, + automation_id: fromDb.automationId || undefined, + source: fromDb.source || undefined, + project: fromDb.project || undefined, + cwd: params.cwd || undefined, + }, opts); + } + + return null; +} diff --git a/external_plugins/zcode-grokbot-dispatch/lib/netconfig.mjs b/external_plugins/zcode-grokbot-dispatch/lib/netconfig.mjs new file mode 100644 index 00000000..a19472b6 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/lib/netconfig.mjs @@ -0,0 +1,93 @@ +/** + * Single source for zcode-dispatch network settings (no hostnames, IPs or user paths in code). + * Used by the receiver, lib/appsession, the plugin (dispatch + Stop hook); the private Yobiru wake adapter (outside this repo) reads the same config file. Transport selection and peers live in transport.mjs. + * + * Resolution order: environment variable > config file > discovery. + * config file: $ZCODE_DISPATCH_CONFIG or ~/.config/zcode-dispatch/config.json + * { "host": "", "port": 7791, "allowedIps": [""], + * "grokPeer": "/grok", "allowedPeers": ["", ...] } + * discovery (`tailscale status --json`; off with ZCODE_DISPATCH_DISCOVERY=0): + * host -> this node's tailnet IPv4 (never 0.0.0.0) + * allowedIps -> tailnet IPs of the grokPeer node + * Unresolved values stay null/empty: the receiver refuses to bind, the allowlist denies everyone, + * and nothing is sent to Grok. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const CONFIG_FILE = () => process.env.ZCODE_DISPATCH_CONFIG + || path.join(os.homedir(), '.config', 'zcode-dispatch', 'config.json'); + +export function readConfigFile(file = CONFIG_FILE()) { + try { const v = JSON.parse(fs.readFileSync(file, 'utf8')); return v && typeof v === 'object' ? v : {}; } + catch { return {}; } +} + +const TAILSCALE_CANDIDATES = ['tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale', + '/Applications/Tailscale.app/Contents/MacOS/Tailscale']; + +let cachedStatus; +/** Parsed `tailscale status --json`, or null. Cached per process; override with statusProvider in tests. */ +export function tailscaleStatus({ refresh = false } = {}) { + if (process.env.ZCODE_DISPATCH_DISCOVERY === '0') return null; + if (cachedStatus !== undefined && !refresh) return cachedStatus; + cachedStatus = null; + const bins = process.env.TAILSCALE_BIN ? [process.env.TAILSCALE_BIN] : TAILSCALE_CANDIDATES; + for (const bin of bins) { + try { + const out = execFileSync(bin, ['status', '--json'], { timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }); + cachedStatus = JSON.parse(out.toString('utf8')); break; + } catch { /* try next */ } + } + return cachedStatus; +} + +const stripDot = s => String(s || '').replace(/\.$/, ''); +const ipv4 = ips => (ips || []).find(ip => /^\d+\.\d+\.\d+\.\d+$/.test(ip)) || null; + +export function selfIPv4(status) { return ipv4(status?.Self?.TailscaleIPs) || ipv4(status?.TailscaleIPs); } +export function selfDNSName(status) { return stripDot(status?.Self?.DNSName) || null; } + +/** Tailnet IPv4s of the peer whose MagicDNS name (or short host name) matches `name`. */ +export function peerIPs(status, name) { + if (!status || !name) return []; + const want = stripDot(name).toLowerCase(); + const short = want.split('.')[0]; + for (const p of Object.values(status.Peer || {})) { + const dns = stripDot(p.DNSName).toLowerCase(); + if (dns === want || (want === short && (dns.split('.')[0] === short || String(p.HostName).toLowerCase() === short))) { + return (p.TailscaleIPs || []).filter(ip => /^\d+\.\d+\.\d+\.\d+$/.test(ip)); + } + } + return []; +} + +const list = v => (Array.isArray(v) ? v : String(v || '').split(',')).map(x => String(x).trim()).filter(Boolean); +export const peerHost = peer => (peer ? String(peer).split('/')[0] : null); + +/** Resolve host/port/allowedIps/grokPeer. Sources are injectable for tests. */ +export function resolveNetConfig({ env = process.env, file = readConfigFile(), status = tailscaleStatus } = {}) { + let st; const ts = () => (st === undefined ? (st = status() || null) : st); + const grokPeer = env.ZCODE_DISPATCH_GROK_PEER || file.grokPeer || null; + const host = env.DISPATCH_HOST || file.host || selfIPv4(ts()) || null; + const port = Number(env.DISPATCH_PORT || file.port || 7791); + let allowedIps = list(env.DISPATCH_ALLOWED_IPS || file.allowedIps); + if (!allowedIps.length) allowedIps = peerIPs(ts(), peerHost(grokPeer)); + return { host, port, allowedIps, grokPeer }; +} + +// Moved to transport.mjs (single place for Yobiru); re-exported for existing callers. +export { yobiruBin } from './transport.mjs'; + +/** Projects root: env ZCODE_DISPATCH_PROJECTS_ROOT > config "projectsRoot" > ~/Projects. */ +export function projectsRoot(env = process.env, file = readConfigFile()) { + const p = env.ZCODE_DISPATCH_PROJECTS_ROOT || file.projectsRoot; + return p ? (p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p) : path.join(os.homedir(), 'Projects'); +} + +/** Refused project names: config "denyProjects" (regex source, case-insensitive) > /finance/i. */ +export function denyProjectsRe(file = readConfigFile()) { + return new RegExp(typeof file.denyProjects === 'string' && file.denyProjects ? file.denyProjects : 'finance', 'i'); +} diff --git a/external_plugins/zcode-grokbot-dispatch/lib/transport.mjs b/external_plugins/zcode-grokbot-dispatch/lib/transport.mjs new file mode 100644 index 00000000..363ac286 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/lib/transport.mjs @@ -0,0 +1,148 @@ +/** + * The only place zcode-dispatch talks to other agents. Three operations: + * send(target, {project, text, timeoutSeconds, jobId?}) -> {id, peer, output} + * respond(job, payload) -> {method} (push a finished job's result) + * status(target, id) -> remote job / request status + * + * Two implementations, picked by `transport` (env ZCODE_DISPATCH_TRANSPORT > config.json > "direct"): + * direct (default): plain HTTP + shared Bearer key; config.json + * "peers": { "dsh": { "url": "http://host:7790", "keyFile": "~/.config/zcode-dispatch/dsh.key" } } + * send -> POST /v1/jobs, status -> GET /v1/jobs/, + * respond -> POST JSON to callback_url or /v1/callbacks. + * yobiru (opt-in, "transport": "yobiru"): the Yobiru / tailmsg CLI, argv identical to v0.2. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readConfigFile, resolveNetConfig } from './netconfig.mjs'; + +export const expandHome = (p) => (typeof p === 'string' && p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p); + +export function transportName(env = process.env, file = readConfigFile()) { + const t = env.ZCODE_DISPATCH_TRANSPORT || file.transport || 'direct'; + if (t !== 'direct' && t !== 'yobiru') throw new Error(`transport must be "direct" or "yobiru" (got ${JSON.stringify(t)})`); + return t; +} + +/** Find an executable on PATH (absolute/relative paths are checked directly). */ +export function which(name, pathStr = process.env.PATH) { + if (!name) return null; + const ok = (f) => { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } }; + if (name.includes('/')) return ok(name) ? name : null; + for (const d of String(pathStr || '').split(path.delimiter).filter(Boolean)) { const f = path.join(d, name); if (ok(f)) return f; } + return null; +} + +// ---------------------------------------------------------------- yobiru (tailmsg CLI) + +/** Newest installed Yobiru plugin CLI (a pinned version path broke on the 0.5.0 -> 0.5.1 upgrade). */ +export function yobiruBin(env = process.env, home = os.homedir()) { + if (env.YOBIRU_BIN) return env.YOBIRU_BIN; + const base = path.join(home, '.codex', 'plugins', 'cache', 'yobiru', 'yobiru'); + let vers = []; + try { vers = fs.readdirSync(base).filter(v => fs.existsSync(path.join(base, v, 'scripts', 'yobiru'))); } catch {} + const key = v => v.split('.').map(n => Number.parseInt(n, 10) || 0); + vers.sort((a, b) => { const x = key(a), y = key(b); for (let i = 0; i < Math.max(x.length, y.length); i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0); } return 0; }); + return vers.length ? path.join(base, vers.at(-1), 'scripts', 'yobiru') : path.join(base, 'MISSING', 'scripts', 'yobiru'); +} + +/** Run Yobiru / tailmsg CLI with automatic binary resolution (unchanged from v0.2). */ +export function runYobiru(args, env = process.env) { + const home = env.TAILMSG_HOME || path.join(os.homedir(), '.tailmsg'); + const bin = env.YOBIRU_BIN; + if (bin && fs.existsSync(bin)) return execFileSync('/bin/sh', [bin, '--home', home, ...args], { encoding: 'utf8', timeout: 15000, env }); + const tailmsgPy = path.join(home, 'tailmsg.py'); + if (fs.existsSync(tailmsgPy)) return execFileSync('python3', [tailmsgPy, '--home', home, ...args], { encoding: 'utf8', timeout: 15000, env }); + return execFileSync('/bin/sh', [yobiruBin({}, os.homedir()), '--home', home, ...args], { encoding: 'utf8', timeout: 15000, env }); +} + +const yobiru = { + async send(target, { text, timeoutSeconds = 1800, run = runYobiru }) { + if (target !== 'grok-bot') throw new Error(`yobiru transport only sends to grok-bot (got ${target})`); + const peer = resolveNetConfig().grokPeer; + if (!peer) throw new Error('Grok peer is not configured: set ZCODE_DISPATCH_GROK_PEER or "grokPeer" in ~/.config/zcode-dispatch/config.json'); + const out = run(['request', 'send', peer, text, '--as', 'zcode', '--wake', 'now', '--timeout-seconds', String(timeoutSeconds)]); + return { id: /\b[0-9a-f]{16}\b/.exec(out)?.[0] ?? null, peer, output: out.trim().slice(0, 2000) }; + }, + // Yobiru results are pushed by hooks/stop-report.mjs (Grok) and the yobiru adapter (request respond). + async respond() { return { method: null, error: 'yobiru: results are reported by the Stop hook / adapter' }; }, + async status(_t, id, { run = runYobiru } = {}) { + const out = run(['request', 'status', id, '--refresh', '--json']); + try { return JSON.parse(out); } catch { return { output: out.trim().slice(0, 4000) }; } + }, +}; + +// ---------------------------------------------------------------- direct (HTTP) + +export function directPeers(file = readConfigFile()) { + const peers = file.peers && typeof file.peers === 'object' ? file.peers : {}; + return Object.fromEntries(Object.entries(peers).filter(([, p]) => p && typeof p.url === 'string') + .map(([n, p]) => [n, { url: p.url.replace(/\/+$/, ''), keyFile: expandHome(p.keyFile) }])); +} + +/** A callback_url is accepted only if it points at a configured peer (whose key is then used). */ +export function peerForUrl(url, peers = directPeers()) { + if (typeof url !== 'string') return null; + return Object.entries(peers).find(([, p]) => url === p.url || url.startsWith(`${p.url}/`))?.[0] ?? null; +} + +async function httpJson(method, url, keyFile, body) { + const key = fs.readFileSync(keyFile, 'utf8').trim(); + const res = await fetch(url, { method, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000), + headers: { authorization: `Bearer ${key}`, ...(body ? { 'content-type': 'application/json' } : {}) } }); + const text = await res.text(); + let json = null; try { json = JSON.parse(text); } catch {} + if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}: ${text.slice(0, 300)}`); + return json; +} + +const direct = { + async send(target, { project, text, timeoutSeconds = 1800, jobId }) { + const peer = directPeers()[target]; + if (!peer) throw new Error(`peer "${target}" is not configured: add peers.${target} {url, keyFile} to ~/.config/zcode-dispatch/config.json`); + const f = readConfigFile(); + const body = { project, text, timeout_seconds: Number(timeoutSeconds), source: 'zcode', ...(jobId ? { job_id: jobId } : {}), + ...(f.callbackUrl ? { callback_url: f.callbackUrl } : { callback_peer: f.selfName || 'zcode' }) }; + const j = await httpJson('POST', `${peer.url}/v1/jobs`, peer.keyFile, body); + return { id: j?.id ?? jobId ?? null, peer: target, output: JSON.stringify(j).slice(0, 2000) }; + }, + async respond(job, payload) { + const peers = directPeers(); + let name = job.callback_peer && peers[job.callback_peer] ? job.callback_peer : null; + let url = name ? `${peers[name].url}/v1/callbacks` : null; + if (job.callback_url) { name = peerForUrl(job.callback_url, peers); url = name ? job.callback_url : null; } + if (!url) return { method: null, error: `no direct callback route (callback_peer=${job.callback_peer ?? ''} callback_url=${job.callback_url ?? ''})` }; + try { await httpJson('POST', url, peers[name].keyFile, payload); return { method: 'http-callback', peer: name }; } + catch (e) { return { method: null, error: e.message }; } + }, + async status(target, id) { + const peer = directPeers()[target]; + if (!peer) throw new Error(`peer "${target}" is not configured`); + return httpJson('GET', `${peer.url}/v1/jobs/${id}`, peer.keyFile); + }, +}; + +export const getTransport = () => (transportName() === 'yobiru' ? yobiru : direct); +export const send = (target, opts) => getTransport().send(target, opts); +export const respond = (job, payload) => direct.respond(job, payload); // HTTP callbacks are only ever direct +export const status = (target, id, opts) => getTransport().status(target, id, opts); +export const directSend = (target, opts) => direct.send(target, opts); +export const directStatus = (target, id) => direct.status(target, id); + +/** Startup check for the selected transport: {transport, errors[], warnings[]}. */ +export function checkTransport({ exists = fs.existsSync } = {}) { + const errors = []; const warnings = []; + let t; try { t = transportName(); } catch (e) { return { transport: null, errors: [e.message], warnings }; } + if (t === 'yobiru') { + const home = process.env.TAILMSG_HOME || path.join(os.homedir(), '.tailmsg'); + const bin = process.env.YOBIRU_BIN; + if (!(bin && exists(bin)) && !exists(path.join(home, 'tailmsg.py')) && !exists(yobiruBin({}, os.homedir()))) errors.push(`yobiru: no Yobiru CLI (YOBIRU_BIN, ${home}/tailmsg.py, ~/.codex/plugins/cache/yobiru)`); + if (!resolveNetConfig().grokPeer) warnings.push('yobiru: grokPeer not set; dispatch_to_grokbot / Grok reporting disabled'); + } else { + const peers = directPeers(); + if (!Object.keys(peers).length) warnings.push('direct: no peers configured; outbound dispatch and callbacks disabled (inbound + polling still work)'); + for (const [n, p] of Object.entries(peers)) if (!p.keyFile || !exists(p.keyFile)) errors.push(`direct: peers.${n}.keyFile missing (${p.keyFile ?? 'unset'})`); + } + return { transport: t, errors, warnings }; +} diff --git a/external_plugins/zcode-grokbot-dispatch/mcp/server.mjs b/external_plugins/zcode-grokbot-dispatch/mcp/server.mjs new file mode 100644 index 00000000..1e7f6106 --- /dev/null +++ b/external_plugins/zcode-grokbot-dispatch/mcp/server.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** Minimal stdio MCP server (JSON-RPC 2.0, newline-delimited): dispatch_to_grokbot, dispatch_to_dsh, dispatch_status. */ +import readline from 'node:readline'; +import { toGrokBot, toDsh, status } from '../lib/dispatch.mjs'; + +const proj = { project: { type: 'string', description: 'Folder name under the projects root (default ~/Projects); the text is tagged [Project: ] for routing' }, + text: { type: 'string', description: 'Task text. May already start with [Project: ]' }, + timeout_seconds: { type: 'integer', minimum: 10, maximum: 7200 } }; +const TOOLS = [ + { name: 'dispatch_to_grokbot', description: 'Send a task to Grok Bot (peer "grok-bot": direct HTTP, or Yobiru grokPeer when transport is yobiru); returns the request/job id.', inputSchema: { type: 'object', properties: proj, required: ['text'] }, run: toGrokBot }, + { name: 'dispatch_to_dsh', description: 'Hand a task to DeepSeek Harness: over HTTP to peers.dsh (any machine) or a local headless dsh; returns a job id.', inputSchema: { type: 'object', properties: proj, required: ['text'] }, run: toDsh }, + { name: 'dispatch_status', description: 'Status/result of a DSH job id, remote request/job id, or ZCode session_id (sess_...) mapped to a dispatched job.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, run: status }, +]; +const write = (m) => process.stdout.write(JSON.stringify(m) + '\n'); +readline.createInterface({ input: process.stdin }).on('line', (line) => { + let msg; try { msg = JSON.parse(line); } catch { return; } + const { id, method, params } = msg; + if (id === undefined) return; // notification + const ok = (result) => write({ jsonrpc: '2.0', id, result }); + if (method === 'initialize') return ok({ protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'zcode-dispatch', version: '0.3.0' } }); + if (method === 'ping') return ok({}); + if (method === 'tools/list') return ok({ tools: TOOLS.map(({ run, ...t }) => t) }); + if (method === 'tools/call') { + const tool = TOOLS.find(t => t.name === params?.name); + if (!tool) return write({ jsonrpc: '2.0', id, error: { code: -32602, message: 'unknown tool' } }); + return Promise.resolve().then(() => tool.run(params.arguments || {})) + .then((r) => ok({ content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] })) + .catch((e) => ok({ isError: true, content: [{ type: 'text', text: String(e.message || e) }] })); + } + write({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found' } }); +}); diff --git a/marketplace.json b/marketplace.json index 8afcd9c7..888bd81e 100644 --- a/marketplace.json +++ b/marketplace.json @@ -790,7 +790,9 @@ }, { "name": "blender", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Blender", "displayName_i18n": { "en": "Blender", @@ -811,7 +813,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -832,7 +836,9 @@ }, { "name": "openpencil", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "OpenPencil", "displayName_i18n": { "en": "OpenPencil", @@ -855,7 +861,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -882,7 +890,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -909,7 +919,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -936,7 +948,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -963,7 +977,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -990,7 +1006,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1017,7 +1035,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1044,7 +1064,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1071,7 +1093,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1239,6 +1263,31 @@ "author": { "name": "ZCode" } + }, + { + "name": "zcode-grokbot-dispatch", + "source": "./external_plugins/zcode-grokbot-dispatch", + "description": "Grok Bot Dispatch for ZCode: bidirectional task dispatch between ZCode desktop and Grok Bot / DSH ([Project: X] routing, persistent session<->job mapping, automatic result reporting back to your Grok Bot peer: direct HTTP by default, Yobiru optional).", + "description_i18n": { + "en": "Grok Bot Dispatch for ZCode: bidirectional task dispatch between ZCode desktop and Grok Bot / DSH ([Project: X] routing, persistent session<->job mapping, automatic result reporting back to your Grok Bot peer: direct HTTP by default, Yobiru optional).", + "zh-CN": "Grok Bot 派活插件(ZCode 版):ZCode 桌面版与 Grok Bot / DSH 双向派活([Project: X] 路由、会话↔job 持久映射、任务结果自动回报给 Grok Bot 对端,默认 HTTP 直连,Yobiru 可选)。" + }, + "version": "0.3.0", + "author": { + "name": "semantic-craft", + "url": "https://github.com/semantic-craft/zcode-dispatch" + }, + "category": "productivity", + "keywords": [ + "grok-bot", + "grokbot", + "dispatch", + "dsh", + "yobiru", + "hooks", + "mcp" + ], + "displayName": "Grok Bot Dispatch for ZCode" } ] }