fix: improve compaction queueing and oauth flows

This commit is contained in:
Peter Steinberger
2026-01-06 05:33:08 +01:00
parent 9ab0b88ac6
commit 77789cb9a8
8 changed files with 213 additions and 60 deletions

View File

@@ -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.

View File

@@ -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",

69
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<void>) | 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<string, OAuthCredentials> = {
[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<string, unknown>;
@@ -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<string, OAuthCredentials> = {
[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: {

View File

@@ -113,6 +113,7 @@ export type EmbeddedPiCompactResult = {
type EmbeddedPiQueueHandle = {
queueMessage: (text: string) => Promise<void>;
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(

View File

@@ -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 () => {

View File

@@ -604,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts,
toolMetas,
unsubscribe,
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise();

View File

@@ -356,12 +356,19 @@ export async function runOnboardingWizard(
"OpenAI Codex OAuth",
);
const spin = prompter.progress("Starting OAuth flow…");
let manualCodePromise: Promise<string> | 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");