@gleapai/kai-bridge 0.10.2 → 0.12.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
@@ -6,7 +6,7 @@ Claude Code / Codex login, and local dev-server previews.
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
- kai-bridge install # run at login (launchd / systemd / Task Scheduler)
9
+ kai-bridge service install # run at login (launchd / systemd / Task Scheduler)
10
10
  kai-bridge status
11
11
  kai-bridge ps # sessions running on this machine, with dashboard links (--json for tooling)
12
12
  ```
@@ -44,23 +44,38 @@ kai-bridge run --task "Explain the auth flow in this repo" --cwd ~/code/my-app
44
44
  `npm run sync-runner` copies the ACP runner from a sibling `gleap_code_analyzer` checkout;
45
45
  `npm test` runs the unit suite (uses real `git`).
46
46
 
47
+ ### Trying CLI changes locally
48
+
49
+ ```bash
50
+ npm run cli -- status # any kai-bridge command, run from this checkout
51
+ npm run cli -- login --name "dev laptop"
52
+ npm run cli -- start # foreground daemon; Ctrl-C stops it
53
+ npm run test:cli # fast CLI regression suite (test/cli.test.mjs)
54
+ ```
55
+
56
+ `npm run cli` (scripts/dev-cli.mjs) uses `<repo>/.kai-dev` as `KAI_HOME` and turns
57
+ self-update off, so it never touches `~/.kai` or the background service running the
58
+ released version. Export `KAI_API_BASE` / `KAI_APP_BASE` to point it at a local server.
59
+ Don't `npm link` the package: the login service runs the global install by absolute
60
+ path, and a link would swap that for this checkout.
61
+
47
62
 
48
63
  ### Computers and your own servers
49
64
 
50
65
  Kai Code offers **Kai Code Cloud** (Gleap's managed execution, using AI credits) or **Kai Code Bridge** (your coding agents on your computer or a server you manage, using your own AI subscriptions).
51
66
 
52
- Run `npm i -g @gleapai/kai-bridge && kai-bridge` in a local terminal or over SSH. The guided setup pairs the device, installs its background service and helps sign in to coding agents. Node.js 20+ and Git are required. On a Linux server, keep the user service running after logout using your system's service configuration; `kai-bridge doctor` reports service status.
67
+ Run `npm i -g @gleapai/kai-bridge && kai-bridge` in a local terminal or over SSH. The guided setup pairs the device, installs its background service and helps sign in to coding agents. Node.js 20+ and Git are required. On a Linux server, keep the user service running after logout using your system's service configuration; `kai-bridge service status` and `kai-bridge service logs --follow` show what it is doing.
53
68
 
54
69
  Codex and Claude Code ship with Kai Code Bridge. Missing agents can be installed or repaired, and existing agents can be updated independently:
55
70
 
56
71
  ```sh
57
72
  kai-bridge harness install codex
58
73
  kai-bridge harness update codex
59
- kai-bridge harness login codex --device-auth
74
+ kai-bridge profile login codex --device-auth
60
75
 
61
76
  kai-bridge harness install claude
62
77
  kai-bridge harness update claude
