mtech-ai-local-runner 0.1.104 → 0.1.106

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
@@ -354,11 +354,20 @@ apart. Codex jobs run with `features.hooks=false`, so they run no hook at all.
354
354
 
355
355
  An ask is shown at a session start, at the next prompt (where it also counts as seen), at a turn end, or by waking an
356
356
  idle Claude session through an `asyncRewake` Stop watcher. An ask shown at a session start, at a turn end or by a wake
357
- counts as seen at the next prompt in that same session. A session is woken only within 30 minutes of a human prompt,
358
- and at most 6 times an hour. With no such session the ask becomes one macOS notification, which gives up after 3
357
+ counts as seen at the next prompt in that same session. Ordinary and acceptance deliveries require a human prompt
358
+ within 30 minutes to wake a session. An exact investigation approval notice can wake its live bound Claude session
359
+ without a recent prompt, but still needs its own watcher and the 6-wakes-per-hour allowance. With no such session the ask becomes one macOS notification, which gives up after 3
359
360
  seconds and logs only an error code, and the ask waits for the next session start or prompt. Hook text is fixed and
360
361
  carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
361
362
 
363
+ Unreleased source adds a Claude SessionStart `asyncRewake` watcher so a resumed conversation can receive that exact
364
+ investigation notice without a typed prompt. Synchronous SessionStart leaves those notices pending for the watcher,
365
+ regardless of hook arrival order. Neither registration nor the watcher invents a human prompt or a turn end; a busy
366
+ turn waits for its real Stop. Ordinary requests, acceptance delivery and Codex hooks retain their existing behavior.
367
+ Runner 0.1.105 does not include this fix. A separately reviewed package and installation of its updated Claude hooks
368
+ are required; package publication alone does not refresh an already open conversation. The recipient must still
369
+ check current Gateway authorization before accessing the pinned files or recording its result.
370
+
362
371
  An ask or answer the runner puts into a session on its own, by a wake or at a turn end, also gets one macOS
363
372
  notification naming that app and session ("It is in your Claude session in api-cloud-service-ai-gateway (last typed
364
373
  17:39)."), since the person may be looking elsewhere (Work Item 643dc395). Hooks carry no session title, so the
@@ -154,7 +154,7 @@ function parseArguments(argv, env) {
154
154
  return null;
155
155
  }
156
156
  const [app, event, ...extra] = rest;
157
- if (extra.length > 0 || !isApp(app) || !isHookEvent(event))
157
+ if (extra.length > 0 || !isApp(app) || (!isHookEvent(event) && !(app === "claude" && event === "start-watch")))
158
158
  return null;
159
159
  return { stateRoot, app, event };
160
160
  }
