@aemonge-dev/pi-stardeck 0.9.1 → 0.9.2

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.
@@ -1,377 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- Theme,
5
- } from '@earendil-works/pi-coding-agent';
6
- import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
7
- import { readFileSync, statSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
- import { dirname, join, resolve } from 'node:path';
10
- import type { GoalHeaderState } from './goal.ts';
11
- import { GOAL_HEADER_STATUSES } from './goal.ts';
12
-
13
- /**
14
- * Goal-block takeover for pi-goal-x: replaces its persistent aboveEditor
15
- * widget ("goal" key) with a compact deck-style block rendered from the
16
- * goal entries pi-goal-x already appends to the session branch.
17
- *
18
- * pi-goal-x registers its widget once per session (one-shot flag), so the
19
- * first same-key registration after theirs owns the slot for the session.
20
- * We re-assert briefly after session start and on turn starts until the
21
- * takeover sticks. pi-goal-x modals, tools, and storage are untouched.
22
- */
23
-
24
- const GOAL_WIDGET_KEY = 'goal';
25
- const GOAL_X_STATE_ENTRY = 'pi-goal-state';
26
- const GOAL_X_FOCUS_ENTRY = 'pi-goal-focus';
27
- const REASSERT_DELAYS_MS = [800, 2_000, 5_000, 10_000];
28
-
29
- export interface GoalBlockTaskCounts {
30
- pending: number;
31
- active: number;
32
- complete: number;
33
- total: number;
34
- }
35
-
36
- export interface GoalBlockState {
37
- id: string;
38
- objective: string;
39
- status: string;
40
- tokensUsed?: number;
41
- activeSeconds?: number;
42
- tokenBudget?: number;
43
- autoContinue?: boolean;
44
- tasks?: GoalBlockTaskCounts;
45
- }
46
-
47
- function isRecord(value: unknown): value is Record<string, unknown> {
48
- return typeof value === 'object' && value !== null && !Array.isArray(value);
49
- }
50
-
51
- function countTasks(node: unknown, counts: GoalBlockTaskCounts): void {
52
- if (!isRecord(node)) return;
53
- const status = node.status;
54
- if (status === 'pending' || status === 'active' || status === 'complete') {
55
- counts.total += 1;
56
- counts[status] += 1;
57
- }
58
- if (Array.isArray(node.subtasks)) {
59
- for (const child of node.subtasks) countTasks(child, counts);
60
- }
61
- }
62
-
63
- function parseTaskCounts(taskList: unknown): GoalBlockTaskCounts | undefined {
64
- if (!isRecord(taskList) || !Array.isArray(taskList.tasks)) return undefined;
65
- const counts: GoalBlockTaskCounts = { pending: 0, active: 0, complete: 0, total: 0 };
66
- for (const task of taskList.tasks) countTasks(task, counts);
67
- return counts.total > 0 ? counts : undefined;
68
- }
69
-
70
- function parseNumber(value: unknown): number | undefined {
71
- return typeof value === 'number' && Number.isFinite(value) && value >= 0
72
- ? value
73
- : undefined;
74
- }
75
-
76
- function parseGoalRecord(value: unknown): GoalBlockState | null {
77
- if (!isRecord(value)) return null;
78
- if (typeof value.id !== 'string' || value.id.trim() === '') return null;
79
- if (typeof value.objective !== 'string' || value.objective.trim() === '') return null;
80
- const usage = isRecord(value.usage) ? value.usage : {};
81
- const budget = parseNumber(usage.tokenBudget) ?? parseNumber(value.tokenBudget);
82
- return {
83
- id: value.id.slice(0, 128),
84
- objective: value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160),
85
- status: typeof value.status === 'string' ? value.status.slice(0, 24) : 'active',
86
- tokensUsed: parseNumber(usage.tokensUsed),
87
- activeSeconds: parseNumber(usage.activeSeconds),
88
- ...(budget !== undefined && budget > 0 ? { tokenBudget: budget } : {}),
89
- ...(value.autoContinue === true ? { autoContinue: true } : {}),
90
- ...(parseTaskCounts(value.taskList)
91
- ? { tasks: parseTaskCounts(value.taskList) }
92
- : {}),
93
- };
94
- }
95
-
96
- function readGoalBlockRecord(entries: readonly unknown[]): {
97
- focusedGoalId: string | null | undefined;
98
- record: unknown;
99
- recordSeen: boolean;
100
- } {
101
- let focusedGoalId: string | null | undefined;
102
- let record: unknown;
103
- let recordSeen = false;
104
- for (let index = entries.length - 1; index >= 0; index -= 1) {
105
- const entry = entries[index];
106
- if (!isRecord(entry) || entry.type !== 'custom') continue;
107
- if (
108
- focusedGoalId === undefined &&
109
- entry.customType === GOAL_X_FOCUS_ENTRY &&
110
- isRecord(entry.data)
111
- ) {
112
- focusedGoalId =
113
- typeof entry.data.focusedGoalId === 'string' ? entry.data.focusedGoalId : null;
114
- }
115
- if (
116
- !recordSeen &&
117
- entry.customType === GOAL_X_STATE_ENTRY &&
118
- isRecord(entry.data)
119
- ) {
120
- record = entry.data.goal;
121
- recordSeen = true;
122
- }
123
- if (focusedGoalId !== undefined && recordSeen) break;
124
- }
125
- return { focusedGoalId, record, recordSeen };
126
- }
127
-
128
- function isDirectorySafe(path: string): boolean {
129
- try {
130
- return statSync(path).isDirectory();
131
- } catch {
132
- return false;
133
- }
134
- }
135
-
136
- /** Mirror pi-goal-x storage resolution: env root, project root, then the
137
- * agent-level fallback. Broken symlinks are skipped, never followed. */
138
- export function resolveGoalRoot(ctx: ExtensionContext): string | undefined {
139
- const candidates: string[] = [];
140
- if (process.env.PI_GOAL_ROOT) candidates.push(resolve(process.env.PI_GOAL_ROOT));
141
- if (typeof ctx.cwd === 'string') {
142
- candidates.push(resolve(ctx.cwd, '.pi/goals'));
143
- }
144
- candidates.push(join(homedir(), '.pi', 'goals'));
145
- return candidates.find((candidate) => isDirectorySafe(candidate));
146
- }
147
-
148
- const poolCache = new Map<string, { mtimeMs: number; goals: unknown[] }>();
149
-
150
- function snapshotPaths(root: string): string[] {
151
- return [
152
- join(root, '.metadata', '.goals-pool-snapshot.json'),
153
- join(dirname(root), '.goals-pool-snapshot.json'),
154
- ];
155
- }
156
-
157
- export function loadPoolGoals(root: string): unknown[] {
158
- for (const snapshotPath of snapshotPaths(root)) {
159
- try {
160
- const stats = statSync(snapshotPath);
161
- if (!stats.isFile()) continue;
162
- const cached = poolCache.get(snapshotPath);
163
- if (cached && cached.mtimeMs === stats.mtimeMs) return cached.goals;
164
- const parsed: unknown = JSON.parse(readFileSync(snapshotPath, 'utf8'));
165
- if (!isRecord(parsed) || !Array.isArray(parsed.goals)) continue;
166
- const goals = parsed.goals.filter(isRecord);
167
- poolCache.set(snapshotPath, { mtimeMs: stats.mtimeMs, goals });
168
- return goals;
169
- } catch {
170
- continue;
171
- }
172
- }
173
- return [];
174
- }
175
-
176
- export function readGoalBlockState(ctx: ExtensionContext): GoalBlockState | null {
177
- const { focusedGoalId, record, recordSeen } = readGoalBlockRecord(
178
- ctx.sessionManager.getBranch(),
179
- );
180
- if (focusedGoalId === null) return null;
181
- let candidate: unknown = recordSeen ? record : undefined;
182
- const root = resolveGoalRoot(ctx);
183
- if (root) {
184
- const goals = loadPoolGoals(root);
185
- const fromPool = goals.find((goal) => isRecord(goal) && goal.id === focusedGoalId);
186
- if (fromPool !== undefined) candidate = fromPool;
187
- }
188
- const state = parseGoalRecord(candidate);
189
- if (!state) return null;
190
- if (
191
- focusedGoalId !== undefined &&
192
- focusedGoalId !== null &&
193
- focusedGoalId !== state.id
194
- ) {
195
- return null;
196
- }
197
- return state;
198
- }
199
-
200
- export function readGoalHeadline(ctx: ExtensionContext): GoalHeaderState | null {
201
- const state = readGoalBlockState(ctx);
202
- if (!state) return null;
203
- return {
204
- id: state.id,
205
- status: (GOAL_HEADER_STATUSES as readonly string[]).includes(state.status)
206
- ? (state.status as GoalHeaderState['status'])
207
- : 'active',
208
- automaticModelTurns: 0,
209
- waiting: state.status === 'active',
210
- ...(state.objective !== undefined ? { objective: state.objective } : {}),
211
- ...(state.activeSeconds !== undefined
212
- ? { elapsedSeconds: state.activeSeconds }
213
- : {}),
214
- ...(state.tasks
215
- ? {
216
- tasksDone: state.tasks.complete,
217
- tasksActive: state.tasks.active,
218
- tasksTotal: state.tasks.total,
219
- }
220
- : {}),
221
- ...(state.tokensUsed !== undefined ? { tokensUsed: state.tokensUsed } : {}),
222
- ...(state.tokenBudget !== undefined ? { tokenBudget: state.tokenBudget } : {}),
223
- };
224
- }
225
-
226
- export function countOpenGoals(ctx: ExtensionContext): number {
227
- const root = resolveGoalRoot(ctx);
228
- if (!root) return 0;
229
- return loadPoolGoals(root).filter(
230
- (goal) => isRecord(goal) && goal.status !== 'complete',
231
- ).length;
232
- }
233
-
234
- export function formatGoalElapsed(seconds: number): string {
235
- if (seconds < 60) return `${Math.floor(seconds)}s`;
236
- const totalMinutes = Math.floor(seconds / 60);
237
- if (totalMinutes < 60) {
238
- return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
239
- }
240
- return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
241
- }
242
-
243
- function formatTokens(tokens: number): string {
244
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
245
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
246
- return `${Math.floor(tokens)}`;
247
- }
248
-
249
- const STATUS_COLORS: Record<
250
- string,
251
- 'success' | 'accent' | 'warning' | 'error' | 'dim'
252
- > = {
253
- active: 'accent',
254
- complete: 'success',
255
- paused: 'warning',
256
- blocked: 'error',
257
- usage_limited: 'warning',
258
- budget_limited: 'warning',
259
- queued: 'dim',
260
- };
261
-
262
- export function renderGoalBlock(
263
- state: GoalBlockState | null,
264
- width: number,
265
- theme: Theme,
266
- openGoals = 0,
267
- ): string[] {
268
- if (width < 40) return [];
269
- const fg = (semantic: string, text: string): string =>
270
- theme.fg(semantic as never, text);
271
-
272
- if (!state) {
273
- if (openGoals > 0 && width >= 48) {
274
- return [
275
- fg(
276
- 'dim',
277
- `◆ ${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`,
278
- ),
279
- ];
280
- }
281
- return [];
282
- }
283
-
284
- const badge = fg(STATUS_COLORS[state.status] ?? 'text', `● ${state.status}`);
285
- const elapsed =
286
- state.activeSeconds !== undefined
287
- ? ` · ${formatGoalElapsed(state.activeSeconds)}`
288
- : '';
289
- const headline = `◆ ${state.objective} ${badge}${elapsed}`;
290
- const rows: string[] = [truncateToWidth(headline, width, '…')];
291
-
292
- if (state.tasks && width >= 48) {
293
- const { complete, total, active } = state.tasks;
294
- rows.push(
295
- truncateToWidth(
296
- ` ├ tasks ${complete}/${total} done` +
297
- (active > 0 ? ` · ${active} active` : ''),
298
- width,
299
- '…',
300
- ),
301
- );
302
- }
303
-
304
- if (
305
- width >= 60 &&
306
- (state.tokensUsed !== undefined || state.tokenBudget !== undefined)
307
- ) {
308
- const parts: string[] = [];
309
- if (state.tokensUsed !== undefined)
310
- parts.push(`${formatTokens(state.tokensUsed)} tokens`);
311
- if (state.tokenBudget !== undefined && state.tokensUsed !== undefined) {
312
- parts.push(
313
- `budget ${Math.min(999, Math.round((state.tokensUsed / state.tokenBudget) * 100))}%`,
314
- );
315
- }
316
- if (parts.length > 0) {
317
- rows.push(truncateToWidth(` └ usage ${parts.join(' · ')}`, width, '…'));
318
- }
319
- }
320
-
321
- return rows.map((line) => fg('dim', line));
322
- }
323
-
324
- export function installGoalBlockTakeover(pi: ExtensionAPI): () => void {
325
- let activeCtx: ExtensionContext | undefined;
326
- let stopped = false;
327
- const timers: ReturnType<typeof setTimeout>[] = [];
328
-
329
- const register = (ctx: ExtensionContext): void => {
330
- if (stopped || ctx.mode !== 'tui' || !ctx.hasUI) return;
331
- if (typeof ctx.ui.setWidget !== 'function') return;
332
- ctx.ui.setWidget(GOAL_WIDGET_KEY, (tui, _theme: Theme) => {
333
- const requestRender = (): void => tui.requestRender();
334
- return {
335
- render(width: number): string[] {
336
- // Pure suppression: the deck owns every goal display, including
337
- // the unfocused open-goal hint in its title row.
338
- const lines: string[] = [];
339
- return lines.every((line) => visibleWidth(line) <= width)
340
- ? lines
341
- : lines.map((line) => truncateToWidth(line, width, '…'));
342
- },
343
- invalidate() {
344
- requestRender();
345
- },
346
- };
347
- });
348
- };
349
-
350
- const scheduleReasserts = (ctx: ExtensionContext): void => {
351
- for (const delay of REASSERT_DELAYS_MS) {
352
- const timer = setTimeout(() => {
353
- if (!stopped && activeCtx === ctx) register(ctx);
354
- }, delay);
355
- timer.unref?.();
356
- timers.push(timer);
357
- }
358
- };
359
-
360
- pi.on('session_start', (_event, ctx) => {
361
- if (!ctx) return;
362
- activeCtx = ctx;
363
- register(ctx);
364
- scheduleReasserts(ctx);
365
- });
366
-
367
- pi.on('turn_start', () => {
368
- if (!stopped && activeCtx) register(activeCtx);
369
- });
370
-
371
- return () => {
372
- stopped = true;
373
- for (const timer of timers) clearTimeout(timer);
374
- activeCtx?.ui.setWidget(GOAL_WIDGET_KEY, undefined);
375
- activeCtx = undefined;
376
- };
377
- }
@@ -1,170 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
-
5
- export const GOAL_STATE_ENTRY_TYPE = 'goal-state';
6
-
7
- export const GOAL_HEADER_STATUSES = [
8
- 'active',
9
- 'queued',
10
- 'paused',
11
- 'blocked',
12
- 'usage_limited',
13
- 'budget_limited',
14
- 'complete',
15
- ] as const;
16
-
17
- export type GoalHeaderStatus = (typeof GOAL_HEADER_STATUSES)[number];
18
-
19
- export interface GoalHeaderState {
20
- id: string;
21
- status: GoalHeaderStatus;
22
- automaticModelTurns: number;
23
- waiting: boolean;
24
- objective?: string;
25
- elapsedSeconds?: number;
26
- tasksDone?: number;
27
- tasksActive?: number;
28
- tasksTotal?: number;
29
- tokensUsed?: number;
30
- tokenBudget?: number;
31
- }
32
-
33
- const STATUS_SET = new Set<string>(GOAL_HEADER_STATUSES);
34
- const MAX_GOAL_ID_POINTS = 128;
35
- const MAX_HEADER_PROGRESS = 9_999;
36
- const GOAL_PROMPT_MARKER =
37
- /<!-- pi-goal-(?:prompt|continuation):[A-Za-z0-9:._-]{1,300} -->/u;
38
-
39
- function isRecord(value: unknown): value is Record<string, unknown> {
40
- return typeof value === 'object' && value !== null && !Array.isArray(value);
41
- }
42
-
43
- function parseGoalHeaderState(value: unknown): GoalHeaderState | null {
44
- if (value === null) return null;
45
- if (!isRecord(value)) return null;
46
- if (typeof value.id !== 'string' || !value.id || value.id !== value.id.trim()) {
47
- return null;
48
- }
49
- if (Array.from(value.id).length > MAX_GOAL_ID_POINTS) return null;
50
- if (typeof value.status !== 'string' || !STATUS_SET.has(value.status)) return null;
51
- const automaticModelTurns =
52
- typeof value.automaticModelTurns === 'number' &&
53
- Number.isSafeInteger(value.automaticModelTurns) &&
54
- value.automaticModelTurns >= 0
55
- ? Math.min(value.automaticModelTurns, MAX_HEADER_PROGRESS)
56
- : 0;
57
- const boundedCount = (input: unknown): number | undefined =>
58
- typeof input === 'number' &&
59
- Number.isSafeInteger(input) &&
60
- input >= 0 &&
61
- input <= 9_999
62
- ? input
63
- : undefined;
64
- const objective =
65
- typeof value.objective === 'string' && value.objective.trim() !== ''
66
- ? value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160)
67
- : undefined;
68
- const elapsedSeconds =
69
- typeof value.elapsedSeconds === 'number' &&
70
- Number.isFinite(value.elapsedSeconds) &&
71
- value.elapsedSeconds >= 0
72
- ? Math.min(value.elapsedSeconds, 3_600_000)
73
- : undefined;
74
- const tokensUsed =
75
- typeof value.tokensUsed === 'number' &&
76
- Number.isFinite(value.tokensUsed) &&
77
- value.tokensUsed >= 0
78
- ? Math.min(value.tokensUsed, 9_999_999_999)
79
- : undefined;
80
- return {
81
- id: value.id,
82
- status: value.status as GoalHeaderStatus,
83
- automaticModelTurns,
84
- waiting: value.status === 'active' && isRecord(value.waiting),
85
- ...(objective !== undefined ? { objective } : {}),
86
- ...(elapsedSeconds !== undefined ? { elapsedSeconds } : {}),
87
- ...(boundedCount(value.tasksDone) !== undefined
88
- ? { tasksDone: boundedCount(value.tasksDone) }
89
- : {}),
90
- ...(boundedCount(value.tasksActive) !== undefined
91
- ? { tasksActive: boundedCount(value.tasksActive) }
92
- : {}),
93
- ...(boundedCount(value.tasksTotal) !== undefined
94
- ? { tasksTotal: boundedCount(value.tasksTotal) }
95
- : {}),
96
- ...(tokensUsed !== undefined ? { tokensUsed } : {}),
97
- ...(boundedCount(value.tokenBudget) !== undefined &&
98
- (boundedCount(value.tokenBudget) ?? 0) > 0
99
- ? { tokenBudget: boundedCount(value.tokenBudget) }
100
- : {}),
101
- };
102
- }
103
-
104
- export function restoreGoalHeaderState(
105
- entries: readonly unknown[],
106
- ): GoalHeaderState | null {
107
- for (let index = entries.length - 1; index >= 0; index -= 1) {
108
- const entry = entries[index];
109
- if (
110
- !isRecord(entry) ||
111
- entry.type !== 'custom' ||
112
- entry.customType !== GOAL_STATE_ENTRY_TYPE
113
- ) {
114
- continue;
115
- }
116
- const data = isRecord(entry.data) ? entry.data : undefined;
117
- return parseGoalHeaderState(data?.goal);
118
- }
119
- return null;
120
- }
121
-
122
- export function isGoalOwnedPrompt(value: unknown): boolean {
123
- return typeof value === 'string' && GOAL_PROMPT_MARKER.test(value);
124
- }
125
-
126
- export function sameGoalHeaderState(
127
- left: GoalHeaderState | null,
128
- right: GoalHeaderState | null,
129
- ): boolean {
130
- return (
131
- left === right ||
132
- (left !== null &&
133
- right !== null &&
134
- left.id === right.id &&
135
- left.status === right.status &&
136
- left.automaticModelTurns === right.automaticModelTurns &&
137
- left.waiting === right.waiting &&
138
- left.objective === right.objective &&
139
- left.tasksDone === right.tasksDone &&
140
- left.tasksActive === right.tasksActive &&
141
- left.tasksTotal === right.tasksTotal &&
142
- left.tokensUsed === right.tokensUsed &&
143
- left.tokenBudget === right.tokenBudget &&
144
- Math.floor((left.elapsedSeconds ?? 0) / 10) ===
145
- Math.floor((right.elapsedSeconds ?? 0) / 10))
146
- );
147
- }
148
-
149
- export function parseGoalAutomaticTurnLimit(value: unknown): number | undefined {
150
- if (!isRecord(value)) return undefined;
151
- const limits = isRecord(value.continuationLimits)
152
- ? value.continuationLimits
153
- : undefined;
154
- const limit = limits?.automaticTurns;
155
- return typeof limit === 'number' &&
156
- Number.isSafeInteger(limit) &&
157
- limit > 0 &&
158
- limit <= MAX_HEADER_PROGRESS
159
- ? limit
160
- : undefined;
161
- }
162
-
163
- export async function loadGoalAutomaticTurnLimit(): Promise<number | undefined> {
164
- try {
165
- const raw = await readFile(join(getAgentDir(), 'pi-goal.json'), 'utf8');
166
- return parseGoalAutomaticTurnLimit(JSON.parse(raw) as unknown);
167
- } catch {
168
- return undefined;
169
- }
170
- }