@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +239 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +103 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +17 -0
- package/licenses/pi-fancy-footer-LICENSE +21 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
- package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
- package/packages/pi-fancy-footer-full-palette/README.md +468 -0
- package/packages/pi-fancy-footer-full-palette/package.json +52 -0
- package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
- package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
- package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
- package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
- package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
- package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
- package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
- package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
- package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
- package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
- package/packages/pi-stardeck/README.md +219 -0
- package/packages/pi-stardeck/index.ts +691 -0
- package/packages/pi-stardeck/package.json +32 -0
- package/packages/pi-stardeck/src/activity-age.ts +184 -0
- package/packages/pi-stardeck/src/capabilities.ts +105 -0
- package/packages/pi-stardeck/src/deck.ts +807 -0
- package/packages/pi-stardeck/src/gauge.ts +1323 -0
- package/packages/pi-stardeck/src/git-refresh.ts +5 -0
- package/packages/pi-stardeck/src/process-resources.ts +428 -0
- package/packages/pi-stardeck/src/title-state.ts +195 -0
- package/packages/pi-stardeck/src/title.ts +443 -0
- package/packages/pi-vim-top-border/LICENSE +21 -0
- package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
- package/packages/pi-vim-top-border/README.md +577 -0
- package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
- package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
- package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
- package/packages/pi-vim-top-border/index.ts +4195 -0
- package/packages/pi-vim-top-border/input-keys.ts +49 -0
- package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
- package/packages/pi-vim-top-border/mode-colors.ts +97 -0
- package/packages/pi-vim-top-border/mode-label.ts +62 -0
- package/packages/pi-vim-top-border/motions.ts +297 -0
- package/packages/pi-vim-top-border/package.json +47 -0
- package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
- package/packages/pi-vim-top-border/prompt-status.ts +131 -0
- package/packages/pi-vim-top-border/settings.ts +287 -0
- package/packages/pi-vim-top-border/text-objects.ts +389 -0
- package/packages/pi-vim-top-border/types.ts +51 -0
- package/packages/pi-vim-top-border/visual.ts +65 -0
- package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
- package/packages/status/README.md +398 -0
- package/packages/status/index.ts +1717 -0
- package/packages/status/package.json +33 -0
- package/packages/status/src/active-work.ts +235 -0
- package/packages/status/src/config.ts +218 -0
- package/packages/status/src/diagnostics.ts +238 -0
- package/packages/status/src/goal-block.ts +377 -0
- package/packages/status/src/goal.ts +170 -0
- package/packages/status/src/ledger.ts +101 -0
- package/packages/status/src/openspec.ts +394 -0
- package/packages/status/src/orchestration.ts +237 -0
- package/packages/status/src/publisher.ts +1294 -0
- package/packages/status/src/reducer.ts +190 -0
- package/packages/status/src/runtime-runs.ts +267 -0
- package/packages/status/src/state.ts +178 -0
- package/packages/status/src/title-activity.ts +224 -0
- package/packages/status/src/title-state.ts +218 -0
- package/packages/status/src/turn-cost.ts +63 -0
- package/packages/status/src/types.ts +228 -0
- package/src/deck-surface.ts +46 -0
- package/src/health.ts +228 -0
- package/src/index.ts +28 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { LedgerEvent } from './ledger.ts';
|
|
2
|
+
import type { GoalHeaderState } from './goal.ts';
|
|
3
|
+
import type { OpenSpecFocus, SessionSubject, SessionWorkFocus } from './active-work.ts';
|
|
4
|
+
import type {
|
|
5
|
+
CockpitSnapshot,
|
|
6
|
+
ProgressFact,
|
|
7
|
+
RunSpan,
|
|
8
|
+
SelectionState,
|
|
9
|
+
StageValue,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pure reducer for the cockpit status core (frozen contract).
|
|
14
|
+
*
|
|
15
|
+
* The reducer is the only writer of snapshot state. Selection priority lives in
|
|
16
|
+
* exactly one resolver. Title and stage are derived projections, never stored
|
|
17
|
+
* facts. Placeholder rendering is structurally impossible here: absent facts
|
|
18
|
+
* produce absent slots, never sentinel dashes or narration.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type ReducerFacts = {
|
|
22
|
+
openSpec: OpenSpecFocus;
|
|
23
|
+
goal: GoalHeaderState | null;
|
|
24
|
+
work: SessionWorkFocus;
|
|
25
|
+
subject: SessionSubject;
|
|
26
|
+
runs: ReadonlyMap<string, RunSpan>;
|
|
27
|
+
progress: ProgressFact | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const NO_SELECTION: SelectionState = { kind: 'none' };
|
|
31
|
+
|
|
32
|
+
export function emptyFacts(): ReducerFacts {
|
|
33
|
+
return {
|
|
34
|
+
openSpec: { mode: 'none' },
|
|
35
|
+
goal: null,
|
|
36
|
+
work: { state: 'clear' },
|
|
37
|
+
subject: { state: 'clear' },
|
|
38
|
+
runs: new Map(),
|
|
39
|
+
progress: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The single selection resolver. Priority order is contract-frozen. */
|
|
44
|
+
export function resolveSelection(facts: {
|
|
45
|
+
openSpec: OpenSpecFocus;
|
|
46
|
+
goal: GoalHeaderState | null;
|
|
47
|
+
work: SessionWorkFocus;
|
|
48
|
+
subject: SessionSubject;
|
|
49
|
+
}): SelectionState {
|
|
50
|
+
if (facts.openSpec.mode === 'task') {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'openspec-task',
|
|
53
|
+
change: facts.openSpec.change,
|
|
54
|
+
task: facts.openSpec.taskId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (facts.goal) {
|
|
58
|
+
return {
|
|
59
|
+
kind: 'goal',
|
|
60
|
+
status: facts.goal.status,
|
|
61
|
+
waiting: facts.goal.waiting,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (facts.work.state !== 'clear') {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'session-work',
|
|
67
|
+
intent: facts.work.intent,
|
|
68
|
+
phase: facts.work.state === 'validation' ? 'validation' : 'active',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (facts.subject.state === 'set') {
|
|
72
|
+
return { kind: 'subject', title: facts.subject.title };
|
|
73
|
+
}
|
|
74
|
+
return NO_SELECTION;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyStageRule(span: RunSpan, stage: StageValue, declared: boolean): RunSpan {
|
|
78
|
+
// Declaration beats inference: an inferred stage never overrides a declared one.
|
|
79
|
+
if (!declared && span.stageDeclared) return span;
|
|
80
|
+
return { ...span, stage, stageDeclared: declared };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applyEvent(facts: ReducerFacts, event: LedgerEvent): ReducerFacts {
|
|
84
|
+
switch (event.type) {
|
|
85
|
+
case 'focus/openspec':
|
|
86
|
+
return { ...facts, openSpec: event.focus };
|
|
87
|
+
case 'focus/goal':
|
|
88
|
+
return { ...facts, goal: event.goal };
|
|
89
|
+
case 'focus/work':
|
|
90
|
+
return { ...facts, work: event.work };
|
|
91
|
+
case 'focus/subject':
|
|
92
|
+
return { ...facts, subject: event.subject };
|
|
93
|
+
case 'run/start': {
|
|
94
|
+
const runs = new Map(facts.runs);
|
|
95
|
+
runs.set(event.span.id, event.span);
|
|
96
|
+
return { ...facts, runs };
|
|
97
|
+
}
|
|
98
|
+
case 'run/stage': {
|
|
99
|
+
const span = facts.runs.get(event.id);
|
|
100
|
+
if (!span || span.endedAt !== undefined) return facts;
|
|
101
|
+
const runs = new Map(facts.runs);
|
|
102
|
+
runs.set(event.id, applyStageRule(span, event.stage, event.declared));
|
|
103
|
+
return { ...facts, runs };
|
|
104
|
+
}
|
|
105
|
+
case 'run/end': {
|
|
106
|
+
const span = facts.runs.get(event.id);
|
|
107
|
+
if (!span || span.endedAt !== undefined) return facts;
|
|
108
|
+
const runs = new Map(facts.runs);
|
|
109
|
+
runs.set(event.id, { ...span, endedAt: event.at });
|
|
110
|
+
return { ...facts, runs };
|
|
111
|
+
}
|
|
112
|
+
case 'progress/refresh':
|
|
113
|
+
return { ...facts, progress: event.progress };
|
|
114
|
+
case 'diagnostics/refresh':
|
|
115
|
+
case 'orchestration/refresh':
|
|
116
|
+
case 'taskflow/phase':
|
|
117
|
+
// Bounded collector domains feed the same ledger path but never the
|
|
118
|
+
// selection; snapshot consumers read them through their own projections.
|
|
119
|
+
return facts;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fold a full ledger into one snapshot. Pure; version counts applied events. */
|
|
124
|
+
export function reduceLedger(events: readonly LedgerEvent[]): CockpitSnapshot {
|
|
125
|
+
let facts = emptyFacts();
|
|
126
|
+
for (const event of events) {
|
|
127
|
+
facts = applyEvent(facts, event);
|
|
128
|
+
}
|
|
129
|
+
return snapshotFromFacts(facts, events.length);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function snapshotFromFacts(
|
|
133
|
+
facts: ReducerFacts,
|
|
134
|
+
version: number,
|
|
135
|
+
): CockpitSnapshot {
|
|
136
|
+
const runs = [...facts.runs.values()].sort((left, right) => {
|
|
137
|
+
if (left.since !== right.since) return left.since - right.since;
|
|
138
|
+
return left.id < right.id ? -1 : 1;
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
version,
|
|
142
|
+
selection: resolveSelection(facts),
|
|
143
|
+
runs,
|
|
144
|
+
progress: facts.progress,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Root run of the current turn, if any (the direct agent). */
|
|
149
|
+
export function rootRun(snapshot: CockpitSnapshot): RunSpan | undefined {
|
|
150
|
+
return snapshot.runs.find((span) => span.kind === 'agent' && span.parent === null);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Derived stage: the root run's stage, or listening when nothing runs. */
|
|
154
|
+
export function derivedStage(snapshot: CockpitSnapshot): StageValue {
|
|
155
|
+
return rootRun(snapshot)?.stage ?? 'listening';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Derived terminal title. `title = f(selection, stage, progress)` — never stored,
|
|
160
|
+
* never updated independently, never a placeholder.
|
|
161
|
+
*/
|
|
162
|
+
export function deriveTitle(snapshot: CockpitSnapshot): string {
|
|
163
|
+
const selection = snapshot.selection;
|
|
164
|
+
let detail: string;
|
|
165
|
+
switch (selection.kind) {
|
|
166
|
+
case 'openspec-task':
|
|
167
|
+
detail = `${selection.change} › ${selection.task}`;
|
|
168
|
+
break;
|
|
169
|
+
case 'goal':
|
|
170
|
+
detail =
|
|
171
|
+
selection.status === 'complete'
|
|
172
|
+
? 'Validate › Goal'
|
|
173
|
+
: selection.waiting
|
|
174
|
+
? 'Goal › waiting'
|
|
175
|
+
: 'Goal';
|
|
176
|
+
break;
|
|
177
|
+
case 'session-work':
|
|
178
|
+
detail =
|
|
179
|
+
selection.phase === 'validation'
|
|
180
|
+
? `Validate › ${selection.intent}`
|
|
181
|
+
: selection.intent;
|
|
182
|
+
break;
|
|
183
|
+
case 'subject':
|
|
184
|
+
detail = selection.title;
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
detail = '';
|
|
188
|
+
}
|
|
189
|
+
return detail ? `π ${detail}` : 'π';
|
|
190
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { StageValue } from './types.ts';
|
|
2
|
+
import { classifyHeaderActivity, headerLifecycleForActivity } from './publisher.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runtime span store (frozen contract, `runs` fact).
|
|
6
|
+
*
|
|
7
|
+
* Replaces count-only tracking: every live participant is a span with identity,
|
|
8
|
+
* parent, stage, and age. Subagent partial results map to per-agent child spans;
|
|
9
|
+
* bash children are leaf spans. The derived `snapshot()` keeps count parity with
|
|
10
|
+
* the retired `RuntimeRunTracker` while adding the span views renderers need.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ActiveRunSpanView {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: 'agent' | 'subagent' | 'bash';
|
|
16
|
+
parent: string | null;
|
|
17
|
+
agent?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
stage: StageValue;
|
|
20
|
+
elapsedMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ActiveRuntimeRuns {
|
|
24
|
+
children: number;
|
|
25
|
+
subagents: number;
|
|
26
|
+
spans?: ActiveRunSpanView[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TrackedSpan {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: 'agent' | 'subagent' | 'bash';
|
|
32
|
+
parent: string | null;
|
|
33
|
+
agent?: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
stage: StageValue;
|
|
36
|
+
stageDeclared: boolean;
|
|
37
|
+
since: number;
|
|
38
|
+
endedAt?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
42
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
43
|
+
? (value as Record<string, unknown>)
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PartialAgentResult {
|
|
48
|
+
agent?: string;
|
|
49
|
+
active: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function partialAgentResults(partialResult: unknown): PartialAgentResult[] | undefined {
|
|
53
|
+
const details = record(record(partialResult)?.details);
|
|
54
|
+
if (details?.kind !== 'pi-subagent' || !Array.isArray(details.results)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return details.results.map((rawResult) => {
|
|
58
|
+
const result = record(rawResult);
|
|
59
|
+
return {
|
|
60
|
+
agent: typeof result?.agent === 'string' ? result.agent : undefined,
|
|
61
|
+
active:
|
|
62
|
+
result?.exitCode === -1 &&
|
|
63
|
+
result.sawAgentStart === true &&
|
|
64
|
+
result.sawAgentSettled !== true,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferredStage(agent: string | undefined): StageValue {
|
|
70
|
+
const activity = classifyHeaderActivity(agent ?? '');
|
|
71
|
+
return headerLifecycleForActivity(activity) ?? 'working';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SpanStoreChange {
|
|
75
|
+
started: ActiveRunSpanView[];
|
|
76
|
+
ended: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class RuntimeSpanStore {
|
|
80
|
+
private readonly spans = new Map<string, TrackedSpan>();
|
|
81
|
+
private changeQueue: SpanStoreChange = { started: [], ended: [] };
|
|
82
|
+
|
|
83
|
+
beginRoot(turnId: string, stage: StageValue, now = Date.now()): boolean {
|
|
84
|
+
return this.beginSpan({
|
|
85
|
+
id: turnId,
|
|
86
|
+
kind: 'agent',
|
|
87
|
+
parent: null,
|
|
88
|
+
stage,
|
|
89
|
+
stageDeclared: false,
|
|
90
|
+
since: now,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
endRoot(turnId: string, now = Date.now()): boolean {
|
|
95
|
+
return this.finishSpan(turnId, now);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
begin(
|
|
99
|
+
toolCallId: string,
|
|
100
|
+
kind: 'bash' | 'subagent',
|
|
101
|
+
parent: string | null,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): boolean {
|
|
104
|
+
if (this.spans.has(toolCallId)) return false;
|
|
105
|
+
return this.beginSpan({
|
|
106
|
+
id: toolCallId,
|
|
107
|
+
kind,
|
|
108
|
+
parent,
|
|
109
|
+
stage: 'working',
|
|
110
|
+
stageDeclared: false,
|
|
111
|
+
since: now,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
finish(toolCallId: string, now = Date.now()): boolean {
|
|
116
|
+
return this.finishSpan(toolCallId, now);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map subagent partial results to per-agent child spans. Returns true when any
|
|
121
|
+
* span started or ended; queued changes are drained by `drainChanges()`.
|
|
122
|
+
*/
|
|
123
|
+
update(
|
|
124
|
+
toolCallId: string,
|
|
125
|
+
partialResult: unknown,
|
|
126
|
+
args?: unknown,
|
|
127
|
+
now = Date.now(),
|
|
128
|
+
): boolean {
|
|
129
|
+
const parent = this.spans.get(toolCallId);
|
|
130
|
+
if (!parent || parent.kind !== 'subagent') return false;
|
|
131
|
+
const results = partialAgentResults(partialResult);
|
|
132
|
+
if (!results) return false;
|
|
133
|
+
// Agent names live in the tool input, not the results: calls[i].agent maps
|
|
134
|
+
// to results[i] by order. Bounded identifiers only, never prompt content.
|
|
135
|
+
const rawCalls = Array.isArray(args)
|
|
136
|
+
? args
|
|
137
|
+
: (args as { calls?: unknown } | undefined)?.calls;
|
|
138
|
+
const calls = Array.isArray(rawCalls)
|
|
139
|
+
? (rawCalls as Array<{ agent?: unknown }>)
|
|
140
|
+
: [];
|
|
141
|
+
const callAgent = (index: number): string | undefined => {
|
|
142
|
+
const agent = calls[index]?.agent;
|
|
143
|
+
return typeof agent === 'string' && agent.trim() ? agent.trim() : undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let changed = false;
|
|
147
|
+
const expected = new Set<string>();
|
|
148
|
+
results.forEach((result, index) => {
|
|
149
|
+
const agent = result.agent?.trim() || callAgent(index);
|
|
150
|
+
if (!result.active) return;
|
|
151
|
+
const childId = `${toolCallId}:${index}`;
|
|
152
|
+
expected.add(childId);
|
|
153
|
+
const existing = this.spans.get(childId);
|
|
154
|
+
if (existing && existing.endedAt === undefined) {
|
|
155
|
+
if (agent && existing.agent !== agent) {
|
|
156
|
+
existing.agent = agent;
|
|
157
|
+
existing.stage = inferredStage(agent);
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
changed =
|
|
163
|
+
this.beginSpan({
|
|
164
|
+
id: childId,
|
|
165
|
+
kind: 'subagent',
|
|
166
|
+
parent: toolCallId,
|
|
167
|
+
agent,
|
|
168
|
+
stage: inferredStage(agent),
|
|
169
|
+
stageDeclared: false,
|
|
170
|
+
since: now,
|
|
171
|
+
}) || changed;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
for (const span of this.spans.values()) {
|
|
175
|
+
if (span.parent === toolCallId && span.endedAt === undefined) {
|
|
176
|
+
if (!expected.has(span.id)) {
|
|
177
|
+
changed = this.finishSpan(span.id, now) || changed;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return changed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setStage(id: string, stage: StageValue, declared: boolean): boolean {
|
|
185
|
+
const span = this.spans.get(id);
|
|
186
|
+
if (!span || span.endedAt !== undefined) return false;
|
|
187
|
+
// Declaration beats inference: an inferred stage never overrides a declared one.
|
|
188
|
+
if (!declared && span.stageDeclared) return false;
|
|
189
|
+
if (span.stage === stage && span.stageDeclared === declared) return false;
|
|
190
|
+
span.stage = stage;
|
|
191
|
+
span.stageDeclared = declared;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Time since the last root span ended; 0 while any span is active. */
|
|
196
|
+
idleMs(now = Date.now()): number {
|
|
197
|
+
let lastEnded = 0;
|
|
198
|
+
for (const span of this.spans.values()) {
|
|
199
|
+
if (span.endedAt === undefined) return 0;
|
|
200
|
+
if (span.kind === 'agent' && span.endedAt > lastEnded) lastEnded = span.endedAt;
|
|
201
|
+
}
|
|
202
|
+
if (lastEnded === 0) return 0;
|
|
203
|
+
return Math.max(0, Math.min(now - lastEnded, (99 * 60 + 59) * 1_000 + 999));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
drainChanges(): SpanStoreChange {
|
|
207
|
+
const drained = this.changeQueue;
|
|
208
|
+
this.changeQueue = { started: [], ended: [] };
|
|
209
|
+
return drained;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
snapshot(now = Date.now()): ActiveRuntimeRuns {
|
|
213
|
+
const views: ActiveRunSpanView[] = [];
|
|
214
|
+
let children = 0;
|
|
215
|
+
let subagents = 0;
|
|
216
|
+
for (const span of this.spans.values()) {
|
|
217
|
+
if (span.endedAt !== undefined) continue;
|
|
218
|
+
const parent = span.parent ? this.spans.get(span.parent) : undefined;
|
|
219
|
+
const isAgentChild = span.kind === 'subagent' && parent?.kind === 'subagent';
|
|
220
|
+
views.push({
|
|
221
|
+
id: span.id,
|
|
222
|
+
kind: span.kind,
|
|
223
|
+
parent: span.parent,
|
|
224
|
+
...(span.agent ? { agent: span.agent } : {}),
|
|
225
|
+
...(span.label ? { label: span.label } : {}),
|
|
226
|
+
stage: span.stage,
|
|
227
|
+
elapsedMs: Math.max(0, now - span.since),
|
|
228
|
+
});
|
|
229
|
+
if (span.kind === 'bash') children += 1;
|
|
230
|
+
if (isAgentChild) {
|
|
231
|
+
children += 1;
|
|
232
|
+
subagents += 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { children, subagents, spans: views };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private beginSpan(span: TrackedSpan): boolean {
|
|
239
|
+
if (this.spans.has(span.id)) return false;
|
|
240
|
+
this.spans.set(span.id, span);
|
|
241
|
+
this.changeQueue.started.push({
|
|
242
|
+
id: span.id,
|
|
243
|
+
kind: span.kind,
|
|
244
|
+
parent: span.parent,
|
|
245
|
+
...(span.agent ? { agent: span.agent } : {}),
|
|
246
|
+
...(span.label ? { label: span.label } : {}),
|
|
247
|
+
stage: span.stage,
|
|
248
|
+
elapsedMs: 0,
|
|
249
|
+
});
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private finishSpan(id: string, now: number): boolean {
|
|
254
|
+
const span = this.spans.get(id);
|
|
255
|
+
if (!span || span.endedAt !== undefined) return false;
|
|
256
|
+
span.endedAt = now;
|
|
257
|
+
this.changeQueue.ended.push(id);
|
|
258
|
+
// Ending a container takes its per-agent children with it.
|
|
259
|
+
for (const child of this.spans.values()) {
|
|
260
|
+
if (child.parent === id && child.endedAt === undefined) {
|
|
261
|
+
child.endedAt = now;
|
|
262
|
+
this.changeQueue.ended.push(child.id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiagnosticsState,
|
|
3
|
+
OpenSpecState,
|
|
4
|
+
OrchestrationState,
|
|
5
|
+
RuntimeErrorRecord,
|
|
6
|
+
StatusSnapshot,
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
|
|
9
|
+
export type DomainName = 'openspec' | 'diagnostics' | 'orchestration';
|
|
10
|
+
|
|
11
|
+
export type RefreshOutcome<T> =
|
|
12
|
+
{ kind: 'success'; value: T } | { kind: 'absent' } | { kind: 'error'; error: string };
|
|
13
|
+
|
|
14
|
+
export interface RefreshBatch {
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
at: number;
|
|
17
|
+
openspec: RefreshOutcome<OpenSpecState>;
|
|
18
|
+
diagnostics: RefreshOutcome<DiagnosticsState>;
|
|
19
|
+
orchestration: RefreshOutcome<OrchestrationState>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function staleValue<
|
|
23
|
+
T extends { meta: { stale: boolean; attemptedAt: number; error?: string } },
|
|
24
|
+
>(previous: T | null, error: string, at: number): T | null {
|
|
25
|
+
if (!previous) return null;
|
|
26
|
+
return {
|
|
27
|
+
...previous,
|
|
28
|
+
meta: {
|
|
29
|
+
...previous.meta,
|
|
30
|
+
stale: true,
|
|
31
|
+
attemptedAt: at,
|
|
32
|
+
error,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applyOutcome<
|
|
38
|
+
T extends { meta: { stale: boolean; attemptedAt: number; error?: string } },
|
|
39
|
+
>(previous: T | null, outcome: RefreshOutcome<T>, at: number): T | null {
|
|
40
|
+
if (outcome.kind === 'success') return outcome.value;
|
|
41
|
+
if (outcome.kind === 'absent') return null;
|
|
42
|
+
return staleValue(previous, outcome.error, at);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class AtomicStatusStore {
|
|
46
|
+
private snapshot: StatusSnapshot;
|
|
47
|
+
|
|
48
|
+
constructor(initial: {
|
|
49
|
+
cwd: string;
|
|
50
|
+
projectRoot: string;
|
|
51
|
+
configPath: string;
|
|
52
|
+
configWarnings?: string[];
|
|
53
|
+
}) {
|
|
54
|
+
this.snapshot = {
|
|
55
|
+
cwd: initial.cwd,
|
|
56
|
+
projectRoot: initial.projectRoot,
|
|
57
|
+
configPath: initial.configPath,
|
|
58
|
+
configWarnings: [...(initial.configWarnings ?? [])],
|
|
59
|
+
openspec: null,
|
|
60
|
+
diagnostics: null,
|
|
61
|
+
orchestration: null,
|
|
62
|
+
errors: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(): StatusSnapshot {
|
|
67
|
+
return this.snapshot;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setConfig(configPath: string, warnings: string[]): void {
|
|
71
|
+
this.snapshot = {
|
|
72
|
+
...this.snapshot,
|
|
73
|
+
configPath,
|
|
74
|
+
configWarnings: [...warnings],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clearOpenSpec(): void {
|
|
79
|
+
if (!this.snapshot.openspec) return;
|
|
80
|
+
this.snapshot = { ...this.snapshot, openspec: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
applyRefresh(batch: RefreshBatch): void {
|
|
84
|
+
this.snapshot = {
|
|
85
|
+
...this.snapshot,
|
|
86
|
+
projectRoot: batch.projectRoot,
|
|
87
|
+
lastRefreshAt: batch.at,
|
|
88
|
+
openspec: applyOutcome(this.snapshot.openspec, batch.openspec, batch.at),
|
|
89
|
+
diagnostics: applyOutcome(this.snapshot.diagnostics, batch.diagnostics, batch.at),
|
|
90
|
+
orchestration: applyOutcome(
|
|
91
|
+
this.snapshot.orchestration,
|
|
92
|
+
batch.orchestration,
|
|
93
|
+
batch.at,
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
recordError(error: RuntimeErrorRecord): void {
|
|
99
|
+
const duplicate = this.snapshot.errors.at(-1);
|
|
100
|
+
if (duplicate?.domain === error.domain && duplicate.message === error.message) {
|
|
101
|
+
this.snapshot = {
|
|
102
|
+
...this.snapshot,
|
|
103
|
+
errors: [...this.snapshot.errors.slice(0, -1), error],
|
|
104
|
+
};
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.snapshot = {
|
|
108
|
+
...this.snapshot,
|
|
109
|
+
errors: [...this.snapshot.errors, error].slice(-12),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ResourceBag {
|
|
115
|
+
private cleanups: Array<() => void> = [];
|
|
116
|
+
private closed = false;
|
|
117
|
+
|
|
118
|
+
add(cleanup: () => void): () => void {
|
|
119
|
+
if (this.closed) {
|
|
120
|
+
cleanup();
|
|
121
|
+
return () => {};
|
|
122
|
+
}
|
|
123
|
+
let active = true;
|
|
124
|
+
const wrapped = () => {
|
|
125
|
+
if (!active) return;
|
|
126
|
+
active = false;
|
|
127
|
+
cleanup();
|
|
128
|
+
};
|
|
129
|
+
this.cleanups.push(wrapped);
|
|
130
|
+
return wrapped;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
addInterval(
|
|
134
|
+
callback: () => void,
|
|
135
|
+
intervalMs: number,
|
|
136
|
+
): ReturnType<typeof setInterval> {
|
|
137
|
+
const timer = setInterval(callback, intervalMs);
|
|
138
|
+
timer.unref?.();
|
|
139
|
+
this.add(() => clearInterval(timer));
|
|
140
|
+
return timer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close(): void {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
this.closed = true;
|
|
146
|
+
for (const cleanup of this.cleanups.splice(0).reverse()) {
|
|
147
|
+
try {
|
|
148
|
+
cleanup();
|
|
149
|
+
} catch {
|
|
150
|
+
// Cleanup remains best-effort during Pi shutdown/reload.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get isClosed(): boolean {
|
|
156
|
+
return this.closed;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createDebouncer(
|
|
161
|
+
callback: () => void,
|
|
162
|
+
delayMs: number,
|
|
163
|
+
resources: ResourceBag,
|
|
164
|
+
): () => void {
|
|
165
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
166
|
+
resources.add(() => {
|
|
167
|
+
if (timer) clearTimeout(timer);
|
|
168
|
+
timer = undefined;
|
|
169
|
+
});
|
|
170
|
+
return () => {
|
|
171
|
+
if (timer) clearTimeout(timer);
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
timer = undefined;
|
|
174
|
+
callback();
|
|
175
|
+
}, delayMs);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
};
|
|
178
|
+
}
|