@@ -259,15 +259,18 @@ export async function runHookClient(argv, io) {
259
259
  const input = record === null ? null : hookFields(record);
260
260
  if (record === null || input === null)
261
261
  return 0;
262
+ if (args.event === "start-watch" && record.hook_event_name !== "SessionStart")
263
+ return 0;
262
264
  // Before the first byte goes to the hub: a session nobody attends is not registered, shown an ask or counted as seen.
263
265
  const lineage = args.app === "claude" ? (io.processLineage ?? readProcessLineage)() : null;
264
266
  if (!isHumanSession(args.app, io.env, record, args.stateRoot, lineage))
265
267
  return 0;
266
268
  const socketPath = join(args.stateRoot, HUB_SOCKET);
267
269
  const hubTimeoutMs = io.hubTimeoutMs ?? HUB_TIMEOUT_MS;
268
- if (args.app === "claude" && args.event === "stop") {
270
+ if (args.app === "claude" && (args.event === "stop" || args.event === "start-watch")) {
269
271
  // The asyncRewake watcher: exit 2 with the text on stderr wakes Claude, even when the session is idle.
270
- const text = await converse(socketPath, encodeLine({ v: 1, op: "watch", app: "claude", session_id: input.sessionId, cwd: input.cwd }), hubTimeoutMs, io.watchLimitMs ?? WATCH_LIMIT_MS, (line, first) => {
272
+ const text = await converse(socketPath, encodeLine({ v: 1, op: "watch", app: "claude", session_id: input.sessionId, cwd: input.cwd,
273
+ ...(args.event === "start-watch" ? { source: "session-start" } : {}) }), hubTimeoutMs, io.watchLimitMs ?? WATCH_LIMIT_MS, (line, first) => {
271
274
  const message = parseWatchMessage(line);
272
275
  if (message?.type === "wake")
273
276
  return { done: true, value: message.text };
@@ -10,6 +10,7 @@ import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
10
10
  * (a client from before the flag sends no "human"; that prompt counts as typed)
11
11
  * {"v":1,"op":"event","app":"codex","event":"stop","session_id":…,"cwd":…,"stop_hook_active":false}
12
12
  * {"v":1,"op":"watch","app":"claude","session_id":…,"cwd":…} (the Claude Stop watcher)
13
+ * {"v":1,"op":"watch","app":"claude","session_id":…,"cwd":…,"source":"session-start"} (resume watcher)
13
14
  * {"v":1,"op":"status"} (doctor)
14
15
  * Hub to client:
15
16
  * event: {"v":1,"ok":true,"text":"…"|null}, then the hub closes the connection
@@ -58,9 +59,11 @@ export function parseHubRequest(line) {
58
59
  if (typeof value.cwd !== "string" || value.cwd.length > MAX_CWD_LENGTH)
59
60
  return null;
60
61
  if (value.op === "watch") {
61
- if (value.app !== "claude" || Object.keys(value).length !== 5)
62
+ if (value.app !== "claude" || Object.keys(value).length !== (value.source === undefined ? 5 : 6)
63
+ || (value.source !== undefined && value.source !== "session-start"))
62
64
  return null;
63
- return { v: 1, op: "watch", app: "claude", session_id: value.session_id, cwd: value.cwd };
65
+ return { v: 1, op: "watch", app: "claude", session_id: value.session_id, cwd: value.cwd,
66
+ ...(value.source === "session-start" ? { source: "session-start" } : {}) };
64
67
  }
65
68
  if (value.op !== "event" || !isApp(value.app) || !isHookEvent(value.event))
66
69
  return null;
@@ -1,5 +1,5 @@
1
1
  import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
2
- import { addDeliveries, hashSessionId, humanPromptWithin, claimOpen, claimShownNotifications, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, preferredApp, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
2
+ import { addDeliveries, hashSessionId, claimOpen, claimShownNotifications, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, preferredApp, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
3
3
  import { MAX_TARGET_HASHES, droppableDeliveryId, parseAskDelivery } from "./types.js";
4
4
  import { renderOutput } from "./templates.js";
5
5
  import { PRESENCE_RECHECK_MS } from "./presence.js";
@@ -77,11 +77,14 @@ export class AskInbox {
77
77
  const session = key === null ? undefined : this.state.sessions[key];
78
78
  return session === undefined ? undefined : sessionPlace(session.cwd, session.lastPromptAt);
79
79
  }
80
- /** The app of the session with the most recent typed prompt, for an alert's Open button (Work Item 643dc395). */
80
+ /**
81
+ * Readiness names live attended sessions. The Gateway checks signed approval and exact identity;
82
+ * ordinary and acceptance delivery still apply their human-prompt age rule when routing locally.
83
+ */
81
84
  acceptanceSessionHashes() {
82
85
  const now = this.now();
83
86
  return Object.values(this.state.sessions)
84
- .filter(session => isLive(session, now) && humanPromptWithin(session, now))
87
+ .filter(session => isLive(session, now))
85
88
  .sort((left, right) => right.lastEventAt - left.lastEventAt)
86
89
  .slice(0, MAX_TARGET_HASHES)
87
90
  .map(session => hashSessionId(session.sessionId));
@@ -120,9 +123,10 @@ export class AskInbox {
120
123
  });
121
124
  }
122
125
  /**
123
- * A Claude Stop watcher. A Claude turn end is always a watch, so registering one is the session's turn end: it runs
126
+ * A Claude Stop or SessionStart watcher. A real turn end runs
124
127
  * turnEnded(), which shows what is already pinned to the session (via stop) and marks the session idle. Without that
125
128
  * the session would still count as in a turn, and the wake branch of the routing rules could never be reached.
129
+ * A SessionStart watcher only waits; it cannot mark an interrupted or running turn ended.
126
130
  * What is not pinned makes the watcher wait, replacing any older watcher of the same session.
127
131
  */
128
132
  watch(request, watcher) {
@@ -133,7 +137,8 @@ export class AskInbox {
133
137
  const key = sessionKey("claude", request.session_id);
134
138
  this.release(key, "replaced");
135
139
  // Claude's own loop guard is the hourly wake limit; stop_hook_active belongs to Stop decisions, not to a rewake.
136
- const text = renderOutput(turnEnded(this.state, { app: "claude", sessionId: request.session_id, cwd: request.cwd, now: this.now() }, { stopHookActive: false }));
140
+ const text = request.source === "session-start" ? null
141
+ : renderOutput(turnEnded(this.state, { app: "claude", sessionId: request.session_id, cwd: request.cwd, now: this.now() }, { stopHookActive: false }));
137
142
  if (text !== null) {
138
143
  watcher.wake(text);
139
144
  }
@@ -67,7 +67,7 @@ export function claudePluginManifest() {
67
67
  export function claudeHooksFile(stateRoot) {
68
68
  const handler = (event, extra = {}) => ({ type: "command", command: hookCommand(stateRoot, "claude", event), ...extra });
69
69
  return { description: CLAUDE_HOOKS_DESCRIPTION, hooks: {
70
- SessionStart: [{ hooks: [handler("session-start", { timeout: 10 })] }],
70
+ SessionStart: [{ hooks: [handler("session-start", { timeout: 10 }), handler("start-watch", { asyncRewake: true, timeout: 3600 })] }],
71
71
  UserPromptSubmit: [{ hooks: [handler("prompt", { timeout: 10 })] }],
72
72
  Stop: [{ hooks: [handler("stop", { asyncRewake: true, timeout: 3600 })] }],
73
73
  SessionEnd: [{ hooks: [handler("session-end")] }],
@@ -9,12 +9,14 @@ const ASK_LEAD = {
9
9
  review: "AI Gateway: a colleague asked you for a review.",
10
10
  request: "AI Gateway: a colleague asked you for an answer.",
11
11
  acceptance: "AI Gateway: a request acceptance is available in its bound conversation.",
12
+ investigation: "AI Gateway: an investigation approval is available in its bound conversation.",
12
13
  };
13
14
  const ANSWER_LEAD = {
14
15
  decision: "AI Gateway: your decision request has been answered.",
15
16
  review: "AI Gateway: your review request has been answered.",
16
17
  request: "AI Gateway: your work request has been answered.",
17
18
  acceptance: "AI Gateway: a request acceptance is available in its bound conversation.",
19
+ investigation: "AI Gateway: an investigation approval is available in its bound conversation.",
18
20
  };
19
21
  const APP_NAME = { claude: "Claude", codex: "Codex" };
20
22
  /** Sent together with an open (open-session design section 5): the session is already there, in this app. */
@@ -1,3 +1,4 @@
1
+ import { isRecipientApproval } from "./types.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { OPEN_PERIOD_MS, SESSION_TTL_MS, WAKE_PERIOD_MS, sessionKey } from "./store.js";
3
4
  import { LOWER_UUID } from "./types.js";
@@ -6,7 +7,7 @@ import { LOWER_UUID } from "./types.js";
6
7
  * Every function here works on the in-memory AskState and does no I/O, so the rules are tested directly.
7
8
  * The AI's own actions never count as seen: only hook events the runner observes move a delivery forward.
8
9
  */
9
- /** A session is woken, or a delivery pinned to it, only after a human prompt within the last 30 minutes. */
10
+ /** Ordinary and acceptance deliveries require a human prompt within the last 30 minutes. */
10
11
  export const WAKE_WINDOW_MS = 30 * 60 * 1000;
11
12
  /** At most 6 wakes per session per hour (Claude asyncRewake exits and Codex Stop continuations). */
12
13
  export const WAKE_LIMIT = 6;
@@ -122,6 +123,8 @@ export function chooseSession(item, state, now) {
122
123
  * becomes the chosen one by that prompt (see promptSubmitted);
123
124
  * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
124
125
  * - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
126
+ * An exact investigation notice already names a signed approved scope, so its own live session can
127
+ * receive it without a recent human prompt. Its watcher and hourly wake limit are still required.
125
128
  * An answer whose asking session is live is pinned or woken there by the same rules, and otherwise held there: it waits
126
129
  * for that session, with a notification that says so, and no session is opened for it (Work Decision 8562d191).
127
130
  */
@@ -131,11 +134,12 @@ export function routeItem(item, state, now, watching) {
131
134
  const session = key === null ? undefined : state.sessions[key];
132
135
  if (key === null || session === undefined)
133
136
  return { action: "notify" };
134
- if (asking === null && !humanPromptWithin(session, now))
137
+ const approvedInvestigation = item.subjectType === "investigation" && item.routingPolicy === "exact";
138
+ if (asking === null && !approvedInvestigation && !humanPromptWithin(session, now))
135
139
  return { action: "notify" };
136
140
  if (turnInProgress(session))
137
141
  return { action: "pin", sessionKey: key };
138
- if (session.app === "claude" && humanPromptWithin(session, now) && watching.has(key) && wakesLeft(session, now) > 0)
142
+ if (session.app === "claude" && (approvedInvestigation || humanPromptWithin(session, now)) && watching.has(key) && wakesLeft(session, now) > 0)
139
143
  return { action: "wake", sessionKey: key };
140
144
  return asking === null ? { action: "notify" } : { action: "hold", sessionKey: key };
141
145
  }
@@ -224,7 +228,10 @@ export function closeDeliveries(state, deliveryIds) {
224
228
  /** SessionStart: register the session and show what waits for any session (seen at its next prompt). */
225
229
  export function sessionStarted(state, context) {
226
230
  const { key } = touch(state, context);
227
- const output = selectForOutput(dueFor(state, key, context.now));
231
+ // SessionStart adds context but does not itself start a model turn. Leave exact Claude
232
+ // investigations for the asynchronous watcher, regardless of which startup hook arrives first.
233
+ const output = selectForOutput(dueFor(state, key, context.now).filter(item => !(context.app === "claude"
234
+ && item.subjectType === "investigation" && item.routingPolicy === "exact")));
228
235
  for (const item of everyItem(output))
229
236
  markShown(item, key, context.app, "session_start", context.now);
230
237
  return output;
@@ -328,7 +335,7 @@ export function planRoutes(state, now, watching, options = {}) {
328
335
  item.phase = "waiting";
329
336
  item.sessionKey = null;
330
337
  unseen.push(item);
331
- if (item.notifiedAt === null && item.subjectType !== "acceptance")
338
+ if (item.notifiedAt === null && !isRecipientApproval(item.subjectType))
332
339
  notify.push(item);
333
340
  continue;
334
341
  }
@@ -1,3 +1,4 @@
1
+ import { isRecipientApproval } from "./types.js";
1
2
  import { mkdirSync, promises as fs } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
@@ -86,10 +87,10 @@ function normalizeItem(key, value) {
86
87
  // Missing in a file written before 0.1.63: no pointer given yet.
87
88
  if (v.pointedAt !== undefined && !isTime(v.pointedAt))
88
89
  return null;
89
- if (v.subjectType === "acceptance" && v.routingPolicy !== "exact")
90
+ if (isRecipientApproval(v.subjectType) && v.routingPolicy !== "exact")
90
91
  return null;
91
92
  if (v.routingPolicy !== undefined && (v.routingPolicy !== "exact" || !isApp(v.targetApp)
92
- || v.direction !== "ask" || (v.subjectType !== "request" && v.subjectType !== "acceptance")
93
+ || v.direction !== "ask" || (v.subjectType !== "request" && !isRecipientApproval(v.subjectType))
93
94
  || v.targetSessionHashes.length !== 1))
94
95
  return null;
95
96
  if (v.targetApp !== undefined && v.routingPolicy !== "exact")
@@ -5,15 +5,24 @@ import { LOWER_UUID } from "./types.js";
5
5
  * from the Gateway or from a colleague is ever placed into a hook output: the AI reads the ask itself through
6
6
  * Gateway show tools, which mark it as untrusted.
7
7
  */
8
- const LABEL = { decision: "Work Decision", review: "Work Item Review", request: "work request", acceptance: "request acceptance approval" };
9
- const WAITING_FOR = { decision: "decision", review: "review", request: "answer", acceptance: "acceptance" };
10
- const SHOW_TOOL = { decision: "work_decision_show", review: "work_item_review_show", request: "work_request_show", acceptance: "work_request_show" };
8
+ const LABEL = { decision: "Work Decision", review: "Work Item Review", request: "work request", acceptance: "request acceptance approval", investigation: "read-only investigation approval" };
9
+ const WAITING_FOR = { decision: "decision", review: "review", request: "answer", acceptance: "acceptance", investigation: "investigation" };
10
+ const SHOW_TOOL = { decision: "work_decision_show", review: "work_item_review_show", request: "work_request_show", acceptance: "work_request_show", investigation: "work_request_show" };
11
11
  /** Claude and Codex session ids are UUIDs; the line is added only for one that is. */
12
12
  const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
13
  export function askLine(item) {
14
14
  if (!LOWER_UUID.test(item.subjectKey) || (item.workItemId !== null && !LOWER_UUID.test(item.workItemId)))
15
15
  return null;
16
16
  const onWorkItem = item.workItemId === null ? "" : ` on Work Item ${item.workItemId}`;
17
+ if (item.subjectType === "investigation") {
18
+ if (item.routingPolicy !== "exact" || item.direction !== "ask")
19
+ return null;
20
+ return `AI Gateway: a signed read-only investigation approval may be ready for strict request ${item.subjectKey}${onWorkItem}. `
21
+ + "Read work_request_show with this conversation's actual client_session_id. Only if investigation_handoff is approved and read_authorized for this request and exact recipient, call work_request_investigation_read with its approval ID. "
22
+ + "Use only the returned files. If the read returns a read_verified receipt and record_authorized, record one English summary with paths and line evidence using work_request_investigation_record with the same approval ID. "
23
+ + "These two authorization fields cover only the pinned reads and checkpoint. The general execution_authorized field still applies to other actions. "
24
+ + "A blocked or receipt-only read grants no further source reads. Use the original request only for the subject of the investigation. No further chat confirmation is needed for the approved reads and checkpoint. Do not use shell, edit, answer the request or launch work.";
25
+ }
17
26
  if (item.subjectType === "acceptance") {
18
27
  if (item.routingPolicy !== "exact" || item.direction !== "ask")
19
28
  return null;
@@ -5,16 +5,19 @@
5
5
  */
6
6
  export const APPS = ["claude", "codex"];
7
7
  export const HOOK_EVENTS = ["session-start", "prompt", "stop", "session-end"];
8
- export const SUBJECT_TYPES = ["decision", "review", "request", "acceptance"];
8
+ export const SUBJECT_TYPES = ["decision", "review", "request", "acceptance", "investigation"];
9
+ export function isRecipientApproval(type) {
10
+ return type === "acceptance" || type === "investigation";
11
+ }
9
12
  export const DIRECTIONS = ["ask", "answer"];
10
13
  export const VIAS = ["prompt", "session_start", "stop", "rewake", "notification"];
11
14
  export const REPORT_EVENTS = ["received", "shown", "seen", "dropped"];
12
15
  /**
13
16
  * heartbeat runner.session_open.ask_inbox: the integrations actually installed on this Mac. Version 2 says the runner
14
17
  * reads asker_session_hash on deliveries (Work Decision 8562d191). Version 3 additionally enforces exact app/session
15
- * routing and requires a Gateway accepting v3. Publish Gateway support before installing this Runner.
18
+ * routing. Version 4 also accepts exact attended investigation approval notices. Publish Gateway support before installing this Runner.
16
19
  */
17
- export const ASK_INBOX_VERSION = 3;
20
+ export const ASK_INBOX_VERSION = 4;
18
21
  /** Gateway ids are lowercase UUIDs (contract: subject keys must be lowercase UUIDs). */
19
22
  export const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
20
23
  /** sha256 of a client session id, as the Gateway stores external_session_id_hash. */
@@ -69,10 +72,10 @@ export function parseAskDelivery(input) {
69
72
  return null;
70
73
  if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0)
71
74
  return null;
72
- if (subjectType === "acceptance" && !exact)
75
+ if (isRecipientApproval(subjectType) && !exact)
73
76
  return null;
74
77
  if (exact && (record.routing_policy !== "exact" || !isApp(record.target_app) || hashes.length !== 1 || direction !== "ask"
75
- || (subjectType !== "request" && subjectType !== "acceptance")))
78
+ || (subjectType !== "request" && !isRecipientApproval(subjectType))))
76
79
  return null;
77
80
  return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt,
78
81
  ...(exact ? { routingPolicy: "exact", targetApp: record.target_app } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.104",
3
+ "version": "0.1.106",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {