aibroker 0.58.1 → 0.60.0

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 (84) hide show
  1. package/dist/adapters/iterm/core.d.ts +12 -1
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +32 -5
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/sessions.d.ts +28 -0
  6. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  7. package/dist/adapters/iterm/sessions.js +101 -4
  8. package/dist/adapters/iterm/sessions.js.map +1 -1
  9. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  10. package/dist/adapters/pailot/gateway.js +46 -11
  11. package/dist/adapters/pailot/gateway.js.map +1 -1
  12. package/dist/core/hybrid.d.ts +10 -1
  13. package/dist/core/hybrid.d.ts.map +1 -1
  14. package/dist/core/hybrid.js +50 -15
  15. package/dist/core/hybrid.js.map +1 -1
  16. package/dist/core/persistence.d.ts +51 -2
  17. package/dist/core/persistence.d.ts.map +1 -1
  18. package/dist/core/persistence.js +45 -1
  19. package/dist/core/persistence.js.map +1 -1
  20. package/dist/core/session-match.d.ts +23 -0
  21. package/dist/core/session-match.d.ts.map +1 -1
  22. package/dist/core/session-match.js +25 -11
  23. package/dist/core/session-match.js.map +1 -1
  24. package/dist/core/state.d.ts +7 -1
  25. package/dist/core/state.d.ts.map +1 -1
  26. package/dist/core/state.js +13 -3
  27. package/dist/core/state.js.map +1 -1
  28. package/dist/daemon/cli.js +3 -0
  29. package/dist/daemon/cli.js.map +1 -1
  30. package/dist/daemon/commands.d.ts.map +1 -1
  31. package/dist/daemon/commands.js +8 -0
  32. package/dist/daemon/commands.js.map +1 -1
  33. package/dist/daemon/core-handlers.d.ts +28 -0
  34. package/dist/daemon/core-handlers.d.ts.map +1 -1
  35. package/dist/daemon/core-handlers.js +215 -59
  36. package/dist/daemon/core-handlers.js.map +1 -1
  37. package/dist/daemon/dispatch.d.ts +60 -1
  38. package/dist/daemon/dispatch.d.ts.map +1 -1
  39. package/dist/daemon/dispatch.js +389 -18
  40. package/dist/daemon/dispatch.js.map +1 -1
  41. package/dist/daemon/inbound.d.ts +4 -0
  42. package/dist/daemon/inbound.d.ts.map +1 -1
  43. package/dist/daemon/inbound.js +134 -29
  44. package/dist/daemon/inbound.js.map +1 -1
  45. package/dist/daemon/index.d.ts.map +1 -1
  46. package/dist/daemon/index.js +42 -0
  47. package/dist/daemon/index.js.map +1 -1
  48. package/dist/daemon/manage.d.ts +37 -0
  49. package/dist/daemon/manage.d.ts.map +1 -1
  50. package/dist/daemon/manage.js +179 -13
  51. package/dist/daemon/manage.js.map +1 -1
  52. package/dist/daemon/session-content.d.ts.map +1 -1
  53. package/dist/daemon/session-content.js +4 -2
  54. package/dist/daemon/session-content.js.map +1 -1
  55. package/dist/daemon/todoist-pending.d.ts +45 -0
  56. package/dist/daemon/todoist-pending.d.ts.map +1 -0
  57. package/dist/daemon/todoist-pending.js +58 -0
  58. package/dist/daemon/todoist-pending.js.map +1 -0
  59. package/dist/daemon/todoist-reply.d.ts +10 -0
  60. package/dist/daemon/todoist-reply.d.ts.map +1 -1
  61. package/dist/daemon/todoist-reply.js +1 -0
  62. package/dist/daemon/todoist-reply.js.map +1 -1
  63. package/dist/daemon/todoist-webhook.d.ts +29 -0
  64. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  65. package/dist/daemon/todoist-webhook.js +334 -209
  66. package/dist/daemon/todoist-webhook.js.map +1 -1
  67. package/dist/ipc/client.d.ts.map +1 -1
  68. package/dist/ipc/client.js +24 -3
  69. package/dist/ipc/client.js.map +1 -1
  70. package/dist/ipc/validate.d.ts +2 -0
  71. package/dist/ipc/validate.d.ts.map +1 -1
  72. package/dist/ipc/validate.js +6 -1
  73. package/dist/ipc/validate.js.map +1 -1
  74. package/dist/mcp/index.js +19 -2
  75. package/dist/mcp/index.js.map +1 -1
  76. package/dist/transport/screen.d.ts.map +1 -1
  77. package/dist/transport/screen.js +30 -4
  78. package/dist/transport/screen.js.map +1 -1
  79. package/dist/transport/sync-facade.d.ts +15 -2
  80. package/dist/transport/sync-facade.d.ts.map +1 -1
  81. package/dist/transport/sync-facade.js +16 -3
  82. package/dist/transport/sync-facade.js.map +1 -1
  83. package/hooks/drain-mailbox.mjs +34 -8
  84. package/package.json +2 -2
