@agentproto/adapter-claude-code-cloud 0.1.0 → 0.2.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.
@@ -12,7 +12,7 @@ relays the vendor's own client + sessions API onto ACP.
12
12
 
13
13
  | Method | Behaviour |
14
14
  | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | `initialize` | Returns `PROTOCOL_VERSION` + `agentCapabilities.loadSession=false` (re-attach is via the persisted vendor session id, AIP-66 §5). |
15
+ | `initialize` | Returns `PROTOCOL_VERSION` + `agentCapabilities.loadSession=true` (`session/load` adopts the persisted vendor session id, AIP-66 §5). |
16
16
  | `authenticate` | No-op. Auth is the user's claude.ai login inside the claude CLI (keychain); preflighted per turn (`claude auth status` — api_key auth and not-logged-in are refused). |
17
17
  | `session/new` | Validates `cwd` per AIP-66 §3 BEFORE any vendor side effect: a git checkout, clean working tree, an `origin` remote, and a HEAD contained in some `origin/*` ref. Refuses `mcpServers` (AIP-66 §4). |
18
18
  | `session/prompt` | First prompt → `claude --cloud "<prompt>"` under a PTY (start; answers the first-run folder-trust prompt), later prompts → `claude -p "<text>" --cloud <session id>` (send-only). Then polls `GET /v1/code/sessions/{id}/events` (2s, backing off to 10s) until a NEW `result` event arrives. |
@@ -41,6 +41,34 @@ a timer or on poll silence. Stop reason: `end_turn` for a success result,
41
41
  failures (HTTP 5xx, network) are retried with the poll backoff — a read
42
42
  failure never ends a turn — until 5 consecutive ones fail the prompt.
43
43
 
44
+ ## Event paging & ordering (Appendix)
45
+
46
+ `GET /v1/code/sessions/{id}/events` does NOT page forward. Observed
47
+ 2026-10-14 against the live API:
48
+
49
+ - **No `cursor` → the NEWEST page**, up to 50 events, newest first.
50
+ - **`resume_cursor`** (alias `next_cursor`, present on the first/no-cursor
51
+ page only) is the `sequence_num` of the OLDEST event on that page. Passing
52
+ it back as `?cursor=<n>` returns the next **OLDER** page — the cursor
53
+ pages **BACKWARD in time**, not forward. (Real example: the newest page
54
+ held seq 60..11 with `next_cursor` `"11"`; `?cursor=11` returned seq
55
+ 10..1 with `resume_cursor` `"1"`.)
56
+ - **`sequence_num`** is a STRING of an integer, strictly increasing within a
57
+ session, and is the ordering key. Parse with `Number()`; skip an event
58
+ whose `sequence_num` is not finite. Every event also carries `event_id`,
59
+ `event_type`, `payload` (the stream-json message), `source`
60
+ (`"worker"`/`"client"`) and `created_at`.
61
+
62
+ The relay therefore tracks progress by `sequence_num`, not by cursor: it
63
+ keeps a per-session `lastSeq` (the highest seq projected or seeded), polls
64
+ the newest page each tick, walks `cursor` backward only as far as needed to
65
+ reach `lastSeq` (capped at 20 pages/tick), then projects the new events in
66
+ ASCENDING seq order. `session/load` seeds `lastSeq` from ONE newest-page GET
67
+ (its max seq is the history high-water — no backward paging needed). Before
68
+ each prompt the relay records the current high-water seq; only a `result`
69
+ with a seq ABOVE that baseline can end the turn, so a prior turn's result
70
+ never ends a new one.
71
+
44
72
  ## Usage telemetry
45
73
 
46
74
  When the `result` event carries `total_cost_usd` and/or `usage`, it is
@@ -56,6 +84,44 @@ and kept for every later turn; the host is expected to persist it on the
56
84
  session descriptor (`meta.cloud = { vendor, sessionId, url }`) and re-attach
57
85
  rather than start a new vendor session after a restart.
58
86
 
