@rallycry/conveyor-agent 9.1.0 → 10.0.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.
@@ -116,6 +116,44 @@ function applyBootstrapToEnv(config) {
116
116
 
117
117
  // src/connection/agent-connection.ts
118
118
  import { io } from "socket.io-client";
119
+
120
+ // src/setup/bootstrap-poll.ts
121
+ var PollUntilBoundHttpError = class extends Error {
122
+ constructor(status) {
123
+ super(`pollUntilBound got unexpected status ${status}`);
124
+ this.status = status;
125
+ this.name = "PollUntilBoundHttpError";
126
+ }
127
+ status;
128
+ };
129
+ async function sleep2(ms) {
130
+ await new Promise((resolve) => {
131
+ setTimeout(resolve, ms);
132
+ });
133
+ }
134
+ async function pollUntilBound(opts) {
135
+ const pollIntervalMs = opts.pollIntervalMs ?? 2e3;
136
+ const maxWaitMs = opts.maxWaitMs ?? 30 * 60 * 1e3;
137
+ const deadline = Date.now() + maxWaitMs;
138
+ while (true) {
139
+ const response = await fetch(`${opts.apiUrl}/api/v3/pods/bootstrap`, {
140
+ headers: { Authorization: `Bearer ${opts.bootstrapToken}` }
141
+ });
142
+ if (response.status === 200) {
143
+ return await response.json();
144
+ }
145
+ if (response.status === 204) {
146
+ if (Date.now() >= deadline) {
147
+ throw new Error(`pollUntilBound timed out after ${maxWaitMs}ms waiting for pod bind`);
148
+ }
149
+ await sleep2(pollIntervalMs);
150
+ continue;
151
+ }
152
+ throw new PollUntilBoundHttpError(response.status);
153
+ }
154
+ }
155
+
156
+ // src/connection/agent-connection.ts
119
157
  var EVENT_BATCH_MS = 500;
120
158
  var MAX_EVENT_BUFFER = 5e3;
121
159
  var TOKEN_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1e3;
@@ -147,6 +185,7 @@ var AgentConnection = class _AgentConnection {
147
185
  modeChangeCallback = null;
148
186
  apiKeyUpdateCallback = null;
149
187
  pullBranchCallback = null;
188
+ finalizeSnapshotCallback = null;
150
189
  earlyPullBranches = [];
151
190
  // PTY relay (S5 terminal). Single-slot callbacks, set per PtySession run.
152
191
  ptyInputCallback = null;
@@ -301,6 +340,9 @@ var AgentConnection = class _AgentConnection {
301
340
  if (this.pullBranchCallback) this.pullBranchCallback(data);
302
341
  else this.earlyPullBranches.push(data);
303
342
  });
343
+ this.socket.on("session:finalizeSnapshot", () => {
344
+ this.finalizeSnapshotCallback?.();
345
+ });
304
346
  this.socket.on("pty:input", (data) => {
305
347
  if (data.sessionId && data.sessionId !== this.config.sessionId) return;
306
348
  this.ptyInputCallback?.(data.data);
@@ -378,6 +420,14 @@ var AgentConnection = class _AgentConnection {
378
420
  static RECONNECT_MAX_DELAY_MS = 6e4;
379
421
  static RECONNECT_STATUS_EVERY_N = 3;
380
422
  isReconnecting = false;
423
+ /**
424
+ * Invoked after every successful session reconnect (the `connectAgent` RPC
425
+ * re-established the session room). The runner uses this to force a TUI
426
+ * repaint: the reconnect may have landed on a different/restarted API
427
+ * process whose PTY scrollback ring is empty, and a quiet terminal would
428
+ * otherwise never re-seed it.
429
+ */
430
+ onReconnected;
381
431
  async reconnectToSession() {
382
432
  if (this.isReconnecting) return;
383
433
  this.isReconnecting = true;
@@ -409,6 +459,10 @@ var AgentConnection = class _AgentConnection {
409
459
  reason: "reconnected",
410
460
  attempts: attempt
411
461
  });
462
+ try {
463
+ this.onReconnected?.();
464
+ } catch {
465
+ }
412
466
  return;
413
467
  } catch (err) {
414
468
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -511,6 +565,9 @@ var AgentConnection = class _AgentConnection {
511
565
  for (const data of this.earlyPullBranches) callback(data);
512
566
  this.earlyPullBranches = [];
513
567
  }
568
+ onFinalizeSnapshot(callback) {
569
+ this.finalizeSnapshotCallback = callback;
570
+ }
514
571
  // ── PTY relay (S5 Connected-TUI terminal) ──────────────────────────
515
572
  /**
516
573
  * Forward a raw chunk of terminal output to the S2 relay (fire-and-forget).
@@ -659,6 +716,12 @@ var AgentConnection = class _AgentConnection {
659
716
  }
660
717
  );
661
718
  }
719
+ /** Report the full current set of runtime-discovered listening ports.
720
+ * Throws on failure so the PortDiscovery poller can retry on its next
721
+ * tick (a swallowed error here would silently drop the delta). */
722
+ async reportDiscoveredPorts(ports) {
723
+ await this.call("reportDiscoveredPorts", { sessionId: this.config.sessionId, ports });
724
+ }
662
725
  // ── Typing indicators ───────────────────────────────────────────────
663
726
  sendTypingStart() {
664
727
  this.sendEvent({ type: "agent_typing_start" });
@@ -667,17 +730,6 @@ var AgentConnection = class _AgentConnection {
667
730
  this.sendEvent({ type: "agent_typing_stop" });
668
731
  }
669
732
  // ── RPC convenience wrappers (v6 compat, will migrate to call()) ───
670
- trackSpending(params) {
671
- if (!this.socket) return;
672
- void this.call("trackSpending", {
673
- sessionId: this.config.sessionId,
674
- inputTokens: params.modelUsage?.reduce((s, m) => s + (m.inputTokens ?? 0), 0) ?? 0,
675
- outputTokens: params.modelUsage?.reduce((s, m) => s + (m.outputTokens ?? 0), 0) ?? 0,
676
- costUsd: params.totalCostUsd,
677
- model: params.modelUsage?.[0]?.model ?? "unknown"
678
- }).catch(() => {
679
- });
680
- }
681
733
  emitRateLimitPause(resetsAt) {
682
734
  this.sendEvent({ type: "rate_limit_update", resetsAt });
683
735
  }
@@ -722,9 +774,6 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
722
774
  triggerIdentification() {
723
775
  return this.call("triggerIdentification", { sessionId: this.config.sessionId });
724
776
  }
725
- getCumulativeSpending() {
726
- return this.call("getCumulativeSpending", { sessionId: this.config.sessionId });
727
- }
728
777
  async refreshAuthToken() {
729
778
  const result = await this.refreshFromBootstrap();
730
779
  return result.refreshedClaude;
@@ -740,15 +789,27 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
740
789
  const result = await this.refreshFromBootstrap();
741
790
  return result.refreshedTaskToken;
742
791
  }
743
- async refreshFromBootstrap() {
792
+ refreshFromBootstrap() {
793
+ const none = Promise.resolve({ refreshedClaude: false, refreshedTaskToken: false });
794
+ const podBootstrapToken = process.env.POD_BOOTSTRAP_TOKEN;
744
795
  const codespaceName = process.env.CODESPACE_NAME || process.env.CLAUDESPACE_NAME;
745
796
  const apiUrl = this.config.apiUrl;
746
- if (!codespaceName || !apiUrl) return { refreshedClaude: false, refreshedTaskToken: false };
797
+ if (!apiUrl || !podBootstrapToken && !codespaceName) {
798
+ return none;
799
+ }
747
800
  const now = Date.now();
748
801
  if (now - this.lastTaskTokenRefreshAt < 6e4) {
749
- return { refreshedClaude: false, refreshedTaskToken: false };
802
+ return none;
750
803
  }
751
804
  this.lastTaskTokenRefreshAt = now;
805
+ if (podBootstrapToken) {
806
+ return this.refreshFromV3Bootstrap(apiUrl, podBootstrapToken);
807
+ }
808
+ if (!codespaceName) return none;
809
+ return this.refreshFromCodespaceBootstrap(apiUrl, codespaceName);
810
+ }
811
+ /** Legacy GitHub Codespaces refresh path — keys on instance name. */
812
+ async refreshFromCodespaceBootstrap(apiUrl, codespaceName) {
752
813
  const bootstrapToken = process.env.CONVEYOR_BOOTSTRAP_TOKEN;
753
814
  const result = await fetchBootstrap({
754
815
  apiUrl,
@@ -774,6 +835,61 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
774
835
  const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
775
836
  return { refreshedClaude, refreshedTaskToken };
776
837
  }
838
+ /**
839
+ * v3 refresh: re-fetch the full bootstrap bundle from the pod's bound v3
840
+ * route and swap the credentials in place. The GitHub installation token
841
+ * dies at ~1h and the sessionJwt at 24h; re-polling the bootstrap GET with
842
+ * the same pod token is the designed refresh mechanism.
843
+ */
844
+ async refreshFromV3Bootstrap(apiUrl, bootstrapToken) {
845
+ const bundle = await this.pollBundleWithRateLimitRetry(apiUrl, bootstrapToken);
846
+ if (!bundle) {
847
+ return { refreshedClaude: false, refreshedTaskToken: false };
848
+ }
849
+ const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
850
+ for (const [key, value] of Object.entries(bundle.envVars ?? {})) {
851
+ process.env[key] = value;
852
+ }
853
+ if (bundle.githubToken) process.env.CONVEYOR_GITHUB_TOKEN = bundle.githubToken;
854
+ if (bundle.anthropicKey) process.env.ANTHROPIC_API_KEY = bundle.anthropicKey;
855
+ if (bundle.gcpToken) process.env.CLOUDSDK_AUTH_ACCESS_TOKEN = bundle.gcpToken;
856
+ const refreshedTaskToken = Boolean(bundle.sessionJwt) && bundle.sessionJwt !== previousTaskToken;
857
+ if (refreshedTaskToken) {
858
+ process.env.CONVEYOR_TASK_TOKEN = bundle.sessionJwt;
859
+ this.config.taskToken = bundle.sessionJwt;
860
+ if (this.socket) {
861
+ const auth = this.socket.auth;
862
+ if (auth && typeof auth === "object") {
863
+ auth.taskToken = bundle.sessionJwt;
864
+ }
865
+ }
866
+ }
867
+ const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
868
+ return { refreshedClaude, refreshedTaskToken };
869
+ }
870
+ /**
871
+ * Poll the bootstrap bundle once (maxWaitMs 0), retrying only on a transient
872
+ * 429 (standby-pool pods share one Cloud NAT IP against podBootstrapLimiter)
873
+ * with a short backoff. Returns null when the refresh should be abandoned —
874
+ * a non-429 error, or 429s past the retry budget — so the caller no-ops
875
+ * instead of parking a RUNNING pod in a poll loop.
876
+ */
877
+ async pollBundleWithRateLimitRetry(apiUrl, bootstrapToken) {
878
+ const retryDelaysMs = [1e3, 3e3];
879
+ for (let attempt = 0; ; attempt++) {
880
+ try {
881
+ return await pollUntilBound({ apiUrl, bootstrapToken, maxWaitMs: 0 });
882
+ } catch (err) {
883
+ const isRateLimited = err instanceof PollUntilBoundHttpError && err.status === 429;
884
+ if (!isRateLimited || attempt >= retryDelaysMs.length) {
885
+ return null;
886
+ }
887
+ await new Promise((resolve) => {
888
+ setTimeout(resolve, retryDelaysMs[attempt]);
889
+ });
890
+ }
891
+ }
892
+ }
777
893
  // ── Event buffering ────────────────────────────────────────────────
778
894
  sendEvent(event) {
779
895
  if (!this.socket) return;
@@ -1358,6 +1474,13 @@ function preToolUse(payload) {
1358
1474
  );
1359
1475
  process.exit(0);
1360
1476
  };
1477
+ // MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
1478
+ // Plan mode prompts for them despite permissions.allow (the CLI can't
1479
+ // classify MCP tools as read-only), and the pod is the sandbox.
1480
+ if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
1481
+ respond("allow");
1482
+ return;
1483
+ }
1361
1484
  // AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
1362
1485
  // missing/slow socket never denies the questionnaire. Everything else
1363
1486
  // (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
@@ -1461,6 +1584,15 @@ async function writeHookSettings(dir) {
1461
1584
  // tool, so the questionnaire is never blocked by Conveyor.
1462
1585
  matcher: "AskUserQuestion",
1463
1586
  hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1587
+ },
1588
+ {
1589
+ // Plan-permission spawns prompt for MCP tools even when they're in
1590
+ // permissions.allow — the CLI can't classify them as read-only. A
1591
+ // hook allow bypasses the permission engine in every mode. Loose by
1592
+ // design: the pod is the sandbox, and planning agents must call
1593
+ // update_task etc. The helper answers locally (no socket).
1594
+ matcher: "mcp__.*",
1595
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1464
1596
  }
1465
1597
  ],
1466
1598
  PostToolUse: [
@@ -1499,8 +1631,8 @@ var PlanSync = class {
1499
1631
  for (const file of readdirSync(plansDir).filter((f) => f.endsWith(".md"))) {
1500
1632
  try {
1501
1633
  const fullPath = join2(plansDir, file);
1502
- const stat4 = statSync(fullPath);
1503
- this.planFileSnapshot.set(fullPath, stat4.mtimeMs);
1634
+ const stat6 = statSync(fullPath);
1635
+ this.planFileSnapshot.set(fullPath, stat6.mtimeMs);
1504
1636
  } catch {
1505
1637
  continue;
1506
1638
  }
@@ -1521,11 +1653,11 @@ var PlanSync = class {
1521
1653
  for (const file of files) {
1522
1654
  const fullPath = join2(plansDir, file);
1523
1655
  try {
1524
- const stat4 = statSync(fullPath);
1656
+ const stat6 = statSync(fullPath);
1525
1657
  const prevMtime = this.planFileSnapshot.get(fullPath);
1526
- const isNew = prevMtime === void 0 || stat4.mtimeMs > prevMtime;
1527
- if (isNew && (!newest || stat4.mtimeMs > newest.mtime)) {
1528
- newest = { path: fullPath, mtime: stat4.mtimeMs };
1658
+ const isNew = prevMtime === void 0 || stat6.mtimeMs > prevMtime;
1659
+ if (isNew && (!newest || stat6.mtimeMs > newest.mtime)) {
1660
+ newest = { path: fullPath, mtime: stat6.mtimeMs };
1529
1661
  }
1530
1662
  } catch {
1531
1663
  continue;
@@ -1566,6 +1698,511 @@ var PlanSync = class {
1566
1698
  }
1567
1699
  };
1568
1700
 
1701
+ // src/setup/git-ready.ts
1702
+ import { access, stat } from "fs/promises";
1703
+ var DEFAULT_FAILED_PATH = "/workspaces/.conveyor-git-failed";
1704
+ var DEFAULT_TIMEOUT_MS = 6e5;
1705
+ var DEFAULT_POLL_MS = 200;
1706
+ async function fileExists(path4) {
1707
+ try {
1708
+ await access(path4);
1709
+ const s = await stat(path4);
1710
+ return s.isFile();
1711
+ } catch {
1712
+ return false;
1713
+ }
1714
+ }
1715
+ function awaitGitReady(opts = {}) {
1716
+ const markerPath = opts.markerPath ?? process.env.CONVEYOR_GIT_READY_MARKER;
1717
+ if (!markerPath) {
1718
+ return Promise.resolve("not-gated");
1719
+ }
1720
+ const failedPath = opts.failedPath ?? process.env.CONVEYOR_GIT_FAILED_MARKER ?? DEFAULT_FAILED_PATH;
1721
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1722
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1723
+ opts.onLog?.(`waiting for workspace git (marker=${markerPath})`);
1724
+ return pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, opts.onLog);
1725
+ }
1726
+ async function pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, onLog) {
1727
+ const deadline = Date.now() + timeoutMs;
1728
+ for (; ; ) {
1729
+ if (await fileExists(markerPath)) {
1730
+ onLog?.("workspace git ready");
1731
+ return "ready";
1732
+ }
1733
+ if (await fileExists(failedPath)) {
1734
+ onLog?.("workspace git preparation failed (marker present)");
1735
+ return "failed";
1736
+ }
1737
+ if (Date.now() >= deadline) {
1738
+ onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
1739
+ return "timeout";
1740
+ }
1741
+ await new Promise((resolve) => {
1742
+ setTimeout(resolve, pollMs);
1743
+ });
1744
+ }
1745
+ }
1746
+
1747
+ // src/runner/work-preservation/index.ts
1748
+ import { rm as rm3 } from "fs/promises";
1749
+
1750
+ // src/runner/work-preservation/pg-dump.ts
1751
+ import { execSync as execSync2 } from "child_process";
1752
+ import { randomUUID } from "crypto";
1753
+ import { tmpdir } from "os";
1754
+ import path from "path";
1755
+ var PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
1756
+ function connectionDefaults(opts) {
1757
+ return {
1758
+ host: opts.host ?? "localhost",
1759
+ port: opts.port ?? 5432,
1760
+ db: opts.db ?? "conveyor"
1761
+ };
1762
+ }
1763
+ function buildPgDumpCommand(opts) {
1764
+ const { host, port, db } = connectionDefaults(opts);
1765
+ return `pg_dump -h ${host} -p ${port} -U postgres ${db} -f ${JSON.stringify(
1766
+ opts.outputPath
1767
+ )} --no-owner --no-privileges`;
1768
+ }
1769
+ function buildPsqlRestoreCommand(opts) {
1770
+ const { host, port, db } = connectionDefaults(opts);
1771
+ return `psql -h ${host} -p ${port} -U postgres -d ${db} -f ${JSON.stringify(opts.dumpPath)}`;
1772
+ }
1773
+ function hasPostgresDep() {
1774
+ return (process.env.CONVEYOR_DEPS ?? "").split(",").map((dep) => dep.trim()).includes("postgresql");
1775
+ }
1776
+ function execErrorDetail(err) {
1777
+ const withStreams = err;
1778
+ const stderr = withStreams.stderr instanceof Buffer ? withStreams.stderr.toString("utf8").trim() : typeof withStreams.stderr === "string" ? withStreams.stderr.trim() : "";
1779
+ const message = withStreams.message ?? String(err);
1780
+ return stderr ? `${message} \u2014 stderr: ${stderr}` : message;
1781
+ }
1782
+ function dumpPostgresIfPresent(exec = execSync2) {
1783
+ if (!hasPostgresDep()) return Promise.resolve({ dumped: false });
1784
+ const outputPath = path.join(tmpdir(), `conveyor-pgdump-${randomUUID()}.sql`);
1785
+ try {
1786
+ exec(buildPgDumpCommand({ outputPath }), {
1787
+ stdio: ["ignore", "pipe", "pipe"],
1788
+ timeout: 6e4
1789
+ });
1790
+ return Promise.resolve({ dumped: true, path: outputPath });
1791
+ } catch (err) {
1792
+ process.stderr.write(`[conveyor-agent] pg_dump failed: ${execErrorDetail(err)}
1793
+ `);
1794
+ return Promise.resolve({ dumped: false });
1795
+ }
1796
+ }
1797
+ function applyPgDumpIfConfigured(dumpPath, exec = execSync2) {
1798
+ if (!hasPostgresDep()) return false;
1799
+ try {
1800
+ exec(buildPsqlRestoreCommand({ dumpPath }), {
1801
+ stdio: ["ignore", "pipe", "pipe"],
1802
+ timeout: 12e4
1803
+ });
1804
+ process.stderr.write("[conveyor-agent] Restored postgres state from snapshot pg dump\n");
1805
+ return true;
1806
+ } catch (err) {
1807
+ process.stderr.write(`[conveyor-agent] psql dump restore failed: ${execErrorDetail(err)}
1808
+ `);
1809
+ return false;
1810
+ }
1811
+ }
1812
+
1813
+ // src/runner/work-preservation/restore-precedence.ts
1814
+ function selectRestoreSource(probe) {
1815
+ if (probe.gcsAvailable) return "gcs";
1816
+ if (probe.wipRestoreResult === "applied") return "wip";
1817
+ return "clean";
1818
+ }
1819
+
1820
+ // src/runner/work-preservation/snapshot-tar.ts
1821
+ import { execSync as execSync3 } from "child_process";
1822
+ import { createReadStream, createWriteStream, mkdtempSync } from "fs";
1823
+ import { copyFile, mkdtemp, rm, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1824
+ import { tmpdir as tmpdir2 } from "os";
1825
+ import path2 from "path";
1826
+ import { pipeline } from "stream/promises";
1827
+ import { createGzip } from "zlib";
1828
+ import * as tar from "tar";
1829
+
1830
+ // src/runner/work-preservation/snapshot-artifact.ts
1831
+ function planSnapshotFiles(git) {
1832
+ const include = git.trackedAndUntracked();
1833
+ const includeSet = new Set(include);
1834
+ const deletions = [...new Set(git.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
1835
+ return { include, deletions };
1836
+ }
1837
+
1838
+ // src/runner/work-preservation/snapshot-tar.ts
1839
+ var SNAPSHOT_MANIFEST_NAME = ".conveyor-snapshot-manifest.json";
1840
+ var STATUS_MAX_BUFFER = 64 * 1024 * 1024;
1841
+ function parseStatusPorcelainZ(raw) {
1842
+ const includes = [];
1843
+ const deletions = [];
1844
+ const tokens = raw.split("\0");
1845
+ for (let i = 0; i < tokens.length; i++) {
1846
+ const token = tokens[i];
1847
+ if (!token || token.length < 4) continue;
1848
+ const x = token[0];
1849
+ const y = token[1];
1850
+ const filePath = token.slice(3);
1851
+ if (x === "R" || x === "C") {
1852
+ const origPath = tokens[++i];
1853
+ if (x === "R" && origPath) deletions.push(origPath);
1854
+ }
1855
+ if (x === "!") continue;
1856
+ const missingFromWorktree = y === "D" || x === "D" && y === " ";
1857
+ if (missingFromWorktree) deletions.push(filePath);
1858
+ else includes.push(filePath);
1859
+ }
1860
+ return { includes, deletions };
1861
+ }
1862
+ function realGitSurface(cwd) {
1863
+ let cached = null;
1864
+ const load = () => {
1865
+ if (!cached) {
1866
+ const out = execSync3("git status --porcelain=v1 -z -uall", {
1867
+ cwd,
1868
+ stdio: ["ignore", "pipe", "ignore"],
1869
+ maxBuffer: STATUS_MAX_BUFFER
1870
+ }).toString("utf8");
1871
+ cached = parseStatusPorcelainZ(out);
1872
+ }
1873
+ return cached;
1874
+ };
1875
+ return {
1876
+ trackedAndUntracked: () => load().includes,
1877
+ deletedSinceHead: () => load().deletions
1878
+ };
1879
+ }
1880
+ function gitHead(cwd) {
1881
+ try {
1882
+ return execSync3("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
1883
+ } catch {
1884
+ return null;
1885
+ }
1886
+ }
1887
+ async function buildSnapshotTar(cwd, opts) {
1888
+ const staging = await mkdtemp(path2.join(tmpdir2(), "conveyor-snapshot-"));
1889
+ const cleanup = async () => {
1890
+ await rm(staging, { recursive: true, force: true }).catch(() => {
1891
+ });
1892
+ };
1893
+ try {
1894
+ const plan = planSnapshotFiles(realGitSurface(cwd));
1895
+ const head = gitHead(cwd);
1896
+ if (!head) throw new Error("cannot snapshot a repo without a resolvable HEAD");
1897
+ const capturedAt = Date.now();
1898
+ const manifest = {
1899
+ head,
1900
+ deletions: plan.deletions,
1901
+ capturedAt,
1902
+ pgDump: Boolean(opts?.pgDumpPath)
1903
+ };
1904
+ await writeFile2(path2.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
1905
+ const stagedEntries = [SNAPSHOT_MANIFEST_NAME];
1906
+ if (opts?.pgDumpPath) {
1907
+ await copyFile(opts.pgDumpPath, path2.join(staging, PG_DUMP_FILENAME));
1908
+ stagedEntries.push(PG_DUMP_FILENAME);
1909
+ }
1910
+ const rawTarPath = path2.join(staging, "snapshot.tar");
1911
+ await tar.create({ cwd: staging, file: rawTarPath, portable: true }, stagedEntries);
1912
+ if (plan.include.length > 0) {
1913
+ await tar.replace({ file: rawTarPath, cwd, portable: true }, plan.include);
1914
+ }
1915
+ const tarPath = path2.join(staging, "snapshot.tar.gz");
1916
+ await pipeline(createReadStream(rawTarPath), createGzip(), createWriteStream(tarPath));
1917
+ await rm(rawTarPath, { force: true });
1918
+ const { size } = await stat2(tarPath);
1919
+ return {
1920
+ tarPath,
1921
+ sizeBytes: size,
1922
+ fileCount: plan.include.length,
1923
+ deletionCount: plan.deletions.length,
1924
+ capturedAt,
1925
+ cleanup
1926
+ };
1927
+ } catch (err) {
1928
+ await cleanup();
1929
+ throw err;
1930
+ }
1931
+ }
1932
+ function isWithinWorkspace(cwd, rel) {
1933
+ if (!rel || path2.isAbsolute(rel)) return false;
1934
+ const root = path2.resolve(cwd);
1935
+ const abs = path2.resolve(root, rel);
1936
+ return abs !== root && abs.startsWith(root + path2.sep);
1937
+ }
1938
+ async function readSnapshotArchive(tarPath) {
1939
+ let manifestRaw = null;
1940
+ let pgDumpDir = null;
1941
+ let pgDumpPath;
1942
+ let pgDumpWritten = Promise.resolve();
1943
+ const cleanup = async () => {
1944
+ if (pgDumpDir) await rm(pgDumpDir, { recursive: true, force: true }).catch(() => {
1945
+ });
1946
+ };
1947
+ try {
1948
+ await tar.list({
1949
+ file: tarPath,
1950
+ onReadEntry: (entry) => {
1951
+ if (entry.path === SNAPSHOT_MANIFEST_NAME) {
1952
+ const chunks = [];
1953
+ entry.on("data", (chunk) => chunks.push(chunk));
1954
+ entry.on("end", () => {
1955
+ manifestRaw = Buffer.concat(chunks);
1956
+ });
1957
+ } else if (entry.path === PG_DUMP_FILENAME) {
1958
+ pgDumpDir = mkdtempSync(path2.join(tmpdir2(), "conveyor-pgdump-restore-"));
1959
+ pgDumpPath = path2.join(pgDumpDir, "dump.sql");
1960
+ const dest = createWriteStream(pgDumpPath);
1961
+ pgDumpWritten = new Promise((resolve, reject) => {
1962
+ dest.on("finish", () => resolve());
1963
+ dest.on("error", reject);
1964
+ });
1965
+ entry.pipe(dest);
1966
+ }
1967
+ }
1968
+ });
1969
+ await pgDumpWritten;
1970
+ } catch {
1971
+ await cleanup();
1972
+ return null;
1973
+ }
1974
+ if (!manifestRaw) {
1975
+ await cleanup();
1976
+ return null;
1977
+ }
1978
+ try {
1979
+ const parsed = JSON.parse(manifestRaw.toString("utf8"));
1980
+ if (typeof parsed.head !== "string" || !parsed.head) {
1981
+ await cleanup();
1982
+ return null;
1983
+ }
1984
+ return {
1985
+ manifest: {
1986
+ head: parsed.head,
1987
+ deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
1988
+ capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0,
1989
+ pgDump: parsed.pgDump === true
1990
+ },
1991
+ ...pgDumpPath ? { pgDumpPath } : {},
1992
+ cleanup
1993
+ };
1994
+ } catch {
1995
+ await cleanup();
1996
+ return null;
1997
+ }
1998
+ }
1999
+ async function extractSnapshotTar(cwd, tarPath, opts) {
2000
+ const read = await readSnapshotArchive(tarPath);
2001
+ if (!read) return { status: "invalid" };
2002
+ try {
2003
+ const { manifest, pgDumpPath } = read;
2004
+ const currentHead = gitHead(cwd);
2005
+ if (manifest.head !== currentHead) {
2006
+ return { status: "stale-head", snapshotHead: manifest.head, currentHead };
2007
+ }
2008
+ let filesExtracted = 0;
2009
+ await tar.extract({
2010
+ file: tarPath,
2011
+ cwd,
2012
+ filter: (entryPath) => {
2013
+ if (entryPath === SNAPSHOT_MANIFEST_NAME || entryPath === PG_DUMP_FILENAME) return false;
2014
+ filesExtracted++;
2015
+ return true;
2016
+ }
2017
+ });
2018
+ let deletionsApplied = 0;
2019
+ for (const rel of manifest.deletions) {
2020
+ if (typeof rel !== "string" || !isWithinWorkspace(cwd, rel)) continue;
2021
+ await rm(path2.resolve(cwd, rel), { recursive: true, force: true });
2022
+ deletionsApplied++;
2023
+ }
2024
+ const pgDumpApplied = manifest.pgDump && pgDumpPath ? applyPgDumpIfConfigured(pgDumpPath, opts?.pgExec) : false;
2025
+ return { status: "extracted", filesExtracted, deletionsApplied, pgDumpApplied };
2026
+ } finally {
2027
+ await read.cleanup();
2028
+ }
2029
+ }
2030
+
2031
+ // src/runner/work-preservation/snapshot-transfer.ts
2032
+ import { randomUUID as randomUUID2 } from "crypto";
2033
+ import { createReadStream as createReadStream2, createWriteStream as createWriteStream2 } from "fs";
2034
+ import { rm as rm2 } from "fs/promises";
2035
+ import { tmpdir as tmpdir3 } from "os";
2036
+ import path3 from "path";
2037
+ import { Readable } from "stream";
2038
+ import { pipeline as pipeline2 } from "stream/promises";
2039
+ var SNAPSHOT_HTTP_TIMEOUT_MS = 6e4;
2040
+ var SNAPSHOT_CAPTURED_AT_HEADER = "x-snapshot-captured-at";
2041
+ async function putSnapshotToGcs(url, tarResult) {
2042
+ try {
2043
+ const body = Readable.toWeb(createReadStream2(tarResult.tarPath));
2044
+ const res = await fetch(url, {
2045
+ method: "PUT",
2046
+ headers: {
2047
+ "Content-Type": "application/gzip",
2048
+ "Content-Length": String(tarResult.sizeBytes),
2049
+ [SNAPSHOT_CAPTURED_AT_HEADER]: String(tarResult.capturedAt)
2050
+ },
2051
+ body,
2052
+ duplex: "half",
2053
+ signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
2054
+ });
2055
+ return res.ok;
2056
+ } catch {
2057
+ return false;
2058
+ }
2059
+ }
2060
+ async function downloadSnapshotToTemp(url) {
2061
+ const tmpTar = path3.join(tmpdir3(), `conveyor-snapshot-restore-${randomUUID2()}.tar.gz`);
2062
+ try {
2063
+ const res = await fetch(url, {
2064
+ method: "GET",
2065
+ signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
2066
+ });
2067
+ if (!res.ok || !res.body) return null;
2068
+ await pipeline2(
2069
+ Readable.fromWeb(res.body),
2070
+ createWriteStream2(tmpTar)
2071
+ );
2072
+ return tmpTar;
2073
+ } catch {
2074
+ await rm2(tmpTar, { force: true }).catch(() => {
2075
+ });
2076
+ return null;
2077
+ }
2078
+ }
2079
+
2080
+ // src/runner/work-preservation/index.ts
2081
+ var periodicTimer = null;
2082
+ var inFlightCapture = null;
2083
+ var inFlightGcsUpload = null;
2084
+ var EMPTY_GCS_RESULT = {
2085
+ uploaded: false,
2086
+ fileCount: 0,
2087
+ deletionCount: 0,
2088
+ sizeBytes: 0,
2089
+ pgDumped: false
2090
+ };
2091
+ async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
2092
+ let tarResult;
2093
+ try {
2094
+ tarResult = await buildSnapshotTar(cwd, { pgDumpPath: opts?.pgDumpPath });
2095
+ } catch {
2096
+ return EMPTY_GCS_RESULT;
2097
+ }
2098
+ try {
2099
+ const uploaded = (opts?.forceUpload || tarResult.sizeBytes > 0) && uploadUrl ? await putSnapshotToGcs(uploadUrl, tarResult) : false;
2100
+ return {
2101
+ uploaded,
2102
+ fileCount: tarResult.fileCount,
2103
+ deletionCount: tarResult.deletionCount,
2104
+ sizeBytes: tarResult.sizeBytes,
2105
+ pgDumped: Boolean(opts?.pgDumpPath)
2106
+ };
2107
+ } finally {
2108
+ await tarResult.cleanup();
2109
+ }
2110
+ }
2111
+ async function uploadSnapshotToGcs(cwd, uploadUrl, opts) {
2112
+ const previous = inFlightGcsUpload;
2113
+ const run = (async () => {
2114
+ if (previous) await previous.catch(() => {
2115
+ });
2116
+ return doUploadSnapshotToGcs(cwd, uploadUrl, opts);
2117
+ })();
2118
+ inFlightGcsUpload = run;
2119
+ try {
2120
+ return await run;
2121
+ } finally {
2122
+ if (inFlightGcsUpload === run) inFlightGcsUpload = null;
2123
+ }
2124
+ }
2125
+ async function captureSnapshot(ctx) {
2126
+ const gcs = await uploadSnapshotToGcs(ctx.cwd, ctx.snapshotUploadUrl, {
2127
+ pgDumpPath: ctx.pgDumpPath,
2128
+ forceUpload: ctx.forceUpload
2129
+ });
2130
+ const wipResult = await flushPendingChanges(ctx.cwd, {
2131
+ wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation periodic snapshot",
2132
+ refreshToken: ctx.refreshToken
2133
+ });
2134
+ return {
2135
+ fileCount: gcs.fileCount,
2136
+ deletionCount: gcs.deletionCount,
2137
+ sizeBytes: gcs.sizeBytes,
2138
+ gcsUploaded: gcs.uploaded,
2139
+ wipPushed: wipResult.committed || wipResult.pushed,
2140
+ pgDumped: gcs.pgDumped
2141
+ };
2142
+ }
2143
+ function startPeriodic(intervalMs, ctx) {
2144
+ stop();
2145
+ periodicTimer = setInterval(() => {
2146
+ if (inFlightCapture) return;
2147
+ const run = captureSnapshot(ctx);
2148
+ inFlightCapture = run;
2149
+ void run.catch(() => {
2150
+ }).finally(() => {
2151
+ if (inFlightCapture === run) inFlightCapture = null;
2152
+ });
2153
+ }, intervalMs);
2154
+ }
2155
+ function stop() {
2156
+ if (periodicTimer) {
2157
+ clearInterval(periodicTimer);
2158
+ periodicTimer = null;
2159
+ }
2160
+ }
2161
+ async function finalizeForSleep(ctx) {
2162
+ stop();
2163
+ if (inFlightCapture) await inFlightCapture.catch(() => {
2164
+ });
2165
+ const dump = await dumpPostgresIfPresent();
2166
+ try {
2167
+ const artifact = await captureSnapshot({
2168
+ ...ctx,
2169
+ pgDumpPath: dump.path,
2170
+ wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation sleep finalize snapshot",
2171
+ // Always stamp snapshotAt on sleep — even an empty workspace must confirm
2172
+ // the finalize so teardown doesn't wait out the reconciler's cap.
2173
+ forceUpload: true
2174
+ });
2175
+ return { ...artifact, pgDumped: dump.dumped };
2176
+ } finally {
2177
+ if (dump.path) await rm3(dump.path, { force: true }).catch(() => {
2178
+ });
2179
+ }
2180
+ }
2181
+ async function restoreOnBoot(bundle, cwd) {
2182
+ let gcsAvailable = false;
2183
+ let gcsFileCount;
2184
+ if (bundle.snapshotUrl) {
2185
+ const tmpTar = await downloadSnapshotToTemp(bundle.snapshotUrl);
2186
+ if (tmpTar) {
2187
+ try {
2188
+ const result = await extractSnapshotTar(cwd, tmpTar);
2189
+ if (result.status === "extracted") {
2190
+ gcsAvailable = true;
2191
+ gcsFileCount = result.filesExtracted;
2192
+ }
2193
+ } catch {
2194
+ gcsAvailable = false;
2195
+ } finally {
2196
+ await rm3(tmpTar, { force: true }).catch(() => {
2197
+ });
2198
+ }
2199
+ }
2200
+ }
2201
+ const wipRestoreResult = gcsAvailable ? "none" : restoreWipSnapshot(cwd, bundle.gitPlan.branch);
2202
+ const source = selectRestoreSource({ gcsAvailable, wipRestoreResult });
2203
+ return source === "gcs" ? { source, fileCount: gcsFileCount } : { source };
2204
+ }
2205
+
1569
2206
  // ../shared/dist/index.js
1570
2207
  import { z } from "zod";
1571
2208
  import { z as z2 } from "zod";
@@ -1706,6 +2343,16 @@ var NotifyAgentVersionRequestSchema = z.object({
1706
2343
  sessionId: z.string(),
1707
2344
  agentVersion: z.string()
1708
2345
  });
2346
+ var DiscoveredPortSchema = z.object({
2347
+ port: z.number().int().min(1).max(65535),
2348
+ label: z.string().min(1).max(64).optional(),
2349
+ protocol: z.enum(["http", "tcp"]).optional(),
2350
+ detectedAt: z.string()
2351
+ });
2352
+ var ReportDiscoveredPortsRequestSchema = z.object({
2353
+ sessionId: z.string(),
2354
+ ports: z.array(DiscoveredPortSchema).max(64)
2355
+ });
1709
2356
  var CreateSubtaskRequestSchema = z.object({
1710
2357
  sessionId: z.string(),
1711
2358
  title: z.string().min(1),
@@ -2039,6 +2686,15 @@ var StopProjectBuildRequestSchema = z2.object({
2039
2686
  taskId: z2.string(),
2040
2687
  requestingUserId: z2.string().optional()
2041
2688
  });
2689
+ var StartProjectWorkspaceRequestSchema = z2.object({
2690
+ projectId: z2.string(),
2691
+ requestingUserId: z2.string().optional()
2692
+ });
2693
+ var StopProjectWorkspaceRequestSchema = z2.object({
2694
+ projectId: z2.string(),
2695
+ destroy: z2.boolean().optional(),
2696
+ requestingUserId: z2.string().optional()
2697
+ });
2042
2698
  var CreateProjectReleaseRequestSchema = z2.object({
2043
2699
  projectId: z2.string(),
2044
2700
  taskIds: z2.array(z2.string()).optional(),
@@ -2515,8 +3171,8 @@ var ClaudeCodeHarness = class {
2515
3171
  };
2516
3172
 
2517
3173
  // src/harness/pty/session.ts
2518
- import { mkdtemp, mkdir as mkdir2, rm, stat } from "fs/promises";
2519
- import { tmpdir } from "os";
3174
+ import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4, stat as stat3 } from "fs/promises";
3175
+ import { tmpdir as tmpdir4 } from "os";
2520
3176
  import { join as join4, dirname } from "path";
2521
3177
 
2522
3178
  // src/harness/pty/event-queue.ts
@@ -2785,8 +3441,8 @@ function mapTranscriptRecord(raw) {
2785
3441
  // src/harness/pty/jsonl-tailer.ts
2786
3442
  var POLL_INTERVAL_MS = 25;
2787
3443
  var JsonlTailer = class {
2788
- constructor(path, onEvent) {
2789
- this.path = path;
3444
+ constructor(path4, onEvent) {
3445
+ this.path = path4;
2790
3446
  this.onEvent = onEvent;
2791
3447
  }
2792
3448
  path;
@@ -2983,7 +3639,7 @@ var PtyOutputCoalescer = class {
2983
3639
 
2984
3640
  // src/harness/pty/tool-server.ts
2985
3641
  import { createServer as createServer2 } from "http";
2986
- import { writeFile as writeFile2 } from "fs/promises";
3642
+ import { writeFile as writeFile3 } from "fs/promises";
2987
3643
  import { join as join3 } from "path";
2988
3644
  import { randomBytes } from "crypto";
2989
3645
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -3090,7 +3746,7 @@ async function startToolServers(mcpServers, tempDir) {
3090
3746
  }
3091
3747
  if (Object.keys(config).length === 0) return { servers, mcpConfigPath: null };
3092
3748
  const mcpConfigPath = join3(tempDir, "mcp-config.json");
3093
- await writeFile2(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
3749
+ await writeFile3(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
3094
3750
  return { servers, mcpConfigPath };
3095
3751
  }
3096
3752
 
@@ -3128,14 +3784,14 @@ var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
3128
3784
  var SUBMIT_NUDGE_MAX_PRESSES = 5;
3129
3785
  var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
3130
3786
  var SUBMIT_NUDGE_WINDOW_MS = 9e4;
3131
- function sleep2(ms) {
3787
+ function sleep3(ms) {
3132
3788
  return new Promise((resolve) => {
3133
3789
  setTimeout(resolve, ms);
3134
3790
  });
3135
3791
  }
3136
- async function transcriptSize(path) {
3792
+ async function transcriptSize(path4) {
3137
3793
  try {
3138
- return (await stat(path)).size;
3794
+ return (await stat3(path4)).size;
3139
3795
  } catch {
3140
3796
  return 0;
3141
3797
  }
@@ -3225,7 +3881,7 @@ var PtySession = class {
3225
3881
  return;
3226
3882
  }
3227
3883
  try {
3228
- this.tempDir = await mkdtemp(join4(tmpdir(), "conveyor-pty-"));
3884
+ this.tempDir = await mkdtemp2(join4(tmpdir4(), "conveyor-pty-"));
3229
3885
  const socketPath = join4(this.tempDir, "hook.sock");
3230
3886
  this.socket = new HookSocketServer(
3231
3887
  socketPath,
@@ -3274,6 +3930,21 @@ var PtySession = class {
3274
3930
  } catch {
3275
3931
  }
3276
3932
  }
3933
+ /**
3934
+ * Force the CLI to repaint its full screen by wiggling the pty size
3935
+ * (SIGWINCH). Used after a socket reconnect: the server's scrollback ring is
3936
+ * per-process and starts empty on a new instance, so without a repaint a
3937
+ * quiet TUI would leave the Connected-TUI terminal blank (and hidden) until
3938
+ * the CLI next draws on its own.
3939
+ */
3940
+ forceRepaint() {
3941
+ if (!this.pty) return;
3942
+ try {
3943
+ this.pty.resize(this.cols, Math.max(1, this.rows - 1));
3944
+ this.pty.resize(this.cols, this.rows);
3945
+ } catch {
3946
+ }
3947
+ }
3277
3948
  async teardown() {
3278
3949
  if (this._toreDown) return;
3279
3950
  this._toreDown = true;
@@ -3310,7 +3981,7 @@ var PtySession = class {
3310
3981
  this.mcpConfigPath = null;
3311
3982
  this.queue.close();
3312
3983
  if (this.tempDir) {
3313
- await rm(this.tempDir, { recursive: true, force: true });
3984
+ await rm4(this.tempDir, { recursive: true, force: true });
3314
3985
  this.tempDir = "";
3315
3986
  }
3316
3987
  }
@@ -3370,7 +4041,7 @@ var PtySession = class {
3370
4041
  async deliverPrompt(text) {
3371
4042
  this.writeStdin(buildPromptBytes(text));
3372
4043
  if (this.options.promptDelivery === "prefill") return;
3373
- await sleep2(SUBMIT_SETTLE_MS);
4044
+ await sleep3(SUBMIT_SETTLE_MS);
3374
4045
  if (this._toreDown) return;
3375
4046
  this.writeStdin("\r");
3376
4047
  this.armSubmitNudge();
@@ -3518,7 +4189,7 @@ var PtySession = class {
3518
4189
  };
3519
4190
 
3520
4191
  // src/harness/pty/credentials.ts
3521
- import { chmod as chmod2, mkdir as mkdir3, readFile, rm as rm2, writeFile as writeFile3 } from "fs/promises";
4192
+ import { chmod as chmod2, mkdir as mkdir3, readFile, rm as rm5, writeFile as writeFile4 } from "fs/promises";
3522
4193
  import { homedir as homedir2 } from "os";
3523
4194
  import { join as join5 } from "path";
3524
4195
  var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
@@ -3569,26 +4240,26 @@ function planCredentialsWrite(input) {
3569
4240
  if (fresh) return { action: "skip", reason: "current" };
3570
4241
  return { action: "write", contents };
3571
4242
  }
3572
- async function readRaw(path) {
4243
+ async function readRaw(path4) {
3573
4244
  try {
3574
- return await readFile(path, "utf8");
4245
+ return await readFile(path4, "utf8");
3575
4246
  } catch {
3576
4247
  return null;
3577
4248
  }
3578
4249
  }
3579
4250
  async function ensureClaudeCredentials(env = process.env) {
3580
4251
  try {
3581
- const path = claudeCredentialsPath();
4252
+ const path4 = claudeCredentialsPath();
3582
4253
  const plan = planCredentialsWrite({
3583
4254
  isCloud: isConveyorCloudEnv(env),
3584
4255
  token: env.CLAUDE_CODE_OAUTH_TOKEN,
3585
- existingRaw: await readRaw(path),
4256
+ existingRaw: await readRaw(path4),
3586
4257
  now: Date.now()
3587
4258
  });
3588
4259
  if (plan.action === "skip") return;
3589
4260
  await mkdir3(claudeConfigHome(), { recursive: true });
3590
- await writeFile3(path, plan.contents, { encoding: "utf8", mode: 384 });
3591
- await chmod2(path, 384).catch(() => {
4261
+ await writeFile4(path4, plan.contents, { encoding: "utf8", mode: 384 });
4262
+ await chmod2(path4, 384).catch(() => {
3592
4263
  });
3593
4264
  } catch (err) {
3594
4265
  const message = err instanceof Error ? err.message : String(err);
@@ -3644,11 +4315,11 @@ function planClaudeJsonSeed(existingRaw, trustCwd) {
3644
4315
  async function ensureClaudeOnboarding(env = process.env, trustCwd) {
3645
4316
  try {
3646
4317
  if (!isConveyorCloudEnv(env)) return;
3647
- const path = claudeJsonPath();
3648
- const contents = planClaudeJsonSeed(await readRaw(path), trustCwd);
4318
+ const path4 = claudeJsonPath();
4319
+ const contents = planClaudeJsonSeed(await readRaw(path4), trustCwd);
3649
4320
  if (contents === null) return;
3650
- await writeFile3(path, contents, "utf8");
3651
- const verify = await readRaw(path);
4321
+ await writeFile4(path4, contents, "utf8");
4322
+ const verify = await readRaw(path4);
3652
4323
  if (verify === contents) {
3653
4324
  process.stderr.write(
3654
4325
  `[conveyor-agent] claude onboarding seeded${trustCwd ? ` (trust: ${trustCwd})` : ""}
@@ -3656,7 +4327,7 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
3656
4327
  );
3657
4328
  } else {
3658
4329
  process.stderr.write(
3659
- `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path}: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
4330
+ `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path4}: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
3660
4331
  `
3661
4332
  );
3662
4333
  }
@@ -3669,12 +4340,12 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
3669
4340
  async function removeConveyorCredentials(env = process.env) {
3670
4341
  try {
3671
4342
  if (!isConveyorCloudEnv(env)) return;
3672
- const path = claudeCredentialsPath();
3673
- const existing = parseClaudeAiOauth(await readRaw(path));
4343
+ const path4 = claudeCredentialsPath();
4344
+ const existing = parseClaudeAiOauth(await readRaw(path4));
3674
4345
  if (existing && typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
3675
4346
  return;
3676
4347
  }
3677
- await rm2(path, { force: true });
4348
+ await rm5(path4, { force: true });
3678
4349
  } catch (err) {
3679
4350
  const message = err instanceof Error ? err.message : String(err);
3680
4351
  process.stderr.write(`[conveyor-agent] claude credentials removal failed: ${message}
@@ -3693,6 +4364,17 @@ var PtyHarness = class {
3693
4364
  this.bridge = bridge;
3694
4365
  }
3695
4366
  bridge;
4367
+ /** The session currently streaming events, if any — repaint target. */
4368
+ activeSession = null;
4369
+ /**
4370
+ * Wiggle the live pty's size so the CLI repaints its whole screen. No-op
4371
+ * between queries or on the SDK harness. Called after a socket reconnect so
4372
+ * the server's (per-process, possibly brand-new) scrollback ring re-seeds
4373
+ * and the Connected-TUI terminal reappears.
4374
+ */
4375
+ forceRepaint() {
4376
+ this.activeSession?.forceRepaint();
4377
+ }
3696
4378
  async *executeQuery(opts) {
3697
4379
  const session = new PtySession(
3698
4380
  opts.prompt,
@@ -3703,11 +4385,13 @@ var PtyHarness = class {
3703
4385
  await ensureClaudeCredentials();
3704
4386
  await ensureClaudeOnboarding(process.env, opts.options.cwd);
3705
4387
  await session.start();
4388
+ this.activeSession = session;
3706
4389
  try {
3707
4390
  for await (const event of session.events()) {
3708
4391
  yield event;
3709
4392
  }
3710
4393
  } finally {
4394
+ if (this.activeSession === session) this.activeSession = null;
3711
4395
  await session.teardown();
3712
4396
  }
3713
4397
  }
@@ -3983,7 +4667,7 @@ function formatIncidents(incidents) {
3983
4667
  }
3984
4668
 
3985
4669
  // src/execution/tag-context-resolver.ts
3986
- import { readFile as readFile2, readdir, stat as stat2 } from "fs/promises";
4670
+ import { readFile as readFile2, readdir, stat as stat4 } from "fs/promises";
3987
4671
  var TYPE_PRIORITY = { rule: 0, file: 1, folder: 2, doc: 3 };
3988
4672
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
3989
4673
  ".png",
@@ -4033,7 +4717,7 @@ async function readFileContent(filePath, maxChars) {
4033
4717
  let rawContent;
4034
4718
  let mtimeMs;
4035
4719
  try {
4036
- const st = await stat2(filePath);
4720
+ const st = await stat4(filePath);
4037
4721
  mtimeMs = st.mtimeMs;
4038
4722
  } catch {
4039
4723
  return null;
@@ -4060,7 +4744,7 @@ async function readFolderListing(folderPath) {
4060
4744
  try {
4061
4745
  let mtimeMs;
4062
4746
  try {
4063
- const st = await stat2(folderPath);
4747
+ const st = await stat4(folderPath);
4064
4748
  mtimeMs = st.mtimeMs;
4065
4749
  } catch {
4066
4750
  return null;
@@ -5062,7 +5746,7 @@ var cliEventFormatters = {
5062
5746
  tool_result: (e) => `${e.tool} \u2192 ${e.output?.slice(0, 500) ?? ""}${e.isError ? " [ERROR]" : ""}`,
5063
5747
  message: (e) => e.content ?? "",
5064
5748
  error: (e) => `ERROR: ${e.message ?? ""}`,
5065
- completed: (e) => `Completed: ${e.summary ?? ""} (cost: $${e.costUsd ?? "?"}, duration: ${e.durationMs ?? "?"}ms)`,
5749
+ completed: (e) => `Completed: ${e.summary ?? ""} (duration: ${e.durationMs ?? "?"}ms)`,
5066
5750
  setup_output: (e) => `[${e.stream ?? "stdout"}] ${e.data ?? ""}`,
5067
5751
  start_command_output: (e) => `[${e.stream ?? "stdout"}] ${e.data ?? ""}`,
5068
5752
  turn_end: (e) => `Turn complete (${e.toolCalls?.length ?? 0} tool calls)`
@@ -5608,7 +6292,7 @@ function buildMutationTools(connection, config) {
5608
6292
  }
5609
6293
 
5610
6294
  // src/tools/attachment-tools.ts
5611
- import { readFile as readFile3, stat as stat3 } from "fs/promises";
6295
+ import { readFile as readFile3, stat as stat5 } from "fs/promises";
5612
6296
  import { basename, extname, isAbsolute, join as join6 } from "path";
5613
6297
  import { z as z6 } from "zod";
5614
6298
  var IMAGE_MIME_BY_EXT = {
@@ -5626,16 +6310,16 @@ function buildUploadAttachmentTool(connection, config) {
5626
6310
  path: z6.string().describe("Path to the image file \u2014 absolute, or relative to the workspace root"),
5627
6311
  title: z6.string().optional().describe("Short caption posted with the image (defaults to the file name)")
5628
6312
  },
5629
- async ({ path, title }) => {
6313
+ async ({ path: path4, title }) => {
5630
6314
  try {
5631
- const filePath = isAbsolute(path) ? path : join6(config.workspaceDir, path);
6315
+ const filePath = isAbsolute(path4) ? path4 : join6(config.workspaceDir, path4);
5632
6316
  const mimeType = IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()];
5633
6317
  if (!mimeType) {
5634
6318
  return textResult(
5635
6319
  `Unsupported file type "${extname(filePath) || "(none)"}". Supported: ${Object.keys(IMAGE_MIME_BY_EXT).join(", ")}`
5636
6320
  );
5637
6321
  }
5638
- const info = await stat3(filePath).catch(() => null);
6322
+ const info = await stat5(filePath).catch(() => null);
5639
6323
  if (!info?.isFile()) {
5640
6324
  return textResult(`File not found: ${filePath}`);
5641
6325
  }
@@ -6326,35 +7010,16 @@ function emitContextUpdate(modelUsage, host, context, lastAssistantUsage) {
6326
7010
  });
6327
7011
  }
6328
7012
  }
6329
- function trackCostSpending(host, context, cumulativeTotal) {
6330
- if (cumulativeTotal > 0 && context.agentId && context._runnerSessionId) {
6331
- const breakdown = host.costTracker.modelBreakdown;
6332
- host.connection.trackSpending({
6333
- agentId: context.agentId,
6334
- sessionId: context._runnerSessionId,
6335
- totalCostUsd: cumulativeTotal,
6336
- onSubscription: host.config.mode === "pm" || !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
6337
- modelUsage: breakdown.length > 0 ? breakdown : void 0
6338
- });
6339
- }
6340
- }
6341
7013
  function handleSuccessResult(event, host, context, startTime, lastAssistantUsage) {
6342
7014
  const durationMs = Date.now() - startTime;
6343
7015
  const summary = event.result || "Task completed.";
6344
7016
  const retriable = isRetriableMessage(summary);
6345
- const cumulativeTotal = host.costTracker.addQueryCost(event.total_cost_usd);
7017
+ host.connection.sendEvent({ type: "completed", summary, durationMs });
6346
7018
  const { modelUsage } = event;
6347
- if (modelUsage && typeof modelUsage === "object") {
6348
- host.costTracker.addModelUsage(
6349
- modelUsage
6350
- );
6351
- }
6352
- host.connection.sendEvent({ type: "completed", summary, costUsd: cumulativeTotal, durationMs });
6353
7019
  if (modelUsage && typeof modelUsage === "object") {
6354
7020
  emitContextUpdate(modelUsage, host, context, lastAssistantUsage);
6355
7021
  }
6356
- trackCostSpending(host, context, cumulativeTotal);
6357
- return { totalCostUsd: cumulativeTotal, retriable };
7022
+ return { retriable };
6358
7023
  }
6359
7024
  function handleErrorResult(event, host) {
6360
7025
  const errorMsg = event.errors.length > 0 ? event.errors.join(", ") : `Agent stopped: ${event.subtype}`;
@@ -6383,7 +7048,7 @@ function handleResultEvent(event, host, context, startTime, lastAssistantUsage)
6383
7048
  return { ...result2, resultSummary };
6384
7049
  }
6385
7050
  const result = handleErrorResult(event, host);
6386
- return { totalCostUsd: 0, ...result, resultSummary };
7051
+ return { ...result, resultSummary };
6387
7052
  }
6388
7053
  async function emitResultEvent(event, host, context, startTime, lastAssistantUsage) {
6389
7054
  const result = handleResultEvent(event, host, context, startTime, lastAssistantUsage);
@@ -6394,7 +7059,6 @@ async function emitResultEvent(event, host, context, startTime, lastAssistantUsa
6394
7059
  await host.callbacks.onEvent({
6395
7060
  type: "completed",
6396
7061
  summary,
6397
- costUsd: result.totalCostUsd,
6398
7062
  durationMs
6399
7063
  });
6400
7064
  } else if (!result.staleSession) {
@@ -6562,14 +7226,14 @@ function flushPendingToolCalls(host, turnToolCalls) {
6562
7226
  }
6563
7227
  const outputsByTool = /* @__PURE__ */ new Map();
6564
7228
  for (const entry of host.pendingToolOutputs) {
6565
- const list = outputsByTool.get(entry.tool) ?? [];
6566
- list.push(entry.output);
6567
- outputsByTool.set(entry.tool, list);
7229
+ const list2 = outputsByTool.get(entry.tool) ?? [];
7230
+ list2.push(entry.output);
7231
+ outputsByTool.set(entry.tool, list2);
6568
7232
  }
6569
7233
  for (const call of turnToolCalls) {
6570
- const list = outputsByTool.get(call.tool);
6571
- if (list && list.length > 0) {
6572
- call.output = list.shift();
7234
+ const list2 = outputsByTool.get(call.tool);
7235
+ if (list2 && list2.length > 0) {
7236
+ call.output = list2.shift();
6573
7237
  }
6574
7238
  }
6575
7239
  host.connection.sendEvent({ type: "turn_end", toolCalls: [...turnToolCalls] });
@@ -7057,10 +7721,10 @@ function hasExistingSessionFile(taskId, cwd, lineage) {
7057
7721
  const key = sessionLineageKey(taskId, lineage.agentMode, lineage.runnerMode);
7058
7722
  return sessionFileExists(taskIdToSessionUuid(key), cwd);
7059
7723
  }
7060
- function repairTornSessionFile(path) {
7724
+ function repairTornSessionFile(path4) {
7061
7725
  try {
7062
- if (!existsSync(path)) return false;
7063
- const content = readFileSync2(path, "utf8");
7726
+ if (!existsSync(path4)) return false;
7727
+ const content = readFileSync2(path4, "utf8");
7064
7728
  if (content.length === 0) return false;
7065
7729
  let keepEnd = content.length;
7066
7730
  if (!content.endsWith("\n")) {
@@ -7079,9 +7743,9 @@ function repairTornSessionFile(path) {
7079
7743
  keepEnd = prevNewline + 1;
7080
7744
  }
7081
7745
  if (keepEnd === content.length) return false;
7082
- truncateSync(path, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
7746
+ truncateSync(path4, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
7083
7747
  logger2.warn("Repaired torn transcript before resume", {
7084
- path,
7748
+ path: path4,
7085
7749
  trimmedBytes: content.length - keepEnd
7086
7750
  });
7087
7751
  return true;
@@ -7149,7 +7813,6 @@ function buildQueryOptions(host, context) {
7149
7813
  effort: settings.effort,
7150
7814
  thinking: settings.thinking,
7151
7815
  betas: settings.betas,
7152
- maxBudgetUsd: settings.maxBudgetUsd ?? 50,
7153
7816
  abortController: host.abortController ?? void 0,
7154
7817
  disallowedTools: buildDisallowedTools(settings, mode, host.hasExitedPlanMode),
7155
7818
  enableFileCheckpointing: settings.enableFileCheckpointing,
@@ -7407,12 +8070,14 @@ async function handleAuthError(context, host, options) {
7407
8070
  host.connection.postChatMessage("Authentication expired. Re-bootstrapping credentials...");
7408
8071
  const refreshed = await host.connection.refreshAuthToken();
7409
8072
  if (!refreshed) {
7410
- host.connection.postChatMessage("Failed to refresh authentication. Agent will restart.");
8073
+ host.connection.postChatMessage(
8074
+ "\u26A0\uFE0F No Claude credential is configured for this project \u2014 the agent can't run and is now idle. Add a Claude Code OAuth token (or Anthropic API key) in Project Settings, then resume this task."
8075
+ );
7411
8076
  host.connection.sendEvent({
7412
8077
  type: "error",
7413
- message: "Auth re-bootstrap failed, exiting for restart"
8078
+ message: "No Claude credential available \u2014 agent idle (dormant)"
7414
8079
  });
7415
- process.exit(1);
8080
+ return;
7416
8081
  }
7417
8082
  context.claudeSessionId = null;
7418
8083
  host.connection.storeSessionId("");
@@ -7548,58 +8213,6 @@ async function runWithRetry(initialQuery, context, host, options) {
7548
8213
  }
7549
8214
  }
7550
8215
 
7551
- // src/execution/cost-tracker.ts
7552
- var CostTracker = class {
7553
- cumulativeCostUsd = 0;
7554
- modelUsage = /* @__PURE__ */ new Map();
7555
- seeded = false;
7556
- /**
7557
- * Rehydrate cumulative spend from the server so `maxBudgetUsd` enforcement
7558
- * accounts for costs incurred by prior agent runs on the same task. Must be
7559
- * called before any `addQueryCost` / `addModelUsage` — re-seeding after
7560
- * costs have accumulated would clobber in-process totals.
7561
- */
7562
- seed(totalCostUsd, modelUsage) {
7563
- if (this.seeded) return;
7564
- if (this.cumulativeCostUsd > 0 || this.modelUsage.size > 0) return;
7565
- this.cumulativeCostUsd = totalCostUsd;
7566
- for (const entry of modelUsage) {
7567
- this.modelUsage.set(entry.model, { ...entry });
7568
- }
7569
- this.seeded = true;
7570
- }
7571
- /** Add cost from a completed query and return the running total */
7572
- addQueryCost(queryCostUsd) {
7573
- this.cumulativeCostUsd += queryCostUsd;
7574
- return this.cumulativeCostUsd;
7575
- }
7576
- /** Merge per-model usage from a completed query */
7577
- addModelUsage(usage) {
7578
- for (const [model, data] of Object.entries(usage)) {
7579
- const existing = this.modelUsage.get(model) ?? {
7580
- model,
7581
- inputTokens: 0,
7582
- outputTokens: 0,
7583
- cacheReadInputTokens: 0,
7584
- cacheCreationInputTokens: 0,
7585
- costUSD: 0
7586
- };
7587
- existing.inputTokens += data.inputTokens ?? 0;
7588
- existing.outputTokens += data.outputTokens ?? 0;
7589
- existing.cacheReadInputTokens += data.cacheReadInputTokens ?? 0;
7590
- existing.cacheCreationInputTokens += data.cacheCreationInputTokens ?? 0;
7591
- existing.costUSD += data.costUSD ?? 0;
7592
- this.modelUsage.set(model, existing);
7593
- }
7594
- }
7595
- get totalCostUsd() {
7596
- return this.cumulativeCostUsd;
7597
- }
7598
- get modelBreakdown() {
7599
- return [...this.modelUsage.values()];
7600
- }
7601
- };
7602
-
7603
8216
  // src/execution/exploration-tracker.ts
7604
8217
  var FILE_REREAD_NUDGE_L1 = 3;
7605
8218
  var FILE_REREAD_NUDGE_L2 = 5;
@@ -7726,7 +8339,6 @@ var QueryBridge = class {
7726
8339
  harnessKind,
7727
8340
  harnessKind === "pty" ? buildPtyBridge(connection) : void 0
7728
8341
  );
7729
- this.costTracker = new CostTracker();
7730
8342
  this.planSync = new PlanSync(workspaceDir, connection);
7731
8343
  }
7732
8344
  connection;
@@ -7737,7 +8349,6 @@ var QueryBridge = class {
7737
8349
  /** Which harness drives this bridge ("pty" task chat; "sdk" only under the
7738
8350
  * CONVEYOR_FORCE_SDK_CARDS maintainer override). */
7739
8351
  harnessKind;
7740
- costTracker;
7741
8352
  planSync;
7742
8353
  sessionIds = /* @__PURE__ */ new Map();
7743
8354
  activeQuery = null;
@@ -7769,6 +8380,13 @@ var QueryBridge = class {
7769
8380
  get wasRateLimited() {
7770
8381
  return this._wasRateLimited;
7771
8382
  }
8383
+ /**
8384
+ * Ask the live terminal (PTY harness only) to redraw its full screen.
8385
+ * Safe no-op on the SDK harness or between queries.
8386
+ */
8387
+ forceRepaint() {
8388
+ this.harness.forceRepaint?.();
8389
+ }
7772
8390
  stop() {
7773
8391
  this._stopped = true;
7774
8392
  this._abortController?.abort();
@@ -7776,10 +8394,6 @@ var QueryBridge = class {
7776
8394
  resume() {
7777
8395
  this._stopped = false;
7778
8396
  }
7779
- /** Rehydrate CostTracker from server-side cumulative spend on agent boot. */
7780
- seedCostTracker(totalCostUsd, modelUsage) {
7781
- this.costTracker.seed(totalCostUsd, modelUsage);
7782
- }
7783
8397
  /**
7784
8398
  * How the INITIAL instructions for this task would be delivered — "prefill"
7785
8399
  * means the TUI input is pre-filled but unsubmitted, awaiting the human.
@@ -7838,7 +8452,6 @@ var QueryBridge = class {
7838
8452
  harness: this.harness,
7839
8453
  harnessKind: this.harnessKind,
7840
8454
  setupLog: [],
7841
- costTracker: this.costTracker,
7842
8455
  explorationTracker: bridge.mode.effectiveMode === "discovery" || bridge.mode.effectiveMode === "auto" && !bridge.mode.hasExitedPlanMode ? new ExplorationTracker(bridge._isParentTask) : null,
7843
8456
  sessionIds: this.sessionIds,
7844
8457
  pendingToolOutputs: this.pendingToolOutputs,
@@ -7948,8 +8561,195 @@ function readAgentVersion() {
7948
8561
  return null;
7949
8562
  }
7950
8563
 
8564
+ // src/runner/port-discovery.ts
8565
+ import { readFile as readFile4 } from "fs/promises";
8566
+ import { execFile } from "child_process";
8567
+ var PROC_TCP_LISTEN_STATE = "0A";
8568
+ function parseProcNetTcpListeners(content) {
8569
+ const ports = [];
8570
+ const lines = content.split("\n");
8571
+ for (let i = 1; i < lines.length; i++) {
8572
+ const line = lines[i];
8573
+ if (!line) continue;
8574
+ const cols = line.trim().split(/\s+/);
8575
+ if (cols.length < 4 || cols[3] !== PROC_TCP_LISTEN_STATE) continue;
8576
+ const local = cols[1];
8577
+ if (!local) continue;
8578
+ const portHex = local.split(":").pop();
8579
+ if (!portHex) continue;
8580
+ const port = Number.parseInt(portHex, 16);
8581
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) ports.push(port);
8582
+ }
8583
+ return ports;
8584
+ }
8585
+ var DEFAULT_PROC_PATHS = ["/proc/net/tcp", "/proc/net/tcp6"];
8586
+ async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
8587
+ const ports = /* @__PURE__ */ new Set();
8588
+ let readable = false;
8589
+ for (const path4 of procPaths) {
8590
+ try {
8591
+ const content = await readFile4(path4, "utf8");
8592
+ readable = true;
8593
+ for (const port of parseProcNetTcpListeners(content)) ports.add(port);
8594
+ } catch {
8595
+ }
8596
+ }
8597
+ return readable ? ports : null;
8598
+ }
8599
+ async function readNetstatListeningPorts() {
8600
+ const output = await new Promise((resolve) => {
8601
+ execFile("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
8602
+ resolve(err ? null : stdout);
8603
+ });
8604
+ });
8605
+ if (output === null) return null;
8606
+ const ports = /* @__PURE__ */ new Set();
8607
+ for (const line of output.split("\n")) {
8608
+ if (!line.includes("LISTEN")) continue;
8609
+ const cols = line.trim().split(/\s+/);
8610
+ const local = cols[3];
8611
+ if (!local) continue;
8612
+ const portStr = local.split(".").pop();
8613
+ const port = Number(portStr);
8614
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) ports.add(port);
8615
+ }
8616
+ return ports;
8617
+ }
8618
+ async function readListeningPorts() {
8619
+ const proc = await readProcListeningPorts();
8620
+ if (proc !== null) return proc;
8621
+ if (process.platform !== "linux") return readNetstatListeningPorts();
8622
+ return null;
8623
+ }
8624
+ var DEFAULT_EXCLUDED_PORTS = [2222, 5432, 6379, 9200];
8625
+ var DEFAULT_EPHEMERAL_PORT_MIN = 32768;
8626
+ var DEFAULT_INTERVAL_MS = 15e3;
8627
+ var DEFAULT_MAX_PORTS = 16;
8628
+ var CONFIRM_SCANS = 2;
8629
+ var PortDiscovery = class {
8630
+ opts;
8631
+ intervalMs;
8632
+ maxPorts;
8633
+ excluded;
8634
+ ephemeralPortMin;
8635
+ scan;
8636
+ now;
8637
+ log;
8638
+ baseline = null;
8639
+ tracked = /* @__PURE__ */ new Map();
8640
+ timer = null;
8641
+ ticking = false;
8642
+ disabled = false;
8643
+ /** Set when the confirmed set changed (or a report failed) — cleared only
8644
+ * after a successful report, so transient RPC failures retry next tick. */
8645
+ reportPending = false;
8646
+ lastReportedKey = "";
8647
+ constructor(options) {
8648
+ this.opts = options;
8649
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
8650
+ this.maxPorts = options.maxPorts ?? DEFAULT_MAX_PORTS;
8651
+ this.excluded = new Set(options.excludedPorts ?? DEFAULT_EXCLUDED_PORTS);
8652
+ this.ephemeralPortMin = options.ephemeralPortMin ?? DEFAULT_EPHEMERAL_PORT_MIN;
8653
+ this.scan = options.scan ?? readListeningPorts;
8654
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
8655
+ this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
8656
+ `));
8657
+ }
8658
+ /** Take the baseline scan and start polling. Safe to call once. */
8659
+ async start() {
8660
+ if (this.timer || this.disabled) return;
8661
+ const baseline = await this.scanSafe();
8662
+ if (baseline === null) {
8663
+ this.disabled = true;
8664
+ this.log("Port discovery disabled: no listening-socket source available");
8665
+ return;
8666
+ }
8667
+ this.baseline = baseline;
8668
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
8669
+ this.timer.unref?.();
8670
+ }
8671
+ stop() {
8672
+ if (this.timer) {
8673
+ clearInterval(this.timer);
8674
+ this.timer = null;
8675
+ }
8676
+ }
8677
+ /** One poll cycle. Exposed for tests (deterministic, no timers needed). */
8678
+ async tick() {
8679
+ if (this.ticking || this.disabled || !this.baseline) return;
8680
+ this.ticking = true;
8681
+ try {
8682
+ const current = await this.scanSafe();
8683
+ if (current === null) return;
8684
+ this.updateTracking(current);
8685
+ if (this.reportPending) await this.flushReport();
8686
+ } finally {
8687
+ this.ticking = false;
8688
+ }
8689
+ }
8690
+ async scanSafe() {
8691
+ try {
8692
+ return await this.scan();
8693
+ } catch {
8694
+ return null;
8695
+ }
8696
+ }
8697
+ isCandidate(port) {
8698
+ if (this.baseline?.has(port)) return false;
8699
+ if (this.excluded.has(port)) return false;
8700
+ if (port >= this.ephemeralPortMin) return false;
8701
+ return true;
8702
+ }
8703
+ updateTracking(current) {
8704
+ for (const port of current) {
8705
+ if (!this.isCandidate(port)) continue;
8706
+ const entry = this.tracked.get(port);
8707
+ if (!entry) {
8708
+ this.tracked.set(port, { seen: 1, missed: 0, confirmed: false, detectedAt: "" });
8709
+ continue;
8710
+ }
8711
+ entry.seen += 1;
8712
+ entry.missed = 0;
8713
+ if (!entry.confirmed && entry.seen >= CONFIRM_SCANS) {
8714
+ entry.confirmed = true;
8715
+ entry.detectedAt = this.now().toISOString();
8716
+ }
8717
+ }
8718
+ for (const [port, entry] of this.tracked) {
8719
+ if (current.has(port)) continue;
8720
+ entry.missed += 1;
8721
+ entry.seen = 0;
8722
+ if (entry.missed >= CONFIRM_SCANS || !entry.confirmed) this.tracked.delete(port);
8723
+ }
8724
+ const key = this.confirmedKey();
8725
+ if (key !== this.lastReportedKey) this.reportPending = true;
8726
+ }
8727
+ confirmedPorts() {
8728
+ const confirmed = [...this.tracked.entries()].filter(([, entry]) => entry.confirmed).sort(([a], [b]) => a - b).slice(0, this.maxPorts);
8729
+ return confirmed.map(([port, entry]) => ({
8730
+ port,
8731
+ protocol: "tcp",
8732
+ detectedAt: entry.detectedAt
8733
+ }));
8734
+ }
8735
+ confirmedKey() {
8736
+ return this.confirmedPorts().map(({ port }) => port).join(",");
8737
+ }
8738
+ async flushReport() {
8739
+ const ports = this.confirmedPorts();
8740
+ const key = ports.map(({ port }) => port).join(",");
8741
+ try {
8742
+ await this.opts.report(ports);
8743
+ this.lastReportedKey = key;
8744
+ this.reportPending = false;
8745
+ this.log(`Discovered preview ports: [${key || "none"}]`);
8746
+ } catch {
8747
+ }
8748
+ }
8749
+ };
8750
+
7951
8751
  // src/runner/parent-pull-handler.ts
7952
- import { execSync as execSync2 } from "child_process";
8752
+ import { execSync as execSync4 } from "child_process";
7953
8753
  function handlePullBranch(workDir, branch) {
7954
8754
  if (!branch) return;
7955
8755
  const current = getCurrentBranch(workDir);
@@ -7968,14 +8768,14 @@ function handlePullBranch(workDir, branch) {
7968
8768
  return;
7969
8769
  }
7970
8770
  try {
7971
- execSync2(`git fetch origin ${branch}`, { cwd: workDir, stdio: "ignore", timeout: 6e4 });
8771
+ execSync4(`git fetch origin ${branch}`, { cwd: workDir, stdio: "ignore", timeout: 6e4 });
7972
8772
  } catch {
7973
8773
  process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
7974
8774
  `);
7975
8775
  return;
7976
8776
  }
7977
8777
  try {
7978
- execSync2(`git pull --ff-only origin ${branch}`, {
8778
+ execSync4(`git pull --ff-only origin ${branch}`, {
7979
8779
  cwd: workDir,
7980
8780
  stdio: "ignore",
7981
8781
  timeout: 6e4
@@ -8027,6 +8827,8 @@ var SessionRunner = class _SessionRunner {
8027
8827
  agentSpokeThisTurn = false;
8028
8828
  /** Guards overlapping runs of the periodic git flush. */
8029
8829
  periodicFlushInFlight = false;
8830
+ /** Runtime preview-port poller (v3 dynamic port discovery). */
8831
+ portDiscovery = null;
8030
8832
  constructor(config, callbacks) {
8031
8833
  this.config = config;
8032
8834
  this.callbacks = callbacks;
@@ -8083,7 +8885,6 @@ var SessionRunner = class _SessionRunner {
8083
8885
  this.wireConnectionCallbacks();
8084
8886
  this.lifecycle.startHeartbeat();
8085
8887
  this.lifecycle.startTokenRefresh();
8086
- this.lifecycle.startGitFlush();
8087
8888
  const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
8088
8889
  sessionId: this.sessionId
8089
8890
  });
@@ -8092,6 +8893,11 @@ var SessionRunner = class _SessionRunner {
8092
8893
  this.pendingMessages.push({ content: msg.content, userId: msg.userId });
8093
8894
  }
8094
8895
  }
8896
+ this.portDiscovery = new PortDiscovery({
8897
+ report: (ports) => this.connection.reportDiscoveredPorts(ports)
8898
+ });
8899
+ void this.portDiscovery.start().catch(() => {
8900
+ });
8095
8901
  const agentVersion = readAgentVersion();
8096
8902
  if (agentVersion) {
8097
8903
  this.connection.call("notifyAgentVersion", { sessionId: this.sessionId, agentVersion }).catch(() => {
@@ -8126,6 +8932,17 @@ var SessionRunner = class _SessionRunner {
8126
8932
  await this.shutdown("error");
8127
8933
  return;
8128
8934
  }
8935
+ const gitState = await awaitGitReady({
8936
+ onLog: (m) => process.stderr.write(`[conveyor-agent] ${m}
8937
+ `)
8938
+ });
8939
+ if (gitState === "failed" || gitState === "timeout") {
8940
+ const message = gitState === "failed" ? "Workspace git preparation failed (see pod logs)" : "Workspace git preparation timed out (see pod logs)";
8941
+ this.connection.sendEvent({ type: "error", message });
8942
+ await this.callbacks.onEvent({ type: "error", message });
8943
+ await this.shutdown("error");
8944
+ return;
8945
+ }
8129
8946
  if (process.env.CONVEYOR_GIT_READY !== "1") {
8130
8947
  if (this.fullContext?.githubBranch) {
8131
8948
  ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
@@ -8134,11 +8951,31 @@ var SessionRunner = class _SessionRunner {
8134
8951
  syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
8135
8952
  }
8136
8953
  }
8954
+ if (!this.stopped) {
8955
+ this.lifecycle.startGitFlush();
8956
+ }
8137
8957
  if (this.fullContext?.githubBranch) {
8138
- const restored = restoreWipSnapshot(this.config.workspaceDir, this.fullContext.githubBranch);
8139
- if (restored !== "none") {
8140
- process.stderr.write(`[conveyor-agent] WIP snapshot restore: ${restored}
8958
+ const snapshotUrl = process.env.CONVEYOR_SNAPSHOT_URL;
8959
+ if (snapshotUrl) {
8960
+ const restore = await restoreOnBoot(
8961
+ { snapshotUrl, gitPlan: { branch: this.fullContext.githubBranch } },
8962
+ this.config.workspaceDir
8963
+ );
8964
+ if (restore.source !== "clean") {
8965
+ process.stderr.write(
8966
+ `[conveyor-agent] WorkPreservation restore: source=${restore.source}${restore.fileCount === void 0 ? "" : ` files=${restore.fileCount}`}
8967
+ `
8968
+ );
8969
+ }
8970
+ } else {
8971
+ const restored = restoreWipSnapshot(
8972
+ this.config.workspaceDir,
8973
+ this.fullContext.githubBranch
8974
+ );
8975
+ if (restored !== "none") {
8976
+ process.stderr.write(`[conveyor-agent] WIP snapshot restore: ${restored}
8141
8977
  `);
8978
+ }
8142
8979
  }
8143
8980
  }
8144
8981
  this.mode.applyServerMode(this.fullContext?.agentMode, this.fullContext?.isAuto);
@@ -8148,7 +8985,6 @@ var SessionRunner = class _SessionRunner {
8148
8985
  });
8149
8986
  }
8150
8987
  this.queryBridge = this.createQueryBridge();
8151
- await this.seedCostTrackerFromServer();
8152
8988
  this.logInitialization();
8153
8989
  const staleBatch = [...this.pendingMessages];
8154
8990
  const didExecuteInitialQuery = await this.executeInitialMode();
@@ -8469,11 +9305,28 @@ var SessionRunner = class _SessionRunner {
8469
9305
  `
8470
9306
  );
8471
9307
  }
9308
+ await this.uploadGcsSnapshotIfConfigured();
8472
9309
  } catch {
8473
9310
  } finally {
8474
9311
  this.periodicFlushInFlight = false;
8475
9312
  }
8476
9313
  }
9314
+ /** PUT the WorkPreservation snapshot tar to the bundle's capability URL
9315
+ * when this pod is v3 (CONVEYOR_SNAPSHOT_UPLOAD_URL set). Never throws. */
9316
+ async uploadGcsSnapshotIfConfigured() {
9317
+ const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
9318
+ if (!uploadUrl) return;
9319
+ try {
9320
+ const gcs = await uploadSnapshotToGcs(this.config.workspaceDir, uploadUrl);
9321
+ if (gcs.uploaded) {
9322
+ process.stderr.write(
9323
+ `[conveyor-agent] WorkPreservation GCS snapshot: files=${gcs.fileCount} bytes=${gcs.sizeBytes}
9324
+ `
9325
+ );
9326
+ }
9327
+ } catch {
9328
+ }
9329
+ }
8477
9330
  /** Best-effort WIP commit + push on shutdown so in-flight work isn't lost
8478
9331
  * when a claudespace pod is killed. Must be called BEFORE stop() so the
8479
9332
  * connection is still alive for token refresh. Never throws. */
@@ -8498,6 +9351,7 @@ var SessionRunner = class _SessionRunner {
8498
9351
  `
8499
9352
  );
8500
9353
  }
9354
+ await this.uploadGcsSnapshotIfConfigured();
8501
9355
  } catch (err) {
8502
9356
  const msg = err instanceof Error ? err.message : String(err);
8503
9357
  process.stderr.write(`[conveyor-agent] Shutdown git flush failed: ${msg}
@@ -8507,6 +9361,7 @@ var SessionRunner = class _SessionRunner {
8507
9361
  stop() {
8508
9362
  this.stopped = true;
8509
9363
  this.queryBridge?.stop();
9364
+ this.portDiscovery?.stop();
8510
9365
  this.lifecycle.destroy();
8511
9366
  this.connection.disconnect();
8512
9367
  if (this.inputResolver) {
@@ -8700,6 +9555,7 @@ var SessionRunner = class _SessionRunner {
8700
9555
  this.connection.onMessage((msg) => this.injectMessage(msg));
8701
9556
  this.connection.onStop(() => this.stop());
8702
9557
  this.connection.onSoftStop(() => this.softStop());
9558
+ this.connection.onReconnected = () => this.queryBridge?.forceRepaint();
8703
9559
  this.connection.onModeChange((data) => {
8704
9560
  const action = this.mode.handleModeChange(data.agentMode);
8705
9561
  if (action.type === "start_auto") {
@@ -8731,35 +9587,37 @@ var SessionRunner = class _SessionRunner {
8731
9587
  this.connection.onPullBranch(({ branch }) => {
8732
9588
  handlePullBranch(this.config.workspaceDir, branch);
8733
9589
  });
8734
- }
8735
- /**
8736
- * Rehydrate CostTracker from server. Caps at 3s so a slow API call never
8737
- * blocks agent startup on timeout/error we fall through with a $0 tracker
8738
- * (the existing behavior before this rehydration path was added).
8739
- */
8740
- async seedCostTrackerFromServer() {
8741
- if (!this.queryBridge) return;
8742
- const TIMEOUT_MS = 3e3;
9590
+ this.connection.onFinalizeSnapshot(() => void this.finalizeSnapshotNow());
9591
+ }
9592
+ /** Eager finalize snapshot triggered by the reconciler's sleep signal
9593
+ * (session:finalizeSnapshot). Runs pg_dump + a full capture and uploads it
9594
+ * NOW so the sleep confirms on this snapshot instead of waiting for the
9595
+ * ~2min periodic flush — and so sidecar DB state (which ONLY finalizeForSleep
9596
+ * captures via pg_dump) actually lands. Best-effort: on failure the
9597
+ * periodic/shutdown path stays the fallback. No-op on non-v3 pods. */
9598
+ async finalizeSnapshotNow() {
9599
+ const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
9600
+ if (!uploadUrl || this.stopped) return;
8743
9601
  try {
8744
- const timeout = new Promise((resolve) => {
8745
- setTimeout(() => resolve(null), TIMEOUT_MS);
9602
+ await finalizeForSleep({
9603
+ cwd: this.config.workspaceDir,
9604
+ branch: this.fullContext?.githubBranch ?? "",
9605
+ snapshotUploadUrl: uploadUrl,
9606
+ refreshToken: async () => {
9607
+ try {
9608
+ const res = await this.connection.call("refreshGithubToken", {
9609
+ sessionId: this.connection.sessionId
9610
+ });
9611
+ return res.token;
9612
+ } catch {
9613
+ return void 0;
9614
+ }
9615
+ }
8746
9616
  });
8747
- const fetched = await Promise.race([this.connection.getCumulativeSpending(), timeout]);
8748
- if (fetched) {
8749
- this.queryBridge.seedCostTracker(fetched.totalCostUsd, fetched.modelUsage);
8750
- process.stderr.write(
8751
- `[conveyor-agent] CostTracker seeded: $${fetched.totalCostUsd.toFixed(4)} across ${fetched.modelUsage.length} model(s)
8752
- `
8753
- );
8754
- } else {
8755
- process.stderr.write(
8756
- "[conveyor-agent] CostTracker seed timed out after 3s \u2014 starting at $0\n"
8757
- );
8758
- }
8759
- } catch (err) {
8760
- const msg = err instanceof Error ? err.message : String(err);
8761
- process.stderr.write(`[conveyor-agent] CostTracker seed failed: ${msg} \u2014 starting at $0
8762
- `);
9617
+ process.stderr.write(
9618
+ "[conveyor-agent] Finalize snapshot (pg_dump) uploaded on sleep signal\n"
9619
+ );
9620
+ } catch {
8763
9621
  }
8764
9622
  }
8765
9623
  /** Proactively refresh the GitHub token before the 1-hour expiry. */
@@ -8804,6 +9662,7 @@ var SessionRunner = class _SessionRunner {
8804
9662
  process.stderr.write(`[conveyor-agent] Shutdown: reason=${finalState}
8805
9663
  `);
8806
9664
  this.connection.sendEvent({ type: "shutdown", reason: finalState });
9665
+ this.portDiscovery?.stop();
8807
9666
  this.lifecycle.destroy();
8808
9667
  try {
8809
9668
  await this.setState(finalState);
@@ -8848,13 +9707,13 @@ var SessionRunner = class _SessionRunner {
8848
9707
  };
8849
9708
 
8850
9709
  // src/setup/config.ts
8851
- import { readFile as readFile4 } from "fs/promises";
9710
+ import { readFile as readFile5 } from "fs/promises";
8852
9711
  import { join as join8 } from "path";
8853
9712
  var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
8854
9713
  var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
8855
9714
  async function loadForwardPorts(workspaceDir) {
8856
9715
  try {
8857
- const raw = await readFile4(join8(workspaceDir, DEVCONTAINER_PATH), "utf-8");
9716
+ const raw = await readFile5(join8(workspaceDir, DEVCONTAINER_PATH), "utf-8");
8858
9717
  const parsed = JSON.parse(raw);
8859
9718
  const ports = (parsed.forwardPorts ?? []).filter(
8860
9719
  (p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
@@ -8898,7 +9757,7 @@ function loadConveyorConfig() {
8898
9757
  }
8899
9758
 
8900
9759
  // src/setup/commands.ts
8901
- import { spawn, execSync as execSync3 } from "child_process";
9760
+ import { spawn, execSync as execSync5 } from "child_process";
8902
9761
  function runSetupCommand(cmd, cwd, onOutput) {
8903
9762
  return new Promise((resolve, reject) => {
8904
9763
  const child = spawn("sh", ["-c", cmd], {
@@ -8927,7 +9786,7 @@ function runSetupCommand(cmd, cwd, onOutput) {
8927
9786
  var AUTH_TOKEN_TIMEOUT_MS = 3e4;
8928
9787
  function runAuthTokenCommand(cmd, userEmail, cwd) {
8929
9788
  try {
8930
- const output = execSync3(`${cmd} ${JSON.stringify(userEmail)}`, {
9789
+ const output = execSync5(`${cmd} ${JSON.stringify(userEmail)}`, {
8931
9790
  cwd,
8932
9791
  timeout: AUTH_TOKEN_TIMEOUT_MS,
8933
9792
  stdio: ["ignore", "pipe", "ignore"],
@@ -8957,10 +9816,10 @@ function runStartCommand(cmd, cwd, onOutput) {
8957
9816
  }
8958
9817
 
8959
9818
  // src/setup/codespace.ts
8960
- import { execSync as execSync4 } from "child_process";
9819
+ import { execSync as execSync6 } from "child_process";
8961
9820
  function unshallowRepo(workspaceDir) {
8962
9821
  try {
8963
- execSync4("git fetch --unshallow", {
9822
+ execSync6("git fetch --unshallow", {
8964
9823
  cwd: workspaceDir,
8965
9824
  timeout: 6e4,
8966
9825
  stdio: "ignore"
@@ -8973,6 +9832,8 @@ export {
8973
9832
  fetchBootstrap,
8974
9833
  applyBootstrapToEnv,
8975
9834
  AgentConnection,
9835
+ DEFAULT_LIFECYCLE_CONFIG,
9836
+ Lifecycle,
8976
9837
  createServiceLogger,
8977
9838
  hasUncommittedChanges,
8978
9839
  getCurrentBranch,
@@ -8982,6 +9843,13 @@ export {
8982
9843
  flushPendingChanges,
8983
9844
  pushToOrigin,
8984
9845
  PlanSync,
9846
+ awaitGitReady,
9847
+ uploadSnapshotToGcs,
9848
+ captureSnapshot,
9849
+ startPeriodic,
9850
+ stop,
9851
+ finalizeForSleep,
9852
+ restoreOnBoot,
8985
9853
  SessionRunner,
8986
9854
  loadForwardPorts,
8987
9855
  buildSessionPreviewPorts,
@@ -8991,4 +9859,4 @@ export {
8991
9859
  runStartCommand,
8992
9860
  unshallowRepo
8993
9861
  };
8994
- //# sourceMappingURL=chunk-P3QCTUHC.js.map
9862
+ //# sourceMappingURL=chunk-QKRPJ7DB.js.map