mtech-ai-local-runner 0.1.105 → 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")] }],
@@ -7,7 +7,7 @@ import { LOWER_UUID } from "./types.js";
7
7
  * Every function here works on the in-memory AskState and does no I/O, so the rules are tested directly.
8
8
  * The AI's own actions never count as seen: only hook events the runner observes move a delivery forward.
9
9
  */
10
- /** 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. */
11
11
  export const WAKE_WINDOW_MS = 30 * 60 * 1000;
12
12
  /** At most 6 wakes per session per hour (Claude asyncRewake exits and Codex Stop continuations). */
13
13
  export const WAKE_LIMIT = 6;
@@ -123,6 +123,8 @@ export function chooseSession(item, state, now) {
123
123
  * becomes the chosen one by that prompt (see promptSubmitted);
124
124
  * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
125
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.
126
128
  * An answer whose asking session is live is pinned or woken there by the same rules, and otherwise held there: it waits
127
129
  * for that session, with a notification that says so, and no session is opened for it (Work Decision 8562d191).
128
130
  */
@@ -132,11 +134,12 @@ export function routeItem(item, state, now, watching) {
132
134
  const session = key === null ? undefined : state.sessions[key];
133
135
  if (key === null || session === undefined)
134
136
  return { action: "notify" };
135
- if (asking === null && !humanPromptWithin(session, now))
137
+ const approvedInvestigation = item.subjectType === "investigation" && item.routingPolicy === "exact";
138
+ if (asking === null && !approvedInvestigation && !humanPromptWithin(session, now))
136
139
  return { action: "notify" };
137
140
  if (turnInProgress(session))
138
141
  return { action: "pin", sessionKey: key };
139
- 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)
140
143
  return { action: "wake", sessionKey: key };
141
144
  return asking === null ? { action: "notify" } : { action: "hold", sessionKey: key };
142
145
  }
@@ -225,7 +228,10 @@ export function closeDeliveries(state, deliveryIds) {
225
228
  /** SessionStart: register the session and show what waits for any session (seen at its next prompt). */
226
229
  export function sessionStarted(state, context) {
227
230
  const { key } = touch(state, context);
228
- 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")));
229
235
  for (const item of everyItem(output))
230
236
  markShown(item, key, context.app, "session_start", context.now);
231
237
  return output;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.105",
3
+ "version": "0.1.106",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {