@mnemom/mnemom 0.17.31-next.0 → 0.17.31
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/dist/commands/auth.js +20 -0
- package/dist/commands/team.js +27 -7
- package/dist/lib/api.d.ts +21 -1
- package/dist/lib/oauth.d.ts +30 -0
- package/dist/lib/oauth.js +155 -43
- package/dist/lib/slack-fs.d.ts +15 -0
- package/dist/lib/slack-fs.js +36 -0
- package/dist/lib/slack-launch.js +3 -2
- package/dist/lib/slack-router.js +3 -2
- package/dist/lib/slack-state.js +10 -13
- package/dist/turn-origin.mjs +8 -1
- package/package.json +4 -4
- package/plugins/mnemom/skills/about-mnemom/SKILL.md +7 -5
package/dist/commands/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getAuthInfo, clearAuthTokens, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
|
|
2
|
+
import { getMe } from "../lib/api.js";
|
|
2
3
|
import { fmt } from "../lib/format.js";
|
|
3
4
|
export async function loginCommand(options = {}) {
|
|
4
5
|
try {
|
|
@@ -7,6 +8,7 @@ export async function loginCommand(options = {}) {
|
|
|
7
8
|
const tokens = options.noBrowser ? await loginWithDeviceFlow() : await loginWithBrowser();
|
|
8
9
|
console.log();
|
|
9
10
|
console.log(fmt.success("Logged in successfully!"));
|
|
11
|
+
await printSignedInAs();
|
|
10
12
|
printGrant(tokens);
|
|
11
13
|
console.log();
|
|
12
14
|
}
|
|
@@ -16,6 +18,24 @@ export async function loginCommand(options = {}) {
|
|
|
16
18
|
process.exit(1);
|
|
17
19
|
}
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Name the account this login signed in as, so a login finished in the wrong
|
|
23
|
+
* account (an approval from another browser or device that was signed in as
|
|
24
|
+
* someone else) is visible straight away. The OAuth token carries no identity,
|
|
25
|
+
* so this asks the API; if that fails the login still succeeded, so it prints
|
|
26
|
+
* nothing rather than an error.
|
|
27
|
+
*/
|
|
28
|
+
async function printSignedInAs() {
|
|
29
|
+
let email;
|
|
30
|
+
try {
|
|
31
|
+
email = (await getMe()).email;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (email)
|
|
37
|
+
console.log(`Signed in as ${email}`);
|
|
38
|
+
}
|
|
19
39
|
/**
|
|
20
40
|
* Print what the issued token actually grants. OAuth access tokens are scoped
|
|
21
41
|
* and (deliberately) carry no user identity, so we show scope + expiry rather
|
package/dist/commands/team.js
CHANGED
|
@@ -105,6 +105,31 @@ export async function teamShowCommand(teamId, opts) {
|
|
|
105
105
|
}
|
|
106
106
|
console.log();
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* What a template write did to the members' SERVED cards (mnemom-platform#2779).
|
|
110
|
+
*
|
|
111
|
+
* Before #2779 this printed "Agents recompose: N" for a count of rows merely
|
|
112
|
+
* FLAGGED for the 15-minute recompose sweep, while `card show` kept serving
|
|
113
|
+
* the stale composed card. The API now recomposes active members in-request
|
|
114
|
+
* and reports `agents_recomposed` / `agents_recompose_pending`; an older API
|
|
115
|
+
* that only flags is reported as queued, never as recomposed.
|
|
116
|
+
*/
|
|
117
|
+
function printRecomposeLines(result) {
|
|
118
|
+
if (typeof result.agents_recomposed === "number") {
|
|
119
|
+
const pending = result.agents_recompose_pending ?? 0;
|
|
120
|
+
console.log(` Agents recomposed: ${result.agents_recomposed} ` +
|
|
121
|
+
`(active members of this team; their served card is updated now)`);
|
|
122
|
+
if (pending > 0) {
|
|
123
|
+
console.log(` Agents pending: ${pending} ` +
|
|
124
|
+
`(queued for the recompose sweep, ~15 min; confirm with \`mnemom card show --agent <id>\`)`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof result.agents_flagged_for_recompose === "number") {
|
|
129
|
+
console.log(` Agents queued: ${result.agents_flagged_for_recompose} ` +
|
|
130
|
+
`(active members of this team only; served cards update on the next recompose sweep, ~15 min)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
108
133
|
export async function teamTemplateCommand(kind, teamId, opts) {
|
|
109
134
|
await requireAuth();
|
|
110
135
|
if (!teamId) {
|
|
@@ -149,10 +174,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
|
|
|
149
174
|
console.log(` Team ID: ${result.team_id}`);
|
|
150
175
|
console.log(` Org ID: ${result.org_id}`);
|
|
151
176
|
console.log(` Enabled: ${result.enabled ? "yes" : "no"}`);
|
|
152
|
-
|
|
153
|
-
console.log(` Agents recompose: ${result.agents_flagged_for_recompose} ` +
|
|
154
|
-
`(active members of this team only — not blanket fan-out)`);
|
|
155
|
-
}
|
|
177
|
+
printRecomposeLines(result);
|
|
156
178
|
console.log();
|
|
157
179
|
return;
|
|
158
180
|
}
|
|
@@ -177,9 +199,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
|
|
|
177
199
|
console.log(` Team ID: ${result.team_id}`);
|
|
178
200
|
console.log(` Org ID: ${result.org_id}`);
|
|
179
201
|
console.log(` Deleted: yes`);
|
|
180
|
-
|
|
181
|
-
console.log(` Agents recompose: ${result.agents_flagged_for_recompose}`);
|
|
182
|
-
}
|
|
202
|
+
printRecomposeLines(result);
|
|
183
203
|
console.log();
|
|
184
204
|
return;
|
|
185
205
|
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -470,7 +470,15 @@ export interface TeamTemplateBody {
|
|
|
470
470
|
name?: string;
|
|
471
471
|
template: Record<string, unknown> | null;
|
|
472
472
|
enabled: boolean;
|
|
473
|
+
/** Active members whose canonical rows were flagged for the recompose sweep. */
|
|
473
474
|
agents_flagged_for_recompose?: number;
|
|
475
|
+
/**
|
|
476
|
+
* Active members recomposed in-request (mnemom-platform#2779): their served
|
|
477
|
+
* card already reflects this write. Absent on an API that only flags.
|
|
478
|
+
*/
|
|
479
|
+
agents_recomposed?: number;
|
|
480
|
+
/** Active members still waiting on the recompose sweep (cap or compose failure). */
|
|
481
|
+
agents_recompose_pending?: number;
|
|
474
482
|
deleted?: boolean;
|
|
475
483
|
}
|
|
476
484
|
export type TeamTemplateKind = "alignment" | "protection";
|
|
@@ -1326,10 +1334,16 @@ export interface AgentSessionGuardrails {
|
|
|
1326
1334
|
turns: number;
|
|
1327
1335
|
spend_usd: number;
|
|
1328
1336
|
stall_run: number;
|
|
1337
|
+
/**
|
|
1338
|
+
* Which ceilings this session has crossed. Under `enforce` a crossing refuses the request
|
|
1339
|
+
* (429) without counting it as a turn — the flag is the record of that refusal (#2782) and
|
|
1340
|
+
* stays set even after the owner raises the ceiling and the session runs on.
|
|
1341
|
+
*/
|
|
1329
1342
|
fired: {
|
|
1330
1343
|
turns?: boolean;
|
|
1331
1344
|
budget?: boolean;
|
|
1332
1345
|
stall?: boolean;
|
|
1346
|
+
time?: boolean;
|
|
1333
1347
|
};
|
|
1334
1348
|
} | null;
|
|
1335
1349
|
}
|
|
@@ -1340,14 +1354,20 @@ export interface AgentSessionGuardrails {
|
|
|
1340
1354
|
* `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
|
|
1341
1355
|
* no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
|
|
1342
1356
|
* the field name and member shape stable.
|
|
1357
|
+
*
|
|
1358
|
+
* `refused` (#2782) is not a judge verdict: it records an enforce-mode ceiling refusal — the
|
|
1359
|
+
* request was turned away (429) and not counted, so `turn` is the turn it would have been and
|
|
1360
|
+
* the counter never reached it; `ceilings` names what was crossed and `fence_breach` is null.
|
|
1343
1361
|
*/
|
|
1344
1362
|
export interface AgentSessionVerdictTrailEntry {
|
|
1345
1363
|
request_id: string | null;
|
|
1346
1364
|
turn: number;
|
|
1347
|
-
verdict: "aligned" | "drifting" | "violation";
|
|
1365
|
+
verdict: "aligned" | "drifting" | "violation" | "refused";
|
|
1348
1366
|
reason: string;
|
|
1349
1367
|
fence_breach: boolean | null;
|
|
1350
1368
|
at: string;
|
|
1369
|
+
/** Only on a `refused` entry: the ceilings the refusal named. */
|
|
1370
|
+
ceilings?: Array<"turns" | "budget" | "time">;
|
|
1351
1371
|
}
|
|
1352
1372
|
/**
|
|
1353
1373
|
* The seal's lifecycle (MNE-8149 R2, MNE-8199 D9/R9, MNE-8234/mnemom-api#3541). `live`: a sealed
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -89,10 +89,35 @@ export interface LoopbackResult {
|
|
|
89
89
|
* scoped tokens plus the client_id that was used (so the caller can persist it
|
|
90
90
|
* for refresh). `openUrl` is injectable so tests can drive the flow without
|
|
91
91
|
* spawning a real browser.
|
|
92
|
+
*
|
|
93
|
+
* Cross-device completion: when the AS advertises a device authorization
|
|
94
|
+
* endpoint, this also starts an RFC 8628 device authorization for the same
|
|
95
|
+
* client and prints its code with the verification URI. The login then
|
|
96
|
+
* finishes on whichever comes first: the loopback redirect (same machine) or an
|
|
97
|
+
* approval of the device code from any browser (e.g. the phone that opened the
|
|
98
|
+
* sign-up confirmation email), where the person types the code from this
|
|
99
|
+
* terminal. The code is never put in the authorize URL or any other link, so
|
|
100
|
+
* nothing that opens in a browser can approve this login without the person
|
|
101
|
+
* typing it. Tokens only ever come back to this process: the loopback code
|
|
102
|
+
* needs our PKCE verifier and the device grant needs our device_code. If the
|
|
103
|
+
* device authorization cannot be started, the login is exactly the loopback
|
|
104
|
+
* flow it always was.
|
|
92
105
|
*/
|
|
93
106
|
export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
|
|
94
107
|
inviteToken?: string;
|
|
108
|
+
sleep?: (ms: number) => Promise<void>;
|
|
95
109
|
}): Promise<LoopbackResult>;
|
|
110
|
+
/** How long the loopback listener waits while a device code is live. */
|
|
111
|
+
export declare function crossDeviceWaitMs(expiresInSec: number): number;
|
|
112
|
+
/**
|
|
113
|
+
* Settle with whichever login path finishes first.
|
|
114
|
+
* - The loopback path settles the login either way, exactly as before: a
|
|
115
|
+
* denial, a state mismatch or the timeout in the browser ends it.
|
|
116
|
+
* - The device path ends it on success, or when the person explicitly denies
|
|
117
|
+
* the device code. Any other device failure (expiry, a transport error)
|
|
118
|
+
* only drops that path and the loopback wait continues.
|
|
119
|
+
*/
|
|
120
|
+
export declare function firstLogin(viaLoopback: Promise<OAuthTokens>, viaDevice: Promise<OAuthTokens> | null): Promise<OAuthTokens>;
|
|
96
121
|
export interface DeviceResult {
|
|
97
122
|
tokens: OAuthTokens;
|
|
98
123
|
clientId: string;
|
|
@@ -169,6 +194,11 @@ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string
|
|
|
169
194
|
* Returns whether a launch was attempted.
|
|
170
195
|
*/
|
|
171
196
|
export declare function openBrowser(url: string): boolean;
|
|
197
|
+
/**
|
|
198
|
+
* setTimeout-based sleep that resolves early when `signal` aborts, so an
|
|
199
|
+
* abandoned device poll does not hold the process open after login finishes.
|
|
200
|
+
*/
|
|
201
|
+
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
172
202
|
/**
|
|
173
203
|
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
174
204
|
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
package/dist/lib/oauth.js
CHANGED
|
@@ -35,6 +35,12 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
|
35
35
|
// Loopback login waits at most this long for the browser round-trip before
|
|
36
36
|
// giving up and freeing the port.
|
|
37
37
|
const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
38
|
+
// Browser login also starts a device authorization (RFC 8628) when the AS offers
|
|
39
|
+
// one, so the login can be approved from ANOTHER device: someone who signs up in
|
|
40
|
+
// the browser and confirms the email on their phone. While that is live the
|
|
41
|
+
// loopback listener waits as long as the device code is valid, capped here, so
|
|
42
|
+
// the port is never left open indefinitely.
|
|
43
|
+
const CROSS_DEVICE_MAX_WAIT_MS = 15 * 60 * 1000;
|
|
38
44
|
// Cadence for the "…still waiting for you to finish signing in" heartbeat that
|
|
39
45
|
// both interactive login poll loops emit, so a multi-minute browser sign-in
|
|
40
46
|
// doesn't read as a hung CLI (matches the card-write retry tick in try-me).
|
|
@@ -208,46 +214,120 @@ function tokensFromResponse(data) {
|
|
|
208
214
|
* scoped tokens plus the client_id that was used (so the caller can persist it
|
|
209
215
|
* for refresh). `openUrl` is injectable so tests can drive the flow without
|
|
210
216
|
* spawning a real browser.
|
|
217
|
+
*
|
|
218
|
+
* Cross-device completion: when the AS advertises a device authorization
|
|
219
|
+
* endpoint, this also starts an RFC 8628 device authorization for the same
|
|
220
|
+
* client and prints its code with the verification URI. The login then
|
|
221
|
+
* finishes on whichever comes first: the loopback redirect (same machine) or an
|
|
222
|
+
* approval of the device code from any browser (e.g. the phone that opened the
|
|
223
|
+
* sign-up confirmation email), where the person types the code from this
|
|
224
|
+
* terminal. The code is never put in the authorize URL or any other link, so
|
|
225
|
+
* nothing that opens in a browser can approve this login without the person
|
|
226
|
+
* typing it. Tokens only ever come back to this process: the loopback code
|
|
227
|
+
* needs our PKCE verifier and the device grant needs our device_code. If the
|
|
228
|
+
* device authorization cannot be started, the login is exactly the loopback
|
|
229
|
+
* flow it always was.
|
|
211
230
|
*/
|
|
212
231
|
export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
|
|
213
232
|
const meta = await discover();
|
|
214
233
|
const pkce = generatePkce();
|
|
215
234
|
const state = crypto.randomBytes(16).toString("hex");
|
|
216
|
-
const { port, codePromise, close } = await startLoopbackServer(state);
|
|
235
|
+
const { port, codePromise, close, setTimeoutMs } = await startLoopbackServer(state);
|
|
217
236
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
218
|
-
|
|
219
|
-
// requires exact redirect_uri matching, and the loopback port is ephemeral,
|
|
220
|
-
// so we register per-login (cheap, public client, no secret).
|
|
221
|
-
const clientId = await registerClient([redirectUri]);
|
|
222
|
-
const authUrl = new URL(meta.authorization_endpoint);
|
|
223
|
-
authUrl.searchParams.set("response_type", "code");
|
|
224
|
-
authUrl.searchParams.set("client_id", clientId);
|
|
225
|
-
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
226
|
-
authUrl.searchParams.set("scope", negotiateScopes(meta));
|
|
227
|
-
authUrl.searchParams.set("state", state);
|
|
228
|
-
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
229
|
-
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
230
|
-
// A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
|
|
231
|
-
// invitee who signs up in this browser round-trip skips the confirmation
|
|
232
|
-
// email: the AS ignores the param but bounces a signed-out browser to /login
|
|
233
|
-
// with this exact URL as return_to, and sign-up forwards the token to the api.
|
|
234
|
-
if (opts.inviteToken)
|
|
235
|
-
authUrl.searchParams.set("invite_token", opts.inviteToken);
|
|
236
|
-
console.log("Opening browser to authenticate...");
|
|
237
|
-
console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
238
|
-
openUrl(authUrl.toString());
|
|
239
|
-
console.log("Waiting for authentication...");
|
|
237
|
+
const stopPolling = new AbortController();
|
|
240
238
|
try {
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
239
|
+
// Register a client bound to the exact loopback redirect URI. OAuth 2.1
|
|
240
|
+
// requires exact redirect_uri matching, and the loopback port is ephemeral,
|
|
241
|
+
// so we register per-login (cheap, public client, no secret). The same
|
|
242
|
+
// client also runs the device grant, so either path refreshes the same way.
|
|
243
|
+
const clientId = await registerClient([redirectUri]);
|
|
244
|
+
const device = await startCrossDeviceAuthorization(meta, clientId);
|
|
245
|
+
const authUrl = new URL(meta.authorization_endpoint);
|
|
246
|
+
authUrl.searchParams.set("response_type", "code");
|
|
247
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
248
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
249
|
+
authUrl.searchParams.set("scope", negotiateScopes(meta));
|
|
250
|
+
authUrl.searchParams.set("state", state);
|
|
251
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
252
|
+
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
253
|
+
// A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
|
|
254
|
+
// invitee who signs up in this browser round-trip skips the confirmation
|
|
255
|
+
// email: the AS ignores the param but bounces a signed-out browser to /login
|
|
256
|
+
// with this exact URL as return_to, and sign-up forwards the token to the api.
|
|
257
|
+
if (opts.inviteToken)
|
|
258
|
+
authUrl.searchParams.set("invite_token", opts.inviteToken);
|
|
259
|
+
console.log("Opening browser to authenticate...");
|
|
260
|
+
console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
261
|
+
if (device) {
|
|
262
|
+
setTimeoutMs(crossDeviceWaitMs(device.expires_in));
|
|
263
|
+
// The plain verification_uri, never verification_uri_complete: the person
|
|
264
|
+
// types the code there, so a link alone can never approve this login.
|
|
265
|
+
console.log(`Confirming on another device? Open ${device.verification_uri} and enter: ${device.user_code}\n`);
|
|
266
|
+
}
|
|
267
|
+
openUrl(authUrl.toString());
|
|
268
|
+
console.log("Waiting for authentication...");
|
|
269
|
+
const viaLoopback = codePromise.then((code) => exchangeCode(meta, clientId, code, pkce.verifier, redirectUri));
|
|
270
|
+
const viaDevice = device
|
|
271
|
+
? pollDeviceToken(meta, clientId, device, opts.sleep ?? ((ms) => abortableSleep(ms, stopPolling.signal)),
|
|
272
|
+
// The loopback wait below already prints the heartbeat.
|
|
273
|
+
() => { }, stopPolling.signal)
|
|
274
|
+
: null;
|
|
275
|
+
// Heartbeat while we wait — otherwise the sign-in round-trip is dead
|
|
276
|
+
// silent and reads as a hung CLI.
|
|
277
|
+
const tokens = await withHeartbeat(firstLogin(viaLoopback, viaDevice), LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
|
|
245
278
|
return { tokens, clientId };
|
|
246
279
|
}
|
|
247
280
|
finally {
|
|
281
|
+
stopPolling.abort();
|
|
248
282
|
close();
|
|
249
283
|
}
|
|
250
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Start the device authorization that lets a browser login be approved from
|
|
287
|
+
* another device. Best-effort: an AS without the endpoint, or any failure,
|
|
288
|
+
* returns null and the login carries on as loopback only.
|
|
289
|
+
*/
|
|
290
|
+
async function startCrossDeviceAuthorization(meta, clientId) {
|
|
291
|
+
if (!meta.device_authorization_endpoint)
|
|
292
|
+
return null;
|
|
293
|
+
try {
|
|
294
|
+
const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
|
|
295
|
+
if (typeof authz.device_code !== "string" ||
|
|
296
|
+
typeof authz.user_code !== "string" ||
|
|
297
|
+
typeof authz.verification_uri !== "string" ||
|
|
298
|
+
typeof authz.expires_in !== "number") {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return authz;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** How long the loopback listener waits while a device code is live. */
|
|
308
|
+
export function crossDeviceWaitMs(expiresInSec) {
|
|
309
|
+
const ms = Number.isFinite(expiresInSec) ? expiresInSec * 1000 : 0;
|
|
310
|
+
return Math.min(Math.max(ms, LOOPBACK_TIMEOUT_MS), CROSS_DEVICE_MAX_WAIT_MS);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Settle with whichever login path finishes first.
|
|
314
|
+
* - The loopback path settles the login either way, exactly as before: a
|
|
315
|
+
* denial, a state mismatch or the timeout in the browser ends it.
|
|
316
|
+
* - The device path ends it on success, or when the person explicitly denies
|
|
317
|
+
* the device code. Any other device failure (expiry, a transport error)
|
|
318
|
+
* only drops that path and the loopback wait continues.
|
|
319
|
+
*/
|
|
320
|
+
export function firstLogin(viaLoopback, viaDevice) {
|
|
321
|
+
if (!viaDevice)
|
|
322
|
+
return viaLoopback;
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
viaLoopback.then(resolve, reject);
|
|
325
|
+
viaDevice.then(resolve, (err) => {
|
|
326
|
+
if (err instanceof Error && err.message === DEVICE_ACCESS_DENIED)
|
|
327
|
+
reject(err);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
251
331
|
/**
|
|
252
332
|
* Start a loopback HTTP listener that captures the OAuth redirect
|
|
253
333
|
* (GET /callback?code=...&state=...). Resolves with the authorization code once
|
|
@@ -293,13 +373,18 @@ function startLoopbackServer(expectedState) {
|
|
|
293
373
|
return new Promise((resolve) => {
|
|
294
374
|
server.listen(0, "127.0.0.1", () => {
|
|
295
375
|
const port = server.address().port;
|
|
296
|
-
const
|
|
376
|
+
const giveUp = () => {
|
|
297
377
|
rejectCode(new Error("Login timed out. Please try again."));
|
|
298
378
|
server.close();
|
|
299
|
-
}
|
|
379
|
+
};
|
|
380
|
+
let timeout = setTimeout(giveUp, LOOPBACK_TIMEOUT_MS);
|
|
300
381
|
resolve({
|
|
301
382
|
port,
|
|
302
383
|
codePromise,
|
|
384
|
+
setTimeoutMs: (ms) => {
|
|
385
|
+
clearTimeout(timeout);
|
|
386
|
+
timeout = setTimeout(giveUp, ms);
|
|
387
|
+
},
|
|
303
388
|
close: () => {
|
|
304
389
|
clearTimeout(timeout);
|
|
305
390
|
server.close();
|
|
@@ -358,18 +443,7 @@ export async function loginWithDevice(opts) {
|
|
|
358
443
|
// clients"). Register a loopback placeholder — it satisfies validation and is
|
|
359
444
|
// never used, since the device grant never redirects.
|
|
360
445
|
const clientId = await registerClient(["http://127.0.0.1/callback"]);
|
|
361
|
-
const
|
|
362
|
-
method: "POST",
|
|
363
|
-
headers: {
|
|
364
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
365
|
-
Accept: "application/json",
|
|
366
|
-
},
|
|
367
|
-
body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
|
|
368
|
-
});
|
|
369
|
-
if (!authzRes.ok) {
|
|
370
|
-
throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
|
|
371
|
-
}
|
|
372
|
-
const authz = (await authzRes.json());
|
|
446
|
+
const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
|
|
373
447
|
display("");
|
|
374
448
|
display("To authenticate, visit:");
|
|
375
449
|
display(` ${authz.verification_uri}`);
|
|
@@ -385,7 +459,26 @@ export async function loginWithDevice(opts) {
|
|
|
385
459
|
const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
|
|
386
460
|
return { tokens, clientId };
|
|
387
461
|
}
|
|
388
|
-
|
|
462
|
+
/** POST the device authorization request (RFC 8628 §3.1) for `clientId`. */
|
|
463
|
+
async function requestDeviceAuthorization(endpoint, meta, clientId) {
|
|
464
|
+
const authzRes = await oauthFetch("Device authorization at", endpoint, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: {
|
|
467
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
468
|
+
Accept: "application/json",
|
|
469
|
+
},
|
|
470
|
+
body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
|
|
471
|
+
});
|
|
472
|
+
if (!authzRes.ok) {
|
|
473
|
+
throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
|
|
474
|
+
}
|
|
475
|
+
return (await authzRes.json());
|
|
476
|
+
}
|
|
477
|
+
/** The message a device-code denial rejects with (matched by firstLogin). */
|
|
478
|
+
const DEVICE_ACCESS_DENIED = "Authorization was denied.";
|
|
479
|
+
/** Thrown into an abandoned device poll once the other login path has won. */
|
|
480
|
+
const POLL_STOPPED = "Device polling stopped.";
|
|
481
|
+
async function pollDeviceToken(meta, clientId, authz, sleep, display, signal) {
|
|
389
482
|
// RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
|
|
390
483
|
// we increase the interval by 5s and keep that as the new minimum.
|
|
391
484
|
let intervalMs = (authz.interval ?? 5) * 1000;
|
|
@@ -399,6 +492,8 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
399
492
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
400
493
|
}
|
|
401
494
|
await sleep(intervalMs);
|
|
495
|
+
if (signal?.aborted)
|
|
496
|
+
throw new Error(POLL_STOPPED);
|
|
402
497
|
waitedMs += intervalMs;
|
|
403
498
|
let res;
|
|
404
499
|
try {
|
|
@@ -437,7 +532,7 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
437
532
|
case "expired_token":
|
|
438
533
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
439
534
|
case "access_denied":
|
|
440
|
-
throw new Error(
|
|
535
|
+
throw new Error(DEVICE_ACCESS_DENIED);
|
|
441
536
|
default:
|
|
442
537
|
throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
|
|
443
538
|
(body.error_description ? ` — ${body.error_description}` : ""));
|
|
@@ -623,6 +718,23 @@ export function openBrowser(url) {
|
|
|
623
718
|
function defaultSleep(ms) {
|
|
624
719
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
625
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* setTimeout-based sleep that resolves early when `signal` aborts, so an
|
|
723
|
+
* abandoned device poll does not hold the process open after login finishes.
|
|
724
|
+
*/
|
|
725
|
+
export function abortableSleep(ms, signal) {
|
|
726
|
+
return new Promise((resolve) => {
|
|
727
|
+
if (signal.aborted)
|
|
728
|
+
return resolve();
|
|
729
|
+
const done = () => {
|
|
730
|
+
clearTimeout(timer);
|
|
731
|
+
signal.removeEventListener("abort", done);
|
|
732
|
+
resolve();
|
|
733
|
+
};
|
|
734
|
+
const timer = setTimeout(done, ms);
|
|
735
|
+
signal.addEventListener("abort", done, { once: true });
|
|
736
|
+
});
|
|
737
|
+
}
|
|
626
738
|
/**
|
|
627
739
|
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
628
740
|
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
|
|
3
|
+
*
|
|
4
|
+
* A sandboxed agent session is allowed to write a few of these files itself (its own
|
|
5
|
+
* offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
|
|
6
|
+
* router or channel server — which run outside the sandbox, as the operator — follows
|
|
7
|
+
* the link and truncates whatever it points at. So every write here opens the path with
|
|
8
|
+
* O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
|
|
9
|
+
* that is not a regular file with a single link (a hard link to another file is the same
|
|
10
|
+
* trick) before a single byte is written or truncated.
|
|
11
|
+
*/
|
|
12
|
+
/** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
|
|
13
|
+
export declare function writeFileNoFollow(path: string, data: string, mode?: number): void;
|
|
14
|
+
/** Append `data` to `path`; never follows a symlink at `path`. */
|
|
15
|
+
export declare function appendFileNoFollow(path: string, data: string, mode?: number): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
|
|
3
|
+
*
|
|
4
|
+
* A sandboxed agent session is allowed to write a few of these files itself (its own
|
|
5
|
+
* offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
|
|
6
|
+
* router or channel server — which run outside the sandbox, as the operator — follows
|
|
7
|
+
* the link and truncates whatever it points at. So every write here opens the path with
|
|
8
|
+
* O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
|
|
9
|
+
* that is not a regular file with a single link (a hard link to another file is the same
|
|
10
|
+
* trick) before a single byte is written or truncated.
|
|
11
|
+
*/
|
|
12
|
+
import { closeSync, constants, fstatSync, ftruncateSync, openSync, writeSync } from "node:fs";
|
|
13
|
+
// O_NOFOLLOW is undefined on Windows, where creating symlinks needs elevation anyway.
|
|
14
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
15
|
+
function writeNoFollow(path, data, flags, truncate, mode) {
|
|
16
|
+
const fd = openSync(path, flags | NOFOLLOW, mode);
|
|
17
|
+
try {
|
|
18
|
+
const st = fstatSync(fd);
|
|
19
|
+
if (!st.isFile() || st.nlink !== 1)
|
|
20
|
+
throw new Error(`refusing to write ${path}: not a regular file with a single link`);
|
|
21
|
+
if (truncate)
|
|
22
|
+
ftruncateSync(fd, 0);
|
|
23
|
+
writeSync(fd, data);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
|
|
30
|
+
export function writeFileNoFollow(path, data, mode = 0o600) {
|
|
31
|
+
writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT, true, mode);
|
|
32
|
+
}
|
|
33
|
+
/** Append `data` to `path`; never follows a symlink at `path`. */
|
|
34
|
+
export function appendFileNoFollow(path, data, mode = 0o600) {
|
|
35
|
+
writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, false, mode);
|
|
36
|
+
}
|
package/dist/lib/slack-launch.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* `--dangerously-load-development-channels`, which shows a confirmation dialog;
|
|
9
9
|
* orgs on Team/Enterprise must also enable `channelsEnabled` in managed settings.
|
|
10
10
|
*/
|
|
11
|
-
import { mkdirSync
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { SESSION_NAME_ENV } from "./agent.js";
|
|
14
14
|
import { ensureRouter } from "./slack-router.js";
|
|
15
15
|
import { slackDir } from "./slack-state.js";
|
|
16
16
|
import { normSession } from "./slack-route.js";
|
|
17
|
+
import { writeFileNoFollow } from "./slack-fs.js";
|
|
17
18
|
export const SLACK_CHANNEL_SERVER = "mnemom-slack";
|
|
18
19
|
/** The MCP config document for `session` (pure; unit-tested). */
|
|
19
20
|
export function slackMcpConfig(session, cli) {
|
|
@@ -31,7 +32,7 @@ export function slackMcpConfig(session, cli) {
|
|
|
31
32
|
export function slackChannelArgs(session, cli = [process.execPath, process.argv[1] ?? "mnemom"]) {
|
|
32
33
|
mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
|
|
33
34
|
const path = join(slackDir(), `mcp-${normSession(session)}.json`);
|
|
34
|
-
|
|
35
|
+
writeFileNoFollow(path, JSON.stringify(slackMcpConfig(session, cli), null, 2));
|
|
35
36
|
ensureRouter(cli);
|
|
36
37
|
return [
|
|
37
38
|
"--mcp-config",
|
package/dist/lib/slack-router.js
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* in-memory user-name cache.
|
|
13
13
|
*/
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import { existsSync, readFileSync,
|
|
15
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
|
|
18
18
|
import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
|
|
19
19
|
import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
|
|
20
|
+
import { writeFileNoFollow } from "./slack-fs.js";
|
|
20
21
|
/** How often the router settles held messages (delivered / expired). */
|
|
21
22
|
export const PENDING_SWEEP_MS = 30_000;
|
|
22
23
|
const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
|
|
@@ -92,7 +93,7 @@ export async function runRouter(opts) {
|
|
|
92
93
|
if (other && other !== process.pid)
|
|
93
94
|
throw new Error(`a router is already running (pid ${other}).`);
|
|
94
95
|
mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
|
|
95
|
-
|
|
96
|
+
writeFileNoFollow(routerPidFile(), String(process.pid));
|
|
96
97
|
log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
|
|
97
98
|
if (aliasesOn) {
|
|
98
99
|
const sweep = setInterval(() => {
|
package/dist/lib/slack-state.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* each session's listener is the only reader of its own inbox. No secret is
|
|
16
16
|
* stored here.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { MNEMOM_DIR } from "./config.js";
|
|
21
|
+
import { appendFileNoFollow, writeFileNoFollow } from "./slack-fs.js";
|
|
21
22
|
import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
|
|
22
23
|
export function slackDir() {
|
|
23
24
|
return join(MNEMOM_DIR, "slack");
|
|
@@ -59,14 +60,14 @@ function readJsonl(path) {
|
|
|
59
60
|
}
|
|
60
61
|
export function recordPost(p) {
|
|
61
62
|
ensure(slackDir());
|
|
62
|
-
|
|
63
|
+
appendFileNoFollow(postsFile(), JSON.stringify(p) + "\n");
|
|
63
64
|
}
|
|
64
65
|
export function readPosts() {
|
|
65
66
|
return readJsonl(postsFile()).slice(-MAX_POSTS);
|
|
66
67
|
}
|
|
67
68
|
export function recordRoute(r) {
|
|
68
69
|
ensure(slackDir());
|
|
69
|
-
|
|
70
|
+
appendFileNoFollow(routesFile(), JSON.stringify(r) + "\n");
|
|
70
71
|
}
|
|
71
72
|
/** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
|
|
72
73
|
export function readRoutes(now = Date.now()) {
|
|
@@ -112,9 +113,7 @@ export function freshInbox(messages, now = Date.now()) {
|
|
|
112
113
|
const fileFor = (session) => `${normSession(session)}`;
|
|
113
114
|
export function appendInbox(session, msg) {
|
|
114
115
|
ensure(inboxDir());
|
|
115
|
-
|
|
116
|
-
mode: 0o600,
|
|
117
|
-
});
|
|
116
|
+
appendFileNoFollow(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n");
|
|
118
117
|
}
|
|
119
118
|
export function inboxPath(session) {
|
|
120
119
|
return join(inboxDir(), `${fileFor(session)}.jsonl`);
|
|
@@ -142,7 +141,7 @@ export function readOffset(session) {
|
|
|
142
141
|
/** `session` has read its inbox up to byte `offset`. */
|
|
143
142
|
export function writeOffset(session, offset) {
|
|
144
143
|
ensure(offsetDir());
|
|
145
|
-
|
|
144
|
+
writeFileNoFollow(offsetFile(session), String(offset));
|
|
146
145
|
}
|
|
147
146
|
/** True when `session` has saved an offset at all (it has read its inbox before). */
|
|
148
147
|
export function hasOffset(session) {
|
|
@@ -150,7 +149,7 @@ export function hasOffset(session) {
|
|
|
150
149
|
}
|
|
151
150
|
export function recordPending(p) {
|
|
152
151
|
ensure(slackDir());
|
|
153
|
-
|
|
152
|
+
appendFileNoFollow(pendingFile(), JSON.stringify(p) + "\n");
|
|
154
153
|
}
|
|
155
154
|
export function readPending() {
|
|
156
155
|
return readJsonl(pendingFile());
|
|
@@ -198,9 +197,7 @@ export function settlePending(now = Date.now()) {
|
|
|
198
197
|
else
|
|
199
198
|
held.push(p);
|
|
200
199
|
}
|
|
201
|
-
|
|
202
|
-
mode: 0o600,
|
|
203
|
-
});
|
|
200
|
+
writeFileNoFollow(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""));
|
|
204
201
|
return { delivered, expired, held };
|
|
205
202
|
}
|
|
206
203
|
/** Messages appended after byte `offset`, and the new offset. */
|
|
@@ -246,7 +243,7 @@ export function readInboxFrom(session, offset) {
|
|
|
246
243
|
}
|
|
247
244
|
export function registerLive(session, pid = process.pid) {
|
|
248
245
|
ensure(liveDir());
|
|
249
|
-
|
|
246
|
+
writeFileNoFollow(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }));
|
|
250
247
|
}
|
|
251
248
|
export function unregisterLive(session) {
|
|
252
249
|
rmSync(join(liveDir(), `${fileFor(session)}.json`), { force: true });
|
|
@@ -280,7 +277,7 @@ export function setAlias(handle, session) {
|
|
|
280
277
|
session: s,
|
|
281
278
|
since: prev && prev.session === s ? prev.since : new Date().toISOString(),
|
|
282
279
|
};
|
|
283
|
-
|
|
280
|
+
writeFileNoFollow(path, JSON.stringify(rec));
|
|
284
281
|
return rec;
|
|
285
282
|
}
|
|
286
283
|
/** Remove `handle`; true when something was removed. */
|
package/dist/turn-origin.mjs
CHANGED
|
@@ -70,7 +70,13 @@ export function isPromptEntry(entry) {
|
|
|
70
70
|
* The origin Claude Code recorded for a prompt entry, or null when it recorded none we know
|
|
71
71
|
* (then the entry is not stated at all, and the gateway reads that text as before).
|
|
72
72
|
* Precedence: summary / transcript-only → meta; channel; scheduled; peer; task; isMeta → meta;
|
|
73
|
-
* a human origin or a typed / queued prompt → human.
|
|
73
|
+
* a human origin or a typed / queued prompt → human; an SDK prompt → human.
|
|
74
|
+
*
|
|
75
|
+
* SDK prompts: under the Agent SDK (and `claude -p`) Claude Code records the caller's own
|
|
76
|
+
* prompts as promptSource / turnOrigin "sdk". The caller is the operator driving the session,
|
|
77
|
+
* so its prompt is the user's turn. Every non-user kind (wakeup, task notification, peer,
|
|
78
|
+
* hook or skill text, compaction summary) is matched above it first, so it keeps its own
|
|
79
|
+
* origin in SDK mode too; `source=sdk` still tells the gateway which surface the turn came from.
|
|
74
80
|
*/
|
|
75
81
|
export function entryOrigin(entry) {
|
|
76
82
|
if (!entry) return null;
|
|
@@ -85,6 +91,7 @@ export function entryOrigin(entry) {
|
|
|
85
91
|
if (entry.isMeta === true || turn === "system") return "meta";
|
|
86
92
|
if (turn === "human" || kind === "human") return "human";
|
|
87
93
|
if (entry.promptSource === "typed" || entry.promptSource === "queued") return "human";
|
|
94
|
+
if (entry.promptSource === "sdk" || turn === "sdk") return "human";
|
|
88
95
|
return null;
|
|
89
96
|
}
|
|
90
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.17.31
|
|
3
|
+
"version": "0.17.31",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|
|
28
28
|
"@types/node": "^20.10.0",
|
|
29
|
-
"@vitest/coverage-v8": "^4.1.
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
30
30
|
"tsx": "^4.23.1",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
|
-
"vitest": "^4.1.
|
|
32
|
+
"vitest": "^4.1.12"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"node": ">=18.0.0"
|
|
72
72
|
},
|
|
73
73
|
"overrides": {
|
|
74
|
-
"@vitest/mocker": "^4.1.
|
|
74
|
+
"@vitest/mocker": "^4.1.12",
|
|
75
75
|
"nanoid": "^3.3.18"
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -26,8 +26,9 @@ Mnemom gateway:
|
|
|
26
26
|
|
|
27
27
|
This plugin is the visible part: the band above the prompt, the status pane, the ◆ sign on the
|
|
28
28
|
spinner and the turn line, Mnemom trailers in commit attribution, a governance section in the
|
|
29
|
-
system prompt, and a guard that denies Bash commands which
|
|
30
|
-
`ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS`
|
|
29
|
+
system prompt, and a guard that denies Bash commands which plainly set or unset
|
|
30
|
+
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` (a text match on the
|
|
31
|
+
command, not a sandbox).
|
|
31
32
|
|
|
32
33
|
A session that was NOT launched by `mnemom agent` shows an amber "not governed" band. Nothing is
|
|
33
34
|
recorded for it. Tell the person to launch with `mnemom agent` (install: `npm i -g @mnemom/mnemom`,
|
|
@@ -79,9 +80,10 @@ server at `https://docs.mnemom.ai/mcp` answers questions from the published docs
|
|
|
79
80
|
|
|
80
81
|
- **"Why is there a ◆ on the spinner?"** The session is governed; every model call is being
|
|
81
82
|
checkpointed through the Mnemom gateway. `/mnemom status` shows by whom.
|
|
82
|
-
- **"Can I turn the gateway off for one command?"** Not
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
- **"Can I turn the gateway off for one command?"** Not from this session: the plugin denies
|
|
84
|
+
commands that set or unset those variables, by design, and you must not work around it. Use
|
|
85
|
+
another terminal, or `mnemom agent --no-plugin` for a session without the guard (the routing
|
|
86
|
+
itself still comes from the launcher).
|
|
85
87
|
- **"Is my score good?"** Grades A and up are strong; NR means not enough history yet, not a
|
|
86
88
|
bad score. Point them at the public page for the breakdown.
|
|
87
89
|
- **"Who sees what?"** The public rating and its chain attestation are public. Alignment and
|