63
- kai-bridge harness login claude
78
+ kai-bridge profile login claude
64
79
  ```
65
80
 
66
81
  Updates install into `~/.kai/harnesses`, verify the new executable, then select it for subsequent sessions. A failed download or startup leaves the previous version selected. Existing versions are retained for running sessions; your global CLI installations and agent logins are not overwritten. `--version <version>` selects a specific package version (for Claude, the Claude Agent SDK package version).
@@ -1,23 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // kai-bridge — run Gleap Kai Code on this machine.
3
- //
4
- // kai-bridge setup guided onboarding: pair · background service · harness sign-ins
5
- // (a bare `kai-bridge` on an unpaired machine runs it too)
6
- // kai-bridge login | logout pair this device with your Gleap account / disconnect it
7
- // kai-bridge install | uninstall run at login (launchd / systemd / Task Scheduler)
8
- // kai-bridge start run in the foreground (what the service runs)
9
- // kai-bridge status device, service, profiles, repos
10
- // kai-bridge ps [--json] what runs right now: daemon, turns, dev servers — with dashboard links
11
- // kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
12
- // kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
13
- // kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
14
- // kai-bridge run --task "…" [--model …] [--profile …] [--cwd …] (local turn, no server)
15
- // kai-bridge doctor
3
+ // The command reference lives in src/help.mjs (`kai-bridge help`).
16
4
 
17
5
  import { spawn } from "node:child_process";
18
- import { mkdtempSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
- import { dirname, join, resolve } from "node:path";
6
+ import { platform } from "node:os";
7
+ import { join, resolve } from "node:path";
21
8
  import { fileURLToPath } from "node:url";
22
9
 
23
10
  import { KAI_HOME, loadConfig, saveConfig } from "../src/config.mjs";
@@ -26,13 +13,38 @@ import { BridgeDaemon, createLogger, resolveProfiles } from "../src/daemon.mjs";
26
13
  import { runTurn } from "../src/executor.mjs";
27
14
  import { HARNESSES, createManagedProfile, describeProfiles, findBinary, loginCommand } from "../src/profiles.mjs";
28
15
  import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src/repos.mjs";
29
- import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
16
+ import { install, isInstalled, uninstall, isEphemeralBinPath, servicePaths } from "../src/service.mjs";
17
+ import { followFile, tailFile } from "../src/logs.mjs";
30
18
  import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
31
19
  import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
32
- import { collectProcessList, formatProcessList } from "../src/ps.mjs";
20
+ import { collectProcessList, formatAge, formatProcessList } from "../src/ps.mjs";
21
+ import { renderCommandHelp, renderHelp, suggestCommand } from "../src/help.mjs";
22
+ import { palette } from "../src/tui.mjs";
33
23
 
34
24
  const BIN = fileURLToPath(import.meta.url);
25
+ // `npm i -g` only warns on an old Node; the first run would die with a
26
+ // SyntaxError instead. Say what is needed.
27
+ {
28
+ const major = Number(process.versions.node.split(".")[0]);
29
+ if (major < 20) {
30
+ process.stderr.write(`error: kai-bridge needs Node.js 20 or newer (this is ${process.versions.node}). Install a current Node from https://nodejs.org and run it again.\n`);
31
+ process.exit(1);
32
+ }
33
+ }
34
+ // `kai-bridge help | head` closes our stdout early: end quietly, not with a stack.
35
+ process.stdout.on("error", (err) => {
36
+ if (err?.code === "EPIPE") process.exit(0);
37
+ throw err;
38
+ });
35
39
  const argv = process.argv.slice(2);
40
+ // `--version`/`-v` and `--help`/`-h` are flags people type before or after the
41
+ // command; normalise them into the `version` and `help` commands.
42
+ if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
43
+ process.stdout.write(`${installedVersion()}\n`);
44
+ process.exit(0);
45
+ }
46
+ if (argv[0] === "--help" || argv[0] === "-h") argv[0] = "help";
47
+ else if (argv.includes("--help") || argv.includes("-h")) argv.splice(0, argv.length, "help", argv[0]);
36
48
  const cmd = argv[0];
37
49
  const flags = {};
38
50
  const positional = [];
