/** * Command handlers for dsh-telegram-remote. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, isAbsolute, resolve } from "node:path"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { esc } from "./bot.js"; import { dshHome, invoke, callApi, pretty, truncate, fmtAge, shortId, contentText } from "./util.js"; import { cmdRename, cmdFork, cmdSearch, cmdExport, cmdWorkspaces, cmdWs, cmdMkdir, cmdArchive, cmdInterrupt, cmdAgentLog, cmdPresets, cmdPreset, cmdSkills, cmdPlugins, cmdSettings, cmdSetting, cmdCreds, cmdPermission } from "./features.js"; export const KEYBOARD = { inline_keyboard: [ [ { text: "πŸ’¬ New chat", callback_data: "/new" }, { text: "πŸ“‹ My chats", callback_data: "/chats" }, ], [ { text: "🧠 Model", callback_data: "/models" }, { text: "πŸ“Š Status", callback_data: "/status" }, ], [ { text: "❓ Help", callback_data: "/help" }, { text: "⏹ Stop AI", callback_data: "/stop" }, { text: "πŸ”” Notify", callback_data: "/notify" }, ], ], }; export const BUSY_KEYBOARD = { inline_keyboard: [ [ { text: "▢️ Send now", callback_data: "/steernow" }, { text: "⏳ Queue it", callback_data: "/queuemsg" }, ], ], }; /** Telegram native command menu β€” EVERY command the bot supports. */ export const COMMANDS_MENU = [ { command: "start", description: "Welcome & quick start" }, { command: "help", description: "Full guide to everything" }, { command: "new", description: "Start a fresh chat" }, { command: "chats", description: "List chats β€” /chats pages, reply a number to open" }, { command: "sessions", description: "List chats (same as /chats)" }, { command: "open", description: "Open a chat by id or number" }, { command: "msg", description: "Send a message to the chat" }, { command: "log", description: "Recent messages of the chat" }, { command: "stop", description: "Stop the AI" }, { command: "queue", description: "Queued messages β€” steer/edit/remove" }, { command: "steer", description: "Send into the running turn now" }, { command: "edit", description: "Rewrite a queued message" }, { command: "remove", description: "Remove a queued message" }, { command: "status", description: "What's happening right now" }, { command: "model", description: "This chat's model" }, { command: "models", description: "Browse available models" }, { command: "rename", description: "Rename a chat" }, { command: "fork", description: "Copy a chat to experiment" }, { command: "search", description: "Search past conversations" }, { command: "export", description: "Download a chat as a file" }, { command: "archive", description: "Tuck a chat away" }, { command: "workspaces", description: "Folders your chats live in" }, { command: "ws", description: "Workspace: new|rename|delete" }, { command: "agents", description: "Subagent children of this chat" }, { command: "send", description: "Message a subagent" }, { command: "interrupt", description: "Stop a subagent" }, { command: "agentlog", description: "A subagent's recent messages" }, { command: "cmd", description: "Run a PowerShell command" }, { command: "fs", description: "Files: ls|read|write|rm|stat" }, { command: "mkdir", description: "Create a folder" }, { command: "jobs", description: "Background tasks" }, { command: "kill", description: "Kill a background task" }, { command: "goal", description: "Goal: get|create|pause|clear|..." }, { command: "permission", description: "Chat access: read|write|full" }, { command: "presets", description: "Agent presets" }, { command: "preset", description: "Switch this chat's preset" }, { command: "skills", description: "Available skills" }, { command: "plugins", description: "Loaded plugins" }, { command: "settings", description: "Settings sections" }, { command: "setting", description: "Change a setting (JSON)" }, { command: "creds", description: "API keys: describe|set|unset" }, { command: "raw", description: "Call any harness endpoint" }, { command: "api", description: "List all harness endpoints" }, { command: "config", description: "Show config files" }, { command: "eval", description: "Run JS in the harness" }, { command: "notify", description: "Notifications on/off/all" }, { command: "reboot", description: "Restart the harness (same port)" }, { command: "shutdown", description: "Stop the harness" }, { command: "whoami", description: "Your telegram id" }, ]; export const HELP = "" + "πŸ“– How to use me\n\n" + "Just type a message and I'll send it to your AI β€” the reply streams here live.\n" + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + "πŸ—¨ Chat\n" + "πŸ’¬ /new β€” start a fresh chat\n" + "πŸ“‹ /chats β€” see your chats (/chats 2 pages; reply a number to open)\n" + "🧠 /model β€” switch the AI's model (try /model max)\n" + "πŸ“Š /status β€” what's happening right now\n" + "⏹ /stop β€” stop the AI if it's working\n" + "πŸ“₯ /queue β€” messages waiting (edit / steer / remove them)\n" + "πŸ”” /notify on|off β€” reply notifications on/off\n\n" + "πŸ›  Control\n" + "/cmd <powershell> β€” run a command on the computer\n" + "/fs read|ls <path> β€” look at files\n" + "/jobs / /kill <job> β€” background tasks\n" + "/goal β€” set & track a long-running objective\n" + "πŸ” /permission β€” chat access: read | write | full\n\n" + "🧩 Explore\n" + "/agents β€” subagents Β· /presets β€” agent presets\n" + "/skills β€” skills Β· /plugins β€” loaded plugins\n" + "/export β€” download a chat Β· /search β€” find things\n\n" + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n" + "Everything runs on your own computer. Only you can talk to this bot."; export const WELCOME = "" + "πŸ‘‹ Welcome to your DeepSeek Harness πŸš€\n" + "your AI, in your pocket\n\n" + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + "πŸ’¬ Chat β€” just type a message and I'll pass it to your AI\n" + "πŸ› οΈ Do β€” write code, run commands, manage files\n" + "πŸ“Š Watch β€” live streams, status, chats, tasks\n\n" + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + "Try typing hello, or tap a button below πŸ‘‡"; export const COMMAND_TABLE = { start: cmdStart, welcome: cmdStart, help: cmdHelp, chats: cmdSessions, chat: cmdSessions, status: cmdStatus, models: cmdModels, model: cmdModel, sessions: cmdSessions, list: cmdSessions, open: cmdOpen, use: cmdOpen, new: cmdNew, msg: cmdMsg, q: cmdMsg, steer: cmdSteer, steernow: cmdSteerNow, queuemsg: cmdQueueMsg, queue: cmdQueue, edit: cmdEdit, remove: cmdRemove, stop: cmdStop, jobs: cmdJobs, kill: cmdKill, goal: cmdGoal, agents: cmdAgents, send: cmdSubagentSend, cmd: cmdShell, sh: cmdShell, fs: cmdFs, log: cmdLog, raw: cmdRaw, api: cmdApi, eval: cmdEval, config: cmdConfig, notify: cmdNotify, reboot: cmdReboot, shutdown: cmdShutdown, whoami: cmdWhoami, // full-harness feature commands (features.js) rename: cmdRename, fork: cmdFork, search: cmdSearch, export: cmdExport, workspaces: cmdWorkspaces, ws: cmdWs, mkdir: cmdMkdir, archive: cmdArchive, interrupt: cmdInterrupt, agentlog: cmdAgentLog, presets: cmdPresets, preset: cmdPreset, skills: cmdSkills, plugins: cmdPlugins, settings: cmdSettings, setting: cmdSetting, creds: cmdCreds, permission: cmdPermission, }; async function cmdHelp(r) { return HELP; } async function cmdStart(r) { // New chats: notifications are on by default so replies always arrive. const state = r.state.chatState(r.chatId); if (state.notify !== "session" && state.notify !== "all") { state.notify = "session"; r.state.saveState(); } return WELCOME; } async function cmdWhoami(r) { return "your telegram user id: " + r.userId + "\nchat id: " + r.chatId + "\nauthorized: " + r.authorized + ""; } export async function collectJobs(ctx) { const jobs = ctx.get("jobs"); if (!jobs) return []; const agents = ctx.get("agents"); const sessions = ctx.get("sessions"); const seen = new Map(); for (const session of sessions.list()) { const agent = agents.get(session.id); if (!agent) continue; try { for (const job of jobs.list(agent)) seen.set(job.id, job); } catch {} } try { for (const job of jobs.list(undefined)) seen.set(job.id, job); } catch {} return [...seen.values()]; } async function cmdStatus(r) { const lines = ["πŸ“Š Status\n─────── β‹†β‹…β˜†β‹…β‹† ───────"]; const gateway = r.ctx.get("typertGateway"); try { const host = await callApi(r.ctx, "host", "describe", {}); lines.push("🟒 Online β€” " + (host.provider ? "model " + esc(host.provider) + "/" + esc(host.model ?? "?") : "ready")); } catch { lines.push("🟒 Online"); } try { const sessions = await r.state.listSessions(); const running = sessions.filter((item) => item.running).length; lines.push("πŸ’¬ Chats β€” " + sessions.length + " total Β· " + running + " working"); const active = r.state.chatState(r.chatId).sessionId; if (active) { const title = sessions?.find((item) => item.sessionId === active)?.projections?.values?.title; lines.push(" πŸ“Œ current: " + esc(title ?? shortId(active)) + ""); } } catch { lines.push("πŸ’¬ Chats: n/a"); } try { const jobs = await collectJobs(r.ctx); const activeJobs = jobs.filter((job) => job.status === "running" || job.status === "stopping"); lines.push("βš™οΈ Tasks β€” " + jobs.length + " total Β· " + activeJobs.length + " running"); } catch { lines.push("βš™οΈ Tasks: n/a"); } try { const goal = await getGoal(r.ctx, r.state, r.chatId); if (goal) lines.push("🎯 Goal β€” " + esc(truncate(goal.objective, 100)) + " (" + goal.phase + ")"); } catch {} lines.push( "πŸ–₯️ Computer β€” pid " + process.pid + " Β· up " + fmtAge(Date.now() - Math.floor(process.uptime() * 1000)), "πŸ€– Bot β€” " + esc(r.state.bot.me?.username ?? "?") + " Β· polling " + (r.state.bot.lastPollAt ? fmtAge(r.state.bot.lastPollAt) : "starting"), ); lines.push("─────── β‹†β‹…β˜†β‹…β‹† ───────"); return lines.join("\n"); } async function getCatalog(ctx) { const result = await callApi(ctx, "llm", "models", {}); return result?.groups ?? []; } /** Flatten provider groups into { provider, id, name, efforts } rows. */ function flattenCatalog(catalog) { const out = []; for (const group of catalog ?? []) { const provider = group.id ?? group.group?.id ?? ""; const models = group.models ?? group.group?.models ?? []; for (const model of models) { if (!model?.id) continue; out.push({ provider, id: String(model.id), name: model.name ?? model.id, efforts: (model.reasoning?.efforts ?? []).map((e) => e.id), }); } } return out; } async function currentModelInfo(ctx, state, chatId) { const sessionId = await state.resolveSessionId(chatId, ""); let current = {}; if (sessionId) { try { const r = await callApi(ctx, "sessions", "models", { sessionId }); current = r?.current ?? {}; } catch {} } const def = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {}; return { sessionId, current, def }; } async function cmdModels(r) { const catalog = await getCatalog(r.ctx); const flat = flattenCatalog(catalog); if (flat.length === 0) return "No models are available right now β€” check your API keys in the web Models page."; let currentId = ""; try { const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (sessionId) { const info = await callApi(r.ctx, "sessions", "models", { sessionId }); currentId = info?.current?.model ?? ""; } } catch {} const cs = r.state.chatState(r.chatId); cs.lastModels = flat.map((m) => ({ provider: m.provider, model: m.id })); cs.lastModelsAt = Date.now(); r.state.saveState(); const lines = ["🧠 Pick a model", "tap a button, or reply with a number:"]; const rows = []; const shown = flat.slice(0, 18); shown.forEach((m, i) => { const n = i + 1; const on = m.id === currentId; lines.push((on ? "βœ… " : "") + "" + n + ". " + esc(m.id) + "" + (m.efforts.includes("max") ? " ⭐" : "")); const cmd = "/model " + m.id; const cb = cmd.length <= 64 ? cmd : "md:" + n; const label = (on ? "βœ… " : "") + n + ". " + (m.id.length > 26 ? m.id.slice(0, 24) + "…" : m.id); rows.push([{ text: label, callback_data: cb }]); }); if (flat.length > shown.length) lines.push("+ " + (flat.length - shown.length) + " more β€” type /model <id>"); rows.push([{ text: "⭐ Deepest thinking (max)", callback_data: "/model max" }]); return { text: truncate(lines.join("\n"), 3500), keyboard: { inline_keyboard: rows } }; } async function cmdModel(r) { const args = r.args; const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId && args.length === 0) { const info = await currentModelInfo(r.ctx, r.state, r.chatId); const lines = ["This chat: none open yet β€” tap πŸ’¬ New chat"]; if (info.def?.provider) lines.push("Default: " + esc(info.def.provider) + " / " + esc(info.def.model)); lines.push("", "Tap 🧠 Model to pick one, or send a message to start chatting."); return lines.join("\n"); } if (args.length === 0) { const info = await currentModelInfo(r.ctx, r.state, r.chatId); const lines = []; if (info.current?.provider) { lines.push("This chat: " + esc(info.current.provider) + " / " + esc(info.current.model) + (info.current.reasoningEffort ? " Β· " + esc(info.current.reasoningEffort) : "")); } else { lines.push("This chat: using the default"); } if (info.def?.provider) { lines.push("Default: " + esc(info.def.provider) + " / " + esc(info.def.model) + (info.def.reasoningEffort ? " Β· " + esc(info.def.reasoningEffort) : "")); } lines.push("", "/models to browse Β· /model <id> to switch Β· /model max for deepest thinking"); return lines.join("\n"); } const first = args[0].toLowerCase(); if (/^\d+$/.test(first)) { const list = r.state.chatState(r.chatId).lastModels ?? []; const pick = list[Number(first) - 1]; if (!pick) return "That number isn't on the last model list β€” tap 🧠 Model again."; return setSessionModel(r, sessionId, { provider: pick.provider, model: pick.model }); } if (first === "max" || first === "deep" || first === "deepest") { return setSessionModel(r, sessionId, { reasoningEffort: "max" }); } if (first === "default") { const prov = args[1]; const model = args[2]; const effort = args[3]; if (!prov || !model) return "usage: /model default <provider> <model> [effort]"; const settings = r.ctx.get("settings"); if (!settings || typeof settings.replace !== "function") return "Settings service unavailable."; await settings.replace("agent-default-model", { provider: prov, model, ...(effort ? { reasoningEffort: effort } : {}), }); return "βœ… Default model set: " + esc(prov) + " / " + esc(model) + (effort ? " Β· " + esc(effort) : "") + "\nApplies to new chats."; } // /model [effort] or /model [effort] // also accepts provider/model as a single token. const catalog = await getCatalog(r.ctx); const flat = flattenCatalog(catalog); let provider = null; let model = null; let effort = null; const raw = String(args[0] ?? ""); if (raw.includes("/") && args.length === 1) { const slash = raw.indexOf("/"); provider = raw.slice(0, slash); model = raw.slice(slash + 1); } else if (args.length >= 2 && (catalog.some((g) => (g.id ?? g.group?.id) === args[0]) || flat.some((m) => m.provider === args[0]))) { provider = args[0]; model = args[1]; effort = args[2]; } else { model = args[0]; effort = args[1]; } if (!provider && model) { const hit = flat.find((m) => m.id === model) ?? flat.find((m) => m.id.toLowerCase() === String(model).toLowerCase()); provider = hit?.provider ?? null; if (hit) model = hit.id; } if (!provider || !model) return "I couldn't find that model β€” tap 🧠 Model and pick one from the list."; return setSessionModel(r, sessionId, { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }); } async function setSessionModel(r, sessionId, { provider, model, reasoningEffort }) { if (!sessionId) { const settings = r.ctx.get("settings"); if (!settings || typeof settings.replace !== "function") return "Open a chat first (tap πŸ’¬ New chat), then pick a model."; if (!provider || !model) return "Tap 🧠 Model and pick one from the list."; await settings.replace("agent-default-model", { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}), }); return "βœ… Next chat will use " + esc(model) + "\nTap πŸ’¬ New chat or just send a message."; } let current = {}; try { const r2 = await callApi(r.ctx, "sessions", "models", { sessionId }); current = r2?.current ?? {}; } catch {} const payload = { sessionId, provider: provider ?? current.provider, model: model ?? current.model, ...(reasoningEffort ? { reasoningEffort } : {}), }; if (!payload.provider || !payload.model) return "This chat has no model yet β€” tap 🧠 Model and pick one."; try { await callApi(r.ctx, "sessions", "selectModel", payload); } catch (error) { const msg = String(error.message ?? error); if (/image input|does not accept image|does not support image/i.test(msg)) { return "⚠️ This chat already has photos, so it needs a vision model.\nPick one that can see images, or tap πŸ’¬ New chat and switch there."; } return "⚠️ Couldn't switch model: " + esc(truncate(msg, 220)); } return "βœ… Switched to " + esc(payload.model) + "\n" + esc(payload.provider) + (payload.reasoningEffort ? " Β· " + esc(payload.reasoningEffort) : "") + "\nSend a message to use it."; } const CHATS_PAGE_SIZE = 10; async function cmdSessions(r) { const sessions = await r.state.listSessions(); if (!sessions) return "No chats yet β€” tap πŸ’¬ New chat and say hi!"; const visible = sessions.filter((item) => item.origin !== "subagent"); if (visible.length === 0) return "No chats yet β€” tap πŸ’¬ New chat and say hi!"; const active = r.state.chatState(r.chatId).sessionId; const chatState = r.state.chatState(r.chatId); chatState.sessionIds = visible.map((item) => item.sessionId); chatState.lastListAt = Date.now(); r.state.saveState(); const totalPages = Math.max(1, Math.ceil(visible.length / CHATS_PAGE_SIZE)); let page = 1; const rawPage = r.args?.[0]; if (rawPage != null && /^\d+$/.test(String(rawPage).trim())) { page = Number(String(rawPage).trim()); if (page < 1) page = 1; if (page > totalPages) page = totalPages; } const start = (page - 1) * CHATS_PAGE_SIZE; const show = visible.slice(start, start + CHATS_PAGE_SIZE); const lines = ["πŸ“‹ Your chats", "reply with a number to open one:"]; show.forEach((item, index) => { const mark = item.sessionId === active ? " πŸ‘ˆ" : ""; const status = item.running ? "πŸƒ" : item.blank ? "⬜" : "πŸ’€"; const title = item.projections?.values?.title; // Shorten the path to its last segment β€” full paths clutter mobile chats. const folder = item.cwd ? basename(item.cwd) : ""; lines.push( (start + index + 1) + ". " + status + " " + esc(title ?? "untitled") + "" + mark + "\n " + fmtAge(item.updatedAt) + (folder ? " Β· " + esc(folder) : "") + "", ); }); lines.push("Page " + page + "/" + totalPages + ""); lines.push("πŸ’¬ /new starts a fresh chat"); if (totalPages > 1) { const row = []; if (page > 1) row.push({ text: "◀️", callback_data: "/chats " + (page - 1) }); if (page < totalPages) row.push({ text: "▢️", callback_data: "/chats " + (page + 1) }); return { text: lines.join("\n"), keyboard: { inline_keyboard: [row] } }; } return lines.join("\n"); } async function cmdOpen(r) { const arg = r.args[0]; if (!arg) { const state = r.state.chatState(r.chatId); return state.sessionId ? "current chat: " + state.sessionId + "" : "No chat open yet β€” use πŸ’¬ New chat or πŸ“‹ My chats"; } let sessionId = null; const chatState = r.state.chatState(r.chatId); if (/^\d+$/.test(arg) && chatState.sessionIds?.length > 0 && Date.now() - (chatState.lastListAt ?? 0) < 10 * 60_000) { const index = Number(arg) - 1; sessionId = chatState.sessionIds[index] ?? null; if (!sessionId) return "That number isn't in the list β€” send /chats again"; } if (!sessionId) { sessionId = await r.state.resolveSessionId(r.chatId, arg); } if (!sessionId) return "I couldn't find that chat: " + esc(arg) + " β€” try /chats"; chatState.sessionId = sessionId; r.state.saveState(); const sessions = await r.state.listSessions(); const title = sessions?.find((item) => item.sessionId === sessionId)?.projections?.values?.title; return "βœ… Opened " + esc(title ?? sessionId.slice(0, 12) + "…") + "\nJust type your message β€” the reply streams right here. πŸ’¬"; } async function cmdNew(r) { const cwd = r.args[0] || r.state.config.workspaceRoot || process.cwd(); const created = await callApi(r.ctx, "sessions", "create", { cwd }); const sessionId = created.sessionId; r.state.chatState(r.chatId).sessionId = sessionId; r.state.saveState(); return "βœ… New chat ready!\nSend me your first message β€” the AI's reply will stream right here. 😊"; } export async function cmdMsg(r) { // Plain text (no leading "/") β†’ the WHOLE message is the text. r.rest would // drop the first word; /msg keeps using r.rest (the part after /msg). let text = r.text.startsWith("/") ? r.rest : r.text; if (!text) return "What should I tell your AI? Just type your message πŸ™‚"; // Friendly default: if no chat is open yet, create one automatically. const cs = r.state.chatState(r.chatId); if (!cs.sessionId) { try { const created = await callApi(r.ctx, "sessions", "create", { cwd: r.state.config.workspaceRoot || process.cwd() }); cs.sessionId = created.sessionId; r.state.saveState(); } catch {} } const agents = r.ctx.get("agents"); const busy = agents?.get(cs.sessionId)?.status === "running"; if (busy) { // Offer a choice: steer it in now, or queue it for after the current work. cs.pendingText = text; cs.pendingAt = Date.now(); r.state.saveState(); return { text: "πŸ€– The AI is working on something. Send this message now (steer it in) or queue it?", keyboard: BUSY_KEYBOARD, }; } await promptSession(r, "queue", text, undefined); return null; } async function cmdQueue(r) { const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "No chat is open β€” tap πŸ’¬ New chat first."; const agent = r.ctx.get("agents")?.get(sessionId); const nextTurn = agent?.inbox?.state?.["next-turn"] ?? []; const nextStep = agent?.inbox?.state?.["next-step"] ?? []; const items = [ ...nextTurn.map((m) => ({ id: m.id, kind: "queued", text: contentText(m.content) })), ...nextStep.map((m) => ({ id: m.id, kind: "steering", text: contentText(m.content) })), ]; if (items.length === 0) return "Nothing is queued β€” the AI is idle. Just send a message!"; const cs = r.state.chatState(r.chatId); cs.queueIds = items.map((item) => item.id); cs.queueListAt = Date.now(); r.state.saveState(); const lines = ["πŸ“₯ Queued messages\n─────── β‹†β‹…β˜†β‹…β‹† ───────"]; items.forEach((item, index) => { const tag = item.kind === "steering" ? "⚑steering" : "⏳queued"; lines.push((index + 1) + ". " + tag + " " + esc(truncate(item.text, 90))); }); lines.push( "─────── β‹†β‹…β˜†β‹…β‹† ───────", "β–Ά /steer <n> β€” push into the running turn", "✏ /edit <n> <new> Β· πŸ—‘ /remove <n>", ); return truncate(lines.join("\n"), 3000); } function queueItemId(r, arg) { const cs = r.state.chatState(r.chatId); if (/^\d+$/.test(arg) && cs.queueIds?.length > 0 && Date.now() - (cs.queueListAt ?? 0) < 10 * 60_000) { return cs.queueIds[Number(arg) - 1] ?? null; } return arg; } async function cmdEdit(r) { const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "No chat is open."; const itemId = queueItemId(r, r.args[0] ?? ""); const text = r.args.slice(1).join(" "); if (!itemId) return "/edit <n> <new text> β€” see /queue for numbers"; if (!text) return "What should the queued message say instead? /edit <n> <new text>"; await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "edit", content: [{ type: "text", text }] } }); return "✏️ Updated the queued message."; } async function cmdRemove(r) { const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "No chat is open."; const itemId = queueItemId(r, r.args[0] ?? ""); if (!itemId) return "/remove <n> β€” see /queue for numbers"; await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "remove" } }); return "πŸ—‘ Removed the queued message."; } async function cmdSteer(r) { const text = r.rest; if (!text) return "/steer <text> β€” steer the running turn, or /steer <n> for a queued message"; // A bare number right after /queue steers that queued message into the turn. if (/^\d+$/.test(text) && r.state.chatState(r.chatId).queueIds?.length > 0) { const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "No chat is open."; const itemId = queueItemId(r, text); if (!itemId) return "That queued message is gone β€” /queue to refresh."; await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "steer" } }); return "βœ… Steered the queued message into the running turn."; } await promptSession(r, "steer", text, undefined); return "▢️ Sent it straight into the AI's current turn."; } async function cmdSteerNow(r) { const cs = r.state.chatState(r.chatId); const text = cs.pendingText; if (!text) return "Nothing pending β€” just type your message."; cs.pendingText = ""; r.state.saveState(); await promptSession(r, "steer", text, undefined); return "▢️ Sent it straight into the AI's current turn."; } async function cmdQueueMsg(r) { const cs = r.state.chatState(r.chatId); const text = cs.pendingText; if (!text) return "Nothing pending β€” just type your message."; cs.pendingText = ""; r.state.saveState(); await promptSession(r, "queue", text, undefined); return "⏳ Queued β€” the AI will pick it up next."; } async function promptSession(r, mode, text, sessionArg) { const sessionId = await r.state.resolveSessionId(r.chatId, sessionArg); if (!sessionId) return "no active session β€” /open <id> or /new first"; if (r.state.isRecentPrompt(sessionId, text)) return "duplicate prompt skipped"; let timeZone = "UTC"; try { timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch {} let result = null; try { result = await callApi(r.ctx, "sessions", "prompt", { sessionId, mode, content: [{ type: "text", text }], clientTimeZone: timeZone, }); } catch (error) { if (error.code !== "invocation-unavailable" && error.code !== "no api surface") { throw error; } // api-proxy absent (base-only profiles): fall back to the live agent's // inbox directly (with resume), mirroring what the api-proxy does. result = await promptSessionDirect(r.ctx, sessionId, mode, text); } r.state.notePrompt(sessionId, text); // Auto-enable notifications for this session so the agent's reply arrives. const chatState = r.state.chatState(r.chatId); const notifyChanged = chatState.notify === "off"; if (notifyChanged) { chatState.notify = "session"; r.state.saveState(); } if (result?.command) { return "⚑ command " + esc(result.command.kind) + (result.command.text ? ": " + esc(result.command.text) : ""); } // No ack β€” the reply arrives as a normal threaded chat message. return null; } /** Direct agent-inbox prompt for profiles without the web api-proxy. */ async function promptSessionDirect(ctx, sessionId, mode, text) { const agents = ctx.get("agents"); if (!agents) throw new Error("no agent service mounted"); let agent = agents.get(sessionId); if (!agent && typeof agents.resume === "function") { // Session not attached after a harness restart: resume it like the // api-proxy would (seed model from the agent-default-model service). let agentOptions = {}; try { const modelService = ctx.get("agentDefaultModel"); const selection = modelService?.currentSelection?.(); if (selection?.provider && selection?.model) { agentOptions = { provider: selection.provider, model: selection.model, ...(selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}), }; } } catch {} const resumed = await agents.resume({ resumeSessionId: sessionId, agentOptions }); agent = resumed?.agent; } if (!agent) throw new Error("session has no live agent (not attached)"); const message = { id: randomUUID(), role: "user", content: [{ type: "text", text }], source: { kind: "user" }, }; if (mode === "steer") { if (typeof agent.steer !== "function") throw new Error("agent does not accept steering"); agent.steer(message); } else if (typeof agent.followup === "function") { agent.followup(message); } else { throw new Error("agent does not accept followups"); } return { accepted: true }; } async function cmdStop(r) { const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); if (!sessionId) return "no active session"; await callApi(r.ctx, "sessions", "cancel", { sessionId }); return "⏹ cancel requested for " + shortId(sessionId) + ""; } async function cmdJobs(r) { const jobs = await collectJobs(r.ctx); if (jobs.length === 0) return "no background jobs"; return jobs .map((job) => { const icon = job.status === "running" ? "πŸƒ" : job.status === "stopping" ? "⏳" : job.status === "completed" ? "βœ…" : job.status === "killed" ? "⏹" : "❌"; const detail = job.detail ? " β€” " + esc(job.detail) : ""; return icon + " " + esc(job.id) + " " + esc(job.label) + " [" + job.status + "]" + detail + " (" + fmtAge(job.startedAt) + ")"; }) .join("\n"); } async function cmdKill(r) { const jobId = r.args[0]; if (!jobId) return "/kill <jobId>"; const jobs = r.ctx.get("jobs"); const agents = r.ctx.get("agents"); const sessions = r.ctx.get("sessions"); for (const session of sessions.list()) { const agent = agents.get(session.id); if (!agent) continue; try { const found = jobs.list(agent).find((job) => job.id === jobId); if (found) { const outcome = jobs.kill(jobId, agent, "killed from telegram remote"); return "⏹ " + esc(jobId) + ": " + outcome; } } catch (error) { if (/not found|no such/i.test(error.message)) continue; throw error; } } return "job not found: " + esc(jobId) + " (use /jobs)"; } export async function getGoal(ctx, state, chatId) { const goals = ctx.get("goals"); if (!goals) return null; const agents = ctx.get("agents"); const sessionId = await state.resolveSessionId(chatId, ""); if (!sessionId) return null; const agent = agents.get(sessionId); if (!agent) return null; try { return goals.get(agent) ?? null; } catch { return null; } } async function cmdGoal(r) { const goals = r.ctx.get("goals"); const agents = r.ctx.get("agents"); if (!goals || !agents) return "goal service unavailable"; const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "no active session β€” /open <id> first"; const agent = agents.get(sessionId); if (!agent) return "session " + shortId(sessionId) + " has no live agent"; const action = r.args[0] ?? "get"; try { switch (action) { case "get": case "status": { const goal = goals.get(agent); if (!goal) return "no active goal for this session"; let text = "Goal " + goal.id + "\nphase " + goal.phase + " Β· rounds " + goal.roundsStarted + "/" + goal.maxGoalRounds + "\nrevision " + goal.revision + "\n\n" + esc(goal.objective); if (goal.blockedReason?.message) text += "\n\nblocked: " + esc(goal.blockedReason.message); return text; } case "create": { const objective = r.rest.replace(/^create\s+/, "").trim(); if (!objective) return "/goal create <objective>"; const ref = goals.create(agent, { objective }); return "goal created: " + ref.id + " r" + ref.revision; } case "pause": { const goal = goals.get(agent); if (!goal) return "no active goal"; const ref = goals.pause(agent, { id: goal.id, revision: goal.revision }); return "⏸ paused " + ref.id + " r" + ref.revision; } case "resume": { const goal = goals.get(agent); if (!goal) return "no active goal"; const ref = goals.resume(agent, { id: goal.id, revision: goal.revision }); return "β–Ά resumed " + ref.id + " r" + ref.revision; } case "complete": { const goal = goals.get(agent); if (!goal) return "no active goal"; const ref = goals.complete(agent, { id: goal.id, revision: goal.revision }); return "βœ… completed " + ref.id + " r" + ref.revision; } case "blocked": { const reason = r.rest.replace(/^blocked\s+/, "").trim(); if (!reason) return "/goal blocked <reason>"; const goal = goals.get(agent); if (!goal) return "no active goal"; const ref = goals.blocked(agent, { id: goal.id, revision: goal.revision }, reason); return "β›” marked blocked " + ref.id + ""; } case "clear": { if (typeof goals.clear === "function") { goals.clear(agent); } else { await callApi(r.ctx, "goals", "clear", { sessionId }); } return "🧹 Goal cleared."; } default: return "unknown action: get|create|pause|resume|complete|blocked|clear"; } } catch (error) { return "goal error: " + esc(error.message); } } async function cmdAgents(r) { const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); if (!sessionId) return "no active session β€” /open <id> first"; const result = await callApi(r.ctx, "subagents", "list", { parentSessionId: sessionId }); const entries = result?.entries ?? []; if (entries.length === 0) return "no subagents under " + shortId(sessionId); return entries .map((entry) => { if (entry.kind === "diagnostic") return "⚠ " + entry.id + " diagnostic " + entry.reason; const icon = entry.activity === "running" ? "πŸƒ" : "πŸ’€"; return icon + " " + entry.id + " " + esc(entry.label ?? "") + " [" + entry.mode + "]" + (entry.hasChildren ? " ⊳" : ""); }) .join("\n"); } async function cmdSubagentSend(r) { const childId = r.args[0]; const text = r.args.slice(1).join(" "); if (!childId || !text) return "/send <agentId> <text>"; const sessionId = await r.state.resolveSessionId(r.chatId, ""); if (!sessionId) return "no active parent session β€” /open <id> first"; const result = await callApi(r.ctx, "subagents", "prompt", { parentSessionId: sessionId, childSessionId: childId, content: [{ type: "text", text }], }); return "πŸ“¨ delivered to " + shortId(childId) + " (message " + esc(result?.messageId ?? "?") + ")"; } async function cmdShell(r) { const shell = r.ctx.get("shell"); if (!shell) return "shell service unavailable"; const command = r.rest; if (!command) return "/cmd <powershell>"; let timeoutMs = 60_000; if (/^\d+$/.test(r.args[0] ?? "")) timeoutMs = Math.min(Math.max(Number(r.args[0]), 5_000), 300_000); const request = { command, workdir: r.state.config.workspaceRoot || process.cwd(), timeoutMs, stdoutMaxBytes: r.state.config.maxOutputBytes, }; const spec = typeof shell.resolve === "function" ? shell.resolve(request) : request; const result = await shell.run(spec); const out = truncate(result.stdout?.text ?? "", 3500); const err = truncate(result.stderr?.text ?? "", 1500); const meta = []; if (result.exitCode != null) meta.push("exit code " + result.exitCode); if (result.signal) meta.push("signal " + result.signal); if (result.timedOut) meta.push("timed out"); if (result.sandbox?.denied) meta.push("sandbox denied (" + result.sandbox.mode + ")"); const parts = []; if (out) parts.push("
" + esc(out) + "
"); if (err) parts.push("
" + esc(err) + "
"); parts.push(meta.length ? "" + esc(meta.join(" Β· ")) + "" : "ok"); return parts.join("\n"); } async function cmdFs(r) { const fs = r.ctx.get("fs"); if (!fs) return "fs service unavailable"; const sub = (r.args[0] ?? "ls").toLowerCase(); const action = sub; const restArgs = r.args.slice(1); const cwd = r.state.config.workspaceRoot || process.cwd(); switch (action) { case "ls": case "list": { const rawPath = restArgs.join(" ").trim() || "."; const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); const entries = await fs.listDir(target); const dirs = entries.filter((entry) => entry.type === "directory"); const files = entries.filter((entry) => entry.type !== "directory"); const lines = [ "" + esc(target.displayPath ?? rawPath) + " β€” " + dirs.length + " dirs, " + files.length + " files", ...dirs.map((entry) => "πŸ“ " + esc(entry.name)), ...files.map((entry) => "πŸ“„ " + esc(entry.name) + (entry.size != null ? " (" + entry.size + " B)" : "")), ]; return truncate(lines.join("\n"), 3500); } case "read": case "cat": { const rawPath = restArgs.join(" ").trim(); if (!rawPath) return "/fs read <path>"; const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); const stat = await fs.stat(target); if (!stat) return "not found: " + esc(target.displayPath ?? rawPath); if (stat.type !== "file") return "not a file: " + esc(target.displayPath ?? rawPath); const text = await fs.readText(target); return "" + esc(target.displayPath ?? rawPath) + " (" + stat.size + " B)\n
" + esc(truncate(text, 3500)) + "
"; } case "write": { const sep = restArgs.indexOf("|"); if (sep < 0) return "/fs write <path> | <content> β€” content after the pipe"; const path = restArgs.slice(0, sep).join(" ").trim(); const content = restArgs.slice(sep + 1).join(" "); const target = await fs.resolve(isAbsolute(path) ? path : resolve(cwd, path), { cwd }); const outcome = await fs.writeText(target, content); return "✍ " + outcome.operation + " " + esc(target.displayPath) + " (v" + outcome.version + ")"; } case "rm": case "del": { const rawPath = restArgs.join(" ").trim(); if (!rawPath) return "/fs rm <path>"; const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); try { await fs.delete(target); return "πŸ—‘ deleted " + esc(target.displayPath ?? rawPath); } catch (error) { return "delete failed: " + esc(error.message); } } case "stat": { const rawPath = restArgs.join(" ").trim(); if (!rawPath) return "/fs stat <path>"; const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); const stat = await fs.stat(target); if (!stat) return "not found: " + esc(target.displayPath ?? rawPath); return "" + esc(target.displayPath ?? rawPath) + "\ntype " + stat.type + " Β· size " + stat.size + " B Β· version " + esc(stat.version); } default: return "usage: /fs ls|read|write|rm|stat <path>"; } } async function cmdLog(r) { const sessions = r.ctx.get("sessions"); const sessionQuery = r.ctx.get("sessionQuery"); let sessionId = r.args[0] ?? ""; let count = 8; if (/^\d+$/.test(sessionId)) { count = Number(sessionId); sessionId = ""; } if (sessionId && /^\d+$/.test(r.args[1] ?? "")) count = Number(r.args[1]); sessionId = await r.state.resolveSessionId(r.chatId, sessionId); if (!sessionId) return "no active session β€” /open <id> first"; let events; try { const live = sessions.get(sessionId); if (live) { events = live.events; } else if (sessionQuery && typeof sessionQuery.readSession === "function") { const loaded = await sessionQuery.readSession(sessionId); events = loaded.events; } else if (sessionQuery && typeof sessionQuery.load === "function") { const loaded = await sessionQuery.load(sessionId); events = loaded.events; } else { return "session " + shortId(sessionId) + " not attached and sessionQuery unavailable"; } } catch (error) { return "load failed: " + esc(error.message); } const shown = []; for (const event of events) { if (event.type === "user/message") { const text = contentText(event.data.content); if (text) shown.push("πŸ§‘ " + esc(truncate(text, 300))); } else if (event.type === "assistant/message") { const text = contentText(event.data.message?.content, { textOnly: true }); if (text) shown.push("πŸ€– " + esc(truncate(text, 500))); } else if (event.type === "tool/call") { shown.push("πŸ”§ " + esc(event.data.name) + " " + esc(truncate(String(event.data.arguments ?? ""), 120))); } else if (event.type === "tool/result" && event.data.error) { shown.push("⚠ " + esc(event.data.error.name ?? "error") + ": " + esc(truncate(String(event.data.error.message ?? ""), 160))); } else if (event.type === "turn/end") { shown.push("⏹ turn ended: " + esc(event.data.reason?.kind ?? "?")); } } if (shown.length === 0) return "" + sessionId + " β€” no surface events"; const tail = shown.slice(-count).join("\n"); return "" + esc(sessionId) + " (last " + Math.min(count, shown.length) + " of " + shown.length + ")\n" + tail; } async function cmdRaw(r) { const endpoint = r.args[0] ?? ""; const [namespace, method] = endpoint.includes(".") ? endpoint.split(".") : endpoint.split("/"); if (!namespace || !method) return "/raw <namespace.method> [json args]"; let args = {}; const jsonPart = r.rest.replace(endpoint, "").trim(); if (jsonPart) { try { args = JSON.parse(jsonPart); } catch (error) { return "invalid JSON args: " + esc(error.message); } } let result; try { result = await callApi(r.ctx, namespace, method, args); } catch (error) { if (error.code !== "no api surface") throw error; const gateway = r.ctx.get("typertGateway"); if (!gateway) throw new Error("no api surface for " + endpoint); result = await invoke(gateway, namespace, method, args); } return "" + esc(endpoint) + " β†’\n
" + esc(pretty(result, 3500)) + "
"; } async function cmdApi(r) { const gateway = r.ctx.get("typertGateway"); const typert = r.ctx.get("typert"); const endpoints = new Set(); try { const local = typert?.local; if (local) { if (typeof local.keys === "function") for (const key of local.keys()) endpoints.add(key); if (local instanceof Map) for (const key of local.keys()) endpoints.add(key); } } catch {} try { if (typeof gateway?.collectSrcClaims === "function") { for (const claim of gateway.collectSrcClaims()) endpoints.add(claim); } } catch {} if (endpoints.size === 0) return "no endpoints discovered"; const sorted = [...endpoints].sort(); return "" + endpoints.size + " endpoints\n" + esc(sorted.join("\n")) + "".slice(0, 3900); } async function cmdEval(r) { if (!r.state.config.allowEval) return "/eval disabled (allowEval=false)"; const code = r.rest; if (!code) return "/eval <js> β€” runs with ctx, state, gateway, jobs, fs, shell, sessions, agents, goals in scope"; const sandbox = { ctx: r.ctx, state: r.state, gateway: r.ctx.get("typertGateway"), apiProxy: r.ctx.get("apiProxy"), callApi: (domain, method, args) => callApi(r.ctx, domain, method, args), jobs: r.ctx.get("jobs"), fs: r.ctx.get("fs"), shell: r.ctx.get("shell"), sessions: r.ctx.get("sessions"), agents: r.ctx.get("agents"), goals: r.ctx.get("goals"), settings: r.ctx.get("settings"), process, }; const fn = new Function(...Object.keys(sandbox), "return (async () => {\n" + code + "\n})()"); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("eval timeout (30s)")), 30_000)); const result = await Promise.race([fn(...Object.values(sandbox)), timeout]); return "
" + esc(pretty(result, 3000)) + "
"; } async function cmdConfig(r) { const parts = []; for (const file of [join(dshHome(), "settings.yaml"), join(dshHome(), "profiles", "web", "cordis.patch.yml")]) { if (!existsSync(file)) continue; try { const text = readFileSync(file, "utf8"); parts.push("" + esc(file.replace(dshHome(), "~")) + "\n
" + esc(truncate(text, 2500)) + "
"); } catch (error) { parts.push("" + esc(file) + " β€” read failed: " + esc(error.message)); } } if (parts.length === 0) return "no config files found"; return parts.join("\n"); } async function cmdNotify(r) { const state = r.state.chatState(r.chatId); const arg = (r.args[0] ?? "").toLowerCase(); const modes = { on: "session β€” replies of this chat's session", all: "all β€” every session's activity", off: "off β€” nothing until you ask", }; if (arg === "on" || arg === "all" || arg === "off") { state.notify = arg; r.state.saveState(); return "πŸ”” Notify: " + arg + "\n" + modes[arg] + ""; } if (arg === "status") { return "πŸ”” Notify: " + state.notify + "" + (state.sessionId ? " (active session " + shortId(state.sessionId) + ")" : ""); } return "πŸ”” Notify\n/notify on β€” this chat's session\n/notify all β€” everything\n/notify off β€” silence"; } async function cmdReboot(r) { const yes = r.args.includes("--yes") || r.args.includes("yes"); if (!yes) return "/reboot --yes β€” this restarts the whole harness process"; const launched = await launchRestart(r.state); if (!launched.ok) { r.state.log("restart failed to launch: " + launched.error); return "❌ restart failed to launch: " + esc(launched.error); } await r.state.bot.send(r.chatId, "πŸ”„ restarting harness (pid " + process.pid + ")… bot will re-appear in ~20s"); r.state.log("/reboot by " + r.userId); // Fallback: if the killer script somehow fails, exit anyway after a grace // period (the detached script relaunches the harness either way). setTimeout(() => { try { process.exit(0); } catch {} }, 15_000); return null; } /** * Write and spawn the detached restart script. Resolves with * { ok: true } only when the child process actually started; spawn errors * (missing shell, sandbox denial, …) resolve with { ok: false, error }. */ async function launchRestart(state) { try { writeRestartScript(state); const pwshPath = resolvePwsh(); const child = spawn(pwshPath, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", state.restartScriptPath], { detached: true, stdio: "ignore", windowsHide: true, }); const failed = await new Promise((resolve) => { child.once("error", (error) => resolve(error)); // The child outlives us (detached); after 2s treat it as started. setTimeout(() => resolve(null), 2_000); }); if (failed) return { ok: false, error: failed.message }; child.unref(); return { ok: true }; } catch (error) { return { ok: false, error: error.message }; } } async function cmdShutdown(r) { const yes = r.args.includes("--yes") || r.args.includes("yes"); if (!yes) return "/shutdown --yes β€” stops the whole harness process"; r.state.log("/shutdown by " + r.userId); await r.state.bot.send(r.chatId, "⏻ shutting down harness…"); setTimeout(() => { try { process.exit(0); } catch {} }, 2_000); return null; } /* ── reboot helpers ── */ function resolvePwsh() { const candidates = [ join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ]; for (const candidate of candidates) if (existsSync(candidate)) return candidate; return "powershell.exe"; } function resolveNpx() { const candidates = [ join(dirname(process.execPath), "npx.cmd"), join(process.env.APPDATA ?? "", "npm", "npx.cmd"), ]; for (const candidate of candidates) if (existsSync(candidate)) return candidate; return "npx"; } export function writeRestartScript(state) { // Relaunch from the SAME directory the harness was originally launched // from (process.cwd()), NOT the workspace root. The launch directory // decides which .env layers dsh loads, and a workspace .env may carry // reserved vars (e.g. DSH_MAX_TOKENS) that make boot crash when loaded. const cwd = process.cwd(); const npx = resolveNpx(); const log = join(dshHome(), "telegram-remote-restart.log"); const outLog = join(dshHome(), "dsh-web.out.log"); const errLog = join(dshHome(), "dsh-web.err.log"); const pid = process.pid; const ppid = process.ppid; // Detect the port THIS harness was launched with so /reboot stays on it // (default 3080). Never kill other dsh instances the user may be running. let port = 3080; const portIdx = process.argv.findIndex((a) => a === "--port" || a === "-p"); if (portIdx >= 0 && process.argv[portIdx + 1]) port = Number(process.argv[portIdx + 1]) || 3080; const script = [ '$log = ' + "'" + log + "'", 'Add-Content $log "restart: killing harness (pid ' + pid + ', ppid ' + ppid + ') on port ' + port + ' at $(Get-Date)"', 'Start-Sleep -Seconds 3', // Graceful first: SIGINT lets the harness checkpoint the session logs // cleanly (no mid-write corruption). Force-kill only as a fallback. 'Add-Content $log "sending graceful SIGINT to ' + pid + '"', "& 'C:\\nvm4w\\nodejs\\node.exe' -e \"process.kill(" + pid + ", 'SIGINT')\" 2>\$null", 'for ($i = 0; $i -lt 20; $i++) {', ' Start-Sleep -Seconds 1', ' if (-not (Get-Process -Id ' + pid + ' -ErrorAction SilentlyContinue)) { Add-Content $log ("exited gracefully after " + ($i + 1) + "s"); break }', '}', 'foreach ($p in @(' + pid + ', ' + ppid + ')) {', ' if (Get-Process -Id $p -ErrorAction SilentlyContinue) {', ' try { Stop-Process -Id $p -Force -ErrorAction Stop; Add-Content $log ("force-killed " + $p) } catch { Add-Content $log ("kill failed " + $p) }', ' }', '}', 'Start-Sleep -Seconds 2', 'Add-Content $log "relaunching npx=' + npx + " cwd=" + cwd + " port=" + port + ' at $(Get-Date)"', "Start-Process -FilePath '" + npx + "' -ArgumentList '@deepseek-ai/dsh','web','--port','" + port + "' -WorkingDirectory '" + cwd + "' -WindowStyle Hidden -RedirectStandardOutput '" + outLog + "' -RedirectStandardError '" + errLog + "'", "for ($i = 0; $i -lt 90; $i++) {", " Start-Sleep -Seconds 2", " try {", " $r = Invoke-WebRequest -Uri 'http://127.0.0.1:" + port + "' -UseBasicParsing -TimeoutSec 3", ' Add-Content $log ("UP status=" + $r.StatusCode + " at " + (Get-Date))', " exit 0", " } catch {}", "}", 'Add-Content $log ("FAILED: harness did not come up at " + (Get-Date))', "exit 1", ].join("\r\n"); writeFileSync(state.restartScriptPath, script, "utf8"); }