pi-ui-extend 0.1.13 → 0.1.15

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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/dist/app/app.d.ts +5 -0
  3. package/dist/app/app.js +82 -12
  4. package/dist/app/commands/command-controller.js +1 -0
  5. package/dist/app/commands/command-host.d.ts +3 -0
  6. package/dist/app/commands/command-model-actions.d.ts +2 -0
  7. package/dist/app/commands/command-model-actions.js +40 -4
  8. package/dist/app/commands/command-navigation-actions.js +3 -0
  9. package/dist/app/commands/command-registry.d.ts +1 -0
  10. package/dist/app/commands/command-registry.js +8 -0
  11. package/dist/app/extensions/extension-ui-controller.d.ts +16 -5
  12. package/dist/app/extensions/extension-ui-controller.js +99 -61
  13. package/dist/app/input/input-action-controller.d.ts +1 -0
  14. package/dist/app/input/input-action-controller.js +8 -2
  15. package/dist/app/logger.d.ts +25 -0
  16. package/dist/app/logger.js +90 -0
  17. package/dist/app/model/model-usage-status.js +30 -15
  18. package/dist/app/popup/menu-items-controller.d.ts +2 -0
  19. package/dist/app/popup/menu-items-controller.js +45 -6
  20. package/dist/app/popup/popup-action-controller.d.ts +2 -1
  21. package/dist/app/popup/popup-action-controller.js +7 -4
  22. package/dist/app/popup/popup-menu-controller.d.ts +36 -23
  23. package/dist/app/popup/popup-menu-controller.js +68 -322
  24. package/dist/app/rendering/conversation-entry-renderer.js +3 -3
  25. package/dist/app/rendering/conversation-viewport.d.ts +10 -2
  26. package/dist/app/rendering/conversation-viewport.js +157 -16
  27. package/dist/app/rendering/editor-panels.js +4 -2
  28. package/dist/app/rendering/popup-menu-renderer.d.ts +50 -0
  29. package/dist/app/rendering/popup-menu-renderer.js +307 -0
  30. package/dist/app/rendering/render-controller.js +5 -13
  31. package/dist/app/rendering/status-line-renderer.d.ts +1 -1
  32. package/dist/app/rendering/status-line-renderer.js +27 -24
  33. package/dist/app/rendering/toast-controller.d.ts +11 -3
  34. package/dist/app/rendering/toast-controller.js +53 -12
  35. package/dist/app/runtime.d.ts +2 -1
  36. package/dist/app/runtime.js +20 -10
  37. package/dist/app/screen/mouse-controller.d.ts +2 -2
  38. package/dist/app/screen/mouse-controller.js +27 -48
  39. package/dist/app/screen/screen-styler.d.ts +1 -1
  40. package/dist/app/screen/screen-styler.js +9 -7
  41. package/dist/app/screen/scroll-controller.d.ts +11 -9
  42. package/dist/app/screen/scroll-controller.js +50 -45
  43. package/dist/app/session/lazy-session-manager.d.ts +11 -0
  44. package/dist/app/session/lazy-session-manager.js +539 -0
  45. package/dist/app/session/pix-system-message.d.ts +16 -0
  46. package/dist/app/session/pix-system-message.js +64 -0
  47. package/dist/app/session/session-event-controller.d.ts +11 -0
  48. package/dist/app/session/session-event-controller.js +58 -2
  49. package/dist/app/session/session-history.d.ts +18 -0
  50. package/dist/app/session/session-history.js +72 -3
  51. package/dist/app/session/session-lifecycle-controller.d.ts +6 -2
  52. package/dist/app/session/session-lifecycle-controller.js +7 -2
  53. package/dist/app/session/tabs-controller.d.ts +13 -1
  54. package/dist/app/session/tabs-controller.js +248 -27
  55. package/dist/app/todo/todo-model.d.ts +3 -1
  56. package/dist/app/todo/todo-model.js +14 -2
  57. package/dist/app/types.d.ts +5 -2
  58. package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
  59. package/dist/app/workspace/workspace-actions-controller.js +12 -0
  60. package/dist/config.d.ts +5 -1
  61. package/dist/config.js +73 -25
  62. package/dist/default-pix-config.js +2 -0
  63. package/dist/schemas/pi-tools-suite-schema.d.ts +1 -0
  64. package/dist/schemas/pi-tools-suite-schema.js +1 -0
  65. package/dist/schemas/pix-schema.d.ts +2 -1
  66. package/dist/schemas/pix-schema.js +5 -4
  67. package/dist/terminal-width.d.ts +2 -0
  68. package/dist/terminal-width.js +64 -3
  69. package/external/pi-tools-suite/README.md +1 -0
  70. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +12 -3
  71. package/external/pi-tools-suite/src/antigravity-auth/commands.ts +2 -4
  72. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +2 -2
  73. package/external/pi-tools-suite/src/antigravity-auth/index.ts +8 -2
  74. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +102 -50
  75. package/external/pi-tools-suite/src/antigravity-auth/status.ts +81 -2
  76. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +29 -8
  77. package/external/pi-tools-suite/src/config.ts +8 -0
  78. package/external/pi-tools-suite/src/dcp/index.ts +16 -1
  79. package/external/pi-tools-suite/src/dcp/state.ts +35 -0
  80. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +3 -0
  81. package/external/pi-tools-suite/src/todo/index.ts +181 -11
  82. package/external/pi-tools-suite/src/todo/state/state-reducer.ts +23 -10
  83. package/external/pi-tools-suite/src/todo/todo.ts +10 -5
  84. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +33 -6
  85. package/external/pi-tools-suite/src/todo/tool/types.ts +9 -1
  86. package/external/pi-tools-suite/src/todo/view/format.ts +2 -1
  87. package/external/pi-tools-suite/src/tool-descriptions.ts +2 -1
  88. package/external/pi-tools-suite/src/usage/index.ts +5 -2
  89. package/external/pi-tools-suite/src/usage/lib/google.ts +6 -13
  90. package/package.json +1 -1
  91. package/schemas/pi-tools-suite.json +4 -0
  92. package/schemas/pix.json +6 -2
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import type { OAuthCredentials } from "@earendil-works/pi-ai";
3
3
  import { CLIENT_ID, CLIENT_SECRET, DEFAULT_PROJECT_ID, LOAD_ENDPOINTS, REDIRECT_URI, SCOPES, TOKEN_EXPIRY_SKEW_MS, PROVIDER_ID } from "./constants";
