@reticlehq/server 2.6.0 → 2.7.0
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/SKILL.md +201 -18
- package/dist/bridge/bridge.d.ts +6 -1
- package/dist/bridge/bridge.js +27 -4
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.js +3 -11
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.js +10 -1
- package/dist/cli.js +40 -9
- package/dist/daemon/daemon-resilience.js +2 -2
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/events/predicate-eval.d.ts +7 -3
- package/dist/events/predicate-eval.js +159 -92
- package/dist/events/predicate-parse.d.ts +1 -1
- package/dist/events/predicate-parse.js +18 -4
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +34 -1
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.js +26 -3
- package/dist/http-server.js +11 -1
- package/dist/index.js +26 -2
- package/dist/init/agent-rules.js +6 -1
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +15 -0
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +19 -1
- package/dist/init/plan.js +20 -3
- package/dist/init/run.js +38 -9
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/mcp/mcp-proxy.js +10 -3
- package/dist/mcp/mcp.js +35 -0
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +4 -4
- package/dist/session/no-session-diagnosis.js +60 -29
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-manager.d.ts +9 -0
- package/dist/session/session-manager.js +11 -0
- package/dist/session/session.d.ts +8 -0
- package/dist/session/session.js +12 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/daemon-telemetry.d.ts +4 -4
- package/dist/telemetry/daemon-telemetry.js +8 -4
- package/dist/telemetry/session-metrics.d.ts +3 -3
- package/dist/telemetry/session-metrics.js +4 -4
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +3 -1
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +32 -10
- package/dist/tools/assert-verdict.js +7 -0
- package/dist/tools/error-recovery.js +6 -1
- package/dist/tools/invoke-tool.js +11 -10
- package/dist/tools/tool-kit.d.ts +52 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +9 -9
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/agent-cheatsheet.md +14 -2
- package/docs/gate-plan.md +1 -1
- package/docs/gates.md +1 -1
- package/docs/telemetry-contract.md +13 -1
- package/docs/telemetry.md +9 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
package/dist/init/snippets.js
CHANGED
|
@@ -50,7 +50,7 @@ export function nextReticleDevFile(port, projectId, testids = [], stores = []) {
|
|
|
50
50
|
const fields = '' === base ? '' : `${base.slice(1, -1).trim()}, `;
|
|
51
51
|
const ids = testids.map((t) => `'${t}'`).join(', ');
|
|
52
52
|
const storeBlock = 0 === stores.length
|
|
53
|
-
? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
|
|
53
|
+
? ' // No state library detected. If you add one, register it here — see node_modules/@reticlehq/server/docs/usage.md.'
|
|
54
54
|
: stores.map((h) => ` // import your store, then: ${h}`).join('\n');
|
|
55
55
|
return `'use client';
|
|
56
56
|
import { useEffect } from 'react';
|
|
@@ -174,7 +174,7 @@ Start the daemon BEFORE \`astro dev\`, so the token file exists when the config
|
|
|
174
174
|
export function viteDevModuleFile(testids, stores) {
|
|
175
175
|
const ids = testids.map((t) => `'${t}'`).join(', ');
|
|
176
176
|
const storeBlock = 0 === stores.length
|
|
177
|
-
? ' // No state library detected. If you add one, register it here — see docs/usage.md.'
|
|
177
|
+
? ' // No state library detected. If you add one, register it here — see node_modules/@reticlehq/server/docs/usage.md.'
|
|
178
178
|
: stores.map((h) => ` // import your store, then: ${h}`).join('\n');
|
|
179
179
|
return `// Dev-only. Imported automatically by @reticlehq/vite-plugin — you do not need to import it.
|
|
180
180
|
// Self-guards on import.meta.env.DEV, so it is a no-op in a production build.
|
|
@@ -216,8 +216,22 @@ export const NEXT_LAYOUT_MANUAL = `Mount <ReticleDev /> in your root layout (app
|
|
|
216
216
|
* where a bare `@reticlehq/react` import resolves. A bare import in a plain index.html does NOT resolve in
|
|
217
217
|
* the browser, so we never tell a bundled app to do that (the old advice silently failed for CRA).
|
|
218
218
|
*/
|
|
219
|
-
export function htmlManual(port, projectId) {
|
|
220
|
-
const
|
|
219
|
+
export function htmlManual(port, projectId, pairingToken) {
|
|
220
|
+
const base = connectArg(port, projectId);
|
|
221
|
+
// The token belongs INSIDE the call the user pastes. Every other stack has a build step to inline
|
|
222
|
+
// it (the Vite plugin's `define`, Next's NEXT_PUBLIC_*, Astro's config, CRA's .env); this path has
|
|
223
|
+
// none, so `init` inlines the literal it already read. Without it the bridge closes the socket
|
|
224
|
+
// with AUTH_FAILED and no session ever appears — see Bridge's hello handler.
|
|
225
|
+
const withToken = pairingToken === undefined || 0 === pairingToken.length
|
|
226
|
+
? base
|
|
227
|
+
: `{ ${[base.length > 0 ? base.slice(1, -1).trim() : '', `token: '${pairingToken}'`]
|
|
228
|
+
.filter((p) => p.length > 0)
|
|
229
|
+
.join(', ')} }`;
|
|
230
|
+
const tokenNote = pairingToken === undefined || 0 === pairingToken.length
|
|
231
|
+
? ''
|
|
232
|
+
: `\n\n The \`token\` is this machine's pairing token, read from ~/.reticle/pairing-token. Keep it: the
|
|
233
|
+
bridge REJECTS a connect without it ("authentication failed") and no session appears. It is
|
|
234
|
+
per-machine and local-only, so do not commit it — a teammate's daemon mints their own.`;
|
|
221
235
|
return `No Vite/Next plugin detected — wire the dev-only connect by hand. Pick the form for your setup:
|
|
222
236
|
|
|
223
237
|
• Bundled app (Create React App, webpack, Parcel, Vue/Svelte CLI, etc.) — add to your ENTRY module
|
|
@@ -226,9 +240,9 @@ export function htmlManual(port, projectId) {
|
|
|
226
240
|
if (window.location.hostname === 'localhost') {
|
|
227
241
|
void import('@reticlehq/react').then(({ reticle, install }) => {
|
|
228
242
|
install();
|
|
229
|
-
reticle.connect(${
|
|
243
|
+
reticle.connect(${withToken});
|
|
230
244
|
});
|
|
231
|
-
}
|
|
245
|
+
}${tokenNote}
|
|
232
246
|
|
|
233
247
|
• Plain static HTML with no build step — the browser can't resolve the bare '@reticlehq/react' import, so
|
|
234
248
|
bundle the SDK once (e.g. \`npx esbuild\`) and point a dev-only <script type="module"> at the output,
|
|
@@ -282,6 +296,57 @@ declare const __RETICLE_ROOT__: string | undefined;
|
|
|
282
296
|
declare const __RETICLE_SDK_VERSION__: string | undefined;
|
|
283
297
|
`;
|
|
284
298
|
}
|
|
299
|
+
/** Where a Nuxt dev-only client plugin belongs. `.client` keeps it out of SSR; Nuxt auto-registers it. */
|
|
300
|
+
export const NUXT_PLUGIN_PATH = 'app/plugins/reticle.client.ts';
|
|
301
|
+
/**
|
|
302
|
+
* The Nuxt recipe, written out in full because every trap in it is one somebody actually hit.
|
|
303
|
+
*
|
|
304
|
+
* Reported from the field, in the order they were hit: `init` classified a Nuxt 4 app as `html`, so
|
|
305
|
+
* it installed a package named `@reticlehq/react` (with `react` in its peer dependencies) into a Vue
|
|
306
|
+
* codebase — the reporter only continued after auditing our dist to confirm there are no React
|
|
307
|
+
* imports at runtime, which most people will not do. It then handed over a snippet guarded on
|
|
308
|
+
* `window.location.hostname === 'localhost'`, which fails twice over in Nuxt: `window` does not
|
|
309
|
+
* exist during SSR, and the dev host here was a hosts-file alias (required for the backend's
|
|
310
|
+
* white-label origin detection), so the guard was false and the connect never ran — no error, no log
|
|
311
|
+
* line, nothing to debug. And nothing said a running dev server does not pick up a new plugin.
|
|
312
|
+
*
|
|
313
|
+
* So: `import.meta.dev` (build-time, host-independent) instead of a hostname check, `.client.ts`
|
|
314
|
+
* instead of an SSR guard, the framework-neutral sensor instead of the React kit, the non-localhost
|
|
315
|
+
* flag named up front, and the restart said out loud.
|
|
316
|
+
*/
|
|
317
|
+
export function nuxtManual(port, projectId) {
|
|
318
|
+
const base = connectArg(port, projectId);
|
|
319
|
+
const fields = '' === base ? '' : base.slice(1, -1).trim();
|
|
320
|
+
const connect = '' === fields ? 'reticle.connect()' : `reticle.connect({ ${fields} })`;
|
|
321
|
+
return `Nuxt owns its own Vite instance and renders its own HTML, so there is no vite.config to patch
|
|
322
|
+
and no index.html to inject into. Wire it with a dev-only CLIENT plugin, which is Nuxt's own idiom:
|
|
323
|
+
|
|
324
|
+
1. Create ${NUXT_PLUGIN_PATH}:
|
|
325
|
+
|
|
326
|
+
export default defineNuxtPlugin(() => {
|
|
327
|
+
// import.meta.dev is the correct guard: it is resolved at build time, so it does not care
|
|
328
|
+
// what hostname you develop on. Do NOT guard on window.location.hostname === 'localhost' —
|
|
329
|
+
// that is false on any hosts-file alias or LAN address, and window does not exist in SSR.
|
|
330
|
+
if (!import.meta.dev) return
|
|
331
|
+
void import('@reticlehq/browser').then(({ reticle }) => {
|
|
332
|
+
${connect}
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
The .client.ts suffix is load-bearing: it is what keeps this out of the server bundle.
|
|
337
|
+
|
|
338
|
+
2. Restart the dev server. A dev server that is already running does not pick up a new plugin —
|
|
339
|
+
it will not appear in .nuxt/plugins/client.mjs, and the app will come up with no SDK at all.
|
|
340
|
+
|
|
341
|
+
3. If your dev host is anything other than localhost (a hosts-file alias, a LAN IP, a tunnel), add
|
|
342
|
+
allowNonLocalhost: true to that connect call. Without it the SDK loads and then refuses, and the
|
|
343
|
+
only sign is one line in the browser console.
|
|
344
|
+
|
|
345
|
+
The package is @reticlehq/browser — the framework-neutral sensor. DOM, network, console, routing and
|
|
346
|
+
source file:line all work in Vue. What you do not get is React component identity, which is the only
|
|
347
|
+
thing the React adapter adds. There is no Nuxt app in this project's CI, so this path is UNVERIFIED:
|
|
348
|
+
if something does not work, please open an issue.`;
|
|
349
|
+
}
|
|
285
350
|
/**
|
|
286
351
|
* Root-level project config for Reticle. Written by `reticle init`; read by `reticle mcp` for the port
|
|
287
352
|
* and by tooling for the stable projectId (the app's identity across port changes).
|
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
9
9
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
10
10
|
import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
|
|
11
|
-
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
11
|
+
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, onQueueExpired, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
12
12
|
import { log } from '../log.js';
|
|
13
13
|
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
14
14
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
@@ -441,9 +441,16 @@ ensureDaemon) {
|
|
|
441
441
|
stdinQueue.splice(0);
|
|
442
442
|
for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
|
|
443
443
|
emit(reply);
|
|
444
|
+
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
445
|
+
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
446
|
+
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
447
|
+
// NEXT request re-probe the port instead of queueing behind a reconnect that will never
|
|
448
|
+
// land, and it is the only path by which a daemon started AFTER we gave up is ever found.
|
|
449
|
+
dormant = onQueueExpired() === OnDrop.DORMANT;
|
|
444
450
|
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
445
451
|
port,
|
|
446
|
-
|
|
452
|
+
dormant,
|
|
453
|
+
note: 'answered queued requests and went dormant; the next client request re-probes the port',
|
|
447
454
|
});
|
|
448
455
|
}, QUEUE_WAIT_MS);
|
|
449
456
|
queueTimer.unref();
|
|
@@ -518,7 +525,7 @@ ensureDaemon) {
|
|
|
518
525
|
attempts++;
|
|
519
526
|
// Once per process: this session has now lost its tools at least once, which is the number
|
|
520
527
|
// that says whether the transport work actually landed for real users. `pendingLost` is the
|
|
521
|
-
// part an agent can FEEL —
|
|
528
|
+
// part an agent can FEEL — almost every measured drop killed nothing in flight.
|
|
522
529
|
reportMcpOutage(OutageStage.FIRST, { reason, attempts, pendingLost });
|
|
523
530
|
// The severe stage: we are about to stop retrying. Reported so the share of sessions where
|
|
524
531
|
// MCP never came back on its own is a number rather than an anecdote.
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -18,6 +18,7 @@ import { SERVER_VERSION } from '../version/server-version.js';
|
|
|
18
18
|
import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
19
19
|
import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
|
|
20
20
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
21
|
+
import { parsePredicate } from '../events/predicate-parse.js';
|
|
21
22
|
/**
|
|
22
23
|
* Merge the runtime-spliced envelope (health/lease/age/control) into a session-bound tool's declared
|
|
23
24
|
* outputSchema so structuredContent validation keeps those fields instead of dropping them. The tool's
|
|
@@ -272,6 +273,17 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
|
|
|
272
273
|
// McpError.message already carries "MCP error -32602: "; re-wrapping would print it twice.
|
|
273
274
|
const raw = error instanceof Error ? error.message : String(error);
|
|
274
275
|
const detail = raw.replace(/^MCP error -?\d+:\s*/, '');
|
|
276
|
+
// A malformed PREDICATE gets the sentence, not the array. The SDK validates tool input against
|
|
277
|
+
// the same zod schema and throws a serialized issue list — so the readable message
|
|
278
|
+
// `parsePredicate` exists to produce was being generated only on paths that never run over
|
|
279
|
+
// MCP, which is every path a real agent uses. Found by driving a live app: `reticle_assert`
|
|
280
|
+
// answered a near-miss predicate with a raw zod array naming the unknown keys and not one
|
|
281
|
+
// field that would have worked. It already carries its own guidance, so the generic example is
|
|
282
|
+
// dropped rather than argued with.
|
|
283
|
+
const predicateHelp = predicateMessage(args);
|
|
284
|
+
if (predicateHelp !== undefined) {
|
|
285
|
+
throw new McpError(ErrorCode.InvalidParams, predicateHelp);
|
|
286
|
+
}
|
|
275
287
|
const example = examples.get(toolName);
|
|
276
288
|
const help = example === undefined
|
|
277
289
|
? ` Call reticle_tools { names: ["${toolName}"] } for its parameters.`
|
|
@@ -280,6 +292,29 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
|
|
|
280
292
|
}
|
|
281
293
|
};
|
|
282
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* The readable reason a predicate argument did not parse, or undefined when the predicate is fine.
|
|
297
|
+
*
|
|
298
|
+
* Undefined matters as much as the message: a call can fail validation for a reason that has nothing
|
|
299
|
+
* to do with the predicate (a bad `timeout_ms`, a missing `ref`), and replacing that error with a
|
|
300
|
+
* predicate explanation would be confidently wrong. Re-parsing is the honest test — if it parses,
|
|
301
|
+
* the predicate is not what broke.
|
|
302
|
+
*/
|
|
303
|
+
function predicateMessage(args) {
|
|
304
|
+
if (typeof args !== 'object' || null === args)
|
|
305
|
+
return undefined;
|
|
306
|
+
const record = args;
|
|
307
|
+
const predicate = record['predicate'] ?? record['until'];
|
|
308
|
+
if (predicate === undefined)
|
|
309
|
+
return undefined;
|
|
310
|
+
try {
|
|
311
|
+
parsePredicate(predicate);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return error instanceof Error ? error.message : undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
283
318
|
/**
|
|
284
319
|
* Answer a call for a real-but-un-advertised tool with the way to make it work.
|
|
285
320
|
*
|
|
@@ -44,6 +44,25 @@ export declare function onStreamDrop(daemonListening: boolean): OnDrop;
|
|
|
44
44
|
* whether the server survives.
|
|
45
45
|
*/
|
|
46
46
|
export declare function onReconnectBudgetSpent(): OnDrop;
|
|
47
|
+
/**
|
|
48
|
+
* What to do when queued requests expire with nothing connected. Always DORMANT.
|
|
49
|
+
*
|
|
50
|
+
* This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
|
|
51
|
+
* proxy spent its budget against it, and from then on every call returned −32001 — including after
|
|
52
|
+
* the human killed the wedged process and started a healthy daemon on the same port. The proxy never
|
|
53
|
+
* looked again.
|
|
54
|
+
*
|
|
55
|
+
* The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
|
|
56
|
+
* neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
|
|
57
|
+
* serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
|
|
58
|
+
* already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
|
|
59
|
+
* in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
|
|
60
|
+
*
|
|
61
|
+
* Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
|
|
62
|
+
* NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
|
|
63
|
+
* arrived late, and it costs nothing when one has not.
|
|
64
|
+
*/
|
|
65
|
+
export declare function onQueueExpired(): OnDrop;
|
|
47
66
|
/** What to do with a client message. */
|
|
48
67
|
export declare const OnRequest: {
|
|
49
68
|
/** Connected — post it straight through. */
|
|
@@ -47,6 +47,27 @@ export function onStreamDrop(daemonListening) {
|
|
|
47
47
|
export function onReconnectBudgetSpent() {
|
|
48
48
|
return OnDrop.DORMANT;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* What to do when queued requests expire with nothing connected. Always DORMANT.
|
|
52
|
+
*
|
|
53
|
+
* This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
|
|
54
|
+
* proxy spent its budget against it, and from then on every call returned −32001 — including after
|
|
55
|
+
* the human killed the wedged process and started a healthy daemon on the same port. The proxy never
|
|
56
|
+
* looked again.
|
|
57
|
+
*
|
|
58
|
+
* The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
|
|
59
|
+
* neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
|
|
60
|
+
* serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
|
|
61
|
+
* already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
|
|
62
|
+
* in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
|
|
63
|
+
*
|
|
64
|
+
* Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
|
|
65
|
+
* NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
|
|
66
|
+
* arrived late, and it costs nothing when one has not.
|
|
67
|
+
*/
|
|
68
|
+
export function onQueueExpired() {
|
|
69
|
+
return OnDrop.DORMANT;
|
|
70
|
+
}
|
|
50
71
|
/** What to do with a client message. */
|
|
51
72
|
export const OnRequest = {
|
|
52
73
|
/** Connected — post it straight through. */
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { BrowserLaunchKind } from '@reticlehq/core';
|
|
9
9
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
10
10
|
import { classifyConnectFailure } from '../telemetry/connect-failure.js';
|
|
11
|
+
import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
|
|
11
12
|
/**
|
|
12
13
|
* How a leased tab navigates. Pure, and exported, because the decision in it is worth a test while
|
|
13
14
|
* the rest of this file needs a real Chromium.
|
|
@@ -48,8 +49,15 @@ function wrapBrowser(browser) {
|
|
|
48
49
|
onDisconnected: (handler) => browser.on('disconnected', handler),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The fix for the most common first-run failure: Chromium isn't installed for Playwright.
|
|
54
|
+
*
|
|
55
|
+
* Pinned to the playwright this process bundles. Unpinned, `npx` fetches the latest playwright and
|
|
56
|
+
* installs ITS browser revision — a build this launcher will never look at — so the user follows the
|
|
57
|
+
* advice, it succeeds, and the next launch fails identically. Shares one builder with `doctor` so the
|
|
58
|
+
* two cannot suggest different commands for the same missing browser.
|
|
59
|
+
*/
|
|
60
|
+
const CHROMIUM_MISSING_HINT = `Chromium is not installed for Playwright. Run: ${chromiumInstallCommand(bundledPlaywrightVersion())}`;
|
|
53
61
|
/** A Launcher that boots a real headless Chromium and adapts it to the pool's interface. */
|
|
54
62
|
export function playwrightLauncher(opts = {}) {
|
|
55
63
|
const headless = opts.headless ?? true;
|
|
@@ -93,12 +93,37 @@ export const LIVE_CONTROL_TOOLS = [
|
|
|
93
93
|
description: 'Drain and return any messages the human queued from the panel since the last poll. Use to ' +
|
|
94
94
|
'explicitly check for human guidance without acting.',
|
|
95
95
|
inputSchema: { ...sessionIdShape },
|
|
96
|
-
outputSchema: {
|
|
96
|
+
outputSchema: {
|
|
97
|
+
messages: z.array(z.unknown()),
|
|
98
|
+
delivered: z
|
|
99
|
+
.array(z.unknown())
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('Present ONLY when this poll found nothing new but the human HAS spoken this session: everything they said, including what was already handed to you inline on an earlier tool result. An empty `messages` never means silence.'),
|
|
102
|
+
},
|
|
97
103
|
handler: (deps, args) => {
|
|
98
104
|
const session = deps.sessions.resolve(asString(args['sessionId']));
|
|
105
|
+
const messages = session.drainInbox();
|
|
106
|
+
if (messages.length > 0)
|
|
107
|
+
return Promise.resolve({ messages });
|
|
108
|
+
// Nothing NEW is not the same as nothing said. The inbox has two consumers — this poll and the
|
|
109
|
+
// control envelope spliced onto every tool result — and delivery is destructive, so a message
|
|
110
|
+
// handed over inline moments ago left this call reporting an empty queue with a note that read
|
|
111
|
+
// as "the human has said nothing". Reported from the field, from both ends: the agent believed
|
|
112
|
+
// it, and the person who typed the message got silence with no sign it had been seen.
|
|
113
|
+
const history = session.inboxHistory();
|
|
114
|
+
if (history.length > 0) {
|
|
115
|
+
return Promise.resolve({
|
|
116
|
+
messages,
|
|
117
|
+
delivered: [...history],
|
|
118
|
+
note: `nothing NEW since your last poll — but the human has sent ` +
|
|
119
|
+
`${String(history.length)} message(s) this session, listed in \`delivered\`. They were ` +
|
|
120
|
+
`handed to you inline on an earlier tool result, which is why this queue is empty. An ` +
|
|
121
|
+
`empty \`messages\` never means the human has said nothing.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
99
124
|
// An empty inbox and a panel that is not wired both return `[]`. The first means the human has
|
|
100
125
|
// said nothing; the second means an agent is waiting on a channel that does not exist.
|
|
101
|
-
return Promise.resolve(noteEmptyRead({ messages
|
|
126
|
+
return Promise.resolve(noteEmptyRead({ messages }, 'messages', {
|
|
102
127
|
noun: 'messages from the human since the last poll',
|
|
103
128
|
}));
|
|
104
129
|
},
|
|
@@ -126,16 +151,26 @@ export const LIVE_CONTROL_TOOLS = [
|
|
|
126
151
|
marks: z.array(z.unknown()),
|
|
127
152
|
pendingCount: z.number(),
|
|
128
153
|
resolved: z.boolean().optional(),
|
|
154
|
+
resolvedNote: z
|
|
155
|
+
.string()
|
|
156
|
+
.optional()
|
|
157
|
+
.describe('The note on the mark that was actually retired. CHECK IT against the mark you meant to resolve: a bare `resolved:true` cannot tell you it closed the right one, and ids are only meaningful while the session that issued them lives.'),
|
|
129
158
|
},
|
|
130
159
|
handler: (deps, args) => {
|
|
131
160
|
const session = deps.sessions.resolve(asString(args['sessionId']));
|
|
132
161
|
const resolveId = asString(args['resolve']);
|
|
133
162
|
let resolved;
|
|
163
|
+
let resolvedNote;
|
|
134
164
|
if (resolveId !== undefined) {
|
|
135
165
|
// Grab the note BEFORE retiring it so we can close the loop visually for the human.
|
|
136
166
|
const mark = session.allMarks().find((m) => m.id === resolveId);
|
|
137
167
|
resolved = session.resolveMark(resolveId);
|
|
138
168
|
if (resolved && mark !== undefined) {
|
|
169
|
+
// Echoed back to the CALLER too, not only to the panel. A bare `resolved:true` is equally
|
|
170
|
+
// consistent with "I retired the bug you fixed" and "I retired somebody else's", and an
|
|
171
|
+
// agent hit exactly that — it closed a mark the human had recorded minutes later while
|
|
172
|
+
// the two it had actually fixed vanished with no record.
|
|
173
|
+
resolvedNote = mark.note;
|
|
139
174
|
// The human watching the panel sees their flagged bug get marked fixed (fire-and-forget).
|
|
140
175
|
session.pushNarration(`✓ fixed: ${mark.note}`);
|
|
141
176
|
}
|
|
@@ -148,6 +183,8 @@ export const LIVE_CONTROL_TOOLS = [
|
|
|
148
183
|
};
|
|
149
184
|
if (resolved !== undefined)
|
|
150
185
|
out.resolved = resolved;
|
|
186
|
+
if (resolvedNote !== undefined)
|
|
187
|
+
out.resolvedNote = resolvedNote;
|
|
151
188
|
return Promise.resolve(out);
|
|
152
189
|
},
|
|
153
190
|
},
|
|
@@ -4,15 +4,6 @@ export interface InboxMessage {
|
|
|
4
4
|
text: string;
|
|
5
5
|
t: number;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
8
|
-
* The human-in-the-loop half of a session: its lifecycle state, and the one-way inbox a person uses
|
|
9
|
-
* to talk to the agent driving their browser.
|
|
10
|
-
*
|
|
11
|
-
* Server-owned on purpose — a paused session has to stay paused even if the page reloads, so this
|
|
12
|
-
* cannot live in the tab. Split out of Session because it shares nothing with the transport, the
|
|
13
|
-
* ring buffer, or the journal: it is state a HUMAN changes, on a human's timescale, while everything
|
|
14
|
-
* else in Session is driven by the event stream.
|
|
15
|
-
*/
|
|
16
7
|
export declare class LiveControl {
|
|
17
8
|
#private;
|
|
18
9
|
state(): SessionState;
|
|
@@ -22,6 +13,13 @@ export declare class LiveControl {
|
|
|
22
13
|
setState(next: SessionState): void;
|
|
23
14
|
/** Queue a human note. Empty/whitespace-only text is ignored so a stray enter cannot spam the agent. */
|
|
24
15
|
push(text: string, t: number): void;
|
|
16
|
+
/**
|
|
17
|
+
* Everything the human has said this session, including what has already been delivered.
|
|
18
|
+
*
|
|
19
|
+
* Read-only and non-destructive by design — this is the answer to "did the human say anything
|
|
20
|
+
* while I was away", which a destructive drain can never give twice.
|
|
21
|
+
*/
|
|
22
|
+
history(): readonly InboxMessage[];
|
|
25
23
|
/** Return the queued notes AND clear them — delivered once, so a hint never repeats. */
|
|
26
24
|
drain(): InboxMessage[];
|
|
27
25
|
/** Diagnostic read of the inbox depth (does not clear). */
|
|
@@ -8,9 +8,29 @@ import { HumanControlKind, SessionState } from '@reticlehq/core';
|
|
|
8
8
|
* ring buffer, or the journal: it is state a HUMAN changes, on a human's timescale, while everything
|
|
9
9
|
* else in Session is driven by the event stream.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* How many delivered messages a session remembers.
|
|
13
|
+
*
|
|
14
|
+
* Enough that an agent picking up a long session can see what was said; bounded because a session
|
|
15
|
+
* can run for hours and this is a convenience, not a record.
|
|
16
|
+
*/
|
|
17
|
+
const HISTORY_CAP = 200;
|
|
11
18
|
export class LiveControl {
|
|
12
19
|
#state = SessionState.ACTIVE;
|
|
13
20
|
#inbox = [];
|
|
21
|
+
/**
|
|
22
|
+
* Everything the human has said this session, delivered or not.
|
|
23
|
+
*
|
|
24
|
+
* The inbox has TWO consumers — the control envelope on every tool result, and the explicit
|
|
25
|
+
* message poll — and `drain` is destructive, so whichever ran first took the message and the other
|
|
26
|
+
* reported an empty queue. That produced a confident "no messages from the human since the last
|
|
27
|
+
* poll" about a message the human had definitely sent, with no way for the agent to tell the
|
|
28
|
+
* difference and no acknowledgement for the person who typed it.
|
|
29
|
+
*
|
|
30
|
+
* Delivered-once stays: a guidance hint that repeats every turn is noise. This only stops the
|
|
31
|
+
* delivery from being the same event as the forgetting.
|
|
32
|
+
*/
|
|
33
|
+
#history = [];
|
|
14
34
|
state() {
|
|
15
35
|
return this.#state;
|
|
16
36
|
}
|
|
@@ -29,7 +49,20 @@ export class LiveControl {
|
|
|
29
49
|
const trimmed = text.trim();
|
|
30
50
|
if (0 === trimmed.length)
|
|
31
51
|
return;
|
|
32
|
-
|
|
52
|
+
const message = { text: trimmed, t };
|
|
53
|
+
this.#inbox.push(message);
|
|
54
|
+
this.#history.push(message);
|
|
55
|
+
if (this.#history.length > HISTORY_CAP)
|
|
56
|
+
this.#history.splice(0, this.#history.length - HISTORY_CAP);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Everything the human has said this session, including what has already been delivered.
|
|
60
|
+
*
|
|
61
|
+
* Read-only and non-destructive by design — this is the answer to "did the human say anything
|
|
62
|
+
* while I was away", which a destructive drain can never give twice.
|
|
63
|
+
*/
|
|
64
|
+
history() {
|
|
65
|
+
return this.#history;
|
|
33
66
|
}
|
|
34
67
|
/** Return the queued notes AND clear them — delivered once, so a hint never repeats. */
|
|
35
68
|
drain() {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turning "no browser session connected" from a dead end into a next action.
|
|
3
3
|
*
|
|
4
|
-
* This is the most consequential sentence in the product.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This is the most consequential sentence in the product. Most sessions never call a Reticle tool at
|
|
5
|
+
* all, and of the ones that do, a large share make exactly ONE call — usually `reticle_sessions` —
|
|
6
|
+
* and stop. Almost none of those ever touched a browser, and this is the session error they hit. The
|
|
7
|
+
* agent asks whether anything is connected, is told no, and leaves.
|
|
8
8
|
*
|
|
9
9
|
* The old message asked the agent to check two things it cannot see from where it stands ("is your
|
|
10
10
|
* app running with the SDK enabled?", "does it point at this port?"). The daemon can actually tell
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turning "no browser session connected" from a dead end into a next action.
|
|
3
3
|
*
|
|
4
|
-
* This is the most consequential sentence in the product.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This is the most consequential sentence in the product. Most sessions never call a Reticle tool at
|
|
5
|
+
* all, and of the ones that do, a large share make exactly ONE call — usually `reticle_sessions` —
|
|
6
|
+
* and stop. Almost none of those ever touched a browser, and this is the session error they hit. The
|
|
7
|
+
* agent asks whether anything is connected, is told no, and leaves.
|
|
8
8
|
*
|
|
9
9
|
* The old message asked the agent to check two things it cannot see from where it stands ("is your
|
|
10
10
|
* app running with the SDK enabled?", "does it point at this port?"). The daemon can actually tell
|
|
@@ -30,12 +30,12 @@ const RETRY = SELF_RECOVERING_MARKER;
|
|
|
30
30
|
* The way out that needs no human at all.
|
|
31
31
|
*
|
|
32
32
|
* `reticle_lease` opens a browser Reticle drives itself, instead of waiting for somebody's tab to
|
|
33
|
-
* dial in.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* dial in. In the field it is the single strongest predictor of a session that works: sessions that
|
|
34
|
+
* use it drive an order of magnitude more tool calls than those that do not, they account for a
|
|
35
|
+
* disproportionate share of every bug found, and no single-call bounce has ever used one. It is also
|
|
36
|
+
* advertised on no profile except `full`, so an agent only ever finds it if it already knew it
|
|
37
|
+
* existed. Naming it HERE puts it in front of the agent at the one moment it is the answer, and
|
|
38
|
+
* costs nothing on the turns when it is not.
|
|
39
39
|
*
|
|
40
40
|
* Only offered when the app is known to carry the SDK: leasing an uninstrumented app just burns a
|
|
41
41
|
* browser and comes back `ready:false`.
|
|
@@ -51,15 +51,23 @@ const SELF_SERVE = 'You do not have to wait for the human: reticle_lease {action
|
|
|
51
51
|
* evidence that was never gathered.
|
|
52
52
|
*/
|
|
53
53
|
/**
|
|
54
|
-
* The
|
|
54
|
+
* The one cause that produces a PERFECTLY healthy everything and still never connects.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* The SDK refuses to dial from a page that is not on localhost unless `allowNonLocalhost` is set.
|
|
57
|
+
* That is deliberate — Reticle must not be reachable from a page it does not trust — but the refusal
|
|
58
|
+
* happens page-side, so the daemon sees only silence, `doctor` sees only a healthy daemon, and every
|
|
59
|
+
* checklist item passes. Reported from the field by an agent on a hosts-file alias (the normal setup
|
|
60
|
+
* for white-label and multi-tenant apps): the whole setup was lost to a flag named in no checklist,
|
|
61
|
+
* and `init`'s own fallback snippet guards on `hostname === 'localhost'`, so on such a host the
|
|
62
|
+
* connect never runs and there is not even a console line to find.
|
|
63
|
+
*
|
|
64
|
+
* Only offered on the wired-and-listening branch: that is where a correctly installed app that
|
|
65
|
+
* cannot connect actually lands.
|
|
59
66
|
*/
|
|
60
|
-
const
|
|
61
|
-
'
|
|
62
|
-
|
|
67
|
+
const NON_LOCALHOST_GATE = 'One more cause that leaves every other check healthy: if the app is served on anything other ' +
|
|
68
|
+
'than localhost (a hosts-file alias, a LAN IP, a tunnel), the SDK refuses to connect unless it ' +
|
|
69
|
+
'is given `allowNonLocalhost: true` — the refusal is page-side, so nothing here can see it. ' +
|
|
70
|
+
"Check the browser console for that message and pass the flag in the app's reticle.connect().";
|
|
63
71
|
const SCANNED_PORTS = [...DEV_SERVER_PORTS].join(', ');
|
|
64
72
|
export function diagnoseNoSession(facts) {
|
|
65
73
|
const { everConnected, initialized, listening, port } = facts;
|
|
@@ -84,26 +92,49 @@ export function diagnoseNoSession(facts) {
|
|
|
84
92
|
`the app (or run \`reticle open\`), or reload the tab. ${SELF_SERVE} ${RETRY}`);
|
|
85
93
|
}
|
|
86
94
|
if (0 === listening.length) {
|
|
95
|
+
// Lead with the stronger EVIDENCE, and do not overstate what it is.
|
|
96
|
+
//
|
|
97
|
+
// `initialized` is one thing only: whether a `.reticle.json` sits in the directory this daemon
|
|
98
|
+
// is running in. It is NOT "this project has never been through `reticle init`", and the two
|
|
99
|
+
// come apart constantly — a monorepo whose daemon runs at the root while the app lives in
|
|
100
|
+
// `apps/web`, or any app wired by the babel/vite plugin rather than by `init`. An earlier
|
|
101
|
+
// version of this branch asserted the strong claim and led with it, on the reasoning that it was
|
|
102
|
+
// a certainty while the port scan was a guess. It is not a certainty. Caught driving Reticle's
|
|
103
|
+
// OWN repo, where the message told an agent the project had never been through `init` about a
|
|
104
|
+
// fixture that is instrumented and working — "the one sentence I was most likely to act on was
|
|
105
|
+
// wrong", which is the exact failure this whole file exists to prevent.
|
|
106
|
+
//
|
|
107
|
+
// So: report what was actually checked, name the directory, and name the case where the absence
|
|
108
|
+
// is expected rather than diagnostic.
|
|
109
|
+
if (!initialized) {
|
|
110
|
+
return ('no browser session connected. Two things to weigh, and neither of them is proof. ' +
|
|
111
|
+
`(1) Nothing is listening on the ports Reticle scans (${SCANNED_PORTS}), so the dev server ` +
|
|
112
|
+
'may not be running — ask the human to start it (`npm run dev`). That scan is narrow ' +
|
|
113
|
+
'though: a server on any other port is invisible to it, so if the app IS running, ask for ' +
|
|
114
|
+
'its URL rather than assuming it is down. ' +
|
|
115
|
+
'(2) There is no `.reticle.json` in the directory this daemon is running in. That is the ' +
|
|
116
|
+
"file `reticle init` writes, so the app may carry no Reticle SDK — but check the app's " +
|
|
117
|
+
'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
|
|
118
|
+
'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
|
|
119
|
+
`carries the SDK without that file at all. ${RETRY}`);
|
|
120
|
+
}
|
|
87
121
|
return ('no browser session connected, and nothing is listening on the ports Reticle scans ' +
|
|
88
122
|
`(${SCANNED_PORTS}). The likeliest cause by far is that the dev server is not running: ask ` +
|
|
89
123
|
'the human to start it (`npm run dev`), then open the app in a browser. ' +
|
|
90
124
|
// The caveat is here rather than omitted because the scan is NARROW, and the old sentence
|
|
91
|
-
// spent its confidence as though an empty result
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
125
|
+
// spent its confidence as though an empty result were proof of absence. Reported twice: a
|
|
126
|
+
// scripted drive of 2.5.0 asserted the app was not running while it served 200 on :7699, and
|
|
127
|
+
// an agent was told nothing was listening while a dev server answered on :5000 under a custom
|
|
128
|
+
// hostname. The common defaults have since been added to the scanned set, which narrows the
|
|
129
|
+
// gap and cannot close it — anything passed to `--port` is still invisible.
|
|
95
130
|
'That scan is narrow, so it is not proof: a server on any other port is invisible to it. If ' +
|
|
96
131
|
// Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
|
|
97
132
|
// if nothing is listening there is nothing at any URL to open. Asking for the real one is the
|
|
98
133
|
// only move that can recover the :7699 case.
|
|
99
134
|
'the app IS running, ask the human for its URL rather than assuming it is down. ' +
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
// SDK. Two round trips to learn two things the daemon knew on the first call, and this is the
|
|
104
|
-
// largest cohort in the funnel (#171): 77 users attached an agent and never instrumented an
|
|
105
|
-
// app. They have a daemon (they registered the MCP server) and no SDK anywhere.
|
|
106
|
-
`${initialized ? '' : UNINSTRUMENTED} ${RETRY}`);
|
|
135
|
+
// Reachable only for a project that HAS been through `init` — the uninstrumented case is
|
|
136
|
+
// answered above, leading with that certainty instead of behind this scan's guess.
|
|
137
|
+
`${RETRY}`);
|
|
107
138
|
}
|
|
108
139
|
if (!initialized) {
|
|
109
140
|
return (`no browser session connected, but something IS listening on port ${ports} — so a server is ` +
|
|
@@ -116,5 +147,5 @@ export function diagnoseNoSession(facts) {
|
|
|
116
147
|
`wired for Reticle — so the app is either serving a build made before the wiring landed, or ` +
|
|
117
148
|
`dialling a different daemon than this one (this daemon is on ${String(port)}). Ask the human ` +
|
|
118
149
|
'to restart the dev server and hard-reload the page; if it still does not appear, check that ' +
|
|
119
|
-
`the app's reticle port matches ${String(port)}. ${SELF_SERVE} ${RETRY}`);
|
|
150
|
+
`the app's reticle port matches ${String(port)}. ${NON_LOCALHOST_GATE} ${SELF_SERVE} ${RETRY}`);
|
|
120
151
|
}
|
|
@@ -21,6 +21,15 @@ export interface ReviewMark {
|
|
|
21
21
|
at: number;
|
|
22
22
|
status: MarkStatus;
|
|
23
23
|
}
|
|
24
|
+
/** Tests only — assertions on `m1` must not depend on which test ran first. */
|
|
25
|
+
export declare function resetMarkIdsForTest(): void;
|
|
26
|
+
/** What `resolve` actually did, including WHICH mark — so a caller can verify it hit the right one. */
|
|
27
|
+
export interface ResolveOutcome {
|
|
28
|
+
resolved: boolean;
|
|
29
|
+
id: string;
|
|
30
|
+
/** The retired mark's note. Absent when nothing was resolved. */
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
24
33
|
export declare class ReviewStore {
|
|
25
34
|
#private;
|
|
26
35
|
/** Store a new mark (status pending) stamped with the caller-supplied session-relative time. */
|
|
@@ -36,4 +45,14 @@ export declare class ReviewStore {
|
|
|
36
45
|
* false for an unknown id or an already-resolved mark (so resolve is idempotent).
|
|
37
46
|
*/
|
|
38
47
|
resolve(id: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve, and say WHICH mark was retired.
|
|
50
|
+
*
|
|
51
|
+
* The bare boolean is what made the misattribution silent: `{"resolved":true}` is equally
|
|
52
|
+
* consistent with "I retired the bug you fixed" and "I retired somebody else's". Echoing the note
|
|
53
|
+
* lets a caller check the answer against what it believed it was resolving, which is exactly what
|
|
54
|
+
* the agent that hit this asked for — it had been re-reading the whole list after every resolve
|
|
55
|
+
* and hand-diffing the notes to get the same assurance.
|
|
56
|
+
*/
|
|
57
|
+
resolveDetail(id: string): ResolveOutcome;
|
|
39
58
|
}
|