@gleapai/kai-bridge 0.9.0 → 0.10.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/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # Kai Code Bridge
2
2
 
3
3
  Run [Gleap Kai Code](https://gleap.io) on your computer or server with your own
4
- Claude Code / Codex login, local dev-server previews, and photo or video verification.
4
+ Claude Code / Codex login, and local dev-server previews.
5
5
 
6
6
  ```bash
7
7
  npm i -g @gleapai/kai-bridge # one install, so the background service has a stable path
8
8
  kai-bridge login # pair this machine with your Gleap account
9
9
  kai-bridge install # run at login (launchd / systemd / Task Scheduler)
10
10
  kai-bridge status
11
+ kai-bridge ps # sessions running on this machine, with dashboard links (--json for tooling)
11
12
  ```
12
13
 
13
14
  (`npx @gleapai/kai-bridge login` works for a quick look, but `install` needs the
@@ -66,4 +67,4 @@ Updates install into `~/.kai/harnesses`, verify the new executable, then select
66
67
 
67
68
  Codex automatically uses device-code sign-in over SSH or on headless Linux. Enable device-code sign-in in the account settings if Codex asks. Claude uses its native `auth login` flow: follow the URL and prompts in the SSH terminal. Dashboard **Sign in** opens a terminal on desktop devices; for headless servers it shows the exact command to run over SSH. The login is picked up automatically.
68
69
 
69
- **Previews are local to the Kai Code Bridge device.** Open them on that computer, on the same network when supported, or use your own SSH port forwarding. Kai Code Bridge does not expose previews through a public tunnel. Screenshot and video verification runs on the Kai Code Bridge device and uploads its evidence to the Kai Code session, so those checks still work on a remote server.
70
+ **Previews boot local to the Kai Code Bridge device.** Open them on that computer, or click **Make public** in the dashboard's Preview popover to get a stable HTTPS link (`https://web-<repo>-<id>.gleap-preview.dev`) that works from a phone or a teammate's laptop. Public links run through a Cloudflare Tunnel: the daemon starts `cloudflared` on the device (it uses one from your PATH, else downloads a pinned release into `~/.kai/bin`; `brew install cloudflared`, `apt install cloudflared` or `winget install Cloudflare.cloudflared` also work). The address is the same for every ticket of a repo on this device, so a saved login survives from one ticket to the next, and only one ticket per repo can be public on a device at a time: making another one public points the same link at it and stops the previous preview (its owner sees a notice). Nothing leaves the device until someone clicks Make public. `kai-bridge ps` shows what is running.
@@ -7,6 +7,7 @@
7
7
  // kai-bridge install | uninstall run at login (launchd / systemd / Task Scheduler)
8
8
  // kai-bridge start run in the foreground (what the service runs)
9
9
  // kai-bridge status device, service, profiles, repos
10
+ // kai-bridge ps [--json] what runs right now: daemon, turns, dev servers — with dashboard links
10
11
  // kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
11
12
  // kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
12
13
  // kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
@@ -28,6 +29,7 @@ import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src
28
29
  import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
29
30
  import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
30
31
  import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
32
+ import { collectProcessList, formatProcessList } from "../src/ps.mjs";
31
33
 
32
34
  const BIN = fileURLToPath(import.meta.url);
33
35
  const argv = process.argv.slice(2);
@@ -68,6 +70,12 @@ async function status() {
68
70
  for (const r of toDeviceRepoReport(groups)) out(` ${r.key.padEnd(48)} ${r.primaryPath}${r.checkouts > 1 ? ` (+${r.checkouts - 1})` : ""} ${r.dirtyCount ? `· ${r.dirtyCount} dirty` : ""}`);
69
71
  }
70
72
 
73
+ /** Sessions running on this machine — read from the daemon's state files, so it works while the daemon is busy or down. */
74
+ function ps() {
75
+ const list = collectProcessList();
76
+ out(flags.json ? JSON.stringify(list, null, 2) : formatProcessList(list));
77
+ }
78
+
71
79
  async function profile() {
72
80
  const config = loadConfig();
73
81
  const sub = positional[0];
@@ -231,6 +239,9 @@ try {
231
239
  case "status":
232
240
  await status();
233
241
  break;
242
+ case "ps":
243
+ ps();
244
+ break;
234
245
  case "profile":
235
246
  await profile();
236
247
  break;
@@ -280,7 +291,7 @@ try {
280
291
  out(`kai-bridge — run Gleap Kai Code on this machine
281
292
 
282
293
  setup guided onboarding (pair · service · sign-ins)
283
- login | logout | install | uninstall | start | status | doctor | update
294
+ login | logout | install | uninstall | start | status | ps | doctor | update
284
295
  harness list|install <id>|update <id>|login <id> [--device-auth]
285
296
  profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
286
297
  repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gleapai/kai-bridge",
9
- "version": "0.9.0",
9
+ "version": "0.10.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.9.0",
4
- "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions, local previews, and photo or video verification.",
3
+ "version": "0.10.0",
4
+ "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions and local previews.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -28,14 +28,12 @@ import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
28
28
 
29
29
  import {
30
30
  KAI_RESOLUTION_ANALYST_AGENT_NAME,
31
- KAI_VERIFIER_AGENT_NAME,
32
31
  captureRepoBaselines,
33
32
  createUsageTracker,
34
33
  debugLog,
35
34
  emit,
36
35
  emitSync,
37
36
  isArtifactWriterAgent,
38
- isReadOnlyAgent,
39
37
  parseRunnerArgs,
40
38
  revertRepoMutations,
41
39
  setTracePrefix,
@@ -77,10 +75,6 @@ const ENGINE_MODEL = ARGS.engineModel;
77
75
  const HARNESS_ID = resolveHarnessId(ARGS.argv.harness, MODEL);
78
76
  const HARNESS = getHarness(HARNESS_ID);
79
77
  const IS_ARTIFACT_WRITER = isArtifactWriterAgent(AGENT);
80
- // Read-only agents (the verifier): build-mode tool access minus the file
81
- // write tools, repos reverted after the turn — see contract.mjs.
82
- const IS_READ_ONLY = isReadOnlyAgent(AGENT);
83
- const IS_VERIFIER = AGENT === KAI_VERIFIER_AGENT_NAME;
84
78
 
85
79
  if (!TASK) {
86
80
  emitSync({ type: "error", message: "runner: missing --task-b64 argument" });
@@ -89,7 +83,7 @@ if (!TASK) {
89
83
 
90
84
  const RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
91
85
  const PERSONA_DIR = process.env.KAI_PERSONA_DIR || "/opt/gleap-runners/personas";
92
- const PERSONA_AGENTS = new Set(["kai-documentarian", "kai-asker", "kai-researcher", KAI_RESOLUTION_ANALYST_AGENT_NAME, KAI_VERIFIER_AGENT_NAME]);
86
+ const PERSONA_AGENTS = new Set(["kai-documentarian", "kai-asker", "kai-researcher", KAI_RESOLUTION_ANALYST_AGENT_NAME]);
93
87
  const SCRATCH_DIR = join(tmpdir(), `kai-acp-${process.pid}`);
94
88
  mkdirSync(SCRATCH_DIR, { recursive: true });
95
89
  // Harness config dir lives OUTSIDE the repo tree and survives across
@@ -132,32 +126,6 @@ const PROMPT_SUGGESTION_WAIT_MS = Math.max(0, Number(process.env.KAI_PROMPT_SUGG
132
126
  const PROMPT_SUGGESTIONS_ENABLED = process.env.KAI_PROMPT_SUGGESTIONS !== "0" && PROMPT_SUGGESTION_WAIT_MS > 0;
133
127
  const TODO_SERVER_KEY = "kai_todos";
134
128
  const TODO_MCP_PATH = process.env.KAI_TODO_MCP_PATH || join(RUNNER_DIR, "tools", "todo-mcp.mjs");
135
- // Verification report bridge (tools/verify-mcp.mjs) — the verifier's
136
- // verdict channel. Attached ONLY to kai-verifier turns; the mapper turns
137
- // its `report_verification` calls into `verify_report` contract events.
138
- const VERIFY_SERVER_KEY = "kai_verify";
139
- const VERIFY_MCP_PATH = process.env.KAI_VERIFY_MCP_PATH || join(RUNNER_DIR, "tools", "verify-mcp.mjs");
140
- /**
141
- * `KAI_VERIFY_*` — the host's request policy for the verify MCP's
142
- * `http_request` (read-only flag, allowed origins, the app's auth header,
143
- * the evidence dir for `requests.jsonl`). Passed EXPLICITLY on the server
144
- * entry: Codex spawns MCP servers with a scrubbed env, and the values must
145
- * never ride in the prompt (the auth header is a live credential).
146
- */
147
- function verifyMcpEnv() {
148
- const out = {};
149
- for (const k of Object.keys(process.env).sort()) {
150
- if (k.startsWith("KAI_VERIFY_") && k !== "KAI_VERIFY_MCP_PATH" && process.env[k]) out[k] = String(process.env[k]);
151
- }
152
- return out;
153
- }
154
- // The verifier must not write files. Claude's bypassPermissions mode still
155
- // honours disallowedTools, so the write tools are removed outright (Bash
156
- // redirections are caught by the post-turn revert).
157
- const READ_ONLY_DISALLOWED = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
158
- // The verifier tests the app, it never needs the app's secrets: env files
159
- // and private keys stay unreadable (gitignore-style Read rules).
160
- const VERIFIER_DISALLOWED = ["Read(**/.env*)", "Read(**/*.pem)"];
161
129
  const TODO_NOTE =
162
130
  "Track multi-step work as a live todo list with the `todo_write` tool " +
163
131
  `from the \`${TODO_SERVER_KEY}\` MCP server: pass the FULL updated list ` +
@@ -311,9 +279,7 @@ function buildAppendSystemPrompt() {
311
279
  if (IS_PLAN_MODE) sections.push(NEEDS_ASK_USER_MCP ? GATEWAY_PLAN_GUARD : PLAN_QUESTION_GUARD);
312
280
  if (IS_PLAN_MODE) sections.push(PLAN_SCOPE_NOTE);
313
281
  if (IS_PLAN_MODE && HARNESS_ID !== "claude") sections.push(READ_ONLY_PLAN_NOTE);
314
- // Read-only agents carry their own "never commit" rule in the persona;
315
- // the hand-off note ("the host commits your changes") would contradict it.
316
- if (!IS_PLAN_MODE && !IS_READ_ONLY) sections.push(GIT_HANDOFF_PROMPT);
282
+ if (!IS_PLAN_MODE) sections.push(GIT_HANDOFF_PROMPT);
317
283
  // After the safety guards (their leading position is load-bearing for
318
284
  // cursor's prompt-prefix mode) but before project instructions.
319
285
  if (!IS_ARTIFACT_WRITER) sections.push(TODO_NOTE);
@@ -331,9 +297,6 @@ function buildAcpMcpServers() {
331
297
  if (!IS_ARTIFACT_WRITER && existsSync(TODO_MCP_PATH)) {
332
298
  out.push({ name: TODO_SERVER_KEY, command: process.execPath, args: [TODO_MCP_PATH], env: [] });
333
299
  }
334
- if (IS_VERIFIER && existsSync(VERIFY_MCP_PATH)) {
335
- out.push({ name: VERIFY_SERVER_KEY, command: process.execPath, args: [VERIFY_MCP_PATH], env: Object.entries(verifyMcpEnv()).map(([name, value]) => ({ name, value })) });
336
- }
337
300
  for (const server of MCP_SERVERS || []) {
338
301
  if (!server || typeof server !== "object") continue;
339
302
  const name = String(server.name || server.id || "").replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -384,8 +347,6 @@ function buildAgents() {
384
347
 
385
348
  function buildDisallowedTools() {
386
349
  const list = IS_PLAN_MODE || IS_ARTIFACT_WRITER ? [] : [...GIT_HANDOFF_DISALLOWED];
387
- if (IS_READ_ONLY) list.push(...READ_ONLY_DISALLOWED);
388
- if (IS_VERIFIER) list.push(...VERIFIER_DISALLOWED);
389
350
  for (const server of MCP_SERVERS || []) {
390
351
  const key = String(server?.name || server?.id || "").replace(/[^a-zA-Z0-9_-]/g, "_");
391
352
  for (const tool of [...(server?.disabledTools ?? []), ...(server?.gatedTools ?? [])]) {
@@ -503,14 +464,12 @@ async function main() {
503
464
  maxSteps: MAX_STEPS,
504
465
  isPlanMode: IS_PLAN_MODE,
505
466
  isArtifactWriter: IS_ARTIFACT_WRITER,
506
- isReadOnly: IS_READ_ONLY,
507
467
  appendSystemPrompt,
508
468
  instructionsPath,
509
469
  disallowedTools: buildDisallowedTools(),
510
470
  allowedTools: buildAllowedTools([
511
471
  // The injected todo bridge must survive plan mode's allow-list.
512
472
  `mcp__${TODO_SERVER_KEY}`,
513
- ...(IS_VERIFIER ? [`mcp__${VERIFY_SERVER_KEY}`] : []),
514
473
  ...(MCP_SERVERS || []).map((srv) => `mcp__${String(srv?.name || srv?.id || "").replace(/[^a-zA-Z0-9_-]/g, "_")}`).filter((k) => k !== "mcp__"),
515
474
  ]),
516
475
  agents: buildAgents(),
@@ -527,9 +486,6 @@ async function main() {
527
486
  ...(!IS_ARTIFACT_WRITER && existsSync(TODO_MCP_PATH)
528
487
  ? [{ name: TODO_SERVER_KEY, command: process.execPath, args: [TODO_MCP_PATH] }]
529
488
  : []),
530
- ...(IS_VERIFIER && existsSync(VERIFY_MCP_PATH)
531
- ? [{ name: VERIFY_SERVER_KEY, command: process.execPath, args: [VERIFY_MCP_PATH], env: verifyMcpEnv() }]
532
- : []),
533
489
  ...(MCP_SERVERS || []),
534
490
  ]
535
491
  : MCP_SERVERS || [],
@@ -618,7 +574,7 @@ async function main() {
618
574
  isPlanMode: IS_PLAN_MODE,
619
575
  mcpServerIds,
620
576
  readPlanFile: readNewestPlan,
621
- allowTool: permissionPolicy({ isPlanMode: IS_PLAN_MODE, isArtifactWriter: IS_ARTIFACT_WRITER, isReadOnly: IS_READ_ONLY, workDir: WORK_DIR }),
577
+ allowTool: permissionPolicy({ isPlanMode: IS_PLAN_MODE, isArtifactWriter: IS_ARTIFACT_WRITER, workDir: WORK_DIR }),
622
578
  onTurnShouldEnd: (reason) => {
623
579
  cancelRequested = reason;
624
580
  if (conn && acpSessionId) conn.cancel({ sessionId: acpSessionId }).catch(() => {});
@@ -749,10 +705,10 @@ async function main() {
749
705
  }
750
706
  }
751
707
 
752
- // Artifact writers and read-only agents must leave the repo untouched:
753
- // snapshot before, revert anything outside `.kai/` after (belt-and-braces
754
- // under the permission rules — shell redirections can still write).
755
- const REVERTS_REPO = IS_ARTIFACT_WRITER || IS_READ_ONLY;
708
+ // Artifact writers must leave the repo untouched: snapshot before, revert
709
+ // anything outside `.kai/` after (belt-and-braces under the permission
710
+ // rules — shell redirections can still write).
711
+ const REVERTS_REPO = IS_ARTIFACT_WRITER;
756
712
  const baselines = REVERTS_REPO ? captureRepoBaselines(WORK_DIR) : null;
757
713
  // Harnesses without a system-prompt channel (Cursor) get the persona as
758
714
  // a prefix of the first prompt.
@@ -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",
@@ -75,8 +62,6 @@ export function toolNameFromUpdate(update) {
75
62
  // the announce leaks a bare "Tool (running)" row that the suppressed
76
63
  // completion never clears.
77
64
  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
65
  if (update?.kind && KIND_LABEL[update.kind]) return KIND_LABEL[update.kind];
81
66
  return "Tool";
82
67
  }
@@ -248,16 +233,11 @@ function contentToValue(content) {
248
233
  * the agent: a mode the adapter fails to apply must not become a free
249
234
  * pass for edits.
250
235
  */
251
- export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false, isReadOnly = false, workDir = "" } = {}) {
236
+ export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false, workDir = "" } = {}) {
252
237
  const WRITE_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
253
238
  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
239
  const kaiDir = workDir ? `${workDir.replace(/\/+$/, "")}/.kai/` : "/.kai/";
255
240
  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
241
  if (!isPlanMode && !isArtifactWriter) return true;
262
242
  const canonical = canonicalToolName(name);
263
243
  if (WRITE_TOOLS.test(canonical)) {
@@ -342,16 +322,6 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
342
322
  }
343
323
  return;
344
324
  }
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
325
  // Adapter-provided titles ("List files in 'src'") stand in when the
356
326
  // input carries nothing summarizeTool understands.
357
327
  const summary = summarizeTool(name, meta.input || {}) || meta.title || "";
@@ -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-verifier | kai-documentarian | …
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
@@ -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/turns/:id/artifacts multipart `file` → { url } (verify evidence)
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
- import { readFile } from "node:fs/promises";
20
- import { basename } from "node:path";
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?.message ?? data?.error ?? text.slice(0, 200)}`);
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,100 +116,22 @@ 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);
119
+ /** Turns the server still believes this device is running. */
120
+ pendingTurns() {
121
+ return this.request("GET", "/gleapcode/bridge/devices/me/pending");
179
122
  }
180
123
  /**
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.
124
+ * Provider git credentials for one connected repository — asked for only
125
+ * after this machine's own credentials were rejected (see git-auth.mjs).
185
126
  */
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);
127
+ gitCredentials(repoKey) {
128
+ return this.request("POST", "/gleapcode/bridge/devices/me/git-credentials", { repoKey });
203
129
  }
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);
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);
207
133
  }
208
134
 
209
- /** Turns (and pending sign-in requests) the server still believes this device is running. */
210
- pendingTurns() {
211
- return this.request("GET", "/gleapcode/bridge/devices/me/pending");
212
- }
213
135
  commandAck(commandId, payload) {
214
136
  return this.request("POST", `/gleapcode/bridge/commands/${encodeURIComponent(commandId)}/ack`, payload);
215
137
  }
@@ -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
- for (const c of node.config?.companions || []) {
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);