@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
543 lines (507 loc) • 27.3 kB
JavaScript
// Installed by Phren Hook and replaced on every update. Copy it under another name to customize.
import { request } from "node:http";
import { execFileSync } from "node:child_process";
import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
const PHREN_STORE = "__PHREN_STORE__";
const FLUSH_MS = 250;
const MAX_TOOL_OUTPUT = 200_000;
// OpenCode's read tool returns a picture as an inline data URL on the tool
// part (`state.attachments`). The Hook serves images up to 8 MiB; the session
// budget bounds what every flush rewrites.
const MAX_IMAGE_DATA = 4_000_000;
const MAX_SESSION_IMAGE_DATA = 24_000_000;
const IMAGE_DATA_URL = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=]+)$/;
const STOP_REASONS = { stop: "end_turn", "tool-calls": "tool_use", length: "max_tokens" };
const OPENCODE_SESSION = /^ses_[0-9A-Za-z]{1,64}$/;
const APPROVAL_POLL_MS = 200;
const APPROVAL_DEADLINE_MS = 50_000;
// An ask relayed from its event blocks nothing: the TUI keeps its own prompt,
// so the phone's card can wait as long as the ask does.
const RELAYED_DEADLINE_MS = 30 * 60_000;
function storeRoot() {
if (PHREN_STORE && !PHREN_STORE.startsWith("__")) return PHREN_STORE;
const configured = process.env.PHREN_PATH?.trim();
if (!configured) return path.join(homedir(), ".phren");
const expanded = configured === "~" ? homedir() : configured.startsWith("~/") ? path.join(homedir(), configured.slice(2)) : configured;
return path.resolve(expanded);
}
function text(value) {
return typeof value === "string" ? value : "";
}
function approvalDirectory() {
return path.join(storeRoot(), ".runtime", "approvals");
}
function fanoutDirectory() {
const configured = process.env.PHREN_FANOUT_DIR?.trim();
if (configured) return path.resolve(configured);
return path.join(storeRoot(), ".runtime", "agent-fanouts", text(process.env.PHREN_FANOUT_JOB));
}
function fanoutPatterns(input) {
const value = input?.pattern;
if (Array.isArray(value)) return value.filter(entry => typeof entry === "string" && entry);
return typeof value === "string" && value ? [value] : [];
}
/** The worktree's grandparent is the scratch root the fan-out launcher owns:
* external reads and writes are allowed only inside it. */
function underScratchRoot(value) {
const scratch = path.dirname(path.dirname(path.resolve(process.cwd())));
if (scratch === path.parse(scratch).root) return false;
const resolved = path.resolve(scratch, value);
return resolved === scratch || resolved.startsWith(scratch + path.sep);
}
function under(parent, value) {
if (!parent || parent === path.parse(parent).root) return false;
const root = path.resolve(parent), resolved = path.resolve(root, value);
return resolved === root || resolved.startsWith(root + path.sep);
}
/** Beyond the scratch root, a worker legitimately reads its own machine: the
* node_modules its worktree links into the main checkout, the store it was
* briefed from, the toolchains a build shells out to, and the temporary
* directories those builds write. Everything outside this set is refused. */
function fanoutReadable(value) {
if (underScratchRoot(value)) return true;
const home = process.env.HOME || "";
const roots = [home, storeRoot(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders",
"/Applications/Xcode.app", "/Library/Developer"];
return roots.some(root => under(root, value));
}
/** A headless fan-out worker may edit, run commands and fetch, and continue
* when OpenCode's own loop detector asks (the launcher's watchdog stops a real
* loop and says why, which is a clearer failure than a refused permission).
* Anything else is refused outright. */
function fanoutAllowed(input) {
const kind = text(input?.type);
if (kind === "edit" || kind === "bash" || kind === "webfetch" || kind === "doom_loop") return true;
if (kind !== "external_directory") return false;
const patterns = fanoutPatterns(input);
return patterns.length > 0 && patterns.every(fanoutReadable);
}
function writeBlocked(input) {
try {
const directory = fanoutDirectory();
mkdirSync(directory, { recursive: true });
writeJsonAtomic(path.join(directory, "blocked.json"), {
type: text(input?.type) || "action",
pattern: fanoutPatterns(input).join(", "),
message: permissionMessage(input),
at: new Date().toISOString(),
});
} catch {}
}
function approvalPaths(sessionID) {
const base = path.join(approvalDirectory(), `opencode-${sessionID}`);
return { request: `${base}.request.json`, answer: `${base}.answer.json` };
}
function writeJsonAtomic(file, value) {
const staging = `${file}.${process.pid}.tmp`;
writeFileSync(staging, JSON.stringify(value), { mode: 0o600 });
renameSync(staging, file);
}
function removeFile(file) {
try { unlinkSync(file); } catch {}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Started by Phren Hook with a port of its own (`opencode --port N`, with
* PHREN_OPENCODE_PORT=N and a server password in its environment): the Hook
* lists and answers this process's permission asks over its HTTP API, so no
* request file is written for them. The port must be on this process's own
* command line, so an OpenCode started by hand in the same shell keeps the
* file path. Plugins run in a worker whose `process.argv` is the worker's,
* so the command line comes from `ps`, once. */
let served;
function servedByHook() {
if (served !== undefined) return served;
const port = text(process.env.PHREN_OPENCODE_PORT);
served = false;
if (!/^\d{1,5}$/.test(port) || !text(process.env.OPENCODE_SERVER_PASSWORD)) return served;
try {
const command = execFileSync("ps", ["-ww", "-o", "command=", "-p", String(process.pid)], { encoding: "utf8", timeout: 2_000 });
served = new RegExp(`(?:^|\\s)--port(?:\\s+|=)${port}(?:\\s|$)`).test(command.trim());
} catch {}
return served;
}
function setStatus(output, status) {
if (output && typeof output === "object") output.status = status;
}
function permissionMessage(input) {
const type = text(input?.type) || "action";
const pattern = Array.isArray(input?.pattern)
? input.pattern.filter(value => typeof value === "string").join(", ")
: text(input?.pattern);
const metadata = input?.metadata && typeof input.metadata === "object" ? input.metadata : {};
const detail = [pattern, text(metadata.command), text(metadata.description), text(metadata.path), text(metadata.url)].find(value => value);
return (detail ? `${type}: ${detail}` : `opencode asks to use ${type}.`).slice(0, 2000);
}
function toolOutput(state) {
if (!state || typeof state !== "object") return "";
const output = state.output ?? state.metadata?.output ?? state.result ?? "";
const value = typeof output === "string" ? output : JSON.stringify(output ?? "");
return value.length > MAX_TOOL_OUTPUT ? value.slice(0, MAX_TOOL_OUTPUT) : value;
}
function blocksFor(message) {
const blocks = [];
for (const id of message.partOrder) {
const part = message.parts.get(id);
if (!part || typeof part !== "object") continue;
if (part.type === "text" && typeof part.text === "string" && part.text.length) {
blocks.push({ type: "text", text: part.text });
} else if (part.type === "tool" && typeof part.callID === "string") {
blocks.push({ type: "tool_use", id: part.callID, name: text(part.tool) || "tool", input: part.state?.input ?? {} });
}
}
return blocks;
}
/** A tool's picture attachments as Anthropic-shaped image blocks, the form the
* Hook's image route and the phone already read from Claude Code. Only inline
* data URLs are carried; an image over the caps becomes a text marker. */
function toolImages(state, budget) {
const blocks = [];
for (const attachment of Array.isArray(state?.attachments) ? state.attachments : []) {
if (attachment?.type !== "file" || typeof attachment.url !== "string") continue;
const match = IMAGE_DATA_URL.exec(attachment.url);
if (!match) continue;
const data = match[2];
if (data.length > MAX_IMAGE_DATA || data.length > budget.remaining) {
blocks.push({ type: "text", text: "[Image not included: too large to show on the phone]" });
continue;
}
budget.remaining -= data.length;
blocks.push({ type: "image", source: { type: "base64", media_type: match[1], data } });
}
return blocks;
}
function toolResults(message, budget) {
const results = [];
for (const id of message.partOrder) {
const part = message.parts.get(id);
if (!part || part.type !== "tool" || typeof part.callID !== "string") continue;
const status = part.state?.status;
if (status !== "completed" && status !== "error") continue;
const output = toolOutput(part.state), images = toolImages(part.state, budget);
const content = images.length ? [{ type: "text", text: output }, ...images] : output;
results.push({ type: "tool_result", tool_use_id: part.callID, content, is_error: status === "error" });
}
return results;
}
function linesFor(session) {
const messages = session.order
.map(id => session.messages.get(id))
.filter(Boolean)
.sort((a, b) => (a.info?.time?.created ?? 0) - (b.info?.time?.created ?? 0) || String(a.info?.id).localeCompare(String(b.info?.id)));
const lines = [];
const budget = { remaining: MAX_SESSION_IMAGE_DATA };
let seq = 0;
const emit = (type, data, created) => {
lines.push(JSON.stringify({ seq: seq++, time: new Date(created || Date.now()).toISOString(), type, data }));
};
for (const message of messages) {
const info = message.info ?? {};
if (info.role === "user") {
const blocks = blocksFor(message);
if (blocks.length) emit("user/message", { source: "user", message: { role: "user", content: blocks } }, info.time?.created);
} else if (info.role === "assistant") {
const complete = Boolean(info.time?.completed || info.finish);
const blocks = blocksFor(message).filter(block => complete || block.type !== "text");
const data = { stop_reason: STOP_REASONS[info.finish] || info.finish || "end_turn", message: { role: "assistant", content: blocks } };
if (info.tokens && (typeof info.tokens.input === "number" || typeof info.tokens.output === "number")) {
data.usage = { input_tokens: info.tokens.input ?? 0, output_tokens: info.tokens.output ?? 0 };
}
if (blocks.length) emit("assistant/message", data, info.time?.created);
const results = toolResults(message, budget);
if (results.length) emit("tool/results", { message: { role: "user", content: results } }, info.time?.updated ?? info.time?.created);
}
}
return lines;
}
/** Which conversation this OpenCode process is showing, by PID: the Hook
* reads it to bind a pane when Herdr reports no session for it. */
const pidBindingFile = () => path.join(storeRoot(), ".runtime", "sessions", `opencode-pid-${process.pid}.json`);
function writePidBinding(sessionID) {
mkdirSync(path.dirname(pidBindingFile()), { recursive: true });
writeJsonAtomic(pidBindingFile(), { session: sessionID, at: new Date().toISOString() });
}
/** What this OpenCode process is doing, by PID: "working", "idle" or
* "blocked" (a permission waits for an answer). The Hook reads it for a pane
* whose terminal does not watch its agents (tmux); Herdr has its own. */
const statusFile = () => path.join(storeRoot(), ".runtime", "sessions", `opencode-status-${process.pid}.json`);
/** Context reads never hold up a model request when the Hook is unavailable. */
function conductorContext(session) {
return new Promise(resolve => {
const socketPath = path.join(process.env.PHREN_BRIDGE_HOME || path.join(homedir(), ".local/share/phren/bridge"), "agent.sock");
const data = JSON.stringify({ pid: process.pid, session });
const req = request({ socketPath, path: "/conductor-context", method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } }, res => {
let body = "";
res.on("data", chunk => { body += chunk; if (body.length > 16_384) req.destroy(); });
res.on("end", () => {
try { const value = JSON.parse(body); finish(res.statusCode === 200 && typeof value.context === "string" ? value.context : undefined); }
catch { finish(); }
});
res.on("error", () => finish());
});
const timer = setTimeout(() => { req.destroy(); finish(); }, 1_000);
function finish(value) { clearTimeout(timer); resolve(value); }
req.on("error", () => finish()); req.end(data);
});
}
export const PhrenTranscriptPlugin = async input => {
// The process's own API, bound to its in-process server (no port needed).
const client = input?.client;
const sessions = new Map();
// Subagent sessions run inside the same process; the pane shows their parent.
const children = new Set();
let boundSession;
// A reused PID must not inherit an old process's conversation or state.
try { removeFile(pidBindingFile()); removeFile(statusFile()); } catch {}
let recordedStatus;
// Permission asks still open, by id: while any is, the process is blocked.
const asking = new Set();
// The session's last turn: when it went busy, and when it went idle after
// that. Dispatch returns read these as the turn's start and Stop.
let turn = {};
const recordStatus = (status, sessionID) => {
// A fan-out worker runs headless, in no pane.
if (process.env.PHREN_FANOUT_JOB) return;
const next = asking.size && status === "working" ? "blocked" : status;
if (next === recordedStatus) return;
try {
const at = new Date().toISOString();
if (sessionID && turn.session !== sessionID) turn = { session: sessionID };
if (next !== "idle" && (recordedStatus === undefined || recordedStatus === "idle")) { turn.busyAt = at; delete turn.idleAt; }
else if (next === "idle" && turn.busyAt) turn.idleAt = at;
mkdirSync(path.dirname(statusFile()), { recursive: true });
writeJsonAtomic(statusFile(), { status: next, ...(sessionID ? { session: sessionID } : {}), at,
...(turn.session === sessionID && turn.busyAt ? { busyAt: turn.busyAt } : {}), ...(turn.session === sessionID && turn.idleAt ? { idleAt: turn.idleAt } : {}) });
recordedStatus = next;
} catch {}
};
const permissionId = properties => text(properties?.permissionID) || text(properties?.requestID) || text(properties?.id);
const timers = new Map();
const written = new Map();
const pendingApprovals = new Set();
// Asks the permission.ask hook took (OpenCode before 1.18), and asks relayed
// from their event, by id; `done` once the terminal answered one.
const hookAsks = new Set();
const relayed = new Map();
/** OpenCode 1.18 never calls the permission.ask hook: its TUI draws the ask
* from the `permission.asked` event. For an OpenCode started by hand the ask
* goes to the phone through the same request file, and the phone's answer
* is replied through this process's own API. Whichever answers first wins;
* an answer in the terminal (`permission.replied`) withdraws the card. */
const relayAsk = async ask => {
const id = text(ask?.id), sessionID = text(ask?.sessionID);
if (process.env.PHREN_FANOUT_JOB || !id || hookAsks.has(id) || relayed.has(id) || !OPENCODE_SESSION.test(sessionID)
|| typeof client?.postSessionIdPermissionsPermissionId !== "function" || servedByHook() || pendingApprovals.has(sessionID)) return;
const shown = { type: text(ask.permission) || text(ask.type), pattern: Array.isArray(ask.patterns) ? ask.patterns : ask.pattern,
metadata: ask.metadata, title: ask.title };
const state = { done: false };
relayed.set(id, state); pendingApprovals.add(sessionID);
const { request, answer } = approvalPaths(sessionID);
try {
mkdirSync(approvalDirectory(), { recursive: true });
removeFile(answer);
const created = Date.now();
writeJsonAtomic(request, { id, sessionID, type: shown.type || "action",
title: text(shown.title) || `Allow ${shown.type || "action"}?`, message: permissionMessage(shown),
createdAt: new Date(created).toISOString(), expiresAt: new Date(created + RELAYED_DEADLINE_MS).toISOString() });
let decision;
while (!state.done && Date.now() < created + RELAYED_DEADLINE_MS) {
await sleep(APPROVAL_POLL_MS);
try {
const info = lstatSync(answer);
if (!info.isFile() || info.size > 65_536) continue;
const value = JSON.parse(readFileSync(answer, "utf8"));
if (value && value.id === id && (value.decision === "approve" || value.decision === "deny" || value.decision === "always")) { decision = value.decision; break; }
} catch {}
}
if (decision && !state.done) {
await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: id },
body: { response: decision === "approve" ? "once" : decision === "always" ? "always" : "reject" } });
}
} catch {} finally {
removeFile(answer); removeFile(request);
pendingApprovals.delete(sessionID); relayed.delete(id);
}
};
const sessionState = sessionID => {
let state = sessions.get(sessionID);
if (!state) { state = { messages: new Map(), order: [], idle: false }; sessions.set(sessionID, state); }
return state;
};
const messageState = (sessionID, messageID) => {
const state = sessionState(sessionID);
let message = state.messages.get(messageID);
if (!message) { message = { info: { id: messageID }, parts: new Map(), partOrder: [] }; state.messages.set(messageID, message); state.order.push(messageID); }
return message;
};
const flush = sessionID => {
const state = sessions.get(sessionID);
if (!state) return;
const body = linesFor(state);
const content = body.length ? body.join("\n") + "\n" : "";
const directory = path.join(storeRoot(), ".runtime", "sessions");
mkdirSync(directory, { recursive: true });
const file = path.join(directory, `opencode-${sessionID}.events.jsonl`);
const messages = state.order.map(id => state.messages.get(id));
const latest = messages.at(-1), info = latest?.info;
const user = messages.findLast(message => message.info?.role === "user");
const preview = !state.idle && info?.role === "assistant" && !info.time?.completed && !info.finish
? blocksFor(latest).filter(block => block.type === "text").map(block => block.text).join("\n").slice(0, 32_768) : "";
if (preview && user?.info.time?.created) writeJsonAtomic(file + ".preview.json", {
turnStartedAt: new Date(user.info.time.created).toISOString(), text: preview,
});
else removeFile(file + ".preview.json");
if (written.get(sessionID) === content) return;
const staging = `${file}.${process.pid}.tmp`;
writeFileSync(staging, content, { mode: 0o600 });
renameSync(staging, file);
written.set(sessionID, content);
};
const schedule = sessionID => {
if (!sessionID || timers.has(sessionID)) return;
timers.set(sessionID, setTimeout(() => {
timers.delete(sessionID);
try { flush(sessionID); } catch {}
}, FLUSH_MS));
};
const rememberInfo = (sessionID, info) => {
if (!info || typeof info.id !== "string") return;
const message = messageState(sessionID, info.id);
message.info = { ...message.info, ...info };
if (info.role === "user") sessionState(sessionID).idle = false;
schedule(sessionID);
};
const rememberPart = (sessionID, messageID, part) => {
if (!part || typeof part.id !== "string" || typeof messageID !== "string") return;
const message = messageState(sessionID, messageID);
if (!message.parts.has(part.id)) message.partOrder.push(part.id);
message.parts.set(part.id, part);
schedule(sessionID);
};
return {
"experimental.chat.system.transform": async (input, output) => {
const sessionID = text(input?.sessionID);
if (process.env.PHREN_FANOUT_JOB || !OPENCODE_SESSION.test(sessionID) || sessionID !== boundSession || children.has(sessionID) || !Array.isArray(output?.system)) return;
const context = await conductorContext(sessionID);
if (context) output.system.push(context);
},
"chat.message": async (input, output) => {
if (!OPENCODE_SESSION.test(text(input?.sessionID)) || !output?.message) return;
if (!process.env.PHREN_FANOUT_JOB && !children.has(input.sessionID) && boundSession !== input.sessionID) {
try { writePidBinding(input.sessionID); boundSession = input.sessionID; } catch {}
}
rememberInfo(input.sessionID, output.message);
for (const part of output.parts ?? []) rememberPart(input.sessionID, output.message.id, part);
if (!children.has(input.sessionID)) recordStatus("working", input.sessionID);
},
"permission.ask": async (input, output) => {
// A fan-out worker runs headless in its own worktree with nobody watching
// the phone's approval queue, so edits, commands and fetches inside that
// worktree are granted here and anything else is refused outright rather
// than waiting 50 seconds for an answer that never comes.
if (process.env.PHREN_FANOUT_JOB) {
if (fanoutAllowed(input)) { setStatus(output, "allow"); return; }
// A worker the launcher drives through `opencode serve` relays the
// ask to the phone and resumes on the owner's answer.
if (process.env.PHREN_FANOUT_APPROVALS === "1") { setStatus(output, "ask"); return; }
setStatus(output, "deny");
// A denied permission aborts the turn; record what was refused so the
// Hook can report the worker as blocked rather than finished.
writeBlocked(input);
return;
}
let request, answer, pendingSession, decision;
// The ask blocks the process until it is answered: here from the
// phone, or in the terminal (permission.replied below).
const askId = text(input?.id);
if (askId) { hookAsks.add(askId); asking.add(askId); recordStatus("blocked", text(input?.sessionID)); }
try {
const sessionID = text(input?.sessionID), id = text(input?.id);
if (!OPENCODE_SESSION.test(sessionID) || !id || servedByHook()) { setStatus(output, "ask"); return; }
// A second request for this session belongs in the terminal until
// the existing phone request has finished.
if (pendingApprovals.has(sessionID)) { setStatus(output, "ask"); return; }
pendingApprovals.add(sessionID);
pendingSession = sessionID;
const paths = approvalPaths(sessionID);
request = paths.request; answer = paths.answer;
mkdirSync(approvalDirectory(), { recursive: true });
removeFile(answer);
const created = Date.now();
writeJsonAtomic(request, { id, sessionID, type: text(input.type) || "action",
title: text(input.title) || `Allow ${text(input.type) || "action"}?`, message: permissionMessage(input),
createdAt: new Date(created).toISOString(), expiresAt: new Date(created + APPROVAL_DEADLINE_MS).toISOString() });
const deadline = created + APPROVAL_DEADLINE_MS;
while (Date.now() < deadline) {
await sleep(APPROVAL_POLL_MS);
try {
const info = lstatSync(answer);
if (!info.isFile() || info.size > 65_536) continue;
const value = JSON.parse(readFileSync(answer, "utf8"));
if (value && value.id === id) { decision = value.decision; break; }
} catch {}
}
setStatus(output, decision === "approve" || decision === "always" ? "allow" : decision === "deny" ? "deny" : "ask");
} catch {
setStatus(output, "ask");
} finally {
if (answer) removeFile(answer);
if (request) removeFile(request);
if (pendingSession) pendingApprovals.delete(pendingSession);
// Answered from the phone: back to work. Otherwise the terminal asks.
if (askId && (decision === "approve" || decision === "deny" || decision === "always")) { asking.delete(askId); recordStatus("working", pendingSession); }
if (askId) hookAsks.delete(askId);
}
},
event: async ({ event }) => {
const properties = event?.properties ?? {};
if ((event?.type === "session.created" || event?.type === "session.updated") && properties.info?.parentID
&& OPENCODE_SESSION.test(text(properties.info.id))) children.add(properties.info.id);
const sessionID = typeof properties.sessionID === "string" ? properties.sessionID : properties.info?.sessionID ?? properties.part?.sessionID;
// Permission events name the ask (permission.updated / permission.asked)
// and its answer, which may come from the terminal.
if (event?.type === "permission.updated" || event?.type === "permission.asked") {
const id = permissionId(properties);
if (id) { asking.add(id); recordStatus("blocked", text(sessionID)); }
if (event.type === "permission.asked") void relayAsk(properties).catch(() => {});
} else if (event?.type === "permission.replied") {
const id = permissionId(properties);
if (id) asking.delete(id); else asking.clear();
const relay = id && relayed.get(id);
if (relay) relay.done = true;
recordStatus("working", text(sessionID));
}
if (!OPENCODE_SESSION.test(text(sessionID))) return;
if (event.type === "message.updated") rememberInfo(sessionID, properties.info);
else if (event.type === "message.part.updated") rememberPart(sessionID, properties.part?.messageID, properties.part);
else if (event.type === "message.part.delta" && properties.field === "text" && typeof properties.delta === "string") {
const message = messageState(sessionID, properties.messageID);
const part = message.parts.get(properties.partID);
if (part?.type === "text") rememberPart(sessionID, properties.messageID, { ...part, text: text(part.text) + properties.delta });
}
else if (event.type === "message.removed") {
const state = sessions.get(sessionID);
if (state && typeof properties.messageID === "string") {
state.messages.delete(properties.messageID);
state.order = state.order.filter(id => id !== properties.messageID);
schedule(sessionID);
}
} else if (event.type === "session.idle" || event.type === "session.error") {
sessionState(sessionID).idle = true;
flush(sessionID);
if (!children.has(sessionID)) { asking.clear(); recordStatus("idle", sessionID); }
} else if (event.type === "session.status") {
if (properties.status?.type === "busy") sessionState(sessionID).idle = false;
// A subagent's session runs inside its parent's turn.
if (!children.has(sessionID) && ["busy", "retry", "idle"].includes(properties.status?.type)) {
if (properties.status.type === "idle") asking.clear();
recordStatus(properties.status.type === "idle" ? "idle" : "working", sessionID);
}
}
},
};
};