@mnemom/mnemom 0.17.15 → 0.17.16
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 +31 -0
- package/dist/commands/agent-invite.d.ts +17 -2
- package/dist/commands/agent-invite.js +48 -5
- package/dist/commands/agent-sessions.d.ts +56 -1
- package/dist/commands/agent-sessions.js +196 -26
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +24 -6
- package/dist/index.js +21 -2
- package/dist/lib/agent.d.ts +56 -1
- package/dist/lib/agent.js +60 -6
- package/dist/lib/api.d.ts +18 -0
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +12 -0
- package/dist/lib/statusline-bin.js +2 -1
- package/dist/lib/statusline.d.ts +46 -1
- package/dist/lib/statusline.js +136 -33
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -67,10 +67,41 @@ header. In implicit mode the goal lags your steering by one request: a response
|
|
|
67
67
|
message carries `x-mnemom-goal-steer: queued` and the revision the agent acted under;
|
|
68
68
|
the next request reads the new revision (`x-mnemom-goal-revision`).
|
|
69
69
|
|
|
70
|
+
A sealed contract takes no in-session steering: nothing said inside the session (a lead's
|
|
71
|
+
direction included) changes what the judge measures against — a lead's direction must
|
|
72
|
+
already be a requirement in the seal. `--lead-directed` seals the standard line for a
|
|
73
|
+
lead-directed agent (e.g. an orchestrating agent that ships the work it was given), so its
|
|
74
|
+
own-branch pushes and draft/ready PR
|
|
75
|
+
creation are read against the contract instead of flagged as an unrequested effect:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
mnemom agent --goal "ship the lane's cards" --lead-directed
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires `--goal`; appends the line at most once, however many times the flag (or the same
|
|
82
|
+
requirement text given verbatim via `--requirement`) appears. It does not permit merging.
|
|
83
|
+
|
|
70
84
|
Make one mode the standing default with `mnemom agent config set goal_mode implicit`
|
|
71
85
|
(or `off`); `--goal` still seals for that launch and `--no-implicit` turns it off for one
|
|
72
86
|
launch. A sealed contract needs a per-launch statement, so it cannot be the default.
|
|
73
87
|
|
|
88
|
+
#### Watching a session: `mnemom agent show [ref] --follow`
|
|
89
|
+
|
|
90
|
+
`mnemom agent show` prints a session's latest state — the sealed contract, the current
|
|
91
|
+
verdict, guardrails, and (under `--json`) the last 50 judge verdicts. Add `--follow` to
|
|
92
|
+
keep the command running and print each new verdict as it lands, instead of re-running
|
|
93
|
+
`show` yourself:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
mnemom agent show --follow # watch the newest session
|
|
97
|
+
mnemom agent show my-task --follow --json # one JSON object per new verdict (NDJSON)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
It polls every 3 seconds by default; override with `--follow-interval <seconds>` for one
|
|
101
|
+
run, or `MNEMOM_AGENT_FOLLOW_INTERVAL=<seconds>` for every run in a shell. It prints each
|
|
102
|
+
verdict once — never a repeat on a later poll — and stops on Ctrl-C, or once the session
|
|
103
|
+
is no longer available.
|
|
104
|
+
|
|
74
105
|
#### Orchestrated launches: `MNEMOM_AGENT_PREFLIGHTED=1`
|
|
75
106
|
|
|
76
107
|
An orchestrator (the ADW fleet) that already checked sign-in and the µ balance before
|
|
@@ -53,8 +53,22 @@ export declare function assertCodeEnabled(orgId: string, orgLabel: string): Prom
|
|
|
53
53
|
export type EnsureSession = (opts: {
|
|
54
54
|
interactive?: boolean;
|
|
55
55
|
}) => Promise<boolean>;
|
|
56
|
-
/**
|
|
57
|
-
export declare
|
|
56
|
+
/** The accept route's 403 codes (mnemom-api `product-invite-accept.ts`). */
|
|
57
|
+
export declare const INVITE_ACCOUNT_MISMATCH = "invite_account_mismatch";
|
|
58
|
+
export declare const INVITE_EMAIL_MISMATCH = "invite_email_mismatch";
|
|
59
|
+
/** The account's email must be confirmed first. The accept route redeems for a matching
|
|
60
|
+
* unconfirmed email today, but the code (and the api's general `email_not_verified`) is
|
|
61
|
+
* still voiced as what it is, never as "a different account". */
|
|
62
|
+
export declare const INVITE_EMAIL_UNVERIFIED = "invite_email_unverified";
|
|
63
|
+
export declare const EMAIL_NOT_VERIFIED = "email_not_verified";
|
|
64
|
+
/**
|
|
65
|
+
* Map an accept failure to the sentence the customer needs. Null = not ours to explain.
|
|
66
|
+
* `signedInAs` (the account's email, when known) is named in the 403 sentences so the
|
|
67
|
+
* customer can see which account the CLI is using.
|
|
68
|
+
*/
|
|
69
|
+
export declare function explainAcceptError(err: unknown, signedInAs?: string | null): string | null;
|
|
70
|
+
/** The signed-in account's email, or null when it can't be read (never throws). */
|
|
71
|
+
export type SignedInEmail = () => Promise<string | null>;
|
|
58
72
|
/** Print the outcome of a successful accept. */
|
|
59
73
|
export declare function printAccepted(res: AcceptProductInviteResult): void;
|
|
60
74
|
/**
|
|
@@ -65,6 +79,7 @@ export declare function printAccepted(res: AcceptProductInviteResult): void;
|
|
|
65
79
|
export declare function agentInviteCommand(token: string | undefined, opts?: {
|
|
66
80
|
interactive?: boolean;
|
|
67
81
|
ensureSession?: EnsureSession;
|
|
82
|
+
signedInEmail?: SignedInEmail;
|
|
68
83
|
}): Promise<AcceptProductInviteResult>;
|
|
69
84
|
export interface AgentInvocation {
|
|
70
85
|
/** The positional `[scenario]` (may be a reserved sub-verb such as "invite"). */
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* All narration goes to stderr (the `mnemom agent` convention — stdout is for
|
|
20
20
|
* the dry-run plan only). Copy says "µ", never "MU".
|
|
21
21
|
*/
|
|
22
|
-
import { acceptProductInvite, CODE_NOT_ENABLED, getCodeEntitlement, MnemomApiError, } from "../lib/api.js";
|
|
22
|
+
import { acceptProductInvite, CODE_NOT_ENABLED, getCodeEntitlement, getMe, MnemomApiError, } from "../lib/api.js";
|
|
23
23
|
import { fmt } from "../lib/format.js";
|
|
24
24
|
import { isInteractive } from "../lib/prompt.js";
|
|
25
25
|
const say = (line = "") => console.error(line);
|
|
@@ -123,8 +123,38 @@ async function defaultEnsureSession(opts) {
|
|
|
123
123
|
const { ensureMnemomSession } = await import("./agent-setup.js");
|
|
124
124
|
return ensureMnemomSession(opts);
|
|
125
125
|
}
|
|
126
|
-
/**
|
|
127
|
-
export
|
|
126
|
+
/** The accept route's 403 codes (mnemom-api `product-invite-accept.ts`). */
|
|
127
|
+
export const INVITE_ACCOUNT_MISMATCH = "invite_account_mismatch";
|
|
128
|
+
export const INVITE_EMAIL_MISMATCH = "invite_email_mismatch";
|
|
129
|
+
/** The account's email must be confirmed first. The accept route redeems for a matching
|
|
130
|
+
* unconfirmed email today, but the code (and the api's general `email_not_verified`) is
|
|
131
|
+
* still voiced as what it is, never as "a different account". */
|
|
132
|
+
export const INVITE_EMAIL_UNVERIFIED = "invite_email_unverified";
|
|
133
|
+
export const EMAIL_NOT_VERIFIED = "email_not_verified";
|
|
134
|
+
/** ` (email)` for the signed-in account, or "" when it could not be read. */
|
|
135
|
+
function signedInLabel(signedInAs) {
|
|
136
|
+
return signedInAs ? ` (${sanitizeTerminalText(signedInAs)})` : "";
|
|
137
|
+
}
|
|
138
|
+
/** A 403 from the accept route: which one it is decides what the customer can do. */
|
|
139
|
+
function explainForbidden(err, signedInAs) {
|
|
140
|
+
const who = signedInLabel(signedInAs);
|
|
141
|
+
switch (err.code) {
|
|
142
|
+
case INVITE_EMAIL_UNVERIFIED:
|
|
143
|
+
case EMAIL_NOT_VERIFIED:
|
|
144
|
+
return `Your Mnemom account email${who} isn't confirmed yet. Open the confirmation email Mnemom sent to that address, then run the same command again.`;
|
|
145
|
+
case INVITE_EMAIL_MISMATCH:
|
|
146
|
+
case INVITE_ACCOUNT_MISMATCH:
|
|
147
|
+
return `This invitation was sent to a different email than the account you're signed in with${who}. Sign in with the address it was sent to (mnemom logout, then mnemom login), then run the same command again.`;
|
|
148
|
+
default:
|
|
149
|
+
return `The invitation couldn't be redeemed for the account you're signed in with${who}: ${sanitizeTerminalText(err.message)}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Map an accept failure to the sentence the customer needs. Null = not ours to explain.
|
|
154
|
+
* `signedInAs` (the account's email, when known) is named in the 403 sentences so the
|
|
155
|
+
* customer can see which account the CLI is using.
|
|
156
|
+
*/
|
|
157
|
+
export function explainAcceptError(err, signedInAs) {
|
|
128
158
|
if (!(err instanceof MnemomApiError))
|
|
129
159
|
return null;
|
|
130
160
|
switch (err.effectiveStatus) {
|
|
@@ -133,13 +163,25 @@ export function explainAcceptError(err) {
|
|
|
133
163
|
case 410:
|
|
134
164
|
return "This invitation has expired or was revoked — ask your Mnemom contact for a new one.";
|
|
135
165
|
case 403:
|
|
136
|
-
return
|
|
166
|
+
return explainForbidden(err, signedInAs);
|
|
137
167
|
case 409:
|
|
138
168
|
return "The invitation changed while redeeming — run the command again.";
|
|
139
169
|
default:
|
|
140
170
|
return null;
|
|
141
171
|
}
|
|
142
172
|
}
|
|
173
|
+
async function defaultSignedInEmail() {
|
|
174
|
+
try {
|
|
175
|
+
return (await getMe()).email ?? null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** The email to name in an accept error — looked up only for a 403, where it matters. */
|
|
182
|
+
async function signedInFor(err, lookup) {
|
|
183
|
+
return err instanceof MnemomApiError && err.effectiveStatus === 403 ? lookup() : null;
|
|
184
|
+
}
|
|
143
185
|
/** Print the outcome of a successful accept. */
|
|
144
186
|
export function printAccepted(res) {
|
|
145
187
|
if (res.already_accepted) {
|
|
@@ -180,7 +222,8 @@ export async function agentInviteCommand(token, opts = {}) {
|
|
|
180
222
|
result = await acceptProductInvite(trimmed);
|
|
181
223
|
}
|
|
182
224
|
catch (err) {
|
|
183
|
-
const
|
|
225
|
+
const signedInAs = await signedInFor(err, opts.signedInEmail ?? defaultSignedInEmail);
|
|
226
|
+
const explained = explainAcceptError(err, signedInAs);
|
|
184
227
|
if (explained)
|
|
185
228
|
throw new Error(`mnemom agent --invite: ${explained}`, { cause: err });
|
|
186
229
|
throw err;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* only the server's hash is shown, labelled "sealed hash".
|
|
15
15
|
*/
|
|
16
16
|
import { type GoalMode, type SessionRecord } from "../lib/agent-ledger.js";
|
|
17
|
-
import { type AgentSessionResponse } from "../lib/api.js";
|
|
17
|
+
import { type AgentSessionResponse, type AgentSessionVerdictTrailEntry } from "../lib/api.js";
|
|
18
18
|
import { type GoalShowResponse } from "../lib/goal-api.js";
|
|
19
19
|
export interface SessionCommandDeps {
|
|
20
20
|
/** The ledger, newest first. */
|
|
@@ -33,6 +33,20 @@ export interface SessionCommandDeps {
|
|
|
33
33
|
cellGateway?: () => string;
|
|
34
34
|
out: (line: string) => void;
|
|
35
35
|
err: (line: string) => void;
|
|
36
|
+
/**
|
|
37
|
+
* `--follow`: sleep for `ms` between polls. Injected so tests can drive the loop without a
|
|
38
|
+
* real timer. Defaults to a real `setTimeout` — deliberately NOT unref'd: `--follow` is a
|
|
39
|
+
* foreground watch loop invoked explicitly by the user, and an unref'd timer does not keep
|
|
40
|
+
* Node's event loop alive, so the process would exit right after the one-shot snapshot
|
|
41
|
+
* instead of polling (reproduced standalone: a loop of unref'd-timer sleeps prints zero
|
|
42
|
+
* iterations because nothing else is pending).
|
|
43
|
+
*/
|
|
44
|
+
sleep?: (ms: number) => Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* `--follow`: register a one-shot SIGINT handler, returning an unregister function. Injected so
|
|
47
|
+
* tests can simulate Ctrl-C without sending a real signal. Defaults to `process.once("SIGINT")`.
|
|
48
|
+
*/
|
|
49
|
+
onSigint?: (handler: () => void) => () => void;
|
|
36
50
|
}
|
|
37
51
|
/** The production wiring (index.ts). Lazy so a test that mocks lib/api without
|
|
38
52
|
* this export can still import index.ts. */
|
|
@@ -82,6 +96,41 @@ export declare function formatSessionsTable(rows: SessionRecord[], opts: {
|
|
|
82
96
|
}): string[];
|
|
83
97
|
/** `turns 3/10 (crossed)` — value against its ceiling (none ⇒ just the value). */
|
|
84
98
|
export declare function gauge(label: string, value: number, ceiling: number | null, fired: boolean | undefined): string;
|
|
99
|
+
/**
|
|
100
|
+
* One verdict-trail entry, human-readable — shared by the one-shot trail section in
|
|
101
|
+
* `buildShowView` and by `--follow`'s streamed lines, so the two never drift apart.
|
|
102
|
+
*/
|
|
103
|
+
export declare function formatTrailEntry(e: AgentSessionVerdictTrailEntry): string;
|
|
104
|
+
/**
|
|
105
|
+
* The verdict trail is a bounded 50-entry ring: once it wraps, new entries at one end push
|
|
106
|
+
* old ones out, so an array index does not name the same verdict across two polls. The stable
|
|
107
|
+
* identity is the (request id, turn) pair the gateway recorded it under — `request_id` is
|
|
108
|
+
* null-able (the judge can run without one), joined with a separator that can't collide with a
|
|
109
|
+
* turn number.
|
|
110
|
+
*/
|
|
111
|
+
export declare function verdictTrailKey(e: Pick<AgentSessionVerdictTrailEntry, "request_id" | "turn">): string;
|
|
112
|
+
/**
|
|
113
|
+
* Best-effort "has this session ended" check for `--follow`'s exit condition. No field is locked
|
|
114
|
+
* in on the wire yet: MNE-8149 adds an `expired` seal state on the gateway's row, and a later
|
|
115
|
+
* mnemom-api card is expected to carry it through to this response — so this reads defensively,
|
|
116
|
+
* off either the top-level response or the `sealed` block, and returns null (never throws) when
|
|
117
|
+
* neither carries a recognized value, which is every server today.
|
|
118
|
+
*/
|
|
119
|
+
export declare function sessionEndedState(remote: AgentSessionResponse): "expired" | "ended" | null;
|
|
120
|
+
/** Default and floor for `--follow`'s poll interval. */
|
|
121
|
+
export declare const DEFAULT_FOLLOW_INTERVAL_MS = 3000;
|
|
122
|
+
export declare const MIN_FOLLOW_INTERVAL_MS = 500;
|
|
123
|
+
/** Seconds; consulted only when `--follow-interval` was not passed. */
|
|
124
|
+
export declare const FOLLOW_INTERVAL_ENV_VAR = "MNEMOM_AGENT_FOLLOW_INTERVAL";
|
|
125
|
+
/** Resolve the poll interval: explicit opt > env override (seconds) > default, floored. */
|
|
126
|
+
export declare function resolveFollowIntervalMs(opts: {
|
|
127
|
+
followIntervalMs?: number;
|
|
128
|
+
}): number;
|
|
129
|
+
export declare function followVerdictTrail(record: SessionRecord, agentId: string, deps: SessionCommandDeps, opts: {
|
|
130
|
+
json?: boolean;
|
|
131
|
+
intervalMs: number;
|
|
132
|
+
seenKeys: Set<string>;
|
|
133
|
+
}): Promise<void>;
|
|
85
134
|
/**
|
|
86
135
|
* The human view for `mnemom agent show`: the local record, then the live
|
|
87
136
|
* gateway state (or a dim "live state unavailable (<reason>)" line when
|
|
@@ -101,6 +150,8 @@ export interface ShowJson {
|
|
|
101
150
|
verdict: AgentSessionResponse["verdict"];
|
|
102
151
|
turn: AgentSessionResponse["turn"];
|
|
103
152
|
guardrails: AgentSessionResponse["guardrails"];
|
|
153
|
+
/** R10 (MNE-8200): oldest first (the gateway's own wire order), unlike the human view. */
|
|
154
|
+
verdict_trail: AgentSessionResponse["verdict_trail"];
|
|
104
155
|
} | {
|
|
105
156
|
source: "gateway";
|
|
106
157
|
available: false;
|
|
@@ -116,6 +167,10 @@ export interface AgentSessionsOptions {
|
|
|
116
167
|
export declare function agentSessionsCommand(opts: AgentSessionsOptions, deps: SessionCommandDeps): Promise<void>;
|
|
117
168
|
export interface AgentShowOptions {
|
|
118
169
|
json?: boolean;
|
|
170
|
+
/** Keep watching after the snapshot and print each new verdict as it lands. */
|
|
171
|
+
follow?: boolean;
|
|
172
|
+
/** `--follow`'s poll interval, in ms. Undefined ⇒ {@link resolveFollowIntervalMs}'s default/env. */
|
|
173
|
+
followIntervalMs?: number;
|
|
119
174
|
}
|
|
120
175
|
/** Why the live read could not happen, from the thrown error. */
|
|
121
176
|
export declare function unavailableReason(err: unknown): string;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { readSessionRecords } from "../lib/agent-ledger.js";
|
|
17
17
|
import { getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
|
|
18
18
|
import { resumeCommand, sameOrigin } from "../lib/agent.js";
|
|
19
|
-
import { getAgentSession, MnemomApiError } from "../lib/api.js";
|
|
19
|
+
import { getAgentSession, MnemomApiError, } from "../lib/api.js";
|
|
20
20
|
import { fmt, formatRelativeAge } from "../lib/format.js";
|
|
21
21
|
import { goalControlFetch } from "../lib/goal-api.js";
|
|
22
22
|
/** The production wiring (index.ts). Lazy so a test that mocks lib/api without
|
|
@@ -35,6 +35,15 @@ export const liveSessionDeps = {
|
|
|
35
35
|
cellGateway: () => getGatewayUrl(),
|
|
36
36
|
out: (line) => console.log(line),
|
|
37
37
|
err: (line) => console.error(line),
|
|
38
|
+
// NOT unref'd: see SessionCommandDeps.sleep's doc comment above — this timer must keep the
|
|
39
|
+
// process alive, or --follow exits right after the one-shot snapshot instead of polling.
|
|
40
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
41
|
+
setTimeout(resolve, ms);
|
|
42
|
+
}),
|
|
43
|
+
onSigint: (handler) => {
|
|
44
|
+
process.once("SIGINT", handler);
|
|
45
|
+
return () => process.removeListener("SIGINT", handler);
|
|
46
|
+
},
|
|
38
47
|
};
|
|
39
48
|
export const NO_SESSIONS_MESSAGE = "No sessions yet — run `mnemom agent` to start one.";
|
|
40
49
|
export const DEFAULT_SESSIONS_LIMIT = 20;
|
|
@@ -162,6 +171,130 @@ export function gauge(label, value, ceiling, fired) {
|
|
|
162
171
|
const ratio = ceiling === null ? String(value) : `${value}/${ceiling}`;
|
|
163
172
|
return `${label} ${ratio}${fired ? " (crossed)" : ""}`;
|
|
164
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* One verdict-trail entry, human-readable — shared by the one-shot trail section in
|
|
176
|
+
* `buildShowView` and by `--follow`'s streamed lines, so the two never drift apart.
|
|
177
|
+
*/
|
|
178
|
+
export function formatTrailEntry(e) {
|
|
179
|
+
const fence = e.fence_breach === null ? "" : e.fence_breach ? " [fence breach]" : "";
|
|
180
|
+
return `turn ${e.turn} ${e.verdict.toUpperCase()} — ${e.reason}${fence} (${e.at})`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The verdict trail is a bounded 50-entry ring: once it wraps, new entries at one end push
|
|
184
|
+
* old ones out, so an array index does not name the same verdict across two polls. The stable
|
|
185
|
+
* identity is the (request id, turn) pair the gateway recorded it under — `request_id` is
|
|
186
|
+
* null-able (the judge can run without one), joined with a separator that can't collide with a
|
|
187
|
+
* turn number.
|
|
188
|
+
*/
|
|
189
|
+
export function verdictTrailKey(e) {
|
|
190
|
+
return `${e.request_id ?? ""}#${e.turn}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Best-effort "has this session ended" check for `--follow`'s exit condition. No field is locked
|
|
194
|
+
* in on the wire yet: MNE-8149 adds an `expired` seal state on the gateway's row, and a later
|
|
195
|
+
* mnemom-api card is expected to carry it through to this response — so this reads defensively,
|
|
196
|
+
* off either the top-level response or the `sealed` block, and returns null (never throws) when
|
|
197
|
+
* neither carries a recognized value, which is every server today.
|
|
198
|
+
*/
|
|
199
|
+
export function sessionEndedState(remote) {
|
|
200
|
+
const as = (v) => v === "expired" || v === "ended" ? v : null;
|
|
201
|
+
const top = as(remote.state);
|
|
202
|
+
if (top)
|
|
203
|
+
return top;
|
|
204
|
+
if (remote.sealed) {
|
|
205
|
+
return as(remote.sealed.state);
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/** Default and floor for `--follow`'s poll interval. */
|
|
210
|
+
export const DEFAULT_FOLLOW_INTERVAL_MS = 3000;
|
|
211
|
+
export const MIN_FOLLOW_INTERVAL_MS = 500;
|
|
212
|
+
/** Seconds; consulted only when `--follow-interval` was not passed. */
|
|
213
|
+
export const FOLLOW_INTERVAL_ENV_VAR = "MNEMOM_AGENT_FOLLOW_INTERVAL";
|
|
214
|
+
/** Resolve the poll interval: explicit opt > env override (seconds) > default, floored. */
|
|
215
|
+
export function resolveFollowIntervalMs(opts) {
|
|
216
|
+
if (opts.followIntervalMs !== undefined) {
|
|
217
|
+
return Math.max(MIN_FOLLOW_INTERVAL_MS, opts.followIntervalMs);
|
|
218
|
+
}
|
|
219
|
+
const envSeconds = Number(process.env[FOLLOW_INTERVAL_ENV_VAR]);
|
|
220
|
+
if (Number.isFinite(envSeconds) && envSeconds > 0) {
|
|
221
|
+
return Math.max(MIN_FOLLOW_INTERVAL_MS, envSeconds * 1000);
|
|
222
|
+
}
|
|
223
|
+
return DEFAULT_FOLLOW_INTERVAL_MS;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* `--follow`: after the one-shot snapshot, keep polling the same live read-back
|
|
227
|
+
* (`deps.fetchSession`, the same `/internal/goal/session` route `show` already uses) and print
|
|
228
|
+
* each newly-landed verdict-trail entry once — a `tail -f` over the gateway's 50-entry ring.
|
|
229
|
+
* `opts.seenKeys` seeds the baseline (every entry already visible in the one-shot print); only
|
|
230
|
+
* entries whose {@link verdictTrailKey} is not yet in that set are printed, so a poll never
|
|
231
|
+
* repeats a verdict the ring-wrap could otherwise make look "new" by index.
|
|
232
|
+
*
|
|
233
|
+
* Exits (resolves) on SIGINT, or once {@link sessionEndedState} reads `"expired"`/`"ended"`, or
|
|
234
|
+
* once the gateway reports the session gone (`MnemomApiError` with `code === "agent_not_found"`).
|
|
235
|
+
* An auth failure (401/403 — an expired or revoked key) is NOT transient: it prints a clear
|
|
236
|
+
* error via `deps.err` and exits (`process.exitCode = 1`) rather than retrying forever. Any other
|
|
237
|
+
* fetch failure — including the documented `gateway_unavailable` — is treated as transient: the
|
|
238
|
+
* loop backs off and retries, and after {@link FOLLOW_FAILURE_WARNING_THRESHOLD} consecutive
|
|
239
|
+
* failures prints exactly one dim warning line to stderr (not one per failure) so a persistently
|
|
240
|
+
* broken poll isn't silent, then keeps retrying; a single success resets the counter.
|
|
241
|
+
*/
|
|
242
|
+
const FOLLOW_FAILURE_WARNING_THRESHOLD = 5;
|
|
243
|
+
function isAuthError(e) {
|
|
244
|
+
return e instanceof MnemomApiError && (e.effectiveStatus === 401 || e.effectiveStatus === 403);
|
|
245
|
+
}
|
|
246
|
+
export async function followVerdictTrail(record, agentId, deps, opts) {
|
|
247
|
+
// NOT unref'd (see SessionCommandDeps.sleep's doc comment) — this is the foreground poll
|
|
248
|
+
// delay for --follow, and it must keep the process alive between polls.
|
|
249
|
+
const sleep = deps.sleep ??
|
|
250
|
+
((ms) => new Promise((resolve) => {
|
|
251
|
+
setTimeout(resolve, ms);
|
|
252
|
+
}));
|
|
253
|
+
const seen = opts.seenKeys;
|
|
254
|
+
let stopped = false;
|
|
255
|
+
let consecutiveFailures = 0;
|
|
256
|
+
const unregister = deps.onSigint?.(() => {
|
|
257
|
+
stopped = true;
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
while (!stopped) {
|
|
261
|
+
await sleep(opts.intervalMs);
|
|
262
|
+
if (stopped)
|
|
263
|
+
break;
|
|
264
|
+
let remote;
|
|
265
|
+
try {
|
|
266
|
+
remote = await deps.fetchSession(record.conversation_id, agentId, record.org_id);
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
if (e instanceof MnemomApiError && e.code === "agent_not_found")
|
|
270
|
+
return;
|
|
271
|
+
if (isAuthError(e)) {
|
|
272
|
+
deps.err(`Error: the gateway rejected the credential (${e.effectiveStatus}) — this key may be expired or revoked: ${e.message}`);
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
consecutiveFailures += 1;
|
|
277
|
+
if (consecutiveFailures === FOLLOW_FAILURE_WARNING_THRESHOLD) {
|
|
278
|
+
deps.err(fmt.dim(`--follow: ${consecutiveFailures} consecutive polls have failed; still retrying…`));
|
|
279
|
+
}
|
|
280
|
+
continue; // transient (gateway_unavailable or anything else) — back off and retry
|
|
281
|
+
}
|
|
282
|
+
consecutiveFailures = 0;
|
|
283
|
+
for (const entry of remote.verdict_trail ?? []) {
|
|
284
|
+
const key = verdictTrailKey(entry);
|
|
285
|
+
if (seen.has(key))
|
|
286
|
+
continue;
|
|
287
|
+
seen.add(key);
|
|
288
|
+
deps.out(opts.json ? JSON.stringify(entry) : formatTrailEntry(entry));
|
|
289
|
+
}
|
|
290
|
+
if (sessionEndedState(remote))
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
unregister?.();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
165
298
|
/**
|
|
166
299
|
* The human view for `mnemom agent show`: the local record, then the live
|
|
167
300
|
* gateway state (or a dim "live state unavailable (<reason>)" line when
|
|
@@ -223,6 +356,15 @@ export function buildShowView(local, remote, reason) {
|
|
|
223
356
|
else {
|
|
224
357
|
L.push(fmt.label("verdict: ", fmt.dim("none yet")));
|
|
225
358
|
}
|
|
359
|
+
// R10 (MNE-8200): the last up-to-50 judged verdicts. The gateway returns them oldest first;
|
|
360
|
+
// shown here newest first (most recent verdict closest to the cursor) — the reverse of the
|
|
361
|
+
// wire order, so don't reuse `remote.verdict_trail`'s index as the turn number.
|
|
362
|
+
if (remote.verdict_trail && remote.verdict_trail.length > 0) {
|
|
363
|
+
L.push(fmt.label("verdict trail: ", `last ${remote.verdict_trail.length} (newest first)`));
|
|
364
|
+
for (const e of [...remote.verdict_trail].reverse()) {
|
|
365
|
+
L.push(fmt.label(" ", formatTrailEntry(e)));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
226
368
|
if (remote.guardrails) {
|
|
227
369
|
const { ceilings, state } = remote.guardrails;
|
|
228
370
|
if (state) {
|
|
@@ -257,6 +399,7 @@ export function buildShowJson(local, remote, reason) {
|
|
|
257
399
|
verdict: remote.verdict,
|
|
258
400
|
turn: remote.turn,
|
|
259
401
|
guardrails: remote.guardrails,
|
|
402
|
+
verdict_trail: remote.verdict_trail,
|
|
260
403
|
}
|
|
261
404
|
: { source: "gateway", available: false, reason: reason ?? "unknown" },
|
|
262
405
|
};
|
|
@@ -300,6 +443,22 @@ export function liveGoalLine(body) {
|
|
|
300
443
|
const cut = statement.slice(0, LIVE_GOAL_STATEMENT_MAX - 1).trimEnd();
|
|
301
444
|
return `${rev} — "${cut}…" (full: mnemom agent goal show)`;
|
|
302
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Can this session's live state (and hence `--follow`) ever be read? Two cases never resolve
|
|
448
|
+
* regardless of retries — no agent id was recorded at launch, or the session ran on a different
|
|
449
|
+
* cell than this CLI is pointed at — so they're resolved once, up front, as a named reason rather
|
|
450
|
+
* than inside the fetch's catch (where a genuinely transient failure also lands).
|
|
451
|
+
*/
|
|
452
|
+
function resolveFollowTarget(record, cell) {
|
|
453
|
+
if (record.agent_id === null)
|
|
454
|
+
return { reason: "agent id was not resolved at launch" };
|
|
455
|
+
if (cell !== undefined && !sameOrigin(record.gateway_host, cell)) {
|
|
456
|
+
return {
|
|
457
|
+
reason: `this session ran on ${record.gateway_host}, not this CLI's cell (${cell}) — point the CLI at that cell to read it`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return { agentId: record.agent_id };
|
|
461
|
+
}
|
|
303
462
|
/** `mnemom agent show [ref] [--json]` */
|
|
304
463
|
export async function agentShowCommand(ref, opts, deps) {
|
|
305
464
|
const records = deps.readRecords();
|
|
@@ -325,18 +484,16 @@ export async function agentShowCommand(ref, opts, deps) {
|
|
|
325
484
|
return;
|
|
326
485
|
}
|
|
327
486
|
const { record, others } = resolved;
|
|
487
|
+
const cell = deps.cellGateway?.();
|
|
488
|
+
const target = resolveFollowTarget(record, cell);
|
|
328
489
|
let remote = null;
|
|
329
490
|
let reason;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
reason = "agent id was not resolved at launch";
|
|
333
|
-
}
|
|
334
|
-
else if (cell !== undefined && !sameOrigin(record.gateway_host, cell)) {
|
|
335
|
-
reason = `this session ran on ${record.gateway_host}, not this CLI's cell (${cell}) — point the CLI at that cell to read it`;
|
|
491
|
+
if ("reason" in target) {
|
|
492
|
+
reason = target.reason;
|
|
336
493
|
}
|
|
337
494
|
else {
|
|
338
495
|
try {
|
|
339
|
-
remote = await deps.fetchSession(record.conversation_id,
|
|
496
|
+
remote = await deps.fetchSession(record.conversation_id, target.agentId, record.org_id);
|
|
340
497
|
}
|
|
341
498
|
catch (e) {
|
|
342
499
|
reason = unavailableReason(e);
|
|
@@ -344,27 +501,40 @@ export async function agentShowCommand(ref, opts, deps) {
|
|
|
344
501
|
}
|
|
345
502
|
if (opts.json) {
|
|
346
503
|
deps.out(JSON.stringify(buildShowJson(record, remote, reason), null, 2));
|
|
347
|
-
return;
|
|
348
504
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
505
|
+
else {
|
|
506
|
+
for (const line of buildShowView(record, remote, reason))
|
|
507
|
+
deps.out(line);
|
|
508
|
+
if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
|
|
509
|
+
// Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
|
|
510
|
+
let line;
|
|
511
|
+
try {
|
|
512
|
+
line = liveGoalLine(await deps.fetchGoal(record));
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
line = fmt.dim("(live goal unavailable)");
|
|
516
|
+
}
|
|
517
|
+
deps.out(fmt.label("live goal: ", line));
|
|
356
518
|
}
|
|
357
|
-
|
|
358
|
-
|
|
519
|
+
if (others.length > 0) {
|
|
520
|
+
deps.out("");
|
|
521
|
+
deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
|
|
522
|
+
for (const o of others) {
|
|
523
|
+
deps.out(fmt.dim(` ${o.conversation_id.slice(0, 8)} ${formatRelativeAge(o.started_at)}`));
|
|
524
|
+
}
|
|
359
525
|
}
|
|
360
|
-
deps.out(fmt.label("live goal: ", line));
|
|
361
|
-
}
|
|
362
|
-
if (others.length > 0) {
|
|
363
526
|
deps.out("");
|
|
364
|
-
deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
|
|
365
|
-
for (const o of others) {
|
|
366
|
-
deps.out(fmt.dim(` ${o.conversation_id.slice(0, 8)} ${formatRelativeAge(o.started_at)}`));
|
|
367
|
-
}
|
|
368
527
|
}
|
|
369
|
-
|
|
528
|
+
if (!opts.follow)
|
|
529
|
+
return;
|
|
530
|
+
if ("reason" in target) {
|
|
531
|
+
deps.err(fmt.dim(`--follow: ${target.reason} — nothing to watch.`));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const seenKeys = new Set((remote?.verdict_trail ?? []).map(verdictTrailKey));
|
|
535
|
+
await followVerdictTrail(record, target.agentId, deps, {
|
|
536
|
+
json: opts.json,
|
|
537
|
+
intervalMs: resolveFollowIntervalMs(opts),
|
|
538
|
+
seenKeys,
|
|
539
|
+
});
|
|
370
540
|
}
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -40,12 +40,16 @@ export interface AgentOptions {
|
|
|
40
40
|
budget?: string;
|
|
41
41
|
maxTurns?: string;
|
|
42
42
|
stall?: string;
|
|
43
|
+
/** `--mode nudge|enforce` (MNE-8199): see `ContractFlags.mode` (lib/agent.ts). */
|
|
44
|
+
mode?: string;
|
|
43
45
|
/**
|
|
44
46
|
* `--implicit`: goal alignment with no sealed contract — the gateway infers the
|
|
45
47
|
* goal from the user's messages and revises it as they steer; the ceilings above
|
|
46
48
|
* ride on the `x-mnemom-goal-mode` header. Exclusive with the contract flags.
|
|
47
49
|
*/
|
|
48
50
|
implicit?: boolean;
|
|
51
|
+
/** `--lead-directed`: see `ContractFlags.leadDirected` (lib/agent.ts, MNE-8152). */
|
|
52
|
+
leadDirected?: boolean;
|
|
49
53
|
/** x-mnemom-agent value (defaults to "code-agent"). */
|
|
50
54
|
agent?: string;
|
|
51
55
|
conversationId?: string;
|
|
@@ -251,6 +255,8 @@ export interface ResolvedCli {
|
|
|
251
255
|
/** The basename, e.g. "claude" — selects the launch adapter. */
|
|
252
256
|
name: string;
|
|
253
257
|
}
|
|
258
|
+
/** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
|
|
259
|
+
export declare function requestedCli(cliOpt: string | undefined, env: NodeJS.ProcessEnv): string;
|
|
254
260
|
/**
|
|
255
261
|
* Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
|
|
256
262
|
* name or a full path; default "claude". A value with a path separator is used
|