shariq-pi-extensions 0.2.23 → 0.2.25

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.
@@ -36,7 +36,7 @@ The goal extension adds persistent, branch-safe objectives, progress evidence, b
36
36
 
37
37
  ### [Task List](../extensions/task-list/README.md)
38
38
 
39
- `task_list` gives the active model a branch-safe ordered checklist for ordinary multi-step work; it is separate from persistent Goals and may be used alongside them. Writes replace the full list, retain stable IDs, support pending/in-progress/completed/blocked/cancelled states and priorities, and require an active item while pending work remains. The prompt contract requires same-message list/action calls and immediate verified status updates. Runtime reminders catch missing or stale bookkeeping, with at most one same-model follow-up if an active list is still stale when Pi settles.
39
+ `task_list` gives the active model a branch-safe ordered checklist for ordinary multi-step work; it is separate from persistent Goals and may be used alongside them. Writes replace the full list, retain stable IDs, support pending/in-progress/completed/blocked/cancelled states and priorities, and require an active item while pending work remains. The prompt contract requires same-message list/action calls and verified updates only at task-level transitions—not after routine file reads, edits, commands, or other tool calls. A runtime reminder catches multi-action work that starts without a list; final reconciliation catches unfinished bookkeeping.
40
40
 
41
41
  State is stored in Pi session entries and reconstructed on resume, reload, and tree navigation. Active work is re-injected when compaction removes the latest snapshot from model context. `/tasks` opens the interactive dashboard and direct editor; the compact live widget auto-hides after all work finishes. Subagents receive the same tool under every capability policy, but each child maintains its own session-local list rather than changing the parent's list.
42
42
 
@@ -10,7 +10,7 @@ Local persistent Pi provider for Google Antigravity-compatible models.
10
10
 
11
11
  The provider includes an IPv4 OAuth token-exchange fallback for Node environments where the default request fails. Its curated catalog follows current Antigravity model identifiers and runtime behavior.
12
12
 
13
- Successful logins are added to `<agent-dir>/antigravity/accounts.json`, written with owner-only permissions. Existing Pi OAuth credentials are migrated into that pool without exposing token values. Requests select the least recently used eligible account, skip disabled/cooling/exhausted accounts, refresh expiring OAuth tokens, and rotate to another account when an auth, rate, quota, or capacity failure occurs before response streaming begins. Cached per-model remaining quota and reset times guide selection; `/antigravity` refreshes the authoritative catalog and can reversibly enable or disable accounts.
13
+ Successful logins are added to `<agent-dir>/antigravity/accounts.json`, written with owner-only permissions. A missing account file triggers one-time migration of an existing Pi OAuth credential; after that, the valid account file is authoritative, so removing an account cannot be undone by a stale credential in `auth.json`. Requests select the least recently used eligible account, skip disabled/cooling/exhausted accounts, refresh expiring OAuth tokens, and rotate to another account when an auth, rate, quota, or capacity failure occurs before response streaming begins. Cached per-model remaining quota and reset times guide selection; `/antigravity` refreshes the authoritative catalog, uses `d` to reversibly enable or disable an account, and uses `x` plus confirmation to remove one permanently.
14
14
 
15
15
  Current public model IDs:
16
16
  - `antigravity/gemini-3.7-flash`
@@ -48,6 +48,22 @@ interface AntigravityAccountFile {
48
48
  accounts: AntigravityAccount[];
49
49
  }
50
50
 
51
+ export type AntigravityAccountStore = {
52
+ status: "missing" | "invalid" | "valid";
53
+ accounts: AntigravityAccount[];
54
+ };
55
+
56
+ export type AntigravityStoredCredential = Pick<OAuthCredentials, "refresh" | "access" | "expires"> & {
57
+ projectId?: string;
58
+ email?: string;
59
+ };
60
+
61
+ export type AntigravityCredentialReconciliation =
62
+ | { action: "none" }
63
+ | { action: "migrate"; credentials: AntigravityStoredCredential }
64
+ | { action: "replace"; account: AntigravityAccount }
65
+ | { action: "delete" };
66
+
51
67
  export interface AntigravityAccountStatus extends AntigravityAccount {
52
68
  active: boolean;
53
69
  }
@@ -105,17 +121,40 @@ function normalizeAccount(value: unknown): AntigravityAccount | undefined {
105
121
  };
106
122
  }
107
123
 
108
- export function loadAntigravityAccounts(): AntigravityAccount[] {
124
+ export function inspectAntigravityAccountStore(): AntigravityAccountStore {
109
125
  try {
110
126
  const parsed = JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, "utf8")) as Partial<AntigravityAccountFile>;
111
- return Array.isArray(parsed.accounts)
112
- ? parsed.accounts.map(normalizeAccount).filter((account): account is AntigravityAccount => Boolean(account))
113
- : [];
114
- } catch {
115
- return [];
127
+ if (!Array.isArray(parsed.accounts)) return { status: "invalid", accounts: [] };
128
+ return {
129
+ status: "valid",
130
+ accounts: parsed.accounts.map(normalizeAccount).filter((account): account is AntigravityAccount => Boolean(account)),
131
+ };
132
+ } catch (error) {
133
+ const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
134
+ return { status: code === "ENOENT" ? "missing" : "invalid", accounts: [] };
116
135
  }
117
136
  }
118
137
 