4
- import { accountFromCredential, clampAccountIndex, decodeApiKey, encodeApiKey, findMatchingAccountIndex, getAccountProjectId, getPiAuthPath, getStoredAccounts, joinRefresh, readJsonFile, splitRefresh, writeJsonFileSecure } from "./auth-store";
4
+ import { accountFromCredential, clampAccountIndex, encodeApiKey, findMatchingAccountIndex, getAccountProjectId, getPiAuthPath, getStoredAccounts, joinRefresh, readJsonFile, splitRefresh, writeJsonFileSecure } from "./auth-store";
5
5
  import { getAntigravityHeaders } from "./headers";
6
+ import { notifyAntigravityLoginFailure } from "./status";
6
7
  import type { AntigravityAddAccountResult, AntigravityFailoverCredential, AntigravityLoginCallbacks, OpencodeAntigravityAccount, PiAuthCredential, PiAuthData, RefreshedAntigravityAccount } from "./types";
7
8
 
8
9
  function base64Url(input: Buffer): string {
@@ -91,59 +92,62 @@ function extractOAuthParams(input: string): { code: string; state: string } {
91
92
  }
92
93
 
93
94
  export async function loginAntigravity(callbacks: AntigravityLoginCallbacks): Promise<OAuthCredentials> {
94
- if (!CLIENT_ID || !CLIENT_SECRET) {
95
- throw new Error("Antigravity Google OAuth credentials are not configured; set PIX_ANTIGRAVITY_GOOGLE_CLIENT_ID and PIX_ANTIGRAVITY_GOOGLE_CLIENT_SECRET.");
96
- }
95
+ try {
96
+ if (!CLIENT_ID || !CLIENT_SECRET) throw new Error("Antigravity Google OAuth credentials are not bundled.");
97
97
 
98
- const { verifier, challenge } = generatePkce();
99
- const state = encodeState({ verifier });
100
- const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
101
- url.searchParams.set("client_id", CLIENT_ID);
102
- url.searchParams.set("response_type", "code");
103
- url.searchParams.set("redirect_uri", REDIRECT_URI);
104
- url.searchParams.set("scope", SCOPES.join(" "));
105
- url.searchParams.set("code_challenge", challenge);
106
- url.searchParams.set("code_challenge_method", "S256");
107
- url.searchParams.set("state", state);
108
- url.searchParams.set("access_type", "offline");
109
- url.searchParams.set("prompt", "consent");
98
+ const { verifier, challenge } = generatePkce();
99
+ const state = encodeState({ verifier });
100
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
101
+ url.searchParams.set("client_id", CLIENT_ID);
102
+ url.searchParams.set("response_type", "code");
103
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
104
+ url.searchParams.set("scope", SCOPES.join(" "));
105
+ url.searchParams.set("code_challenge", challenge);
106
+ url.searchParams.set("code_challenge_method", "S256");
107
+ url.searchParams.set("state", state);
108
+ url.searchParams.set("access_type", "offline");
109
+ url.searchParams.set("prompt", "consent");
110
110
 
111
- callbacks.onAuth({ url: url.toString() });
112
- const pasted = await callbacks.onPrompt({
113
- message: "Paste the full http://localhost:51121/oauth-callback URL after Google login (or code#state):",
114
- });
115
- const params = extractOAuthParams(pasted);
116
- const decodedState = decodeState(params.state);
117
- if (decodedState.verifier !== verifier) throw new Error("OAuth state verifier mismatch");
111
+ callbacks.onAuth({ url: url.toString() });
112
+ const pasted = await callbacks.onPrompt({
113
+ message: "Paste the full http://localhost:51121/oauth-callback URL after Google login (or code#state):",
114
+ });
115
+ const params = extractOAuthParams(pasted);
116
+ const decodedState = decodeState(params.state);
117
+ if (decodedState.verifier !== verifier) throw new Error("OAuth state verifier mismatch");
118
118
 
119
- const start = Date.now();
120
- const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
121
- method: "POST",
122
- headers: {
123
- "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
124
- Accept: "*/*",
125
- "User-Agent": getAntigravityHeaders("gemini-cli")["User-Agent"],
126
- },
127
- body: new URLSearchParams({
128
- client_id: CLIENT_ID,
129
- client_secret: CLIENT_SECRET,
130
- code: params.code,
131
- grant_type: "authorization_code",
132
- redirect_uri: REDIRECT_URI,
133
- code_verifier: verifier,
134
- }),
135
- });
136
- if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
137
- const tokenPayload = (await tokenResponse.json()) as { access_token: string; refresh_token?: string; expires_in: number };
138
- if (!tokenPayload.refresh_token) throw new Error("Missing refresh token in Google response");
119
+ const start = Date.now();
120
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
124
+ Accept: "*/*",
125
+ "User-Agent": getAntigravityHeaders("gemini-cli")["User-Agent"],
126
+ },
127
+ body: new URLSearchParams({
128
+ client_id: CLIENT_ID,
129
+ client_secret: CLIENT_SECRET,
130
+ code: params.code,
131
+ grant_type: "authorization_code",
132
+ redirect_uri: REDIRECT_URI,
133
+ code_verifier: verifier,
134
+ }),
135
+ });
136
+ if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
137
+ const tokenPayload = (await tokenResponse.json()) as { access_token: string; refresh_token?: string; expires_in: number };
138
+ if (!tokenPayload.refresh_token) throw new Error("Missing refresh token in Google response");
139
139
 
