aibroker 0.61.3 → 0.61.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/pailot/gateway.js +4 -4
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/daemon/dispatch.d.ts +3 -3
- package/dist/daemon/dispatch.js +5 -5
- package/dist/daemon/dispatch.js.map +1 -1
- package/dist/daemon/manage.d.ts +40 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +149 -99
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/pai-projects.d.ts.map +1 -1
- package/dist/daemon/pai-projects.js +29 -16
- package/dist/daemon/pai-projects.js.map +1 -1
- package/dist/daemon/setup.d.ts +14 -2
- package/dist/daemon/setup.d.ts.map +1 -1
- package/dist/daemon/setup.js +48 -7
- package/dist/daemon/setup.js.map +1 -1
- package/dist/mcp/index.js +3 -11
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/ota-args.d.ts +10 -0
- package/dist/mcp/ota-args.d.ts.map +1 -0
- package/dist/mcp/ota-args.js +15 -0
- package/dist/mcp/ota-args.js.map +1 -0
- package/dist/transport/iterm.js +1 -1
- package/dist/transport/iterm.js.map +1 -1
- package/dist/transport/screen.d.ts +2 -2
- package/dist/transport/screen.js +2 -2
- package/dist/transport/session-transport.d.ts +1 -1
- package/dist/transport/session-transport.d.ts.map +1 -1
- package/dist/transport/session-transport.js +1 -1
- package/dist/transport/session-transport.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/manage.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* carries one-shot instructions from the operator into the next arming, and it
|
|
20
20
|
* says what it did. Everything requiring judgement stays with the person.
|
|
21
21
|
*/
|
|
22
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, unlinkSync, chmodSync } from "node:fs";
|
|
23
23
|
import { execFileSync } from "node:child_process";
|
|
24
24
|
import { join, dirname } from "node:path";
|
|
25
25
|
import { homedir } from "node:os";
|
|
@@ -1304,27 +1304,50 @@ export function defaultHandoverTarget(cwd, notesDirExists) {
|
|
|
1304
1304
|
return null;
|
|
1305
1305
|
return join(cwd, "Notes", "TODO.md");
|
|
1306
1306
|
}
|
|
1307
|
+
/**
|
|
1308
|
+
* The live transcript's tail. File mtime is not evidence of life: a stale
|
|
1309
|
+
* transcript keeps getting non-message records appended. Among the five most
|
|
1310
|
+
* recently modified files, the live one is the one whose last user/assistant
|
|
1311
|
+
* record is newest; files with no such record in the tail rank last.
|
|
1312
|
+
* Only the tail is read — these files reach tens of megabytes.
|
|
1313
|
+
*/
|
|
1314
|
+
export function liveTranscriptTail(dir) {
|
|
1315
|
+
let best = null;
|
|
1316
|
+
const files = readdirSync(dir)
|
|
1317
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
1318
|
+
.map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
|
|
1319
|
+
.sort((a, b) => b.m - a.m)
|
|
1320
|
+
.slice(0, 5);
|
|
1321
|
+
for (const { f } of files) {
|
|
1322
|
+
const raw = execFileSync("/usr/bin/tail", ["-n", "200", join(dir, f)], {
|
|
1323
|
+
encoding: "utf8",
|
|
1324
|
+
timeout: 4_000,
|
|
1325
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1326
|
+
});
|
|
1327
|
+
let at = -1;
|
|
1328
|
+
for (const line of raw.split("\n")) {
|
|
1329
|
+
try {
|
|
1330
|
+
const j = JSON.parse(line);
|
|
1331
|
+
if ((j.type === "assistant" || j.type === "user") && j.timestamp)
|
|
1332
|
+
at = Date.parse(j.timestamp);
|
|
1333
|
+
}
|
|
1334
|
+
catch { /* blank or truncated line */ }
|
|
1335
|
+
}
|
|
1336
|
+
if (!best || at > best.at)
|
|
1337
|
+
best = { file: f, raw, at };
|
|
1338
|
+
}
|
|
1339
|
+
return best;
|
|
1340
|
+
}
|
|
1307
1341
|
function transcriptReading(claudePid) {
|
|
1308
1342
|
const none = { working: null, doing: null, contextK: null, lastAt: null };
|
|
1309
1343
|
try {
|
|
1310
1344
|
const dir = projectTranscriptDir(claudePid);
|
|
1311
1345
|
if (!dir)
|
|
1312
1346
|
return none;
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
// rather than being silently mistaken for a fresh one.
|
|
1316
|
-
const newest = readdirSync(dir)
|
|
1317
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
1318
|
-
.map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs }))
|
|
1319
|
-
.sort((a, b) => b.m - a.m)[0];
|
|
1320
|
-
if (!newest)
|
|
1347
|
+
const picked = liveTranscriptTail(dir);
|
|
1348
|
+
if (!picked)
|
|
1321
1349
|
return none;
|
|
1322
|
-
|
|
1323
|
-
const raw = execFileSync("/usr/bin/tail", ["-n", "40", join(dir, newest.f)], {
|
|
1324
|
-
encoding: "utf8",
|
|
1325
|
-
timeout: 4_000,
|
|
1326
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
1327
|
-
});
|
|
1350
|
+
const raw = picked.raw;
|
|
1328
1351
|
const msgs = [];
|
|
1329
1352
|
for (const line of raw.split("\n")) {
|
|
1330
1353
|
if (!line.trim())
|
|
@@ -1727,19 +1750,17 @@ export function writeStandingRules(text, path = RULES_FILE) {
|
|
|
1727
1750
|
*/
|
|
1728
1751
|
const GOAL_MAX_CHARS = 3800;
|
|
1729
1752
|
/**
|
|
1730
|
-
* The most a LINE TYPED AT A SESSION may be, in characters
|
|
1731
|
-
* than GOAL_MAX_CHARS above, and for a different reason.
|
|
1753
|
+
* The most a LINE TYPED AT A SESSION may be, in characters.
|
|
1732
1754
|
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
1738
|
-
* set at all. A ~1,150-char line failed this way that night; a ~900-char
|
|
1739
|
-
* line of the same shape still typed correctly on Claude Code 2.1.267 the
|
|
1740
|
-
* night before. 700 leaves headroom under both observed points.
|
|
1755
|
+
* Measured twice: above roughly 300 characters the prompt turns the typed
|
|
1756
|
+
* `/goal` into a pasted attachment, Enter never submits it, and the text sits
|
|
1757
|
+
* in the operator's input line while the log says "armed". Only a short line
|
|
1758
|
+
* arms. Everything long goes to a brief file the line points at (see
|
|
1759
|
+
* {@link buildGoalLine}); 250 leaves headroom under the measured point.
|
|
1741
1760
|
*/
|
|
1742
|
-
const GOAL_LINE_MAX_CHARS =
|
|
1761
|
+
const GOAL_LINE_MAX_CHARS = 250;
|
|
1762
|
+
/** Where per-session briefs are written. */
|
|
1763
|
+
const BRIEF_DIR = join(homedir(), ".aibroker", "manage-briefs");
|
|
1743
1764
|
/** `/goal` plus whichever of the given bits are non-empty, one line, no doubled spaces. */
|
|
1744
1765
|
function assembleGoalLine(agentish, objective, rulesPointer, screenGrant) {
|
|
1745
1766
|
const bits = [agentish, objective, rulesPointer, screenGrant].filter((s) => s.length > 0);
|
|
@@ -1981,65 +2002,91 @@ function screenStatus(m, now = Date.now()) {
|
|
|
1981
2002
|
}
|
|
1982
2003
|
return `screen: granted until ${at(lease.until)}, but ${describeControls(tool, now).replace(/^screen: /, "")} — renewing`;
|
|
1983
2004
|
}
|
|
2005
|
+
/** A file-name-safe slug for a session, stable across armings. */
|
|
2006
|
+
export function briefSlug(m) {
|
|
2007
|
+
const slug = (m.name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
2008
|
+
return slug || m.sessionId.replace(/[^a-zA-Z0-9]+/g, "").slice(0, 40) || "session";
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* The line typed at the session: `/goal` plus a pointer to the brief, plus as
|
|
2012
|
+
* much of the objective's first words as fits. Never longer than `max`.
|
|
2013
|
+
*/
|
|
2014
|
+
export function buildGoalLine(briefPath, objective, max = GOAL_LINE_MAX_CHARS) {
|
|
2015
|
+
const head = `/goal Follow ${briefPath} until done`;
|
|
2016
|
+
const room = max - head.length - 2;
|
|
2017
|
+
const words = oneLine(objective);
|
|
2018
|
+
return room >= 20 && words ? `${head}: ${truncateAtWord(words, room)}` : head;
|
|
2019
|
+
}
|
|
2020
|
+
/** Everything that does not fit on the typed line, as the file it points to. */
|
|
2021
|
+
export function buildBrief(parts) {
|
|
2022
|
+
const sections = [];
|
|
2023
|
+
if (parts.agentish)
|
|
2024
|
+
sections.push(`## Message format between agents\n\n${parts.agentish}`);
|
|
2025
|
+
sections.push(`## Objective\n\n${parts.objective}`);
|
|
2026
|
+
if (parts.shiftRules)
|
|
2027
|
+
sections.push(`## Shift rules\n\n${parts.shiftRules}`);
|
|
2028
|
+
if (parts.rulesSource) {
|
|
2029
|
+
sections.push(`## Standing rules\n\nFIRST, before anything else: read ${parts.rulesSource} and follow every rule in it for the whole of this work — they are not optional and they are not summarised here.`);
|
|
2030
|
+
}
|
|
2031
|
+
if (parts.hands.trim())
|
|
2032
|
+
sections.push(`## Screen\n\n${parts.hands.trim()}`);
|
|
2033
|
+
if (parts.pending.length) {
|
|
2034
|
+
sections.push(`## Operator notes since you were last armed\n\n${parts.pending.map((p) => `- ${p}`).join("\n")}`);
|
|
2035
|
+
}
|
|
2036
|
+
return `${sections.join("\n\n")}\n`;
|
|
2037
|
+
}
|
|
1984
2038
|
/**
|
|
1985
|
-
* The
|
|
1986
|
-
* afterwards to know it landed.
|
|
2039
|
+
* The line typed at the session, after rewriting its brief file (0600).
|
|
1987
2040
|
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
* there is no case left where trying the longer composeGoal() inline form
|
|
1992
|
-
* first would help.
|
|
2041
|
+
* Rewritten on every arming so standing rules, shift state, the screen grant
|
|
2042
|
+
* and pending operator notes are always current; pending notes are consumed
|
|
2043
|
+
* into the brief, not the line.
|
|
1993
2044
|
*/
|
|
1994
2045
|
function goalText(m) {
|
|
1995
|
-
const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
|
|
1996
|
-
// The screen rule has to ride along with EVERY arming. Delivered once, it
|
|
1997
|
-
// lasts only until the session next reads a goal — and the goal is what tells
|
|
1998
|
-
// it what to do. So a standing rule that is not in the goal is a rule with a
|
|
1999
|
-
// lifetime of one turn, and the next arming would send it back to clicking.
|
|
2000
|
-
//
|
|
2001
|
-
// The positive case has to ride along too, and for a sharper reason. Silence
|
|
2002
|
-
// about the screen is not neutral: a session that has been careful with the
|
|
2003
|
-
// operator's machine all night reads it as permission having quietly ended,
|
|
2004
|
-
// and one of them announced it had handed the controls back while the grant
|
|
2005
|
-
// on disk still had two hours on it. Saying the expiry out loud, every time,
|
|
2006
|
-
// is what stops a session inventing one.
|
|
2007
|
-
//
|
|
2008
|
-
// `rules clear` is the operator saying objectives now carry only themselves,
|
|
2009
|
-
// so the screen guidance goes with the rest of the standing prose and only
|
|
2010
|
-
// the expiry rides along. The prohibition below is NOT shortened with it: a
|
|
2011
|
-
// goal template that hands the screen over says nothing about the case where
|
|
2012
|
-
// the screen is withheld, so that guidance exists nowhere else.
|
|
2013
2046
|
const rules = readStandingRules();
|
|
2047
|
+
// The screen rule rides along with EVERY arming: delivered once it lasts
|
|
2048
|
+
// only until the session next reads a goal, and silence about the screen is
|
|
2049
|
+
// read as permission having quietly ended. Now it lives in the brief.
|
|
2014
2050
|
const hands = m.noScreen
|
|
2015
|
-
? "
|
|
2051
|
+
? "THE OPERATOR HAS THE SCREEN: do no screen or pointer work at all, and do not ask for it. Everything else continues as normal. Where something would need checking on screen, write down what would need checking instead of checking it."
|
|
2016
2052
|
: (() => {
|
|
2017
2053
|
const lease = screenLease(m, Date.now());
|
|
2018
2054
|
return lease ? screenGrantedClause(lease.until, !rules) : "";
|
|
2019
2055
|
})();
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
: ""
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2056
|
+
const shift = shiftObjective();
|
|
2057
|
+
const path = join(BRIEF_DIR, `${briefSlug(m)}.md`);
|
|
2058
|
+
mkdirSync(BRIEF_DIR, { recursive: true, mode: 0o700 });
|
|
2059
|
+
writeFileSync(path, buildBrief({
|
|
2060
|
+
objective: m.objective,
|
|
2061
|
+
agentish: AG2_SPEC,
|
|
2062
|
+
rulesSource: rules ? standingRulesSource() : "",
|
|
2063
|
+
shiftRules: m.shift && m.objective !== shift ? shift : "",
|
|
2064
|
+
hands,
|
|
2065
|
+
pending: m.pending,
|
|
2066
|
+
}), { mode: 0o600 });
|
|
2067
|
+
chmodSync(path, 0o600);
|
|
2068
|
+
return { text: buildGoalLine(foldHome(path), m.objective) };
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Did it arm? Within a bounded wait the pane must show the goal marker with an
|
|
2072
|
+
* empty input line. If our own line is still sitting in the input, it is
|
|
2073
|
+
* cleared — never left in the operator's prompt — and the arm counts as failed.
|
|
2074
|
+
*
|
|
2075
|
+
* Text is not proof of a landing (a stranded line shows up in the transcript
|
|
2076
|
+
* area too, which is what the earlier fragment check could not tell apart).
|
|
2077
|
+
*/
|
|
2078
|
+
export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
|
|
2079
|
+
let unsent = null;
|
|
2080
|
+
for (let i = 0; i < waits; i++) {
|
|
2081
|
+
await deps.sleep(waitMs);
|
|
2082
|
+
const pane = deps.readPane();
|
|
2083
|
+
unsent = promptUnsentText(pane);
|
|
2084
|
+
if (GOAL_ACTIVE.test(pane) && !unsent)
|
|
2085
|
+
return true;
|
|
2086
|
+
}
|
|
2087
|
+
if (unsent && typedLineMatches(unsent, typed))
|
|
2088
|
+
deps.clearInput();
|
|
2089
|
+
return false;
|
|
2043
2090
|
}
|
|
2044
2091
|
function readPane(sessionId, opts = {}) {
|
|
2045
2092
|
try {
|
|
@@ -2053,7 +2100,7 @@ async function sleep(ms) {
|
|
|
2053
2100
|
return new Promise((r) => setTimeout(r, ms));
|
|
2054
2101
|
}
|
|
2055
2102
|
async function arm(m, reason) {
|
|
2056
|
-
const { text
|
|
2103
|
+
const { text } = goalText(m);
|
|
2057
2104
|
/**
|
|
2058
2105
|
* NEVER TYPE A GOAL INTO A BARE SHELL.
|
|
2059
2106
|
*
|
|
@@ -2155,22 +2202,20 @@ async function arm(m, reason) {
|
|
|
2155
2202
|
}
|
|
2156
2203
|
sendEnterKey(m.sessionId);
|
|
2157
2204
|
// Typed is not sent, and sent is not received.
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
return true;
|
|
2171
|
-
}
|
|
2205
|
+
const landed = await confirmArmed(text, {
|
|
2206
|
+
readPane: () => readPane(m.sessionId, { fresh: true }),
|
|
2207
|
+
sleep,
|
|
2208
|
+
clearInput: () => sendControlU(m.sessionId),
|
|
2209
|
+
});
|
|
2210
|
+
if (landed) {
|
|
2211
|
+
m.lastRearmAt = Date.now();
|
|
2212
|
+
const carried = m.pending.length;
|
|
2213
|
+
m.pending = [];
|
|
2214
|
+
armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
|
|
2215
|
+
note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
|
|
2216
|
+
return true;
|
|
2172
2217
|
}
|
|
2173
|
-
notify(m, `typed but the
|
|
2218
|
+
notify(m, `typed but the goal never took (no active marker, or the line stayed in the input) — input cleared, treating as NOT armed (${reason})`);
|
|
2174
2219
|
return false;
|
|
2175
2220
|
}
|
|
2176
2221
|
/**
|
|
@@ -3038,6 +3083,9 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3038
3083
|
message: `manage — keep a session working on a standing objective.\n\n` +
|
|
3039
3084
|
` <objective> start managing, or once running, an instruction carried\n` +
|
|
3040
3085
|
` into the next arming ("do the tests before the docs")\n` +
|
|
3086
|
+
` start <text> start managing, or REPLACE the objective when already managed\n` +
|
|
3087
|
+
` instruct <text>\n` +
|
|
3088
|
+
` a one-shot note for the next arming (same as plain text)\n` +
|
|
3041
3089
|
` status what the session looks like right now, and what the\n` +
|
|
3042
3090
|
` manager has done. Also: state, what, info, show\n` +
|
|
3043
3091
|
` hands off the operator needs the screen: stops visual work at once,\n` +
|
|
@@ -3174,8 +3222,8 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3174
3222
|
}
|
|
3175
3223
|
const { hours, visual, workers } = parseShift(rest);
|
|
3176
3224
|
const until = Date.now() + hours * 3_600_000;
|
|
3177
|
-
existing.objective
|
|
3178
|
-
|
|
3225
|
+
if (!existing.objective.trim())
|
|
3226
|
+
existing.objective = shiftObjective();
|
|
3179
3227
|
existing.paused = false;
|
|
3180
3228
|
existing.noScreen = !visual;
|
|
3181
3229
|
existing.handsUntil = until;
|
|
@@ -3211,7 +3259,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3211
3259
|
message: `${name} is on shift for ${hours} hour(s), until ${ends}.\n` +
|
|
3212
3260
|
` ${visual ? "The screen is its own until then, and reverts by itself." : "No screen work — the operator has the machine."}\n` +
|
|
3213
3261
|
screenLine +
|
|
3214
|
-
` Objective set to the tracker's open issues; the standing rules ride along with every arming.\n` +
|
|
3262
|
+
` ${existing.objective === shiftObjective() ? "Objective set to the tracker's open issues" : "Objective kept, the shift's issue-work rules go in the brief"}; the standing rules ride along with every arming.\n` +
|
|
3215
3263
|
(workers > 1
|
|
3216
3264
|
? ` Asked for ${workers} workers. Only one runs today — worktrees and the claim protocol are designed but not built, and a second worker without them would share a checkout with the first.\n`
|
|
3217
3265
|
: "") +
|
|
@@ -3280,7 +3328,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3280
3328
|
* rather than misleading once. That is the difference between an objective
|
|
3281
3329
|
* and a message, and it is why this needs its own verb.
|
|
3282
3330
|
*/
|
|
3283
|
-
const setMatch = arg.match(/^(?:set|objective|replace)\s+([\s\S]+)$/i);
|
|
3331
|
+
const setMatch = arg.match(/^(?:set|objective|replace|start)\s+([\s\S]+)$/i);
|
|
3284
3332
|
if (setMatch && existing) {
|
|
3285
3333
|
const before = existing.objective;
|
|
3286
3334
|
existing.objective = setMatch[1].trim();
|
|
@@ -3562,10 +3610,11 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3562
3610
|
`If you meant a different session, name it first: aibroker manage <session> <objective>`,
|
|
3563
3611
|
};
|
|
3564
3612
|
}
|
|
3613
|
+
const objective = arg.match(/^start\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
|
|
3565
3614
|
const m = {
|
|
3566
3615
|
sessionId,
|
|
3567
3616
|
name,
|
|
3568
|
-
objective
|
|
3617
|
+
objective,
|
|
3569
3618
|
pending: [],
|
|
3570
3619
|
history: [],
|
|
3571
3620
|
// Give the session the benefit of the grace period rather than arming
|
|
@@ -3587,7 +3636,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3587
3636
|
return {
|
|
3588
3637
|
ok: true,
|
|
3589
3638
|
managed: true,
|
|
3590
|
-
message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${
|
|
3639
|
+
message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${objective}`,
|
|
3591
3640
|
};
|
|
3592
3641
|
}
|
|
3593
3642
|
if (word === "now") {
|
|
@@ -3595,8 +3644,9 @@ export async function handleManage(sessionIdOrName, rawArg) {
|
|
|
3595
3644
|
saveState(state);
|
|
3596
3645
|
return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
|
|
3597
3646
|
}
|
|
3598
|
-
|
|
3599
|
-
|
|
3647
|
+
const instruction = arg.match(/^instruct\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
|
|
3648
|
+
existing.pending.push(instruction);
|
|
3649
|
+
note(existing, `operator: ${instruction.slice(0, 80)}`);
|
|
3600
3650
|
saveState(state);
|
|
3601
3651
|
return {
|
|
3602
3652
|
ok: true,
|