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.
@@ -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
- // The live transcript is the one being written. Newest wins; a session that
1314
- // has not written for a long time will show that in its own timestamp
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
- // Only the tail is needed and these files reach tens of megabytes.
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 — much stricter
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
- * 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.
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 = 700;
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 text actually typed at the session, and the fragment to look for
1986
- * afterwards to know it landed.
2039
+ * The line typed at the session, after rewriting its brief file (0600).
1987
2040
  *
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.
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
- ? " 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."
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
- // 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, ""));
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, fragment } = goalText(m);
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
- 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
- }
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 objective's own words never appeared — treating as NOT armed (${reason})`);
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 = shiftObjective();
3178
- existing.pending = [];
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: arg,
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 ${arg}`,
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
- existing.pending.push(arg);
3599
- note(existing, `operator: ${arg.slice(0, 80)}`);
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,