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 +45 -0
- package/dist/src/asks/commands.js +111 -18
- package/dist/src/asks/hook-client.js +31 -4
- package/dist/src/asks/hub-protocol.js +8 -1
- package/dist/src/asks/inbox.js +1 -1
- package/dist/src/asks/messenger.js +9 -3
- package/dist/src/asks/routing.js +31 -5
- package/dist/src/asks/shim.js +18 -11
- package/dist/src/asks/store.js +4 -0
- package/dist/src/cli.js +15 -6
- package/dist/src/daemon/runtime.js +10 -2
- package/dist/src/gateway-client.js +14 -0
- package/dist/src/guide/aggregate.js +58 -0
- package/dist/src/guide/company.js +102 -0
- package/dist/src/guide/doctor.js +47 -0
- package/dist/src/guide/files.js +237 -0
- package/dist/src/guide/hook-client.js +289 -0
- package/dist/src/guide/integrations.js +335 -0
- package/dist/src/guide/matching.js +314 -0
- package/dist/src/guide/runtime.js +173 -0
- package/dist/src/guide/templates.js +22 -0
- package/dist/src/guide/types.js +95 -0
- package/dist/src/transport/sync-dispatch.js +12 -0
- package/package.json +1 -1
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
|
198
|
-
|
|
199
|
-
:
|
|
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"|"
|
|
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 };
|
package/dist/src/asks/inbox.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/asks/routing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/src/asks/shim.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(),
|
|
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
|
}
|
package/dist/src/asks/store.js
CHANGED
|
@@ -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
|
|
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') {
|