87
+ ## Re-attach (AIP-66 §5)
88
+
89
+ The vendor id only exists AFTER the first prompt reaches the vendor, so the
90
+ handoff from relay to host is a side file plus a poll, and the re-attach
91
+ itself rides the ordinary ACP `session/load` resume path:
92
+
93
+ - **Meta-file handoff.** At spawn the host sets
94
+ `AGENTPROTO_CLOUD_SESSION_META_FILE` to a path inside the session's
95
+ isolated config dir. Whenever the relay learns the vendor id — after
96
+ `claude --cloud` prints it, or when `session/load` adopts an id handed
97
+ back by the host — it writes exactly the three §5 fields
98
+ `{ vendor, sessionId, url }` there (`src/meta-file.ts`, atomic
99
+ temp-file + rename, best-effort: a failed write never sinks the turn,
100
+ and a re-attach preserves an existing `url` for the same id). The file
101
+ is the ONLY channel: no credential, keychain material, or bearer token
102
+ ever enters it, the descriptor's `meta.cloud`, logs or events (AIP-66
103
+ §8) — it is byte-scoped to those three fields.
104
+ - **The host's watch.** The runtime polls that file every 2s (timers
105
+ unref'd, NO fixed timeout) and stamps the learned mapping onto the
106
+ session's `meta.cloud` the moment it appears; the watch stops on learn
107
+ or when the session ends.
108
+ - **`session/load`.** A restart or re-attach hands the relay the vendor id
109
+ persisted on `meta.cloud.sessionId` through the standard ACP
110
+ `session/load`. The relay validates it against the repo source first
111
+ (an id that isn't `session_`/`cse_`, or whose repo no longer matches,
112
+ fails closed), ADOPTS it — never starting a replacement vendor session —
113
+ pre-marks the vendor's existing events so only the next turn's own
114
+ `result` can end it, and resumes through `sendPrompt`
115
+ (`claude -p --cloud <id>`).
116
+ - **The resume fallback.** The spawn-time watch dies with a daemon
117
+ restart, so every resume point first reads `meta.cloud.sessionId`; when
118
+ the descriptor never learned one, the runtime falls back to reading the
119
+ relay's meta file inside the row's own config dir
120
+ (`cloudSessionMetaFilePath`) and stamps the found mapping back onto the
121
+ descriptor before resuming. Only when both sources are empty does the
122
+ resume fall back to the ordinary ACP uuid, which fails closed at the
123
+ vendor.
124
+
59
125
  ## Repo source (AIP-66 §3)
60
126
 
61
127
  The cloud runs the user's REPO from its git remote — never an uploaded
package/README.md CHANGED
@@ -25,8 +25,12 @@ zero — AIP-66 §7).
25
25
  from the `Claude Code-credentials` keychain entry (org uuid from
26
26
  `~/.claude.json`), or `CLAUDE_CLOUD_OAUTH_TOKEN` when injected. A CLI
27
27
  authenticated by API key, or not logged in, is refused with a pointer to
28
- `claude auth login`. The token is only ever used as a request header — never
29
- logged or persisted (AIP-66 §8).
28
+ `claude auth login`. When the token is expired (or within five minutes of
29
+ expiry) or the API answers 401, the adapter runs `claude -p "Reply OK"
30
+ --model haiku` once to let the CLI re-mint it, drops its cached credential,
31
+ and retries — a still-failing call reports `run \`claude auth login\``. The
32
+ token is only ever used as a request header — never logged or persisted
33
+ (AIP-66 §8).
30
34
  - **A clean, pushed git checkout as the session cwd.** The cloud runs the repo
31
35
  from its git remote, never an uploaded working tree, so `session/new`
32
36
  refuses (AIP-66 §3) a non-git cwd, a dirty working tree, a missing `origin`
@@ -1,3 +1,3 @@
1
- export { ClaudeCloudAcpAgent, MAX_LIST_EVENTS_FAILURES, POLL_INITIAL_MS, POLL_MAX_MS, SEEN_EVENT_IDS_LIMIT, isTransientListEventsError, main, promptText, resultEventUpdates, resultStopReason, runAcpOverStdio } from './chunk-KA4G4O2X.mjs';
1
+ export { ClaudeCloudAcpAgent, MAX_LIST_EVENTS_FAILURES, OBSERVE_PAGES_LIMIT, POLL_INITIAL_MS, POLL_MAX_MS, isTransientListEventsError, main, promptText, resultEventUpdates, resultStopReason, runAcpOverStdio } from './chunk-LN5RJ5ZT.mjs';
2
2
  //# sourceMappingURL=acp-server.mjs.map
3
3
  //# sourceMappingURL=acp-server.mjs.map
@@ -3,10 +3,10 @@ import { PROTOCOL_VERSION, ndJsonStream, AgentSideConnection } from '@agentclien
3
3
  import { sdkMessageToUpdates, promptText } from '@agentproto/adapter-claude-sdk';
4
4
  export { promptText } from '@agentproto/adapter-claude-sdk';
5
5
  import { execFile, spawn } from 'child_process';
6
+ import { homedir, tmpdir } from 'os';
6
7
  import { ANTHROPIC_CORE_SCRUB_ENV } from '@agentproto/provider-presets';
7
- import { readFile } from 'fs/promises';
8
- import { homedir } from 'os';
9
- import { join } from 'path';
8
+ import { readFile, mkdir, writeFile, rename } from 'fs/promises';
9
+ import { join, dirname } from 'path';
10
10
  import { promisify } from 'util';
11
11
 
12
12
  /**
@@ -15,6 +15,7 @@ import { promisify } from 'util';
15
15
  */
16
16
 
17
17
  var execFileAsync = promisify(execFile);