@@ -52,14 +64,18 @@ const fail = (s) => {
52
64
  async function login() {
53
65
  const config = loadConfig();
54
66
  await pairDevice({ config, name: flags.name, print: out });
55
- out(`Next: \`kai-bridge install\` keeps it running at login. \`kai-bridge start\` runs it right now.`);
67
+ out(`Next: \`kai-bridge service install\` keeps it running at login (\`kai-bridge start\` runs it right now), then \`kai-bridge profile login claude\` (or codex/cursor) to sign in your coding agent. \`kai-bridge\` does all of this in one go.`);
56
68
  }
57
69
 
58
70
  async function status() {
59
71
  const config = loadConfig();
72
+ out(`node: ${process.version} (${process.execPath})`);
73
+ out(`git: ${findBinary("git") ?? "not found"}`);
74
+ out(`claude: ${findBinary("claude") ?? "not on PATH (bundled copy is used)"}`);
75
+ out(`codex: ${findBinary("codex") ?? "not on PATH (bundled copy is used)"}`);
60
76
  out(`home: ${KAI_HOME}`);
61
77
  out(`device: ${config.device ? `${config.device.name} (${config.device.id})` : "not paired — run `kai-bridge login`"}`);
62
- out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge install`"}`);
78
+ out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge service install`"}`);
63
79
  out("harnesses:");
64
80
  for (const h of describeHarnesses(KAI_HOME)) out(` ${h.id.padEnd(8)} ${h.installed ? `installed ${h.version ?? ""}` : "not installed — kai-bridge harness install " + h.id}${h.bundled ? " (bundled)" : ""}`);
65
81
  const profiles = await describeProfiles(resolveProfiles(config));
@@ -95,8 +111,10 @@ async function profile() {
95
111
  return;
96
112
  }
97
113
  if (sub === "login") {
98
- const p = resolveProfiles(config).find((x) => x.id === positional[1]);
99
- if (!p) fail("unknown profile");
114
+ // A harness name means the machine's own login for it.
115
+ const wanted = HARNESS_INFO[positional[1]] ? `${positional[1]}-ambient` : positional[1];
116
+ const p = resolveProfiles(config).find((x) => x.id === wanted);
117
+ if (!p) fail(`unknown profile ${positional[1] ?? ""} — \`kai-bridge profile list\` shows them; \`profile login claude|codex|cursor\` signs in the machine's own agent.`);
100
118
  await installHarness(p.harness, { kaiHome: KAI_HOME, onLog: out });
101
119
  const c = loginCommand(p.harness, p.configDir, KAI_HOME, flags["device-auth"] ? { deviceAuth: true } : undefined);
102
120
  if (!c) fail(`${p.harness} is not installed`);
@@ -110,7 +128,7 @@ async function profile() {
110
128
  out("removed (the login dir is kept under ~/.kai/accounts)");
111
129
  return;
112
130
  }
113
- fail("usage: profile list|add|login|remove");
131
+ fail("usage: profile list | login <id> [--device-auth] | add <id> --harness <claude|codex|cursor> [--label …] | remove <id>");
114
132
  }
115
133
 
116
134
  async function harness() {
@@ -120,25 +138,22 @@ async function harness() {
120
138
  return;
121
139
  }
122
140
  const id = positional[1];
123
- if (!HARNESS_INFO[id]) fail("usage: harness list|install <id>|update <id>|login <id> (claude|codex|cursor)");
141
+ if (!HARNESS_INFO[id]) fail("usage: harness list | install <id> | update <id> (claude|codex|cursor — sign in with `profile login <id>`)");
124
142
  if (sub === "install" || sub === "update") {
125
143
  const res = await installHarness(id, { kaiHome: KAI_HOME, onLog: out, update: sub === "update", version: typeof flags.version === "string" ? flags.version : undefined });
126
144
  if (!res.ok) fail(`${id} is not usable`);
127
145
  out(`${HARNESS_INFO[id].label} ${res.version} → ${res.binary}`);
128
146
  return;
129
147
  }
130
- if (sub === "login") {
131
- positional[1] = `${id}-ambient`;
132
- positional[0] = "login";
133
- return profile();
134
- }
135
- fail("usage: harness list|install <id>|update <id>|login <id> [--device-auth]");
148
+ if (sub === "login") return profile(); // old spelling of `profile login <id>`
149
+ fail("usage: harness list | install <id> | update <id>");
136
150
  }
137
151
 
