mtech-ai-local-runner 0.1.59 → 0.1.61

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/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.1.60: answers go back to the session that asked
2
+
3
+ An answer to a colleague ask now goes first to the session that made the ask, when the asking AI passed its client
4
+ session id (Work Item e1c08b33). Among the sessions an answer names, the runner takes the one listed first, as long
5
+ as a person typed in it in the last 30 minutes.
6
+
1
7
  ## 0.1.59: completed publication acknowledgement
2
8
 
3
9
  Runner 0.1.59 corrects post-publication completion acknowledgement. The Gateway binding primary key is independent of the client session UUID. Recovery uses the saved binding/Job/generation mapping and exact server completion receipt; it does not renew an expired lease or replay a completed publication. Unverified PRs retain `unverified` / `CI not_run` status.
@@ -1,5 +1,5 @@
1
1
  import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
2
- import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, planRoutes, preferredApp, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
2
+ import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
3
3
  import { droppableDeliveryId, parseAskDelivery } from "./types.js";
4
4
  import { renderOutput } from "./templates.js";
5
5
  import { PRESENCE_RECHECK_MS } from "./presence.js";
@@ -292,11 +292,16 @@ export class AskInbox {
292
292
  // Claimed before anything runs, so a delivery is never opened or notified twice.
293
293
  const opening = new Set(plan.open.map(item => item.deliveryId));
294
294
  claimOpen(this.state, [...opening], now);
295
- for (const item of plan.notify)
295
+ for (const item of [...plan.notify, ...plan.held])
296
296
  confirmNotified(this.state, item.deliveryId, now, false);
297
297
  const notify = plan.notify.map(item => item.deliveryId);
298
+ const held = plan.held.flatMap(item => {
299
+ const session = item.sessionKey === null ? undefined : this.state.sessions[item.sessionKey];
300
+ return session === undefined ? [] : [{ deliveryId: item.deliveryId, app: session.app }];
301
+ });
298
302
  return {
299
303
  notify: notify.filter(id => !opening.has(id)),
304
+ held,
300
305
  // The notification of a delivery in the open waits for its outcome, which decides the text.
301
306
  open: opening.size === 0 ? null : { deliveryIds: [...opening], notifyAfter: notify.filter(id => opening.has(id)) },
302
307
  };
@@ -306,6 +311,8 @@ export class AskInbox {
306
311
  await this.persist();
307
312
  if (work.notify.length > 0)
308
313
  this.startNotifying(work.notify);
314
+ for (const { deliveryId, app } of work.held)
315
+ this.startNotifying([deliveryId], { heldIn: app });
309
316
  if (work.open !== null)
310
317
  this.startOpening(work.open);
311
318
  try {
@@ -411,7 +418,7 @@ export class AskInbox {
411
418
  return { status: "skipped" };
412
419
  const kinds = { asks: items.some(item => item.direction === "ask"), answers: items.some(item => item.direction === "answer") };
413
420
  try {
414
- return await this.deps.openSession(preferredApp(this.state), kinds);
421
+ return await this.deps.openSession(openApp(this.state, items), kinds);
415
422
  }
416
423
  catch {
417
424
  return { status: "failed", code: "open_failed" };
@@ -25,6 +25,11 @@ const OPENED_TEXT = {
25
25
  codex: "AI Gateway: your ask has an answer. A Codex session is open to continue.",
26
26
  },
27
27
  };
28
+ /** An answer held in its live asking session (Work Decision 8562d191): no session is opened, the person goes back to it. */
29
+ const HELD_TEXT = {
30
+ claude: "AI Gateway: your ask has an answer. It is waiting in the Claude session that asked. Type there to see it.",
31
+ codex: "AI Gateway: your ask has an answer. It is waiting in the Codex session that asked. Type there to see it.",
32
+ };
28
33
  export const OSASCRIPT = "/usr/bin/osascript";
29
34
  const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title \"AI Gateway\"", "end run"];
30
35
  /**
@@ -34,6 +39,8 @@ const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title
34
39
  */
35
40
  export const NOTIFY_TIMEOUT_MS = 3_000;
36
41
  export function notificationText(item, options = {}) {
42
+ if (options.heldIn !== undefined)
43
+ return HELD_TEXT[options.heldIn];
37
44
  if (options.openedApp !== undefined)
38
45
  return OPENED_TEXT[item.direction][options.openedApp];
39
46
  return (item.direction === "ask" ? ASK_TEXT : ANSWER_TEXT)[item.subjectType];
@@ -59,17 +59,54 @@ export function preferredApp(state) {
59
59
  }
60
60
  return best?.app ?? null;
61
61
  }
62
+ /**
63
+ * The live session on this Mac that made an answer's ask (Work Decision 8562d191): null for an ask, without a hash, or
64
+ * when that session is unknown here or no longer live.
65
+ */
66
+ export function askerSessionKey(item, state, now) {
67
+ if (item.direction !== "answer" || item.askerSessionHash === undefined || item.askerSessionHash === null)
68
+ return null;
69
+ for (const [key, session] of Object.entries(state.sessions)) {
70
+ if (isLive(session, now) && hashSessionId(session.sessionId) === item.askerSessionHash)
71
+ return key;
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * The app an open uses: for answers only, the app of the first one whose asking session this Mac still knows, live or
77
+ * ended, so the new session opens where the person asked; otherwise preferredApp.
78
+ */
79
+ export function openApp(state, items) {
80
+ if (items.length > 0 && items.every(item => item.direction === "answer")) {
81
+ for (const item of items) {
82
+ if (item.askerSessionHash === undefined || item.askerSessionHash === null)
83
+ continue;
84
+ const asking = Object.values(state.sessions).find(session => hashSessionId(session.sessionId) === item.askerSessionHash);
85
+ if (asking !== undefined)
86
+ return asking.app;
87
+ }
88
+ }
89
+ return preferredApp(state);
90
+ }
62
91
  /**
63
92
  * Spec section 5: a live session the delivery names, else the live session with the most recent human prompt. A named
64
93
  * session counts only with a human prompt in the last 30 minutes: named sessions stay live until closed, and an older
65
- * one would otherwise keep the delivery from the session the person is using (2026-10-26 Test B).
94
+ * one would otherwise keep the delivery from the session the person is using (2026-10-26 Test B). Among named sessions
95
+ * the one listed first wins: the Gateway lists the session that made the ask first (2026-10-26 test C). An answer whose
96
+ * asking session is live goes there, however long ago it was typed in (Work Decision 8562d191).
66
97
  */
67
98
  export function chooseSession(item, state, now) {
99
+ const asking = askerSessionKey(item, state, now);
100
+ if (asking !== null)
101
+ return asking;
68
102
  const live = Object.entries(state.sessions).filter(([, session]) => isLive(session, now));
69
- const targets = new Set(item.targetSessionHashes);
70
- const named = live.filter(([, session]) => targets.has(hashSessionId(session.sessionId)) && humanPromptWithin(session, now));
103
+ const order = new Map(item.targetSessionHashes.map((hash, index) => [hash, index]));
104
+ const rank = (session) => order.get(hashSessionId(session.sessionId)) ?? order.size;
105
+ const named = live.filter(([, session]) => rank(session) < order.size && humanPromptWithin(session, now));
71
106
  const pool = named.length > 0 ? named : live;
72
- pool.sort(([keyA, a], [keyB, b]) => (b.lastPromptAt ?? -1) - (a.lastPromptAt ?? -1) || b.lastEventAt - a.lastEventAt || keyA.localeCompare(keyB));
107
+ const byOrder = named.length > 0 ? rank : () => 0;
108
+ pool.sort(([keyA, a], [keyB, b]) => byOrder(a) - byOrder(b) || (b.lastPromptAt ?? -1) - (a.lastPromptAt ?? -1)
109
+ || b.lastEventAt - a.lastEventAt || keyA.localeCompare(keyB));
73
110
  return pool[0]?.[0] ?? null;
74
111
  }
75
112
  /**
@@ -80,17 +117,22 @@ export function chooseSession(item, state, now) {
80
117
  * becomes the chosen one by that prompt (see promptSubmitted);
81
118
  * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
82
119
  * - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
120
+ * An answer whose asking session is live is pinned or woken there by the same rules, and otherwise held there: it waits
121
+ * for that session, with a notification that says so, and no session is opened for it (Work Decision 8562d191).
83
122
  */
84
123
  export function routeItem(item, state, now, watching) {
85
- const key = chooseSession(item, state, now);
124
+ const asking = askerSessionKey(item, state, now);
125
+ const key = asking ?? chooseSession(item, state, now);
86
126
  const session = key === null ? undefined : state.sessions[key];
87
- if (key === null || session === undefined || !humanPromptWithin(session, now))
127
+ if (key === null || session === undefined)
128
+ return { action: "notify" };
129
+ if (asking === null && !humanPromptWithin(session, now))
88
130
  return { action: "notify" };
89
131
  if (turnInProgress(session))
90
132
  return { action: "pin", sessionKey: key };
91
- if (session.app === "claude" && watching.has(key) && wakesLeft(session, now) > 0)
133
+ if (session.app === "claude" && humanPromptWithin(session, now) && watching.has(key) && wakesLeft(session, now) > 0)
92
134
  return { action: "wake", sessionKey: key };
93
- return { action: "notify" };
135
+ return asking === null ? { action: "notify" } : { action: "hold", sessionKey: key };
94
136
  }
95
137
  /** At most 5 spelled out, oldest first; the rest are summarized by one "and N more" line. */
96
138
  export function selectForOutput(items) {
@@ -259,6 +301,7 @@ export function sessionEnded(state, context) {
259
301
  export function planRoutes(state, now, watching, options = {}) {
260
302
  const wakeGroups = new Map();
261
303
  const notify = [];
304
+ const held = [];
262
305
  const unseen = [];
263
306
  for (const item of itemsWhere(state, item => item.phase === "waiting" || item.phase === "pinned")) {
264
307
  const decision = routeItem(item, state, now, watching);
@@ -272,12 +315,14 @@ export function planRoutes(state, now, watching, options = {}) {
272
315
  }
273
316
  item.phase = "pinned";
274
317
  item.sessionKey = decision.sessionKey;
318
+ if (decision.action === "hold" && item.notifiedAt === null)
319
+ held.push(item);
275
320
  if (decision.action === "wake")
276
321
  wakeGroups.set(decision.sessionKey, [...wakeGroups.get(decision.sessionKey) ?? [], item]);
277
322
  }
278
323
  const openable = unseen.filter(item => item.openedAt === null);
279
324
  const open = options.canOpen === true && openable.length > 0 && !openPending(state, now) && opensLeft(state, now) > 0 ? openable : [];
280
- return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, open };
325
+ return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, held, open };
281
326
  }
282
327
  /** The hub delivered a wake to the session's watcher: everything in it counts as shown there. */
283
328
  export function confirmWake(state, key, output, now) {
@@ -6,6 +6,7 @@ import { installedApps } from "./integrations.js";
6
6
  import { createNotifier } from "./notifier.js";
7
7
  import { createPresenceReader } from "./presence.js";
8
8
  import { createSessionOpener } from "./opener.js";
9
+ import { ASK_INBOX_VERSION } from "./types.js";
9
10
  /*
10
11
  * AI-first ask delivery inside the daemon (Work Item e1c08b33). It rides on the existing session-open loop:
11
12
  * no new long-poll. The hub runs for as long as that loop runs, deliveries from each wait are stored and
@@ -152,7 +153,7 @@ export class AskRuntime {
152
153
  catch {
153
154
  apps = [];
154
155
  }
155
- return apps.length > 0 ? { version: 1, apps: [...apps] } : undefined;
156
+ return apps.length > 0 ? { version: ASK_INBOX_VERSION, apps: [...apps] } : undefined;
156
157
  }
157
158
  /** Resolves when the start settles or the bound passes, whichever comes first. Never rejects: start() never throws. */
158
159
  async waitForStart(starting) {
@@ -79,6 +79,10 @@ function normalizeItem(key, value) {
79
79
  // Missing in a file written before opens existed: never opened.
80
80
  if (v.openedAt !== undefined && !isTimeOrNull(v.openedAt))
81
81
  return null;
82
+ // Missing in a file written before 0.1.61 or from a seven-key delivery: no asking session known.
83
+ if (v.askerSessionHash !== undefined && v.askerSessionHash !== null
84
+ && (typeof v.askerSessionHash !== "string" || !SESSION_HASH.test(v.askerSessionHash)))
85
+ return null;
82
86
  const outbox = v.outbox.map(normalizeReport);
83
87
  if (outbox.some(report => report === null))
84
88
  return null;
@@ -86,7 +90,8 @@ function normalizeItem(key, value) {
86
90
  targetSessionHashes: [...v.targetSessionHashes], createdAt: v.createdAt, receivedAt: v.receivedAt,
87
91
  phase: settlePhase(v.phase, v.sessionKey),
88
92
  sessionKey: v.sessionKey, shownVia: v.shownVia, shownAt: v.shownAt, firstShownVia: v.firstShownVia,
89
- notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox };
93
+ notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox,
94
+ ...(v.askerSessionHash === undefined ? {} : { askerSessionHash: v.askerSessionHash }) };
90
95
  }
91
96
  function normalizeSession(key, value) {
92
97
  if (value === null || typeof value !== "object")
@@ -9,6 +9,11 @@ export const SUBJECT_TYPES = ["decision", "review", "request"];
9
9
  export const DIRECTIONS = ["ask", "answer"];
10
10
  export const VIAS = ["prompt", "session_start", "stop", "rewake", "notification"];
11
11
  export const REPORT_EVENTS = ["received", "shown", "seen", "dropped"];
12
+ /**
13
+ * heartbeat runner.session_open.ask_inbox: the integrations actually installed on this Mac. Version 2 says the runner
14
+ * reads asker_session_hash on deliveries (Work Decision 8562d191); it needs a Gateway that accepts version 2.
15
+ */
16
+ export const ASK_INBOX_VERSION = 2;
12
17
  /** Gateway ids are lowercase UUIDs (contract: subject keys must be lowercase UUIDs). */
13
18
  export const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
19
  /** sha256 of a client session id, as the Gateway stores external_session_id_hash. */
@@ -22,8 +27,10 @@ export const OPEN_ASKS_LEAD = "AI Gateway: colleagues' AIs are waiting for you."
22
27
  export const OPEN_ANSWERS_LEAD = "AI Gateway: your asks have answers.";
23
28
  /** The most target session hashes one delivery may carry: a longer list is refused on the wire and dropped on load. */
24
29
  export const MAX_TARGET_HASHES = 50;
25
- // Sorted, so they compare directly with the sorted keys of a wire item.
30
+ // Sorted, so they compare directly with the sorted keys of a wire item. A Gateway that knows this runner reads
31
+ // ask_inbox version 2 adds asker_session_hash; an older one sends the seven keys only.
26
32
  const DELIVERY_FIELDS = ["created_at", "delivery_id", "direction", "subject_key", "subject_type", "target_session_hashes", "work_item_id"];
33
+ const DELIVERY_FIELDS_WITH_ASKER = ["asker_session_hash", ...DELIVERY_FIELDS];
27
34
  function oneOf(values, value) {
28
35
  return typeof value === "string" && values.includes(value);
29
36
  }
@@ -39,9 +46,13 @@ export function parseAskDelivery(input) {
39
46
  return null;
40
47
  const record = input;
41
48
  const keys = Object.keys(record).sort();
42
- if (keys.length !== DELIVERY_FIELDS.length || keys.some((key, index) => key !== DELIVERY_FIELDS[index]))
49
+ const withAsker = keys.includes("asker_session_hash");
50
+ const fields = withAsker ? DELIVERY_FIELDS_WITH_ASKER : DELIVERY_FIELDS;
51
+ if (keys.length !== fields.length || keys.some((key, index) => key !== fields[index]))
52
+ return null;
53
+ const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt, asker_session_hash: asker } = record;
54
+ if (withAsker && asker !== null && (typeof asker !== "string" || !SESSION_HASH.test(asker)))
43
55
  return null;
44
- const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt } = record;
45
56
  if (typeof deliveryId !== "string" || !LOWER_UUID.test(deliveryId))
46
57
  return null;
47
58
  if (!isSubjectType(subjectType) || !isDirection(direction))
@@ -56,7 +67,8 @@ export function parseAskDelivery(input) {
56
67
  return null;
57
68
  if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0)
58
69
  return null;
59
- return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt };
70
+ return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt,
71
+ ...(withAsker ? { askerSessionHash: asker } : {}) };
60
72
  }
61
73
  /** The id of a wire item that failed validation, when the id itself is valid, so it can be reported as dropped. */
62
74
  export function droppableDeliveryId(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {