@integrity-labs/agt-cli 0.15.34 → 0.15.36

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.
@@ -14,20 +14,29 @@ var TWO_BYTE_RE = new RegExp(`${ANSI_ESC}[=>cM78]`, "g");
14
14
  function stripAnsi(text) {
15
15
  return text.replace(CSI_RE, "").replace(OSC_RE, "").replace(TWO_BYTE_RE, "");
16
16
  }
17
- var OAUTH_URL_RE = /https:\/\/(?:claude\.ai|console\.anthropic\.com|auth\.anthropic\.com)\/[^\s)\]]*/;
18
- function extractOAuthUrl(rawPane) {
17
+ var OAUTH_URL_RE = /https:\/\/(?:claude\.com|claude\.ai|platform\.claude\.com|console\.anthropic\.com|auth\.anthropic\.com)\/[^\s)\]]*/;
18
+ function dewrapPane(rawPane) {
19
19
  const stripped = stripAnsi(rawPane);
20
- const match = OAUTH_URL_RE.exec(stripped);
20
+ let dewrapped = stripped;
21
+ let prev = "";
22
+ while (prev !== dewrapped) {
23
+ prev = dewrapped;
24
+ dewrapped = dewrapped.replace(/(https?:\/\/\S+?)\n(?=\S)/, (_m, head) => head);
25
+ }
26
+ return dewrapped;
27
+ }
28
+ function extractOAuthUrl(rawPane) {
29
+ const match = OAUTH_URL_RE.exec(dewrapPane(rawPane));
21
30
  if (!match) return null;
22
31
  return match[0].replace(/[.,;:!?]+$/, "");
23
32
  }
24
33
  var URL_PROMPT_RE = /(?:Paste code here|Paste your code|Enter (?:the )?code|Authorization code)/i;
25
34
  function isUrlPromptReady(rawPane) {
26
- const stripped = stripAnsi(rawPane);
27
- return OAUTH_URL_RE.test(stripped) && URL_PROMPT_RE.test(stripped);
35
+ const dewrapped = dewrapPane(rawPane);
36
+ return OAUTH_URL_RE.test(dewrapped) && URL_PROMPT_RE.test(dewrapped);
28
37
  }
