aibroker 0.61.2 → 0.61.4

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.
Files changed (43) hide show
  1. package/dist/adapters/iterm/core.d.ts +34 -4
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +139 -104
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/iterm2-api.d.ts.map +1 -1
  6. package/dist/adapters/iterm/iterm2-api.js +4 -2
  7. package/dist/adapters/iterm/iterm2-api.js.map +1 -1
  8. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  9. package/dist/adapters/iterm/sessions.js +47 -114
  10. package/dist/adapters/iterm/sessions.js.map +1 -1
  11. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  12. package/dist/adapters/pailot/gateway.js +6 -52
  13. package/dist/adapters/pailot/gateway.js.map +1 -1
  14. package/dist/daemon/commands.d.ts.map +1 -1
  15. package/dist/daemon/commands.js +4 -29
  16. package/dist/daemon/commands.js.map +1 -1
  17. package/dist/daemon/core-handlers.js +1 -1
  18. package/dist/daemon/core-handlers.js.map +1 -1
  19. package/dist/daemon/manage.d.ts +29 -18
  20. package/dist/daemon/manage.d.ts.map +1 -1
  21. package/dist/daemon/manage.js +120 -123
  22. package/dist/daemon/manage.js.map +1 -1
  23. package/dist/daemon/screenshot.d.ts.map +1 -1
  24. package/dist/daemon/screenshot.js +6 -42
  25. package/dist/daemon/screenshot.js.map +1 -1
  26. package/dist/daemon/session-content.d.ts +1 -1
  27. package/dist/daemon/session-content.d.ts.map +1 -1
  28. package/dist/daemon/session-content.js +10 -25
  29. package/dist/daemon/session-content.js.map +1 -1
  30. package/dist/daemon/sessions.d.ts.map +1 -1
  31. package/dist/daemon/sessions.js +7 -13
  32. package/dist/daemon/sessions.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/mcp/index.js +7 -13
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/transport/iterm.d.ts.map +1 -1
  40. package/dist/transport/iterm.js +12 -20
  41. package/dist/transport/iterm.js.map +1 -1
  42. package/hooks/manage-hook.mjs +4 -1
  43. package/package.json +1 -1
