@aemonge-dev/pi-stardeck 0.0.0-stage → 0.6.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -2
  3. package/demo/pi-stardeck.tape +59 -0
  4. package/docs/architecture.md +106 -0
  5. package/docs/assets/pi-stardeck.gif +0 -0
  6. package/docs/development.md +128 -0
  7. package/licenses/README.md +19 -0
  8. package/licenses/pi-vim-LICENSE +21 -0
  9. package/package.json +81 -4
  10. package/packages/footer/README.md +15 -0
  11. package/packages/footer/index.ts +37 -0
  12. package/packages/footer/package.json +32 -0
  13. package/packages/footer/src/protocol.ts +10 -0
  14. package/packages/footer/src/quota.ts +26 -0
  15. package/packages/footer/src/telemetry.ts +59 -0
  16. package/packages/pi-stardeck/README.md +220 -0
  17. package/packages/pi-stardeck/index.ts +691 -0
  18. package/packages/pi-stardeck/package.json +32 -0
  19. package/packages/pi-stardeck/src/activity-age.ts +184 -0
  20. package/packages/pi-stardeck/src/capabilities.ts +105 -0
  21. package/packages/pi-stardeck/src/deck.ts +807 -0
  22. package/packages/pi-stardeck/src/gauge.ts +1323 -0
  23. package/packages/pi-stardeck/src/git-refresh.ts +5 -0
  24. package/packages/pi-stardeck/src/process-resources.ts +428 -0
  25. package/packages/pi-stardeck/src/title-state.ts +195 -0
  26. package/packages/pi-stardeck/src/title.ts +443 -0
  27. package/packages/pi-vim-top-border/LICENSE +21 -0
  28. package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
  29. package/packages/pi-vim-top-border/README.md +577 -0
  30. package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
  31. package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
  32. package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
  33. package/packages/pi-vim-top-border/index.ts +4195 -0
  34. package/packages/pi-vim-top-border/input-keys.ts +49 -0
  35. package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
  36. package/packages/pi-vim-top-border/mode-colors.ts +97 -0
  37. package/packages/pi-vim-top-border/mode-label.ts +62 -0
  38. package/packages/pi-vim-top-border/motions.ts +297 -0
  39. package/packages/pi-vim-top-border/package.json +47 -0
  40. package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
  41. package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
  42. package/packages/pi-vim-top-border/prompt-status.ts +131 -0
  43. package/packages/pi-vim-top-border/settings.ts +287 -0
  44. package/packages/pi-vim-top-border/text-objects.ts +389 -0
  45. package/packages/pi-vim-top-border/types.ts +51 -0
  46. package/packages/pi-vim-top-border/visual.ts +65 -0
  47. package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
  48. package/packages/status/README.md +398 -0
  49. package/packages/status/index.ts +1717 -0
  50. package/packages/status/package.json +33 -0
  51. package/packages/status/src/active-work.ts +235 -0
  52. package/packages/status/src/config.ts +218 -0
  53. package/packages/status/src/diagnostics.ts +238 -0
  54. package/packages/status/src/goal-block.ts +377 -0
  55. package/packages/status/src/goal.ts +170 -0
  56. package/packages/status/src/ledger.ts +101 -0
  57. package/packages/status/src/openspec.ts +394 -0
  58. package/packages/status/src/orchestration.ts +237 -0
  59. package/packages/status/src/publisher.ts +1294 -0
  60. package/packages/status/src/reducer.ts +190 -0
  61. package/packages/status/src/runtime-runs.ts +267 -0
  62. package/packages/status/src/state.ts +178 -0
  63. package/packages/status/src/title-activity.ts +224 -0
  64. package/packages/status/src/title-state.ts +218 -0
  65. package/packages/status/src/turn-cost.ts +63 -0
  66. package/packages/status/src/types.ts +228 -0
  67. package/src/deck-surface.ts +46 -0
  68. package/src/health.ts +227 -0
  69. package/src/index.ts +27 -0
  70. package/src/pi-status.ts +334 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "status",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Project status producer for the pi-stardeck deck",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "status",