29
- var SUCCESS_RE = /(?:Logged in|Successfully (?:logged in|authenticated)|Authentication successful)/i;
30
- var FAILURE_RE = /(?:Invalid (?:code|authorization code)|Authentication failed|Error (?:logging in|during authentication)|Login failed)/i;
38
+ var SUCCESS_RE = /(?:Logged in|Login successful|Successfully (?:logged in|authenticated|signed in)|Authentication successful|Sign-?in (?:complete|successful)|You(?:'|’)?re signed in|Signed in as)/i;
39
+ var FAILURE_RE = /(?:Invalid (?:code|authorization code)|OAuth error|Authentication failed|Error (?:logging in|during authentication)|Login failed|Sign-?in failed|Failed to (?:authenticate|sign in|log in))/i;
31
40
  function detectAuthOutcome(rawPane) {
32
41
  const stripped = stripAnsi(rawPane);
33
42
  const failureMatch = lastMatch(stripped, FAILURE_RE);
@@ -88,25 +97,44 @@ async function spawnPairSession(session) {
88
97
  return { ok: true };
89
98
  } catch {
90
99
  }
91
- const { resolveClaudeBinary } = await import("./persistent-session-HUQXZSHP.js");
100
+ const { resolveClaudeBinary } = await import("./persistent-session-VRS3MFQ3.js");
92
101
  const claudeBin = resolveClaudeBinary();
93
102
  try {
94
103
  await execFileAsync("tmux", [
95
104
  "new-session",
96
105
  "-d",
106
+ "-x",
107
+ "240",
108
+ "-y",
109
+ "50",
97
110
  "-s",
98
111
  session,
99
112
  claudeBin
100
113
  ]);
101
- return { ok: true };
102
114
  } catch (err) {
103
115
  return { ok: false, error: classifyTmuxError(err) };
104
116
  }
117
+ await sleep(500);
118
+ try {
119
+ await execFileAsync("tmux", ["has-session", "-t", session]);
120
+ } catch {
121
+ return {
122
+ ok: false,
123
+ error: {
124
+ kind: "unknown",
125
+ message: `claude exited immediately after launch (binary at ${claudeBin}). Run \`${claudeBin}\` manually on the host to see why \u2014 likely missing TTY, missing HOME, or a startup error.`
126
+ }
127
+ };
128
+ }
129
+ return { ok: true };
105
130
  }
106
131
  async function killPairSession(session) {
107
132
  try {
108
133
  await execFileAsync("tmux", ["kill-session", "-t", session]);
109
- } catch {
134
+ return true;
135
+ } catch (err) {
136
+ if (classifyTmuxError(err).kind === "no-session") return true;
137
+ return false;
110
138
  }
111
139
  }
112
140
  async function startClaudePair(opts) {
@@ -133,7 +161,7 @@ async function startClaudePair(opts) {
133
161
  await sleep(pollIntervalMs);
134
162
  let pane;
135
163
  try {
136
- pane = await capturePane(session);
164
+ pane = await capturePane(session, { scrollback: 0 });
137
165
  } catch (err) {
138
166
  return { kind: "error", error: classifyTmuxError(err) };
139
167
  }
@@ -141,6 +169,22 @@ async function startClaudePair(opts) {
141
169
  const url = extractOAuthUrl(pane);
142
170
  if (url) return { kind: "url", url };
143
171
  }
172
+ let scrollPane = pane;
173
+ try {
174
+ scrollPane = await capturePane(session, { scrollback: -50 });
175
+ } catch {
176
+ }
177
+ const hasOAuthInvalidCode = /OAuth error[\s\S]*Invalid code/i.test(scrollPane);
178
+ const hasOAuthRetryPrompt = /OAuth error/i.test(scrollPane) && /Press Enter to retry/i.test(scrollPane);
179
+ if (hasOAuthInvalidCode || hasOAuthRetryPrompt) {
180
+ return {
181
+ kind: "error",
182
+ error: {
183
+ kind: "unknown",
184
+ message: "claude is stuck on a previous failed-login retry prompt. SSH to the host and clear ~/.claude/.credentials.json (and any *.json next to it), then retry pair-via-browser."
185
+ }
186
+ };
187
+ }
144
188
  if (/Select login method:/i.test(pane)) {
145
189
  await dispatchEnter();
146
190
  continue;
@@ -204,24 +248,44 @@ async function submitClaudePairCode(opts) {
204
248
  };
205
249
  }
206
250
  try {
207
- await sendKeys(session, opts.code.trim(), "C-m");
251
+ await execFileAsync("tmux", ["send-keys", "-t", session, "-l", opts.code.trim()]);
252
+ } catch (err) {
253
+ return { kind: "error", error: classifyTmuxError(err) };
254
+ }
255
+ await sleep(250);
256
+ try {
257
+ await sendKeys(session, "Enter");
208
258
  } catch (err) {
209
259
  return { kind: "error", error: classifyTmuxError(err) };
210
260
  }
211
261
  const deadline = Date.now() + timeoutMs;
262
+ let enterRetried = false;
263
+ let lastPane = "";
212
264
  while (Date.now() < deadline) {
213
265
  await sleep(pollIntervalMs);
214
- let pane;
215
266
  try {
216
- pane = await capturePane(session);
267
+ lastPane = await capturePane(session);
217
268
  } catch (err) {
218
269
  return { kind: "error", error: classifyTmuxError(err) };
219
270
  }
220
- const outcome = detectAuthOutcome(pane);
271
+ const outcome = detectAuthOutcome(lastPane);
221
272
  if (outcome.kind === "success") return { kind: "success", rawMatch: outcome.rawMatch };
222
273
  if (outcome.kind === "failure") return { kind: "failure", rawMatch: outcome.rawMatch };
274
+ if (!enterRetried && Date.now() - (deadline - timeoutMs) > 5e3 && /Paste code here/i.test(lastPane)) {
275
+ enterRetried = true;
276
+ try {
277
+ await sendKeys(session, "C-m");
278
+ } catch {
279
+ }
280
+ }
223
281
  }
224
- return { kind: "timeout" };
282
+ return {
283
+ kind: "error",
284
+ error: {
285
+ kind: "unknown",
286
+ message: `submit timed out after ${timeoutMs}ms \u2014 outcome regex didn't match. Last pane: ${lastPane.slice(-600)}`
287
+ }
288
+ };
225
289
  }
226
290
  async function getClaudePairStatus(session) {
227
291
  try {
@@ -254,4 +318,4 @@ export {
254
318
  startClaudePair,
255
319
  submitClaudePairCode
256
320
  };
257
- //# sourceMappingURL=claude-pair-runtime-H4YBW4YE.js.map
321
+ //# sourceMappingURL=claude-pair-runtime-GLO2D7WP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/claude-pair-runtime.ts","../src/lib/claude-pair-parser.ts"],"sourcesContent":["/**\n * ENG-4580: manager-side runtime for the Claude Code OAuth pairing flow.\n *\n * These functions own the actual tmux dance — sending `/login`,\n * polling the pane until Claude Code prints the OAuth URL, sending\n * the auth code, and detecting success/failure. They are the\n * counterpart to the pure parser in `claude-pair-parser.ts`.\n *\n * The API surface in ENG-4581 will wrap these — they don't include\n * any HTTP / DB code so the unit tests can target the parser layer\n * without spinning up a fake API. Errors are classified into the\n * `SessionError` shape so the API can translate them into structured\n * 4xx responses (e.g. `session_missing`).\n *\n * Architectural note: tmux capture-pane on a session that doesn't\n * exist exits non-zero with `can't find session`. Same goes for tmux\n * not being installed. classifyTmuxError covers both.\n */\n\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nimport {\n classifyTmuxError,\n detectAuthOutcome,\n extractOAuthUrl,\n isUrlPromptReady,\n type AuthOutcome,\n type SessionError,\n} from './claude-pair-parser.js';\n\nconst execFileAsync = promisify(execFile);\n\n// Pair-scoped tmux session name. The pairing flow runs inside a\n// throwaway `claude` instance — never inside an agent's persistent\n// session — so first-time auth on a fresh host works (no chicken-and-\n// egg) and re-auth doesn't disrupt an in-flight agent conversation.\nexport function pairTmuxSession(pairId: string): string {\n return `agt-pair-${pairId.slice(0, 12)}`;\n}\n\n// ---------------------------------------------------------------------------\n// Low-level helpers\n// ---------------------------------------------------------------------------\n\ninterface CapturePaneOpts {\n /** How many lines of scrollback to include (negative = lines back). Default -200. */\n scrollback?: number;\n}\n\nasync function capturePane(session: string, opts: CapturePaneOpts = {}): Promise<string> {\n const scrollback = opts.scrollback ?? -200;\n const { stdout } = await execFileAsync('tmux', [\n 'capture-pane',\n '-t',\n session,\n '-p',\n '-S',\n String(scrollback),\n ]);\n return stdout;\n}\n\nasync function sendKeys(session: string, ...keys: string[]): Promise<void> {\n await execFileAsync('tmux', ['send-keys', '-t', session, ...keys]);\n}\n\nasync function sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\n// ---------------------------------------------------------------------------\n// Pair-scoped tmux session lifecycle\n// ---------------------------------------------------------------------------\n\n/**\n * Spawn a throwaway tmux session running `claude` for the pairing flow.\n * Idempotent — if the session already exists, returns success silently.\n * Caller is responsible for `killPairSession` once the pair reaches a\n * terminal state.\n *\n * Uses an absolute path to the claude binary so we don't depend on the\n * inherited PATH (the manager runs with cloud-init's minimal env on\n * EC2). resolveClaudeBinary checks CLAUDE_PATH, then `which`, then\n * canonical Linux/macOS Homebrew install dirs.\n */\nexport async function spawnPairSession(session: string): Promise<{ ok: true } | { ok: false; error: SessionError }> {\n try {\n await execFileAsync('tmux', ['has-session', '-t', session]);\n return { ok: true };\n } catch {\n // session doesn't exist yet — fall through to create it\n }\n\n const { resolveClaudeBinary } = await import('./persistent-session.js');\n const claudeBin = resolveClaudeBinary();\n\n try {\n // -x 240 -y 50 — wide enough that Claude Code's OAuth URL (~350\n // chars after PKCE expansion) doesn't soft-wrap. The dewrap pass in\n // extractOAuthUrl handles any leftover wrapping, but giving the URL\n // a single line in the first place is more robust against future\n // claude UI changes.\n await execFileAsync('tmux', [\n 'new-session',\n '-d',\n '-x',\n '240',\n '-y',\n '50',\n '-s',\n session,\n claudeBin,\n ]);\n } catch (err) {\n return { ok: false, error: classifyTmuxError(err) };\n }\n\n // `tmux new-session` returns 0 even if the command exited immediately\n // (binary missing, claude crashed, etc.) — the session is created and\n // then torn down. Without this verification we'd propagate a generic\n // `session_missing` to the operator on the next has-session call,\n // hiding the real failure. Sleep briefly to give claude a beat to\n // crash-or-stay-alive, then check.\n await sleep(500);\n try {\n await execFileAsync('tmux', ['has-session', '-t', session]);\n } catch {\n return {\n ok: false,\n error: {\n kind: 'unknown',\n message: `claude exited immediately after launch (binary at ${claudeBin}). Run \\`${claudeBin}\\` manually on the host to see why — likely missing TTY, missing HOME, or a startup error.`,\n },\n };\n }\n return { ok: true };\n}\n\n/**\n * Returns true if the session is gone afterwards (either kill succeeded\n * or it was already missing). Returns false only if `kill-session`\n * failed for a non-missing-session reason — caller should treat this\n * as \"still tracked, will retry next poll\".\n */\nexport async function killPairSession(session: string): Promise<boolean> {\n try {\n await execFileAsync('tmux', ['kill-session', '-t', session]);\n return true;\n } catch (err) {\n // \"can't find session\" is success-equivalent — the desired end state\n // is \"this session does not exist\" and that's already true.\n if (classifyTmuxError(err).kind === 'no-session') return true;\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Result shapes\n// ---------------------------------------------------------------------------\n\nexport type ClaudePairStartResult =\n | { kind: 'url'; url: string }\n | { kind: 'timeout' }\n | { kind: 'error'; error: SessionError };\n\nexport type ClaudePairSubmitResult =\n | { kind: 'success'; rawMatch: string }\n | { kind: 'failure'; rawMatch: string }\n | { kind: 'timeout' }\n | { kind: 'error'; error: SessionError };\n\nexport type ClaudePairStatusResult =\n | { kind: 'idle' }\n | { kind: 'awaiting-code'; url: string }\n | { kind: 'success' }\n | { kind: 'failure'; rawMatch: string }\n | { kind: 'session-missing' }\n | { kind: 'error'; error: SessionError };\n\n// ---------------------------------------------------------------------------\n// start — send `/login`, wait for the OAuth URL prompt\n// ---------------------------------------------------------------------------\n\nexport interface ClaudePairStartOpts {\n /** Pair-scoped tmux session name (see pairTmuxSession). */\n session: string;\n /** Total time to wait for Claude Code to print the URL prompt. Default 60s. */\n timeoutMs?: number;\n /** How often to re-capture and check the pane. Default 500ms. */\n pollIntervalMs?: number;\n}\n\nexport async function startClaudePair(opts: ClaudePairStartOpts): Promise<ClaudePairStartResult> {\n const { session } = opts;\n // Pair sessions cold-start `claude` from scratch — first-run on a fresh\n // host can take 10-30s before the prompt is interactive enough to\n // accept `/login`. Stay generous.\n const timeoutMs = opts.timeoutMs ?? 60_000;\n const pollIntervalMs = opts.pollIntervalMs ?? 500;\n\n // Quick precheck — fail fast if the tmux session doesn't exist\n // rather than blasting `/login` into an unrelated pane.\n try {\n await execFileAsync('tmux', ['has-session', '-t', session]);\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n\n // Drive claude through its first-run onboarding to reach a state\n // where the OAuth URL is on screen. Possible paths:\n //\n // • Fresh host: theme picker → \"Trust folder?\" → login method picker\n // (option 1 = Claude subscription, already highlighted) → OAuth URL.\n // No `/login` needed — option 1 IS the login.\n // • Already onboarded: regular prompt → we send `/login` ourselves →\n // login method picker → OAuth URL.\n //\n // Auto-advance every onboarding screen we recognize. Keep going until\n // we either see the URL prompt or hit the deadline.\n const onboardingDeadline = Date.now() + Math.min(45_000, timeoutMs);\n let lastDispatchAt = 0;\n let loginCommandSent = false;\n const dispatchEnter = async (): Promise<void> => {\n if (Date.now() - lastDispatchAt < 1_500) return;\n lastDispatchAt = Date.now();\n try { await sendKeys(session, 'C-m'); } catch { /* keep polling */ }\n };\n\n while (Date.now() < onboardingDeadline) {\n await sleep(pollIntervalMs);\n let pane: string;\n try {\n // VIEWPORT ONLY (no scrollback). With scrollback included, the\n // theme picker / login picker text lingers in the buffer after\n // claude advances to the URL-paste prompt. The match would then\n // dispatch Enter on the empty paste field — submitting an empty\n // code and triggering \"OAuth error: Invalid code\". Visible-only\n // capture ensures we only react to what's actually on screen.\n pane = await capturePane(session, { scrollback: 0 });\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n\n // Already at the URL prompt — done driving onboarding, fall through\n // to the URL-extraction loop below.\n if (isUrlPromptReady(pane)) {\n const url = extractOAuthUrl(pane);\n if (url) return { kind: 'url', url };\n }\n\n // Stuck on \"OAuth error: Invalid code. Press Enter to retry\" — usually\n // because claude has cached partial state from a previous failed login\n // attempt on this host. Pressing Enter would just resubmit the bad\n // code and loop forever, so bail with an actionable error.\n //\n // Capture WITH scrollback for this check: claude's TUI sometimes\n // re-renders the splash/banner over the OAuth error so the viewport\n // alone misses it (we'd then time out instead of giving the operator\n // an actionable error). OAuth-retry is a sticky state — claude waits\n // for input — so checking recent scrollback is safe; a false positive\n // from stale scrollback would only fire if a previous attempt actually\n // failed, in which case bailing is the right call.\n let scrollPane = pane;\n try {\n scrollPane = await capturePane(session, { scrollback: -50 });\n } catch { /* fall back to viewport-only check */ }\n const hasOAuthInvalidCode = /OAuth error[\\s\\S]*Invalid code/i.test(scrollPane);\n const hasOAuthRetryPrompt = /OAuth error/i.test(scrollPane) && /Press Enter to retry/i.test(scrollPane);\n if (hasOAuthInvalidCode || hasOAuthRetryPrompt) {\n return {\n kind: 'error',\n error: {\n kind: 'unknown',\n message:\n 'claude is stuck on a previous failed-login retry prompt. SSH to the host and clear ~/.claude/.credentials.json (and any *.json next to it), then retry pair-via-browser.',\n },\n };\n }\n\n // Login method picker — option 1 (Claude subscription) is already\n // highlighted, Enter selects it.\n if (/Select login method:/i.test(pane)) {\n await dispatchEnter();\n continue;\n }\n // First-run theme picker. Accept the highlighted default; operator\n // can change it later via /theme.\n if (/\\bDark mode\\b/.test(pane) && /\\bLight mode\\b/.test(pane)) {\n await dispatchEnter();\n continue;\n }\n // \"Trust this folder?\" — Enter accepts the default (Yes).\n if (/Do you trust the files in this folder\\?/i.test(pane)) {\n await dispatchEnter();\n continue;\n }\n // Onboarding \"Press Enter to continue\" splashes.\n if (/press\\s+enter\\s+to\\s+continue/i.test(pane)) {\n await dispatchEnter();\n continue;\n }\n // Regular interactive prompt — this means claude was already\n // onboarded. Send `/login` once to surface the login picker, then\n // the next iteration handles it via the picker branch above.\n const promptVisible =\n /[│|]\\s*>\\s/.test(pane) || /Try .+ to .+/.test(pane) || /^\\s*>\\s*$/m.test(pane);\n if (promptVisible && !loginCommandSent) {\n loginCommandSent = true;\n try {\n await sendKeys(session, '/login', 'C-m');\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n continue;\n }\n }\n\n // Onboarding deadline hit without seeing a URL — surface the pane so\n // operators can see what was on screen when we gave up.\n let lastPane = '';\n try { lastPane = await capturePane(session); } catch { /* best-effort */ }\n return {\n kind: 'error',\n error: {\n kind: 'unknown',\n message: `claude never reached OAuth URL prompt within ${timeoutMs}ms. Last pane: ${lastPane.slice(-500)}`,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// submit-code — paste the auth code, wait for outcome\n// ---------------------------------------------------------------------------\n\nexport interface ClaudePairSubmitOpts {\n /** Pair-scoped tmux session name (see pairTmuxSession). */\n session: string;\n code: string;\n /** Total time to wait for the success/failure marker. Default 30s. */\n timeoutMs?: number;\n pollIntervalMs?: number;\n}\n\nexport async function submitClaudePairCode(\n opts: ClaudePairSubmitOpts,\n): Promise<ClaudePairSubmitResult> {\n const { session } = opts;\n const timeoutMs = opts.timeoutMs ?? 30_000;\n const pollIntervalMs = opts.pollIntervalMs ?? 500;\n\n // Validate code shape minimally — Claude Code's auth codes are\n // alphanumeric with dashes, ~40-80 chars. Accept anything within\n // that envelope; reject blank or whitespace-only to avoid\n // accidentally submitting an empty buffer.\n if (!opts.code || !opts.code.trim()) {\n return {\n kind: 'error',\n error: { kind: 'unknown', message: 'empty auth code' },\n };\n }\n if (opts.code.length > 1024) {\n return {\n kind: 'error',\n error: { kind: 'unknown', message: 'auth code suspiciously long' },\n };\n }\n\n // Send the code + Enter. We use the literal value as one send-keys\n // argument; tmux handles spaces fine, but newlines would terminate\n // early so reject those as well.\n if (/[\\r\\n]/.test(opts.code)) {\n return {\n kind: 'error',\n error: { kind: 'unknown', message: 'auth code contains newline' },\n };\n }\n\n // Send the code as LITERAL text (-l) so tmux doesn't try to interpret\n // any chars as key tokens. Then a 250ms breather so claude's\n // ink/React render loop finishes processing the paste before Enter\n // lands. Without that wait the Enter often gets swallowed mid-render\n // and the code stays in the input box unsubmitted.\n try {\n await execFileAsync('tmux', ['send-keys', '-t', session, '-l', opts.code.trim()]);\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n await sleep(250);\n try {\n // Use Enter (semantic) plus a fallback C-m on the next iteration if\n // claude still hasn't moved.\n await sendKeys(session, 'Enter');\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n\n const deadline = Date.now() + timeoutMs;\n let enterRetried = false;\n let lastPane = '';\n while (Date.now() < deadline) {\n await sleep(pollIntervalMs);\n try {\n lastPane = await capturePane(session);\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n const outcome: AuthOutcome = detectAuthOutcome(lastPane);\n if (outcome.kind === 'success') return { kind: 'success', rawMatch: outcome.rawMatch };\n if (outcome.kind === 'failure') return { kind: 'failure', rawMatch: outcome.rawMatch };\n\n // After ~5s with no outcome, retry Enter once — covers the case\n // where the first Enter landed mid-render and claude swallowed it.\n // The \"Paste code here\" prompt is still on screen if submission\n // didn't take; if it advanced, we'd already have an outcome above.\n if (!enterRetried && Date.now() - (deadline - timeoutMs) > 5_000 && /Paste code here/i.test(lastPane)) {\n enterRetried = true;\n try { await sendKeys(session, 'C-m'); } catch { /* keep polling */ }\n }\n }\n // Include pane snippet in the error path so operators can see what\n // claude was actually showing — outcome detection is brittle and the\n // session is killed immediately after this returns, so this is our\n // only chance to capture state for debugging regex updates.\n return {\n kind: 'error',\n error: {\n kind: 'unknown',\n message: `submit timed out after ${timeoutMs}ms — outcome regex didn't match. Last pane: ${lastPane.slice(-600)}`,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// status — non-mutating peek at the pane state\n// ---------------------------------------------------------------------------\n\nexport async function getClaudePairStatus(session: string): Promise<ClaudePairStatusResult> {\n try {\n await execFileAsync('tmux', ['has-session', '-t', session]);\n } catch (err) {\n const classified = classifyTmuxError(err);\n if (classified.kind === 'no-session') return { kind: 'session-missing' };\n return { kind: 'error', error: classified };\n }\n\n let pane: string;\n try {\n pane = await capturePane(session);\n } catch (err) {\n return { kind: 'error', error: classifyTmuxError(err) };\n }\n\n // Outcome takes priority — if the pane already shows success/failure\n // from a recent submission, the API can short-circuit without\n // restarting the flow.\n const outcome = detectAuthOutcome(pane);\n if (outcome.kind === 'success') return { kind: 'success' };\n if (outcome.kind === 'failure') return { kind: 'failure', rawMatch: outcome.rawMatch };\n\n if (isUrlPromptReady(pane)) {\n const url = extractOAuthUrl(pane);\n if (url) return { kind: 'awaiting-code', url };\n }\n return { kind: 'idle' };\n}\n","/**\n * ENG-4580: pane-scrape parser for Claude Code's `/login` OAuth flow.\n *\n * The manager drives the flow by sending `/login` into the agent's\n * persistent tmux session, capturing the pane after a short poll, and\n * extracting the OAuth URL Claude Code prints. After the operator\n * pastes the auth code via the UI, the manager sends it back into the\n * pane and polls for a success / failure marker.\n *\n * Everything in this module is pure — no tmux calls, no fs I/O. The\n * runtime side (apps/cli/src/lib/manager-worker.ts) shells out and\n * feeds the captured pane string through these functions.\n *\n * Why a dedicated module: pane scraping is fragile across Claude Code\n * versions, terminal widths, and locale changes. Centralising the\n * regexes + the ANSI stripper makes them easy to fixture-test and\n * iterate on without touching the runtime path.\n */\n\n// ---------------------------------------------------------------------------\n// ANSI escape sequence stripper\n// ---------------------------------------------------------------------------\n\n/**\n * Strip the ANSI escape sequences a terminal emits for colour, cursor\n * movement, screen clears, and bracketed paste mode. The pattern below\n * covers:\n *\n * - CSI sequences: `ESC [ ... <final byte>` where the final byte is\n * in the 0x40-0x7E range (covers SGR colour, cursor-position,\n * erase-in-line/display, etc.)\n * - OSC sequences: `ESC ] ... BEL` or `ESC ] ... ESC \\` (used for\n * window titles and hyperlinks)\n * - Single-character `ESC <char>` two-byte escapes (e.g. `ESC =`,\n * `ESC >`, the `ESC c` reset)\n *\n * We keep newlines and printable text intact so pane content remains\n * matchable after stripping.\n *\n * The regex uses Unicode-friendly character classes; we explicitly\n * avoid `\\x1b` named escapes in source to keep the file ASCII-safe.\n */\nconst ANSI_ESC = String.fromCharCode(0x1b);\nconst ANSI_BEL = String.fromCharCode(0x07);\n\nconst CSI_RE = new RegExp(`${ANSI_ESC}\\\\[[0-?]*[ -/]*[@-~]`, 'g');\nconst OSC_RE = new RegExp(\n `${ANSI_ESC}\\\\][^${ANSI_BEL}${ANSI_ESC}]*(?:${ANSI_BEL}|${ANSI_ESC}\\\\\\\\)`,\n 'g',\n);\nconst TWO_BYTE_RE = new RegExp(`${ANSI_ESC}[=>cM78]`, 'g');\n\nexport function stripAnsi(text: string): string {\n return text.replace(CSI_RE, '').replace(OSC_RE, '').replace(TWO_BYTE_RE, '');\n}\n\n// ---------------------------------------------------------------------------\n// OAuth URL extraction\n// ---------------------------------------------------------------------------\n\n/**\n * Anchored to Anthropic-owned domains that Claude Code's `/login`\n * actually prints. Adding more hosts is fine — keep them allowlisted\n * rather than matching arbitrary `https://` to avoid pulling random\n * URLs from the user's previous shell output.\n *\n * Note: Claude Code currently emits URLs at `claude.com/cai/oauth/...`\n * (the consumer-facing domain), but `claude.ai` and the console hosts\n * have appeared historically and are kept here for tolerance.\n */\nconst OAUTH_URL_RE =\n /https:\\/\\/(?:claude\\.com|claude\\.ai|platform\\.claude\\.com|console\\.anthropic\\.com|auth\\.anthropic\\.com)\\/[^\\s)\\]]*/;\n\n/**\n * Strip ANSI + reassemble URLs that were soft-wrapped across terminal\n * lines. tmux capture-pane emits hard newlines for wrapped lines, and\n * Claude Code's OAuth URL routinely runs >300 chars — much wider than\n * the manager's default tmux window. Iteratively strip newlines that\n * fall inside what looks like a URL until convergence (a single URL\n * can wrap 5+ times).\n *\n * Shared between extractOAuthUrl and isUrlPromptReady so the readiness\n * check sees the same string the extractor would.\n */\nfunction dewrapPane(rawPane: string): string {\n const stripped = stripAnsi(rawPane);\n let dewrapped = stripped;\n let prev = '';\n while (prev !== dewrapped) {\n prev = dewrapped;\n dewrapped = dewrapped.replace(/(https?:\\/\\/\\S+?)\\n(?=\\S)/, (_m, head: string) => head);\n }\n return dewrapped;\n}\n\nexport function extractOAuthUrl(rawPane: string): string | null {\n const match = OAUTH_URL_RE.exec(dewrapPane(rawPane));\n if (!match) return null;\n // Trim trailing punctuation that often clings to URLs in TUIs.\n return match[0].replace(/[.,;:!?]+$/, '');\n}\n\n// ---------------------------------------------------------------------------\n// Prompt readiness — \"we've printed the URL, now waiting for a code\"\n// ---------------------------------------------------------------------------\n\nconst URL_PROMPT_RE =\n /(?:Paste code here|Paste your code|Enter (?:the )?code|Authorization code)/i;\n\nexport function isUrlPromptReady(rawPane: string): boolean {\n const dewrapped = dewrapPane(rawPane);\n // Both anchors must be present: the URL itself AND the paste-code\n // prompt. The prompt alone could appear during a stale screen redraw;\n // the URL alone could be a stray match in command history. Use the\n // dewrapped pane so a wrapped URL still matches OAUTH_URL_RE.\n return OAUTH_URL_RE.test(dewrapped) && URL_PROMPT_RE.test(dewrapped);\n}\n\n// ---------------------------------------------------------------------------\n// Outcome detection after submitting the code\n// ---------------------------------------------------------------------------\n\n// Claude Code's success/failure copy has drifted across versions\n// (\"Logged in\" → \"Login successful\" → \"Signed in as ...\" → etc.).\n// Keep the alternations broad-but-specific — phrases that only appear\n// after a real auth roundtrip, never in welcome / tutorial text.\n// \"Welcome back\" was tried and rejected: it shows up in onboarding\n// help blurbs and produced false positives.\nconst SUCCESS_RE =\n /(?:Logged in|Login successful|Successfully (?:logged in|authenticated|signed in)|Authentication successful|Sign-?in (?:complete|successful)|You(?:'|’)?re signed in|Signed in as)/i;\nconst FAILURE_RE =\n /(?:Invalid (?:code|authorization code)|OAuth error|Authentication failed|Error (?:logging in|during authentication)|Login failed|Sign-?in failed|Failed to (?:authenticate|sign in|log in))/i;\n\nexport type AuthOutcome =\n | { kind: 'success'; rawMatch: string }\n | { kind: 'failure'; rawMatch: string }\n | { kind: 'pending' };\n\nexport function detectAuthOutcome(rawPane: string): AuthOutcome {\n const stripped = stripAnsi(rawPane);\n // Failure first — Claude Code sometimes prints a stale \"logged in\" from\n // a previous successful session above the new failure banner. The\n // most-recent line wins, so we scan from the end of the pane.\n const failureMatch = lastMatch(stripped, FAILURE_RE);\n const successMatch = lastMatch(stripped, SUCCESS_RE);\n\n if (failureMatch && successMatch) {\n // Whichever is later on the pane is the live state.\n if (failureMatch.index > successMatch.index) {\n return { kind: 'failure', rawMatch: failureMatch.match };\n }\n return { kind: 'success', rawMatch: successMatch.match };\n }\n if (failureMatch) return { kind: 'failure', rawMatch: failureMatch.match };\n if (successMatch) return { kind: 'success', rawMatch: successMatch.match };\n return { kind: 'pending' };\n}\n\nfunction lastMatch(haystack: string, re: RegExp): { match: string; index: number } | null {\n // Construct a sticky/global variant if needed. Most of our REs are\n // anchored to small phrases; iterating with a `g`-flagged RegExp is\n // cheap and correct.\n const globalRe = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);\n let last: RegExpExecArray | null = null;\n let m: RegExpExecArray | null;\n while ((m = globalRe.exec(haystack)) !== null) {\n last = m;\n // Prevent zero-length matches from looping.\n if (m.index === globalRe.lastIndex) globalRe.lastIndex++;\n }\n return last ? { match: last[0], index: last.index } : null;\n}\n\n// ---------------------------------------------------------------------------\n// \"Session not running / tmux missing\" — surface as a structured signal\n// ---------------------------------------------------------------------------\n\n/**\n * The runtime path will throw when `tmux capture-pane` fails. This\n * helper classifies the failure for the API layer so the UI can show\n * \"start a session first\" rather than a generic 500.\n */\nexport type SessionError =\n | { kind: 'no-session' }\n | { kind: 'tmux-missing' }\n | { kind: 'pane-empty' }\n | { kind: 'unknown'; message: string };\n\nexport function classifyTmuxError(err: unknown): SessionError {\n const msg = err instanceof Error ? err.message : String(err);\n if (/can't find session|no server running/i.test(msg)) return { kind: 'no-session' };\n if (/command not found.*tmux|ENOENT.*tmux/i.test(msg)) return { kind: 'tmux-missing' };\n return { kind: 'unknown', message: msg };\n}\n"],"mappings":"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","names":[]}
@@ -22,7 +22,7 @@ import {
22
22
  resolveChannels,
23
23
  resolveDmTarget,
24
24
  wrapScheduledTaskPrompt
25
- } from "../chunk-6DIVYBKA.js";
25
+ } from "../chunk-TPR5XCFY.js";
26
26
  import {
27
27
  findTaskByTemplate,
28
28
  getProjectDir,
@@ -42,11 +42,11 @@ import {
42
42
  startPersistentSession,
43
43
  stopAllSessionsAndWait,
44
44
  stopPersistentSession
45
- } from "../chunk-66ZLF2MI.js";
45
+ } from "../chunk-AFUG4KD3.js";
46
46
 
47
47
  // src/lib/manager-worker.ts
48
48
  import { createHash } from "crypto";
49
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, appendFileSync, mkdirSync as mkdirSync2, chmodSync, existsSync as existsSync2, rmSync as rmSync2, readdirSync as readdirSync2, statSync, unlinkSync, copyFileSync } from "fs";
49
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, chmodSync, existsSync as existsSync2, rmSync as rmSync2, readdirSync as readdirSync2, statSync, unlinkSync, copyFileSync } from "fs";
50
50
  import https from "https";
51
51
  import { execFileSync as syncExecFile } from "child_process";
52
52
  import { join as join3, dirname } from "path";
@@ -1435,6 +1435,7 @@ function clearAgentCaches(agentId, codeName) {
1435
1435
  var cachedFrameworkVersion = null;
1436
1436
  var lastVersionCheckAt = 0;
1437
1437
  var VERSION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
1438
+ var agtCliVersion = true ? "0.15.36" : "dev";
1438
1439
  function resolveBrewPath(execFileSync2) {
1439
1440
  try {
1440
1441
  const out = execFileSync2("which", ["brew"], { timeout: 5e3 }).toString().trim();
@@ -1447,7 +1448,21 @@ function resolveBrewPath(execFileSync2) {
1447
1448
  }
1448
1449
  var toolkitCliEnsured = /* @__PURE__ */ new Set();
1449
1450
  var toolkitCliRetryAfter = /* @__PURE__ */ new Map();
1451
+ var toolkitCliFailureCount = /* @__PURE__ */ new Map();
1450
1452
  var TOOLKIT_INSTALL_RETRY_MS = 5 * 6e4;
1453
+ var TOOLKIT_INSTALL_MAX_FAILURES = 3;
1454
+ function recordToolkitFailure(toolkitSlug, reason) {
1455
+ const count = (toolkitCliFailureCount.get(toolkitSlug) ?? 0) + 1;
1456
+ toolkitCliFailureCount.set(toolkitSlug, count);
1457
+ if (count >= TOOLKIT_INSTALL_MAX_FAILURES) {
1458
+ log(`[toolkit-install] ${toolkitSlug}: ${reason} (giving up after ${count} attempts \u2014 restart the manager to retry)`);
1459
+ toolkitCliEnsured.add(toolkitSlug);
1460
+ toolkitCliRetryAfter.delete(toolkitSlug);
1461
+ } else {
1462
+ log(`[toolkit-install] ${toolkitSlug}: ${reason} (attempt ${count}/${TOOLKIT_INSTALL_MAX_FAILURES}, retrying in ${TOOLKIT_INSTALL_RETRY_MS / 6e4}m)`);
1463
+ toolkitCliRetryAfter.set(toolkitSlug, Date.now() + TOOLKIT_INSTALL_RETRY_MS);
1464
+ }
1465
+ }
1451
1466
  async function ensureToolkitCli(toolkitSlug) {
1452
1467
  if (toolkitCliEnsured.has(toolkitSlug)) return;
1453
1468
  const retryAfter = toolkitCliRetryAfter.get(toolkitSlug) ?? 0;
@@ -1464,6 +1479,7 @@ async function ensureToolkitCli(toolkitSlug) {
1464
1479
  execFileSync2("which", [binary], { timeout: 5e3, stdio: "pipe" });
1465
1480
  toolkitCliEnsured.add(toolkitSlug);
1466
1481
  toolkitCliRetryAfter.delete(toolkitSlug);
1482
+ toolkitCliFailureCount.delete(toolkitSlug);
1467
1483
  return;
1468
1484
  } catch {
1469
1485
  }
@@ -1498,7 +1514,7 @@ async function ensureToolkitCli(toolkitSlug) {
1498
1514
  const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1499
1515
  log(`[toolkit-install] ${toolkitSlug}: installing via brew (${pkg})\u2026`);
1500
1516
  if (isRoot) {
1501
- execFileSync2("sudo", ["-u", "ec2-user", "-H", brewPath, "install", pkg], { timeout: 18e4, stdio: "pipe" });
1517
+ execFileSync2("sudo", ["-u", "ec2-user", "-H", brewPath, "install", pkg], { timeout: 18e4, stdio: "pipe", cwd: "/tmp" });
1502
1518
  } else {
1503
1519
  execFileSync2(brewPath, ["install", pkg], { timeout: 18e4, stdio: "pipe" });
1504
1520
  }
@@ -1513,8 +1529,7 @@ async function ensureToolkitCli(toolkitSlug) {
1513
1529
  }
1514
1530
  } catch (err) {
1515
1531
  const msg = err.message.slice(0, 200);
1516
- log(`[toolkit-install] ${toolkitSlug}: installer=${resolvedInstaller} failed \u2014 ${msg} (retrying in ${TOOLKIT_INSTALL_RETRY_MS / 6e4}m)`);
1517
- toolkitCliRetryAfter.set(toolkitSlug, Date.now() + TOOLKIT_INSTALL_RETRY_MS);
1532
+ recordToolkitFailure(toolkitSlug, `installer=${resolvedInstaller} failed \u2014 ${msg}`);
1518
1533
  return;
1519
1534
  }
1520
1535
  if (brewBinDir && !process.env.PATH?.split(":").includes(brewBinDir)) {
@@ -1525,15 +1540,15 @@ async function ensureToolkitCli(toolkitSlug) {
1525
1540
  log(`[toolkit-install] ${toolkitSlug}: installed \u2014 ${binary} now on PATH`);
1526
1541
  toolkitCliEnsured.add(toolkitSlug);
1527
1542
  toolkitCliRetryAfter.delete(toolkitSlug);
1543
+ toolkitCliFailureCount.delete(toolkitSlug);
1528
1544
  } catch {
1529
- log(`[toolkit-install] ${toolkitSlug}: installer=${resolvedInstaller} completed but ${binary} still not on PATH (retrying in ${TOOLKIT_INSTALL_RETRY_MS / 6e4}m)`);
1530
- toolkitCliRetryAfter.set(toolkitSlug, Date.now() + TOOLKIT_INSTALL_RETRY_MS);
1545
+ recordToolkitFailure(toolkitSlug, `installer=${resolvedInstaller} completed but ${binary} still not on PATH`);
1531
1546
  }
1532
1547
  }
1533
1548
  function runAsync(cmd, args, opts) {
1534
1549
  return new Promise((resolve, reject) => {
1535
1550
  import("child_process").then(({ spawn }) => {
1536
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1551
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd: opts.cwd });
1537
1552
  let stdout = "";
1538
1553
  let stderr = "";
1539
1554
  let settled = false;
@@ -1584,7 +1599,7 @@ async function ensureFrameworkBinary(frameworkId) {
1584
1599
  const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1585
1600
  const runBrew = (args, opts) => {
1586
1601
  if (isRoot) {
1587
- return runAsync("sudo", ["-u", "ec2-user", "-H", brewPath, ...args], opts);
1602
+ return runAsync("sudo", ["-u", "ec2-user", "-H", brewPath, ...args], { ...opts, cwd: "/tmp" });
1588
1603
  }
1589
1604
  return runAsync(brewPath, args, opts);
1590
1605
  };
@@ -1812,19 +1827,15 @@ function log(msg) {
1812
1827
  const safeMsg = redactForDiskLog(msg);
1813
1828
  process.stderr.write(`[manager-worker ${ts}] ${safeMsg}
1814
1829
  `);
1815
- try {
1816
- if (!managerLogPath) {
1830
+ if (!managerLogPath) {
1831
+ try {
1817
1832
  managerLogPath = join3(homedir3(), ".augmented", "manager.log");
1818
1833
  mkdirSync2(dirname(managerLogPath), { recursive: true });
1819
- if (!existsSync2(managerLogPath)) {
1820
- appendFileSync(managerLogPath, "", { mode: 384 });
1821
- } else {
1834
+ if (existsSync2(managerLogPath)) {
1822
1835
  chmodSync(managerLogPath, 384);
1823
1836
  }
1837
+ } catch {
1824
1838
  }
1825
- appendFileSync(managerLogPath, `[manager-worker ${ts}] ${safeMsg}
1826
- `);
1827
- } catch {
1828
1839
  }
1829
1840
  }
1830
1841
  function sha256(content) {
@@ -2206,7 +2217,7 @@ async function pollCycle() {
2206
2217
  }
2207
2218
  try {
2208
2219
  const { detectHostSecurity } = await import("../host-security-6PDFG7F5.js");
2209
- const { collectDiagnostics } = await import("../persistent-session-HUQXZSHP.js");
2220
+ const { collectDiagnostics } = await import("../persistent-session-VRS3MFQ3.js");
2210
2221
  const diagCodeNames = [...persistentSessionAgents];
2211
2222
  const agentDiagnostics = diagCodeNames.length > 0 ? collectDiagnostics(diagCodeNames) : void 0;
2212
2223
  let tailscaleHostname;
@@ -2242,6 +2253,7 @@ async function pollCycle() {
2242
2253
  await api.post("/host/heartbeat", {
2243
2254
  host_id: hostId,
2244
2255
  framework_version: cachedFrameworkVersion ?? void 0,
2256
+ agt_version: agtCliVersion,
2245
2257
  host_security: detectHostSecurity() ?? void 0,
2246
2258
  agent_runtime_authenticated: agentRuntimeAuthenticated,
2247
2259
  agent_diagnostics: agentDiagnostics,
@@ -5244,19 +5256,32 @@ async function reportDeliveryStatus(agentId, taskId, payload) {
5244
5256
  log(`[delivery] Failed to report delivery status for ${agentId}/${taskId}: ${err.message}`);
5245
5257
  }
5246
5258
  }
5259
+ var spawnedPairIds = /* @__PURE__ */ new Set();
5247
5260
  async function processClaudePairSessions(agents) {
5248
- if (agents.length === 0) return;
5261
+ if (agents.length === 0 && spawnedPairIds.size === 0) return;
5249
5262
  const agentIds = agents.map((a) => a.agentId);
5250
5263
  const codeNameByAgentId = new Map(agents.map((a) => [a.agentId, a.codeName]));
5251
- const pendingResp = await api.post("/host/claude-pair/pending", { agent_ids: agentIds });
5252
- if (!pendingResp.pending || pendingResp.pending.length === 0) return;
5264
+ const pendingResp = await api.post("/host/claude-pair/pending", {
5265
+ agent_ids: agentIds,
5266
+ spawned_pair_ids: Array.from(spawnedPairIds)
5267
+ });
5253
5268
  const {
5254
5269
  startClaudePair,
5255
5270
  submitClaudePairCode,
5256
5271
  spawnPairSession,
5257
5272
  killPairSession,
5258
5273
  pairTmuxSession
5259
- } = await import("../claude-pair-runtime-H4YBW4YE.js");
5274
+ } = await import("../claude-pair-runtime-GLO2D7WP.js");
5275
+ for (const pairId of pendingResp.cancelled_pair_ids ?? []) {
5276
+ log(`[claude-pair] sweeping orphan tmux session for pair ${pairId.slice(0, 8)}`);
5277
+ const killed = await killPairSession(pairTmuxSession(pairId));
5278
+ if (killed) {
5279
+ spawnedPairIds.delete(pairId);
5280
+ } else {
5281
+ log(`[claude-pair] kill-session failed for pair ${pairId.slice(0, 8)} \u2014 will retry on next poll`);
5282
+ }
5283
+ }
5284
+ if (!pendingResp.pending || pendingResp.pending.length === 0) return;
5260
5285
  const TERMINAL = /* @__PURE__ */ new Set([
5261
5286
  "success",
5262
5287
  "failure",
@@ -5266,7 +5291,13 @@ async function processClaudePairSessions(agents) {
5266
5291
  async function reportAndCleanup(pairId, body) {
5267
5292
  await api.post("/host/claude-pair/result", { pair_id: pairId, ...body });
5268
5293
  if (typeof body.status === "string" && TERMINAL.has(body.status)) {
5269
- await killPairSession(pairTmuxSession(pairId));
5294
+ if (body.status === "success") {
5295
+ await new Promise((r) => setTimeout(r, 3e3));
5296
+ }
5297
+ const killed = await killPairSession(pairTmuxSession(pairId));
5298
+ if (killed) {
5299
+ spawnedPairIds.delete(pairId);
5300
+ }
5270
5301
  }
5271
5302
  }
5272
5303
  for (const session of pendingResp.pending) {
@@ -5284,6 +5315,7 @@ async function processClaudePairSessions(agents) {
5284
5315
  });
5285
5316
  continue;
5286
5317
  }
5318
+ spawnedPairIds.add(session.pair_id);
5287
5319
  log(`[claude-pair] dispatching /login (pair ${session.pair_id.slice(0, 8)})`);
5288
5320
  const result = await startClaudePair({ session: pairSession });
5289
5321
  if (result.kind === "url") {