@@ -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";
@@ -1727,19 +1727,17 @@ export function writeStandingRules(text, path = RULES_FILE) {
1727
1727
  */
1728
1728
  const GOAL_MAX_CHARS = 3800;
1729
1729
  /**
1730
- * The most a LINE TYPED AT A SESSION may be, in characters — much stricter
1731
- * than GOAL_MAX_CHARS above, and for a different reason.
1730
+ * The most a LINE TYPED AT A SESSION may be, in characters.
1732
1731
  *
1733
- * Proven from a live transcript on 2026-09-24: on Claude Code 2.1.280,
1734
- * pasting text over roughly this length gets converted into a
1735
- * `<pasted_content>` attachment instead of typed input, so a leading `/goal`
1736
- * is never read as a slash command — the whole thing lands as one plain
1737
- * message ("[Pasted text #N]"), the session answers "Noted.", and no goal is
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.
1732
+ * Measured twice: above roughly 300 characters the prompt turns the typed
1733
+ * `/goal` into a pasted attachment, Enter never submits it, and the text sits
1734
+ * in the operator's input line while the log says "armed". Only a short line
1735
+ * arms. Everything long goes to a brief file the line points at (see
1736
+ * {@link buildGoalLine}); 250 leaves headroom under the measured point.
1741
1737
  */
1742
- const GOAL_LINE_MAX_CHARS = 700;
1738
+ const GOAL_LINE_MAX_CHARS = 250;
1739
+ /** Where per-session briefs are written. */
1740
+ const BRIEF_DIR = join(homedir(), ".aibroker", "manage-briefs");
1743
1741
  /** `/goal` plus whichever of the given bits are non-empty, one line, no doubled spaces. */
1744
1742
  function assembleGoalLine(agentish, objective, rulesPointer, screenGrant) {
1745
1743
  const bits = [agentish, objective, rulesPointer, screenGrant].filter((s) => s.length > 0);
@@ -1981,65 +1979,91 @@ function screenStatus(m, now = Date.now()) {
1981
1979
  }
1982
1980
  return `screen: granted until ${at(lease.until)}, but ${describeControls(tool, now).replace(/^screen: /, "")} — renewing`;
1983
1981
  }
1982
+ /** A file-name-safe slug for a session, stable across armings. */
1983
+ export function briefSlug(m) {
1984
+ const slug = (m.name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
1985
+ return slug || m.sessionId.replace(/[^a-zA-Z0-9]+/g, "").slice(0, 40) || "session";
1986
+ }
1987
+ /**
1988
+ * The line typed at the session: `/goal` plus a pointer to the brief, plus as
1989
+ * much of the objective's first words as fits. Never longer than `max`.
1990
+ */
1991
+ export function buildGoalLine(briefPath, objective, max = GOAL_LINE_MAX_CHARS) {
1992
+ const head = `/goal Follow ${briefPath} until done`;
1993
+ const room = max - head.length - 2;
1994
+ const words = oneLine(objective);
1995
+ return room >= 20 && words ? `${head}: ${truncateAtWord(words, room)}` : head;
1996
+ }
1997
+ /** Everything that does not fit on the typed line, as the file it points to. */
1998
+ export function buildBrief(parts) {
1999
+ const sections = [];
2000
+ if (parts.agentish)
2001
+ sections.push(`## Message format between agents\n\n${parts.agentish}`);
2002
+ sections.push(`## Objective\n\n${parts.objective}`);
2003
+ if (parts.shiftRules)
2004
+ sections.push(`## Shift rules\n\n${parts.shiftRules}`);
2005
+ if (parts.rulesSource) {
2006
+ 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.`);
2007
+ }
2008
+ if (parts.hands.trim())
2009
+ sections.push(`## Screen\n\n${parts.hands.trim()}`);
2010
+ if (parts.pending.length) {
2011
+ sections.push(`## Operator notes since you were last armed\n\n${parts.pending.map((p) => `- ${p}`).join("\n")}`);
2012
+ }
2013
+ return `${sections.join("\n\n")}\n`;
2014
+ }
1984
2015
  /**
1985
- * The text actually typed at the session, and the fragment to look for
1986
- * afterwards to know it landed.
2016
+ * The line typed at the session, after rewriting its brief file (0600).
1987
2017
  *
1988
- * Short goal, context by reference — and, since GOAL_LINE_MAX_CHARS, always
1989
- * a pointer to the rules rather than the rules themselves: inlining a real
1990
- * standing-rules paragraph cannot fit a 700-char paste-safe budget anyway, so
1991
- * there is no case left where trying the longer composeGoal() inline form
1992
- * first would help.
2018
+ * Rewritten on every arming so standing rules, shift state, the screen grant
2019
+ * and pending operator notes are always current; pending notes are consumed
2020
+ * into the brief, not the line.
1993
2021
  */
1994
2022
  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
2023
  const rules = readStandingRules();
2024
+ // The screen rule rides along with EVERY arming: delivered once it lasts
2025
+ // only until the session next reads a goal, and silence about the screen is
2026
+ // read as permission having quietly ended. Now it lives in the brief.
2014
2027
  const hands = m.noScreen
2015
- ? " 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."
2028
+ ? "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
2029
  : (() => {
2017
2030
  const lease = screenLease(m, Date.now());
2018
2031
  return lease ? screenGrantedClause(lease.until, !rules) : "";
2019
2032
  })();
2020
- // AG2 goes first, ahead of the objective and the standing rules, on every
2021
- // arming — not once at setup. A managed session works unattended for hours
2022
- // and a reminder given only at the start does not survive a compaction or a
2023
- // `/clear`. What a managed session sends the operator, and what it commits
2024
- // to git, stays prose either way — AG2 is only how agents talk to agents.
2025
- const rulesPointer = rules
2026
- ? `FIRST, before anything else: read ${standingRulesSource()} and follow every rule in it for the whole of this work — they are not optional and they are not summarised here.`
2027
- : "";
2028
- const fit = fitGoal({ objective: `${m.objective}${extra}`, agentish: AG2_SPEC, rulesPointer, screenGrant: hands }, GOAL_LINE_MAX_CHARS);
2029
- return { text: fit.line, fragment: fit.objective.slice(0, 40) };
2030
- }
2031
- /**
2032
- * Did it land? Look for the goal's own words in the transcript.
2033
- *
2034
- * NOT "did the content change" — that was the first version and it could not
2035
- * tell a goal that arrived from text stranded unsubmitted in the input line,
2036
- * which is the exact failure it existed to catch. A session prints for a dozen
2037
- * reasons; only the item's own words say the item is there.
2038
- */
2039
- function seenInContent(content, fragment) {
2040
- if (!content)
2041
- return false;
2042
- return content.replace(/\s+/g, "").includes(fragment.replace(/\s+/g, ""));
2033
+ const shift = shiftObjective();
2034
+ const path = join(BRIEF_DIR, `${briefSlug(m)}.md`);
2035
+ mkdirSync(BRIEF_DIR, { recursive: true, mode: 0o700 });
2036
+ writeFileSync(path, buildBrief({
2037
+ objective: m.objective,
2038
+ agentish: AG2_SPEC,
2039
+ rulesSource: rules ? standingRulesSource() : "",
2040
+ shiftRules: m.shift && m.objective !== shift ? shift : "",
2041
+ hands,
2042
+ pending: m.pending,
2043
+ }), { mode: 0o600 });
2044
+ chmodSync(path, 0o600);
2045
+ return { text: buildGoalLine(foldHome(path), m.objective) };
2046
+ }
2047
+ /**
2048
+ * Did it arm? Within a bounded wait the pane must show the goal marker with an
2049
+ * empty input line. If our own line is still sitting in the input, it is
2050
+ * cleared — never left in the operator's prompt — and the arm counts as failed.
2051
+ *
2052
+ * Text is not proof of a landing (a stranded line shows up in the transcript
2053
+ * area too, which is what the earlier fragment check could not tell apart).
2054
+ */
2055
+ export async function confirmArmed(typed, deps, waits = 5, waitMs = 2_000) {
2056
+ let unsent = null;
2057
+ for (let i = 0; i < waits; i++) {
2058
+ await deps.sleep(waitMs);
2059
+ const pane = deps.readPane();
2060
+ unsent = promptUnsentText(pane);
2061
+ if (GOAL_ACTIVE.test(pane) && !unsent)
2062
+ return true;
2063
+ }
2064
+ if (unsent && typedLineMatches(unsent, typed))
2065
+ deps.clearInput();
2066
+ return false;
2043
2067
  }
2044
2068
  function readPane(sessionId, opts = {}) {
2045
2069
  try {
@@ -2053,7 +2077,7 @@ async function sleep(ms) {
2053
2077
  return new Promise((r) => setTimeout(r, ms));
2054
2078
  }
2055
2079
  async function arm(m, reason) {
2056
- const { text, fragment } = goalText(m);
2080
+ const { text } = goalText(m);
2057
2081
  /**
2058
2082
  * NEVER TYPE A GOAL INTO A BARE SHELL.
2059
2083
  *
@@ -2155,22 +2179,20 @@ async function arm(m, reason) {
2155
2179
  }
2156
2180
  sendEnterKey(m.sessionId);
2157
2181
  // Typed is not sent, and sent is not received.
2158
- for (let i = 0; i < 5; i++) {
2159
- await sleep(2_000);
2160
- if (seenInContent(readPane(m.sessionId, { fresh: true }), fragment)) {
2161
- m.lastRearmAt = Date.now();
2162
- const carried = m.pending.length;
2163
- m.pending = [];
2164
- armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
2165
- // The read-back above already confirmed the line held exactly what was
2166
- // typed before CR ever went out, so there is nothing left here to weld
2167
- // a goal onto — unlike before any of this guard existed, this note
2168
- // never has an "input line held X" case left to report.
2169
- note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
2170
- return true;
2171
- }
2182
+ const landed = await confirmArmed(text, {
2183
+ readPane: () => readPane(m.sessionId, { fresh: true }),
2184
+ sleep,
2185
+ clearInput: () => sendControlU(m.sessionId),
2186
+ });
2187
+ if (landed) {
2188
+ m.lastRearmAt = Date.now();
2189
+ const carried = m.pending.length;
2190
+ m.pending = [];
2191
+ armingsSinceReport.set(m.sessionId, (armingsSinceReport.get(m.sessionId) ?? 0) + 1);
2192
+ note(m, `armed: ${reason}${carried ? ` (carrying ${carried} operator instruction${carried > 1 ? "s" : ""})` : ""}`);
2193
+ return true;
2172
2194
  }
2173
- notify(m, `typed but the objective's own words never appeared — treating as NOT armed (${reason})`);
2195
+ 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
2196
  return false;
2175
2197
  }
2176
2198
  /**
@@ -2278,26 +2300,8 @@ function beat() {
2278
2300
  */
2279
2301
  const DIALOG_EVERY_TICKS = 3;
2280
2302
  let tickCount = 0;
2281
- /** A failure must last this long, continuously, before the operator is paged. */
2282
- export const ENUMERATION_ALERT_AFTER_MS = 120_000;
2283
- /**
2284
- * Pure decision for one tick: a transient failure (an osascript hitting the
2285
- * LaunchServices window) clears within a tick or two and must not page anyone;
2286
- * only a sustained one alerts, once, and only that one earns a recovery line.
2287
- */
2288
- export function enumerationStep(ep, now, reliable) {
2289
- if (reliable) {
2290
- const downMs = ep.since === null ? 0 : now - ep.since;
2291
- return { ep: { since: null, alerted: false }, action: ep.alerted ? "recovered" : "none", downMs };
2292
- }
2293
- const since = ep.since ?? now;
2294
- const downMs = now - since;
2295
- if (!ep.alerted && downMs >= ENUMERATION_ALERT_AFTER_MS) {
2296
- return { ep: { since, alerted: true }, action: "alert", downMs };
2297
- }
2298
- return { ep: { since, alerted: ep.alerted }, action: "none", downMs };
2299
- }
2300
- let enumerationEpisode = { since: null, alerted: false };
2303
+ /** When the current iTerm enumeration failure began; null while reliable. Logged, never alerted. */
2304
+ let enumerationDownSince = null;
2301
2305
  function answerBlockingDialogs() {
2302
2306
  if (Object.keys(state).length === 0)
2303
2307
  return;
@@ -2323,26 +2327,14 @@ async function tick() {
2323
2327
  // failure this exists to catch. Reuses the flag every `sessions`/`status`
2324
2328
  // caller already sets; no separate probe.
2325
2329
  const reliable = wasLastEnumerationReliable();
2326
- if (!reliable && enumerationEpisode.since === null)
2327
- log("[manage] iTerm enumeration unreliable — episode started, alerting only if it lasts");
2328
- const step = enumerationStep(enumerationEpisode, now, reliable);
2329
- enumerationEpisode = step.ep;
2330
- if (step.action === "alert") {
2331
- let text = "iTerm session enumeration is failing (AppleScript errors) for over 2 minutes — the session list may be empty or stale until this clears.";
2330
+ if (!reliable && enumerationDownSince === null) {
2331
+ enumerationDownSince = now;
2332
2332
  const impostors = findItermBundleIdImpostors();
2333
- if (impostors.length) {
2334
- const named = impostors.map((i) => `pid ${i.pid} (${i.executablePath})`).join(", ");
2335
- text += ` Found ${named} registered under iTerm's bundle id — a process spawned with iTerm's __CFBundleIdentifier; killing it restores enumeration.`;
2336
- }
2337
- alertOperator(text);
2338
- log(`[manage] iTerm enumeration unreliable — alerted the operator${impostors.length ? ` (impostors: ${impostors.map((i) => i.pid).join(", ")})` : ""}`);
2339
- }
2340
- else if (step.action === "recovered") {
2341
- alertOperator(`iTerm session enumeration recovered after ${Math.round(step.downMs / 1000)}s.`);
2342
- log("[manage] iTerm enumeration recovered — alerted the operator");
2333
+ log(`[manage] iTerm enumeration unreliable — episode started${impostors.length ? ` (impostors: ${impostors.map((i) => i.pid).join(", ")})` : ""}`);
2343
2334
  }
2344
- else if (reliable && step.downMs > 0) {
2345
- log(`[manage] iTerm enumeration recovered after ${Math.round(step.downMs / 1000)}s — no alert sent`);
2335
+ else if (reliable && enumerationDownSince !== null) {
2336
+ log(`[manage] iTerm enumeration recovered after ${Math.round((now - enumerationDownSince) / 1000)}s`);
2337
+ enumerationDownSince = null;
2346
2338
  }
2347
2339
  if (++tickCount % DIALOG_EVERY_TICKS === 0) {
2348
2340
  try {
@@ -3068,6 +3060,9 @@ export async function handleManage(sessionIdOrName, rawArg) {
3068
3060
  message: `manage — keep a session working on a standing objective.\n\n` +
3069
3061
  ` <objective> start managing, or once running, an instruction carried\n` +
3070
3062
  ` into the next arming ("do the tests before the docs")\n` +
3063
+ ` start <text> start managing, or REPLACE the objective when already managed\n` +
3064
+ ` instruct <text>\n` +
3065
+ ` a one-shot note for the next arming (same as plain text)\n` +
3071
3066
  ` status what the session looks like right now, and what the\n` +
3072
3067
  ` manager has done. Also: state, what, info, show\n` +
3073
3068
  ` hands off the operator needs the screen: stops visual work at once,\n` +
@@ -3204,8 +3199,8 @@ export async function handleManage(sessionIdOrName, rawArg) {
3204
3199
  }
3205
3200
  const { hours, visual, workers } = parseShift(rest);
3206
3201
  const until = Date.now() + hours * 3_600_000;
3207
- existing.objective = shiftObjective();
3208
- existing.pending = [];
3202
+ if (!existing.objective.trim())
3203
+ existing.objective = shiftObjective();
3209
3204
  existing.paused = false;
3210
3205
  existing.noScreen = !visual;
3211
3206
  existing.handsUntil = until;
@@ -3241,7 +3236,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
3241
3236
  message: `${name} is on shift for ${hours} hour(s), until ${ends}.\n` +
3242
3237
  ` ${visual ? "The screen is its own until then, and reverts by itself." : "No screen work — the operator has the machine."}\n` +
3243
3238
  screenLine +
3244
- ` Objective set to the tracker's open issues; the standing rules ride along with every arming.\n` +
3239
+ ` ${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` +
3245
3240
  (workers > 1
3246
3241
  ? ` 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`
3247
3242
  : "") +
@@ -3310,7 +3305,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
3310
3305
  * rather than misleading once. That is the difference between an objective
3311
3306
  * and a message, and it is why this needs its own verb.
3312
3307
  */
3313
- const setMatch = arg.match(/^(?:set|objective|replace)\s+([\s\S]+)$/i);
3308
+ const setMatch = arg.match(/^(?:set|objective|replace|start)\s+([\s\S]+)$/i);
3314
3309
  if (setMatch && existing) {
3315
3310
  const before = existing.objective;
3316
3311
  existing.objective = setMatch[1].trim();
@@ -3592,10 +3587,11 @@ export async function handleManage(sessionIdOrName, rawArg) {
3592
3587
  `If you meant a different session, name it first: aibroker manage <session> <objective>`,
3593
3588
  };
3594
3589
  }
3590
+ const objective = arg.match(/^start\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
3595
3591
  const m = {
3596
3592
  sessionId,
3597
3593
  name,
3598
- objective: arg,
3594
+ objective,
3599
3595
  pending: [],
3600
3596
  history: [],
3601
3597
  // Give the session the benefit of the grace period rather than arming
@@ -3617,7 +3613,7 @@ export async function handleManage(sessionIdOrName, rawArg) {
3617
3613
  return {
3618
3614
  ok: true,
3619
3615
  managed: true,
3620
- message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${arg}`,
3616
+ message: `managing ${name}. It will be re-armed with this objective whenever it stops:\n ${objective}`,
3621
3617
  };
3622
3618
  }
3623
3619
  if (word === "now") {
@@ -3625,8 +3621,9 @@ export async function handleManage(sessionIdOrName, rawArg) {
3625
3621
  saveState(state);
3626
3622
  return { ok: true, managed: true, message: `${name} will be armed on the next tick` };
3627
3623
  }
3628
- existing.pending.push(arg);
3629
- note(existing, `operator: ${arg.slice(0, 80)}`);
3624
+ const instruction = arg.match(/^instruct\s+([\s\S]+)$/i)?.[1].trim() ?? arg;
3625
+ existing.pending.push(instruction);
3626
+ note(existing, `operator: ${instruction.slice(0, 80)}`);
3630
3627
  saveState(state);
3631
3628
  return {
3632
3629
  ok: true,