138
+ export function loadAntigravityAccounts(): AntigravityAccount[] {
139
+ return inspectAntigravityAccountStore().accounts;
140
+ }
141
+
142
+ function matchesStoredCredential(account: AntigravityAccount, credentials: AntigravityStoredCredential) {
143
+ return account.refresh === credentials.refresh || Boolean(account.email && credentials.email && account.email === credentials.email);
144
+ }
145
+
146
+ export function reconcileAntigravityStoredCredential(
147
+ store: AntigravityAccountStore,
148
+ credentials: AntigravityStoredCredential | undefined,
149
+ ): AntigravityCredentialReconciliation {
150
+ if (store.status === "invalid") return { action: "none" };
151
+ if (store.status === "missing") return credentials ? { action: "migrate", credentials } : { action: "none" };
152
+ if (credentials && store.accounts.some((account) => matchesStoredCredential(account, credentials))) return { action: "none" };
153
+ const replacement = store.accounts.find((account) => !account.disabled) ?? store.accounts[0];
154
+ if (replacement) return { action: "replace", account: replacement };
155
+ return credentials ? { action: "delete" } : { action: "none" };
156
+ }
157
+
119
158
  function saveAntigravityAccounts(accounts: AntigravityAccount[]) {
120
159
  fs.mkdirSync(ANTIGRAVITY_STATE_DIR, { recursive: true, mode: 0o700 });
121
160
  fs.chmodSync(ANTIGRAVITY_STATE_DIR, 0o700);
@@ -78,6 +78,7 @@ export class AntigravityDashboard implements Component {
78
78
  private snapshot: AntigravityDashboardSnapshot;
79
79
  private readonly refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>;
80
80
  private readonly toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>;
81
+ private readonly removeAccount: (id: string, label: string) => Promise<AntigravityDashboardSnapshot>;
81
82
  private readonly done: () => void;
82
83
 
83
84
  constructor(
@@ -87,6 +88,7 @@ export class AntigravityDashboard implements Component {
87
88
  snapshot: AntigravityDashboardSnapshot,
88
89
  refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
89
90
  toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
91
+ removeAccount: (id: string, label: string) => Promise<AntigravityDashboardSnapshot>,
90
92
  done: () => void,
91
93
  ) {
92
94
  this.tui = tui;
@@ -95,6 +97,7 @@ export class AntigravityDashboard implements Component {
95
97
  this.snapshot = snapshot;
96
98
  this.refreshData = refreshData;
97
99
  this.toggleAccount = toggleAccount;
100
+ this.removeAccount = removeAccount;
98
101
  this.done = done;
99
102
  }
100
103
 
@@ -139,10 +142,13 @@ export class AntigravityDashboard implements Component {
139
142
  if (accounts.length) this.selected = (this.selected + 1) % accounts.length;
140
143
  } else if (data === "r") {
141
144
  this.startRefresh(true);
142
- } else if (data === "d" && accounts[this.selected] && !this.refreshing) {
145
+ } else if ((data === "d" || data === "x") && accounts[this.selected] && !this.refreshing) {
143
146
  const account = accounts[this.selected]!;
144
147
  this.refreshing = true;
145
- void this.toggleAccount(account.id, account.disabled === true)
148
+ const action = data === "x"
149
+ ? this.removeAccount(account.id, account.email || `account-${account.id.slice(0, 6)}`)
150
+ : this.toggleAccount(account.id, account.disabled === true);
151
+ void action
146
152
  .then((snapshot) => this.replaceSnapshot(snapshot))
147
153
  .catch((error) => {
148
154
  if (!this.closed) this.snapshot = { ...this.snapshot, warning: error instanceof Error ? error.message : String(error) };
@@ -188,7 +194,7 @@ export class AntigravityDashboard implements Component {
188
194
  for (let row = 0; row < bodyHeight; row++) lines.push(this.theme.fg("border", "│") + padLine(list[row] ?? "", inner) + this.theme.fg("border", "│"));
189
195
  }
190
196
  lines.push(frameBottom(this.theme, width));
191
- lines.push(truncateToWidth(`${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh")} ${this.theme.fg("accent", "d")} ${this.theme.fg("dim", "enable/disable")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`, width, ""));
197
+ lines.push(truncateToWidth(`${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh")} ${this.theme.fg("accent", "d")} ${this.theme.fg("dim", "enable/disable")} ${this.theme.fg("accent", "x")} ${this.theme.fg("dim", "remove")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`, width, ""));
192
198
  return lines.map((line) => truncateToWidth(line, width, ""));
193
199
  }
194
200
 
@@ -250,10 +256,11 @@ export async function openAntigravityDashboard(
250
256
  initial: AntigravityDashboardSnapshot,
251
257
  refresh: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
252
258
  toggle: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
259
+ remove: (id: string, label: string) => Promise<AntigravityDashboardSnapshot>,
253
260
  ) {
254
261
  await ctx.ui.custom<void>(
255
262
  (tui, theme, keys, done) => {
256
- const dashboard = new AntigravityDashboard(tui, theme, keys, initial, refresh, toggle, () => done(undefined));
263
+ const dashboard = new AntigravityDashboard(tui, theme, keys, initial, refresh, toggle, remove, () => done(undefined));
257
264
  queueMicrotask(() => dashboard.startRefresh(false));
258
265
  return dashboard;
259
266
  },
@@ -24,8 +24,12 @@ import {
24
24
  import { streamAntigravity } from "./cloud-code-assist.ts";
25
25
  import {
26
26
  antigravityAccountStatuses,
27
+ inspectAntigravityAccountStore,
28
+ reconcileAntigravityStoredCredential,
29
+ removeAntigravityAccount,
27
30
  setAntigravityAccountEnabled,
28
31
  upsertAntigravityAccount,
32
+ type AntigravityStoredCredential,
29
33
  } from "./accounts.ts";
30
34
  import { refreshAntigravityQuotas } from "./quotas.ts";
31
35
  import {
@@ -43,19 +47,43 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
43
47
  };
44
48
  };
45
49
 
46
- const migrateStoredCredential = () => {
50
+ const readOAuthCredential = (): AntigravityStoredCredential | undefined => {
51
+ const credential = readStoredCredential(PROVIDER_ID) as any;
52
+ if (credential?.type !== "oauth" || typeof credential.refresh !== "string" || typeof credential.access !== "string") return undefined;
53
+ return {
54
+ refresh: credential.refresh,
55
+ access: credential.access,
56
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
57
+ projectId: typeof credential.projectId === "string" ? credential.projectId : undefined,
58
+ email: typeof credential.email === "string" ? credential.email : undefined,
59
+ };
60
+ };
61
+
62
+ const reconcileStoredCredential = async (ctx?: ExtensionContext) => {
47
63
  try {
48
- const credential = readStoredCredential(PROVIDER_ID) as any;
49
- if (credential?.type !== "oauth" || typeof credential.refresh !== "string" || typeof credential.access !== "string") return;
50
- upsertAntigravityAccount({
51
- refresh: credential.refresh,
52
- access: credential.access,
53
- expires: typeof credential.expires === "number" ? credential.expires : 0,
54
- projectId: typeof credential.projectId === "string" ? credential.projectId : undefined,
55
- email: typeof credential.email === "string" ? credential.email : undefined,
56
- });
64
+ const credentials = readOAuthCredential();
65
+ const reconciliation = reconcileAntigravityStoredCredential(inspectAntigravityAccountStore(), credentials);
66
+ if (reconciliation.action === "migrate") {
67
+ upsertAntigravityAccount(reconciliation.credentials);
68
+ return;
69
+ }
70
+ const authStorage = (ctx?.modelRegistry as any)?.authStorage;
71
+ if (!authStorage) return;
72
+ if (reconciliation.action === "replace") {
73
+ const account = reconciliation.account;
74
+ await authStorage.modify(PROVIDER_ID, async () => ({
75
+ type: "oauth",
76
+ refresh: account.refresh,
77
+ access: account.access,
78
+ expires: account.expires,
79
+ projectId: account.projectId,
80
+ email: account.email,
81
+ }));
82
+ } else if (reconciliation.action === "delete") {
83
+ await authStorage.delete(PROVIDER_ID);
84
+ }
57
85
  } catch {
58
- // A malformed or unavailable Pi credential must not block extension startup.
86
+ // Malformed or unavailable credential state must not block extension startup.
59
87
  }
60
88
  };
61
89
 
@@ -67,9 +95,9 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
67
95
  oauth: {
68
96
  name: PROVIDER_NAME,
69
97
  login: ((callbacks: Parameters<typeof loginAntigravity>[0]) => {
70
- // Pi replaces its single stored provider credential after login. Archive
71
- // the current account first so signing into another account cannot lose it.
72
- migrateStoredCredential();
98
+ // A missing account store is a legacy installation, so archive Pi's single
99
+ // credential before login. A valid store remains authoritative after edits.
100
+ void reconcileStoredCredential();
73
101
  return loginAntigravity(callbacks);
74
102
  }) as any,
75
103
  refreshToken: refreshAntigravityToken as any,
@@ -78,9 +106,9 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
78
106
  streamSimple: streamAntigravity,
79
107
  } as any);
80
108
 
81
- pi.on("session_start", (_event, ctx) => {
109
+ pi.on("session_start", async (_event, ctx) => {
82
110
  (ctx.modelRegistry as any).authStorage?.reload?.();
83
- migrateStoredCredential();
111
+ await reconcileStoredCredential(ctx);
84
112
  void refreshAntigravityQuotas({ signal: ctx.signal }).catch(() => {
85
113
  // Cached quota state remains available; /antigravity reports refresh failures.
86
114
  });
@@ -94,7 +122,7 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
94
122
  pi.registerCommand("antigravity", {
95
123
  description: "Open Antigravity accounts, rotation, and quota dashboard",
96
124
  handler: async (_args, ctx) => {
97
- migrateStoredCredential();
125
+ await reconcileStoredCredential(ctx);
98
126
  await openAntigravityDashboard(
99
127
  ctx,
100
128
  dashboardSnapshot(ctx),
@@ -107,6 +135,13 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
107
135
  if (enabled) await refreshAntigravityQuotas({ force: true, signal: ctx.signal });
108
136
  return dashboardSnapshot(ctx);
109
137
  },
138
+ async (id, label) => {
139
+ const confirmed = await ctx.ui.confirm("Remove Antigravity account?", `Permanently remove ${label} from this Pi installation?`);
140
+ if (!confirmed) return dashboardSnapshot(ctx);
141
+ removeAntigravityAccount(id);
142
+ await reconcileStoredCredential(ctx);
143
+ return dashboardSnapshot(ctx);
144
+ },
110
145
  );
111
146
  },
112
147
  });
@@ -14,4 +14,4 @@ Alternatively choose Pi's top-level **Use an API key** authentication method to
14
14
 
15
15
  Use `/logout` to remove the active Factory authentication selection. The configured key file is intentionally not deleted by logout.
16
16
 
17
- Open `/factory` to inspect every account, its Standard/Core usage, and rotation status. For a smoke test, use the current `factory/kimi-k3` model.
17
+ Open `/factory` to inspect every account, its Standard/Core usage, and rotation status. File-backed keys remain visible when disabled: select one and press `d` to enable/disable it, or press `x` and confirm to remove it permanently from `api-keys.json`. Environment-provided keys are read-only. For a smoke test, use the current `factory/kimi-k3` model.
@@ -59,7 +59,7 @@ There are no `-oauth` or `-api-key` model duplicates. The user-curated catalog e
59
59
  /factory
60
60
  ```
61
61
 
62
- `/factory` opens a full-width account dashboard combining provider status, authentication, Droid/model metadata, rotation cooldowns, and separate Standard and Droid Core usage for every credential. Percentages are explicitly labeled as **used**. Navigate with the arrow keys or `j`/`k`, press `r` to force-refresh every account, and press Escape to close.
62
+ `/factory` opens a full-width account dashboard combining provider status, authentication, Droid/model metadata, rotation cooldowns, and separate Standard and Droid Core usage for every credential. Percentages are explicitly labeled as **used**. Navigate with the arrow keys or `j`/`k`, press `r` to force-refresh every account, use `d` to reversibly enable or disable a rotating file-backed key, use `x` plus confirmation to remove one permanently, and press Escape to close. Environment variables and single credentials managed by Pi auth remain read-only in this dashboard.
63
63
 
64
64
  Usage is never estimated from Pi token counts: Factory's API already applies model multipliers and cache-hit discounts. Cached records refresh at most every 15 minutes during normal sessions and after Factory runs; the manual command is the explicit force-refresh path.
65
65
 
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
5
5
  import { FACTORY_API_BASE_URL, FACTORY_STATE_DIR } from "./constants.ts";
@@ -25,6 +25,10 @@ export type FactoryApiKeyEntry = {
25
25
  disabled?: boolean;
26
26
  };
27
27
 
28
+ type FactoryApiKeySourceEntry = FactoryApiKeyEntry & {
29
+ source: "environment" | "file";
30
+ };
31
+
28
32
  type FactoryApiKeyConfig = {
29
33
  keys?: Array<string | Partial<FactoryApiKeyEntry>>;
30
34
  };
@@ -69,19 +73,19 @@ export function parseFactoryApiKeyFile(raw: string): { entries: FactoryApiKeyEnt
69
73
  }
70
74
  }
71
75
 
72
- export function loadFactoryApiKeys(): FactoryApiKeyEntry[] {
73
- const entries: FactoryApiKeyEntry[] = [];
76
+ function configuredFactoryApiKeys(): FactoryApiKeySourceEntry[] {
77
+ const entries: FactoryApiKeySourceEntry[] = [];
74
78
  const envKeys = (process.env.FACTORY_API_KEYS || process.env.FACTORY_API_KEY || "")
75
79
  .split(/[\n,]+/)
76
80
  .map((key) => key.trim())
77
81
  .filter(Boolean);
78
- envKeys.forEach((key, index) => entries.push({ label: `env-${index + 1}`, key }));
82
+ envKeys.forEach((key, index) => entries.push({ label: `env-${index + 1}`, key, source: "environment" }));
79
83
 
80
84
  lastConfigurationWarning = undefined;
81
85
  if (existsSync(FACTORY_API_KEYS_PATH)) {
82
86
  try {
83
87
  const parsed = parseFactoryApiKeyFile(readFileSync(FACTORY_API_KEYS_PATH, "utf8"));
84
- entries.push(...parsed.entries);
88
+ entries.push(...parsed.entries.map((entry) => ({ ...entry, source: "file" as const })));
85
89
  lastConfigurationWarning = parsed.warning;
86
90
  } catch {
87
91
  lastConfigurationWarning = "Factory rotating-key configuration could not be read.";
@@ -90,13 +94,63 @@ export function loadFactoryApiKeys(): FactoryApiKeyEntry[] {
90
94
 
91
95
  const seen = new Set<string>();
92
96
  return entries.filter((entry) => {
93
- if (entry.disabled) return false;
94
97
  if (seen.has(entry.key)) return false;
95
98
  seen.add(entry.key);
96
99
  return true;
97
100
  });
98
101
  }
99
102
 
103
+ export function loadFactoryApiKeys(): FactoryApiKeyEntry[] {
104
+ return configuredFactoryApiKeys().filter((entry) => !entry.disabled);
105
+ }
106
+
107
+ function editableFactoryApiKeyEntries(): FactoryApiKeyEntry[] {
108
+ if (!existsSync(FACTORY_API_KEYS_PATH)) return [];
109
+ const parsed = parseFactoryApiKeyFile(readFileSync(FACTORY_API_KEYS_PATH, "utf8"));
110
+ if (parsed.warning) throw new Error(parsed.warning);
111
+ return parsed.entries;
112
+ }
113
+
114
+ function saveFactoryApiKeyEntries(entries: FactoryApiKeyEntry[]) {
115
+ mkdirSync(FACTORY_STATE_DIR, { recursive: true, mode: 0o700 });
116
+ chmodSync(FACTORY_STATE_DIR, 0o700);
117
+ const temporary = `${FACTORY_API_KEYS_PATH}.${process.pid}.tmp`;
118
+ writeFileSync(temporary, `${JSON.stringify({ keys: entries }, null, 2)}\n`, { mode: 0o600 });
119
+ renameSync(temporary, FACTORY_API_KEYS_PATH);
120
+ chmodSync(FACTORY_API_KEYS_PATH, 0o600);
121
+ }
122
+
123
+ export function updateFactoryApiKeyEntries(
124
+ entries: FactoryApiKeyEntry[],
125
+ id: string,
126
+ action: { kind: "enabled"; enabled: boolean } | { kind: "remove" },
127
+ ) {
128
+ let changed = false;
129
+ const next = entries.flatMap((entry): FactoryApiKeyEntry[] => {
130
+ if (keyId(entry) !== id) return [entry];
131
+ changed = true;
132
+ if (action.kind === "remove") return [];
133
+ return [{ ...entry, disabled: !action.enabled }];
134
+ });
135
+ return { entries: next, changed };
136
+ }
137
+
138
+ export function setFactoryApiKeyEnabled(id: string, enabled: boolean) {
139
+ const result = updateFactoryApiKeyEntries(editableFactoryApiKeyEntries(), id, { kind: "enabled", enabled });
140
+ if (!result.changed) return false;
141
+ saveFactoryApiKeyEntries(result.entries);
142
+ return true;
143
+ }
144
+
145
+ export function removeFactoryApiKey(id: string) {
146
+ const result = updateFactoryApiKeyEntries(editableFactoryApiKeyEntries(), id, { kind: "remove" });
147
+ if (!result.changed) return false;
148
+ saveFactoryApiKeyEntries(result.entries);
149
+ state.delete(id);
150
+ organizationIdCache.delete(id);
151
+ return true;
152
+ }
153
+
100
154
  export function sortFactoryApiKeysByLastUsed(
101
155
  entries: FactoryApiKeyEntry[],
102
156
  lastUsed = (entry: FactoryApiKeyEntry) => state.get(keyId(entry))?.lastUsedAt,
@@ -395,7 +449,7 @@ export function streamSimpleUnifiedFactoryResponses(model: any, context: any, op
395
449
  }
396
450
 
397
451
  export function factoryApiKeyStatus() {
398
- const keys = loadFactoryApiKeys();
452
+ const keys = configuredFactoryApiKeys();
399
453
  const now = Date.now();
400
454
  return {
401
455
  configured: keys.length,
@@ -408,6 +462,8 @@ export function factoryApiKeyStatus() {
408
462
  id: keyId(entry),
409
463
  label: entry.label,
410
464
  key: maskKey(entry.key),
465
+ disabled: Boolean(entry.disabled),
466
+ editable: entry.source === "file",
411
467
  cooldownSeconds: runtime?.cooldownUntil && runtime.cooldownUntil > now ? Math.ceil((runtime.cooldownUntil - now) / 1000) : 0,
412
468
  lastError: runtime?.lastError ? runtime.lastError.slice(0, 200) : undefined,
413
469
  lastUsedAt: runtime?.lastUsedAt ? new Date(runtime.lastUsedAt).toISOString() : undefined,
@@ -26,6 +26,8 @@ import { factoryWarmTheme } from "./warm-theme.ts";
26
26
  export interface FactoryDashboardAccount {
27
27
  id: string;
28
28
  label: string;
29
+ disabled?: boolean;
30
+ editable?: boolean;
29
31
  record?: FactoryLimitRecord;
30
32
  cooldownSeconds?: number;
31
33
  lastUsedAt?: string;
@@ -96,6 +98,7 @@ function compactPool(
96
98
  }
97
99
 
98
100
  function accountState(account: FactoryDashboardAccount) {
101
+ if (account.disabled) return "disabled";
99
102
  if (account.cooldownSeconds && account.cooldownSeconds > 0) return "cooldown";
100
103
  if (account.record?.error) return "refresh error";
101
104
  const observedAt = account.record?.fetchedAt ?? Date.now();
@@ -122,6 +125,8 @@ export class FactoryDashboard implements Component {
122
125
  private readonly keys: KeybindingsManager;
123
126
  private snapshot: FactoryDashboardSnapshot;
124
127
  private readonly refreshData: (force: boolean) => Promise<FactoryDashboardSnapshot>;
128
+ private readonly toggleAccount: (id: string, enabled: boolean) => Promise<FactoryDashboardSnapshot>;
129
+ private readonly removeAccount: (id: string, label: string) => Promise<FactoryDashboardSnapshot>;
125
130
  private readonly done: () => void;
126
131
 
127
132
  constructor(
@@ -130,6 +135,8 @@ export class FactoryDashboard implements Component {
130
135
  keys: KeybindingsManager,
131
136
  snapshot: FactoryDashboardSnapshot,
132
137
  refreshData: (force: boolean) => Promise<FactoryDashboardSnapshot>,
138
+ toggleAccount: (id: string, enabled: boolean) => Promise<FactoryDashboardSnapshot>,
139
+ removeAccount: (id: string, label: string) => Promise<FactoryDashboardSnapshot>,
133
140
  done: () => void,
134
141
  ) {
135
142
  this.tui = tui;
@@ -137,6 +144,8 @@ export class FactoryDashboard implements Component {
137
144
  this.keys = keys;
138
145
  this.snapshot = snapshot;
139
146
  this.refreshData = refreshData;
147
+ this.toggleAccount = toggleAccount;
148
+ this.removeAccount = removeAccount;
140
149
  this.done = done;
141
150
  }
142
151
 
@@ -145,17 +154,7 @@ export class FactoryDashboard implements Component {
145
154
  this.refreshing = true;
146
155
  this.tui.requestRender();
147
156
  void this.refreshData(force)
148
- .then((snapshot) => {
149
- if (this.closed) return;
150
- const selectedId = this.snapshot.accounts[this.selected]?.id;
151
- this.snapshot = snapshot;
152
- const nextIndex = selectedId
153
- ? snapshot.accounts.findIndex((account) => account.id === selectedId)
154
- : -1;
155
- this.selected = nextIndex >= 0
156
- ? nextIndex
157
- : Math.min(this.selected, Math.max(0, snapshot.accounts.length - 1));
158
- })
157
+ .then((snapshot) => this.replaceSnapshot(snapshot))
159
158
  .catch((error) => {
160
159
  if (this.closed) return;
161
160
  this.snapshot = {
@@ -170,6 +169,18 @@ export class FactoryDashboard implements Component {
170
169
  });
171
170
  }
172
171
 
172
+ private replaceSnapshot(snapshot: FactoryDashboardSnapshot) {
173
+ if (this.closed) return;
174
+ const selectedId = this.snapshot.accounts[this.selected]?.id;
175
+ this.snapshot = snapshot;
176
+ const nextIndex = selectedId
177
+ ? snapshot.accounts.findIndex((account) => account.id === selectedId)
178
+ : -1;
179
+ this.selected = nextIndex >= 0
180
+ ? nextIndex
181
+ : Math.min(this.selected, Math.max(0, snapshot.accounts.length - 1));
182
+ }
183
+
173
184
  dispose() {
174
185
  this.closed = true;
175
186
  }
@@ -191,6 +202,23 @@ export class FactoryDashboard implements Component {
191
202
  if (accounts.length) this.selected = (this.selected + 1) % accounts.length;
192
203
  } else if (data === "r") {
193
204
  this.startRefresh(true);
205
+ } else if ((data === "d" || data === "x") && accounts[this.selected]?.editable && !this.refreshing) {
206
+ const account = accounts[this.selected]!;
207
+ this.refreshing = true;
208
+ const action = data === "x"
209
+ ? this.removeAccount(account.id, account.label)
210
+ : this.toggleAccount(account.id, account.disabled === true);
211
+ void action
212
+ .then((snapshot) => this.replaceSnapshot(snapshot))
213
+ .catch((error) => {
214
+ if (!this.closed) this.snapshot = { ...this.snapshot, warning: error instanceof Error ? error.message : String(error) };
215
+ })
216
+ .finally(() => {
217
+ if (!this.closed) {
218
+ this.refreshing = false;
219
+ this.tui.requestRender();
220
+ }
221
+ });
194
222
  }
195
223
  this.tui.requestRender();
196
224
  }
@@ -242,7 +270,7 @@ export class FactoryDashboard implements Component {
242
270
  lines.push(frameBottom(this.theme, width));
243
271
  lines.push(
244
272
  truncateToWidth(
245
- `${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh all")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`,
273
+ `${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh all")} ${this.theme.fg("accent", "d")} ${this.theme.fg("dim", "enable/disable")} ${this.theme.fg("accent", "x")} ${this.theme.fg("dim", "remove")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`,
246
274
  width,
247
275
  "",
248
276
  ),
@@ -341,6 +369,8 @@ export async function openFactoryDashboard(
341
369
  ctx: ExtensionContext,
342
370
  initial: FactoryDashboardSnapshot,
343
371
  refresh: (force: boolean) => Promise<FactoryDashboardSnapshot>,
372
+ toggle: (id: string, enabled: boolean) => Promise<FactoryDashboardSnapshot>,
373
+ remove: (id: string, label: string) => Promise<FactoryDashboardSnapshot>,
344
374
  ) {
345
375
  await ctx.ui.custom<void>(
346
376
  (tui, theme, keys, done) => {
@@ -350,6 +380,8 @@ export async function openFactoryDashboard(
350
380
  keys,
351
381
  initial,
352
382
  refresh,
383
+ toggle,
384
+ remove,
353
385
  () => done(undefined),
354
386
  );
355
387
  queueMicrotask(() => dashboard.startRefresh(false));
@@ -11,6 +11,8 @@ import {
11
11
  FACTORY_API_KEY_FILE_SENTINEL,
12
12
  factoryApiKeyStatus,
13
13
  loadFactoryApiKeys,
14
+ removeFactoryApiKey,
15
+ setFactoryApiKeyEnabled,
14
16
  streamSimpleUnifiedFactoryResponses,
15
17
  } from "./factory/api-keys.ts";
16
18
  import {
@@ -88,21 +90,35 @@ export default async function factoryExtension(pi: ExtensionAPI) {
88
90
  ctx: ExtensionContext,
89
91
  ): Promise<FactoryDashboardSnapshot> => {
90
92
  const credentials = await limitCredentials(ctx);
93
+ const resolved = await ctx.modelRegistry.getProviderAuth(PROVIDER_ID);
94
+ const fileBacked = resolved?.auth.apiKey?.trim() === FACTORY_API_KEY_FILE_SENTINEL;
91
95
  const auth = ctx.modelRegistry.getProviderAuthStatus(PROVIDER_ID);
92
96
  const apiKeys = factoryApiKeyStatus();
93
97
  const cache = loadFactoryLimitCache();
94
98
  const runtimeById = new Map(apiKeys.keys.map((key) => [key.id, key]));
95
- const accounts = credentials.map((credential) => {
96
- const id = factoryCredentialId(credential.secret);
97
- const runtime = runtimeById.get(id);
98
- return {
99
- id,
100
- label: credential.label,
101
- record: cache.records.find((record) => record.id === id),
102
- cooldownSeconds: runtime?.cooldownSeconds,
103
- lastUsedAt: runtime?.lastUsedAt,
104
- };
105
- });
99
+ const accounts = fileBacked
100
+ ? apiKeys.keys.map((key) => ({
101
+ id: key.id,
102
+ label: key.label,
103
+ disabled: key.disabled,
104
+ editable: key.editable,
105
+ record: cache.records.find((record) => record.id === key.id),
106
+ cooldownSeconds: key.cooldownSeconds,
107
+ lastUsedAt: key.lastUsedAt,
108
+ }))
109
+ : credentials.map((credential) => {
110
+ const id = factoryCredentialId(credential.secret);
111
+ const runtime = runtimeById.get(id);
112
+ return {
113
+ id,
114
+ label: credential.label,
115
+ disabled: false,
116
+ editable: false,
117
+ record: cache.records.find((record) => record.id === id),
118
+ cooldownSeconds: runtime?.cooldownSeconds,
119
+ lastUsedAt: runtime?.lastUsedAt,
120
+ };
121
+ });
106
122
  return {
107
123
  version: clientVersion,
108
124
  modelCount: models.length,
@@ -110,7 +126,7 @@ export default async function factoryExtension(pi: ExtensionAPI) {
110
126
  ? auth.label || auth.source || "configured"
111
127
  : "not configured",
112
128
  configured: accounts.length,
113
- active: accounts.filter((account) => !account.cooldownSeconds).length,
129
+ active: accounts.filter((account) => !account.disabled && !account.cooldownSeconds).length,
114
130
  warning: apiKeys.warning,
115
131
  accounts,
116
132
  };
@@ -195,10 +211,25 @@ export default async function factoryExtension(pi: ExtensionAPI) {
195
211
  description: "Open Factory account, rotation, and usage dashboard",
196
212
  handler: async (_args, ctx) => {
197
213
  const initial = await dashboardSnapshot(ctx);
198
- await openFactoryDashboard(ctx, initial, async (force) => {
199
- await refreshLimits(ctx, force);
200
- return dashboardSnapshot(ctx);
201
- });
214
+ await openFactoryDashboard(
215
+ ctx,
216
+ initial,
217
+ async (force) => {
218
+ await refreshLimits(ctx, force);
219
+ return dashboardSnapshot(ctx);
220
+ },
221
+ async (id, enabled) => {
222
+ if (!setFactoryApiKeyEnabled(id, enabled)) throw new Error("This Factory credential is not editable from the rotating-key file.");
223
+ if (enabled) await refreshLimits(ctx, true);
224
+ return dashboardSnapshot(ctx);
225
+ },
226
+ async (id, label) => {
227
+ const confirmed = await ctx.ui.confirm("Remove Factory account?", `Permanently remove ${label} from the rotating-key file?`);
228
+ if (!confirmed) return dashboardSnapshot(ctx);
229
+ if (!removeFactoryApiKey(id)) throw new Error("This Factory credential is not editable from the rotating-key file.");
230
+ return dashboardSnapshot(ctx);
231
+ },
232
+ );
202
233
  },
203
234
  });
204
235
  }
@@ -38,8 +38,8 @@ The tool definition and Pi prompt guidance explicitly require the model to:
38
38
  The runtime reinforces these instructions without assigning the list to another worker:
39
39
 
40
40
  - after two substantive tool calls with no list, the next model context receives a conditional reminder;
41
- - after substantive work makes an active list stale, the next model context asks for an immediate update paired with the next action;
42
- - if the model still stops with a stale active list, Pi starts at most one follow-up model turn so the same model can update it and continue or finish;
41
+ - routine file reads, edits, commands, and tool calls do not trigger bookkeeping updates while the current task remains active;
42
+ - the model updates only for task-level transitions: verified completion and handoff to the next task, genuine blockers or cancellations, user-requested scope changes, and final reconciliation;
43
43
  - status-only inspection tools do not count as substantive progress.
44
44
 
45
45
  The list remains a coordination aid, not evidence that implementation or verification succeeded.
@@ -97,13 +97,8 @@ export default function taskListExtension(pi: ExtensionAPI) {
97
97
  let state = emptyTaskListState();
98
98
  let lastCtx: ExtensionContext | null = null;
99
99
  let finishedTimer: ReturnType<typeof setTimeout> | undefined;
100
- let sequence = 0;
101
- let lastTaskSequence = 0;
102
- let lastWorkSequence = 0;
103
100
  let workCallsSinceUser = 0;
104
101
  let taskCallsSinceUser = 0;
105
- let nudgeCount = 0;
106
- let staleAtAgentEnd = false;
107
102
 
108
103
  function cancelFinishedTimer(): void {
109
104
  if (!finishedTimer) return;
@@ -207,9 +202,6 @@ export default function taskListExtension(pi: ExtensionAPI) {
207
202
  }
208
203
 
209
204
  function reminderText(): string | null {
210
- if (hasActiveTasks(state) && lastWorkSequence > lastTaskSequence) {
211
- return "The task list is stale after substantive work. Before more narration, call task_list with the complete updated list: mark finished work completed only if verified, keep current work in_progress, and pair the update with the next action tool when work remains.";
212
- }
213
205
  if (state.tasks.length === 0 && taskCallsSinceUser === 0 && workCallsSinceUser >= 2) {
214
206
  return "You have started multi-action work without task_list. If this request requires at least three distinct actions or contains multiple user tasks, create the complete list now and call task_list in the same assistant message as the next action. Do not create a retroactive list if the work is already complete or was genuinely trivial.";
215
207
  }
@@ -220,21 +212,13 @@ export default function taskListExtension(pi: ExtensionAPI) {
220
212
  if (event.source === "extension") return;
221
213
  workCallsSinceUser = 0;
222
214
  taskCallsSinceUser = 0;
223
- lastTaskSequence = 0;
224
- lastWorkSequence = 0;
225
- staleAtAgentEnd = false;
226
- nudgeCount = 0;
227
215
  });
228
216
 
229
217
  pi.on("session_start", async (_event, ctx) => {
230
218
  lastCtx = ctx;
231
219
  state = restoreTaskList(ctx);
232
- sequence = 0;
233
- lastTaskSequence = 0;
234
- lastWorkSequence = 0;
235
220
  workCallsSinceUser = 0;
236
221
  taskCallsSinceUser = 0;
237
- nudgeCount = 0;
238
222
  updatePresentation(ctx);
239
223
  });
240
224
 
@@ -245,14 +229,8 @@ export default function taskListExtension(pi: ExtensionAPI) {
245
229
 
246
230
  pi.on("tool_execution_start", async (event, ctx) => {
247
231
  lastCtx = ctx;
248
- sequence++;
249
- if (event.toolName === TASK_LIST_TOOL) {
250
- lastTaskSequence = sequence;
251
- taskCallsSinceUser++;
252
- } else if (!WORK_TOOL_EXCLUSIONS.has(event.toolName)) {
253
- lastWorkSequence = sequence;
254
- workCallsSinceUser++;
255
- }
232
+ if (event.toolName === TASK_LIST_TOOL) taskCallsSinceUser++;
233
+ else if (!WORK_TOOL_EXCLUSIONS.has(event.toolName)) workCallsSinceUser++;
256
234
  });
257
235
 
258
236
  pi.on("context", async (event) => {
@@ -281,23 +259,6 @@ export default function taskListExtension(pi: ExtensionAPI) {
281
259
  return additions.length > 0 ? { messages: [...event.messages, ...additions] } : undefined;
282
260
  });
283
261
 
284
- pi.on("agent_end", async () => {
285
- staleAtAgentEnd = hasActiveTasks(state) && lastWorkSequence > lastTaskSequence;
286
- });
287
-
288
- pi.on("agent_settled", async (_event, ctx) => {
289
- lastCtx = ctx;
290
- if (!staleAtAgentEnd || nudgeCount >= 1 || ctx.hasPendingMessages()) return;
291
- staleAtAgentEnd = false;
292
- nudgeCount++;
293
- pi.sendMessage({
294
- customType: "task-list-reminder",
295
- content: "You stopped with a stale active task list. Update task_list now. If work remains, pair that update with the next concrete action; if work is finished, mark verified items completed and cancelled items with a reason before the final response.",
296
- display: false,
297
- details: { revision: state.revision, kind: "settled-stale-list" },
298
- }, { deliverAs: "followUp", triggerTurn: true });
299
- });
300
-
301
262
  pi.on("session_shutdown", async () => {
302
263
  cancelFinishedTimer();
303
264
  });
@@ -398,7 +359,7 @@ Use task_list for work with at least three distinct actions, multiple user-reque
398
359
 
399
360
  Start the list before substantive work and call task_list in the SAME assistant message as the first action tool. Never spend a turn only announcing or updating the list when another action can run. Keep stable ids and preserve every user-requested item, exact command, flag, path, and success condition.
400
361
 
401
- Update the list as work happens, not after several steps: mark a finished item completed only after its outcome is verified, move the next sequential item to in_progress, and issue that next action in the same assistant message. Keep one in_progress task for sequential work; use several only when work is genuinely running in parallel. Use blocked only for a concrete unresolved dependency and cancelled only when an item is no longer required, with the reason in note.
362
+ Update the list only when task-level state changes—not after every file read, edit, command, or tool call. When a task is fully verified, mark it completed, move the next sequential item to in_progress, and issue that next action in the same assistant message. Also update for genuine blockers, cancellations, or user-requested scope changes. Keep one in_progress task for sequential work; use several only when work is genuinely running in parallel.
402
363
 
403
364
  Before the final response, reconcile the whole list with actual results. No item may remain pending or in_progress if the requested work is finished. Do not claim completion from the list itself.`,
404
365
  promptSnippet: "Read or replace the current session's complete task list and progress state.",
@@ -406,7 +367,7 @@ Before the final response, reconcile the whole list with actual results. No item
406
367
  "Use task_list for requests with at least three distinct actions, multiple requested tasks, or meaningful phases; skip it for direct answers and one- or two-action work.",
407
368
  "Create task_list before substantive multi-step work and send the update in the same assistant message as the first real action; never spend a turn on task bookkeeping alone when another action exists.",
408
369
  "Every task_list write replaces the entire ordered list. Preserve stable ids, all unfinished and user-requested work, and exact commands, flags, paths, and success conditions.",
409
- "Update task_list immediately as each step changes: complete only verified work, set the next sequential item in_progress, and pair the update with the next action. Multiple in_progress items require genuinely parallel work.",
370
+ "Update task_list only for task-level transitions: verified completion, starting the next task, a genuine blocker or cancellation, or a user-requested scope change. Do not update it after every file read, edit, command, or tool call. Pair transitions with the next action when work remains.",
410
371
  "Before a final response, reconcile task_list with observed results and leave no stale pending or in_progress items when the requested work is finished. The list is not proof of completion.",
411
372
  ],
412
373
  parameters: TaskListParams,
@@ -414,7 +375,6 @@ Before the final response, reconcile the whole list with actual results. No item
414
375
  lastCtx = ctx;
415
376
  if (params.tasks !== undefined) {
416
377
  replaceState(buildUpdatedTaskList(state, params), ctx);
417
- lastTaskSequence = Math.max(lastTaskSequence, sequence);
418
378
  }
419
379
  const action = params.tasks === undefined ? "read" : "update";
420
380
  const details: TaskListDetails = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",