11
+ "footer"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.19.0"
15
+ },
16
+ "scripts": {
17
+ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\" --ignore-path .prettierignore",
18
+ "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\" --ignore-path .prettierignore",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "node --test --experimental-strip-types test/*.test.ts",
21
+ "check": "npm run format:check && npm run typecheck && npm test"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-coding-agent": "*",
31
+ "typebox": "*"
32
+ }
33
+ }
@@ -0,0 +1,235 @@
1
+ import { basename } from 'node:path';
2
+ import type { GoalHeaderState } from './goal.ts';
3
+
4
+ export const OPEN_SPEC_FOCUS_ENTRY_TYPE = 'galactica-status.openspec-focus.v1';
5
+ export const SESSION_WORK_FOCUS_ENTRY_TYPE = 'galactica-status.session-work.v1';
6
+ export const SESSION_SUBJECT_ENTRY_TYPE = 'galactica-status.session-subject.v1';
7
+
8
+ export type TaskOpenSpecFocus = {
9
+ mode: 'task';
10
+ change: string;
11
+ taskId: string;
12
+ };
13
+
14
+ export type OpenSpecFocus = { mode: 'none' } | TaskOpenSpecFocus;
15
+
16
+ export const NO_OPEN_SPEC_FOCUS: OpenSpecFocus = { mode: 'none' };
17
+
18
+ export type SessionWorkFocus =
19
+ { state: 'clear' } | { state: 'active' | 'validation'; intent: string };
20
+
21
+ export const NO_SESSION_WORK_FOCUS: SessionWorkFocus = { state: 'clear' };
22
+
23
+ export type SessionSubject = { state: 'clear' } | { state: 'set'; title: string };
24
+
25
+ export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
26
+
27
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
28
+ const MAX_TITLE_POINTS = 180;
29
+ const MAX_TASK_TITLE_POINTS = 72;
30
+ const MAX_WORK_INTENT_POINTS = 160;
31
+ const MAX_SUBJECT_POINTS = 120;
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ function shorten(value: string, maximum: number): string {
38
+ const points = Array.from(value);
39
+ if (points.length <= maximum) return value;
40
+ return `${points.slice(0, Math.max(1, maximum - 1)).join('')}…`;
41
+ }
42
+
43
+ export function normalizeTitlePart(value: string): string {
44
+ return value.replace(CONTROL_CHARACTERS, ' ').replace(/\s+/gu, ' ').trim();
45
+ }
46
+
47
+ function explicitId(value: unknown): string | undefined {
48
+ if (typeof value !== 'string') return undefined;
49
+ const normalized = normalizeTitlePart(value);
50
+ return normalized || undefined;
51
+ }
52
+
53
+ export function taskFocus(
54
+ change: unknown,
55
+ taskId: unknown,
56
+ ): TaskOpenSpecFocus | undefined {
57
+ const normalizedChange = explicitId(change);
58
+ const normalizedTaskId = explicitId(taskId);
59
+ if (!normalizedChange || !normalizedTaskId) return undefined;
60
+ return { mode: 'task', change: normalizedChange, taskId: normalizedTaskId };
61
+ }
62
+
63
+ export function parseOpenSpecFocus(value: unknown): OpenSpecFocus | undefined {
64
+ if (!isRecord(value)) return undefined;
65
+ if (value.mode === 'none') return NO_OPEN_SPEC_FOCUS;
66
+ if (value.mode !== 'task') return undefined;
67
+ return taskFocus(value.change, value.taskId);
68
+ }
69
+
70
+ export function sessionWorkFocus(
71
+ state: unknown,
72
+ intent?: unknown,
73
+ ): SessionWorkFocus | undefined {
74
+ if (state === 'clear') return NO_SESSION_WORK_FOCUS;
75
+ if (state !== 'active' && state !== 'validation') return undefined;
76
+ const normalizedIntent = explicitId(intent);
77
+ if (!normalizedIntent) return undefined;
78
+ return {
79
+ state,
80
+ intent: shorten(normalizedIntent, MAX_WORK_INTENT_POINTS),
81
+ };
82
+ }
83
+
84
+ export function parseSessionWorkFocus(value: unknown): SessionWorkFocus | undefined {
85
+ if (!isRecord(value)) return undefined;
86
+ return sessionWorkFocus(value.state, value.intent);
87
+ }
88
+
89
+ export function sessionSubject(
90
+ state: unknown,
91
+ title?: unknown,
92
+ ): SessionSubject | undefined {
93
+ if (state === 'clear') return NO_SESSION_SUBJECT;
94
+ if (state !== 'set') return undefined;
95
+ const normalizedTitle = explicitId(title);
96
+ if (!normalizedTitle) return undefined;
97
+ return {
98
+ state,
99
+ title: shorten(normalizedTitle, MAX_SUBJECT_POINTS),
100
+ };
101
+ }
102
+
103
+ export function parseSessionSubject(value: unknown): SessionSubject | undefined {
104
+ if (!isRecord(value)) return undefined;
105
+ return sessionSubject(value.state, value.title);
106
+ }
107
+
108
+ export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus {
109
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
110
+ const entry = entries[index];
111
+ if (
112
+ !isRecord(entry) ||
113
+ entry.type !== 'custom' ||
114
+ entry.customType !== OPEN_SPEC_FOCUS_ENTRY_TYPE
115
+ ) {
116
+ continue;
117
+ }
118
+ const focus = parseOpenSpecFocus(entry.data);
119
+ if (focus) return focus;
120
+ }
121
+ return NO_OPEN_SPEC_FOCUS;
122
+ }
123
+
124
+ export function restoreSessionWorkFocus(entries: readonly unknown[]): SessionWorkFocus {
125
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
126
+ const entry = entries[index];
127
+ if (
128
+ !isRecord(entry) ||
129
+ entry.type !== 'custom' ||
130
+ entry.customType !== SESSION_WORK_FOCUS_ENTRY_TYPE
131
+ ) {
132
+ continue;
133
+ }
134
+ const focus = parseSessionWorkFocus(entry.data);
135
+ if (focus) return focus;
136
+ }
137
+ return NO_SESSION_WORK_FOCUS;
138
+ }
139
+
140
+ export function restoreSessionSubject(entries: readonly unknown[]): SessionSubject {
141
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
142
+ const entry = entries[index];
143
+ if (
144
+ !isRecord(entry) ||
145
+ entry.type !== 'custom' ||
146
+ entry.customType !== SESSION_SUBJECT_ENTRY_TYPE
147
+ ) {
148
+ continue;
149
+ }
150
+ const subject = parseSessionSubject(entry.data);
151
+ if (subject) return subject;
152
+ }
153
+ return NO_SESSION_SUBJECT;
154
+ }
155
+
156
+ export function taskflowRunFocus(input: unknown): TaskOpenSpecFocus | undefined {
157
+ if (!isRecord(input) || input.action !== 'run' || !isRecord(input.args)) {
158
+ return undefined;
159
+ }
160
+ const focus = taskFocus(input.args.change, input.args.task);
161
+ return focus && focus.change !== 'none' && focus.taskId !== 'none'
162
+ ? focus
163
+ : undefined;
164
+ }
165
+
166
+ export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
167
+ if (left.mode !== right.mode) return false;
168
+ return (
169
+ left.mode === 'none' ||
170
+ (right.mode === 'task' &&
171
+ left.change === right.change &&
172
+ left.taskId === right.taskId)
173
+ );
174
+ }
175
+
176
+ export function sameSessionWorkFocus(
177
+ left: SessionWorkFocus,
178
+ right: SessionWorkFocus,
179
+ ): boolean {
180
+ if (left.state !== right.state) return false;
181
+ return (
182
+ left.state === 'clear' || (right.state !== 'clear' && left.intent === right.intent)
183
+ );
184
+ }
185
+
186
+ export function sameSessionSubject(
187
+ left: SessionSubject,
188
+ right: SessionSubject,
189
+ ): boolean {
190
+ if (left.state !== right.state) return false;
191
+ return (
192
+ left.state === 'clear' || (right.state === 'set' && left.title === right.title)
193
+ );
194
+ }
195
+
196
+ export function formatActiveWorkTitle(options: {
197
+ focus: OpenSpecFocus;
198
+ workFocus?: SessionWorkFocus;
199
+ goal?: GoalHeaderState | null;
200
+ subject?: SessionSubject;
201
+ taskTitle?: string;
202
+ sessionName?: string;
203
+ projectRoot?: string;
204
+ cwd: string;
205
+ }): string {
206
+ let detail: string;
207
+ if (options.focus.mode === 'task') {
208
+ const taskTitle = options.taskTitle
209
+ ? shorten(normalizeTitlePart(options.taskTitle), MAX_TASK_TITLE_POINTS)
210
+ : '';
211
+ detail = `${options.focus.change} › ${options.focus.taskId}${taskTitle ? ` ${taskTitle}` : ''}`;
212
+ } else if (options.goal) {
213
+ detail =
214
+ options.goal.status === 'complete'
215
+ ? 'Validate › Goal'
216
+ : options.goal.status === 'active' && !options.goal.waiting
217
+ ? 'Goal'
218
+ : `Goal › ${options.goal.waiting ? 'waiting' : options.goal.status.replace('_', ' ')}`;
219
+ } else if (options.workFocus && options.workFocus.state !== 'clear') {
220
+ detail =
221
+ options.workFocus.state === 'validation'
222
+ ? `Validate › ${options.workFocus.intent}`
223
+ : options.workFocus.intent;
224
+ } else if (options.subject && options.subject.state === 'set') {
225
+ detail = options.subject.title;
226
+ } else {
227
+ detail =
228
+ normalizeTitlePart(options.sessionName ?? '') ||
229
+ normalizeTitlePart(basename(options.projectRoot ?? '')) ||
230
+ normalizeTitlePart(basename(options.cwd)) ||
231
+ 'pi';
232
+ }
233
+
234
+ return shorten(`π  ${normalizeTitlePart(detail)}`, MAX_TITLE_POINTS);
235
+ }
@@ -0,0 +1,218 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { CommandSpec, GalacticaStatusConfig, LoadedConfig } from './types.ts';
5
+
6
+ const MIN_INTERVAL_SECONDS = 60;
7
+ const MAX_INTERVAL_SECONDS = 86_400;
8
+ const MIN_TIMEOUT_SECONDS = 1;
9
+ const MAX_TIMEOUT_SECONDS = 120;
10
+
11
+ export const DEFAULT_CONFIG: GalacticaStatusConfig = {
12
+ version: 1,
13
+ enabled: true,
14
+ openspec: {
15
+ enabled: true,
16
+ change: null,
17
+ },
18
+ diagnostics: {
19
+ enabled: true,
20
+ sourceFile: null,
21
+ command: null,
22
+ intervalSeconds: 300,
23
+ runAutomatically: false,
24
+ timeoutSeconds: 15,
25
+ },
26
+ orchestration: {
27
+ enabled: true,
28
+ stateFile: null,
29
+ },
30
+ };
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function booleanValue(value: unknown, fallback: boolean): boolean {
37
+ return typeof value === 'boolean' ? value : fallback;
38
+ }
39
+
40
+ function nullableString(value: unknown, fallback: string | null): string | null {
41
+ if (value === null) return null;
42
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
43
+ }
44
+
45
+ function boundedInteger(
46
+ value: unknown,
47
+ fallback: number,
48
+ minimum: number,
49
+ maximum: number,
50
+ ): number {
51
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
52
+ return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
53
+ }
54
+
55
+ function parseCommand(value: unknown, warnings: string[]): CommandSpec | null {
56
+ if (value === null || value === undefined) return null;
57
+
58
+ if (Array.isArray(value)) {
59
+ if (
60
+ value.length > 0 &&
61
+ value.every((part) => typeof part === 'string' && part.length > 0)
62
+ ) {
63
+ return {
64
+ executable: value[0] as string,
65
+ args: value.slice(1) as string[],
66
+ };
67
+ }
68
+ warnings.push('diagnostics.command array was ignored because it is invalid');
69
+ return null;
70
+ }
71
+
72
+ if (isRecord(value)) {
73
+ const executable = value.executable;
74
+ const args = value.args;
75
+ if (
76
+ typeof executable === 'string' &&
77
+ executable.length > 0 &&
78
+ (args === undefined ||
79
+ (Array.isArray(args) && args.every((part) => typeof part === 'string')))
80
+ ) {
81
+ return { executable, args: (args ?? []) as string[] };
82
+ }
83
+ }
84
+
85
+ warnings.push(
86
+ 'diagnostics.command was ignored; use null, an argv array, or { executable, args }',
87
+ );
88
+ return null;
89
+ }
90
+
91
+ function unknownKeys(
92
+ value: Record<string, unknown>,
93
+ allowed: readonly string[],
94
+ path: string,
95
+ warnings: string[],
96
+ ): void {
97
+ for (const key of Object.keys(value)) {
98
+ if (!allowed.includes(key)) warnings.push(`${path}.${key} was ignored`);
99
+ }
100
+ }
101
+
102
+ export function parseConfig(value: unknown): {
103
+ config: GalacticaStatusConfig;
104
+ warnings: string[];
105
+ } {
106
+ const warnings: string[] = [];
107
+ if (!isRecord(value)) {
108
+ return {
109
+ config: structuredClone(DEFAULT_CONFIG),
110
+ warnings: ['configuration root is not an object; defaults are active'],
111
+ };
112
+ }
113
+
114
+ unknownKeys(
115
+ value,
116
+ ['version', 'enabled', 'openspec', 'diagnostics', 'orchestration'],
117
+ 'config',
118
+ warnings,
119
+ );
120
+
121
+ if (value.version !== undefined && value.version !== 1) {
122
+ warnings.push('unsupported config version; version 1 defaults are active');
123
+ }
124
+
125
+ const openspec = isRecord(value.openspec) ? value.openspec : {};
126
+ const diagnostics = isRecord(value.diagnostics) ? value.diagnostics : {};
127
+ const orchestration = isRecord(value.orchestration) ? value.orchestration : {};
128
+
129
+ unknownKeys(openspec, ['enabled', 'change'], 'openspec', warnings);
130
+ unknownKeys(
131
+ diagnostics,
132
+ [
133
+ 'enabled',
134
+ 'sourceFile',
135
+ 'command',
136
+ 'intervalSeconds',
137
+ 'runAutomatically',
138
+ 'timeoutSeconds',
139
+ ],
140
+ 'diagnostics',
141
+ warnings,
142
+ );
143
+ unknownKeys(orchestration, ['enabled', 'stateFile'], 'orchestration', warnings);
144
+
145
+ return {
146
+ config: {
147
+ version: 1,
148
+ enabled: booleanValue(value.enabled, DEFAULT_CONFIG.enabled),
149
+ openspec: {
150
+ enabled: booleanValue(openspec.enabled, DEFAULT_CONFIG.openspec.enabled),
151
+ change: nullableString(openspec.change, DEFAULT_CONFIG.openspec.change),
152
+ },
153
+ diagnostics: {
154
+ enabled: booleanValue(diagnostics.enabled, DEFAULT_CONFIG.diagnostics.enabled),
155
+ sourceFile: nullableString(
156
+ diagnostics.sourceFile,
157
+ DEFAULT_CONFIG.diagnostics.sourceFile,
158
+ ),
159
+ command: parseCommand(diagnostics.command, warnings),
160
+ intervalSeconds: boundedInteger(
161
+ diagnostics.intervalSeconds,
162
+ DEFAULT_CONFIG.diagnostics.intervalSeconds,
163
+ MIN_INTERVAL_SECONDS,
164
+ MAX_INTERVAL_SECONDS,
165
+ ),
166
+ runAutomatically: booleanValue(
167
+ diagnostics.runAutomatically,
168
+ DEFAULT_CONFIG.diagnostics.runAutomatically,
169
+ ),
170
+ timeoutSeconds: boundedInteger(
171
+ diagnostics.timeoutSeconds,
172
+ DEFAULT_CONFIG.diagnostics.timeoutSeconds,
173
+ MIN_TIMEOUT_SECONDS,
174
+ MAX_TIMEOUT_SECONDS,
175
+ ),
176
+ },
177
+ orchestration: {
178
+ enabled: booleanValue(
179
+ orchestration.enabled,
180
+ DEFAULT_CONFIG.orchestration.enabled,
181
+ ),
182
+ stateFile: nullableString(
183
+ orchestration.stateFile,
184
+ DEFAULT_CONFIG.orchestration.stateFile,
185
+ ),
186
+ },
187
+ },
188
+ warnings,
189
+ };
190
+ }
191
+
192
+ export function getConfigPath(): string {
193
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
194
+ return join(agentDir, 'galactica-status.json');
195
+ }
196
+
197
+ export async function loadConfig(path = getConfigPath()): Promise<LoadedConfig> {
198
+ try {
199
+ const raw = await fs.readFile(path, 'utf8');
200
+ const parsed = parseConfig(JSON.parse(raw) as unknown);
201
+ return { ...parsed, path };
202
+ } catch (error) {
203
+ const code = isRecord(error) ? error.code : undefined;
204
+ if (code === 'ENOENT') {
205
+ return {
206
+ config: structuredClone(DEFAULT_CONFIG),
207
+ path,
208
+ warnings: [],
209
+ };
210
+ }
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ return {
213
+ config: structuredClone(DEFAULT_CONFIG),
214
+ path,
215
+ warnings: [`failed to load configuration: ${message}`],
216
+ };
217
+ }
218
+ }