@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.
@@ -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, isReadOnly = false, workDir = "" } = {}) {
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
  /**
@@ -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
@@ -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.75.1) drops the message on the floor — `case
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
@@ -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,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);
@@ -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);