shariq-pi-extensions 0.1.0

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/docs/ARCHITECTURE.md +54 -0
  5. package/docs/DEVELOPMENT.md +88 -0
  6. package/docs/EXTENSIONS.md +89 -0
  7. package/extensions/antigravity-provider/LICENSE +21 -0
  8. package/extensions/antigravity-provider/README.md +23 -0
  9. package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +519 -0
  10. package/extensions/antigravity-provider/antigravity/index.ts +67 -0
  11. package/extensions/antigravity-provider/antigravity/models.ts +184 -0
  12. package/extensions/antigravity-provider/antigravity/oauth.ts +499 -0
  13. package/extensions/antigravity-provider/index.ts +3 -0
  14. package/extensions/ask-user/README.md +21 -0
  15. package/extensions/ask-user/index.ts +417 -0
  16. package/extensions/background-terminals/README.md +43 -0
  17. package/extensions/background-terminals/index.ts +400 -0
  18. package/extensions/background-terminals/src/manager.ts +480 -0
  19. package/extensions/background-terminals/src/output-buffer.ts +110 -0
  20. package/extensions/background-terminals/src/presentation.ts +74 -0
  21. package/extensions/background-terminals/src/types.ts +47 -0
  22. package/extensions/background-terminals/src/ui.ts +478 -0
  23. package/extensions/context-usage/LICENSE +21 -0
  24. package/extensions/context-usage/README.md +20 -0
  25. package/extensions/context-usage/index.ts +2006 -0
  26. package/extensions/context-usage/lib/ansi.ts +60 -0
  27. package/extensions/context-usage/lib/boundary.ts +27 -0
  28. package/extensions/context-usage/lib/chat.ts +190 -0
  29. package/extensions/context-usage/lib/config.ts +30 -0
  30. package/extensions/context-usage/lib/fmt.ts +59 -0
  31. package/extensions/context-usage/lib/style.ts +260 -0
  32. package/extensions/copy-all/README.md +15 -0
  33. package/extensions/copy-all/index.ts +72 -0
  34. package/extensions/factory-provider/API_KEYS.md +17 -0
  35. package/extensions/factory-provider/AUTH.md +15 -0
  36. package/extensions/factory-provider/README.md +90 -0
  37. package/extensions/factory-provider/REFRESH_MODELS.md +185 -0
  38. package/extensions/factory-provider/UPDATING.md +97 -0
  39. package/extensions/factory-provider/factory/api-keys.ts +407 -0
  40. package/extensions/factory-provider/factory/auth.ts +132 -0
  41. package/extensions/factory-provider/factory/constants.ts +15 -0
  42. package/extensions/factory-provider/factory/dashboard.ts +363 -0
  43. package/extensions/factory-provider/factory/droid.ts +84 -0
  44. package/extensions/factory-provider/factory/limits.ts +342 -0
  45. package/extensions/factory-provider/factory/models.ts +283 -0
  46. package/extensions/factory-provider/factory/responses.ts +331 -0
  47. package/extensions/factory-provider/factory/warm-theme.ts +53 -0
  48. package/extensions/factory-provider/factory/websocket.ts +130 -0
  49. package/extensions/factory-provider/index.ts +204 -0
  50. package/extensions/factory-provider/scripts/audit-reasoning-efforts.py +84 -0
  51. package/extensions/firecrawl-web/README.md +31 -0
  52. package/extensions/firecrawl-web/auth.ts +92 -0
  53. package/extensions/firecrawl-web/client.ts +238 -0
  54. package/extensions/firecrawl-web/index.ts +96 -0
  55. package/extensions/firecrawl-web/output.ts +125 -0
  56. package/extensions/git-info/README.md +25 -0
  57. package/extensions/git-info/index.ts +162 -0
  58. package/extensions/git-info/src/git.ts +206 -0
  59. package/extensions/git-info/src/ui.ts +291 -0
  60. package/extensions/goal/README.md +44 -0
  61. package/extensions/goal/constants.ts +4 -0
  62. package/extensions/goal/extension.ts +1064 -0
  63. package/extensions/goal/index.ts +2 -0
  64. package/extensions/goal/types.ts +56 -0
  65. package/extensions/goal/ui.ts +334 -0
  66. package/extensions/orchestration/README.md +58 -0
  67. package/extensions/orchestration/dashboard.ts +192 -0
  68. package/extensions/orchestration/engine.ts +706 -0
  69. package/extensions/orchestration/index.ts +243 -0
  70. package/extensions/orchestration/prompts.ts +124 -0
  71. package/extensions/orchestration/protocol.ts +116 -0
  72. package/extensions/orchestration/settings-ui.ts +41 -0
  73. package/extensions/orchestration/settings.ts +59 -0
  74. package/extensions/orchestration/storage.ts +49 -0
  75. package/extensions/orchestration/types.ts +105 -0
  76. package/extensions/performance-status/README.md +5 -0
  77. package/extensions/performance-status/index.ts +171 -0
  78. package/extensions/pi-memory/README.md +53 -0
  79. package/extensions/pi-memory/index.ts +326 -0
  80. package/extensions/pi-memory/src/config.ts +75 -0
  81. package/extensions/pi-memory/src/database.ts +617 -0
  82. package/extensions/pi-memory/src/extraction.ts +178 -0
  83. package/extensions/pi-memory/src/project.ts +80 -0
  84. package/extensions/pi-memory/src/projection.ts +59 -0
  85. package/extensions/pi-memory/src/redaction.ts +60 -0
  86. package/extensions/pi-memory/src/retrieval.ts +30 -0
  87. package/extensions/pi-memory/src/service.ts +194 -0
  88. package/extensions/pi-memory/src/session.ts +146 -0
  89. package/extensions/pi-memory/src/types.ts +82 -0
  90. package/extensions/shared/README.md +18 -0
  91. package/extensions/shared/activity-status.ts +31 -0
  92. package/extensions/shared/child-session.ts +150 -0
  93. package/extensions/shared/context-utilization.ts +47 -0
  94. package/extensions/shared/dashboard-state.ts +99 -0
  95. package/extensions/shared/tool-call-timeout.ts +104 -0
  96. package/extensions/shared/tui-dashboard.ts +139 -0
  97. package/extensions/shell-shortcuts/README.md +17 -0
  98. package/extensions/shell-shortcuts/features/exit-alias.ts +10 -0
  99. package/extensions/shell-shortcuts/index.ts +6 -0
  100. package/extensions/subagents/README.md +89 -0
  101. package/extensions/subagents/index.ts +1386 -0
  102. package/extensions/subagents/src/backend.ts +69 -0
  103. package/extensions/subagents/src/backends/pi.ts +781 -0
  104. package/extensions/subagents/src/catalog.ts +85 -0
  105. package/extensions/subagents/src/config.ts +241 -0
  106. package/extensions/subagents/src/context.ts +57 -0
  107. package/extensions/subagents/src/coordinator.ts +64 -0
  108. package/extensions/subagents/src/domain.ts +294 -0
  109. package/extensions/subagents/src/format.ts +74 -0
  110. package/extensions/subagents/src/manager.ts +813 -0
  111. package/extensions/subagents/src/prompt.ts +94 -0
  112. package/extensions/subagents/src/result-delivery.ts +20 -0
  113. package/extensions/subagents/src/runtime.ts +48 -0
  114. package/extensions/subagents/src/ui/takeover.ts +695 -0
  115. package/extensions/subagents/src/ui/transcript.ts +182 -0
  116. package/extensions/subagents/src/worktree.ts +175 -0
  117. package/extensions/web-fetch/README.md +13 -0
  118. package/extensions/web-fetch/index.ts +441 -0
  119. package/package.json +117 -0
  120. package/skills/background-terminals/SKILL.md +42 -0
  121. package/skills/orchestration/SKILL.md +33 -0
  122. package/skills/subagents/SKILL.md +89 -0
  123. package/themes/ember-warm-dark.json +85 -0