140
- const [projectId, email] = await Promise.all([fetchProjectId(tokenPayload.access_token), fetchGoogleUserEmail(tokenPayload.access_token)]);
141
- return {
142
- refresh: joinRefresh(tokenPayload.refresh_token, projectId ?? DEFAULT_PROJECT_ID),
143
- access: encodeApiKey(tokenPayload.access_token, projectId ?? DEFAULT_PROJECT_ID),
144
- expires: start + tokenPayload.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
145
- ...(email ? { email } : {}),
146
- };
140
+ const [projectId, email] = await Promise.all([fetchProjectId(tokenPayload.access_token), fetchGoogleUserEmail(tokenPayload.access_token)]);
141
+ return {
142
+ refresh: joinRefresh(tokenPayload.refresh_token, projectId ?? DEFAULT_PROJECT_ID),
143
+ access: encodeApiKey(tokenPayload.access_token, projectId ?? DEFAULT_PROJECT_ID),
144
+ expires: start + tokenPayload.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
145
+ ...(email ? { email } : {}),
146
+ };
147
+ } catch (error) {
148
+ notifyAntigravityLoginFailure(error);
149
+ throw error;
150
+ }
147
151
  }
148
152
 
149
153
  export async function addAntigravityAccount(
@@ -260,6 +264,54 @@ export async function refreshAntigravityToken(credentials: OAuthCredentials): Pr
260
264
  };
