返回 CodeWhale
lib.mjs
1 import { chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises";
2 import path from "node:path";
3 import { randomBytes } from "node:crypto";
4
5 const DEFAULT_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
6
7 function normalizeCursorValue(value, fallback = 0) {
8 const number = Number(value);
9 if (Number.isFinite(number) && number >= 0) return Math.floor(number);
10 const fallbackNumber = Number(fallback);
11 if (Number.isFinite(fallbackNumber) && fallbackNumber >= 0) return Math.floor(fallbackNumber);
12 return 0;
13 }
14
15 async function chmodBestEffort(filePath, mode) {
16 try {
17 await chmod(filePath, mode);
18 } catch (error) {
19 if (process.platform !== "win32") throw error;
20 }
21 }
22
23 /**
24 * Replace `filePath` so a crash leaves either the old bytes or the new ones:
25 * a unique temporary name (two processes sharing a state dir never write the
26 * same temp file), fsync before the rename, and a best-effort directory fsync
27 * after it so the rename itself survives power loss where the OS allows.
28 */
29 export async function writeFileDurable(filePath, contents, { mode = 0o600 } = {}) {
30 const dir = path.dirname(filePath);
31 const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
32 let handle = null;
33 try {
34 handle = await open(tmp, "wx", mode);
35 await handle.writeFile(contents);
36 await handle.sync();
37 await handle.close();
38 handle = null;
39 await rename(tmp, filePath);
40 } catch (error) {
41 await handle?.close().catch(() => {});
42 await rm(tmp, { force: true }).catch(() => {});
43 throw error;
44 }
45 let dirHandle = null;
46 try {
47 dirHandle = await open(dir, "r");
48 await dirHandle.sync();
49 } catch {
50 // Windows and some filesystems cannot fsync a directory.
51 } finally {
52 await dirHandle?.close().catch(() => {});
53 }
54 }
55
56 export class ThreadStore {
57 static async open(filePath, options = {}) {
58 const store = new ThreadStore(filePath, options);
59 await store.load();
60 return store;
61 }
62
63 constructor(filePath, options = {}) {
64 this.filePath = filePath;
65 this.options = {
66 messageLimit: options.messageLimit || 0,
67 actions: options.actions === true,
68 actionLimit: options.actionLimit || 200,
69 actionTtlMs: options.actionTtlMs || DEFAULT_ACTION_TTL_MS,
70 privateMode: options.privateMode === true
71 };
72 this.data = { chats: {} };
73 this.saveDirty = false;
74 this.savePending = null;
75 this.ensureShape();
76 }
77
78 ensureShape() {
79 if (!this.data || typeof this.data !== "object") this.data = {};
80 if (!this.data.chats || typeof this.data.chats !== "object") this.data.chats = {};
81 if (this.options.messageLimit > 0 && !Array.isArray(this.data.messages)) {
82 this.data.messages = [];
83 }
84 if (this.options.messageLimit > 0 && (!this.data.inflight || typeof this.data.inflight !== "object" || Array.isArray(this.data.inflight))) {
85 this.data.inflight = {};
86 }
87 if (this.options.actions && (!this.data.actions || typeof this.data.actions !== "object")) {
88 this.data.actions = {};
89 }
90 if (this.data.cursors && typeof this.data.cursors !== "object") {
91 this.data.cursors = {};
92 }
93 }
94
95 async load() {
96 try {
97 const raw = await readFile(this.filePath, "utf8");
98 this.data = JSON.parse(raw);
99 this.ensureShape();
100 } catch (error) {
101 if (error.code !== "ENOENT") throw error;
102 }
103 }
104
105 /**
106 * Claim one inbound message before acting on it. "new": handle it now (it
107 * is recorded as in flight first, durably). "done": a finished duplicate.
108 * "interrupted": an earlier process claimed it and stopped before
109 * completeMessage, so its effect is unknown; the caller reports that
110 * instead of running it again. Callers handle messages sequentially — a
111 * key still in flight in this process is not a restart.
112 */
113 async claimMessage(messageKey) {
114 if (!messageKey || this.options.messageLimit <= 0) return "new";
115 this.ensureShape();
116 const inflight = this.data.inflight;
117 if (this.data.messages.includes(messageKey)) {
118 if (!Object.hasOwn(inflight, messageKey)) return "done";
119 delete inflight[messageKey];
120 await this.save();
121 return "interrupted";
122 }
123 this.data.messages.push(messageKey);
124 this.data.messages = this.data.messages.slice(-this.options.messageLimit);
125 for (const key of Object.keys(inflight)) if (!this.data.messages.includes(key)) delete inflight[key];
126 inflight[messageKey] = new Date().toISOString();
127 await this.save();
128 return "new";
129 }
130
131 /** Mark a claimed message handled (whether it succeeded or failed in-process). */
132 async completeMessage(messageKey) {
133 if (!messageKey || !this.data.inflight || !Object.hasOwn(this.data.inflight, messageKey)) return;
134 delete this.data.inflight[messageKey];
135 await this.save();
136 }
137
138 async recordMessage(messageKey) {
139 if (!messageKey || this.options.messageLimit <= 0) return false;
140 this.ensureShape();
141 if (this.data.messages.includes(messageKey)) return true;
142 this.data.messages.push(messageKey);
143 this.data.messages = this.data.messages.slice(-this.options.messageLimit);
144 await this.save();
145 return false;
146 }
147
148 getCursor(name, fallback = 0) {
149 if (!name) return normalizeCursorValue(fallback);
150 this.ensureShape();
151 return normalizeCursorValue(this.data.cursors?.[name], fallback);
152 }
153
154 async setCursor(name, value) {
155 if (!name) return normalizeCursorValue(value);
156 this.ensureShape();
157 if (!this.data.cursors || typeof this.data.cursors !== "object") {
158 this.data.cursors = {};
159 }
160 const cursor = normalizeCursorValue(value);
161 if (this.data.cursors[name] === cursor) return cursor;
162 this.data.cursors[name] = cursor;
163 await this.save();
164 return cursor;
165 }
166
167 async getChat(chatId) {
168 return this.data.chats[chatId] || null;
169 }
170
171 listChats() {
172 return Object.entries(this.data.chats || {});
173 }
174
175 async setChat(chatId, state) {
176 this.data.chats[chatId] = state;
177 await this.save();
178 return state;
179 }
180
181 async patchChat(chatId, patch) {
182 const current = this.data.chats[chatId] || {};
183 this.data.chats[chatId] = { ...current, ...patch };
184 await this.save();
185 return this.data.chats[chatId];
186 }
187
188 /**
189 * Record only a turn accepted by Runtime, before its events are delivered.
190 * A later sender cannot overwrite that turn's origin. Records use the chat's
191 * existing lifecycle and action retention bound; retired records fail closed.
192 * This does not authenticate the platform SDK or pin replacement credentials.
193 */
194 async recordTurnOrigin(chatId, threadId, turnId, actorId) {
195 const current = this.data.chats[chatId];
196 if (!current || current.threadId !== threadId || !turnId || !actorId) {
197 throw new ApprovalOwnershipError("accepted turn needs its current chat, thread and initiating human");
198 }
199 const origins = Array.isArray(current.turnOrigins) ? current.turnOrigins : [];
200 const existing = origins.find((origin) => origin.threadId === threadId && origin.turnId === turnId);
201 if (existing) {
202 if (existing.actorId !== actorId) throw new ApprovalOwnershipError("turn origin cannot change");
203 return existing;
204 }
205 const origin = { threadId, turnId, actorId };
206 // patchChat mutates synchronously before saving, preserving concurrent turns.
207 await this.patchChat(chatId, {
208 turnOrigins: [...origins.filter((entry) => entry.threadId === threadId), origin].slice(-this.options.actionLimit)
209 });
210 return origin;
211 }
212
213 turnOrigin(chatId, threadId, turnId) {
214 const state = this.data.chats[chatId];
215 if (!state || state.threadId !== threadId || !turnId) return null;
216 return (Array.isArray(state.turnOrigins) ? state.turnOrigins : [])
217 .find((origin) => origin.threadId === threadId && origin.turnId === turnId && origin.actorId) || null;
218 }
219
220 async putAction(action, owner) {
221 if (!this.options.actions) return "";
222 this.ensureShape();
223 const binding = actionOwner(owner);
224 if (!binding.chatId) throw new ApprovalOwnershipError("button action needs its receiving chat");
225 if (action.kind === "approval" &&
226 (!binding.threadId || !binding.turnId || !binding.actorId ||
227 this.turnOrigin(binding.chatId, binding.threadId, binding.turnId)?.actorId !== binding.actorId)) {
228 throw new ApprovalOwnershipError("approval button needs the accepted turn and initiating human");
229 }
230 const token = randomBytes(16).toString("hex");
231 this.data.actions[token] = {
232 ...action,
233 owner: binding,
234 createdAt: new Date().toISOString()
235 };
236 this.pruneActions();
237 await this.save();
238 return token;
239 }
240
241 async getAction(token, owner = null) {
242 if (!token || !this.options.actions) return null;
243 this.ensureShape();
244 this.pruneActions();
245 const action = this.data.actions[token] || null;
246 if (!action) return null;
247 // Legacy unbound buttons cannot prove which chat received authority.
248 if (!action.owner || !sameOwner(action.owner, owner)) return null;
249 if (action.kind === "approval" &&
250 (!action.owner.threadId || !action.owner.turnId || !action.owner.actorId ||
251 this.turnOrigin(action.owner.chatId, action.owner.threadId, action.owner.turnId)?.actorId !== action.owner.actorId)) return null;
252 return action;
253 }
254
255 async takeAction(token, owner = null) {
256 const action = await this.getAction(token, owner);
257 if (action) {
258 delete this.data.actions[token];
259 await this.save();
260 }
261 return action;
262 }
263
264 pruneActions() {
265 if (!this.options.actions) return;
266 const cutoff = Date.now() - this.options.actionTtlMs;
267 const fresh = Object.entries(this.data.actions || {}).filter(([, action]) => {
268 const time = Date.parse(action.createdAt || "");
269 return Number.isFinite(time) && time >= cutoff;
270 });
271 this.data.actions = Object.fromEntries(fresh.slice(-this.options.actionLimit));
272 }
273
274 async save() {
275 // Batch bursts of small updates: saves issued while a write is in flight
276 // coalesce into a single follow-up write. The returned promise resolves
277 // only after this mutation is durable on disk (temp file + rename).
278 this.saveDirty = true;
279 if (!this.savePending) {
280 this.savePending = this.flushSaves();
281 }
282 return this.savePending;
283 }
284
285 async flushSaves() {
286 try {
287 while (this.saveDirty) {
288 this.saveDirty = false;
289 await this.writeSnapshot();
290 }
291 } finally {
292 this.savePending = null;
293 }
294 }
295
296 async writeSnapshot() {
297 const dir = path.dirname(this.filePath);
298 await mkdir(dir, { recursive: true, mode: 0o700 });
299 if (this.options.privateMode) await chmodBestEffort(dir, 0o700);
300 await writeFileDurable(this.filePath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
301 if (this.options.privateMode) await chmodBestEffort(this.filePath, 0o600);
302 }
303 }
304
305 function actionOwner(owner) {
306 return {
307 chatId: String(owner?.chatId ?? ""),
308 ...(owner?.threadId ? { threadId: String(owner.threadId) } : {}),
309 ...(owner?.turnId ? { turnId: String(owner.turnId) } : {}),
310 ...(owner?.actorId ? { actorId: String(owner.actorId) } : {})
311 };
312 }
313
314 function sameOwner(stored, owner) {
315 if (!owner) return false;
316 const wanted = actionOwner(owner);
317 if (!stored.chatId || stored.chatId !== wanted.chatId) return false;
318 return (!stored.threadId || stored.threadId === wanted.threadId) &&
319 (!stored.actorId || stored.actorId === wanted.actorId) &&
320 (!stored.turnId || !wanted.turnId || stored.turnId === wanted.turnId);
321 }
322
323 export class ApprovalOwnershipError extends Error {
324 constructor(message) {
325 super(message);
326 this.name = "ApprovalOwnershipError";
327 }
328 }
329
330 /**
331 * Runtime owns pending approvals and their turn ids. The existing chat store
332 * owns the immutable human who initiated that accepted turn. Admission to a
333 * group, a newer sender or a legacy unbound button does not grant this authority.
334 */
335 export async function decideApproval(runtimeJson, { store, chatId, actorId, approvalId, decision, remember = false, turnId = null }) {
336 if (decision !== "allow" && decision !== "deny") throw new ApprovalOwnershipError("decision must be allow or deny");
337 const state = await store.getChat(chatId);
338 const threadId = state?.threadId;
339 if (!threadId) throw new ApprovalOwnershipError("this chat has no Runtime thread yet");
340 if (!actorId) throw new ApprovalOwnershipError("missing initiating human");
341 if (!approvalId) throw new ApprovalOwnershipError("missing approval id");
342 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
343 const pending = detail?.pending_approvals ?? detail?.thread?.pending_approvals ?? [];
344 const approval = pending.find((entry) => (entry?.id ?? entry?.approval_id) === approvalId);
345 const pendingTurnId = approval?.turn_id;
346 if (!pendingTurnId || (turnId && pendingTurnId !== turnId) ||
347 store.turnOrigin(chatId, threadId, pendingTurnId)?.actorId !== actorId) {
348 throw new ApprovalOwnershipError("approval needs its accepted turn's initiating human in this chat");
349 }
350 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
351 method: "POST",
352 body: { decision, remember: remember === true }
353 });
354 }
355
356 /**
357 * Settle a stored approval button: look it up for this owner, deliver the
358 * decision, and consume the token only once the Runtime accepted it. A failed
359 * POST leaves the button usable.
360 */
361 export async function settleStoredApproval(store, token, owner, deliver) {
362 const stored = await store.getAction(token, owner);
363 if (!stored || stored.kind !== "approval") return null;
364 await deliver(stored);
365 await store.takeAction(token, owner);
366 return stored;
367 }
368
369 export function envFirst(env, ...names) {
370 for (const name of names) {
371 const value = env?.[name];
372 if (value != null && String(value).trim()) return String(value).trim();
373 }
374 return "";
375 }
376
377 export function parseList(raw) {
378 return String(raw || "")
379 .split(",")
380 .map((item) => item.trim())
381 .filter(Boolean);
382 }
383
384 export function parseBool(raw, fallback = false) {
385 if (raw == null || raw === "") return fallback;
386 return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase());
387 }
388
389 export function parseEnvText(raw) {
390 const env = {};
391 for (const line of String(raw || "").split(/\r?\n/)) {
392 const trimmed = line.trim();
393 if (!trimmed || trimmed.startsWith("#")) continue;
394 const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
395 const index = normalized.indexOf("=");
396 if (index <= 0) continue;
397 const key = normalized.slice(0, index).trim();
398 let value = normalized.slice(index + 1).trim();
399 if (
400 value.length >= 2 &&
401 ((value.startsWith('"') && value.endsWith('"')) ||
402 (value.startsWith("'") && value.endsWith("'")))
403 ) {
404 value = value.slice(1, -1);
405 }
406 env[key] = value;
407 }
408 return env;
409 }
410
411 export function cleanEnvValue(value) {
412 return String(value ?? "").trim();
413 }
414
415 export function isPlaceholderValue(value) {
416 const normalized = cleanEnvValue(value).toLowerCase();
417 return (
418 !normalized ||
419 normalized.includes("replace-with") ||
420 normalized.includes("xxxxxxxx") ||
421 normalized === "changeme"
422 );
423 }
424
425 export function parseTextContent(content, keys = ["text", "content"]) {
426 if (typeof content !== "string") return "";
427 try {
428 const parsed = JSON.parse(content);
429 for (const key of keys) {
430 if (typeof parsed?.[key] === "string") return parsed[key];
431 }
432 } catch {
433 return content;
434 }
435 return content;
436 }
437
438 export function stripGroupPrefix(text, { chatType, requirePrefix, prefix, directChatTypes = [] }) {
439 const trimmed = String(text || "").trim();
440 if (!trimmed) return { accepted: false, text: "" };
441 if (!requirePrefix || directChatTypes.includes(chatType)) {
442 return { accepted: true, text: trimmed };
443 }
444 const marker = prefix || "/ds";
445 if (trimmed === marker) return { accepted: true, text: "/help" };
446 if (trimmed.startsWith(`${marker} `)) {
447 return { accepted: true, text: trimmed.slice(marker.length).trim() };
448 }
449 return { accepted: false, text: "" };
450 }
451
452 export function parseCommand(text, options = {}) {
453 const trimmed = String(text || "").trim();
454 if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed };
455 const [head, ...rest] = trimmed.split(/\s+/);
456 const rawName = head.slice(1);
457 const name = (options.stripBotMention ? rawName.split("@")[0] : rawName).toLowerCase();
458 return {
459 name,
460 args: rest.join(" ").trim()
461 };
462 }
463
464 export function parseApprovalDecisionArgs(args) {
465 const parts = String(args || "")
466 .split(/\s+/)
467 .filter(Boolean);
468 return {
469 approvalId: parts[0] || "",
470 remember: parts.slice(1).includes("remember")
471 };
472 }
473
474 export function commandAction(command, options = {}) {
475 const allowMenu = options.allowMenu === true;
476 const allowStart = options.allowStart === true;
477 switch (command.name) {
478 case "start":
479 if (allowStart) return { kind: "help" };
480 break;
481 case "help":
482 return { kind: "help" };
483 case "menu":
484 if (allowMenu) return { kind: "menu" };
485 break;
486 case "status":
487 return { kind: "status" };
488 case "threads":
489 return { kind: "threads" };
490 case "new":
491 return { kind: "new_thread" };
492 case "resume":
493 return { kind: "resume", threadId: command.args };
494 case "interrupt":
495 return { kind: "interrupt" };
496 case "compact":
497 return { kind: "compact" };
498 case "model":
499 return { kind: "set_model", modelName: command.args };
500 case "allow":
501 return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) };
502 case "deny":
503 return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) };
504 case "prompt":
505 return { kind: "prompt", prompt: command.args };
506 default:
507 break;
508 }
509 return {
510 kind: "prompt",
511 prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}`
512 };
513 }
514
515 export function preservedChatStateFields(state = {}, fields = ["model", "authorizedIdentity"]) {
516 const preserved = {};
517 for (const field of fields) {
518 if (Object.prototype.hasOwnProperty.call(state || {}, field)) {
519 preserved[field] = state[field] || null;
520 }
521 }
522 return preserved;
523 }
524
525 export function splitMessage(text, maxChars = 3500) {
526 const value = String(text || "");
527 const limit = Math.max(1, Math.floor(Number(maxChars) || 3500));
528 // Materialize code points once; all chunking below works on index ranges
529 // instead of re-running Array.from over the shrinking remainder.
530 const chars = Array.from(value);
531 if (chars.length <= limit) return value ? [value] : [];
532 const chunks = [];
533 let offset = 0;
534 let openFence = null;
535 while (offset < chars.length) {
536 const next = takeRenderedSplitMessageChunk(chars, offset, limit, openFence);
537 chunks.push(next.chunk);
538 offset = next.offset;
539 openFence = next.openFence;
540 }
541 return chunks;
542 }
543
544 function takeRenderedSplitMessageChunk(chars, offset, maxChars, openFence) {
545 const prefix = openFence !== null ? `\`\`\`${openFence}\n` : "";
546 const prefixLength = charLength(prefix);
547 let payloadLimit = Math.max(1, maxChars - prefixLength);
548
549 while (true) {
550 const splitAt = splitMessageChunkEnd(chars, offset, payloadLimit);
551 const payload = chars.slice(offset, splitAt).join("");
552 const body = `${prefix}${payload}`;
553 const nextOpenFence = updateCodeFenceState(openFence, payload);
554 const suffix =
555 nextOpenFence !== null && splitAt < chars.length ? (body.endsWith("\n") ? "```" : "\n```") : "";
556 // prefix/suffix are ASCII fence markup, so string length == code points.
557 const overflow = prefixLength + (splitAt - offset) + suffix.length - maxChars;
558 if (overflow <= 0 || payloadLimit === 1) {
559 return { chunk: `${body}${suffix}`, offset: splitAt, openFence: nextOpenFence };
560 }
561 payloadLimit = Math.max(1, payloadLimit - overflow);
562 }
563 }
564
565 function splitMessageChunkEnd(chars, offset, maxChars) {
566 if (chars.length - offset <= maxChars) return chars.length;
567 return offset + preferredSplitIndex(chars, offset, maxChars);
568 }
569
570 function preferredSplitIndex(chars, offset, maxChars) {
571 const limit = Math.min(chars.length - offset, maxChars);
572 for (let i = limit - 1; i > 0; i -= 1) {
573 if (chars[offset + i] === "\n") return i + 1;
574 }
575 for (let i = limit - 1; i > 0; i -= 1) {
576 if (/\s/u.test(chars[offset + i])) return i + 1;
577 }
578 return limit;
579 }
580
581 function charLength(text) {
582 let length = 0;
583 for (const _ of text) length += 1;
584 return length;
585 }
586
587 function updateCodeFenceState(openFence, text) {
588 let current = openFence;
589 for (const match of text.matchAll(/^```([^\n`]*)\s*$/gm)) {
590 if (current === null) {
591 current = match[1]?.trim() || "";
592 } else {
593 current = null;
594 }
595 }
596 return current;
597 }
598
599 export async function readJsonSafe(response) {
600 const text = await response.text();
601 if (!text) return {};
602 try {
603 return JSON.parse(text);
604 } catch {
605 return text;
606 }
607 }
608
609 export async function* readSse(response) {
610 const decoder = new TextDecoder();
611 let buffer = "";
612 for await (const chunk of response.body) {
613 buffer += decoder.decode(chunk, { stream: true });
614 let boundary;
615 while ((boundary = buffer.indexOf("\n\n")) >= 0) {
616 const raw = buffer.slice(0, boundary).replace(/\r/g, "");
617 buffer = buffer.slice(boundary + 2);
618 const event = { event: "", data: "" };
619 for (const line of raw.split("\n")) {
620 if (line.startsWith("event:")) event.event = line.slice(6).trim();
621 if (line.startsWith("data:")) event.data += line.slice(5).trim();
622 }
623 yield event;
624 }
625 }
626 }
627
628 export function createRuntimeClient({ runtimeUrl, runtimeToken }) {
629 function authHeaders() {
630 return { authorization: `Bearer ${runtimeToken}` };
631 }
632
633 async function runtimeJson(route, options = {}) {
634 const response = await fetch(`${runtimeUrl}${route}`, {
635 method: options.method || "GET",
636 headers: {
637 ...(options.auth === false ? {} : authHeaders()),
638 ...(options.body ? { "content-type": "application/json" } : {})
639 },
640 body: options.body ? JSON.stringify(options.body) : undefined
641 });
642 const body = await readJsonSafe(response);
643 if (!response.ok) {
644 throw new Error(compactRuntimeError(response.status, body));
645 }
646 return body;
647 }
648
649 return { runtimeJson, authHeaders };
650 }
651
652 export function compactRuntimeError(status, body) {
653 const message =
654 body?.error?.message ||
655 body?.message ||
656 (typeof body === "string" ? body : JSON.stringify(body));
657 return `Runtime API request failed (${status}): ${message}`;
658 }
659
660 export function latestRunningTurn(detail) {
661 const turns = Array.isArray(detail?.turns) ? detail.turns : [];
662 for (let index = turns.length - 1; index >= 0; index -= 1) {
663 const turn = turns[index];
664 if (["queued", "in_progress"].includes(turn?.status)) return turn;
665 }
666 return null;
667 }
668
669 export function activeTurnBlock(detail, state = {}) {
670 const runningTurn = latestRunningTurn(detail);
671 if (!runningTurn) return null;
672 const activeTurnId = state?.activeTurnId || "";
673 return {
674 turnId: runningTurn.id || activeTurnId,
675 message: `Thread already has active turn ${
676 runningTurn.id || activeTurnId || "(unknown)"
677 }. Wait for it to finish or send /interrupt.`
678 };
679 }
680
680 lines Plain Text