@@ -0,0 +1,363 @@
1
+ import type {
2
+ ExtensionContext,
3
+ KeybindingsManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ truncateToWidth,
7
+ type Component,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+ import {
11
+ frameBottom,
12
+ frameTop,
13
+ joinSides,
14
+ meter,
15
+ oneLine,
16
+ padLine,
17
+ stateLabel,
18
+ } from "../../shared/tui-dashboard.ts";
19
+ import {
20
+ exhaustedBucket,
21
+ type FactoryLimitRecord,
22
+ type FactoryPoolLimits,
23
+ } from "./limits.ts";
24
+ import { factoryWarmTheme } from "./warm-theme.ts";
25
+
26
+ export interface FactoryDashboardAccount {
27
+ id: string;
28
+ label: string;
29
+ record?: FactoryLimitRecord;
30
+ cooldownSeconds?: number;
31
+ lastUsedAt?: string;
32
+ }
33
+
34
+ export interface FactoryDashboardSnapshot {
35
+ version: string;
36
+ modelCount: number;
37
+ authentication: string;
38
+ configured: number;
39
+ active: number;
40
+ warning?: string;
41
+ accounts: FactoryDashboardAccount[];
42
+ }
43
+
44
+ type Theme = ExtensionContext["ui"]["theme"];
45
+
46
+ function bucketActive(
47
+ bucket: { secondsRemaining?: number | null; windowEnd?: string | null },
48
+ now = Date.now(),
49
+ observedAt = now,
50
+ ) {
51
+ if (bucket.windowEnd) {
52
+ const end = Date.parse(bucket.windowEnd);
53
+ if (Number.isFinite(end)) return end > now;
54
+ }
55
+ return typeof bucket.secondsRemaining === "number" &&
56
+ observedAt + bucket.secondsRemaining * 1000 > now;
57
+ }
58
+
59
+ function remainingText(
60
+ bucket: { secondsRemaining?: number | null; windowEnd?: string | null },
61
+ now = Date.now(),
62
+ observedAt = now,
63
+ ) {
64
+ const resetAt = bucket.windowEnd
65
+ ? Date.parse(bucket.windowEnd)
66
+ : observedAt + (bucket.secondsRemaining ?? 0) * 1000;
67
+ const seconds = Number.isFinite(resetAt)
68
+ ? Math.max(0, Math.round((resetAt - now) / 1000))
69
+ : 0;
70
+ if (!seconds || !bucketActive(bucket, now, observedAt)) return "inactive";
71
+ const days = Math.floor(seconds / 86_400);
72
+ const hours = Math.floor((seconds % 86_400) / 3_600);
73
+ const minutes = Math.floor((seconds % 3_600) / 60);
74
+ if (days) return `${days}d ${hours}h`;
75
+ if (hours) return `${hours}h ${minutes}m`;
76
+ return `${minutes}m`;
77
+ }
78
+
79
+ function compactPool(
80
+ prefix: string,
81
+ limits: FactoryPoolLimits | undefined,
82
+ observedAt = Date.now(),
83
+ ) {
84
+ if (!limits) return `${prefix} usage unavailable`;
85
+ const exhausted = exhaustedBucket(limits, Date.now(), observedAt);
86
+ if (exhausted === "monthly") return `${prefix} used · M 100% · children inactive`;
87
+ if (exhausted === "weekly") {
88
+ return `${prefix} used · M ${limits.monthly.usedPercent}% · W 100% · 5h inactive`;
89
+ }
90
+ const parts = [
91
+ bucketActive(limits.monthly, Date.now(), observedAt) ? `M ${limits.monthly.usedPercent}%` : undefined,
92
+ bucketActive(limits.weekly, Date.now(), observedAt) ? `W ${limits.weekly.usedPercent}%` : undefined,
93
+ bucketActive(limits.fiveHour, Date.now(), observedAt) ? `5h ${limits.fiveHour.usedPercent}%` : undefined,
94
+ ].filter(Boolean);
95
+ return `${prefix} used · ${parts.join(" · ") || "fresh window"}`;
96
+ }
97
+
98
+ function accountState(account: FactoryDashboardAccount) {
99
+ if (account.cooldownSeconds && account.cooldownSeconds > 0) return "cooldown";
100
+ if (account.record?.error) return "refresh error";
101
+ const observedAt = account.record?.fetchedAt ?? Date.now();
102
+ const standard = exhaustedBucket(account.record?.limits?.standard, Date.now(), observedAt);
103
+ const core = exhaustedBucket(account.record?.limits?.core, Date.now(), observedAt);
104
+ if (standard && core) return "exhausted";
105
+ if (standard || core) return `${standard ? "standard" : "core"} exhausted`;
106
+ return account.record?.limits ? "ready" : "not refreshed";
107
+ }
108
+
109
+ function stateColor(state: string) {
110
+ if (state === "ready") return "success" as const;
111
+ if (state === "not refreshed") return "muted" as const;
112
+ if (state === "refresh error") return "error" as const;
113
+ return "warning" as const;
114
+ }
115
+
116
+ export class FactoryDashboard implements Component {
117
+ private selected = 0;
118
+ private refreshing = false;
119
+ private closed = false;
120
+ private readonly tui: TUI;
121
+ private readonly theme: Theme;
122
+ private readonly keys: KeybindingsManager;
123
+ private snapshot: FactoryDashboardSnapshot;
124
+ private readonly refreshData: (force: boolean) => Promise<FactoryDashboardSnapshot>;
125
+ private readonly done: () => void;
126
+
127
+ constructor(
128
+ tui: TUI,
129
+ theme: Theme,
130
+ keys: KeybindingsManager,
131
+ snapshot: FactoryDashboardSnapshot,
132
+ refreshData: (force: boolean) => Promise<FactoryDashboardSnapshot>,
133
+ done: () => void,
134
+ ) {
135
+ this.tui = tui;
136
+ this.theme = factoryWarmTheme(theme);
137
+ this.keys = keys;
138
+ this.snapshot = snapshot;
139
+ this.refreshData = refreshData;
140
+ this.done = done;
141
+ }
142
+
143
+ startRefresh(force: boolean) {
144
+ if (this.refreshing || this.closed) return;
145
+ this.refreshing = true;
146
+ this.tui.requestRender();
147
+ 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
+ })
159
+ .catch((error) => {
160
+ if (this.closed) return;
161
+ this.snapshot = {
162
+ ...this.snapshot,
163
+ warning: `Refresh failed: ${error instanceof Error ? error.message : String(error)}`,
164
+ };
165
+ })
166
+ .finally(() => {
167
+ if (this.closed) return;
168
+ this.refreshing = false;
169
+ this.tui.requestRender();
170
+ });
171
+ }
172
+
173
+ dispose() {
174
+ this.closed = true;
175
+ }
176
+
177
+ invalidate() {}
178
+
179
+ handleInput(data: string) {
180
+ const accounts = this.snapshot.accounts;
181
+ if (this.keys.matches(data, "tui.select.cancel")) {
182
+ this.closed = true;
183
+ this.done();
184
+ return;
185
+ }
186
+ if (this.keys.matches(data, "tui.select.up") || data === "k") {
187
+ if (accounts.length) {
188
+ this.selected = (this.selected - 1 + accounts.length) % accounts.length;
189
+ }
190
+ } else if (this.keys.matches(data, "tui.select.down") || data === "j") {
191
+ if (accounts.length) this.selected = (this.selected + 1) % accounts.length;
192
+ } else if (data === "r") {
193
+ this.startRefresh(true);
194
+ }
195
+ this.tui.requestRender();
196
+ }
197
+
198
+ render(width: number) {
199
+ const rows = this.tui.terminal.rows || 30;
200
+ const bodyHeight = Math.max(8, rows - 7);
201
+ const accounts = this.snapshot.accounts;
202
+ this.selected = Math.min(this.selected, Math.max(0, accounts.length - 1));
203
+ const selected = accounts[this.selected];
204
+ const ready = `${this.snapshot.active}/${this.snapshot.configured} active`;
205
+ const headerRight = this.refreshing
206
+ ? this.theme.fg("warning", "refreshing…")
207
+ : this.snapshot.warning
208
+ ? this.theme.fg("warning", oneLine(this.snapshot.warning))
209
+ : this.theme.fg("muted", `${ready} · ${this.snapshot.modelCount} models · Droid ${this.snapshot.version}`);
210
+ const title = ` ${this.theme.fg("accent", this.theme.bold("◆ FACTORY"))} ${this.theme.fg("dim", `· ${this.snapshot.authentication}`)}`;
211
+ const lines = width >= 60
212
+ ? [joinSides(title, `${headerRight} `, width)]
213
+ : [truncateToWidth(title, width, ""), truncateToWidth(` ${headerRight}`, width, "")];
214
+ lines.push(
215
+ frameTop(this.theme, width, `ACCOUNTS ${accounts.length} · USAGE CONSUMED`),
216
+ );
217
+ const innerWidth = Math.max(1, width - 2);
218
+ if (width >= 104 && selected) {
219
+ const leftWidth = Math.max(46, Math.floor((innerWidth - 1) * 0.48));
220
+ const rightWidth = Math.max(24, innerWidth - leftWidth - 1);
221
+ const list = this.renderAccounts(leftWidth, bodyHeight, true);
222
+ const detail = this.renderDetail(selected, rightWidth, bodyHeight);
223
+ for (let row = 0; row < bodyHeight; row++) {
224
+ lines.push(
225
+ this.theme.fg("border", "│") +
226
+ padLine(list[row] ?? "", leftWidth) +
227
+ this.theme.fg("borderMuted", "│") +
228
+ padLine(detail[row] ?? "", rightWidth) +
229
+ this.theme.fg("border", "│"),
230
+ );
231
+ }
232
+ } else {
233
+ const list = this.renderAccounts(innerWidth, bodyHeight, false);
234
+ for (let row = 0; row < bodyHeight; row++) {
235
+ lines.push(
236
+ this.theme.fg("border", "│") +
237
+ padLine(list[row] ?? "", innerWidth) +
238
+ this.theme.fg("border", "│"),
239
+ );
240
+ }
241
+ }
242
+ lines.push(frameBottom(this.theme, width));
243
+ lines.push(
244
+ 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")}`,
246
+ width,
247
+ "",
248
+ ),
249
+ );
250
+ return lines.map((line) => truncateToWidth(line, width, ""));
251
+ }
252
+
253
+ private renderAccounts(width: number, height: number, compact: boolean) {
254
+ const accounts = this.snapshot.accounts;
255
+ if (!accounts.length) {
256
+ return [
257
+ this.theme.fg("muted", " No Factory credentials configured."),
258
+ this.theme.fg("dim", " Run /login factory to configure an account or API-key file."),
259
+ ];
260
+ }
261
+ const rowsPerAccount = compact ? 1 : 4;
262
+ const visibleCount = Math.max(1, Math.floor(height / rowsPerAccount));
263
+ const start = Math.min(
264
+ Math.max(0, this.selected - Math.floor(visibleCount / 2)),
265
+ Math.max(0, accounts.length - visibleCount),
266
+ );
267
+ const visible = accounts.slice(start, start + visibleCount);
268
+ const lines: string[] = [];
269
+ for (const [offset, account] of visible.entries()) {
270
+ const index = start + offset;
271
+ const selected = index === this.selected;
272
+ const state = accountState(account);
273
+ const marker = selected ? this.theme.fg("accent", "◆") : " ";
274
+ const title = this.theme.fg(selected ? "accent" : "text", oneLine(account.label));
275
+ const status = stateLabel(this.theme, stateColor(state), state);
276
+ const first = joinSides(` ${marker} ${title}`, `${status} `, width);
277
+ lines.push(selected ? this.theme.bg("selectedBg", padLine(first, width)) : first);
278
+ if (!compact) {
279
+ lines.push(` ${this.theme.fg("muted", compactPool("Standard", account.record?.limits?.standard, account.record?.fetchedAt))}`);
280
+ lines.push(` ${this.theme.fg("muted", compactPool("Core", account.record?.limits?.core, account.record?.fetchedAt))}`);
281
+ lines.push("");
282
+ }
283
+ }
284
+ if (start > 0 && lines.length) lines[0] = this.theme.fg("dim", ` ↑ ${start} more`);
285
+ const below = accounts.length - start - visible.length;
286
+ if (below > 0 && lines.length) lines[lines.length - 1] = this.theme.fg("dim", ` ↓ ${below} more`);
287
+ return lines.slice(0, height);
288
+ }
289
+
290
+ private renderDetail(account: FactoryDashboardAccount, width: number, height: number) {
291
+ const state = accountState(account);
292
+ const lines = [
293
+ ` ${stateLabel(this.theme, stateColor(state), state)} ${this.theme.fg("accent", this.theme.bold(oneLine(account.label)))}`,
294
+ account.record
295
+ ? ` ${this.theme.fg("dim", `updated ${Math.max(0, Math.floor((Date.now() - account.record.fetchedAt) / 60_000))}m ago`)}`
296
+ : ` ${this.theme.fg("dim", "usage has not been refreshed")}`,
297
+ ];
298
+ if (account.cooldownSeconds) {
299
+ lines.push(` ${this.theme.fg("warning", `rotation cooldown ${remainingText({ secondsRemaining: account.cooldownSeconds })}`)}`);
300
+ }
301
+ if (account.record?.error) {
302
+ lines.push(` ${this.theme.fg("error", oneLine(account.record.error))}`);
303
+ }
304
+ lines.push("", ...this.renderPool("Standard", account.record?.limits?.standard, width, account.record?.fetchedAt));
305
+ lines.push("", ...this.renderPool("Droid Core", account.record?.limits?.core, width, account.record?.fetchedAt));
306
+ if (account.lastUsedAt) {
307
+ lines.push("", ` ${this.theme.fg("muted", "LAST USED")} ${this.theme.fg("text", account.lastUsedAt)}`);
308
+ }
309
+ return lines.slice(0, height);
310
+ }
311
+
312
+ private renderPool(
313
+ name: string,
314
+ limits: FactoryPoolLimits | undefined,
315
+ width: number,
316
+ observedAt = Date.now(),
317
+ ) {
318
+ const lines = [` ${this.theme.fg("accent", this.theme.bold(`${name.toUpperCase()} · CONSUMED`))}`];
319
+ if (!limits) return [...lines, ` ${this.theme.fg("muted", "Unavailable")}`];
320
+ const exhausted = exhaustedBucket(limits, Date.now(), observedAt);
321
+ const windows = [
322
+ ["MONTHLY", limits.monthly, false],
323
+ ["WEEKLY", limits.weekly, exhausted === "monthly"],
324
+ ["5-HOUR", limits.fiveHour, exhausted === "monthly" || exhausted === "weekly"],
325
+ ] as const;
326
+ for (const [label, bucket, suppressed] of windows) {
327
+ const isActive = bucketActive(bucket, Date.now(), observedAt) && !suppressed;
328
+ const state = bucket.usedPercent >= 100 && isActive
329
+ ? "error"
330
+ : bucket.usedPercent >= 85 && isActive
331
+ ? "warning"
332
+ : "active";
333
+ const value = isActive ? bucket.usedPercent : 0;
334
+ lines.push(` ${this.theme.fg("muted", label.padEnd(7))} ${meter(this.theme, value, 100, Math.max(12, width - 21), isActive ? state : "muted")} ${this.theme.fg("dim", suppressed ? "parent exhausted" : remainingText(bucket, Date.now(), observedAt))}`);
335
+ }
336
+ return lines;
337
+ }
338
+ }
339
+
340
+ export async function openFactoryDashboard(
341
+ ctx: ExtensionContext,
342
+ initial: FactoryDashboardSnapshot,
343
+ refresh: (force: boolean) => Promise<FactoryDashboardSnapshot>,
344
+ ) {
345
+ await ctx.ui.custom<void>(
346
+ (tui, theme, keys, done) => {
347
+ const dashboard = new FactoryDashboard(
348
+ tui,
349
+ theme,
350
+ keys,
351
+ initial,
352
+ refresh,
353
+ () => done(undefined),
354
+ );
355
+ queueMicrotask(() => dashboard.startRefresh(false));
356
+ return dashboard;
357
+ },
358
+ {
359
+ overlay: true,
360
+ overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
361
+ },
362
+ );
363
+ }
@@ -0,0 +1,84 @@
1
+ import { execFileSync } from "child_process";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs";
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from "path";
4
+ import { DEFAULT_DROID_BINARY, DROID_CACHE_PATH, FALLBACK_DROID_VERSION } from "./constants.ts";
5
+
6
+ type DroidCache = {
7
+ binaryPath: string;
8
+ binaryMtimeMs: number;
9
+ version: string;
10
+ execHelp: string;
11
+ };
12
+
13
+ export function droidPath() {
14
+ const configured = process.env.FACTORY_DROID_BINARY || process.env.DROID_BINARY || DEFAULT_DROID_BINARY;
15
+ const candidates = isAbsolute(configured) || configured.includes("/") || configured.includes("\\")
16
+ ? [resolve(configured)]
17
+ : (process.env.PATH || "").split(delimiter).filter(Boolean).map((dir) => join(dir, configured));
18
+ for (const candidate of candidates) {
19
+ try {
20
+ accessSync(candidate, constants.X_OK);
21
+ return realpathSync(candidate);
22
+ } catch {
23
+ // Keep searching PATH; callers retain the configured fallback below.
24
+ }
25
+ }
26
+ return configured;
27
+ }
28
+
29
+ function binaryMtimeMs(path: string) {
30
+ try {
31
+ return statSync(path).mtimeMs;
32
+ } catch {
33
+ return 0;
34
+ }
35
+ }
36
+
37
+ function readCache(): DroidCache | null {
38
+ try {
39
+ if (!existsSync(DROID_CACHE_PATH)) return null;
40
+ const cache = JSON.parse(readFileSync(DROID_CACHE_PATH, "utf8")) as DroidCache;
41
+ const path = droidPath();
42
+ if (cache.binaryPath !== path || cache.binaryMtimeMs !== binaryMtimeMs(path)) return null;
43
+ return cache;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function writeCache(cache: DroidCache) {
50
+ try {
51
+ mkdirSync(dirname(DROID_CACHE_PATH), { recursive: true, mode: 0o700 });
52
+ writeFileSync(DROID_CACHE_PATH, JSON.stringify(cache), { mode: 0o600 });
53
+ } catch {
54
+ // Cache is best-effort; never block Pi startup on it.
55
+ }
56
+ }
57
+
58
+ function refreshCache(): DroidCache | null {
59
+ const path = droidPath();
60
+ try {
61
+ const version = execFileSync(path, ["--version"], { encoding: "utf8", timeout: 5000 }).trim() || FALLBACK_DROID_VERSION;
62
+ const execHelp = execFileSync(path, ["exec", "--help"], { encoding: "utf8", timeout: 10_000 });
63
+ const cache = { binaryPath: path, binaryMtimeMs: binaryMtimeMs(path), version, execHelp };
64
+ writeCache(cache);
65
+ return cache;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function getCache() {
72
+ // Do not shell out to Droid during normal Pi startup/reload. It adds seconds.
73
+ // Refresh explicitly with FACTORY_DROID_REFRESH=1 pi --list-models, then /reload.
74
+ if (process.env.FACTORY_DROID_REFRESH === "1") return refreshCache() || readCache();
75
+ return readCache();
76
+ }
77
+
78
+ export function droidVersion() {
79
+ return getCache()?.version || FALLBACK_DROID_VERSION;
80
+ }
81
+
82
+ export function droidExecHelp() {
83
+ return getCache()?.execHelp || "";
84
+ }