261
265
  }
262
266
 
267
+ export async function refreshStoredAntigravityCredential(authPath = getPiAuthPath()): Promise<AntigravityFailoverCredential | undefined> {
268
+ const auth = await readJsonFile<PiAuthData>(authPath, {});
269
+ const current = auth[PROVIDER_ID];
270
+ if (current?.type !== "oauth") return undefined;
271
+
272
+ const accounts = getStoredAccounts(current);
273
+ const activeIndex = accounts.length > 0 ? clampAccountIndex(current.activeIndex, accounts.length) : 0;
274
+ const fallback = splitRefresh(current.refresh ?? "");
275
+ const account = accounts[activeIndex] ?? {
276
+ refreshToken: fallback.refreshToken,
277
+ projectId: fallback.projectId || fallback.managedProjectId,
278
+ managedProjectId: fallback.managedProjectId,
279
+ email: current.email,
280
+ };
281
+ if (!account.refreshToken) return undefined;
282
+
283
+ const refreshed = await refreshAccountToken({ ...account, refreshToken: account.refreshToken });
284
+ const refreshedParts = splitRefresh(refreshed.credentials.refresh);
285
+ const nextAccounts = accounts.length > 0
286
+ ? accounts.map((stored, index) => index === activeIndex
287
+ ? {
288
+ ...stored,
289
+ refreshToken: refreshedParts.refreshToken || stored.refreshToken,
290
+ projectId: refreshed.projectId,
291
+ managedProjectId: account.managedProjectId,
292
+ email: account.email ?? stored.email,
293
+ enabled: stored.enabled !== false,
294
+ }
295
+ : stored)
296
+ : [];
297
+ const nextCredential: PiAuthCredential = {
298
+ ...current,
299
+ type: "oauth",
300
+ ...refreshed.credentials,
301
+ ...(nextAccounts.length > 0 ? { accounts: nextAccounts, activeIndex, email: account.email ?? current.email } : {}),
302
+ };
303
+ auth[PROVIDER_ID] = nextCredential;
304
+ await writeJsonFileSecure(authPath, auth);
305
+
306
+ return {
307
+ apiKey: nextCredential.access ?? "",
308
+ projectId: refreshed.projectId,
309
+ email: account.email,
310
+ accountIndex: activeIndex,
311
+ accountCount: accounts.length || 1,
312
+ };
313
+ }
314
+
263
315
  export async function refreshNextFailoverCredential(attemptedAccountIndices: Set<number>): Promise<AntigravityFailoverCredential | undefined> {
264
316
  const authPath = getPiAuthPath();
265
317
  const auth = await readJsonFile<PiAuthData>(authPath, {});
@@ -7,6 +7,9 @@ import type { AntigravityStatusDetails, OpencodeAntigravityAccount, PiAuthData,
7
7
 
8
8
  let extensionUi: ExtensionUIContext | undefined;
9
9
  let extensionApi: ExtensionAPI | undefined;
10
+ const notifiedLoginFailures = new WeakSet<object>();
11
+ const PROVIDER_FAILURE_DEDUPE_MS = 60_000;
12
+ const notifiedProviderFailures = new Map<string, number>();
10
13
 
11
14
  export function rememberAntigravityApi(api: ExtensionAPI): void {
12
15
  extensionApi = api;
@@ -16,6 +19,80 @@ export function rememberAntigravityUi(ui: ExtensionUIContext | undefined): void
16
19
  if (ui) extensionUi = ui;
17
20
  }
18
21
 
22
+ function errorMessage(error: unknown): string {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+
26
+ export function formatAntigravityLoginFailure(error: unknown): string {
27
+ return `Antigravity login failed: ${errorMessage(error)}. Auth file: ${getPiAuthPath()}`;
28
+ }
29
+
30
+ export function formatAntigravityProviderFailure(error: unknown): string {
31
+ return `Antigravity request failed: ${errorMessage(error)}. Auth file: ${getPiAuthPath()}`;
32
+ }
33
+
34
+ function notifyAntigravityFailure(message: string, details: Record<string, unknown>, options: { ui?: ExtensionUIContext; sendSessionMessage?: boolean } = {}): void {
35
+ const { ui, sendSessionMessage = true } = options;
36
+ const targetUi = ui ?? extensionUi;
37
+ if (typeof targetUi?.notify === "function") {
38
+ targetUi.notify(message, "error");
39
+ } else if (typeof (targetUi as any)?.toast?.error === "function") {
40
+ (targetUi as any).toast.error(message);
41
+ }
42
+ if (!sendSessionMessage) return;
43
+ (extensionApi as any)?.sendMessage?.({
44
+ customType: "antigravity-auth-status",
45
+ content: message,
46
+ display: true,
47
+ details,
48
+ }, { triggerTurn: false });
49
+ }
50
+
51
+ function shouldNotifyProviderFailure(message: string, model?: string): boolean {
52
+ const now = Date.now();
53
+ const key = `${model ?? ""}\0${message}`;
54
+ for (const [existingKey, timestamp] of notifiedProviderFailures) {
55
+ if (now - timestamp > PROVIDER_FAILURE_DEDUPE_MS) notifiedProviderFailures.delete(existingKey);
56
+ }
57
+ const previous = notifiedProviderFailures.get(key);
58
+ if (previous !== undefined && now - previous <= PROVIDER_FAILURE_DEDUPE_MS) return false;
59
+ notifiedProviderFailures.set(key, now);
60
+ return true;
61
+ }
62
+
63
+ export function notifyAntigravityLoginFailure(error: unknown): boolean {
64
+ if (typeof error === "object" && error !== null) {
65
+ if (notifiedLoginFailures.has(error)) return false;
66
+ notifiedLoginFailures.add(error);
67
+ }
68
+
69
+ const message = formatAntigravityLoginFailure(error);
70
+ notifyAntigravityFailure(message, {
71
+ kind: "login-failure",
72
+ authPath: getPiAuthPath(),
73
+ error: errorMessage(error),
74
+ });
75
+ return true;
76
+ }
77
+
78
+ export function notifyAntigravityProviderFailure(error: unknown, options: { ui?: ExtensionUIContext; model?: string } = {}): boolean {
79
+ const message = formatAntigravityProviderFailure(error);
80
+ if (!shouldNotifyProviderFailure(message, options.model)) return false;
81
+ notifyAntigravityFailure(message, {
82
+ kind: "provider-failure",
83
+ authPath: getPiAuthPath(),
84
+ error: errorMessage(error),
85
+ model: options.model,
86
+ }, {
87
+ ui: options.ui,
88
+ // Provider failures are raised while the agent is still streaming. pi.sendMessage()
89
+ // would be delivered as a steering message in that state, which can trigger
90
+ // another Antigravity request and create an endless toast/request loop.
91
+ sendSessionMessage: false,
92
+ });
93
+ return true;
94
+ }
95
+
19
96
  export async function getCurrentAntigravityStatus(): Promise<AntigravityStatusDetails> {
20
97
  const auth = await readJsonFile<PiAuthData>(getPiAuthPath(), {});
21
98
  const credential = auth[PROVIDER_ID];
@@ -68,8 +145,10 @@ export async function publishAntigravityAuthStartupSection(): Promise<void> {
68
145
  }
69
146
 
70
147
  export function emitAntigravityStatus(details: AntigravityStatusDetails): void {
71
- extensionUi?.setStatus(LEGACY_STATUS_KEY, undefined);
72
- extensionUi?.setStatus(STATUS_KEY, formatAntigravityStatus(details));
148
+ if (typeof extensionUi?.setStatus === "function") {
149
+ extensionUi.setStatus(LEGACY_STATUS_KEY, undefined);
150
+ extensionUi.setStatus(STATUS_KEY, formatAntigravityStatus(details));
151
+ }
73
152
  (extensionApi as any)?.sendMessage?.({
74
153
  role: "system",
75
154
  content: formatAntigravityStatus(details),
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { calculateCost, createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type Context, type SimpleStreamOptions, type ToolCall } from "@earendil-works/pi-ai";
3
3
  import { ALL_ACCOUNTS_EXHAUSTED_MARKER, API_ID, ENDPOINT_PROD, PROVIDER_ID, STREAM_ENDPOINTS } from "./constants";
4
- import { clampAccountIndex, decodeApiKey, getPiAuthPath, getStoredAccounts, readJsonFile } from "./auth-store";
4
+ import { clampAccountIndex, decodeApiKey, getDefaultOpencodeAccountsPath, getPiAuthPath, getStoredAccounts, importDefaultOpencodeAntigravityAccount, readJsonFile } from "./auth-store";
5
5
  import { getAntigravityHeaders, getModelHeaderStyle } from "./headers";
6
- import { refreshNextFailoverCredential } from "./oauth";
6
+ import { refreshNextFailoverCredential, refreshStoredAntigravityCredential } from "./oauth";
7
7
  import { buildPayload, extraHeadersForPayload, partThoughtSignature } from "./payload";
8
8
  import { emitAntigravityStatus } from "./status";
9
9
  import type { AntigravityChunk, AntigravityModel, PiAuthData } from "./types";
@@ -104,6 +104,32 @@ function updateUsage(output: AssistantMessage, model: AntigravityModel, metadata
104
104
  calculateCost(model, output.usage);
105
105
  }
106
106
 
107
+ async function resolveAntigravityApiKey(optionsApiKey?: string): Promise<{ auth: PiAuthData; apiKey: string }> {
108
+ let auth = await readJsonFile<PiAuthData>(getPiAuthPath(), {});
109
+ let storedCredential = auth[PROVIDER_ID];
110
+
111
+ if (storedCredential?.type !== "oauth") {
112
+ const imported = await importDefaultOpencodeAntigravityAccount().catch(() => undefined);
113
+ if (imported?.imported || imported?.reason === "already-imported") {
114
+ auth = await readJsonFile<PiAuthData>(getPiAuthPath(), {});
115
+ storedCredential = auth[PROVIDER_ID];
116
+ }
117
+ }
118
+
119
+ const storedApiKey = storedCredential?.type === "oauth" && storedCredential.access && (storedCredential.expires ?? 0) > Date.now()
120
+ ? storedCredential.access
121
+ : undefined;
122
+ const apiKey = storedApiKey ?? optionsApiKey;
123
+ if (apiKey) return { auth, apiKey };
124
+
125
+ if (storedCredential?.type === "oauth") {
126
+ const refreshed = await refreshStoredAntigravityCredential();
127
+ if (refreshed?.apiKey) return { auth: await readJsonFile<PiAuthData>(getPiAuthPath(), {}), apiKey: refreshed.apiKey };
128
+ }
129
+
130
+ throw new Error(`No Antigravity OAuth account found. Checked Pi auth: ${getPiAuthPath()}; opencode accounts: ${getDefaultOpencodeAccountsPath()}.`);
131
+ }
132
+
107
133
  export function streamAntigravity(model: AntigravityModel, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
108
134
  const stream = createAssistantMessageEventStream();
109
135
  (async () => {
@@ -135,12 +161,7 @@ export function streamAntigravity(model: AntigravityModel, context: Context, opt
135
161
 
136
162
  try {
137
163
  const attemptedAccountIndices = new Set<number>();
138
- const auth = await readJsonFile<PiAuthData>(getPiAuthPath(), {});
139
- const storedCredential = auth[PROVIDER_ID];
140
- const apiKey = storedCredential?.type === "oauth" && storedCredential.access && (storedCredential.expires ?? 0) > Date.now()
141
- ? storedCredential.access
142
- : options?.apiKey;
143
- if (!apiKey) throw new Error("Not authenticated with Antigravity. Run /login antigravity first.");
164
+ const { auth, apiKey } = await resolveAntigravityApiKey(options?.apiKey);
144
165
  const decodedApiKey = decodeApiKey(apiKey);
145
166
  const authAccounts = getStoredAccounts(auth[PROVIDER_ID]);
146
167
  if (authAccounts.length > 0) attemptedAccountIndices.add(clampAccountIndex(auth[PROVIDER_ID]?.activeIndex, authAccounts.length));
@@ -8,11 +8,13 @@ import { DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC } from "./default-pi-tools-suite-co
8
8
  export interface PiToolsSuiteConfig {
9
9
  enabled: boolean;
10
10
  disabledModules: string[];
11
+ todoThinking: boolean;
11
12
  }
12
13
 
13
14
  type MutableConfig = {
14
15
  enabled: boolean;
15
16
  disabledModules: Set<string>;
17
+ todoThinking: boolean;
16
18
  };
17
19
 
18
20
  type Env = Record<string, string | undefined>;
@@ -107,6 +109,7 @@ function removeDisabled(config: MutableConfig, value: unknown, knownModules: Rea
107
109
 
108
110
  function mergeConfigLayer(config: MutableConfig, raw: Record<string, unknown>, knownModules: ReadonlySet<string>): MutableConfig {
109
111
  if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
112
+ if (typeof raw.todoThinking === "boolean") config.todoThinking = raw.todoThinking;
110
113
 
111
114
  for (const key of DISABLED_LIST_KEYS) addDisabled(config, raw[key], knownModules);
112
115
  for (const key of ENABLED_LIST_KEYS) removeDisabled(config, raw[key], knownModules);
@@ -150,6 +153,9 @@ function applyEnv(config: MutableConfig, env: Env, knownModules: ReadonlySet<str
150
153
  addDisabled(config, env.PI_TOOLS_SUITE_DISABLED_MODULES, knownModules);
151
154
  addDisabled(config, env.PI_TOOLS_SUITE_DISABLED_EXTENSIONS, knownModules);
152
155
 
156
+ const todoThinking = boolFromEnv(env.PI_TOOLS_SUITE_TODO_THINKING);
157
+ if (todoThinking !== undefined) config.todoThinking = todoThinking;
158
+
153
159
  return config;
154
160
  }
155
161
 
@@ -159,6 +165,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
159
165
  const config: MutableConfig = {
160
166
  enabled: true,
161
167
  disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))),
168
+ todoThinking: false,
162
169
  };
163
170
  const userConfigPath = getPiToolsSuiteUserConfigPath(options.homeDir);
164
171
 
@@ -176,5 +183,6 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
176
183
  return {
177
184
  enabled: config.enabled,
178
185
  disabledModules: [...config.disabledModules].sort(),
186
+ todoThinking: config.todoThinking,
179
187
  };
180
188
  }
@@ -105,7 +105,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
105
105
  // ── 2. Create state ───────────────────────────────────────────────────────
106
106
  const state = createState()
107
107
  const appendNudgeTelemetry = (
108
- event: "emitted" | "upgraded",
108
+ event: "emitted" | "upgraded" | "reapplied",
109
109
  type: DcpNudgeType,
110
110
  anchor: { id: number; anchorTimestamp: number; anchorStableId?: string; anchorRole: string },
111
111
  usage: ReturnType<typeof normalizeDcpContextUsage>,
@@ -329,6 +329,21 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
329
329
  toolCallsSinceLastUser,
330
330
  )
331
331
  saveState(pi, state)
332
+ } else {
333
+ // Anchor already exists at >= priority; the reminder text is
334
+ // re-applied below via applyAnchoredNudges on every context
335
+ // event. Emit 'reapplied' so telemetry reflects every active
336
+ // reminder delivery, not just creates/upgrades. Without this
337
+ // branch the user/developer sees a single "emitted" entry even
338
+ // when the LLM was reminded many times across a long autonomous
339
+ // loop, which made auto-nudge look silent when it actually ran.
340
+ appendNudgeTelemetry(
341
+ "reapplied",
342
+ anchorResult.anchor.type,
343
+ anchorResult.anchor,
344
+ usage,
345
+ toolCallsSinceLastUser,
346
+ )
332
347
  }
333
348
  } else {
334
349
  // No safe existing message could be anchored (rare); keep the older
@@ -283,6 +283,20 @@ export interface SerializedDcpState {
283
283
  nudgeAnchors?: DcpNudgeAnchor[]
284
284
  nextNudgeAnchorId?: number
285
285
  lastNudge?: DcpLastNudge
286
+ /**
287
+ * Persisted since v??. `context` events re-seed currentTurn from raw
288
+ * messages, but keeping it across session restarts gives diagnostics and
289
+ * telemetry a contiguous turn counter instead of resetting to 0.
290
+ */
291
+ currentTurn?: number
292
+ /**
293
+ * Persisted since v?.?. Without persistence a pi process restart silently
294
+ * reset the nudge cadence counter to 0, which could suppress the next
295
+ * reminder on a session that was already near a context threshold.
296
+ */
297
+ nudgeCounter?: number
298
+ /** Persisted since v?.?. Diagnostic turn of the last emitted nudge. */
299
+ lastNudgeTurn?: number
286
300
  }
287
301
 
288
302
  function isToolRecord(value: unknown): value is ToolRecord {
@@ -337,6 +351,9 @@ export function serializeState(state: DcpState): SerializedDcpState {
337
351
  nudgeAnchors: state.nudgeAnchors,
338
352
  nextNudgeAnchorId: state.nextNudgeAnchorId,
339
353
  lastNudge: state.lastNudge,
354
+ currentTurn: state.currentTurn,
355
+ nudgeCounter: state.nudgeCounter,
356
+ lastNudgeTurn: state.lastNudgeTurn,
340
357
  }
341
358
  }
342
359
 
@@ -444,6 +461,24 @@ export function restoreState(state: DcpState, data: unknown): void {
444
461
  if (isLastNudge(saved.lastNudge)) {
445
462
  state.lastNudge = saved.lastNudge
446
463
  }
464
+
465
+ // nudgeCounter: clamp to a non-negative integer so a corrupted payload
466
+ // cannot stall reminders by going negative. Default 0 keeps older sessions
467
+ // behaving like a fresh cadence on first post-restart context event.
468
+ if (typeof saved.nudgeCounter === "number" && Number.isFinite(saved.nudgeCounter) && saved.nudgeCounter >= 0) {
469
+ state.nudgeCounter = Math.floor(saved.nudgeCounter)
470
+ }
471
+
472
+ // lastNudgeTurn and currentTurn default to createState() values when absent
473
+ // (lastNudgeTurn = -1, currentTurn = 0). currentTurn is re-derived from
474
+ // raw messages on every `context` event, so persistence here is best-effort
475
+ // for telemetry continuity rather than authoritative.
476
+ if (typeof saved.lastNudgeTurn === "number" && Number.isFinite(saved.lastNudgeTurn)) {
477
+ state.lastNudgeTurn = Math.floor(saved.lastNudgeTurn)
478
+ }
479
+ if (typeof saved.currentTurn === "number" && Number.isFinite(saved.currentTurn) && saved.currentTurn >= 0) {
480
+ state.currentTurn = Math.floor(saved.currentTurn)
481
+ }
447
482
  }
448
483
 
449
484
  // ---------------------------------------------------------------------------
@@ -5,6 +5,9 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
5
5
  // "ast-grep",
6
6
  // "dcp"
7
7
  ],
8
+ // When true, todo items may carry a per-task thinking level and the todo
9
+ // module will switch/restore Pi's thinking level as in-progress tasks change.
10
+ "todoThinking": false,
8
11
  "terminalBell": { "sound": true },
9
12
  "dcp": {
10
13
  "enabled": true,