diff --git a/CHANGELOG.md b/CHANGELOG.md index bb970eca1..0f337c53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. ### Fixes +- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. +- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. +- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. - WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75. - Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178. diff --git a/package.json b/package.json index ce18123ce..61c60d401 100644 --- a/package.json +++ b/package.json @@ -85,10 +85,10 @@ "@clack/prompts": "^0.11.0", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.36.0", - "@mariozechner/pi-ai": "^0.36.0", - "@mariozechner/pi-coding-agent": "^0.36.0", - "@mariozechner/pi-tui": "^0.36.0", + "@mariozechner/pi-agent-core": "^0.37.2", + "@mariozechner/pi-ai": "^0.37.2", + "@mariozechner/pi-coding-agent": "^0.37.2", + "@mariozechner/pi-tui": "^0.37.2", "@sinclair/typebox": "0.34.46", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.13.0", @@ -110,6 +110,7 @@ "json5": "^2.2.3", "long": "5.3.2", "playwright-core": "1.57.0", + "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "tslog": "^4.10.2", @@ -126,6 +127,7 @@ "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.3", + "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce9e10913..7506239cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,17 +29,17 @@ importers: specifier: ^1.3.4 version: 1.3.4 '@mariozechner/pi-agent-core': - specifier: ^0.36.0 - version: 0.36.0(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': - specifier: ^0.36.0 - version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': - specifier: ^0.36.0 - version: 0.36.0(ws@8.19.0)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': - specifier: ^0.36.0 - version: 0.36.0 + specifier: ^0.37.2 + version: 0.37.2 '@sinclair/typebox': specifier: 0.34.46 version: 0.34.46 @@ -103,6 +103,9 @@ importers: playwright-core: specifier: 1.57.0 version: 1.57.0 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 qrcode-terminal: specifier: ^0.12.0 version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12) @@ -146,6 +149,9 @@ importers: '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -804,22 +810,22 @@ packages: peerDependencies: lit: ^3.3.1 - '@mariozechner/pi-agent-core@0.36.0': - resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==} + '@mariozechner/pi-agent-core@0.37.2': + resolution: {integrity: sha512-GAN1lDVmlY1yH/FCfvpH29f2WBoqqMQkda7zKthOJO9l8tagxnlCWtq078CjzUGYlTDhKSf388XlOuDByBGYLA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.36.0': - resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==} + '@mariozechner/pi-ai@0.37.2': + resolution: {integrity: sha512-IhhvlPrgkdrlbS7QnV+qJPmlzKyae/aI1kenclG18/dXCypxUU50OuzGoVwrXvXw/RIHRwodhd7w4IH38Z7W4Q==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.36.0': - resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==} + '@mariozechner/pi-coding-agent@0.37.2': + resolution: {integrity: sha512-wRFqcyY76h4mONO1si2oAn9WVKnhmVV28dPHjQXVPrl7uSwMCLn+Fcde/nmbL29pYfiU1il4GmUR+iSyoxBUVQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.36.0': - resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==} + '@mariozechner/pi-tui@0.37.2': + resolution: {integrity: sha512-XNV+jEeWJxQ8U3r5njRotVs6DnEIunkLHSA4nnF4OaRRgrcsafD8M4Pm/3RywSucclVK8P7+KoGiBB2Lokkmuw==} engines: {node: '>=20.0.0'} '@mistralai/mistralai@1.10.0': @@ -1272,6 +1278,9 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -1284,6 +1293,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -3588,10 +3600,10 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.36.0(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-agent-core@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-ai': 0.37.2(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -3600,7 +3612,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.37.2(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3620,12 +3632,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.36.0(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-coding-agent@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: '@crosscopy/clipboard': 0.2.8 - '@mariozechner/pi-agent-core': 0.36.0(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-agent-core': 0.37.2(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.37.2(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.2 @@ -3633,6 +3645,7 @@ snapshots: glob: 11.1.0 jiti: 2.6.1 marked: 15.0.12 + proper-lockfile: 4.1.2 sharp: 0.34.5 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3642,7 +3655,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.36.0': + '@mariozechner/pi-tui@0.37.2': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -4038,6 +4051,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qrcode-terminal@0.12.2': {} '@types/qs@6.14.0': {} @@ -4046,6 +4063,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/retry@0.12.5': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.0.3 diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index f9999f17e..f390061dc 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -6,6 +6,7 @@ import { type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai"; +import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthPath } from "../config/paths.js"; @@ -68,6 +69,83 @@ function saveJsonFile(pathname: string, data: unknown) { fs.chmodSync(pathname, 0o600); } +function ensureAuthStoreFile(pathname: string) { + if (fs.existsSync(pathname)) return; + const payload: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: {}, + }; + saveJsonFile(pathname, payload); +} + +function buildOAuthApiKey( + provider: OAuthProvider, + credentials: OAuthCredentials, +): string { + const needsProjectId = + provider === "google-gemini-cli" || provider === "google-antigravity"; + return needsProjectId + ? JSON.stringify({ + token: credentials.access, + projectId: credentials.projectId, + }) + : credentials.access; +} + +async function refreshOAuthTokenWithLock(params: { + profileId: string; + provider: OAuthProvider; +}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const authPath = resolveAuthStorePath(); + ensureAuthStoreFile(authPath); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(authPath, { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, + }); + + const store = ensureAuthProfileStore(); + const cred = store.profiles[params.profileId]; + if (!cred || cred.type !== "oauth") return null; + + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + newCredentials: cred, + }; + } + + const oauthCreds: Record = { + [cred.provider]: cred, + }; + const result = await getOAuthApiKey(cred.provider, oauthCreds); + if (!result) return null; + store.profiles[params.profileId] = { + ...cred, + ...result.newCredentials, + type: "oauth", + }; + saveAuthProfileStore(store); + return result; + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} + function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; @@ -323,23 +401,41 @@ export async function resolveApiKeyForProfile(params: { if (cred.type === "api_key") { return { apiKey: cred.key, provider: cred.provider, email: cred.email }; } + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + provider: cred.provider, + email: cred.email, + }; + } - const oauthCreds: Record = { - [cred.provider]: cred, - }; - const result = await getOAuthApiKey(cred.provider, oauthCreds); - if (!result) return null; - store.profiles[profileId] = { - ...cred, - ...result.newCredentials, - type: "oauth", - }; - saveAuthProfileStore(store); - return { - apiKey: result.apiKey, - provider: cred.provider, - email: cred.email, - }; + try { + const result = await refreshOAuthTokenWithLock({ + profileId, + provider: cred.provider, + }); + if (!result) return null; + return { + apiKey: result.apiKey, + provider: cred.provider, + email: cred.email, + }; + } catch (error) { + const refreshedStore = ensureAuthProfileStore(); + const refreshed = refreshedStore.profiles[profileId]; + if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { + return { + apiKey: buildOAuthApiKey(refreshed.provider, refreshed), + provider: refreshed.provider, + email: refreshed.email ?? cred.email, + }; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `OAuth token refresh failed for ${cred.provider}: ${message}. ` + + "Please try again or re-authenticate.", + ); + } } export function markAuthProfileGood(params: { diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index cc27f95ef..5666e85ea 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -113,6 +113,7 @@ export type EmbeddedPiCompactResult = { type EmbeddedPiQueueHandle = { queueMessage: (text: string) => Promise; isStreaming: () => boolean; + isCompacting: () => boolean; abort: () => void; }; @@ -212,6 +213,7 @@ export function queueEmbeddedPiMessage( const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); if (!handle) return false; if (!handle.isStreaming()) return false; + if (handle.isCompacting()) return false; void handle.queueMessage(text); return true; } @@ -810,21 +812,7 @@ export async function runEmbeddedPiAgent(params: { aborted = true; void session.abort(); }; - const queueHandle: EmbeddedPiQueueHandle = { - queueMessage: async (text: string) => { - await session.steer(text); - }, - isStreaming: () => session.isStreaming, - abort: abortRun, - }; - ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); - - const { - assistantTexts, - toolMetas, - unsubscribe, - waitForCompactionRetry, - } = subscribeEmbeddedPiSession({ + const subscription = subscribeEmbeddedPiSession({ session, runId: params.runId, verboseLevel: params.verboseLevel, @@ -837,6 +825,22 @@ export async function runEmbeddedPiAgent(params: { onAgentEvent: params.onAgentEvent, enforceFinalTag: params.enforceFinalTag, }); + const { + assistantTexts, + toolMetas, + unsubscribe, + waitForCompactionRetry, + } = subscription; + + const queueHandle: EmbeddedPiQueueHandle = { + queueMessage: async (text: string) => { + await session.steer(text); + }, + isStreaming: () => session.isStreaming, + isCompacting: () => subscription.isCompacting(), + abort: abortRun, + }; + ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); let abortWarnTimer: NodeJS.Timeout | undefined; const abortTimer = setTimeout( diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 8c7751c51..c22316357 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -968,6 +968,7 @@ describe("subscribeEmbeddedPiSession", () => { }); } + expect(subscription.isCompacting()).toBe(true); expect(subscription.assistantTexts.length).toBe(0); let resolved = false; @@ -1004,6 +1005,8 @@ describe("subscribeEmbeddedPiSession", () => { listener({ type: "auto_compaction_start" }); } + expect(subscription.isCompacting()).toBe(true); + let resolved = false; const waitPromise = subscription.waitForCompactionRetry().then(() => { resolved = true; @@ -1018,6 +1021,7 @@ describe("subscribeEmbeddedPiSession", () => { await waitPromise; expect(resolved).toBe(true); + expect(subscription.isCompacting()).toBe(false); }); it("waits for multiple compaction retries before resolving", async () => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 330422efd..e87ff74e8 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -604,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: { assistantTexts, toolMetas, unsubscribe, + isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, waitForCompactionRetry: () => { if (compactionInFlight || pendingCompactionRetry > 0) { ensureCompactionPromise(); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 9f99c62c7..724dfec01 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -356,12 +356,19 @@ export async function runOnboardingWizard( "OpenAI Codex OAuth", ); const spin = prompter.progress("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; try { const creds = await loginOpenAICodex({ onAuth: async ({ url }) => { if (isRemote) { spin.stop("OAuth URL ready"); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }) + .then((value) => String(value)); } else { spin.update("Complete sign-in in browser…"); await openUrl(url); @@ -369,6 +376,9 @@ export async function runOnboardingWizard( } }, onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } const code = await prompter.text({ message: prompt.message, placeholder: prompt.placeholder, @@ -376,6 +386,20 @@ export async function runOnboardingWizard( }); return String(code); }, + onManualCodeInput: isRemote + ? () => { + if (!manualCodePromise) { + manualCodePromise = prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => + value?.trim() ? undefined : "Required", + }) + .then((value) => String(value)); + } + return manualCodePromise; + } + : undefined, onProgress: (msg) => spin.update(msg), }); spin.stop("OpenAI OAuth complete");