18
+ var EXPIRY_SKEW_MS = 5 * 6e4;
18
19
  var KEYCHAIN_SERVICE = "Claude Code-credentials";
19
20
  function keychainCredentialSource(deps = {}) {
20
21
  const execFileImpl = deps.execFileImpl ?? (async (cmd, args) => {
@@ -52,8 +53,16 @@ function keychainCredentialSource(deps = {}) {
52
53
  orgUuid = rc.oauthAccount?.organizationUuid;
53
54
  } catch {
54
55
  }
55
- cached = { accessToken, ...orgUuid ? { orgUuid } : {} };
56
+ const expiresAt = keychain.claudeAiOauth?.expiresAt;
57
+ cached = {
58
+ accessToken,
59
+ ...orgUuid ? { orgUuid } : {},
60
+ ...typeof expiresAt === "number" ? { expiresAt } : {}
61
+ };
56
62
  return cached;
63
+ },
64
+ invalidate() {
65
+ cached = null;
57
66
  }
58
67
  };
59
68
  }
@@ -68,9 +77,17 @@ function envCredentialSource(env = process.env) {
68
77
  }
69
78
  const orgUuid = env.CLAUDE_CLOUD_ORG_UUID;
70
79
  return { accessToken, ...orgUuid ? { orgUuid } : {} };
80
+ },
81
+ invalidate() {
71
82
  }
72
83
  };
73
84
  }
