@gleapai/kai-bridge 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/npm-shrinkwrap.json +105 -101
- package/package.json +7 -7
- package/runner/acp-runner.mjs +15 -53
- package/runner/lib/acp/harnesses.mjs +15 -1
- package/runner/lib/acp/mapper.mjs +13 -31
- package/runner/lib/acp/transcripts.mjs +41 -0
- package/runner/lib/contract.mjs +1 -16
- package/runner/tools/patch-claude-acp.mjs +1 -1
- package/scripts/postinstall.mjs +7 -0
- package/src/api.mjs +16 -102
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +607 -1054
- package/src/executor.mjs +2 -2
- package/src/gateway.mjs +217 -0
- package/src/harnesses.mjs +16 -3
- package/src/playwright-patch.mjs +84 -0
- package/src/preview-errors.mjs +0 -29
- package/src/preview.mjs +291 -101
- package/src/service.mjs +0 -11
- package/src/setup.mjs +5 -5
- package/src/tunnel-binary.mjs +109 -0
- package/src/tunnel.mjs +227 -0
- package/src/workspace.mjs +1 -1
- package/runner/personas/claude/kai-verifier.md +0 -84
- package/runner/personas/codex/kai-verifier.md +0 -84
- package/runner/tools/verify-mcp.mjs +0 -442
- package/src/preview-login.mjs +0 -610
- package/src/verify.mjs +0 -387
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
|
-
import { findClaudeTranscript, findCodexRollout, readClaudeTurnUsage, readCodexTurnUsage } from "./transcripts.mjs";
|
|
14
|
+
import { findClaudeTranscript, findCodexRollout, readClaudePriorCostUsd, readClaudeTurnUsage, readCodexTurnUsage } from "./transcripts.mjs";
|
|
15
15
|
import { isClaudeAcpPatched } from "../../tools/patch-claude-acp.mjs";
|
|
16
16
|
|
|
17
17
|
export const HARNESS_IDS = ["claude", "codex", "cursor"];
|
|
@@ -242,6 +242,14 @@ export const HARNESSES = {
|
|
|
242
242
|
// Drops the CLI's per-run dynamic sections (cwd listing, date …)
|
|
243
243
|
// so the cached prefix stays byte-stable across turns.
|
|
244
244
|
excludeDynamicSections: true,
|
|
245
|
+
// Render the append fresh on every launch. Agent SDKs after 0.3.258
|
|
246
|
+
// (0.3.266 and 0.3.280 checked) record the first launch's system
|
|
247
|
+
// prompt in the transcript by default and replay it on resume,
|
|
248
|
+
// IGNORING a different `append` until compaction — a plan → build
|
|
249
|
+
// resume would keep the plan turn's guards and lose the git
|
|
250
|
+
// hand-off rule (verified live on 0.3.280: a resumed turn answered
|
|
251
|
+
// from the previous turn's project instructions).
|
|
252
|
+
snapshot: false,
|
|
245
253
|
},
|
|
246
254
|
claudeCode: {
|
|
247
255
|
options: {
|
|
@@ -297,6 +305,12 @@ export const HARNESSES = {
|
|
|
297
305
|
sessionModePreference: (ctx) => (ctx.isPlanMode ? ["plan"] : ctx.isArtifactWriter ? ["dontAsk", "plan"] : ["bypassPermissions", "acceptEdits", "default"]),
|
|
298
306
|
/** A prior turn's transcript on disk is what makes `resume` viable. */
|
|
299
307
|
hasResumableSession: (ctx) => !!findClaudeTranscript(ctx.configDir, ctx.workDir, ctx.resumeSessionId),
|
|
308
|
+
/**
|
|
309
|
+
* USD the session's earlier turns already cost, as the CLI saved it —
|
|
310
|
+
* a resumed query's cost figure continues from it (see
|
|
311
|
+
* readClaudePriorCostUsd). Read BEFORE session/resume.
|
|
312
|
+
*/
|
|
313
|
+
priorTurnsCostUsd: (ctx) => readClaudePriorCostUsd({ configDir: ctx.configDir, cwd: ctx.workDir, sessionId: ctx.resumeSessionId }),
|
|
300
314
|
/** Billing-grade usage for the turn from the CLI's own transcript. */
|
|
301
315
|
collectTurnUsage: (ctx) =>
|
|
302
316
|
readClaudeTurnUsage({
|
|
@@ -24,19 +24,6 @@ import {
|
|
|
24
24
|
const QUESTION_TOOL = "Question";
|
|
25
25
|
const EXIT_PLAN_TOOL = "ExitPlanMode";
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* The `kai_verify` bridge's `report_verification` (tools/verify-mcp.mjs)
|
|
29
|
-
* in every harness spelling: Claude `mcp__kai_verify__report_verification`,
|
|
30
|
-
* Codex's server-scoped form (rebuilt from `rawInput.server/tool` by
|
|
31
|
-
* toolNameFromUpdate), a bare title. Its call becomes the `verify_report`
|
|
32
|
-
* contract event — never a tool row, and NOT turn-ending (the persona
|
|
33
|
-
* wraps up after reporting).
|
|
34
|
-
*/
|
|
35
|
-
export function isVerifyReportTool(name) {
|
|
36
|
-
return /(^|__|\.)report_verification$/.test(String(name || ""));
|
|
37
|
-
}
|
|
38
|
-
const VERIFY_REPORT_TOOL_NAME = "mcp__kai_verify__report_verification";
|
|
39
|
-
|
|
40
27
|
/** Generic ACP `kind` → a readable tool label when no meta name exists. */
|
|
41
28
|
const KIND_LABEL = {
|
|
42
29
|
read: "Read",
|
|
@@ -60,6 +47,14 @@ export function toolNameFromUpdate(update) {
|
|
|
60
47
|
meta.codex?.tool ??
|
|
61
48
|
meta.toolName;
|
|
62
49
|
if (typeof fromMeta === "string" && fromMeta) return fromMeta;
|
|
50
|
+
// ACP's own tool `name` is all that names an ExitPlanMode /
|
|
51
|
+
// AskUserQuestion PERMISSION REQUEST — claude-agent-acp sends that
|
|
52
|
+
// toolCall without `_meta.claudeCode`. Read as a generic "SwitchMode",
|
|
53
|
+
// the policy approved the plan whenever the request beat the hand-off's
|
|
54
|
+
// cancel (`exit-plan-clear-auto`, seen live on 0.81.1).
|
|
55
|
+
// Trusted for these turn-ending names only: codex-acp's generic ones
|
|
56
|
+
// (`exec_command`, `request_permissions`, …) keep the mapping below.
|
|
57
|
+
if (update?.name === EXIT_PLAN_TOOL || update?.name === "AskUserQuestion") return update.name;
|
|
63
58
|
// codex-acp encodes MCP tool calls as `rawInput: {server, tool,
|
|
64
59
|
// arguments}` under kind "execute" — without this they'd render as a
|
|
65
60
|
// Bash row, and the ask_user question bridge would never be detected.
|
|
@@ -75,8 +70,6 @@ export function toolNameFromUpdate(update) {
|
|
|
75
70
|
// the announce leaks a bare "Tool (running)" row that the suppressed
|
|
76
71
|
// completion never clears.
|
|
77
72
|
if (typeof update?.title === "string" && /todo_write/.test(update.title)) return "TodoWrite";
|
|
78
|
-
// Same for the verifier's report bridge.
|
|
79
|
-
if (typeof update?.title === "string" && /report_verification/.test(update.title)) return VERIFY_REPORT_TOOL_NAME;
|
|
80
73
|
if (update?.kind && KIND_LABEL[update.kind]) return KIND_LABEL[update.kind];
|
|
81
74
|
return "Tool";
|
|
82
75
|
}
|
|
@@ -248,16 +241,11 @@ function contentToValue(content) {
|
|
|
248
241
|
* the agent: a mode the adapter fails to apply must not become a free
|
|
249
242
|
* pass for edits.
|
|
250
243
|
*/
|
|
251
|
-
export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false,
|
|
244
|
+
export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false, workDir = "" } = {}) {
|
|
252
245
|
const WRITE_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
253
246
|
const READONLY_BASH = /^\s*(cat|head|tail|wc|stat|ls|find|grep|rg|git (log|diff|show|status|ls-files|grep|rev-parse|branch)|sed -n|awk|jq|sort|uniq|cut|tr|diff|nl|basename|dirname|realpath|file|echo|pwd|which)\b/;
|
|
254
247
|
const kaiDir = workDir ? `${workDir.replace(/\/+$/, "")}/.kai/` : "/.kai/";
|
|
255
248
|
return (name, input) => {
|
|
256
|
-
// Read-only agents (kai-verifier) keep build-mode tool access — the
|
|
257
|
-
// browser MCP, shell probes and questions must run unprompted — but a
|
|
258
|
-
// file-write tool is never theirs. Shell leaks are caught by the
|
|
259
|
-
// post-turn revert.
|
|
260
|
-
if (isReadOnly && !isPlanMode && !isArtifactWriter) return !WRITE_TOOLS.test(canonicalToolName(name));
|
|
261
249
|
if (!isPlanMode && !isArtifactWriter) return true;
|
|
262
250
|
const canonical = canonicalToolName(name);
|
|
263
251
|
if (WRITE_TOOLS.test(canonical)) {
|
|
@@ -342,16 +330,6 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
342
330
|
}
|
|
343
331
|
return;
|
|
344
332
|
}
|
|
345
|
-
if (isVerifyReportTool(meta.name)) {
|
|
346
|
-
// One `verify_report` per call, on completion only — the announce
|
|
347
|
-
// (input still streaming) and the completed update would otherwise
|
|
348
|
-
// hand the host two reports for one filing. Local artifact paths
|
|
349
|
-
// ride in `report`; the host uploads them and rewrites to URLs.
|
|
350
|
-
if (status !== "running" && meta.input && typeof meta.input === "object" && !Array.isArray(meta.input)) {
|
|
351
|
-
emit({ type: "verify_report", message: "Verification report filed", report: sanitizeToolValue(meta.input, 4000) });
|
|
352
|
-
}
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
333
|
// Adapter-provided titles ("List files in 'src'") stand in when the
|
|
356
334
|
// input carries nothing summarizeTool understands.
|
|
357
335
|
const summary = summarizeTool(name, meta.input || {}) || meta.title || "";
|
|
@@ -594,6 +572,10 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
594
572
|
return pick("reject_once") ?? pick("reject_always") ?? "__reject__";
|
|
595
573
|
}
|
|
596
574
|
if (handleTurnEndingTool(name, input)) return null;
|
|
575
|
+
// A question / plan hand-off without a usable payload is still never
|
|
576
|
+
// approved: allowing ExitPlanMode would start the build inside the
|
|
577
|
+
// plan turn. Cancelling denies just this call.
|
|
578
|
+
if (isTurnEndingTool(name)) return null;
|
|
597
579
|
if (!allowTool(name, input)) {
|
|
598
580
|
emit({
|
|
599
581
|
type: "tool_status",
|
|
@@ -148,6 +148,47 @@ export function readClaudeTurnUsage({ configDir, cwd, sessionId, sinceTs }) {
|
|
|
148
148
|
return { path, rows: parseClaudeTranscript(readFileSync(path, "utf8"), { sinceTs }) };
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The session's cumulative USD as Claude Code saved it: the LAST
|
|
153
|
+
* `{"type":"cost-state","totalCostUSD":…}` line (written after each turn
|
|
154
|
+
* by CLI 2.1.280; absent from older transcripts). 0 when there is none.
|
|
155
|
+
*/
|
|
156
|
+
export function lastClaudeCostState(text) {
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
159
|
+
if (!line.includes('"cost-state"')) continue;
|
|
160
|
+
let obj;
|
|
161
|
+
try {
|
|
162
|
+
obj = JSON.parse(line);
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (obj?.type === "cost-state" && typeof obj.totalCostUSD === "number" && Number.isFinite(obj.totalCostUSD)) {
|
|
167
|
+
total = Math.max(0, obj.totalCostUSD);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return total;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What the session's earlier turns already cost. A resumed query's
|
|
175
|
+
* `total_cost_usd` — the adapter's `usage_update.cost` — "continues from
|
|
176
|
+
* the total its transcript saved" (Agent SDK 0.3.280; 0.3.258 started
|
|
177
|
+
* resumed sessions at 0), so without subtracting this every resumed turn
|
|
178
|
+
* reports the whole conversation's cost. Must be read BEFORE the resume:
|
|
179
|
+
* the turn appends its own record.
|
|
180
|
+
*/
|
|
181
|
+
export function readClaudePriorCostUsd({ configDir, cwd, sessionId }) {
|
|
182
|
+
if (!sessionId) return 0;
|
|
183
|
+
const path = findClaudeTranscript(configDir, cwd, sessionId);
|
|
184
|
+
if (!path) return 0;
|
|
185
|
+
try {
|
|
186
|
+
return lastClaudeCostState(readFileSync(path, "utf8"));
|
|
187
|
+
} catch {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
151
192
|
// ── Codex ──────────────────────────────────────────────────────────────
|
|
152
193
|
|
|
153
194
|
/**
|
package/runner/lib/contract.mjs
CHANGED
|
@@ -64,9 +64,6 @@ export function decodeB64Json(value, fallback) {
|
|
|
64
64
|
export const KAI_PLANNER_AGENT_NAME = "kai-planner";
|
|
65
65
|
export const KAI_BUILDER_AGENT_NAME = "kai";
|
|
66
66
|
export const KAI_RESOLUTION_ANALYST_AGENT_NAME = "kai-resolution-analyst";
|
|
67
|
-
// Kai Code "Verify": drives the running preview with the browser tools,
|
|
68
|
-
// records evidence, files a `report_verification`. Read-only by contract.
|
|
69
|
-
export const KAI_VERIFIER_AGENT_NAME = "kai-verifier";
|
|
70
67
|
|
|
71
68
|
export function normalizeAgentName(raw) {
|
|
72
69
|
const value = typeof raw === "string" ? raw : "";
|
|
@@ -121,18 +118,6 @@ export function isArtifactWriterAgent(agentName) {
|
|
|
121
118
|
return ARTIFACT_WRITER_AGENT_NAMES.includes(agentName);
|
|
122
119
|
}
|
|
123
120
|
|
|
124
|
-
// Read-only agents produce NO files at all — not even `.kai/` artifacts.
|
|
125
|
-
// The verifier's output is the `report_verification` tool call plus the
|
|
126
|
-
// browser recordings the Playwright MCP writes OUTSIDE the workspace
|
|
127
|
-
// (the host's artifacts dir). They run with build-mode tool access (the
|
|
128
|
-
// browser MCP + questions must work unprompted), so `revertRepoMutations`
|
|
129
|
-
// is what guarantees the repos come out exactly as they went in.
|
|
130
|
-
export const READ_ONLY_AGENT_NAMES = [KAI_VERIFIER_AGENT_NAME];
|
|
131
|
-
|
|
132
|
-
export function isReadOnlyAgent(agentName) {
|
|
133
|
-
return READ_ONLY_AGENT_NAMES.includes(agentName);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
121
|
function findWorkspaceRepos(workDir) {
|
|
137
122
|
const repos = [];
|
|
138
123
|
if (existsSync(join(workDir, ".git"))) {
|
|
@@ -356,7 +341,7 @@ const SUPPORTED_FILE_PART_MIMES = new Set(["application/pdf"]);
|
|
|
356
341
|
* flags are optional except `--task-b64`; callers fail fast on an
|
|
357
342
|
* empty `task` themselves (the error message differs per runner).
|
|
358
343
|
*
|
|
359
|
-
* --agent <name> kai | kai-planner | kai-
|
|
344
|
+
* --agent <name> kai | kai-planner | kai-documentarian | …
|
|
360
345
|
* --model <provider/modelID> canonical registry id
|
|
361
346
|
* --engine-model <slug> engine-native model slug (claude/codex wire form)
|
|
362
347
|
* --subagent-model <provider/modelID> cheaper sibling for explorer subagents
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `prompt_suggestion` message (the predicted next user prompt Claude
|
|
4
4
|
// Code shows as ghost text in its own composer) reaches the runner.
|
|
5
5
|
//
|
|
6
|
-
// Upstream (≤ 0.
|
|
6
|
+
// Upstream (≤ 0.81.1) drops the message on the floor — `case
|
|
7
7
|
// "prompt_suggestion": break;` — because ACP has no update kind for it.
|
|
8
8
|
// We forward it as a `session_info_update` carrying `_meta.kai
|
|
9
9
|
// .promptSuggestion` (ACP's sanctioned extension point; the SDK's
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -16,6 +16,13 @@ try {
|
|
|
16
16
|
} catch {
|
|
17
17
|
// The daemon retries on start; a missing patch only means no suggestions.
|
|
18
18
|
}
|
|
19
|
+
// Raise the browser MCP's 30 s per-call default (see src/playwright-patch.mjs).
|
|
20
|
+
try {
|
|
21
|
+
const { ensurePlaywrightTimeoutPatched } = await import("../src/playwright-patch.mjs");
|
|
22
|
+
ensurePlaywrightTimeoutPatched();
|
|
23
|
+
} catch {
|
|
24
|
+
// The daemon retries on start.
|
|
25
|
+
}
|
|
19
26
|
try {
|
|
20
27
|
const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
|
21
28
|
const isGlobal = process.env.npm_config_global === "true";
|
package/src/api.mjs
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
// POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
|
|
7
7
|
// POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
|
|
8
8
|
// POST /gleapcode/bridge/turns/:id/result { result, changes, status }
|
|
9
|
-
// POST /gleapcode/bridge/
|
|
10
|
-
// POST /gleapcode/bridge/turns/:id/verification{ status, scope, reason, checks, untested, artifacts, evidence?, revisions? }
|
|
11
|
-
// PUT /gleapcode/bridge/turns/:id/verification/artifacts { artifacts, evidence } (evidence retry — union by URL, clears evidenceMissing)
|
|
12
|
-
// POST /gleapcode/bridge/turns/:id/verification/stage { stage, note? } (setup|booting|login|verifying|fixing|saving)
|
|
13
|
-
// GET /gleapcode/bridge/turns/:id/preview-login → the saved sign-in record for this verify turn (one GET per turn)
|
|
14
|
-
// POST /gleapcode/bridge/preview-logins/:requestId { storageState, origins, services, landingUrl, loginPaths } (capture upload)
|
|
15
|
-
// POST /gleapcode/bridge/preview-logins/:requestId/status { status: opened|waiting_signin|detected|failed|cancelled, error? }
|
|
16
|
-
// PUT /gleapcode/bridge/preview-logins/:id { storageState, basedOnVersion } → 409 on a stale version
|
|
9
|
+
// POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
|
|
17
10
|
// POST /users/me/pusher { socket_id, channel_name } (channel auth)
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The human-readable part of a Server error body. `statusOr500` answers
|
|
14
|
+
* `{ ok: false, message }`; the global handler answers `{ error: { message,
|
|
15
|
+
* details } }` — reading `error` itself printed "[object Object]".
|
|
16
|
+
*/
|
|
17
|
+
export function errorMessage(data, text = "") {
|
|
18
|
+
const pick = (v) => (typeof v === "string" && v ? v : null);
|
|
19
|
+
return pick(data?.message) ?? pick(data?.error?.message) ?? pick(data?.error) ?? pick(text.slice(0, 200)) ?? "no error body";
|
|
20
|
+
}
|
|
21
21
|
|
|
22
22
|
export class BridgeApi {
|
|
23
23
|
constructor({ apiBase, token, fetchImpl = fetch }) {
|
|
@@ -51,7 +51,7 @@ export class BridgeApi {
|
|
|
51
51
|
data = { raw: text };
|
|
52
52
|
}
|
|
53
53
|
if (!res.ok) {
|
|
54
|
-
const err = new Error(`${method} ${path} → ${res.status}: ${data
|
|
54
|
+
const err = new Error(`${method} ${path} → ${res.status}: ${errorMessage(data, text)}`);
|
|
55
55
|
err.status = res.status;
|
|
56
56
|
err.data = data;
|
|
57
57
|
throw err;
|
|
@@ -116,97 +116,7 @@ export class BridgeApi {
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/**
|
|
120
|
-
* Upload one verify artifact (recording / screenshot / trace) as
|
|
121
|
-
* multipart `file`; resolves the Server's `{ url }`. Separate from the
|
|
122
|
-
* JSON-only `request()`: the body is a FormData (fetch sets the boundary
|
|
123
|
-
* header itself — never set content-type here), a video can take a
|
|
124
|
-
* while (5-min cap), and it retries transient failures 3× — a dropped
|
|
125
|
-
* screenshot is missing evidence, not a broken turn.
|
|
126
|
-
*/
|
|
127
|
-
async uploadArtifact(turnId, filePath, contentType, { tries = 3, timeoutMs = 5 * 60_000, onRetry } = {}) {
|
|
128
|
-
const bytes = await readFile(filePath);
|
|
129
|
-
const path = `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/artifacts`;
|
|
130
|
-
let delay = 1_000;
|
|
131
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
132
|
-
try {
|
|
133
|
-
const form = new FormData();
|
|
134
|
-
form.append("file", new Blob([bytes], { type: contentType }), basename(filePath));
|
|
135
|
-
const res = await this.fetch(`${this.apiBase}${path}`, {
|
|
136
|
-
method: "POST",
|
|
137
|
-
headers: { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) },
|
|
138
|
-
body: form,
|
|
139
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
140
|
-
});
|
|
141
|
-
const text = await res.text();
|
|
142
|
-
let data = null;
|
|
143
|
-
try {
|
|
144
|
-
data = text ? JSON.parse(text) : null;
|
|
145
|
-
} catch {
|
|
146
|
-
data = { raw: text };
|
|
147
|
-
}
|
|
148
|
-
if (!res.ok) {
|
|
149
|
-
const err = new Error(`POST ${path} → ${res.status}: ${data?.message ?? data?.error ?? text.slice(0, 200)}`);
|
|
150
|
-
err.status = res.status;
|
|
151
|
-
err.data = data;
|
|
152
|
-
throw err;
|
|
153
|
-
}
|
|
154
|
-
if (typeof data?.url !== "string" || !data.url) throw new Error(`POST ${path} → no url in response`);
|
|
155
|
-
return data;
|
|
156
|
-
} catch (err) {
|
|
157
|
-
const permanent = err.status >= 400 && err.status < 500 && err.status !== 429;
|
|
158
|
-
if (permanent || attempt >= tries) throw err;
|
|
159
|
-
onRetry?.(err, attempt, delay);
|
|
160
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
161
|
-
delay = Math.min(delay * 2, 30_000);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The verification report for a verify turn (artifacts already uploaded → URLs). */
|
|
167
|
-
turnVerification(turnId, payload, opts) {
|
|
168
|
-
return this.requestWithRetry("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification`, payload, opts);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Late evidence (`bridge.verify.evidence.retry`): the Server unions by URL and clears `evidenceMissing`. */
|
|
172
|
-
putVerificationArtifacts(turnId, payload, opts) {
|
|
173
|
-
return this.requestWithRetry("PUT", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification/artifacts`, payload, opts);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Stage transition of a running verify turn (best-effort UI state — callers swallow errors). */
|
|
177
|
-
turnVerificationStage(turnId, payload) {
|
|
178
|
-
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/verification/stage`, payload);
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* The saved sign-in for this verify turn: `{ id, version, storageState,
|
|
182
|
-
* origins, services, landingUrl, loginPaths, optional }` (or `null` —
|
|
183
|
-
* a 404 is "no record", not an error). The Server allows ONE successful
|
|
184
|
-
* GET per turn and never caches it.
|
|
185
|
-
*/
|
|
186
|
-
async turnPreviewLogin(turnId) {
|
|
187
|
-
try {
|
|
188
|
-
const data = await this.request("GET", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/preview-login`);
|
|
189
|
-
const record = data && typeof data === "object" && "record" in data ? data.record : data;
|
|
190
|
-
return record && typeof record === "object" && record.storageState ? record : null;
|
|
191
|
-
} catch (err) {
|
|
192
|
-
if (err?.status === 404) return null;
|
|
193
|
-
throw err;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
/** Capture upload against a pending login request (the ONLY thing a device may upload a sign-in against). */
|
|
197
|
-
uploadPreviewLogin(requestId, payload, opts) {
|
|
198
|
-
return this.requestWithRetry("POST", `/gleapcode/bridge/preview-logins/${encodeURIComponent(requestId)}`, payload, opts);
|
|
199
|
-
}
|
|
200
|
-
/** Capture progress for the dashboard's sign-in card. */
|
|
201
|
-
previewLoginStatus(requestId, payload) {
|
|
202
|
-
return this.request("POST", `/gleapcode/bridge/preview-logins/${encodeURIComponent(requestId)}/status`, payload);
|
|
203
|
-
}
|
|
204
|
-
/** Refresh a record after a verify run (CAS on `basedOnVersion`; 409 = someone else refreshed first → discard). */
|
|
205
|
-
refreshPreviewLogin(recordId, payload) {
|
|
206
|
-
return this.request("PUT", `/gleapcode/bridge/preview-logins/${encodeURIComponent(recordId)}`, payload);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Turns (and pending sign-in requests) the server still believes this device is running. */
|
|
119
|
+
/** Turns the server still believes this device is running. */
|
|
210
120
|
pendingTurns() {
|
|
211
121
|
return this.request("GET", "/gleapcode/bridge/devices/me/pending");
|
|
212
122
|
}
|
|
@@ -217,6 +127,10 @@ export class BridgeApi {
|
|
|
217
127
|
gitCredentials(repoKey) {
|
|
218
128
|
return this.request("POST", "/gleapcode/bridge/devices/me/git-credentials", { repoKey });
|
|
219
129
|
}
|
|
130
|
+
/** Public hostnames + tunnel credentials for a session being published (the Server decides displacement). */
|
|
131
|
+
publicHosts(payload) {
|
|
132
|
+
return this.request("POST", "/gleapcode/bridge/devices/me/public-hosts", payload);
|
|
133
|
+
}
|
|
220
134
|
|
|
221
135
|
commandAck(commandId, payload) {
|
|
222
136
|
return this.request("POST", `/gleapcode/bridge/commands/${encodeURIComponent(commandId)}/ack`, payload);
|
package/src/companions.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function orderCompanions(entries) {
|
|
|
69
69
|
* repo required by any config is required overall; depth is capped at
|
|
70
70
|
* `maxDepth`; a visited set keeps cycles harmless.
|
|
71
71
|
*/
|
|
72
|
-
export async function collectCompanions({ roots, loadConfig, maxDepth = 3 } = {}) {
|
|
72
|
+
export async function collectCompanions({ roots, loadConfig, maxDepth = 3, extra = [] } = {}) {
|
|
73
73
|
const sessionKeys = new Set((roots || []).map((r) => String(r.key).toLowerCase()));
|
|
74
74
|
const seen = new Map(); // key → entry
|
|
75
75
|
let queue = (roots || []).map((r) => ({ key: String(r.key).toLowerCase(), config: r.config, depth: 0 }));
|
|
@@ -77,7 +77,8 @@ export async function collectCompanions({ roots, loadConfig, maxDepth = 3 } = {}
|
|
|
77
77
|
for (let depth = 1; depth <= maxDepth && queue.length > 0; depth += 1) {
|
|
78
78
|
const next = [];
|
|
79
79
|
for (const node of queue) {
|
|
80
|
-
|
|
80
|
+
// `extra`: companions the daemon inferred (reverse companions) count as declared by the session repos.
|
|
81
|
+
for (const c of [...(node.config?.companions || []), ...(node.depth === 0 ? extra : [])]) {
|
|
81
82
|
const key = String(c.repo).toLowerCase();
|
|
82
83
|
if (sessionKeys.has(key)) continue; // already part of the session
|
|
83
84
|
const prev = seen.get(key);
|