mtech-ai-local-runner 0.1.54 → 0.1.56

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,48 @@
1
+ ## 0.1.56: AI-first asks count only prompts a person typed
2
+
3
+ Claude Code runs UserPromptSubmit for turns nobody typed as well: an asyncRewake wake, a task notification, a message
4
+ from another session, a side session's brief (Work Item e1c08b33, Work Decision cc8817f9). The ask hook client now
5
+ classifies each prompt on this Mac and tells the hub only whether a person typed it, never the text.
6
+ - A prompt nobody typed marks the session as in a turn and does nothing else: it does not show an ask, does not count one
7
+ as seen and does not make the session the one asks go to.
8
+ - A typed prompt within 10 s of a wake or a stop show does not count that ask as seen.
9
+ - An ask pinned to a session in a turn is shown at the next typed prompt in any session that becomes its chosen session
10
+ (PR #421), or at that session's turn end.
11
+
12
+ The hub still accepts the prompt events of an older hook client and counts them as typed. `asks.json` gains an optional
13
+ `lastTurnStartAt` per session. A session that was already open before the integration was installed has no hooks: start
14
+ a new session, or run `/reload-plugins` in the terminal.
15
+
16
+ ## 0.1.55: guide hooks that steer AI sessions to the AI Gateway (disabled)
17
+
18
+ The guide integration (Work Item d38d60f9) adds two hooks to Claude Code and Codex. Install it only on request:
19
+
20
+ npx mtech-ai-local-runner integrations install --feature guide
21
+ npx mtech-ai-local-runner setup --integrations claude,codex --feature asks,guide
22
+
23
+ **Where it is installed**
24
+ - Claude Code: a second skills-directory plugin, `mtec-ai-gateway-guide`.
25
+ - Codex: two handlers in `~/.codex/hooks.json`. Trust them once, in the Codex app under Settings → Hooks or with `/hooks`
26
+ in the Codex CLI.
27
+ - Both run the stable shim `<state root>/bin/gateway-guide-hook`.
28
+
29
+ **What the hooks do**
30
+ - They act only in sessions a person attends. The session must be in a company clone (origin on bitbucket.org in a
31
+ workspace the Gateway lists) or in a folder of such clones.
32
+ - They remind the session to call `workspace_getting_started` and to search with `knowledge_search`.
33
+ - They count KnowledgeBase reads, searches, Gateway tool calls and CLI commands that a Gateway tool covers.
34
+ - A rule adds a line of text only after the Gateway moves it to `warn`.
35
+ - Nothing is ever blocked, and no hook sends `permissionDecision`.
36
+
37
+ **What leaves the Mac**
38
+ - Only daily counts reach the Gateway.
39
+ - Prompts, commands, paths, hosts, file contents and session ids stay on the Mac.
40
+ - The remote URL embeds a token, so it is never stored.
41
+ - `doctor` shows the policy, the guide hooks with Codex trust, today's counts and the recent local lines.
42
+
43
+ Remove guide with `integrations remove --feature guide`. Without the Gateway's guide switch the hooks print and record
44
+ nothing.
45
+
1
46
  ## 0.1.53: separate candidate change, file and payload limits
2
47
 
3
48
  ## 0.1.54: read only the source lines you need
@@ -3,27 +3,61 @@ import { join } from "node:path";
3
3
  import { ASKS_FILE } from "./store.js";
4
4
  import { installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
5
5
  import { APPS, isApp } from "./types.js";
6
+ import { deleteGuideState } from "../guide/files.js";
7
+ import { guideStatus, installGuide, removeGuide, removeGuideShimIfUnused } from "../guide/integrations.js";
6
8
  const NEXT_STEP = {
7
9
  claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
8
10
  // /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
9
11
  codex: "Codex runs the four AI Gateway hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
10
12
  + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.",
11
13
  };
12
- function errorCode(error) {
13
- const code = error?.code;
14
- if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
15
- return code;
16
- const message = error instanceof Error ? error.message.split(":")[0] : "";
17
- return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
14
+ /** What an integrations command acts on (Work Item d38d60f9): asks, guide, or both; asks when no --feature is given. */
15
+ export const FEATURES = ["asks", "guide"];
16
+ const GUIDE_NEXT_STEP = {
17
+ claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-guide@skills-dir.",
18
+ codex: "Codex runs the two AI Gateway guide hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
19
+ + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks.",
20
+ };
21
+ const GUIDE_NOTICE = "Guide counts, per day: sessions, knowledge_search and other AI Gateway tool calls, KnowledgeBase reads and their bytes, "
22
+ + "and rule matches. Only these numbers reach the AI Gateway: prompts, commands, paths and file contents never leave this Mac.";
23
+ const isFeatureFlag = (arg) => arg === "--feature" || arg.startsWith("--feature=");
24
+ /** --feature asks|guide|asks,guide (or --feature=...): undefined when absent, null when malformed or given twice. */
25
+ export function parseFeatures(argv) {
26
+ const index = argv.findIndex(isFeatureFlag);
27
+ if (index < 0)
28
+ return undefined;
29
+ if (argv.slice(index + 1).some(isFeatureFlag))
30
+ return null;
31
+ const value = argv[index] === "--feature" ? argv[index + 1] : argv[index].slice("--feature=".length);
32
+ const features = (value ?? "").split(",").map(feature => feature.trim()).filter(feature => feature !== "");
33
+ if (features.length === 0 || !features.every(feature => FEATURES.includes(feature)) || new Set(features).size !== features.length)
34
+ return null;
35
+ return features;
18
36
  }
19
- /** integrations status|install|remove [claude|codex]; null for anything else. */
37
+ function withoutFeatureFlag(argv) {
38
+ const index = argv.findIndex(isFeatureFlag);
39
+ if (index < 0)
40
+ return argv;
41
+ return [...argv.slice(0, index), ...argv.slice(index + (argv[index] === "--feature" ? 2 : 1))];
42
+ }
43
+ /** integrations status|install|remove [claude|codex] [--feature ...]; null for anything else. features only when given. */
20
44
  export function parseIntegrationsArguments(argv) {
21
- const [action, app, ...extra] = argv;
45
+ const features = parseFeatures(argv);
46
+ if (features === null)
47
+ return null;
48
+ const [action, app, ...extra] = withoutFeatureFlag(argv);
22
49
  if (extra.length > 0 || (action !== "status" && action !== "install" && action !== "remove"))
23
50
  return null;
24
51
  if (app !== undefined && !isApp(app))
25
52
  return null;
26
- return { action, apps: app === undefined ? [...APPS] : [app] };
53
+ return { action, apps: app === undefined ? [...APPS] : [app], ...(features ? { features } : {}) };
54
+ }
55
+ function errorCode(error) {
56
+ const code = error?.code;
57
+ if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
58
+ return code;
59
+ const message = error instanceof Error ? error.message.split(":")[0] : "";
60
+ return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
27
61
  }
28
62
  /** setup --integrations claude,codex (or --integrations=claude): undefined when absent, null when malformed. */
29
63
  export function parseSetupIntegrations(argv) {
@@ -61,6 +95,47 @@ export async function installIntegrations(apps, env, stateRoot) {
61
95
  }
62
96
  return results;
63
97
  }
98
+ /** The guide state of one app; a status that cannot be read is reported with its error, never thrown. */
99
+ export async function guideStatusOf(app, env, stateRoot, error) {
100
+ try {
101
+ return { ...await guideStatus(app, env, stateRoot), ...(error === undefined ? {} : { error }) };
102
+ }
103
+ catch (failure) {
104
+ return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
105
+ }
106
+ }
107
+ async function attemptGuide(app, env, stateRoot, change) {
108
+ try {
109
+ return await change();
110
+ }
111
+ catch (error) {
112
+ return guideStatusOf(app, env, stateRoot, errorCode(error));
113
+ }
114
+ }
115
+ export async function installGuideIntegrations(apps, env, stateRoot) {
116
+ const results = [];
117
+ for (const app of apps) {
118
+ const result = await attemptGuide(app, env, stateRoot, () => installGuide(app, env, stateRoot));
119
+ results.push(result.error === undefined ? { ...result, action: GUIDE_NEXT_STEP[app], notice: GUIDE_NOTICE } : result);
120
+ }
121
+ return results;
122
+ }
123
+ /**
124
+ * uninstall: removes guide from both apps, then the guide shim and everything guide keeps on the Mac. Reports only the
125
+ * apps where something was removed, so an uninstall without guide prints exactly what it printed before.
126
+ */
127
+ export async function removeGuideInstall(env, stateRoot) {
128
+ const results = [];
129
+ for (const app of APPS) {
130
+ const before = await guideStatusOf(app, env, stateRoot);
131
+ if (before.state === "not_installed")
132
+ continue;
133
+ results.push(await attemptGuide(app, env, stateRoot, () => removeGuide(app, env, stateRoot)));
134
+ }
135
+ await removeGuideShimIfUnused(env, stateRoot).catch(() => false);
136
+ await deleteGuideState(stateRoot);
137
+ return results;
138
+ }
64
139
  /**
65
140
  * uninstall: removes both integrations, then the shim once nothing uses it, asks.json and the store's temporary files
66
141
  * that a crash left behind (asks.json.<pid>.<uuid>.tmp), which can hold raw session ids.
@@ -81,22 +156,40 @@ export async function removeAsksInstall(env, stateRoot) {
81
156
  export async function runIntegrationsCommand(argv, io) {
82
157
  const parsed = parseIntegrationsArguments(argv);
83
158
  if (parsed === null) {
84
- io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex]");
159
+ io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex] [--feature asks|guide|asks,guide]");
85
160
  return 1;
86
161
  }
87
- let results;
162
+ const features = parsed.features ?? ["asks"];
163
+ let results = [];
164
+ if (features.includes("asks")) {
165
+ if (parsed.action === "install") {
166
+ results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
167
+ }
168
+ else if (parsed.action === "remove") {
169
+ for (const app of parsed.apps)
170
+ results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
171
+ await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
172
+ }
173
+ else {
174
+ results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
175
+ }
176
+ }
177
+ if (!features.includes("guide")) {
178
+ io.stdout(JSON.stringify({ integrations: results }));
179
+ return results.some(result => result.error !== undefined) ? 1 : 0;
180
+ }
181
+ let guide = [];
88
182
  if (parsed.action === "install") {
89
- results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
183
+ guide = await installGuideIntegrations(parsed.apps, io.env, io.stateRoot);
90
184
  }
91
185
  else if (parsed.action === "remove") {
92
- results = [];
93
186
  for (const app of parsed.apps)
94
- results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
95
- await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
187
+ guide.push(await attemptGuide(app, io.env, io.stateRoot, () => removeGuide(app, io.env, io.stateRoot)));
188
+ await removeGuideShimIfUnused(io.env, io.stateRoot).catch(() => false);
96
189
  }
97
190
  else {
98
- results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
191
+ guide = await Promise.all(parsed.apps.map(app => guideStatusOf(app, io.env, io.stateRoot)));
99
192
  }
100
- io.stdout(JSON.stringify({ integrations: results }));
101
- return results.some(result => result.error !== undefined) ? 1 : 0;
193
+ io.stdout(JSON.stringify({ integrations: results, guide }));
194
+ return [...results, ...guide].some(result => result.error !== undefined) ? 1 : 0;
102
195
  }
@@ -56,6 +56,32 @@ export function isHumanSession(app, env, _input, stateRoot) {
56
56
  return attended === "1";
57
57
  return INTERACTIVE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT ?? "");
58
58
  }
59
+ /** The reminders a client app puts in front of what was submitted, typed or not. */
60
+ const LEADING_REMINDERS = /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+/;
61
+ /**
62
+ * How a turn nobody typed begins (Work Decision cc8817f9), as the 2026-10-26 transcripts recorded them. Claude Code runs
63
+ * UserPromptSubmit for these turns as well, and its hook input says nothing of where a prompt came from.
64
+ * - A Claude asyncRewake wake and a background task: a task notification.
65
+ * - The app's report of a side session's turn, and a message from another session.
66
+ * - A side session's first brief, written by the session that started it.
67
+ * - The summary that continues a compacted conversation, and an interruption marker.
68
+ * - This runner's own lines.
69
+ */
70
+ const NOT_TYPED_PREFIXES = [
71
+ "<task-notification>", "<side-sessions-event>", "<cross-session-message", "<agent-message", "Another Claude session sent a message:",
72
+ "Background (from the session that started this one):", "This session is being continued from a previous conversation",
73
+ "[Request interrupted by user", "AI Gateway:",
74
+ ];
75
+ /**
76
+ * True when a person typed this prompt. Only the answer leaves this client, never the text. A prompt that is not a
77
+ * string counts as typed, as every prompt did before this rule.
78
+ */
79
+ export function isHumanPrompt(prompt) {
80
+ if (typeof prompt !== "string")
81
+ return true;
82
+ const rest = prompt.replace(LEADING_REMINDERS, "").trimStart();
83
+ return rest !== "" && !NOT_TYPED_PREFIXES.some(prefix => rest.startsWith(prefix));
84
+ }
59
85
  /** Same rule as defaultStateRootFromEnv in src/config.ts, repeated so this client loads nothing else. */
60
86
  function stateRootFrom(env) {
61
87
  return env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "mtec-ai-local-runner") : join(env.HOME ?? "", ".local", "state", "mtec-ai-local-runner");
@@ -107,7 +133,7 @@ function parseRecord(raw) {
107
133
  }
108
134
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
109
135
  }
110
- /** Takes the session id, the cwd and Codex's stop_hook_active. Every other field, the prompt included, is ignored. */
136
+ /** Takes the session id, the cwd and Codex's stop_hook_active. Every other field is ignored; the prompt is only classified (isHumanPrompt). */
111
137
  function hookFields(input) {
112
138
  const { session_id: sessionId, cwd, stop_hook_active: stopHookActive } = input;
113
139
  if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId))
@@ -194,9 +220,10 @@ export async function runHookClient(argv, io) {
194
220
  io.stderr(text);
195
221
  return 2;
196
222
  }
197
- const request = args.event === "stop"
198
- ? { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd, stop_hook_active: input.stopHookActive }
199
- : { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd };
223
+ const base = { v: 1, op: "event", app: args.app, event: args.event, session_id: input.sessionId, cwd: input.cwd };
224
+ const request = args.event === "stop" ? { ...base, stop_hook_active: input.stopHookActive }
225
+ : args.event === "prompt" ? { ...base, human: isHumanPrompt(record.prompt) }
226
+ : base;
200
227
  const text = await converse(socketPath, encodeLine(request), hubTimeoutMs, hubTimeoutMs, line => ({ done: true, value: parseEventResponse(line)?.text ?? null }));
201
228
  if (text === null || text === "")
202
229
  return 0;
@@ -5,7 +5,9 @@ import { MAX_CWD_LENGTH, SESSION_ID, isApp, isHookEvent } from "./types.js";
5
5
  * may import nothing but ./types.js.
6
6
  *
7
7
  * Client to hub, one line, then the client waits without closing its side:
8
- * {"v":1,"op":"event","app":"claude"|"codex","event":"session-start"|"prompt"|"session-end","session_id":…,"cwd":…}
8
+ * {"v":1,"op":"event","app":"claude"|"codex","event":"session-start"|"session-end","session_id":…,"cwd":…}
9
+ * {"v":1,"op":"event","app":"claude"|"codex","event":"prompt","session_id":…,"cwd":…,"human":true|false}
10
+ * (a client from before the flag sends no "human"; that prompt counts as typed)
9
11
  * {"v":1,"op":"event","app":"codex","event":"stop","session_id":…,"cwd":…,"stop_hook_active":false}
10
12
  * {"v":1,"op":"watch","app":"claude","session_id":…,"cwd":…} (the Claude Stop watcher)
11
13
  * {"v":1,"op":"status"} (doctor)
@@ -68,6 +70,11 @@ export function parseHubRequest(line) {
68
70
  return null;
69
71
  return { v: 1, op: "event", app: "codex", event: "stop", session_id: value.session_id, cwd: value.cwd, stop_hook_active: value.stop_hook_active };
70
72
  }
73
+ if (value.event === "prompt" && Object.keys(value).length === 7) {
74
+ if (typeof value.human !== "boolean")
75
+ return null;
76
+ return { v: 1, op: "event", app: value.app, event: "prompt", session_id: value.session_id, cwd: value.cwd, human: value.human };
77
+ }
71
78
  if (Object.keys(value).length !== 6)
72
79
  return null;
73
80
  return { v: 1, op: "event", app: value.app, event: value.event, session_id: value.session_id, cwd: value.cwd };
@@ -83,7 +83,7 @@ export class AskInbox {
83
83
  answer(null);
84
84
  return;
85
85
  }
86
- const context = { app: request.app, sessionId: request.session_id, cwd: request.cwd, now: this.now() };
86
+ const context = { app: request.app, sessionId: request.session_id, cwd: request.cwd, now: this.now(), human: request.human !== false };
87
87
  let text = null;
88
88
  if (request.event === "session-start")
89
89
  text = renderOutput(sessionStarted(this.state, context), { sessionId: request.session_id });
@@ -12,6 +12,7 @@ import { abortableDelay } from "../transport/abortable-delay.js";
12
12
  import { sideLoops, tickingHeartbeat } from "../transport/side-loops.js";
13
13
  import { packageVersion } from "../package-version.js";
14
14
  import { AskRuntime, withAsks } from "./runtime.js";
15
+ import { GuideRuntime, withGuide } from "../guide/runtime.js";
15
16
  /*
16
17
  * Messenger mode (Work Item e1c08b33, spec section 8.1). Setup chose it because no executor is admitted on
17
18
  * this Mac. The daemon runs the push transport only (Work Item 25d8ea74): there is no jobs loop, no adapter probe,
@@ -93,6 +94,8 @@ export async function buildMessengerDaemon(input) {
93
94
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
94
95
  const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env, now: input.now, log, installedApps: input.installedApps,
95
96
  report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal) });
97
+ const guide = new GuideRuntime({ stateRoot: input.stateRoot, env: input.env, now: input.now, log,
98
+ report: (day, signal) => gateway.reportGuidance(day, signal) });
96
99
  const heartbeat = messengerHeartbeat(async (signal) => {
97
100
  let sessionOpen;
98
101
  // A detection error only leaves session_open out; the Gateway keeps what it stored.
@@ -103,16 +106,18 @@ export async function buildMessengerDaemon(input) {
103
106
  sessionOpen = undefined;
104
107
  }
105
108
  const askInbox = await asks.advertisement();
109
+ const guidance = await guide.advertisement();
106
110
  await gateway.heartbeatLocalRunner({
107
111
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
108
112
  displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
109
113
  // Without an integration, session_open still goes out (clients only): the Gateway keeps the last one it stored, and would keep long-polling for asks.
110
114
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
115
+ ...(guidance ? { guidance } : {}),
111
116
  }, signal);
112
117
  // Without session_open the Gateway keeps its stored ask_inbox, which may be stale: send again, backing off.
113
118
  return sessionOpen !== undefined;
114
119
  }, { now: input.now, reportError: code => log({ event: "messenger_heartbeat_failed", code }),
115
- advertised: async () => JSON.stringify(await asks.advertisement() ?? null) });
120
+ advertised: async () => JSON.stringify([await asks.advertisement() ?? null, await guide.advertisement() ?? null]) });
116
121
  const opener = new SessionOpener({ home });
117
122
  const stopping = new AbortController();
118
123
  const closureAcks = new PendingClosureAcks();
@@ -122,6 +127,7 @@ export async function buildMessengerDaemon(input) {
122
127
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
123
128
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
124
129
  },
130
+ guide,
125
131
  asks,
126
132
  closureAcks,
127
133
  delay: abortableDelay,
@@ -133,7 +139,7 @@ export async function buildMessengerDaemon(input) {
133
139
  sync: async (reason, signal) => {
134
140
  const acknowledged = closureAcks.take();
135
141
  try {
136
- return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged }, signal);
142
+ return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
137
143
  }
138
144
  catch (error) {
139
145
  closureAcks.restore(acknowledged);
@@ -156,7 +162,7 @@ export async function buildMessengerDaemon(input) {
156
162
  state: { load: async () => null },
157
163
  reconcile: async () => undefined,
158
164
  poller: idleJobPoller(),
159
- sessionOpens: withAsks(asks, sessionOpens),
165
+ sessionOpens: withGuide(guide, withAsks(asks, sessionOpens)),
160
166
  });
161
167
  return { daemon, close: async () => { daemon.stop(); } };
162
168
  }
@@ -19,6 +19,12 @@ export const MAX_ITEMS_PER_OUTPUT = 5;
19
19
  export const OPEN_PENDING_MS = 15 * 60 * 1000;
20
20
  /** At most 3 sessions opened per Mac in any hour. */
21
21
  export const OPEN_LIMIT = 3;
22
+ /**
23
+ * A prompt this soon after a wake or a stop show does not count as seen (Work Decision cc8817f9). Claude submits the
24
+ * woken turn itself within a few seconds, and a person needs longer to read the ask and type. An early typed prompt
25
+ * leaves the item shown: the next one counts, or the Gateway's Slack fallback covers it.
26
+ */
27
+ export const SEEN_AFTER_CONTINUATION_MS = 10_000;
22
28
  export function hashSessionId(sessionId) {
23
29
  return createHash("sha256").update(sessionId, "utf8").digest("hex");
24
30
  }
@@ -29,8 +35,10 @@ export function isLive(session, now) {
29
35
  export function humanPromptWithin(session, now) {
30
36
  return session.lastPromptAt !== null && now - session.lastPromptAt <= WAKE_WINDOW_MS;
31
37
  }
38
+ /** A turn started by a typed prompt or by one nobody typed has not ended yet. */
32
39
  export function turnInProgress(session) {
33
- return session.lastPromptAt !== null && (session.lastTurnEndAt === null || session.lastPromptAt > session.lastTurnEndAt);
40
+ const started = Math.max(session.lastPromptAt ?? -1, session.lastTurnStartAt ?? -1);
41
+ return started >= 0 && (session.lastTurnEndAt === null || started > session.lastTurnEndAt);
34
42
  }
35
43
  export function wakesLeft(session, now) {
36
44
  return WAKE_LIMIT - session.wakes.filter(at => now - at < WAKE_PERIOD_MS).length;
@@ -64,7 +72,8 @@ export function chooseSession(item, state, now) {
64
72
  * Where a delivery that is not shown yet goes now:
65
73
  * - no live session, or the chosen one had no human prompt in 30 minutes: a notification, then the next
66
74
  * SessionStart or prompt in any session;
67
- * - the chosen session is in a turn: pinned there, shown at its turn end or next prompt;
75
+ * - the chosen session is in a turn: pinned there, shown at its turn end or at the next prompt in any session that
76
+ * becomes the chosen one by that prompt (see promptSubmitted);
68
77
  * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
69
78
  * - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
70
79
  */
@@ -168,13 +177,30 @@ export function sessionStarted(state, context) {
168
177
  markShown(item, key, context.app, "session_start", context.now);
169
178
  return output;
170
179
  }
171
- /** UserPromptSubmit: earlier items shown in this session become seen; waiting items are shown and seen at once. */
180
+ /**
181
+ * UserPromptSubmit. A prompt nobody typed only starts a turn: it shows nothing, counts nothing as seen and is not a human
182
+ * prompt for routing (Work Decision cc8817f9).
183
+ *
184
+ * A typed prompt makes earlier items shown in this session seen, except those a wake or a stop showed under
185
+ * SEEN_AFTER_CONTINUATION_MS ago. Waiting items are shown and seen at once. So is an item pinned to another session that
186
+ * this prompt makes the chosen session for it: the person just typed here, while that session is still in its turn.
187
+ * Without this the next plan pinned the item here to wait for this turn's end, and a prompt elsewhere could move it on
188
+ * again before then (live test 2026-10-26). An answer that names a live session stays there, because the session that
189
+ * asked is still chosen first.
190
+ */
172
191
  export function promptSubmitted(state, context) {
173
192
  const { key, session } = touch(state, context);
193
+ if (context.human === false) {
194
+ session.lastTurnStartAt = context.now;
195
+ return { items: [], more: [] };
196
+ }
174
197
  session.lastPromptAt = context.now;
175
- for (const item of itemsWhere(state, item => item.phase === "shown" && item.sessionKey === key))
198
+ const settled = (item) => (item.shownVia !== "rewake" && item.shownVia !== "stop")
199
+ || item.shownAt === null || context.now - item.shownAt >= SEEN_AFTER_CONTINUATION_MS;
200
+ for (const item of itemsWhere(state, item => item.phase === "shown" && item.sessionKey === key && settled(item)))
176
201
  markSeen(item, context.app, context.now);
177
- const output = selectForOutput(dueFor(state, key));
202
+ const takenOver = itemsWhere(state, item => item.phase === "pinned" && item.sessionKey !== key && chooseSession(item, state, context.now) === key);
203
+ const output = selectForOutput([...dueFor(state, key), ...takenOver]);
178
204
  for (const item of everyItem(output)) {
179
205
  markShown(item, key, context.app, "prompt", context.now);
180
206
  markSeen(item, context.app, context.now);
@@ -8,12 +8,11 @@ import { fileURLToPath } from "node:url";
8
8
  * rewrites it at start so it follows the running release.
9
9
  */
10
10
  export const SHIM_FILE = join("bin", "gateway-hook");
11
+ /** The guide hooks' own stable entry point (Work Item d38d60f9): it execs dist/src/guide/hook-client.js. */
12
+ export const GUIDE_SHIM_FILE = join("bin", "gateway-guide-hook");
11
13
  // Paths go into single quotes in the shim and double quotes in hook commands; only characters that stay literal in
12
14
  // both are allowed (the same set as the PreToolUse hook command), so no quote, $, backquote or backslash.
13
15
  const SAFE_PATH = /^[A-Za-z0-9._/:@+~,=% -]+$/;
14
- export function shimPath(stateRoot) {
15
- return join(stateRoot, SHIM_FILE);
16
- }
17
16
  /** The hook client installed next to this module (dist/src/asks/hook-client.js in the package). */
18
17
  export function hookClientScript() {
19
18
  return fileURLToPath(new URL("./hook-client.js", import.meta.url));
@@ -25,9 +24,16 @@ function safePath(label, path) {
25
24
  }
26
25
  return path;
27
26
  }
28
- /** The command line in every installed hook. It names only the shim, the app and the event. */
27
+ export function shimPath(stateRoot, file = SHIM_FILE) {
28
+ return join(stateRoot, file);
29
+ }
30
+ /** The command line of an installed hook: the quoted shim, then the words given (an app and an event). */
31
+ export function shimCommand(stateRoot, file, args) {
32
+ return `"${safePath("shim", shimPath(stateRoot, file))}" ${args.join(" ")}`;
33
+ }
34
+ /** The command line in every installed asks hook. It names only the shim, the app and the event. */
29
35
  export function hookCommand(stateRoot, app, event) {
30
- return `"${safePath("shim", shimPath(stateRoot))}" ${app} ${event}`;
36
+ return shimCommand(stateRoot, SHIM_FILE, [app, event]);
31
37
  }
32
38
  export function renderShim(input) {
33
39
  const node = safePath("Node executable", input.nodePath);
@@ -35,7 +41,7 @@ export function renderShim(input) {
35
41
  const stateRoot = safePath("state root", input.stateRoot);
36
42
  return [
37
43
  "#!/bin/sh",
38
- "# Written by mtech-ai-local-runner for AI Gateway asks. The runner rewrites this file; do not edit it.",
44
+ `# Written by mtech-ai-local-runner for AI Gateway ${input.feature ?? "asks"}. The runner rewrites this file; do not edit it.`,
39
45
  `node='${node}'`,
40
46
  `client='${client}'`,
41
47
  "# A runner release that is gone must never disturb a session: print nothing and succeed.",
@@ -46,8 +52,9 @@ export function renderShim(input) {
46
52
  }
47
53
  /** Writes the shim atomically with mode 0755 for this release. Returns whether its content changed. */
48
54
  export async function writeShim(input) {
49
- const path = shimPath(input.stateRoot);
50
- const content = renderShim({ nodePath: input.nodePath ?? process.execPath, clientPath: input.clientPath ?? hookClientScript(), stateRoot: input.stateRoot });
55
+ const path = shimPath(input.stateRoot, input.file);
56
+ const content = renderShim({ nodePath: input.nodePath ?? process.execPath, clientPath: input.clientPath ?? hookClientScript(),
57
+ stateRoot: input.stateRoot, feature: input.feature });
51
58
  if (await readFile(path, "utf8").catch(() => null) === content)
52
59
  return { path, changed: false };
53
60
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
@@ -66,13 +73,13 @@ export async function writeShim(input) {
66
73
  /** Points an existing shim at the running release. It never creates one: installing an integration does. */
67
74
  export async function refreshShim(input) {
68
75
  try {
69
- await access(shimPath(input.stateRoot));
76
+ await access(shimPath(input.stateRoot, input.file));
70
77
  }
71
78
  catch {
72
79
  return false;
73
80
  }
74
81
  return (await writeShim(input)).changed;
75
82
  }
76
- export async function removeShim(stateRoot) {
77
- await rm(shimPath(stateRoot), { force: true });
83
+ export async function removeShim(stateRoot, file = SHIM_FILE) {
84
+ await rm(shimPath(stateRoot, file), { force: true });
78
85
  }
@@ -100,7 +100,11 @@ function normalizeSession(key, value) {
100
100
  return null;
101
101
  if (!Array.isArray(v.wakes) || !v.wakes.every(isTime))
102
102
  return null;
103
+ // Stored before Work Decision cc8817f9 a session has no turn start; one that is not a time is dropped with the session.
104
+ if (v.lastTurnStartAt !== undefined && !isTime(v.lastTurnStartAt))
105
+ return null;
103
106
  return { app: v.app, sessionId: v.sessionId, cwd: v.cwd, startedAt: v.startedAt, lastEventAt: v.lastEventAt, lastPromptAt: v.lastPromptAt,
107
+ ...(v.lastTurnStartAt === undefined ? {} : { lastTurnStartAt: v.lastTurnStartAt }),
104
108
  lastTurnEndAt: v.lastTurnEndAt, endedAt: v.endedAt, wakes: [...v.wakes] };
105
109
  }
106
110
  /**
package/dist/src/cli.js CHANGED
@@ -31,8 +31,9 @@ import { verifyPackagedHook } from "./safety/hook-command.js";
31
31
  import { access, mkdir, rm } from "node:fs/promises";
32
32
  import { buildMessengerDaemon } from "./asks/messenger.js";
33
33
  import { runHookClient } from "./asks/hook-client.js";
34
- import { installIntegrations, parseSetupIntegrations, removeAsksInstall, runIntegrationsCommand } from "./asks/commands.js";
34
+ import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand } from "./asks/commands.js";
35
35
  import { asksDoctorReport } from "./asks/doctor.js";
36
+ import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
36
37
  function hookAction(result) {
37
38
  return result.reason === "invalid_command"
38
39
  ? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
@@ -199,6 +200,9 @@ export async function main(argv, env, io = {}) {
199
200
  const requestedIntegrations = parseSetupIntegrations(argv.slice(1));
200
201
  if (requestedIntegrations === null)
201
202
  throw new Error("--integrations takes claude, codex or claude,codex");
203
+ const requestedFeatures = parseFeatures(argv.slice(1));
204
+ if (requestedFeatures === null)
205
+ throw new Error("--feature takes asks, guide or asks,guide");
202
206
  const existing = await existingRunnerMode(config.stateRoot, localStore);
203
207
  const detected = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
204
208
  // A Mac already set up as a full runner is never turned into a messenger: it fails as before, untouched.
@@ -239,11 +243,13 @@ export async function main(argv, env, io = {}) {
239
243
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1, ...(mode === "messenger" ? { mode } : {}) });
240
244
  // Only the apps named with --integrations: setup never asks. Before the daemon starts, so its first heartbeat
241
245
  // already advertises what is installed.
242
- const integrations = await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot);
246
+ const features = requestedFeatures ?? ["asks"];
247
+ const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
248
+ const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
243
249
  await launchd.install(process.argv[1], config.stateRoot);
244
250
  stdout(JSON.stringify(mode === "full"
245
- ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations }
246
- : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations,
251
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}) }
252
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}),
247
253
  action: "No executor is admitted on this Mac, so the runner is set up as a messenger: it delivers AI Gateway asks and opens sessions, and runs no jobs. Resolve the adapter diagnostics, then rerun setup to run jobs." }));
248
254
  return 0;
249
255
  }
@@ -251,8 +257,9 @@ export async function main(argv, env, io = {}) {
251
257
  if (config.mode === "uninstall") {
252
258
  await launchd.uninstall();
253
259
  const integrations = await removeAsksInstall(env, config.stateRoot);
260
+ const guide = await removeGuideInstall(env, config.stateRoot);
254
261
  await rm(join(config.stateRoot, "config.json"), { force: true });
255
- stdout(JSON.stringify({ uninstalled: true, integrations }));
262
+ stdout(JSON.stringify({ uninstalled: true, integrations, ...(guide.length > 0 ? { guide } : {}) }));
256
263
  return 0;
257
264
  }
258
265
  const doctorAdapters = config.mode === "doctor" ? adapterDiagnosticsWithRemedies(await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env))) : undefined;
@@ -313,6 +320,7 @@ export async function main(argv, env, io = {}) {
313
320
  // A messenger runs no executor, so neither an adapter nor the PreToolUse self-test is required of it.
314
321
  const hookCheck = messenger ? Promise.resolve(null) : hookSelfTest(local.workspaceRoot);
315
322
  const asksCheck = asksDoctorReport({ stateRoot: config.stateRoot, env, mode: runnerMode(local), run: io.claudePluginList });
323
+ const guideCheck = guideDoctorReport({ stateRoot: config.stateRoot, env });
316
324
  // Refresh rotating credentials once before the Gateway client asks for a token.
317
325
  const [oauth] = await Promise.allSettled([tokenProvider.getToken()]);
318
326
  const [gateway] = oauth.status === "fulfilled"
@@ -321,11 +329,12 @@ export async function main(argv, env, io = {}) {
321
329
  const [service, workspace] = await localChecks;
322
330
  const hook = await hookCheck;
323
331
  const asks = await asksCheck;
332
+ const guide = await guideCheck;
324
333
  const checks = [oauth, gateway, service, workspace];
325
334
  const loaded = service.status === "fulfilled" && service.value.loaded;
326
335
  const ok = checks.every(check => check.status === "fulfilled") && loaded && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
327
336
  stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }) },
328
- hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
337
+ hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
329
338
  return ok ? 0 : 1;
330
339
  }
331
340
  if (config.mode === 'manual-codex') {