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 +6 -0
- package/dist/src/asks/inbox.js +10 -3
- package/dist/src/asks/notifier.js +7 -0
- package/dist/src/asks/routing.js +54 -9
- package/dist/src/asks/runtime.js +2 -1
- package/dist/src/asks/store.js +6 -1
- package/dist/src/asks/types.js +16 -4
- package/package.json +1 -1
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.
|
package/dist/src/asks/inbox.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
|
|
2
|
-
import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive,
|
|
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(
|
|
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];
|
package/dist/src/asks/routing.js
CHANGED
|
@@ -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
|
|
70
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|
package/dist/src/asks/runtime.js
CHANGED
|
@@ -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:
|
|
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) {
|
package/dist/src/asks/store.js
CHANGED
|
@@ -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")
|
package/dist/src/asks/types.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|