@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,224 @@
1
+ import {
2
+ shortenTitleTarget,
3
+ type TitleActivityClass,
4
+ type TitleActivityState,
5
+ } from './title-state.ts';
6
+
7
+ /**
8
+ * Activity feed for the title state (`deck-title-state`).
9
+ *
10
+ * Classifies tool-execution starts/updates into the observed activity classes
11
+ * with a short target label, attributes `span`/`parent` from the same span
12
+ * ids the runtime span store uses (turn root, bash/subagent containers,
13
+ * per-agent children), keeps only the newest event, and resets to null at
14
+ * turn end. Direct tools are owned by the turn span; bash and subagent
15
+ * containers own their own span ids.
16
+ */
17
+
18
+ const EDITING_TOOLS = new Set(['edit', 'write']);
19
+ const READING_TOOLS = new Set(['read', 'ls']);
20
+ const GREPPING_TOOLS = new Set(['grep', 'find']);
21
+ const BASH_TOOLS = new Set(['bash', 'powershell']);
22
+ const SUBAGENT_TOOL = 'subagent';
23
+
24
+ const FALLBACK_SUBAGENT_TARGET = 'subagent';
25
+
26
+ function record(value: unknown): Record<string, unknown> | undefined {
27
+ return value && typeof value === 'object' && !Array.isArray(value)
28
+ ? (value as Record<string, unknown>)
29
+ : undefined;
30
+ }
31
+
32
+ function text(value: unknown): string | undefined {
33
+ return typeof value === 'string' && value.trim() ? value : undefined;
34
+ }
35
+
36
+ function shortTarget(value: unknown): string | undefined {
37
+ const raw = text(value);
38
+ if (!raw) return undefined;
39
+ const shortened = shortenTitleTarget(raw);
40
+ return shortened || undefined;
41
+ }
42
+
43
+ function firstAgentArg(args: unknown): string | undefined {
44
+ const input = record(args);
45
+ if (!input) return undefined;
46
+ const single = text(input.agent);
47
+ if (single) return single;
48
+ for (const key of ['calls', 'tasks', 'chain'] as const) {
49
+ const list = Array.isArray(input[key]) ? input[key] : undefined;
50
+ const agent = list ? text(record(list[0])?.agent) : undefined;
51
+ if (agent) return agent;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ export type TitleActivityClassification = {
57
+ class: TitleActivityClass;
58
+ target: string;
59
+ };
60
+
61
+ /**
62
+ * Classify one tool execution into a title activity class with a bounded
63
+ * target label. Returns undefined for tools outside the observed classes or
64
+ * when no bounded target label can be derived (except subagent, which falls
65
+ * back to a generic label until agent names stream in).
66
+ */
67
+ export function classifyTitleActivity(
68
+ toolName: string,
69
+ args: unknown,
70
+ ): TitleActivityClassification | undefined {
71
+ const tool = toolName.trim().toLowerCase();
72
+ const input = record(args);
73
+
74
+ if (EDITING_TOOLS.has(tool)) {
75
+ const target = shortTarget(input?.path);
76
+ return target ? { class: 'editing', target } : undefined;
77
+ }
78
+ if (READING_TOOLS.has(tool)) {
79
+ const target =
80
+ tool === 'ls' ? (shortTarget(input?.path) ?? '.') : shortTarget(input?.path);
81
+ return target ? { class: 'reading', target } : undefined;
82
+ }
83
+ if (GREPPING_TOOLS.has(tool)) {
84
+ const target = shortTarget(input?.pattern);
85
+ return target ? { class: 'grepping', target } : undefined;
86
+ }
87
+ if (BASH_TOOLS.has(tool)) {
88
+ const target = shortTarget(input?.command);
89
+ return target ? { class: 'bash', target } : undefined;
90
+ }
91
+ if (tool === SUBAGENT_TOOL) {
92
+ return {
93
+ class: 'subagent',
94
+ target: shortTarget(firstAgentArg(args)) ?? FALLBACK_SUBAGENT_TARGET,
95
+ };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ interface PartialAgentResult {
101
+ agent?: string;
102
+ active: boolean;
103
+ }
104
+
105
+ function partialAgentResults(partialResult: unknown): PartialAgentResult[] | undefined {
106
+ const details = record(record(partialResult)?.details);
107
+ if (details?.kind !== 'pi-subagent' || !Array.isArray(details.results)) {
108
+ return undefined;
109
+ }
110
+ return details.results.map((rawResult) => {
111
+ const result = record(rawResult);
112
+ return {
113
+ agent: text(result?.agent),
114
+ active:
115
+ result?.exitCode === -1 &&
116
+ result.sawAgentStart === true &&
117
+ result.sawAgentSettled !== true,
118
+ };
119
+ });
120
+ }
121
+
122
+ function callsAgentName(args: unknown, index: number): string | undefined {
123
+ const rawCalls = Array.isArray(args)
124
+ ? args
125
+ : Array.isArray(record(args)?.calls)
126
+ ? (record(args) as { calls: unknown[] }).calls
127
+ : undefined;
128
+ if (!rawCalls) return undefined;
129
+ return text(record(rawCalls[index])?.agent);
130
+ }
131
+
132
+ function sameActivity(
133
+ left: TitleActivityState | null,
134
+ right: TitleActivityState | null,
135
+ ): boolean {
136
+ return JSON.stringify(left) === JSON.stringify(right);
137
+ }
138
+
139
+ export class TitleActivityFeed {
140
+ private newest: TitleActivityState | null = null;
141
+
142
+ /** The newest observed event, or null when idle. */
143
+ current(): TitleActivityState | null {
144
+ return this.newest;
145
+ }
146
+
147
+ /** Clear the feed at turn end. Returns true when an event was dropped. */
148
+ reset(): boolean {
149
+ if (this.newest === null) return false;
150
+ this.newest = null;
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Observe a tool-execution start. Bash and subagent containers own their
156
+ * span id (the tool call id); direct tools are owned by the turn span, so
157
+ * they publish `parent: null` unless no turn is active, in which case the
158
+ * tool call id stands in.
159
+ */
160
+ begin(
161
+ toolCallId: string,
162
+ toolName: string,
163
+ args: unknown,
164
+ turnSpanId: string | null,
165
+ ): boolean {
166
+ const classification = classifyTitleActivity(toolName, args);
167
+ if (!classification) return false;
168
+ const owned =
169
+ classification.class === 'bash' || classification.class === 'subagent';
170
+ const event: TitleActivityState = owned
171
+ ? {
172
+ class: classification.class,
173
+ target: classification.target,
174
+ span: toolCallId,
175
+ parent: turnSpanId,
176
+ }
177
+ : {
178
+ class: classification.class,
179
+ target: classification.target,
180
+ span: turnSpanId ?? toolCallId,
181
+ parent: null,
182
+ };
183
+ return this.set(event);
184
+ }
185
+
186
+ /**
187
+ * Observe a tool-execution update. Subagent partial results publish the
188
+ * newest active per-agent child span (nested under its container id);
189
+ * other tools refresh their start event with the same attribution.
190
+ */
191
+ update(
192
+ toolCallId: string,
193
+ toolName: string,
194
+ args: unknown,
195
+ partialResult: unknown,
196
+ turnSpanId: string | null,
197
+ ): boolean {
198
+ const results = partialAgentResults(partialResult);
199
+ if (results) {
200
+ for (let index = results.length - 1; index >= 0; index -= 1) {
201
+ const result = results[index];
202
+ if (!result?.active) continue;
203
+ const target =
204
+ shortTarget(result.agent) ??
205
+ shortTarget(callsAgentName(args, index)) ??
206
+ FALLBACK_SUBAGENT_TARGET;
207
+ return this.set({
208
+ class: 'subagent',
209
+ target,
210
+ span: `${toolCallId}:${index}`,
211
+ parent: toolCallId,
212
+ });
213
+ }
214
+ return false;
215
+ }
216
+ return this.begin(toolCallId, toolName, args, turnSpanId);
217
+ }
218
+
219
+ private set(event: TitleActivityState): boolean {
220
+ if (sameActivity(this.newest, event)) return false;
221
+ this.newest = event;
222
+ return true;
223
+ }
224
+ }
@@ -0,0 +1,218 @@
1
+ import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
2
+ import type { OpenSpecState } from './types.ts';
3
+
4
+ /**
5
+ * Title-state contract v1 (`deck-title-state`).
6
+ *
7
+ * One authoritative object for the deck's upper half: `subject` is authored
8
+ * once, `work` is derived from OpenSpec focus, `activity` is observed from
9
+ * tool executions, and `waiver` suppresses one exact notice text. Rendering
10
+ * code may never write any slot; it only parses defensively.
11
+ */
12
+
13
+ export const TITLE_STATE_VERSION = 1 as const;
14
+
15
+ export const TITLE_ACTIVITY_CLASSES = [
16
+ 'editing',
17
+ 'reading',
18
+ 'grepping',
19
+ 'bash',
20
+ 'subagent',
21
+ ] as const;
22
+
23
+ export type TitleActivityClass = (typeof TITLE_ACTIVITY_CLASSES)[number];
24
+
25
+ /** Short target label bound from the accepted schema. */
26
+ export const TITLE_TARGET_MAX = 48;
27
+
28
+ /** Task/step totals bound from the accepted schema. */
29
+ export const TITLE_TOTAL_MAX = 999;
30
+
31
+ const SUBJECT_MAX = 180;
32
+ const IDENTIFIER_MAX = 120;
33
+ const TASK_ID_MAX = 32;
34
+ const SPAN_ID_MAX = 64;
35
+ const WAIVER_ID_MAX = 64;
36
+ const NOTICE_MAX = 180;
37
+
38
+ export type TitleWorkState = {
39
+ change: string;
40
+ task?: string;
41
+ done: number;
42
+ total: number;
43
+ };
44
+
45
+ export type TitleActivityState = {
46
+ class: TitleActivityClass;
47
+ target: string;
48
+ span: string;
49
+ parent: string | null;
50
+ };
51
+
52
+ export type TitleWaiverState = { id: string; notice: string };
53
+
54
+ export type TitleState = {
55
+ v: typeof TITLE_STATE_VERSION;
56
+ subject: string | null;
57
+ work: TitleWorkState | null;
58
+ activity: TitleActivityState | null;
59
+ waiver: TitleWaiverState | null;
60
+ };
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function boundedText(
67
+ value: unknown,
68
+ maximum: number,
69
+ { required = true }: { required?: boolean } = {},
70
+ ): string | null | undefined {
71
+ if (value === null || value === undefined) return required ? null : undefined;
72
+ if (typeof value !== 'string') return null;
73
+ const points = Array.from(value.replace(/\s+/gu, ' ').trim());
74
+ if (points.length === 0) return null;
75
+ if (points.length > maximum) return null;
76
+ return points.join('');
77
+ }
78
+
79
+ function boundedTotal(value: unknown): number | null {
80
+ return typeof value === 'number' &&
81
+ Number.isInteger(value) &&
82
+ value >= 0 &&
83
+ value <= TITLE_TOTAL_MAX
84
+ ? value
85
+ : null;
86
+ }
87
+
88
+ function parseTitleActivity(value: unknown): TitleActivityState | null | undefined {
89
+ if (value === null) return null;
90
+ if (!isRecord(value)) return undefined;
91
+ const activityClass = TITLE_ACTIVITY_CLASSES.includes(
92
+ value.class as TitleActivityClass,
93
+ )
94
+ ? (value.class as TitleActivityClass)
95
+ : undefined;
96
+ const target = boundedText(value.target, TITLE_TARGET_MAX);
97
+ const span = boundedText(value.span, SPAN_ID_MAX);
98
+ let parent: string | null | undefined;
99
+ if (value.parent === null) parent = null;
100
+ else if (typeof value.parent === 'string') {
101
+ const normalized = boundedText(value.parent, SPAN_ID_MAX);
102
+ parent = normalized === null ? undefined : normalized;
103
+ } else parent = undefined;
104
+ if (!activityClass || !target || !span || parent === undefined) return undefined;
105
+ return { class: activityClass, target, span, parent };
106
+ }
107
+
108
+ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
109
+ if (value === null) return null;
110
+ if (!isRecord(value)) return undefined;
111
+ const change = boundedText(value.change, IDENTIFIER_MAX);
112
+ const done = boundedTotal(value.done);
113
+ const total = boundedTotal(value.total);
114
+ if (!change || done === null || total === null) return undefined;
115
+ const task = boundedText(value.task, TASK_ID_MAX, { required: false });
116
+ if (task === null) return undefined;
117
+ return task === undefined ? { change, done, total } : { change, task, done, total };
118
+ }
119
+
120
+ function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
121
+ if (value === null) return null;
122
+ if (!isRecord(value)) return undefined;
123
+ const id = boundedText(value.id, WAIVER_ID_MAX);
124
+ const notice = boundedText(value.notice, NOTICE_MAX);
125
+ if (!id || !notice) return undefined;
126
+ return { id, notice };
127
+ }
128
+
129
+ /**
130
+ * Defensive parser for incoming title-state payloads. Unknown versions,
131
+ * malformed shapes, and out-of-bounds values (target > 48 characters,
132
+ * totals > 999) reject the whole object with `null`; it never throws.
133
+ */
134
+ export function parseTitleState(value: unknown): TitleState | null {
135
+ try {
136
+ if (!isRecord(value)) return null;
137
+ if (value.v !== TITLE_STATE_VERSION) return null;
138
+ const subject = boundedText(value.subject, SUBJECT_MAX, { required: false });
139
+ if (subject === null) return null;
140
+ const work = parseTitleWork(value.work);
141
+ if (work === undefined) return null;
142
+ const activity = parseTitleActivity(value.activity);
143
+ if (activity === undefined) return null;
144
+ const waiver = parseTitleWaiver(value.waiver);
145
+ if (waiver === undefined) return null;
146
+ return {
147
+ v: TITLE_STATE_VERSION,
148
+ subject: subject ?? null,
149
+ work,
150
+ activity,
151
+ waiver,
152
+ };
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /** Collapse whitespace and shorten a live target label to the schema bound. */
159
+ export function shortenTitleTarget(value: string): string {
160
+ const points = Array.from(value.replace(/\s+/gu, ' ').trim());
161
+ if (points.length <= TITLE_TARGET_MAX) return points.join('');
162
+ return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
163
+ }
164
+
165
+ function boundedCount(value: number | undefined, fallback = 0): number {
166
+ const candidate = value === undefined || !Number.isFinite(value) ? fallback : value;
167
+ return Math.max(0, Math.min(TITLE_TOTAL_MAX, Math.round(candidate)));
168
+ }
169
+
170
+ /**
171
+ * Derive the `work` slot from the OpenSpec focus and the focused snapshot.
172
+ * A task focus owns its change; without a focus a passively collected change
173
+ * still reports progress, just without a task id.
174
+ */
175
+ export function deriveTitleWork(
176
+ focus: OpenSpecFocus,
177
+ openspec: OpenSpecState | null | undefined,
178
+ ): TitleWorkState | null {
179
+ const counts = (state: OpenSpecState): { done: number; total: number } => ({
180
+ done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
181
+ total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
182
+ });
183
+ if (focus.mode === 'task') {
184
+ const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
185
+ return {
186
+ change: focus.change,
187
+ task: focus.taskId,
188
+ ...(state ? counts(state) : { done: 0, total: 0 }),
189
+ };
190
+ }
191
+ if (!openspec) return null;
192
+ return { change: openspec.changeId, ...counts(openspec) };
193
+ }
194
+
195
+ /**
196
+ * Build the v1 title state from publisher-owned facts: the authored session
197
+ * subject entry, the OpenSpec focus (plus its snapshot), and the newest
198
+ * observed activity event. The waiver slot stays null until the notice slice.
199
+ */
200
+ export function buildTitleState(options: {
201
+ subject: SessionSubject;
202
+ focus: OpenSpecFocus;
203
+ openspec?: OpenSpecState | null;
204
+ activity?: TitleActivityState | null;
205
+ }): TitleState {
206
+ return {
207
+ v: TITLE_STATE_VERSION,
208
+ subject: options.subject.state === 'set' ? options.subject.title : null,
209
+ work: deriveTitleWork(options.focus, options.openspec),
210
+ activity: options.activity ?? null,
211
+ waiver: null,
212
+ };
213
+ }
214
+
215
+ /** Checkpoint line for `/galactica-status-debug`: the live title-state JSON. */
216
+ export function titleStateDebugLine(title: TitleState): string {
217
+ return `title-state: ${JSON.stringify(title)}`;
218
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Per-turn cost meter for the title-state publisher (`deck-title-state`).
3
+ *
4
+ * Sums provider usage deltas across the assistant messages of the current
5
+ * turn and measures turn elapsed time. `before_agent_start` resets it; a
6
+ * settled (idle) session reads zero so the deck's line 3 shows `— · 0 tok`.
7
+ */
8
+
9
+ export type TurnCostSnapshot = { tokens: number; elapsedMs: number };
10
+
11
+ /** Matches the span-idle convention: 99m59s999ms. */
12
+ const MAX_ELAPSED_MS = (99 * 60 + 59) * 1_000 + 999;
13
+
14
+ function usageTokens(value: unknown): number {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return 0;
16
+ const usage = value as Record<string, unknown>;
17
+ let tokens = 0;
18
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite'] as const) {
19
+ const count = usage[key];
20
+ if (typeof count === 'number' && Number.isFinite(count) && count > 0) {
21
+ tokens += count;
22
+ }
23
+ }
24
+ return Math.round(tokens);
25
+ }
26
+
27
+ export class TurnCostMeter {
28
+ private tokens = 0;
29
+ private startedAt: number | null = null;
30
+ private readonly now: () => number;
31
+
32
+ constructor(now: () => number = Date.now) {
33
+ this.now = now;
34
+ }
35
+
36
+ /** Reset at `before_agent_start`: zero tokens, elapsed from now. */
37
+ begin(): void {
38
+ this.tokens = 0;
39
+ this.startedAt = this.now();
40
+ }
41
+
42
+ /** Add one provider response's usage; returns true when tokens accrued. */
43
+ addUsage(usage: unknown): boolean {
44
+ const tokens = usageTokens(usage);
45
+ if (tokens <= 0) return false;
46
+ this.tokens += tokens;
47
+ return true;
48
+ }
49
+
50
+ /** Zero the meter when the turn settles so idle reads `{tokens:0,elapsedMs:0}`. */
51
+ settle(): void {
52
+ this.tokens = 0;
53
+ this.startedAt = null;
54
+ }
55
+
56
+ snapshot(): TurnCostSnapshot {
57
+ const elapsedMs =
58
+ this.startedAt === null
59
+ ? 0
60
+ : Math.max(0, Math.min(MAX_ELAPSED_MS, this.now() - this.startedAt));
61
+ return { tokens: this.tokens, elapsedMs };
62
+ }
63
+ }