@@ -26,7 +26,7 @@ import { homedir } from "node:os";
26
26
  import { log } from "../core/log.js";
27
27
  import { timeCall } from "../core/call-timing.js";
28
28
  import { readSessionContent } from "./session-content.js";
29
- import { typeIntoSession, pasteTextIntoSession, sendControlU, sendEnterKey, escapeInputMode } from "../transport/sync-facade.js";
29
+ import { typeIntoSession, pasteTextIntoSession, sendControlU, sendEnterKey, escapeInputMode, wasLastEnumerationReliable } from "../transport/sync-facade.js";
30
30
  import { discoverLiveSessions } from "../core/session-discovery.js";
31
31
  import { hasPailotClients } from "../adapters/pailot/gateway.js";
32
32
  import { getAibpBridge } from "../core/state.js";
@@ -847,6 +847,40 @@ export function promptUnsentText(content) {
847
847
  export function promptHasUnsentText(content) {
848
848
  return promptUnsentText(content) !== null;
849
849
  }
850
+ /**
851
+ * Floor for {@link paneLooksIdle}: never call a pane idle before it has sat
852
+ * this long unchanged, no matter how empty it looks — a pane can be static
853
+ * for a few seconds between real turns too.
854
+ */
855
+ export const PANE_IDLE_FLOOR_MS = 5 * 60_000;
856
+ // Claude Code's own working indicator: "Word… (12s · ↓ ...)" or
857
+ // "Word… (1m 3s · ↓ ...)". A finished turn reads "Word for Xm Ys · done
858
+ // H:MM ..." with no "…" before a paren, so it never matches this.
859
+ const SPINNER_RE = /…\s*\(\d+(?:m\s*\d+)?s/;
860
+ // promptUnsentText() treats the terminal's own "queued messages" hint as no
861
+ // text (it isn't something an operator typed), but for idleness it means the
862
+ // opposite: messages are still waiting to run, so the pane is not idle.
863
+ const QUEUED_HINT_RE = /❯\s*press up to edit/i;
864
+ /**
865
+ * Escape hatch for the arm gate ONLY. sessionIsWorking() reads any transcript
866
+ * activity within WORKING_RECENT_MS as "working", which holds for a session
867
+ * whose turn has already ended but which is running a long background shell
868
+ * (e.g. `pai worker run`) that keeps streaming progress into the transcript
869
+ * for up to an hour. This looks at the pane instead: quiet long enough,
870
+ * nothing typed, nothing spinning.
871
+ */
872
+ export function paneLooksIdle(paneText, quietMs, floorMs = PANE_IDLE_FLOOR_MS) {
873
+ if (quietMs < floorMs)
874
+ return false;
875
+ if (promptUnsentText(paneText) !== null)
876
+ return false;
877
+ const lastLines = paneText.split("\n").slice(-8).join("\n");
878
+ if (QUEUED_HINT_RE.test(lastLines))
879
+ return false;
880
+ if (SPINNER_RE.test(lastLines))
881
+ return false;
882
+ return true;
883
+ }
850
884
  /**
851
885
  * Expand the date tokens in a handover path, against the clock right now.
852
886
  *
@@ -1703,6 +1737,85 @@ export function writeStandingRules(text, path = RULES_FILE) {
1703
1737
  * by a sentence does not walk back over it.
1704
1738
  */
1705
1739
  const GOAL_MAX_CHARS = 3800;
1740
+ /**
1741
+ * The most a LINE TYPED AT A SESSION may be, in characters — much stricter
1742
+ * than GOAL_MAX_CHARS above, and for a different reason.
1743
+ *
1744
+ * Proven from a live transcript on 2026-09-24: on Claude Code 2.1.280,
1745
+ * pasting text over roughly this length gets converted into a
1746
+ * `<pasted_content>` attachment instead of typed input, so a leading `/goal`
1747
+ * is never read as a slash command — the whole thing lands as one plain
1748
+ * message ("[Pasted text #N]"), the session answers "Noted.", and no goal is
1749
+ * set at all. A ~1,150-char line failed this way that night; a ~900-char
1750
+ * line of the same shape still typed correctly on Claude Code 2.1.267 the
1751
+ * night before. 700 leaves headroom under both observed points.
1752
+ */
1753
+ const GOAL_LINE_MAX_CHARS = 700;
1754
+ /** `/goal` plus whichever of the given bits are non-empty, one line, no doubled spaces. */
1755
+ function assembleGoalLine(agentish, objective, rulesPointer, screenGrant) {
1756
+ const bits = [agentish, objective, rulesPointer, screenGrant].filter((s) => s.length > 0);
1757
+ return oneLine(`/goal ${bits.join(" ")}`);
1758
+ }
1759
+ /** Cut `s` to at most `max` characters, breaking at a word and marking the cut. */
1760
+ function truncateAtWord(s, max) {
1761
+ if (max <= 0)
1762
+ return "…";
1763
+ if (s.length <= max)
1764
+ return s;
1765
+ const cut = s.slice(0, max - 1);
1766
+ const lastSpace = cut.lastIndexOf(" ");
1767
+ return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
1768
+ }
1769
+ /**
1770
+ * The fitted goal line, and the (possibly shortened) objective that ended up
1771
+ * in it — the latter is what a caller must use to verify the arming landed,
1772
+ * since the fragment it waits for has to be a real substring of what was
1773
+ * actually typed.
1774
+ *
1775
+ * Shrinks in the order a reader can spare things: the AG2 header first (it
1776
+ * says nothing the session doesn't already know after its first arming), then
1777
+ * the rules pointer down to its shortest form, then the screen grant down to
1778
+ * just the time, and only as a last resort the objective itself — which is
1779
+ * the one thing nobody asked to have shortened, so it is never dropped
1780
+ * entirely, only trimmed at a word boundary.
1781
+ */
1782
+ function fitGoal(parts, max) {
1783
+ const { objective } = parts;
1784
+ const agentish = parts.agentish;
1785
+ let rulesPointer = parts.rulesPointer;
1786
+ let screenGrant = parts.screenGrant ?? "";
1787
+ let line = assembleGoalLine(agentish, objective, rulesPointer, screenGrant);
1788
+ if (line.length <= max)
1789
+ return { line, objective };
1790
+ line = assembleGoalLine("", objective, rulesPointer, screenGrant);
1791
+ if (line.length <= max)
1792
+ return { line, objective };
1793
+ if (rulesPointer) {
1794
+ rulesPointer = "Rules: read ~/.aibroker/manage-rules.txt first.";
1795
+ line = assembleGoalLine("", objective, rulesPointer, screenGrant);
1796
+ if (line.length <= max)
1797
+ return { line, objective };
1798
+ }
1799
+ if (screenGrant) {
1800
+ const time = screenGrant.match(/until\s+(\d{1,2}:\d{2})/i)?.[1];
1801
+ if (time)
1802
+ screenGrant = `Screen: yours until ${time}.`;
1803
+ line = assembleGoalLine("", objective, rulesPointer, screenGrant);
1804
+ if (line.length <= max)
1805
+ return { line, objective };
1806
+ }
1807
+ const overhead = assembleGoalLine("", "", rulesPointer, screenGrant).length;
1808
+ const fittedObjective = truncateAtWord(objective, max - overhead - 1);
1809
+ return { line: assembleGoalLine("", fittedObjective, rulesPointer, screenGrant), objective: fittedObjective };
1810
+ }
1811
+ /**
1812
+ * Pure wrapper around {@link fitGoal} for callers that only want the line —
1813
+ * pinned by test/manage-goal-line-fit.test.ts so the shrink order above
1814
+ * cannot drift without a failing test.
1815
+ */
1816
+ export function fitGoalLine(parts, max) {
1817
+ return fitGoal(parts, max).line;
1818
+ }
1706
1819
  /**
1707
1820
  * `until 08:00` — an END, rather than a length.
1708
1821
  *
@@ -1879,7 +1992,16 @@ function screenStatus(m, now = Date.now()) {
1879
1992
  }
1880
1993
  return `screen: granted until ${at(lease.until)}, but ${describeControls(tool, now).replace(/^screen: /, "")} — renewing`;
1881
1994
  }
1882
- /** The text actually typed at the session. Short goal, context by reference. */
1995
+ /**
1996
+ * The text actually typed at the session, and the fragment to look for
1997
+ * afterwards to know it landed.
1998
+ *
1999
+ * Short goal, context by reference — and, since GOAL_LINE_MAX_CHARS, always
2000
+ * a pointer to the rules rather than the rules themselves: inlining a real
2001
+ * standing-rules paragraph cannot fit a 700-char paste-safe budget anyway, so
2002
+ * there is no case left where trying the longer composeGoal() inline form
2003
+ * first would help.
2004
+ */
1883
2005
  function goalText(m) {
1884
2006
  const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
1885
2007
  // The screen rule has to ride along with EVERY arming. Delivered once, it
@@ -1909,12 +2031,13 @@ function goalText(m) {
1909
2031
  // AG2 goes first, ahead of the objective and the standing rules, on every
1910
2032
  // arming — not once at setup. A managed session works unattended for hours
1911
2033
  // and a reminder given only at the start does not survive a compaction or a
1912
- // `/clear`. This is folded into composeGoal's own objective argument, not
1913
- // spliced in afterwards, so the ordering it produces (task, then rules,
1914
- // then right-now context) still holds with AG2 simply riding in front of
1915
- // the task. What a managed session sends the operator, and what it commits
2034
+ // `/clear`. What a managed session sends the operator, and what it commits
1916
2035
  // to git, stays prose either way — AG2 is only how agents talk to agents.
1917
- return composeGoal(`${AG2_SPEC} ${m.objective}`, rules, hands, extra, standingRulesSource());
2036
+ const rulesPointer = rules
2037
+ ? `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.`
2038
+ : "";
2039
+ const fit = fitGoal({ objective: `${m.objective}${extra}`, agentish: AG2_SPEC, rulesPointer, screenGrant: hands }, GOAL_LINE_MAX_CHARS);
2040
+ return { text: fit.line, fragment: fit.objective.slice(0, 40) };
1918
2041
  }
1919
2042
  /**
1920
2043
  * Did it land? Look for the goal's own words in the transcript.
@@ -1941,8 +2064,7 @@ async function sleep(ms) {
1941
2064
  return new Promise((r) => setTimeout(r, ms));
1942
2065
  }
1943
2066
  async function arm(m, reason) {
1944
- const text = goalText(m);
1945
- const fragment = m.objective.slice(0, 40);
2067
+ const { text, fragment } = goalText(m);
1946
2068
  /**
1947
2069
  * NEVER TYPE A GOAL INTO A BARE SHELL.
1948
2070
  *
@@ -2167,6 +2289,12 @@ function beat() {
2167
2289
  */
2168
2290
  const DIALOG_EVERY_TICKS = 3;
2169
2291
  let tickCount = 0;
2292
+ /**
2293
+ * Whether the operator has already been told about the current iTerm
2294
+ * enumeration failure — set once per episode, cleared on recovery, so a
2295
+ * standing fault alerts once rather than every 20s tick.
2296
+ */
2297
+ let enumerationAlarmArmed = false;
2170
2298
  function answerBlockingDialogs() {
2171
2299
  if (Object.keys(state).length === 0)
2172
2300
  return;
@@ -2187,6 +2315,21 @@ async function tick() {
2187
2315
  const now = Date.now();
2188
2316
  let dirty = false;
2189
2317
  beat();
2318
+ // A failed osascript enumeration returns [] identically to a genuinely
2319
+ // empty machine — reported "ok" with 0 sessions is exactly the silent
2320
+ // failure this exists to catch. Reuses the flag every `sessions`/`status`
2321
+ // caller already sets; no separate probe.
2322
+ if (!wasLastEnumerationReliable()) {
2323
+ if (!enumerationAlarmArmed) {
2324
+ enumerationAlarmArmed = true;
2325
+ alertOperator("iTerm session enumeration is failing (AppleScript errors) — the session list may be empty or stale until this clears.");
2326
+ log("[manage] iTerm enumeration unreliable — alerted the operator");
2327
+ }
2328
+ }
2329
+ else if (enumerationAlarmArmed) {
2330
+ enumerationAlarmArmed = false;
2331
+ log("[manage] iTerm enumeration recovered");
2332
+ }
2190
2333
  if (++tickCount % DIALOG_EVERY_TICKS === 0) {
2191
2334
  try {
2192
2335
  answerBlockingDialogs();
@@ -2763,9 +2906,9 @@ async function tick() {
2763
2906
  // an arming can never see it land. Logged at most once every 5 minutes
2764
2907
  // (the skip itself runs every tick regardless) so a session in a long
2765
2908
  // turn does not fill its own history with the same line.
2766
- if (working) {
2909
+ if (working && !paneLooksIdle(content, now - m.lastChangeAt)) {
2767
2910
  if (!m.busyNotedAt || now - m.busyNotedAt >= 5 * 60_000) {
2768
- note(m, `busy — transcript moved <${Math.round(WORKING_RECENT_MS / 1000)}s ago; not arming`);
2911
+ note(m, `busy — transcript moved <${Math.round(WORKING_RECENT_MS / 1000)}s ago and pane not idle; not arming`);
2769
2912
  m.busyNotedAt = now;
2770
2913
  dirty = true;
2771
2914
  }
@@ -2845,6 +2988,23 @@ export function startManagerLoop() {
2845
2988
  }, TICK_MS);
2846
2989
  timer.unref?.();
2847
2990
  }
2991
+ /**
2992
+ * The off path's one job: make the session stop being managed, under every key
2993
+ * it may be filed under.
2994
+ *
2995
+ * managers.json is keyed by sessionId, and a relaunched pane gets a new one —
2996
+ * so `delete state[resolveSession(name)]` removes nothing (or a different,
2997
+ * newer entry) while the stale same-name entry survives and keeps arming the
2998
+ * session the operator just stopped. Returns the stale keys it removed so the
2999
+ * caller can name them in the log.
3000
+ */
3001
+ export function stopManaging(s, sessionId, name) {
3002
+ const stale = Object.keys(s).filter((k) => k !== sessionId && s[k].name === name);
3003
+ delete s[sessionId];
3004
+ for (const k of stale)
3005
+ delete s[k];
3006
+ return stale;
3007
+ }
2848
3008
  /**
2849
3009
  * The whole operator surface, in one call.
2850
3010
  *
@@ -3075,10 +3235,16 @@ export async function handleManage(sessionIdOrName, rawArg) {
3075
3235
  };
3076
3236
  }
3077
3237
  if (word === "off" || word === "stop") {
3078
- if (!existing)
3238
+ // Under a dead pane id the entry is still there, just not under the id
3239
+ // resolveSession returned — "was not being managed" is only true when no
3240
+ // key carries this name at all.
3241
+ const somewhere = !!existing || Object.keys(state).some((k) => state[k].name === name);
3242
+ if (!somewhere)
3079
3243
  return { ok: true, message: `${name} was not being managed`, managed: false };
3080
- delete state[sessionId];
3244
+ const stale = stopManaging(state, sessionId, name);
3081
3245
  saveState(state);
3246
+ if (stale.length)
3247
+ log(`[manage:${name}] removed stale key(s) ${stale.join(", ")} — the pane was relaunched`);
3082
3248
  log(`[manage:${name}] stopped by the operator`);
3083
3249
  return { ok: true, message: `stopped managing ${name}`, managed: false };
3084
3250
  }