138
152
  async function repo() {
139
153
  const config = loadConfig();
140
- const sub = positional[0] || "scan";
154
+ const sub = positional[0] || "roots";
141
155
  if (sub === "scan") {
156
+ // Undocumented: `status` shows the same list.
142
157
  const groups = groupByRepo(scanRoots([...defaultRoots(), ...(config.roots || [])]), config.primaryOverrides);
143
158
  for (const r of toDeviceRepoReport(groups)) out(`${r.key}\t${r.primaryPath}\t${r.branch}`);
144
159
  return;
@@ -160,7 +175,7 @@ async function repo() {
160
175
  out("ok");
161
176
  return;
162
177
  }
163
- fail("usage: repo scan|roots [add|remove <dir>]|primary <repoKey> <path>");
178
+ fail("usage: repo roots [add <dir> | remove <dir>] | primary <repoKey> <path> (`kai-bridge status` lists the checkouts)");
164
179
  }
165
180
 
166
181
  async function runLocal() {
@@ -187,13 +202,54 @@ async function runLocal() {
187
202
  process.exit(res.code ?? 0);
188
203
  }
189
204
 
190
- async function doctor() {
191
- out(`node: ${process.version}`);
192
- out(`claude: ${findBinary("claude") ?? "not found (npm i -g @anthropic-ai/claude-code)"}`);
193
- out(`codex: ${findBinary("codex") ?? "not found (npm i -g @openai/codex)"}`);
194
- out(`git: ${findBinary("git") ?? "not found"}`);
195
- out(`home: ${KAI_HOME}`);
196
- await status();
205
+ /** `kai-bridge service …` — the login service and its logs. */
206
+ async function service() {
207
+ const sub = positional[0] || "status";
208
+ if (sub === "install") {
209
+ if (isEphemeralBinPath(BIN)) {
210
+ fail(
211
+ "Running from npx, so there's no stable path to install from.\n" +
212
+ "Install it once, then this works:\n\n" +
213
+ " npm i -g @gleapai/kai-bridge\n kai-bridge service install\n",
214
+ );
215
+ }
216
+ const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
217
+ out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
218
+ return;
219
+ }
220
+ if (sub === "uninstall") {
221
+ out(uninstall() ? "Removed." : "Nothing to remove.");
222
+ return;
223
+ }
224
+ if (sub === "restart") {
225
+ if (!isInstalled()) fail("The background service is not installed — run `kai-bridge service install`.");
226
+ const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
227
+ out(`Restarted (${res.kind}).`);
228
+ return;
229
+ }
230
+ if (sub === "status") {
231
+ const paths = servicePaths();
232
+ const unit = platform() === "darwin" ? paths.launchdPlist : platform() === "linux" ? paths.systemdUnit : "Task Scheduler: GleapKaiBridge";
233
+ const d = collectProcessList().daemon;
234
+ out(`service: ${isInstalled() ? `installed · ${unit}` : "not installed — run `kai-bridge service install`"}`);
235
+ out(`daemon: ${d.running ? `running · pid ${d.pid} · up ${formatAge(d.startedAt)} · v${d.version}` : `not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""}`}`);
236
+ out(`logs: ${join(KAI_HOME, "logs", "bridge.log")}`);
237
+ return;
238
+ }
239
+ if (sub === "logs") {
240
+ const file = join(KAI_HOME, "logs", flags.service ? "service.err.log" : "bridge.log");
241
+ const n = Number(flags.lines) || 50;
242
+ const last = tailFile(file, n);
243
+ if (last) out(last);
244
+ else if (!flags.follow) out(`(no log yet at ${file})`);
245
+ if (flags.follow) {
246
+ const ctrl = new AbortController();
247
+ process.on("SIGINT", () => ctrl.abort());
248
+ await followFile(file, { signal: ctrl.signal });
249
+ }
250
+ return;
251
+ }
252
+ fail("usage: service install | uninstall | restart | status | logs [--lines <n>] [--follow] [--service]");
197
253
  }
198
254
 
199
255
  try {
@@ -207,20 +263,14 @@ try {
207
263
  case "logout":
208
264
  await logout();
209
265
  break;
210
- case "install": {
211
- if (isEphemeralBinPath(BIN)) {
212
- fail(
213
- "Running from npx, so there's no stable path to install from.\n" +
214
- "Install it once, then this works:\n\n" +
215
- " npm i -g @gleapai/kai-bridge\n kai-bridge install\n",
216
- );
217
- }
218
- const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
219
- out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
266
+ case "service":
267
+ await service();
220
268
  break;
221
- }
269
+ case "install":
222
270
  case "uninstall":
223
- out(uninstall() ? "Removed." : "Nothing to remove.");
271
+ // Old spellings, kept working.
272
+ positional.unshift(cmd);
273
+ await service();
224
274
  break;
225
275
  case "start": {
226
276
  process.env.KAI_BRIDGE_FOREGROUND = process.stdout.isTTY ? "1" : "0";
@@ -255,7 +305,7 @@ try {
255
305
  await runLocal();
256
306
  break;
257
307
  case "doctor":
258
- await doctor();
308
+ await status();
259
309
  break;
260
310
  case "update": {
261
311
  // Manual update: ignores autoUpdate and a previous failed attempt.
@@ -288,14 +338,20 @@ try {
288
338
  await runSetup({ binPath: BIN });
289
339
  break;
290
340
  }
291
- out(`kai-bridge — run Gleap Kai Code on this machine
292
-
293
- setup guided onboarding (pair · service · sign-ins)
294
- login | logout | install | uninstall | start | status | ps | doctor | update
295
- harness list|install <id>|update <id>|login <id> [--device-auth]
296
- profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
297
- repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
298
- run --task "…" [--model …] [--profile …] [--cwd …]`);
341
+ if (cmd === "help" || !cmd) {
342
+ const topic = positional[0];
343
+ if (topic) {
344
+ const lines = renderCommandHelp(topic, { paint: palette() });
345
+ if (!lines) fail(`unknown command: ${topic}${suggestCommand(topic) ? ` (did you mean \`${suggestCommand(topic)}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
346
+ for (const l of lines) out(l);
347
+ } else {
348
+ for (const l of renderHelp({ paint: palette(), version: installedVersion() })) out(l);
349
+ }
350
+ break;
351
+ }
352
+ // Not a command at all. A close match is almost always a typo.
353
+ const near = suggestCommand(cmd);
354
+ fail(`unknown command: ${cmd}${near ? ` (did you mean \`${near}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
299
355
  }
300
356
  }
301
357
  } catch (err) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gleapai/kai-bridge",
9
- "version": "0.10.2",
9
+ "version": "0.12.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -134,9 +134,6 @@
134
134
  "cpu": [
135
135
  "arm64"
136
136
  ],
137
- "libc": [
138
- "glibc"
139
- ],
140
137
  "license": "SEE LICENSE IN LICENSE.md",
141
138
  "optional": true,
142
139
  "os": [
@@ -150,9 +147,6 @@
150
147
  "cpu": [
151
148
  "arm64"
152
149
  ],
153
- "libc": [
154
- "musl"
155
- ],
156
150
  "license": "SEE LICENSE IN LICENSE.md",
157
151
  "optional": true,
158
152
  "os": [
@@ -166,9 +160,6 @@
166
160
  "cpu": [
167
161
  "x64"
168
162
  ],
169
- "libc": [
170
- "glibc"
171
- ],
172
163
  "license": "SEE LICENSE IN LICENSE.md",
173
164
  "optional": true,
174
165
  "os": [
@@ -182,9 +173,6 @@
182
173
  "cpu": [
183
174
  "x64"
184
175
  ],
185
- "libc": [
186
- "musl"
187
- ],
188
176
  "license": "SEE LICENSE IN LICENSE.md",
189
177
  "optional": true,
190
178
  "os": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
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",
@@ -25,7 +25,9 @@
25
25
  "postinstall": "node scripts/postinstall.mjs",
26
26
  "runtime:check": "node scripts/runtime-smoke.mjs",
27
27
  "prepack": "node scripts/prepare-release.mjs",
28
- "postpack": "node scripts/prepare-release.mjs --clean"
28
+ "postpack": "node scripts/prepare-release.mjs --clean",
29
+ "cli": "node scripts/dev-cli.mjs",
30
+ "test:cli": "node --test test/cli.test.mjs"
29
31
  },
30
32
  "dependencies": {
31
33
  "@agentclientprotocol/claude-agent-acp": "0.81.1",
package/src/api.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
5
5
  // PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
6
6
  // POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
7
+ // POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
7
8
  // POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
8
9
  // POST /gleapcode/bridge/turns/:id/result { result, changes, status }
9
10
  // POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
@@ -96,6 +97,15 @@ export class BridgeApi {
96
97
  heartbeat(payload) {
97
98
  return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
98
99
  }
100
+ /**
101
+ * "This machine has the turn" — sent for every delivery of a
102
+ * `bridge.turn.start` (push, the Server's re-send, the pending poll); the
103
+ * Server stops re-sending once one lands. Short timeout: the turn does not
104
+ * wait on a slow Server. 410 = the turn ended before it got here.
105
+ */
106
+ turnAck(turnId, payload) {
107
+ return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
108
+ }
99
109
  turnEvents(turnId, events) {
100
110
  return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
101
111
  }
package/src/config.mjs CHANGED
@@ -31,6 +31,7 @@ export function defaultConfig() {
31
31
  appBase: DEFAULT_APP_BASE,
32
32
  realtime: DEFAULT_REALTIME,
33
33
  device: null, // { id, name, token, organisationId, userId }
34
+ machineId: null, // stable per-install id; re-pairing keeps the same device row
34
35
  roots: [], // extra scan roots
35
36
  profiles: [
36
37
  { id: "claude-ambient", harness: "claude", kind: "ambient", label: "Claude (this machine)" },
package/src/daemon.mjs CHANGED
@@ -5,7 +5,9 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
5
5
  //
6
6
  // Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
7
7
  // protocol, the same channel family the dashboard uses):
8
- // bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
8
+ // bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], resend?, ...AgentRunOpts }
9
+ // — acknowledged with POST /turns/:id/ack; the Server re-sends an
10
+ // unacknowledged start (`resend: n`), so a turn id seen before is ignored
9
11
  // bridge.turn.cancel { turnId }
10
12
  // bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
11
13
  // answered with a {type:"steer", id, outcome} turn event
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
57
59
  const HEARTBEAT_MS = 30_000;
58
60
  /** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
59
61
  const PENDING_POLL_MS = 60_000;
62
+ /** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
63
+ const HANDLED_TURNS_MAX = 200;
60
64
  const USAGE_REFRESH_MS = 10 * 60_000;
61
65
  // Harness model catalogues change on releases, not by the minute.
62
66
  const MODELS_REFRESH_MS = 6 * 60 * 60_000;
@@ -563,6 +567,42 @@ export class BridgeDaemon {
563
567
  this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
564
568
  }
565
569
 
570
+ get handledTurnsPath() {
571
+ return join(this.kaiHome, "state", "handled-turns.json");
572
+ }
573
+
574
+ /**
575
+ * Turn ids this machine already ran (newest last, capped). Kept on disk:
576
+ * a turn whose result was lost is still "running" on the Server, and the
577
+ * pending poll after a restart must not run it a second time.
578
+ */
579
+ handledTurns() {
580
+ if (!this.handled) {
581
+ let ids = [];
582
+ try {
583
+ const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
584
+ if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
585
+ } catch {
586
+ /* none yet */
587
+ }
588
+ this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
589
+ }
590
+ return this.handled;
591
+ }
592
+
593
+ rememberHandledTurn(turnId) {
594
+ const handled = this.handledTurns();
595
+ handled.delete(turnId);
596
+ handled.add(turnId);
597
+ while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
598
+ try {
599
+ mkdirSync(join(this.kaiHome, "state"), { recursive: true });
600
+ writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
601
+ } catch {
602
+ /* best effort */
603
+ }
604
+ }
605
+
566
606
  /** Turns this machine was running when it was killed — report them dead. */
567
607
  async reportInterruptedTurns() {
568
608
  const entries = this.readInflight();
@@ -571,6 +611,8 @@ export class BridgeDaemon {
571
611
  for (const entry of entries) {
572
612
  const { turnId } = entry;
573
613
  this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
614
+ // Reported failed below; a re-send or a poll must not start it over.
615
+ this.rememberHandledTurn(turnId);
574
616
  await this.api
575
617
  .turnResult(turnId, {
576
618
  status: "failed",
@@ -867,8 +909,11 @@ export class BridgeDaemon {
867
909
  }
868
910
  for (const turn of pending?.turns || []) {
869
911
  if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
912
+ // Ran here already and its result never landed: the Server's reaper
913
+ // settles it — running the work again is never the fix.
914
+ if (this.handledTurns().has(turn.turnId)) continue;
870
915
  this.log("info", "turn.recovered", { turnId: turn.turnId, via });
871
- void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
916
+ void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
872
917
  }
873
918
  } catch (err) {
874
919
  // Older server without the endpoint, or the server is restarting: the
@@ -931,7 +976,7 @@ export class BridgeDaemon {
931
976
  .catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
932
977
  return;
933
978
  }
934
- return this.startTurn(data);
979
+ return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
935
980
  case "bridge.turn.cancel":
936
981
  this.running.get(data.turnId)?.ctrl.abort();
937
982
  return;
@@ -1665,9 +1710,10 @@ export class BridgeDaemon {
1665
1710
  }
1666
1711
 
1667
1712
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1668
- async bindRepos(turn) {
1713
+ async bindRepos(turn, { onPrepare, signal } = {}) {
1669
1714
  const bound = [];
1670
1715
  for (const r of turn.repos || []) {
1716
+ if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
1671
1717
  const group = this.repoGroups.find((g) => g.key === r.key);
1672
1718
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1673
1719
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
@@ -1678,6 +1724,7 @@ export class BridgeDaemon {
1678
1724
  sessionId: turn.sessionId,
1679
1725
  title: turn.title,
1680
1726
  gitEnv,
1727
+ onPrepare,
1681
1728
  }));
1682
1729
  bound.push({ key: r.key, ...ws });
1683
1730
  if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
@@ -1736,11 +1783,25 @@ export class BridgeDaemon {
1736
1783
  return adopted;
1737
1784
  }
1738
1785
 
1739
- async startTurn(turn) {
1786
+ /**
1787
+ * `via` = how the turn reached us: push, resend (the Server re-sent an
1788
+ * unacknowledged start), poll or reconnect (pullPendingWork).
1789
+ */
1790
+ async startTurn(turn, { via = "push" } = {}) {
1740
1791
  const { turnId } = turn;
1741
- if (this.running.has(turnId)) return;
1792
+ // One turn, several deliveries by design: the Server re-sends a start
1793
+ // until it is acknowledged, and the poll / reconnect pull replays what
1794
+ // the Server still expects. Only the first delivery runs. Every one is
1795
+ // acknowledged — a duplicate usually means the first ack was missed.
1796
+ if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
1797
+ this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
1798
+ await this.ackTurn(turnId, via);
1799
+ return;
1800
+ }
1742
1801
  const ctrl = new AbortController();
1743
1802
  const entry = { ctrl, control: null, sessionId: turn.sessionId };
1803
+ // Claimed before the first await, so a second delivery arriving while
1804
+ // the ack is in flight is the duplicate above.
1744
1805
  this.running.set(turnId, entry);
1745
1806
  const releaseAwake = keepAwake();
1746
1807
  let outcome = null;
@@ -1749,8 +1810,39 @@ export class BridgeDaemon {
1749
1810
  this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
1750
1811
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1751
1812
  try {
1813
+ // Receipt first: the Server stops re-sending the start.
1814
+ if ((await this.ackTurn(turnId, via)) === "ended") {
1815
+ // Stopped or reaped before it reached this machine — nobody is
1816
+ // waiting for it, and its result would be refused anyway.
1817
+ this.log("info", "turn.ended_before_start", { turnId, via });
1818
+ return;
1819
+ }
1752
1820
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
1753
- const bound = await this.bindRepos(turn);
1821
+ // Prep no longer blocks the daemon, so a Stop (or session close) can
1822
+ // land before the runner exists — and runTurn's abort listener would
1823
+ // never hear a signal that fired before it was added. Never start the
1824
+ // agent for a turn that was stopped on the way.
1825
+ const stoppedBeforeRun = () => {
1826
+ if (!ctrl.signal.aborted) return false;
1827
+ this.log("info", "turn.cancelled_before_run", { turnId });
1828
+ outcome = { status: "cancelled", changes: [], profileId: profile.id };
1829
+ return true;
1830
+ };
1831
+ if (stoppedBeforeRun()) return;
1832
+ // A fresh worktree costs a fetch, a checkout and a node_modules clone
1833
+ // — up to minutes for a big repo — while the dashboard already calls
1834
+ // the session running. Say what is happening before it starts (once
1835
+ // per turn; resumed worktrees and local checkouts skip it).
1836
+ let preparing = null;
1837
+ const onPrepare = () => {
1838
+ preparing ??= (async () => {
1839
+ batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
1840
+ await batcher.flush();
1841
+ })();
1842
+ return preparing;
1843
+ };
1844
+ const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
1845
+ if (stoppedBeforeRun()) return;
1754
1846
  // Multi-repo: the runner's cwd is the first repo; the others are
1755
1847
  // reachable as siblings under the same worktree root or by their
1756
1848
  // local paths — the prompt lists them.
@@ -1764,6 +1856,7 @@ export class BridgeDaemon {
1764
1856
  const live = await this.describeLivePreview(turn, bound, batcher);
1765
1857
  const previewNote = live.note;
1766
1858
  const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
1859
+ if (stoppedBeforeRun()) return;
1767
1860
  const res = await runTurn({
1768
1861
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
1769
1862
  profile,
@@ -1861,12 +1954,30 @@ export class BridgeDaemon {
1861
1954
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
1862
1955
  }
1863
1956
  this.forgetInflight(turnId);
1957
+ this.rememberHandledTurn(turnId);
1864
1958
  releaseAwake();
1865
1959
  this.running.delete(turnId);
1866
1960
  if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
1867
1961
  }
1868
1962
  }
1869
1963
 
1964
+ /**
1965
+ * Tell the Server this machine has the turn. Resolves "acked", "ended"
1966
+ * (410: the turn is over — it must not run), or "unknown" (an older Server
1967
+ * without the route, a network hiccup): the turn then runs as it always
1968
+ * did. Never throws.
1969
+ */
1970
+ async ackTurn(turnId, via) {
1971
+ try {
1972
+ await this.api.turnAck(turnId, { via });
1973
+ return "acked";
1974
+ } catch (err) {
1975
+ if (err?.status === 410) return "ended";
1976
+ this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
1977
+ return "unknown";
1978
+ }
1979
+ }
1980
+
1870
1981
  // ── preview browser ────────────────────────────────────────────────
1871
1982
  /** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
1872
1983
  ensurePreviewBrowser() {