85
+ function eventSequence(event) {
86
+ const raw = event.sequence_num;
87
+ if (typeof raw !== "string" || raw.trim() === "") return null;
88
+ const n = Number(raw);
89
+ return Number.isFinite(n) ? n : null;
90
+ }
74
91
  function normalizeSessionId(id) {
75
92
  const trimmed = id.trim();
76
93
  if (trimmed.startsWith("session_")) return `cse_${trimmed.slice("session_".length)}`;
@@ -84,13 +101,18 @@ function toCliSessionId(id) {
84
101
  return `session_${normalized.slice("cse_".length)}`;
85
102
  }
86
103
  var UNAUTHORIZED_HINT = "run `claude auth login`";
104
+ function isCredentialExpiring(credential, now = Date.now()) {
105
+ return credential.expiresAt !== void 0 && credential.expiresAt - now <= EXPIRY_SKEW_MS;
106
+ }
87
107
  var SessionsApi = class {
88
108
  #credentials;
109
+ #refreshLogin;
89
110
  #fetch;
90
111
  #baseUrl;
91
112
  #credential = null;
92
113
  constructor(options) {
93
114
  this.#credentials = options.credentials;
115
+ this.#refreshLogin = options.refreshLogin;
94
116
  this.#fetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
95
117
  this.#baseUrl = (options.baseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
96
118
  }
@@ -102,8 +124,9 @@ var SessionsApi = class {
102
124
  );
103
125
  }
104
126
  /**
105
- * GET /v1/code/sessions/{id}/events — newest first. Pass the previous
106
- * page's `resume_cursor` as `cursor` to resume after it.
127
+ * GET /v1/code/sessions/{id}/events — newest first. With no `cursor` this
128
+ * returns the NEWEST page; pass the page's `resume_cursor` (the oldest
129
+ * event's `sequence_num`) as `cursor` to fetch the next OLDER page.
107
130
  */
108
131
  async listEvents(id, opts = {}) {
109
132
  const params = new URLSearchParams();
@@ -127,8 +150,26 @@ var SessionsApi = class {
127
150
  if (!this.#credential) this.#credential = await this.#credentials.get();
128
151
  return this.#credential;
129
152
  }
130
- async #request(method, path, retriedAuth = false) {
153
+ /** Refresh the login (when injected), drop the source cache, and re-read.
154
+ * A refresh failure is surfaced to the caller — better a clear "run
155
+ * `claude auth login`" than a request with a known-stale token. */
156
+ async #refreshAndInvalidate() {
157
+ await this.#refreshLogin?.();
158
+ this.#credentials.invalidate();
159
+ this.#credential = null;
160
+ }
161
+ /** The credential for the next request: refresh first when it is expired
162
+ * or expiring. Without an injected refresh the check is a no-op. */
163
+ async #freshCredential() {
131
164
  const credential = await this.#credentialNow();
165
+ if (this.#refreshLogin && isCredentialExpiring(credential)) {
166
+ await this.#refreshAndInvalidate();
167
+ return this.#credentialNow();
168
+ }
169
+ return credential;
170
+ }
171
+ async #request(method, path, retriedAuth = false) {
172
+ const credential = retriedAuth ? await this.#credentialNow() : await this.#freshCredential();
132
173
  const headers = {
133
174
  authorization: `Bearer ${credential.accessToken}`,
134
175
  "anthropic-version": "2023-06-01",
@@ -137,7 +178,7 @@ var SessionsApi = class {
137
178
  };
138
179
  const res = await this.#fetch(`${this.#baseUrl}${path}`, { method, headers });
139
180
  if (res.status === 401 && !retriedAuth) {
140
- this.#credential = null;
181
+ await this.#refreshAndInvalidate();
141
182
  return this.#request(method, path, true);
142
183
  }
143
184
  if (res.status === 401) {
@@ -174,8 +215,18 @@ function cloudChildEnv(base = process.env) {
174
215
  }
175
216
  return out;
176
217
  }
218
+ var ANSI_ESCAPE_RE = /\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g;
219
+ function normalizePtyOutput(raw) {
220
+ return raw.replace(ANSI_ESCAPE_RE, " ").replace(/[^\S\n]+/g, " ").replace(/\n[ \t]*(?:\n[ \t]*)+/g, "\n").trim();
221
+ }
222
+ var TRUST_PROMPT_RE = /Yes,\s*I\s*trust\s*this\s*folder/i;
223
+ var PTY_TAIL_CHARS = 300;
224
+ function ptyOutputTail(raw, maxChars = PTY_TAIL_CHARS) {
225
+ const stripped = normalizePtyOutput(raw);
226
+ return stripped.length > maxChars ? stripped.slice(stripped.length - maxChars) : stripped;
227
+ }
177
228
  var DEFAULT_RUN_TIMEOUT_MS = 6e4;
178
- function defaultCloudCliRunner() {
229
+ function defaultCloudCliRunner(deps = {}) {
179
230
  return {
180
231
  run(cmd, args, opts) {
181
232
  return new Promise((resolve, reject) => {
@@ -216,10 +267,8 @@ function defaultCloudCliRunner() {
216
267
  });
217
268
  let proc = null;
218
269
  const pendingWrites = [];
219
- void import('node-pty').then((mod) => {
220
- const pty = mod.default ?? mod;
221
- if (typeof pty.spawn !== "function") throw new Error("node-pty has no spawn");
222
- const p = pty.spawn(cmd, args, {
270
+ const attach = (spawnFn) => {
271
+ const p = spawnFn(cmd, args, {
223
272
  name: "xterm-256color",
224
273
  cols: 120,
225
274
  rows: 40,
@@ -234,7 +283,7 @@ function defaultCloudCliRunner() {
234
283
  p.onData((chunk) => {
235
284
  output += chunk;
236
285
  opts.onData?.(chunk);
237
- if (!answeredTrustPrompt && /Yes, I trust this folder/.test(output)) {
286
+ if (!answeredTrustPrompt && TRUST_PROMPT_RE.test(normalizePtyOutput(output))) {
238
287
  answeredTrustPrompt = true;
239
288
  p.write("\x1B[B\r");
240
289
  }
@@ -246,7 +295,7 @@ function defaultCloudCliRunner() {
246
295
  }
247
296
  rejectResult(
248
297
  new Error(
249
- `claude code cloud: \`${cmd} ${args.join(" ")}\` timed out after ${opts.timeoutMs ?? 12e4}ms under the PTY`
298
+ `claude code cloud: \`${cmd} ${args.join(" ")}\` timed out after ${opts.timeoutMs ?? 12e4}ms under the PTY; last output: ${ptyOutputTail(output)}`
250
299
  )
251
300
  );
252
301
  }, opts.timeoutMs ?? 12e4);
@@ -255,13 +304,26 @@ function defaultCloudCliRunner() {
255
304
  clearTimeout(timer);
256
305
  resolveResult({ code: exitCode, output });
257
306
  });
258
- }).catch((err) => {
259
- rejectResult(
260
- new Error(
261
- `claude code cloud: \`claude --cloud\` needs a real PTY and node-pty is not available (${err instanceof Error ? err.message : String(err)}) \u2014 install it (npm i node-pty) to use managed cloud sessions`
262
- )
263
- );
264
- });
307
+ };
308
+ if (deps.spawnPty) {
309
+ try {
310
+ attach(deps.spawnPty);
311
+ } catch (err) {
312
+ rejectResult(err);
313
+ }
314
+ } else {
315
+ void import('node-pty').then((mod) => {
316
+ const pty = mod.default ?? mod;
317
+ if (typeof pty.spawn !== "function") throw new Error("node-pty has no spawn");
318
+ attach(pty.spawn);
319
+ }).catch((err) => {
320
+ rejectResult(
321
+ new Error(
322
+ `claude code cloud: \`claude --cloud\` needs a real PTY and node-pty is not available (${err instanceof Error ? err.message : String(err)}) \u2014 install it (npm i node-pty) to use managed cloud sessions`
323
+ )
324
+ );
325
+ });
326
+ }
265
327
  return {
266
328
  write: (data) => {
267
329
  if (proc) proc.write(data);
@@ -272,6 +334,43 @@ function defaultCloudCliRunner() {
272
334
  }
273
335
  };
274
336
  }
337
+ function createRefreshLogin(opts = {}) {
338
+ let inFlight = null;
339
+ return () => {
340
+ if (inFlight) return inFlight;
341
+ const run = (async () => {
342
+ const runner = opts.runner ?? defaultCloudCliRunner();
343
+ const env = cloudChildEnv(opts.env ?? process.env);
344
+ let result;
345
+ try {
346
+ result = await runner.run(
347
+ "claude",
348
+ ["-p", "Reply OK", "--model", "haiku"],
349
+ {
350
+ cwd: opts.cwd ?? tmpdir(),
351
+ env,
352
+ stdin: "ignore",
353
+ timeoutMs: opts.timeoutMs ?? 6e4
354
+ }
355
+ );
356
+ } catch (err) {
357
+ throw new Error(
358
+ `claude code cloud: could not refresh the claude.ai login \u2014 is the claude CLI installed and on PATH? (${err instanceof Error ? err.message : String(err)})`
359
+ );
360
+ }
361
+ if (result.code !== 0) {
362
+ throw new Error(
363
+ `claude code cloud: could not refresh the claude.ai login (exit ${result.code}) \u2014 run \`claude auth login\``
364
+ );
365
+ }
366
+ })();
367
+ const wrapped = run.finally(() => {
368
+ if (inFlight === wrapped) inFlight = null;
369
+ });
370
+ inFlight = wrapped;
371
+ return wrapped;
372
+ };
373
+ }
275
374
  function parseAuthStatus(stdout) {
276
375
  const trimmed = stdout.trim();
277
376
  const start = trimmed.indexOf("{");
@@ -317,12 +416,13 @@ async function preflightAuth(runner, opts) {
317
416
  return status;
318
417
  }
319
418
  function parseCloudStartOutput(output) {
419
+ const transcript = normalizePtyOutput(output);
320
420
  let sessionId;
321
421
  let url;
322
422
  let title;
323
- const resume = output.match(/Resume with:\s*claude\s+--teleport\s+(session_[A-Za-z0-9_-]+)/);
423
+ const resume = transcript.match(/Resume with:\s*claude\s+--teleport\s+(session_[A-Za-z0-9_-]+)/);
324
424
  if (resume?.[1]) sessionId = resume[1];
325
- const view = output.match(/View:\s*(https?:\/\/\S+)/);
425
+ const view = transcript.match(/View:\s*(https?:\/\/\S+)/);
326
426
  if (view?.[1]) {
327
427
  url = view[1];
328
428
  if (!sessionId) {
@@ -330,7 +430,7 @@ function parseCloudStartOutput(output) {
330
430
  if (fromUrl?.[1]) sessionId = `session_${fromUrl[1]}`;
331
431
  }
332
432
  }
333
- const created = output.match(/Created cloud session:\s*(.+)/);
433
+ const created = transcript.match(/Created cloud session:\s*(.+)/);
334
434
  if (created?.[1]) title = created[1].trim();
335
435
  if (!sessionId) return null;
336
436
  return {
@@ -383,6 +483,49 @@ async function sendPrompt(sessionId, text, opts) {
383
483
  }
384
484
  return { stdout: result.stdout };
385
485
  }
486
+ var CLOUD_SESSION_META_FILE_ENV = "AGENTPROTO_CLOUD_SESSION_META_FILE";
487
+ var CLOUD_VENDOR_SLUG = "anthropic";
488
+ async function readExisting(path) {
489
+ try {
490
+ const parsed = JSON.parse(await readFile(path, "utf8"));
491
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
492
+ const p = parsed;
493
+ if (typeof p["vendor"] !== "string" || p["vendor"].length === 0) return void 0;
494
+ if (typeof p["sessionId"] !== "string" || p["sessionId"].length === 0) return void 0;
495
+ return {
496
+ vendor: p["vendor"],
497
+ sessionId: p["sessionId"],
498
+ ...typeof p["url"] === "string" && p["url"].length > 0 ? { url: p["url"] } : {}
499
+ };
500
+ } catch {
501
+ return void 0;
502
+ }
503
+ }
504
+ async function writeCloudSessionMeta(meta, opts = {}) {
505
+ const env = opts.env ?? process.env;
506
+ const path = env[CLOUD_SESSION_META_FILE_ENV];
507
+ if (!path) return null;
508
+ try {
509
+ const existing = await readExisting(path);
510
+ const url = meta.url ?? (existing?.sessionId === meta.sessionId ? existing.url : void 0);
511
+ const payload = JSON.stringify({
512
+ vendor: meta.vendor,
513
+ sessionId: meta.sessionId,
514
+ ...url !== void 0 ? { url } : {}
515
+ });
516
+ await mkdir(dirname(path), { recursive: true });
517
+ const tmp = `${path}.${process.pid}.tmp`;
518
+ await writeFile(tmp, payload, "utf8");
519
+ await rename(tmp, path);
520
+ return path;
521
+ } catch (err) {
522
+ process.stderr.write(
523
+ `[claude-code-cloud] could not write the cloud session meta file (${CLOUD_SESSION_META_FILE_ENV}): ${err instanceof Error ? err.message : String(err)}
524
+ `
525
+ );
526
+ return null;
527
+ }
528
+ }
386
529
  var execFileAsync2 = promisify(execFile);
387
530
  var defaultGitRunner = async (args, cwd) => {
388
531
  try {
@@ -439,6 +582,7 @@ async function validateRepoSource(cwd, git = defaultGitRunner) {
439
582
  var POLL_INITIAL_MS = 2e3;
440
583
  var POLL_MAX_MS = 1e4;
441
584
  var MAX_LIST_EVENTS_FAILURES = 5;
585
+ var OBSERVE_PAGES_LIMIT = 20;
442
586
  var NETWORK_ERROR_CODES = /^(ECONNABORTED|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|ENOTFOUND|EPIPE|ETIMEDOUT|UND_ERR)/;
443
587
  function isTransientListEventsError(err) {
444
588
  if (!(err instanceof Error)) return false;
@@ -452,16 +596,6 @@ function isTransientListEventsError(err) {
452
596
  }
453
597
  return false;
454
598
  }
455
- var SEEN_EVENT_IDS_LIMIT = 500;
456
- function rememberSeenEventId(session, id) {
457
- const seen = session.seenEventIds;
458
- seen.add(id);
459
- while (seen.size > SEEN_EVENT_IDS_LIMIT) {
460
- const oldest = seen.values().next();
461
- if (oldest.done) break;
462
- seen.delete(oldest.value);
463
- }
464
- }
465
599
  function defaultSleep(ms) {
466
600
  return new Promise((resolve) => {
467
601
  const timer = setTimeout(resolve, ms);
@@ -471,8 +605,9 @@ function defaultSleep(ms) {
471
605
  function defaultDeps() {
472
606
  const runner = defaultCloudCliRunner();
473
607
  const credentials = keychainCredentialSource();
608
+ const refreshLogin = createRefreshLogin({ runner, env: process.env });
474
609
  return {
475
- api: new SessionsApi({ credentials }),
610
+ api: new SessionsApi({ credentials, refreshLogin }),
476
611
  cli: {
477
612
  startCloudSession: (prompt, opts) => startCloudSession(prompt, {
478
613
  cwd: opts.cwd,
@@ -536,9 +671,10 @@ var ClaudeCloudAcpAgent = class {
536
671
  return {
537
672
  protocolVersion: PROTOCOL_VERSION,
538
673
  agentCapabilities: {
539
- // Sessions live in the vendor's cloud; re-attach is via the vendor
540
- // id, not an ACP session/load replay (AIP-66 §5).
541
- loadSession: false
674
+ // Re-attach rides ACP `session/load` with the vendor session id the
675
+ // host persisted on `meta.cloud.sessionId` (AIP-66 §5) — that call
676
+ // ADOPTS the existing cloud session, it never starts a new one.
677
+ loadSession: true
542
678
  }
543
679
  };
544
680
  }
@@ -557,12 +693,117 @@ var ClaudeCloudAcpAgent = class {
557
693
  cwd: params.cwd,
558
694
  vendorSessionId: null,
559
695
  vendorUrl: null,
560
- pollCursor: null,
561
- seenEventIds: /* @__PURE__ */ new Set(),
696
+ lastSeq: 0,
697
+ turnBaseline: 0,
562
698
  promptInFlight: false
563
699
  });
564
700
  return { sessionId: id };
565
701
  }
702
+ /**
703
+ * AIP-66 §5 re-attach: ADOPT the vendor session id the host persisted on
704
+ * `meta.cloud.sessionId` as this ACP session's vendor session. No vendor
705
+ * side effect happens here — the next `session/prompt` goes through
706
+ * `sendPrompt` (`claude -p … --cloud <id>`), never `startCloudSession`,
707
+ * so a resumed relay can never replace the vendor's session with a new
708
+ * one. The adopted id is also reported back to the host's meta file so a
709
+ * re-attach against a fresh config dir still carries the §5 mapping.
710
+ *
711
+ * Fails closed on an id the vendor could never own (e.g. the ACP uuid of
712
+ * a spawn whose vendor id was never learned): no session is started in
713
+ * its place.
714
+ */
715
+ async loadSession(params) {
716
+ if (params.mcpServers && params.mcpServers.length > 0) {
717
+ throw new Error(
718
+ "claude code cloud: mcpServers is not supported for managed cloud agents (AIP-66 \xA74) \u2014 the vendor owns the harness"
719
+ );
720
+ }
721
+ let vendorSessionId;
722
+ try {
723
+ vendorSessionId = toCliSessionId(params.sessionId);
724
+ } catch {
725
+ throw new Error(
726
+ `claude code cloud: session/load needs the vendor session id, got ${JSON.stringify(params.sessionId)} \u2014 expected session_<id> or cse_<id> (AIP-66 \xA75). That id was never learned onto meta.cloud.sessionId, so there is no vendor session to re-attach to \u2014 and this relay will not start a replacement.`
727
+ );
728
+ }
729
+ await this.#validateRepo(params.cwd);
730
+ const session = {
731
+ id: vendorSessionId,
732
+ cwd: params.cwd,
733
+ vendorSessionId,
734
+ vendorUrl: null,
735
+ lastSeq: 0,
736
+ turnBaseline: 0,
737
+ promptInFlight: false
738
+ };
739
+ this.#sessions.set(vendorSessionId, session);
740
+ await this.#seedHistory(session, vendorSessionId);
741
+ await writeCloudSessionMeta({ vendor: CLOUD_VENDOR_SLUG, sessionId: vendorSessionId });
742
+ return {};
743
+ }
744
+ /** Seed `lastSeq` from the vendor's newest events page. Best effort: a
745
+ * failed seed degrades to (at worst) an early end on an older `result` —
746
+ * it must never turn a re-attach into a failed load, which AIP-66 §5
747
+ * forbids replacing with a fresh start anyway.
748
+ *
749
+ * ONE newest-page GET with no cursor: it returns the newest page (newest
750
+ * first), so its max `sequence_num` is the history high-water. No
751
+ * backward paging is needed — `lastSeq` only has to be high enough that
752
+ * the first post-attach poll projects nothing already in the history. */
753
+ async #seedHistory(session, vendorSessionId) {
754
+ try {
755
+ session.lastSeq = await this.#highWaterSeq(vendorSessionId);
756
+ } catch (err) {
757
+ process.stderr.write(
758
+ `[claude-code-cloud] session/load: could not seed the vendor's existing event high-water (${err instanceof Error ? err.message : String(err)})
759
+ `
760
+ );
761
+ }
762
+ }
763
+ /** Max `sequence_num` on the session's newest events page (0 when the
764
+ * page is empty). One newest-page GET, no cursor. */
765
+ async #highWaterSeq(vendorSessionId) {
766
+ const res = await this.#api.listEvents(vendorSessionId, {});
767
+ let max = 0;
768
+ for (const event of res.data ?? []) {
769
+ const seq = eventSequence(event);
770
+ if (seq !== null && seq > max) max = seq;
771
+ }
772
+ return max;
773
+ }
774
+ /**
775
+ * Read every event newer than `lastSeq`, in ascending seq order, walking
776
+ * the API's cursor BACKWARD as far as needed. The API serves the NEWEST
777
+ * page with no cursor and pages toward OLDER events via `?cursor=<n>`
778
+ * (sessions-api.ts), so catching up across a >1-page gap means following
779
+ * `resume_cursor` and collecting the seqs above `lastSeq` until a page
780
+ * reaches already-projected history. Capped at {@link OBSERVE_PAGES_LIMIT}
781
+ * pages per call.
782
+ */
783
+ async #collectNewEvents(vendorSessionId, lastSeq) {
784
+ const collected = [];
785
+ let cursor;
786
+ for (let page = 0; page < OBSERVE_PAGES_LIMIT; page += 1) {
787
+ const res = await this.#api.listEvents(
788
+ vendorSessionId,
789
+ cursor ? { cursor } : {}
790
+ );
791
+ const events = res.data ?? [];
792
+ let oldest = null;
793
+ for (const event of events) {
794
+ const seq = eventSequence(event);
795
+ if (seq === null) continue;
796
+ if (oldest === null || seq < oldest) oldest = seq;
797
+ if (seq > lastSeq) collected.push({ seq, event });
798
+ }
799
+ if (events.length === 0 || oldest === null || oldest <= lastSeq + 1) break;
800
+ const next = res.resume_cursor ?? res.next_cursor;
801
+ if (!next || next === cursor) break;
802
+ cursor = next;
803
+ }
804
+ collected.sort((a, b) => a.seq - b.seq);
805
+ return collected;
806
+ }
566
807
  async prompt(params) {
567
808
  const session = this.#sessions.get(params.sessionId);
568
809
  if (!session) throw new Error(`unknown session ${params.sessionId}`);
@@ -580,6 +821,11 @@ var ClaudeCloudAcpAgent = class {
580
821
  });
581
822
  session.vendorSessionId = started.sessionId;
582
823
  session.vendorUrl = started.url;
824
+ await writeCloudSessionMeta({
825
+ vendor: CLOUD_VENDOR_SLUG,
826
+ sessionId: started.sessionId,
827
+ url: started.url
828
+ });
583
829
  await this.#conn.sessionUpdate({
584
830
  sessionId: session.id,
585
831
  update: {
@@ -590,7 +836,9 @@ var ClaudeCloudAcpAgent = class {
590
836
  }
591
837
  }
592
838
  });
839
+ session.turnBaseline = 0;
593
840
  } else {
841
+ session.turnBaseline = await this.#highWaterSeq(session.vendorSessionId);
594
842
  await this.#cli.sendPrompt(session.vendorSessionId, text, {
595
843
  cwd: session.cwd
596
844
  });
@@ -607,12 +855,9 @@ var ClaudeCloudAcpAgent = class {
607
855
  let delay = this.#pollInitialMs;
608
856
  let failures = 0;
609
857
  for (; ; ) {
610
- let page;
858
+ let fresh;
611
859
  try {
612
- page = await this.#api.listEvents(
613
- vendorId,
614
- session.pollCursor ? { cursor: session.pollCursor } : {}
615
- );
860
+ fresh = await this.#collectNewEvents(vendorId, session.lastSeq);
616
861
  } catch (err) {
617
862
  if (!isTransientListEventsError(err) || failures + 1 >= MAX_LIST_EVENTS_FAILURES) {
618
863
  throw err;
@@ -623,16 +868,9 @@ var ClaudeCloudAcpAgent = class {
623
868
  continue;
624
869
  }
625
870
  failures = 0;
626
- if (page.resume_cursor) session.pollCursor = page.resume_cursor;
627
- const fresh = (page.data ?? []).filter((event) => !session.seenEventIds.has(event.event_id)).sort((a, b) => {
628
- if (a.created_at !== b.created_at) {
629
- return a.created_at < b.created_at ? -1 : 1;
630
- }
631
- return a.event_id < b.event_id ? -1 : 1;
632
- });
633
871
  let stopReason = null;
634
- for (const event of fresh) {
635
- rememberSeenEventId(session, event.event_id);
872
+ for (const { seq, event } of fresh) {
873
+ session.lastSeq = Math.max(session.lastSeq, seq);
636
874
  if (event.event_type === "result") {
637
875
  if (resultStopReason(event.payload) === "refusal") {
638
876
  const errText = errorResultText(event.payload);
@@ -646,7 +884,9 @@ var ClaudeCloudAcpAgent = class {
646
884
  for (const update of resultEventUpdates(event.payload)) {
647
885
  await this.#send(session.id, update);
648
886
  }
649
- stopReason = resultStopReason(event.payload);
887
+ if (seq > session.turnBaseline) {
888
+ stopReason = resultStopReason(event.payload);
889
+ }
650
890
  } else if (event.event_type === "user") {
651
891
  for (const update of sdkMessageToUpdates(
652
892
  event.payload
@@ -704,6 +944,6 @@ function main() {
704
944
  runAcpOverStdio();
705
945
  }
706
946
 
707
- export { CLAUDE_CODE_GATEWAY_ENV_UNSET, ClaudeCloudAcpAgent, MAX_LIST_EVENTS_FAILURES, POLL_INITIAL_MS, POLL_MAX_MS, SEEN_EVENT_IDS_LIMIT, SessionsApi, cloudChildEnv, defaultCloudCliRunner, defaultGitRunner, envCredentialSource, isTransientListEventsError, keychainCredentialSource, main, normalizeSessionId, parseAuthStatus, parseCloudStartOutput, preflightAuth, resultEventUpdates, resultStopReason, runAcpOverStdio, sendPrompt, startCloudSession, toCliSessionId, validateRepoSource };
708
- //# sourceMappingURL=chunk-KA4G4O2X.mjs.map
709
- //# sourceMappingURL=chunk-KA4G4O2X.mjs.map
947
+ export { CLAUDE_CODE_GATEWAY_ENV_UNSET, CLOUD_SESSION_META_FILE_ENV, CLOUD_VENDOR_SLUG, ClaudeCloudAcpAgent, EXPIRY_SKEW_MS, MAX_LIST_EVENTS_FAILURES, OBSERVE_PAGES_LIMIT, POLL_INITIAL_MS, POLL_MAX_MS, SessionsApi, cloudChildEnv, createRefreshLogin, defaultCloudCliRunner, defaultGitRunner, envCredentialSource, isCredentialExpiring, isTransientListEventsError, keychainCredentialSource, main, normalizePtyOutput, normalizeSessionId, parseAuthStatus, parseCloudStartOutput, preflightAuth, ptyOutputTail, resultEventUpdates, resultStopReason, runAcpOverStdio, sendPrompt, startCloudSession, toCliSessionId, validateRepoSource, writeCloudSessionMeta };
948
+ //# sourceMappingURL=chunk-LN5RJ5ZT.mjs.map
949
+ //# sourceMappingURL=chunk-LN5RJ5ZT.mjs.map