@reticlehq/server 2.5.0 → 2.6.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 +27 -10
- package/dist/bridge/bridge.d.ts +12 -0
- package/dist/bridge/bridge.js +32 -2
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +71 -3
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +35 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +131 -11
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +10 -0
- package/dist/events/predicate-eval.js +24 -4
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +54 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.js +1 -16
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/http-server.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -1
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +22 -4
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +6 -0
- package/dist/init/detect.js +8 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- package/dist/init/plan-framework.js +36 -2
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +73 -9
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +133 -16
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +30 -7
- package/dist/mcp/mcp.js +29 -2
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +53 -3
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +3 -0
- package/dist/session/session-manager.js +72 -3
- package/dist/session/session.d.ts +1 -0
- package/dist/session/session.js +8 -0
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +28 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +19 -1
- package/dist/telemetry/telemetry.js +31 -8
- package/dist/tools/act-tools.js +33 -6
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +15 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +83 -7
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +69 -1
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.js +13 -0
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +2 -2
- package/docs/telemetry.md +7 -0
- package/package.json +2 -2
package/SKILL.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Reticle
|
|
2
2
|
|
|
3
|
+
> ## Before anything else: feedback is part of the job
|
|
4
|
+
>
|
|
5
|
+
> Reticle is built FOR agents, so you are its user and the only one who can report what it costs you. That applies **from this line onwards** — including everything that happens during setup, which is where Reticle is most likely to break and least likely to hear about it.
|
|
6
|
+
>
|
|
7
|
+
> **The moment something is wrong, missing, awkward, or slower than it should be, file it — one call, then carry on with the task.** A step in this document that did not match reality, an install that needed a manual fix, a tool that returned nonsense, something you wished existed, something that works but takes five calls where it should take one. Nothing is too minor; the minor things are exactly what never gets reported and therefore never gets fixed.
|
|
8
|
+
>
|
|
9
|
+
> - Tools available: `reticle_feedback { kind, text, need?, impact?, currentApproach?, model?, trace? }` where `kind` is `bug` | `gap` | `ambiguity` | `feature_request` | `improvement`.
|
|
10
|
+
> - Not set up yet, or the daemon will not start (which is most of setup): `reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"` — it needs nothing running.
|
|
11
|
+
> - Report defects in **Reticle**. A bug you find in the app under test is Reticle working; that one goes to the user. Never put app source, secrets, or user data in a report.
|
|
12
|
+
|
|
3
13
|
Start by detecting which mode to run:
|
|
4
14
|
|
|
5
15
|
```bash
|
|
@@ -442,7 +452,7 @@ reticle_network({ sessionId, limit: 10 })
|
|
|
442
452
|
reticle_console({ sessionId, limit: 20 })
|
|
443
453
|
```
|
|
444
454
|
|
|
445
|
-
> **There is one tool surface, and nothing to pick.** The verify loop is advertised directly (navigate / snapshot / query / act / act_and_wait / observe / network / console / wait_for / assert / state / inspect / sessions / session / feedback). Everything else the skill names — `reticle_capabilities`,
|
|
455
|
+
> **There is one tool surface, and nothing to pick.** The verify loop is advertised directly (navigate / snapshot / query / act / act_sequence / act_and_wait / observe / network / console / wait_for / assert / state / inspect / sessions / session / feedback). Everything else the skill names — `reticle_capabilities`, the flow and record tools — is one hop away through two meta-tools: `reticle_run({ tool, args })` invokes any tool by name, `reticle_tools` lists them (pass `names:[…]` for full params). `reticle_run` takes `sessionId` at the top level and forwards it, so you do not have to nest it in `args`. Advertised counts, checked by a gate (`surface-sizes.test.ts`): `default` 18, `all` 48.
|
|
446
456
|
>
|
|
447
457
|
> `all` is a verification switch, not a mode to drive agents in: `RETICLE_ADVERTISE_ALL_TOOLS=1` advertises every tool **with its output schema**, at several times the per-turn cost. It is read by the DAEMON at startup, never by your client — exporting it while a daemon is already running changes nothing, so run `npx @reticlehq/server stop` first.
|
|
448
458
|
|
|
@@ -476,22 +486,28 @@ Then pick a mode:
|
|
|
476
486
|
|
|
477
487
|
### Targeted
|
|
478
488
|
|
|
489
|
+
> **Only two tools produce a verdict: `reticle_act_and_wait` and `reticle_assert`.** Everything else — `act`, `snapshot`, `query`, `navigate`, `observe`, `network`, `console` — moves or reads the app and proves nothing. A drive that ends without one of those two is a drive with no result, however many tools it used. Measured across two days of real sessions: `reticle_act` was called 3.6× more often than `reticle_act_and_wait`, and **20 of the 28 agents that drove an app produced no verdict at all.** Reach for `act_and_wait` first; drop to bare `act` only for a step whose consequence you are deliberately asserting later.
|
|
490
|
+
|
|
479
491
|
1. Navigate if needed: `reticle_navigate({ sessionId, url })`
|
|
480
492
|
2. Snapshot to confirm correct state
|
|
481
|
-
3. Act
|
|
482
|
-
```
|
|
483
|
-
reticle_act({ sessionId, ref, action: "click" })
|
|
493
|
+
3. **Act and declare the consequence in the same hop** — this is the default:
|
|
484
494
|
```
|
|
485
|
-
|
|
486
|
-
```
|
|
487
|
-
reticle_assert({ sessionId, since, timeout_ms: 5000, predicate: { kind: "allOf", predicates: [
|
|
495
|
+
reticle_act_and_wait({ sessionId, ref, action: "click", until: { kind: "allOf", predicates: [
|
|
488
496
|
{ kind: "net", method: "POST", urlContains: "/api/...", status: 200 },
|
|
489
497
|
{ kind: "element", query: { role: "...", name: "..." }, state: "visible" },
|
|
490
|
-
{ kind: "signal", name: "..." },
|
|
491
498
|
{ kind: "console", level: "error", absent: true }
|
|
492
499
|
]}})
|
|
493
500
|
```
|
|
494
|
-
|
|
501
|
+
One call, one verdict, and the oracle is written _before_ the result exists — see **Declare before you act** below.
|
|
502
|
+
4. Only when an action's consequence lands somewhere you cannot name up front, act bare and assert after — always with `since` from the act result, or the assertion silently reads the wrong window:
|
|
503
|
+
```
|
|
504
|
+
reticle_act({ sessionId, ref, action: "click" })
|
|
505
|
+
reticle_assert({ sessionId, since, timeout_ms: 5000, predicate: { kind: "allOf", predicates: [
|
|
506
|
+
{ kind: "net", method: "POST", urlContains: "/api/...", status: 200 },
|
|
507
|
+
{ kind: "signal", name: "..." }
|
|
508
|
+
]}})
|
|
509
|
+
```
|
|
510
|
+
5. Record: ✅ pass / ❌ fail / ⚠️ partial. A verdict of `verified: "unknown"` is **not** a pass — it means Reticle drove the app and could not tell what happened (`unclean_capture`, `outcome_unread`, `unsettled`). Report it as unknown and say why; do not round it up.
|
|
495
511
|
|
|
496
512
|
### Plan then batch — do NOT ping-pong act-by-act
|
|
497
513
|
|
|
@@ -614,7 +630,8 @@ If something failed, call `reticle_inspect({ sessionId, ref })` on the failing e
|
|
|
614
630
|
- Batch net + element + signal + console into one `allOf` — don't call `reticle_assert` four times.
|
|
615
631
|
- Never assert on pixels — use predicates, not `reticle_screenshot` (screenshots are for genuinely visual checks only).
|
|
616
632
|
- If the session disconnects mid-test (navigation creates a new session ID) — call `reticle_sessions()` again and continue.
|
|
617
|
-
- **If Reticle itself misbehaves, file it with `reticle_feedback` before you work around it.**
|
|
633
|
+
- **If Reticle itself misbehaves, file it with `reticle_feedback` before you work around it.** A tool that returned something wrong or impossible (`kind: "bug"`), something you needed to observe and Reticle simply could not see (`kind: "gap"`), a verification that ran but left you unable to tell pass from fail (`kind: "ambiguity"`). Write `text` as a root-cause analysis — what you called, what you expected, what you got — and put the call trail in `trace`. Then continue the task; do not retry the failing call just to re-report it. Report defects in **Reticle**, not bugs you found in the app under test: finding those is Reticle working, and they belong in your report to the user.
|
|
634
|
+
- **File what you WISHED for too, in the same breath.** `kind: "feature_request"` for something that does not exist, `kind: "improvement"` for something that works but is awkward or costs too many calls. Put the goal in `need` (not your guess at the fix), what gets better in `impact`, and how you are working around it today in `currentApproach`. This is the half nobody files, and it is the half that decides the roadmap — you are the user Reticle is built for, and the thought is gone when your context is. Set `model` to the model you are running.
|
|
618
635
|
|
|
619
636
|
---
|
|
620
637
|
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -4,6 +4,18 @@ import { Session, SessionManager } from '../session/session.js';
|
|
|
4
4
|
type ReplayRequestHandler = (sessionId: string, flowName: string) => void;
|
|
5
5
|
/** Called once a browser session connects, so the daemon can push it the replayable-flow list. */
|
|
6
6
|
type SessionReadyHandler = (session: Session) => void;
|
|
7
|
+
/**
|
|
8
|
+
* The close reasons worth telling an AGENT about, not just the SDK.
|
|
9
|
+
*
|
|
10
|
+
* The SDK prints these and stops retrying. The agent then calls a tool and is told "no browser
|
|
11
|
+
* session connected" — which is indistinguishable from an app nobody started. An outdated SDK and a
|
|
12
|
+
* stale pairing token were both invisible for that reason; these two carry their own remedy, so
|
|
13
|
+
* surfacing them is strictly better than the generic answer.
|
|
14
|
+
*/
|
|
15
|
+
export declare const WS_CLOSE_REASON: {
|
|
16
|
+
readonly PROTOCOL_MISMATCH: "protocol version mismatch — upgrade @reticlehq/browser";
|
|
17
|
+
readonly AUTH_FAILED: "authentication failed — reload the page to pick up the current pairing token";
|
|
18
|
+
};
|
|
7
19
|
interface BridgeOptions {
|
|
8
20
|
port: number;
|
|
9
21
|
host?: string;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -5,6 +5,7 @@ import { EventType, HumanControlKind, RETICLE_WS_PATH, ReticleMessageSchema, LOO
|
|
|
5
5
|
import { Session, SessionManager } from '../session/session.js';
|
|
6
6
|
import { tokensMatch } from './token-auth.js';
|
|
7
7
|
import { log } from '../log.js';
|
|
8
|
+
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
8
9
|
import { describeSkew, sdkFix, SkewPair } from '../version/version-skew.js';
|
|
9
10
|
import { noteVersionSkew } from '../version/version-nudge.js';
|
|
10
11
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
@@ -26,15 +27,31 @@ function refreshIdentity(session, hello) {
|
|
|
26
27
|
session.adapters = hello.adapters;
|
|
27
28
|
session.hasCapabilities = hello.hasCapabilities ?? false;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The close reasons worth telling an AGENT about, not just the SDK.
|
|
32
|
+
*
|
|
33
|
+
* The SDK prints these and stops retrying. The agent then calls a tool and is told "no browser
|
|
34
|
+
* session connected" — which is indistinguishable from an app nobody started. An outdated SDK and a
|
|
35
|
+
* stale pairing token were both invisible for that reason; these two carry their own remedy, so
|
|
36
|
+
* surfacing them is strictly better than the generic answer.
|
|
37
|
+
*/
|
|
38
|
+
export const WS_CLOSE_REASON = {
|
|
39
|
+
PROTOCOL_MISMATCH: 'protocol version mismatch — upgrade @reticlehq/browser',
|
|
40
|
+
AUTH_FAILED: 'authentication failed — reload the page to pick up the current pairing token',
|
|
41
|
+
};
|
|
29
42
|
/** WS close codes + reasons the bridge sends to the SDK (1008 = policy violation, 1013 = try again later). */
|
|
30
43
|
const WS_CLOSE = {
|
|
31
44
|
TOO_MANY_HANDSHAKES: [1013, 'too many pending handshakes'],
|
|
32
45
|
HELLO_TIMEOUT: [1008, 'hello timeout'],
|
|
33
46
|
INVALID_MESSAGE: [1008, 'invalid message'],
|
|
34
47
|
HELLO_DUPLICATE: [1008, 'hello already received'],
|
|
35
|
-
|
|
48
|
+
// Names the recovery, because the SDK prints this reason and then stops retrying — so it is the
|
|
49
|
+
// last thing the developer sees. The common cause is a page served before the daemon existed,
|
|
50
|
+
// which carries no token; a reload re-fetches the connect module and picks the current one up.
|
|
51
|
+
// Kept under the 123-byte WebSocket close-reason limit.
|
|
52
|
+
AUTH_FAILED: [1008, WS_CLOSE_REASON.AUTH_FAILED],
|
|
36
53
|
SESSION_LIMIT: [1013, 'session limit reached'],
|
|
37
|
-
PROTOCOL_MISMATCH: [1008,
|
|
54
|
+
PROTOCOL_MISMATCH: [1008, WS_CLOSE_REASON.PROTOCOL_MISMATCH],
|
|
38
55
|
};
|
|
39
56
|
/** Parse a positive integer env override; anything else (unset, zero, junk) falls through to the default. */
|
|
40
57
|
function positiveIntFromEnv(raw) {
|
|
@@ -261,6 +278,10 @@ export class Bridge {
|
|
|
261
278
|
const got = helloProtocolMismatch(text);
|
|
262
279
|
if (got !== null) {
|
|
263
280
|
log('protocol_version_mismatch', { got, expected: RETICLE_PROTOCOL_VERSION });
|
|
281
|
+
// Told to the AGENT too, not only to the SDK that is about to stop retrying. Without this
|
|
282
|
+
// the next tool call answers "no browser session connected", which reads as "no app is
|
|
283
|
+
// running" — so an SDK too old to connect is invisible.
|
|
284
|
+
this.sessions.noteClosure(WS_CLOSE_REASON.PROTOCOL_MISMATCH, this.#clock());
|
|
264
285
|
socket.close(...WS_CLOSE.PROTOCOL_MISMATCH);
|
|
265
286
|
return;
|
|
266
287
|
}
|
|
@@ -315,6 +336,7 @@ export class Bridge {
|
|
|
315
336
|
served: [...this.#servedProjects],
|
|
316
337
|
...(parsed.projectId === undefined ? {} : { helloProject: parsed.projectId }),
|
|
317
338
|
});
|
|
339
|
+
this.sessions.noteClosure(WS_CLOSE_REASON.AUTH_FAILED, this.#clock());
|
|
318
340
|
socket.close(WS_CLOSE.AUTH_FAILED[0], reason);
|
|
319
341
|
return;
|
|
320
342
|
}
|
|
@@ -363,6 +385,14 @@ export class Bridge {
|
|
|
363
385
|
noteVersionSkew(SkewPair.SDK, skew);
|
|
364
386
|
}
|
|
365
387
|
log('session_connected', { sessionId: session.id, url: session.url });
|
|
388
|
+
// The one fact that separates a broken install from an unused one. Best-effort and wrapped:
|
|
389
|
+
// a metric must never affect whether an app can connect. See SessionSummary.appConnects.
|
|
390
|
+
try {
|
|
391
|
+
getSessionMetrics().recordAppConnected();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* never let a counter interfere with an app connecting */
|
|
395
|
+
}
|
|
366
396
|
this.#onSessionReady?.(session); // daemon pushes the replayable-flow list to the panel
|
|
367
397
|
return;
|
|
368
398
|
}
|
|
@@ -35,6 +35,14 @@ export declare class FakeBrowser {
|
|
|
35
35
|
emit(type: string, data: Record<string, unknown>, ref?: string): void;
|
|
36
36
|
close(): void;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Poll until `cond` holds.
|
|
40
|
+
*
|
|
41
|
+
* The default was 2000ms and the fifty-browser test blew it on the Windows runner at 5155ms — a
|
|
42
|
+
* statement about the machine, not about the bridge. Raising it is safe in a way that raising a
|
|
43
|
+
* fixed sleep would not be: this POLLS every 10ms and resolves the instant the condition holds, so
|
|
44
|
+
* a passing test is not slowed by one millisecond and only a genuinely failing one waits longer.
|
|
45
|
+
*/
|
|
38
46
|
export declare function waitUntil(cond: () => boolean, timeoutMs?: number): Promise<void>;
|
|
39
47
|
export declare const callTool: (deps: ToolDeps, name: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
40
48
|
/** Builds the ToolDeps wired to a running bridge, with a frozen clock for deterministic tests. */
|
|
@@ -170,7 +170,15 @@ export class FakeBrowser {
|
|
|
170
170
|
this.#ws.send(JSON.stringify(obj));
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Poll until `cond` holds.
|
|
175
|
+
*
|
|
176
|
+
* The default was 2000ms and the fifty-browser test blew it on the Windows runner at 5155ms — a
|
|
177
|
+
* statement about the machine, not about the bridge. Raising it is safe in a way that raising a
|
|
178
|
+
* fixed sleep would not be: this POLLS every 10ms and resolves the instant the condition holds, so
|
|
179
|
+
* a passing test is not slowed by one millisecond and only a genuinely failing one waits longer.
|
|
180
|
+
*/
|
|
181
|
+
export function waitUntil(cond, timeoutMs = 15_000) {
|
|
174
182
|
return new Promise((resolve, reject) => {
|
|
175
183
|
const start = Date.now();
|
|
176
184
|
const tick = () => {
|
package/dist/cli/cli-doctor.d.ts
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `reticle doctor` — collapse the ~6 independent first-run failure modes into one command. Checks the
|
|
3
|
-
* Chromium install (the #1 silent failure), whether a daemon is up on the resolved bridge port, and
|
|
4
|
-
* reminds the user which port the app must dial. Human-readable to stdout (not the JSON log).
|
|
5
|
-
*/
|
|
6
1
|
export declare function handleDoctor(port: number): Promise<void>;
|
package/dist/cli/cli-doctor.js
CHANGED
|
@@ -1,13 +1,59 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ReticleEnv } from '@reticlehq/core';
|
|
4
|
-
import { readPid,
|
|
4
|
+
import { readPid, reticleStateHome } from '../daemon/daemon.js';
|
|
5
|
+
import { PortPresence, probePresence } from '../daemon/port-presence.js';
|
|
6
|
+
import { probeDaemon } from '../mcp/mcp-proxy.js';
|
|
7
|
+
import { fetchStatus } from './cli-launch.js';
|
|
8
|
+
import { daemonLine } from './doctor-daemon-line.js';
|
|
9
|
+
import { describeForeignHolder, findPortHolder } from './port-holder.js';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { SERVER_VERSION } from '../version/server-version.js';
|
|
12
|
+
import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
|
|
5
13
|
import { diagnoseDesktop, isDesktopProject } from '../init/desktop-doctor.js';
|
|
6
14
|
/**
|
|
7
15
|
* `reticle doctor` — collapse the ~6 independent first-run failure modes into one command. Checks the
|
|
8
16
|
* Chromium install (the #1 silent failure), whether a daemon is up on the resolved bridge port, and
|
|
9
17
|
* reminds the user which port the app must dial. Human-readable to stdout (not the JSON log).
|
|
10
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Run a lookup and return its stdout, or null if it could not run.
|
|
21
|
+
*
|
|
22
|
+
* Null on ANY failure — no lsof (every Windows user, a slim container), a non-zero exit, a throw.
|
|
23
|
+
* This is a diagnostic nicety inside the command people run when things are already broken; it must
|
|
24
|
+
* never be the reason `doctor` fails. `describeForeignHolder` handles the null by saying it could
|
|
25
|
+
* not identify the holder and naming our recorded pid, rather than asserting the holder is not ours
|
|
26
|
+
* — a claim it has no evidence for, and one that was wrong for every Windows user.
|
|
27
|
+
*/
|
|
28
|
+
function runCapture(command, args) {
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(command, [...args], { encoding: 'utf8', timeout: LOOKUP_TIMEOUT_MS });
|
|
31
|
+
return 0 === result.status && 'string' === typeof result.stdout ? result.stdout : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Bound on the holder lookup. A hung `lsof` must not hang the command that diagnoses hangs. */
|
|
38
|
+
const LOOKUP_TIMEOUT_MS = 2_000;
|
|
39
|
+
/** Narrow the `/status` payload to the two fields the daemon line reads. */
|
|
40
|
+
function asIdentity(payload) {
|
|
41
|
+
if (typeof payload !== 'object' || null === payload)
|
|
42
|
+
return {};
|
|
43
|
+
const record = payload;
|
|
44
|
+
const pick = (key) => {
|
|
45
|
+
const value = record[key];
|
|
46
|
+
return 'string' === typeof value && value.length > 0 ? value : undefined;
|
|
47
|
+
};
|
|
48
|
+
// Keys are OMITTED rather than set to undefined: `exactOptionalPropertyTypes` is on, and the
|
|
49
|
+
// distinction is the point — "this daemon did not say" is not the same as "this daemon said none".
|
|
50
|
+
const version = pick('version');
|
|
51
|
+
const contract = pick('contract');
|
|
52
|
+
return {
|
|
53
|
+
...(version === undefined ? {} : { version }),
|
|
54
|
+
...(contract === undefined ? {} : { contract }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
11
57
|
export async function handleDoctor(port) {
|
|
12
58
|
const line = (s) => {
|
|
13
59
|
process.stdout.write(`${s}\n`);
|
|
@@ -24,9 +70,31 @@ export async function handleDoctor(port) {
|
|
|
24
70
|
catch {
|
|
25
71
|
line(' chromium ✗ missing — run: npx playwright install chromium');
|
|
26
72
|
}
|
|
73
|
+
// Ask the PORT, not just the pid file. "not running on :4400" has been printed about a port that
|
|
74
|
+
// was demonstrably occupied, which sends the reader to start a daemon that cannot bind. The three
|
|
75
|
+
// states are genuinely different problems with different fixes, so doctor names which one it is.
|
|
27
76
|
const pid = readPid(port);
|
|
28
|
-
|
|
29
|
-
|
|
77
|
+
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
78
|
+
if (presence === PortPresence.DAEMON) {
|
|
79
|
+
// Name WHICH daemon. The /status payload already carries version + contract, and doctor was
|
|
80
|
+
// throwing both away — while skew is invisible everywhere else, reaching the agent as a bare
|
|
81
|
+
// -32000 naming no version. This is the command a human runs at exactly that moment.
|
|
82
|
+
const status = asIdentity(await fetchStatus(port));
|
|
83
|
+
const built = daemonLine(port, pid, status, {
|
|
84
|
+
version: SERVER_VERSION,
|
|
85
|
+
contract: CONTRACT_FINGERPRINT,
|
|
86
|
+
});
|
|
87
|
+
line(built.text);
|
|
88
|
+
if (built.skew !== undefined)
|
|
89
|
+
line(` version ✗ ${built.skew}`);
|
|
90
|
+
}
|
|
91
|
+
else if (presence === PortPresence.FOREIGN) {
|
|
92
|
+
// Name the holder when we can. `doctor` exists for exactly this moment, and "another process"
|
|
93
|
+
// leaves the reader to find a shell command themselves — the obvious one being the `lsof -ti`
|
|
94
|
+
// pipeline that also kills the agent's own MCP proxy.
|
|
95
|
+
// `pid` is what our pid file recorded for this port. When it matches the process actually
|
|
96
|
+
// holding it, this is our OWN daemon wedged, not a stranger — and the fix is different.
|
|
97
|
+
line(` daemon ✗ ${describeForeignHolder(port, findPortHolder(port, runCapture), pid)}`);
|
|
30
98
|
}
|
|
31
99
|
else {
|
|
32
100
|
line(` daemon ✗ not running on :${port} — your agent runs \`reticle mcp\` (or \`reticle serve\`)`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** `reticle feedback --rating 4 "the words"` — the human half of the channel. */
|
|
2
|
+
export declare const RATING_FLAG = "--rating";
|
|
3
|
+
export declare const BUG_FLAG = "--bug";
|
|
4
|
+
/**
|
|
5
|
+
* The AGENT half of the same channel, for the phase where `reticle_feedback` does not exist yet.
|
|
6
|
+
*
|
|
7
|
+
* An agent that is still installing Reticle — or that just watched `init` fail, or a daemon refuse to
|
|
8
|
+
* start — has no MCP tools to file with, and that is exactly the report we most need: the failures
|
|
9
|
+
* that happen before the tool surface is reachable are invisible to every other path we have. This
|
|
10
|
+
* gives that agent the same shape the tool takes, over a command that needs nothing running.
|
|
11
|
+
*/
|
|
12
|
+
export declare const KIND_FLAG = "--kind";
|
|
13
|
+
export declare const AGENT_FLAG = "--agent";
|
|
14
|
+
/** Accepted `--kind` values, from core's enum — never a second hand-written list to drift from it. */
|
|
15
|
+
export declare const FEEDBACK_KINDS: readonly string[];
|
|
16
|
+
/** What `parseFeedbackArgs` yields: the parsed command, or a usage error to print. */
|
|
17
|
+
export type ParsedFeedback = {
|
|
18
|
+
kind: 'feedback';
|
|
19
|
+
text: string;
|
|
20
|
+
rating?: number;
|
|
21
|
+
/** The FeedbackKind name from `--kind`; absent means the legacy bug/experience split. */
|
|
22
|
+
feedbackKind?: string;
|
|
23
|
+
bug: boolean;
|
|
24
|
+
/** `--agent`: file as the agent, so agent reports never dilute the human rating signal. */
|
|
25
|
+
agent: boolean;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'error';
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
/** Parse everything after `reticle feedback`. */
|
|
31
|
+
export declare function parseFeedbackArgs(rest: string[]): ParsedFeedback;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `reticle feedback` grammar — flags, kind validation, and the message-vs-flag split.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `cli-parse.ts` when that file crossed the line cap. Cohesive on its own terms: this
|
|
5
|
+
* is the one command whose arguments are free text, so it is the only place in the grammar where
|
|
6
|
+
* "everything that is not a flag" is a rule with edge cases rather than a one-liner.
|
|
7
|
+
*/
|
|
8
|
+
import { FeedbackKind } from '@reticlehq/core';
|
|
9
|
+
/** `reticle feedback --rating 4 "the words"` — the human half of the channel. */
|
|
10
|
+
export const RATING_FLAG = '--rating';
|
|
11
|
+
export const BUG_FLAG = '--bug';
|
|
12
|
+
/**
|
|
13
|
+
* The AGENT half of the same channel, for the phase where `reticle_feedback` does not exist yet.
|
|
14
|
+
*
|
|
15
|
+
* An agent that is still installing Reticle — or that just watched `init` fail, or a daemon refuse to
|
|
16
|
+
* start — has no MCP tools to file with, and that is exactly the report we most need: the failures
|
|
17
|
+
* that happen before the tool surface is reachable are invisible to every other path we have. This
|
|
18
|
+
* gives that agent the same shape the tool takes, over a command that needs nothing running.
|
|
19
|
+
*/
|
|
20
|
+
export const KIND_FLAG = '--kind';
|
|
21
|
+
export const AGENT_FLAG = '--agent';
|
|
22
|
+
/** Accepted `--kind` values, from core's enum — never a second hand-written list to drift from it. */
|
|
23
|
+
export const FEEDBACK_KINDS = Object.values(FeedbackKind);
|
|
24
|
+
/** Parse everything after `reticle feedback`. */
|
|
25
|
+
export function parseFeedbackArgs(rest) {
|
|
26
|
+
const ratingAt = rest.indexOf(RATING_FLAG);
|
|
27
|
+
const rating = -1 === ratingAt ? undefined : Number(rest[ratingAt + 1]);
|
|
28
|
+
if (rating !== undefined && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
|
|
29
|
+
return { kind: 'error', message: `${RATING_FLAG} takes a whole number from 1 to 5` };
|
|
30
|
+
}
|
|
31
|
+
const kindAt = rest.indexOf(KIND_FLAG);
|
|
32
|
+
const feedbackKind = -1 === kindAt ? undefined : rest[kindAt + 1];
|
|
33
|
+
if (feedbackKind !== undefined && !FEEDBACK_KINDS.includes(feedbackKind)) {
|
|
34
|
+
return { kind: 'error', message: `${KIND_FLAG} takes one of: ${FEEDBACK_KINDS.join(', ')}` };
|
|
35
|
+
}
|
|
36
|
+
// Everything that is not a flag or a flag's VALUE is the message. Quoting is the user's job for
|
|
37
|
+
// shell reasons, but joining the remainder means an unquoted sentence still works. Both value
|
|
38
|
+
// positions have to be consumed: without that, `--kind bug "..."` filed a report whose text began
|
|
39
|
+
// with the word "bug".
|
|
40
|
+
const consumed = new Set([
|
|
41
|
+
...(-1 === ratingAt ? [] : [ratingAt, ratingAt + 1]),
|
|
42
|
+
...(-1 === kindAt ? [] : [kindAt, kindAt + 1]),
|
|
43
|
+
]);
|
|
44
|
+
const text = rest
|
|
45
|
+
.filter((arg, i) => !consumed.has(i) && !arg.startsWith('--'))
|
|
46
|
+
.join(' ')
|
|
47
|
+
.trim();
|
|
48
|
+
return {
|
|
49
|
+
kind: 'feedback',
|
|
50
|
+
text,
|
|
51
|
+
...(rating !== undefined ? { rating } : {}),
|
|
52
|
+
...(feedbackKind !== undefined ? { feedbackKind } : {}),
|
|
53
|
+
bug: rest.includes(BUG_FLAG),
|
|
54
|
+
agent: rest.includes(AGENT_FLAG),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/cli/cli-parse.d.ts
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
* file-size cap and keep the parser pure + unit-testable. Re-exported from cli.ts so existing
|
|
5
5
|
* imports are unchanged.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export declare const
|
|
10
|
-
export declare const BUG_FLAG = "--bug";
|
|
7
|
+
import { type ParsedFeedback } from './cli-parse-feedback.js';
|
|
8
|
+
export { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG, } from './cli-parse-feedback.js';
|
|
9
|
+
export declare const CLI_USAGE = "usage:\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>] (wire Reticle into the project in this directory)\n --app picks WHICH app in a monorepo, when several are found\n --no-mcp skips MORE than the server registration: also the agent rule files\n (CLAUDE.md / AGENTS.md / .cursor) and the /reticle command, because all three\n only make sense once the tools are reachable.\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet]\n reticle status [--port N]\n reticle doctor [--port N] (diagnose setup: Chromium, daemon, port \u2014 one command)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--headed] [--timeout N] [--storage-state <file>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode \u2014 for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy \u2014 auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics \u2014 status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't \u2014 prints exactly what it sends)\n reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> \"message\"\n (agents: file from anywhere, including a setup that never finished)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle Cloud \u2014 runs/flows recorded on the dashboard):\n reticle login --email <e> [--code <c>] [--org <n>] (sign in: mails a code, then exchanges it)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push (send local run artifacts to the dashboard)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner \u2014 batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
|
|
11
10
|
/** The `reticle telemetry` sub-actions. Bare `reticle telemetry` means `status`. */
|
|
12
11
|
export declare const TelemetryAction: {
|
|
13
12
|
readonly STATUS: "status";
|
|
@@ -61,12 +60,9 @@ export type CliResult = {
|
|
|
61
60
|
} | {
|
|
62
61
|
kind: 'telemetry';
|
|
63
62
|
action: TelemetryAction;
|
|
64
|
-
} | {
|
|
63
|
+
} | Extract<ParsedFeedback, {
|
|
65
64
|
kind: 'feedback';
|
|
66
|
-
|
|
67
|
-
rating?: number;
|
|
68
|
-
bug: boolean;
|
|
69
|
-
} | {
|
|
65
|
+
}> | {
|
|
70
66
|
kind: 'identify';
|
|
71
67
|
context?: string;
|
|
72
68
|
company?: string;
|
package/dist/cli/cli-parse.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* file-size cap and keep the parser pure + unit-testable. Re-exported from cli.ts so existing
|
|
5
5
|
* imports are unchanged.
|
|
6
6
|
*/
|
|
7
|
+
import { parseFeedbackArgs } from './cli-parse-feedback.js';
|
|
8
|
+
// Re-exported so every existing importer of these flags is unaffected by the file split.
|
|
9
|
+
export { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG, } from './cli-parse-feedback.js';
|
|
7
10
|
export const CLI_USAGE = `usage:
|
|
8
11
|
reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] [--app <dir>] (wire Reticle into the project in this directory)
|
|
9
12
|
--app picks WHICH app in a monorepo, when several are found
|
|
@@ -26,6 +29,8 @@ export const CLI_USAGE = `usage:
|
|
|
26
29
|
reticle license (show enterprise license status: active | eval | missing)
|
|
27
30
|
reticle telemetry [status|enable|disable] (anonymous usage metrics — status shows what's sent + the policy)
|
|
28
31
|
reticle feedback [--rating 1-5] [--bug] "message" (tell us what worked and what didn't — prints exactly what it sends)
|
|
32
|
+
reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "message"
|
|
33
|
+
(agents: file from anywhere, including a setup that never finished)
|
|
29
34
|
reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]
|
|
30
35
|
(OPT-IN: tell us who you are, e.g. for support or an enterprise trial)
|
|
31
36
|
|
|
@@ -64,9 +69,6 @@ const COMPANY_FLAG = '--company';
|
|
|
64
69
|
const EMAIL_FLAG = '--email';
|
|
65
70
|
const CONTEXT_FLAG = '--context';
|
|
66
71
|
const FORGET_FLAG = '--forget';
|
|
67
|
-
/** `reticle feedback --rating 4 "the words"` — the human half of the channel. */
|
|
68
|
-
export const RATING_FLAG = '--rating';
|
|
69
|
-
export const BUG_FLAG = '--bug';
|
|
70
72
|
/** The `reticle telemetry` sub-actions. Bare `reticle telemetry` means `status`. */
|
|
71
73
|
export const TelemetryAction = {
|
|
72
74
|
STATUS: 'status',
|
|
@@ -496,26 +498,8 @@ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
|
|
|
496
498
|
forget: rest.includes(FORGET_FLAG),
|
|
497
499
|
};
|
|
498
500
|
}
|
|
499
|
-
case FEEDBACK_COMMAND:
|
|
500
|
-
|
|
501
|
-
const rating = -1 === ratingAt ? undefined : Number(rest[ratingAt + 1]);
|
|
502
|
-
if (rating !== undefined && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
|
|
503
|
-
return { kind: 'error', message: `${RATING_FLAG} takes a whole number from 1 to 5` };
|
|
504
|
-
}
|
|
505
|
-
// Everything that is not a flag or the rating's value is the message. Quoting is the user's
|
|
506
|
-
// job for shell reasons, but joining the remainder means an unquoted sentence still works.
|
|
507
|
-
const consumed = new Set(-1 === ratingAt ? [] : [ratingAt, ratingAt + 1]);
|
|
508
|
-
const text = rest
|
|
509
|
-
.filter((arg, i) => !consumed.has(i) && !arg.startsWith('--'))
|
|
510
|
-
.join(' ')
|
|
511
|
-
.trim();
|
|
512
|
-
return {
|
|
513
|
-
kind: 'feedback',
|
|
514
|
-
text,
|
|
515
|
-
...(rating !== undefined ? { rating } : {}),
|
|
516
|
-
bug: rest.includes(BUG_FLAG),
|
|
517
|
-
};
|
|
518
|
-
}
|
|
501
|
+
case FEEDBACK_COMMAND:
|
|
502
|
+
return parseFeedbackArgs(rest);
|
|
519
503
|
case OPEN_COMMAND: {
|
|
520
504
|
const port = parsePortFlag(rest, defaultPort);
|
|
521
505
|
// The first non-flag arg is the url (optional — omitting reuses a connected tab).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `doctor` says about the daemon it found — pure, so it can be tested without a socket.
|
|
3
|
+
*
|
|
4
|
+
* Doctor already stopped lying about the PORT: `probePresence` separates "a daemon is here" from "a
|
|
5
|
+
* stranger holds it" from "nothing is listening", which was the load-bearing half of the report. It
|
|
6
|
+
* still did not say WHICH daemon, though the `/status` payload it already fetches carries `version`
|
|
7
|
+
* and `contract` and it discarded both.
|
|
8
|
+
*
|
|
9
|
+
* That is the gap worth closing, because skew is invisible everywhere else: a CLI and a daemon on
|
|
10
|
+
* different contracts connect anyway and then disagree about behaviour, which reaches the agent as a
|
|
11
|
+
* bare `-32000` naming no version at all. Doctor is the command a human runs at exactly that moment.
|
|
12
|
+
*
|
|
13
|
+
* Same split as `classifyPort`/`probePresence`: the rule lives here and is tested; the fetching stays
|
|
14
|
+
* at the call site.
|
|
15
|
+
*/
|
|
16
|
+
/** The fields of `/status` this line cares about. Both optional — an old daemon reports neither. */
|
|
17
|
+
export interface DaemonIdentity {
|
|
18
|
+
version?: string;
|
|
19
|
+
contract?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DaemonLine {
|
|
22
|
+
/** The `daemon ✓ …` line itself. */
|
|
23
|
+
text: string;
|
|
24
|
+
/** A skew sentence to print underneath, when this CLI and that daemon disagree. */
|
|
25
|
+
skew?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the daemon line for a port that answered `/status`.
|
|
29
|
+
*
|
|
30
|
+
* `pid` comes from the pid file and is genuinely optional — a daemon started by another user, or one
|
|
31
|
+
* whose pid file was cleaned up, still answers. Printing `(pid null)` would be worse than printing
|
|
32
|
+
* nothing, which is the same class of mistake as the port lie this command already fixed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function daemonLine(port: number, pid: number | null, peer: DaemonIdentity, self: {
|
|
35
|
+
version: string;
|
|
36
|
+
contract: string;
|
|
37
|
+
}): DaemonLine;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `doctor` says about the daemon it found — pure, so it can be tested without a socket.
|
|
3
|
+
*
|
|
4
|
+
* Doctor already stopped lying about the PORT: `probePresence` separates "a daemon is here" from "a
|
|
5
|
+
* stranger holds it" from "nothing is listening", which was the load-bearing half of the report. It
|
|
6
|
+
* still did not say WHICH daemon, though the `/status` payload it already fetches carries `version`
|
|
7
|
+
* and `contract` and it discarded both.
|
|
8
|
+
*
|
|
9
|
+
* That is the gap worth closing, because skew is invisible everywhere else: a CLI and a daemon on
|
|
10
|
+
* different contracts connect anyway and then disagree about behaviour, which reaches the agent as a
|
|
11
|
+
* bare `-32000` naming no version at all. Doctor is the command a human runs at exactly that moment.
|
|
12
|
+
*
|
|
13
|
+
* Same split as `classifyPort`/`probePresence`: the rule lives here and is tested; the fetching stays
|
|
14
|
+
* at the call site.
|
|
15
|
+
*/
|
|
16
|
+
import { describeSkew } from '../version/version-skew.js';
|
|
17
|
+
const FIX = 'Restart it so both ends are the same build: `reticle stop`, then let your agent reconnect.';
|
|
18
|
+
/**
|
|
19
|
+
* Build the daemon line for a port that answered `/status`.
|
|
20
|
+
*
|
|
21
|
+
* `pid` comes from the pid file and is genuinely optional — a daemon started by another user, or one
|
|
22
|
+
* whose pid file was cleaned up, still answers. Printing `(pid null)` would be worse than printing
|
|
23
|
+
* nothing, which is the same class of mistake as the port lie this command already fixed.
|
|
24
|
+
*/
|
|
25
|
+
export function daemonLine(port, pid, peer, self) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (null !== pid)
|
|
28
|
+
parts.push(`pid ${String(pid)}`);
|
|
29
|
+
if (peer.version !== undefined)
|
|
30
|
+
parts.push(`v${peer.version}`);
|
|
31
|
+
const detail = 0 === parts.length ? '' : ` (${parts.join(', ')})`;
|
|
32
|
+
const skew = describeSkew({ what: 'the daemon on this port', version: peer.version, contract: peer.contract, fix: FIX }, self);
|
|
33
|
+
const line = { text: ` daemon ✓ running on :${String(port)}${detail}` };
|
|
34
|
+
return skew === undefined ? line : { ...line, skew };
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is holding the bridge port.
|
|
3
|
+
*
|
|
4
|
+
* `doctor` is what a user runs when the agent cannot reach the bridge, and the commonest real cause
|
|
5
|
+
* is that something else is on 4400. It already stopped saying "nothing is listening" for that case,
|
|
6
|
+
* but it did not say WHICH process — so the reader's next move was a shell command they had to know.
|
|
7
|
+
*
|
|
8
|
+
* Naming the holder is also the safe answer to a trap this project has hit for real: the obvious
|
|
9
|
+
* lookup, `lsof -ti tcp:4400 | xargs kill -9`, ALSO kills the agent's own `reticle mcp` proxy,
|
|
10
|
+
* because that proxy holds a client connection to the same port and `-ti` does not filter to
|
|
11
|
+
* listeners. Every invocation here is `-sTCP:LISTEN`, and the message never suggests `-ti`.
|
|
12
|
+
*
|
|
13
|
+
* The parser is pure and the lookup takes an injected exec, so the rule is tested on every platform
|
|
14
|
+
* while the shelling out stays at the call site.
|
|
15
|
+
*/
|
|
16
|
+
export interface PortHolder {
|
|
17
|
+
pid: number;
|
|
18
|
+
command: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The FIRST listener in `lsof -nP -iTCP:<port> -sTCP:LISTEN -F pc` output, or null.
|
|
22
|
+
*
|
|
23
|
+
* First, not "the only one": a dual-stack listener (IPv4 + IPv6) is reported twice, and on a port
|
|
24
|
+
* conflict the answer the reader needs is "something is there, here is what" rather than an
|
|
25
|
+
* exhaustive list. A second `p` record ends the first.
|
|
26
|
+
*
|
|
27
|
+
* Null on anything incomplete. Half an answer — a pid with no command — prints worse than the
|
|
28
|
+
* un-named message it would replace, which is the same mistake as the port lie this command fixed.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parsePortHolder(stdout: string): PortHolder | null;
|
|
31
|
+
/** What `doctor` prints for a port held by something that is not a Reticle daemon. */
|
|
32
|
+
export declare function describeForeignHolder(port: number, holder: PortHolder | null,
|
|
33
|
+
/**
|
|
34
|
+
* The pid we recorded for this port, from the daemon pid file.
|
|
35
|
+
*
|
|
36
|
+
* When it matches the process actually holding the port, the port is held by OUR daemon — one
|
|
37
|
+
* that accepts TCP and never answers `/status`, i.e. wedged. That classifies as FOREIGN, which is
|
|
38
|
+
* right for "can anything bind this?" and wrong to say out loud: the reader goes hunting a
|
|
39
|
+
* stranger, and the fix that works (`reticle stop`, it respawns on the next call) is never named.
|
|
40
|
+
*/
|
|
41
|
+
ourPid?: number | null): string;
|
|
42
|
+
/** The lookup itself. `exec` is injected so the shelling out never runs in a unit test. */
|
|
43
|
+
export declare function findPortHolder(port: number, exec: (command: string, args: readonly string[]) => string | null): PortHolder | null;
|