@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.
- package/LICENSE +21 -0
- package/README.md +240 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +106 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +19 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/footer/README.md +15 -0
- package/packages/footer/index.ts +37 -0
- package/packages/footer/package.json +32 -0
- package/packages/footer/src/protocol.ts +10 -0
- package/packages/footer/src/quota.ts +26 -0
- package/packages/footer/src/telemetry.ts +59 -0
- package/packages/pi-stardeck/README.md +220 -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 +227 -0
- package/src/index.ts +27 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,1717 @@
|
|
|
1
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import { promises as fs, watch } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { Type } from 'typebox';
|
|
10
|
+
import {
|
|
11
|
+
formatActiveWorkTitle,
|
|
12
|
+
NO_OPEN_SPEC_FOCUS,
|
|
13
|
+
NO_SESSION_SUBJECT,
|
|
14
|
+
NO_SESSION_WORK_FOCUS,
|
|
15
|
+
OPEN_SPEC_FOCUS_ENTRY_TYPE,
|
|
16
|
+
type OpenSpecFocus,
|
|
17
|
+
restoreOpenSpecFocus,
|
|
18
|
+
restoreSessionSubject,
|
|
19
|
+
restoreSessionWorkFocus,
|
|
20
|
+
sameOpenSpecFocus,
|
|
21
|
+
sameSessionSubject,
|
|
22
|
+
sameSessionWorkFocus,
|
|
23
|
+
SESSION_SUBJECT_ENTRY_TYPE,
|
|
24
|
+
SESSION_WORK_FOCUS_ENTRY_TYPE,
|
|
25
|
+
type SessionSubject,
|
|
26
|
+
type SessionWorkFocus,
|
|
27
|
+
sessionSubject,
|
|
28
|
+
sessionWorkFocus,
|
|
29
|
+
taskflowRunFocus,
|
|
30
|
+
taskFocus,
|
|
31
|
+
} from './src/active-work.ts';
|
|
32
|
+
import { collectDiagnostics } from './src/diagnostics.ts';
|
|
33
|
+
import { loadConfig } from './src/config.ts';
|
|
34
|
+
import {
|
|
35
|
+
type GoalHeaderState,
|
|
36
|
+
isGoalOwnedPrompt,
|
|
37
|
+
loadGoalAutomaticTurnLimit,
|
|
38
|
+
restoreGoalHeaderState,
|
|
39
|
+
sameGoalHeaderState,
|
|
40
|
+
} from './src/goal.ts';
|
|
41
|
+
import {
|
|
42
|
+
countOpenGoals,
|
|
43
|
+
installGoalBlockTakeover,
|
|
44
|
+
readGoalHeadline,
|
|
45
|
+
} from './src/goal-block.ts';
|
|
46
|
+
import { collectOpenSpec, collectOpenSpecOverview } from './src/openspec.ts';
|
|
47
|
+
import { collectOrchestration } from './src/orchestration.ts';
|
|
48
|
+
import {
|
|
49
|
+
buildHeaderStatusEvent,
|
|
50
|
+
buildWidgetSnapshots,
|
|
51
|
+
FancyFooterPublisher,
|
|
52
|
+
FANCY_FOOTER_READY_CHANNEL,
|
|
53
|
+
FANCY_FOOTER_WIDGET_CHANNEL,
|
|
54
|
+
formatDiagnostics,
|
|
55
|
+
formatOpenSpec,
|
|
56
|
+
formatOpenSpecOverview,
|
|
57
|
+
formatOrchestration,
|
|
58
|
+
GALACTICA_HEADER_CHANNEL,
|
|
59
|
+
goalHeaderLifecycle,
|
|
60
|
+
type HeaderActivity,
|
|
61
|
+
type HeaderLifecycle,
|
|
62
|
+
directToolCompletionHeaderActivity,
|
|
63
|
+
directToolHeaderActivity,
|
|
64
|
+
headerLifecycleForActivity,
|
|
65
|
+
taskflowCallHeaderActivity,
|
|
66
|
+
taskflowInitialHeaderPhase,
|
|
67
|
+
taskflowPhaseHeaderLifecycle,
|
|
68
|
+
taskflowUpdateHeaderPhase,
|
|
69
|
+
} from './src/publisher.ts';
|
|
70
|
+
import { RuntimeSpanStore } from './src/runtime-runs.ts';
|
|
71
|
+
import { TitleActivityFeed } from './src/title-activity.ts';
|
|
72
|
+
import {
|
|
73
|
+
buildTitleState,
|
|
74
|
+
titleStateDebugLine,
|
|
75
|
+
type TitleState,
|
|
76
|
+
} from './src/title-state.ts';
|
|
77
|
+
import { TurnCostMeter } from './src/turn-cost.ts';
|
|
78
|
+
import {
|
|
79
|
+
AtomicStatusStore,
|
|
80
|
+
createDebouncer,
|
|
81
|
+
ResourceBag,
|
|
82
|
+
type RefreshOutcome,
|
|
83
|
+
} from './src/state.ts';
|
|
84
|
+
import { boundedPhase, EventLedger, type LedgerEvent } from './src/ledger.ts';
|
|
85
|
+
import {
|
|
86
|
+
applyEvent,
|
|
87
|
+
emptyFacts,
|
|
88
|
+
snapshotFromFacts,
|
|
89
|
+
type ReducerFacts,
|
|
90
|
+
} from './src/reducer.ts';
|
|
91
|
+
import type {
|
|
92
|
+
CockpitSnapshot,
|
|
93
|
+
StageValue,
|
|
94
|
+
CommandRunner,
|
|
95
|
+
DiagnosticsState,
|
|
96
|
+
LoadedConfig,
|
|
97
|
+
OpenSpecFeedback,
|
|
98
|
+
OpenSpecOverview,
|
|
99
|
+
OpenSpecState,
|
|
100
|
+
OrchestrationState,
|
|
101
|
+
RuntimeErrorRecord,
|
|
102
|
+
} from './src/types.ts';
|
|
103
|
+
|
|
104
|
+
type SessionContext = ExtensionContext | ExtensionCommandContext;
|
|
105
|
+
type Domain = RuntimeErrorRecord['domain'];
|
|
106
|
+
type OpenSpecFocusAction = 'status' | 'set' | 'replace' | 'clear';
|
|
107
|
+
|
|
108
|
+
type StatusHealthCondition = {
|
|
109
|
+
id: string;
|
|
110
|
+
severity: 'warning' | 'error';
|
|
111
|
+
summary: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const PI_STATUS_SOURCE_CHANNEL = 'pi-stardeck:status-source/v1';
|
|
115
|
+
|
|
116
|
+
function stableStatusId(prefix: string, value: string): string {
|
|
117
|
+
let hash = 2_166_136_261;
|
|
118
|
+
for (const character of value) {
|
|
119
|
+
hash ^= character.codePointAt(0) ?? 0;
|
|
120
|
+
hash = Math.imul(hash, 16_777_619);
|
|
121
|
+
}
|
|
122
|
+
return `${prefix}.${(hash >>> 0).toString(16)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function errorMessage(error: unknown): string {
|
|
126
|
+
return error instanceof Error ? error.message : String(error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assistantStopReason(message: unknown): string | undefined {
|
|
130
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const candidate = message as { role?: unknown; stopReason?: unknown };
|
|
134
|
+
return candidate.role === 'assistant' && typeof candidate.stopReason === 'string'
|
|
135
|
+
? candidate.stopReason
|
|
136
|
+
: undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assistantHasVisibleText(message: unknown): boolean {
|
|
140
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) return false;
|
|
141
|
+
const candidate = message as { role?: unknown; content?: unknown };
|
|
142
|
+
if (candidate.role !== 'assistant') return false;
|
|
143
|
+
if (typeof candidate.content === 'string') return candidate.content.length > 0;
|
|
144
|
+
return (
|
|
145
|
+
Array.isArray(candidate.content) &&
|
|
146
|
+
candidate.content.some(
|
|
147
|
+
(item) =>
|
|
148
|
+
item !== null &&
|
|
149
|
+
typeof item === 'object' &&
|
|
150
|
+
!Array.isArray(item) &&
|
|
151
|
+
(item as { type?: unknown }).type === 'text' &&
|
|
152
|
+
typeof (item as { text?: unknown }).text === 'string' &&
|
|
153
|
+
(item as { text: string }).text.length > 0,
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pathIsDirectory(path: string): Promise<boolean> {
|
|
159
|
+
try {
|
|
160
|
+
return (await fs.stat(path)).isDirectory();
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function detectProjectRoot(cwd: string, runner: CommandRunner): Promise<string> {
|
|
167
|
+
let candidate = resolve(cwd);
|
|
168
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
169
|
+
if (await pathIsDirectory(join(candidate, 'openspec'))) return candidate;
|
|
170
|
+
if (await pathIsDirectory(join(candidate, '.git'))) return candidate;
|
|
171
|
+
const parent = dirname(candidate);
|
|
172
|
+
if (parent === candidate) break;
|
|
173
|
+
candidate = parent;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const result = await runner('git', ['rev-parse', '--show-toplevel'], {
|
|
178
|
+
cwd,
|
|
179
|
+
timeoutMs: 2_000,
|
|
180
|
+
});
|
|
181
|
+
const root = result.stdout.trim();
|
|
182
|
+
if (result.code === 0 && root) return root;
|
|
183
|
+
} catch {
|
|
184
|
+
// Non-Git projects use their session cwd.
|
|
185
|
+
}
|
|
186
|
+
return resolve(cwd);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class GalacticaStatusRuntime {
|
|
190
|
+
private readonly resources = new ResourceBag();
|
|
191
|
+
private watchCleanups: Array<() => void> = [];
|
|
192
|
+
private watchSignature = '';
|
|
193
|
+
private generation = 0;
|
|
194
|
+
private stopped = false;
|
|
195
|
+
private refreshPromise: Promise<void> | undefined;
|
|
196
|
+
private refreshRequested = false;
|
|
197
|
+
private pendingForceCommand = false;
|
|
198
|
+
private loadedConfig: LoadedConfig | undefined;
|
|
199
|
+
private openSpecFeedback: OpenSpecFeedback | undefined;
|
|
200
|
+
private openSpecOverview: OpenSpecOverview | undefined;
|
|
201
|
+
private openSpecProjectDetected: boolean | undefined;
|
|
202
|
+
private focus: OpenSpecFocus;
|
|
203
|
+
private workFocus: SessionWorkFocus;
|
|
204
|
+
private subject: SessionSubject;
|
|
205
|
+
private goal: GoalHeaderState | null;
|
|
206
|
+
private goalProjectionSuppressed = false;
|
|
207
|
+
private goalAutomaticTurnLimit: number | undefined;
|
|
208
|
+
private goalPollCleanup: (() => void) | undefined;
|
|
209
|
+
private sessionName: string | undefined;
|
|
210
|
+
private lastTitle: string | undefined;
|
|
211
|
+
private diagnosticsPath: string | undefined;
|
|
212
|
+
private orchestrationPath: string | undefined;
|
|
213
|
+
private liveLifecycle: HeaderLifecycle;
|
|
214
|
+
private liveActivity: HeaderActivity | undefined;
|
|
215
|
+
private liveTaskflowPhase: string | undefined;
|
|
216
|
+
private readonly directToolActivities = new Map<string, HeaderActivity>();
|
|
217
|
+
private directToolFailed = false;
|
|
218
|
+
private readonly runtimeSpans = new RuntimeSpanStore();
|
|
219
|
+
private readonly titleActivity = new TitleActivityFeed();
|
|
220
|
+
private readonly turnCost = new TurnCostMeter();
|
|
221
|
+
private currentTurnId: string | null = null;
|
|
222
|
+
private turnCounter = 0;
|
|
223
|
+
private readonly statusHealth = new Map<Domain, StatusHealthCondition>();
|
|
224
|
+
private lastStatusHealthSignature = '';
|
|
225
|
+
private readonly store: AtomicStatusStore;
|
|
226
|
+
private readonly statusLedger = new EventLedger();
|
|
227
|
+
private cockpitFacts: ReducerFacts = emptyFacts();
|
|
228
|
+
private cockpitVersion = 0;
|
|
229
|
+
private readonly pi: ExtensionAPI;
|
|
230
|
+
private readonly ctx: SessionContext;
|
|
231
|
+
private readonly publisher: FancyFooterPublisher;
|
|
232
|
+
|
|
233
|
+
constructor(
|
|
234
|
+
pi: ExtensionAPI,
|
|
235
|
+
ctx: SessionContext,
|
|
236
|
+
publisher: FancyFooterPublisher,
|
|
237
|
+
focus: OpenSpecFocus,
|
|
238
|
+
workFocus: SessionWorkFocus,
|
|
239
|
+
subject: SessionSubject,
|
|
240
|
+
goal: GoalHeaderState | null,
|
|
241
|
+
sessionName: string | undefined,
|
|
242
|
+
) {
|
|
243
|
+
this.pi = pi;
|
|
244
|
+
this.ctx = ctx;
|
|
245
|
+
this.publisher = publisher;
|
|
246
|
+
this.focus = focus;
|
|
247
|
+
this.workFocus = workFocus;
|
|
248
|
+
this.subject = subject;
|
|
249
|
+
this.goal = goal;
|
|
250
|
+
this.sessionName = sessionName;
|
|
251
|
+
this.liveLifecycle = goal
|
|
252
|
+
? goalHeaderLifecycle(goal)
|
|
253
|
+
: workFocus.state === 'validation'
|
|
254
|
+
? 'waiting'
|
|
255
|
+
: 'listening';
|
|
256
|
+
this.liveActivity =
|
|
257
|
+
!goal && workFocus.state === 'validation'
|
|
258
|
+
? { kind: 'awaiting-validation' }
|
|
259
|
+
: undefined;
|
|
260
|
+
this.store = new AtomicStatusStore({
|
|
261
|
+
cwd: ctx.cwd,
|
|
262
|
+
projectRoot: ctx.cwd,
|
|
263
|
+
configPath: '',
|
|
264
|
+
});
|
|
265
|
+
const seededAt = Date.now();
|
|
266
|
+
this.recordEvent({ type: 'focus/openspec', at: seededAt, focus });
|
|
267
|
+
this.recordEvent({ type: 'focus/work', at: seededAt, work: workFocus });
|
|
268
|
+
this.recordEvent({ type: 'focus/subject', at: seededAt, subject });
|
|
269
|
+
if (goal) this.recordEvent({ type: 'focus/goal', at: seededAt, goal });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private runner: CommandRunner = async (command, args, options) => {
|
|
273
|
+
const result = await this.pi.exec(command, args, {
|
|
274
|
+
cwd: options.cwd,
|
|
275
|
+
timeout: options.timeoutMs,
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
stdout: String(result.stdout ?? ''),
|
|
279
|
+
stderr: String(result.stderr ?? ''),
|
|
280
|
+
code: result.code,
|
|
281
|
+
killed: result.killed,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
start(): void {
|
|
286
|
+
// Heartbeat: span ages and the idle timer are publish-time facts; without
|
|
287
|
+
// a tick they freeze. One unref'd interval, no-op when nothing is live.
|
|
288
|
+
this.resources.addInterval(() => {
|
|
289
|
+
if (this.stopped) return;
|
|
290
|
+
const snap = this.runtimeSpans.snapshot();
|
|
291
|
+
const idle = this.runtimeSpans.idleMs();
|
|
292
|
+
if ((snap.spans?.length ?? 0) > 0 || idle > 0) this.publish();
|
|
293
|
+
}, 1_000);
|
|
294
|
+
this.publish();
|
|
295
|
+
this.ensureGoalPolling();
|
|
296
|
+
this.launch(
|
|
297
|
+
loadGoalAutomaticTurnLimit().then((limit) => {
|
|
298
|
+
if (this.stopped || this.goalAutomaticTurnLimit === limit) return;
|
|
299
|
+
this.goalAutomaticTurnLimit = limit;
|
|
300
|
+
this.publish();
|
|
301
|
+
}),
|
|
302
|
+
'config',
|
|
303
|
+
);
|
|
304
|
+
this.launch(
|
|
305
|
+
(async () => {
|
|
306
|
+
this.loadedConfig = await loadConfig();
|
|
307
|
+
this.statusHealth.delete('config');
|
|
308
|
+
this.store.setConfig(this.loadedConfig.path, this.loadedConfig.warnings);
|
|
309
|
+
for (const warning of this.loadedConfig.warnings) {
|
|
310
|
+
this.store.recordError({
|
|
311
|
+
domain: 'config',
|
|
312
|
+
message: warning,
|
|
313
|
+
at: Date.now(),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.resources.addInterval(() => this.publish(), 60_000);
|
|
318
|
+
if (
|
|
319
|
+
this.loadedConfig.config.diagnostics.enabled &&
|
|
320
|
+
this.loadedConfig.config.diagnostics.runAutomatically &&
|
|
321
|
+
this.loadedConfig.config.diagnostics.command
|
|
322
|
+
) {
|
|
323
|
+
this.resources.addInterval(
|
|
324
|
+
() => this.launch(this.refresh(true), 'diagnostics'),
|
|
325
|
+
this.loadedConfig.config.diagnostics.intervalSeconds * 1_000,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
await this.refresh(false);
|
|
329
|
+
})(),
|
|
330
|
+
'config',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private launch(promise: Promise<unknown>, domain: Domain): void {
|
|
335
|
+
void promise.catch((error) => {
|
|
336
|
+
if (this.stopped) return;
|
|
337
|
+
this.recordError(domain, errorMessage(error));
|
|
338
|
+
this.publish();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private recordError(domain: Domain, message: string): void {
|
|
343
|
+
this.store.recordError({ domain, message, at: Date.now() });
|
|
344
|
+
if (domain === 'config') {
|
|
345
|
+
this.statusHealth.set(domain, {
|
|
346
|
+
id: domain,
|
|
347
|
+
severity: 'error',
|
|
348
|
+
summary: 'Galactica status configuration could not be loaded',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private updateRefreshHealth(
|
|
354
|
+
domain: Extract<Domain, 'openspec' | 'diagnostics' | 'orchestration'>,
|
|
355
|
+
outcomes: readonly RefreshOutcome<unknown>[],
|
|
356
|
+
retained: boolean,
|
|
357
|
+
): void {
|
|
358
|
+
if (!outcomes.some((outcome) => outcome.kind === 'error')) {
|
|
359
|
+
this.statusHealth.delete(domain);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.statusHealth.set(domain, {
|
|
363
|
+
id: domain,
|
|
364
|
+
severity: retained ? 'warning' : 'error',
|
|
365
|
+
summary: retained
|
|
366
|
+
? `${domain} refresh failed; previous status is retained`
|
|
367
|
+
: `${domain} status is unavailable`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private publishStatusHealth(): void {
|
|
372
|
+
const snapshot = this.store.get();
|
|
373
|
+
const conditions: StatusHealthCondition[] = [
|
|
374
|
+
...snapshot.configWarnings.map((warning) => ({
|
|
375
|
+
id: stableStatusId('config-warning', warning),
|
|
376
|
+
severity: 'warning' as const,
|
|
377
|
+
summary: 'A Galactica status configuration value was ignored',
|
|
378
|
+
})),
|
|
379
|
+
...this.statusHealth.values(),
|
|
380
|
+
].sort((left, right) => left.id.localeCompare(right.id));
|
|
381
|
+
const signature = JSON.stringify(conditions);
|
|
382
|
+
if (signature === this.lastStatusHealthSignature) return;
|
|
383
|
+
this.lastStatusHealthSignature = signature;
|
|
384
|
+
this.pi.events.emit(PI_STATUS_SOURCE_CHANNEL, {
|
|
385
|
+
protocol: 1,
|
|
386
|
+
type: 'snapshot',
|
|
387
|
+
source: 'galactica-status',
|
|
388
|
+
conditions,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async outcome<T>(
|
|
393
|
+
domain: Domain,
|
|
394
|
+
collect: () => Promise<T | undefined>,
|
|
395
|
+
): Promise<RefreshOutcome<T>> {
|
|
396
|
+
try {
|
|
397
|
+
const value = await collect();
|
|
398
|
+
return value === undefined ? { kind: 'absent' } : { kind: 'success', value };
|
|
399
|
+
} catch (error) {
|
|
400
|
+
const message = errorMessage(error);
|
|
401
|
+
this.recordError(domain, message);
|
|
402
|
+
return { kind: 'error', error: message };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async refresh(forceCommand: boolean): Promise<void> {
|
|
407
|
+
this.pendingForceCommand ||= forceCommand;
|
|
408
|
+
if (this.refreshPromise) {
|
|
409
|
+
this.refreshRequested = true;
|
|
410
|
+
return this.refreshPromise;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.refreshPromise = (async () => {
|
|
414
|
+
do {
|
|
415
|
+
const runCommand = this.pendingForceCommand;
|
|
416
|
+
this.pendingForceCommand = false;
|
|
417
|
+
this.refreshRequested = false;
|
|
418
|
+
await this.refreshOnce(runCommand);
|
|
419
|
+
} while (!this.stopped && (this.pendingForceCommand || this.refreshRequested));
|
|
420
|
+
})().finally(() => {
|
|
421
|
+
this.refreshPromise = undefined;
|
|
422
|
+
});
|
|
423
|
+
return this.refreshPromise;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private async refreshOnce(runCommand: boolean): Promise<void> {
|
|
427
|
+
const generation = ++this.generation;
|
|
428
|
+
if (!this.loadedConfig || this.stopped) return;
|
|
429
|
+
const config = this.loadedConfig.config;
|
|
430
|
+
const focus = this.focus;
|
|
431
|
+
const at = Date.now();
|
|
432
|
+
let projectRoot = await detectProjectRoot(this.ctx.cwd, this.runner);
|
|
433
|
+
|
|
434
|
+
const openSpecEnabled = config.enabled && config.openspec.enabled;
|
|
435
|
+
const hasOpenSpecProject =
|
|
436
|
+
openSpecEnabled && (await pathIsDirectory(join(projectRoot, 'openspec')));
|
|
437
|
+
let openSpecOverview: RefreshOutcome<OpenSpecOverview> = { kind: 'absent' };
|
|
438
|
+
let openspec: RefreshOutcome<OpenSpecState> = { kind: 'absent' };
|
|
439
|
+
|
|
440
|
+
if (hasOpenSpecProject) {
|
|
441
|
+
openSpecOverview = await this.outcome('openspec', () =>
|
|
442
|
+
collectOpenSpecOverview({ cwd: projectRoot, runner: this.runner }),
|
|
443
|
+
);
|
|
444
|
+
if (openSpecOverview.kind === 'success') {
|
|
445
|
+
projectRoot = openSpecOverview.value.projectRoot;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (focus.mode === 'none') {
|
|
450
|
+
const passiveChange = config.openspec.change ?? process.env.PI_OPENSPEC_CHANGE;
|
|
451
|
+
if (passiveChange) {
|
|
452
|
+
openspec = await this.outcome('openspec', () =>
|
|
453
|
+
collectOpenSpec({
|
|
454
|
+
cwd: projectRoot,
|
|
455
|
+
runner: this.runner,
|
|
456
|
+
configuredChange: passiveChange,
|
|
457
|
+
now: at,
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
if (openspec.kind === 'success') projectRoot = openspec.value.projectRoot;
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
openspec = await this.outcome('openspec', () =>
|
|
464
|
+
collectOpenSpec({
|
|
465
|
+
cwd: projectRoot,
|
|
466
|
+
runner: this.runner,
|
|
467
|
+
configuredChange: focus.change,
|
|
468
|
+
now: at,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
if (openspec.kind === 'success') projectRoot = openspec.value.projectRoot;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const [diagnostics, orchestration] = await Promise.all([
|
|
475
|
+
!config.enabled || !config.diagnostics.enabled
|
|
476
|
+
? Promise.resolve<RefreshOutcome<DiagnosticsState>>({ kind: 'absent' })
|
|
477
|
+
: this.outcome('diagnostics', async () => {
|
|
478
|
+
const result = await collectDiagnostics({
|
|
479
|
+
projectRoot,
|
|
480
|
+
config: config.diagnostics,
|
|
481
|
+
runner: this.runner,
|
|
482
|
+
runCommand,
|
|
483
|
+
now: at,
|
|
484
|
+
});
|
|
485
|
+
this.diagnosticsPath = result.sourcePath;
|
|
486
|
+
return result.state;
|
|
487
|
+
}),
|
|
488
|
+
!config.enabled || !config.orchestration.enabled
|
|
489
|
+
? Promise.resolve<RefreshOutcome<OrchestrationState>>({
|
|
490
|
+
kind: 'absent',
|
|
491
|
+
})
|
|
492
|
+
: this.outcome('orchestration', async () => {
|
|
493
|
+
const result = await collectOrchestration({
|
|
494
|
+
projectRoot,
|
|
495
|
+
config: config.orchestration,
|
|
496
|
+
now: at,
|
|
497
|
+
});
|
|
498
|
+
this.orchestrationPath = result.sourcePath;
|
|
499
|
+
return result.state;
|
|
500
|
+
}),
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
if (
|
|
504
|
+
this.stopped ||
|
|
505
|
+
generation !== this.generation ||
|
|
506
|
+
!sameOpenSpecFocus(focus, this.focus)
|
|
507
|
+
) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const previous = this.store.get();
|
|
511
|
+
this.updateRefreshHealth(
|
|
512
|
+
'openspec',
|
|
513
|
+
[openSpecOverview, openspec],
|
|
514
|
+
this.openSpecOverview !== null || previous.openspec !== null,
|
|
515
|
+
);
|
|
516
|
+
this.updateRefreshHealth(
|
|
517
|
+
'diagnostics',
|
|
518
|
+
[diagnostics],
|
|
519
|
+
previous.diagnostics !== null,
|
|
520
|
+
);
|
|
521
|
+
this.updateRefreshHealth(
|
|
522
|
+
'orchestration',
|
|
523
|
+
[orchestration],
|
|
524
|
+
previous.orchestration !== null,
|
|
525
|
+
);
|
|
526
|
+
this.openSpecProjectDetected = openSpecEnabled ? hasOpenSpecProject : undefined;
|
|
527
|
+
if (openSpecOverview.kind === 'success') {
|
|
528
|
+
this.openSpecOverview = openSpecOverview.value;
|
|
529
|
+
} else if (openSpecOverview.kind === 'absent') {
|
|
530
|
+
this.openSpecOverview = undefined;
|
|
531
|
+
}
|
|
532
|
+
this.openSpecFeedback =
|
|
533
|
+
!hasOpenSpecProject || focus.mode === 'task'
|
|
534
|
+
? undefined
|
|
535
|
+
: openSpecOverview.kind === 'error'
|
|
536
|
+
? 'unavailable'
|
|
537
|
+
: openSpecOverview.kind === 'success' &&
|
|
538
|
+
openSpecOverview.value.actionableChanges > 0
|
|
539
|
+
? 'no-focus'
|
|
540
|
+
: 'clear';
|
|
541
|
+
this.store.applyRefresh({
|
|
542
|
+
projectRoot,
|
|
543
|
+
at,
|
|
544
|
+
openspec,
|
|
545
|
+
diagnostics,
|
|
546
|
+
orchestration,
|
|
547
|
+
});
|
|
548
|
+
const refreshed = this.store.get();
|
|
549
|
+
const hierarchy = refreshed.openspec?.hierarchy;
|
|
550
|
+
const recordedAt = Date.now();
|
|
551
|
+
this.recordEvent({
|
|
552
|
+
type: 'progress/refresh',
|
|
553
|
+
at: recordedAt,
|
|
554
|
+
progress: hierarchy
|
|
555
|
+
? {
|
|
556
|
+
tasks: hierarchy.tasks,
|
|
557
|
+
steps: {
|
|
558
|
+
completed: Object.values(hierarchy.stepsByTask).reduce(
|
|
559
|
+
(sum, entry) => sum + entry.completed,
|
|
560
|
+
0,
|
|
561
|
+
),
|
|
562
|
+
total: Object.values(hierarchy.stepsByTask).reduce(
|
|
563
|
+
(sum, entry) => sum + entry.total,
|
|
564
|
+
0,
|
|
565
|
+
),
|
|
566
|
+
},
|
|
567
|
+
freshAt: recordedAt,
|
|
568
|
+
}
|
|
569
|
+
: null,
|
|
570
|
+
});
|
|
571
|
+
this.recordEvent({
|
|
572
|
+
type: 'diagnostics/refresh',
|
|
573
|
+
at: recordedAt,
|
|
574
|
+
state: refreshed.diagnostics,
|
|
575
|
+
});
|
|
576
|
+
this.recordEvent({
|
|
577
|
+
type: 'orchestration/refresh',
|
|
578
|
+
at: recordedAt,
|
|
579
|
+
state: refreshed.orchestration,
|
|
580
|
+
});
|
|
581
|
+
this.configureWatchers();
|
|
582
|
+
this.publish();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private configureWatchers(): void {
|
|
586
|
+
const snapshot = this.store.get();
|
|
587
|
+
const candidates = new Set<string>();
|
|
588
|
+
if (this.openSpecOverview) {
|
|
589
|
+
candidates.add(join(this.openSpecOverview.projectRoot, 'openspec', 'changes'));
|
|
590
|
+
}
|
|
591
|
+
if (snapshot.openspec) {
|
|
592
|
+
candidates.add(join(snapshot.openspec.projectRoot, 'openspec', 'changes'));
|
|
593
|
+
if (snapshot.openspec.tasksPath) {
|
|
594
|
+
candidates.add(dirname(snapshot.openspec.tasksPath));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (this.diagnosticsPath) candidates.add(dirname(this.diagnosticsPath));
|
|
598
|
+
if (this.orchestrationPath) candidates.add(dirname(this.orchestrationPath));
|
|
599
|
+
|
|
600
|
+
const paths = [...candidates].sort();
|
|
601
|
+
const signature = paths.join('\n');
|
|
602
|
+
if (signature === this.watchSignature) return;
|
|
603
|
+
this.clearWatchers();
|
|
604
|
+
this.watchSignature = signature;
|
|
605
|
+
|
|
606
|
+
const debounceResources = new ResourceBag();
|
|
607
|
+
this.watchCleanups.push(() => debounceResources.close());
|
|
608
|
+
const refreshDebounced = createDebouncer(
|
|
609
|
+
() => this.launch(this.refresh(false), 'watcher'),
|
|
610
|
+
250,
|
|
611
|
+
debounceResources,
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
for (const path of paths) {
|
|
615
|
+
try {
|
|
616
|
+
const watcher = watch(path, { persistent: false }, refreshDebounced);
|
|
617
|
+
this.watchCleanups.push(() => watcher.close());
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this.recordError('watcher', `${path}: ${errorMessage(error)}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private clearWatchers(): void {
|
|
625
|
+
for (const cleanup of this.watchCleanups.splice(0).reverse()) {
|
|
626
|
+
try {
|
|
627
|
+
cleanup();
|
|
628
|
+
} catch {
|
|
629
|
+
// Watchers may already be closed after an atomic file replacement.
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
this.watchSignature = '';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private projectedGoal(): GoalHeaderState | null {
|
|
636
|
+
return this.goalProjectionSuppressed ? null : this.goal;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private publish(): void {
|
|
640
|
+
if (this.stopped) return;
|
|
641
|
+
const snapshot = this.store.get();
|
|
642
|
+
const goal = this.projectedGoal();
|
|
643
|
+
// Builders read focus facts through the snapshot layer (recordEvent stores
|
|
644
|
+
// the same object references), so output stays byte-identical while the
|
|
645
|
+
// ledger becomes the feeding seam.
|
|
646
|
+
const focus = this.cockpitFacts.openSpec;
|
|
647
|
+
const work = this.cockpitFacts.work;
|
|
648
|
+
const subject = this.cockpitFacts.subject;
|
|
649
|
+
const title = this.currentTitleState(snapshot);
|
|
650
|
+
const focusedOpenSpec =
|
|
651
|
+
focus.mode === 'task' && snapshot.openspec?.changeId === focus.change
|
|
652
|
+
? snapshot.openspec
|
|
653
|
+
: null;
|
|
654
|
+
const visibleWorkFocus =
|
|
655
|
+
focus.mode === 'task' || goal || (work.state === 'clear' && this.openSpecFeedback)
|
|
656
|
+
? undefined
|
|
657
|
+
: work;
|
|
658
|
+
const widgets = buildWidgetSnapshots(
|
|
659
|
+
{ ...snapshot, openspec: focusedOpenSpec },
|
|
660
|
+
{
|
|
661
|
+
focusedTaskId: focus.mode === 'task' ? focus.taskId : undefined,
|
|
662
|
+
openSpecFeedback:
|
|
663
|
+
focus.mode === 'none' && !visibleWorkFocus
|
|
664
|
+
? this.openSpecFeedback
|
|
665
|
+
: undefined,
|
|
666
|
+
openSpecOverview: this.openSpecOverview,
|
|
667
|
+
openSpecProjectDetected: this.openSpecProjectDetected,
|
|
668
|
+
workFocus: visibleWorkFocus,
|
|
669
|
+
goal,
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
this.publisher.sync(widgets);
|
|
673
|
+
this.pi.events.emit(
|
|
674
|
+
GALACTICA_HEADER_CHANNEL,
|
|
675
|
+
buildHeaderStatusEvent(widgets, {
|
|
676
|
+
openSpec: focusedOpenSpec,
|
|
677
|
+
focusedTaskId: focus.mode === 'task' ? focus.taskId : undefined,
|
|
678
|
+
workFocus: visibleWorkFocus,
|
|
679
|
+
subject,
|
|
680
|
+
orchestration: snapshot.orchestration,
|
|
681
|
+
lifecycle: this.liveLifecycle,
|
|
682
|
+
activity: this.liveActivity,
|
|
683
|
+
taskflowPhase: this.liveTaskflowPhase,
|
|
684
|
+
goal,
|
|
685
|
+
openGoals: goal ? 0 : countOpenGoals(this.ctx),
|
|
686
|
+
goalAutomaticTurnLimit: this.goalAutomaticTurnLimit,
|
|
687
|
+
activeRuns: (() => {
|
|
688
|
+
const runs = this.runtimeSpans.snapshot();
|
|
689
|
+
return { children: runs.children, subagents: runs.subagents };
|
|
690
|
+
})(),
|
|
691
|
+
activeRunSpans: this.runtimeSpans.snapshot().spans ?? [],
|
|
692
|
+
idleMs: this.runtimeSpans.idleMs(),
|
|
693
|
+
title,
|
|
694
|
+
turn: this.turnCost.snapshot(),
|
|
695
|
+
diagnostics: snapshot.diagnostics,
|
|
696
|
+
}),
|
|
697
|
+
);
|
|
698
|
+
this.updateTitle(snapshot);
|
|
699
|
+
this.publishStatusHealth();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private currentTitleState(snapshot = this.store.get()): TitleState {
|
|
703
|
+
return buildTitleState({
|
|
704
|
+
subject: this.cockpitFacts.subject,
|
|
705
|
+
focus: this.cockpitFacts.openSpec,
|
|
706
|
+
openspec: snapshot.openspec,
|
|
707
|
+
activity: this.titleActivity.current(),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private updateTitle(snapshot = this.store.get()): void {
|
|
712
|
+
const facts = this.cockpitFacts;
|
|
713
|
+
const focusedTaskId =
|
|
714
|
+
facts.openSpec.mode === 'task' ? facts.openSpec.taskId : undefined;
|
|
715
|
+
const taskTitle =
|
|
716
|
+
facts.openSpec.mode === 'task' &&
|
|
717
|
+
snapshot.openspec?.changeId === facts.openSpec.change &&
|
|
718
|
+
!snapshot.openspec.meta.stale
|
|
719
|
+
? (snapshot.openspec.allTasks ?? snapshot.openspec.pendingTasks).find(
|
|
720
|
+
(task) => task.id === focusedTaskId,
|
|
721
|
+
)?.title
|
|
722
|
+
: undefined;
|
|
723
|
+
const title = formatActiveWorkTitle({
|
|
724
|
+
focus: facts.openSpec,
|
|
725
|
+
workFocus: facts.work,
|
|
726
|
+
subject: facts.subject,
|
|
727
|
+
goal: this.projectedGoal(),
|
|
728
|
+
...(taskTitle ? { taskTitle } : {}),
|
|
729
|
+
...(this.sessionName ? { sessionName: this.sessionName } : {}),
|
|
730
|
+
projectRoot: snapshot.projectRoot,
|
|
731
|
+
cwd: snapshot.cwd,
|
|
732
|
+
});
|
|
733
|
+
if (title === this.lastTitle) return;
|
|
734
|
+
this.lastTitle = title;
|
|
735
|
+
this.ctx.ui.setTitle(title);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private recordEvent(event: LedgerEvent): void {
|
|
739
|
+
this.cockpitFacts = applyEvent(this.cockpitFacts, event);
|
|
740
|
+
this.cockpitVersion += 1;
|
|
741
|
+
this.statusLedger.append(event);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
cockpitSnapshot(): CockpitSnapshot {
|
|
745
|
+
return snapshotFromFacts(this.cockpitFacts, this.cockpitVersion);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
currentFocus(): OpenSpecFocus {
|
|
749
|
+
return this.focus;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
currentWorkFocus(): SessionWorkFocus {
|
|
753
|
+
return this.workFocus;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
currentSubject(): SessionSubject {
|
|
757
|
+
return this.subject;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
currentGoal(): GoalHeaderState | null {
|
|
761
|
+
return this.goal;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
syncGoalFromSession(): void {
|
|
765
|
+
this.setGoal(
|
|
766
|
+
readGoalHeadline(this.ctx) ??
|
|
767
|
+
restoreGoalHeaderState(this.ctx.sessionManager.getBranch()),
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private ensureGoalPolling(): void {
|
|
772
|
+
if (!this.goal || this.goalPollCleanup || this.stopped) return;
|
|
773
|
+
const timer = setInterval(() => this.syncGoalFromSession(), 500);
|
|
774
|
+
timer.unref?.();
|
|
775
|
+
const cleanup = this.resources.add(() => clearInterval(timer));
|
|
776
|
+
this.goalPollCleanup = () => {
|
|
777
|
+
cleanup();
|
|
778
|
+
this.goalPollCleanup = undefined;
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private stopGoalPolling(): void {
|
|
783
|
+
this.goalPollCleanup?.();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
setGoal(goal: GoalHeaderState | null): void {
|
|
787
|
+
if (sameGoalHeaderState(this.goal, goal)) return;
|
|
788
|
+
this.goal = goal;
|
|
789
|
+
this.recordEvent({ type: 'focus/goal', at: Date.now(), goal });
|
|
790
|
+
this.goalProjectionSuppressed = false;
|
|
791
|
+
if (goal) {
|
|
792
|
+
this.ensureGoalPolling();
|
|
793
|
+
if (goal.status !== 'active' || goal.waiting) {
|
|
794
|
+
this.liveLifecycle = goalHeaderLifecycle(goal);
|
|
795
|
+
this.liveActivity = undefined;
|
|
796
|
+
} else if (['listening', 'waiting', 'blocked'].includes(this.liveLifecycle)) {
|
|
797
|
+
this.liveLifecycle = 'working';
|
|
798
|
+
this.liveActivity = undefined;
|
|
799
|
+
}
|
|
800
|
+
this.publish();
|
|
801
|
+
this.updateTitle();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.stopGoalPolling();
|
|
805
|
+
this.settle();
|
|
806
|
+
this.updateTitle();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
beginTurn(prompt: unknown): void {
|
|
810
|
+
this.directToolActivities.clear();
|
|
811
|
+
this.directToolFailed = false;
|
|
812
|
+
this.titleActivity.reset();
|
|
813
|
+
this.turnCost.begin();
|
|
814
|
+
this.turnCounter += 1;
|
|
815
|
+
this.currentTurnId = `turn-${this.turnCounter}`;
|
|
816
|
+
this.runtimeSpans.beginRoot(this.currentTurnId, 'understanding');
|
|
817
|
+
this.syncSpanEvents();
|
|
818
|
+
this.syncGoalFromSession();
|
|
819
|
+
const suppressStoppedGoal = Boolean(
|
|
820
|
+
this.goal && this.goal.status !== 'active' && !isGoalOwnedPrompt(prompt),
|
|
821
|
+
);
|
|
822
|
+
const projectionChanged = this.goalProjectionSuppressed !== suppressStoppedGoal;
|
|
823
|
+
this.goalProjectionSuppressed = suppressStoppedGoal;
|
|
824
|
+
if (projectionChanged) {
|
|
825
|
+
this.liveLifecycle = 'understanding';
|
|
826
|
+
this.liveActivity = { kind: 'understanding' };
|
|
827
|
+
this.publish();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.setLiveProjection('understanding', { kind: 'understanding' });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
setFocus(focus: OpenSpecFocus): void {
|
|
834
|
+
if (sameOpenSpecFocus(this.focus, focus)) return;
|
|
835
|
+
this.focus = focus;
|
|
836
|
+
this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
|
|
837
|
+
this.store.clearOpenSpec();
|
|
838
|
+
this.publish();
|
|
839
|
+
this.launch(this.refresh(false), 'openspec');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
setWorkFocus(focus: SessionWorkFocus): void {
|
|
843
|
+
if (sameSessionWorkFocus(this.workFocus, focus)) return;
|
|
844
|
+
this.workFocus = focus;
|
|
845
|
+
this.recordEvent({ type: 'focus/work', at: Date.now(), work: focus });
|
|
846
|
+
if (focus.state === 'validation') {
|
|
847
|
+
this.liveLifecycle = 'waiting';
|
|
848
|
+
this.liveActivity = { kind: 'awaiting-validation' };
|
|
849
|
+
} else if (focus.state === 'clear' && this.focus.mode === 'none') {
|
|
850
|
+
this.liveLifecycle = 'listening';
|
|
851
|
+
this.liveActivity = undefined;
|
|
852
|
+
}
|
|
853
|
+
this.publish();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
setSubject(subject: SessionSubject): void {
|
|
857
|
+
if (sameSessionSubject(this.subject, subject)) return;
|
|
858
|
+
this.subject = subject;
|
|
859
|
+
this.recordEvent({ type: 'focus/subject', at: Date.now(), subject });
|
|
860
|
+
this.publish();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
declareStage(stage: StageValue): boolean {
|
|
864
|
+
if (!this.currentTurnId) return false;
|
|
865
|
+
const changed = this.runtimeSpans.setStage(this.currentTurnId, stage, true);
|
|
866
|
+
this.recordEvent({
|
|
867
|
+
type: 'run/stage',
|
|
868
|
+
at: Date.now(),
|
|
869
|
+
id: this.currentTurnId,
|
|
870
|
+
stage,
|
|
871
|
+
declared: true,
|
|
872
|
+
});
|
|
873
|
+
this.setLiveProjection(stage);
|
|
874
|
+
return changed;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
setLiveProjection(lifecycle: HeaderLifecycle, activity?: HeaderActivity): void {
|
|
878
|
+
if (
|
|
879
|
+
this.liveLifecycle === lifecycle &&
|
|
880
|
+
this.liveActivity?.kind === activity?.kind &&
|
|
881
|
+
this.liveActivity?.current === activity?.current &&
|
|
882
|
+
this.liveActivity?.total === activity?.total
|
|
883
|
+
) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
this.liveLifecycle = lifecycle;
|
|
887
|
+
this.liveActivity = activity;
|
|
888
|
+
this.publish();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
setLiveActivity(activity: HeaderActivity | undefined): void {
|
|
892
|
+
this.setLiveProjection(
|
|
893
|
+
headerLifecycleForActivity(activity) ?? this.liveLifecycle,
|
|
894
|
+
activity,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
beginDirectTool(toolCallId: string, activity: HeaderActivity | undefined): void {
|
|
899
|
+
if (!activity) return;
|
|
900
|
+
this.directToolActivities.set(toolCallId, activity);
|
|
901
|
+
this.setLiveActivity(activity);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
finishDirectTool(toolCallId: string, isError: boolean): void {
|
|
905
|
+
const completed = this.directToolActivities.get(toolCallId);
|
|
906
|
+
if (!completed) return;
|
|
907
|
+
this.directToolActivities.delete(toolCallId);
|
|
908
|
+
this.directToolFailed ||= isError;
|
|
909
|
+
|
|
910
|
+
const stillRunning = [...this.directToolActivities.values()].at(-1);
|
|
911
|
+
if (stillRunning) {
|
|
912
|
+
this.setLiveActivity(stillRunning);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const completion = directToolCompletionHeaderActivity(
|
|
917
|
+
completed,
|
|
918
|
+
this.directToolFailed,
|
|
919
|
+
);
|
|
920
|
+
this.directToolFailed = false;
|
|
921
|
+
this.setLiveActivity(completion);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
beginRuntimeRun(toolCallId: string, toolName: string): void {
|
|
925
|
+
const kind = toolName === 'bash' ? 'bash' : 'subagent';
|
|
926
|
+
if (!this.runtimeSpans.begin(toolCallId, kind, this.currentTurnId)) return;
|
|
927
|
+
this.syncSpanEvents();
|
|
928
|
+
this.publish();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Feed the title-state activity slot from a tool-execution start. */
|
|
932
|
+
beginTitleActivity(toolCallId: string, toolName: string, args: unknown): void {
|
|
933
|
+
if (!this.titleActivity.begin(toolCallId, toolName, args, this.currentTurnId)) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
this.publish();
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Accumulate one provider response's usage into the per-turn cost meter. */
|
|
940
|
+
recordTurnUsage(message: unknown): void {
|
|
941
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) return;
|
|
942
|
+
const candidate = message as { role?: unknown; usage?: unknown };
|
|
943
|
+
if (candidate.role !== 'assistant') return;
|
|
944
|
+
if (this.turnCost.addUsage(candidate.usage)) this.publish();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Feed the title-state activity slot from a tool-execution update. */
|
|
948
|
+
updateTitleActivity(
|
|
949
|
+
toolCallId: string,
|
|
950
|
+
toolName: string,
|
|
951
|
+
args: unknown,
|
|
952
|
+
partialResult: unknown,
|
|
953
|
+
): void {
|
|
954
|
+
if (
|
|
955
|
+
!this.titleActivity.update(
|
|
956
|
+
toolCallId,
|
|
957
|
+
toolName,
|
|
958
|
+
args,
|
|
959
|
+
partialResult,
|
|
960
|
+
this.currentTurnId,
|
|
961
|
+
)
|
|
962
|
+
) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
this.publish();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private syncSpanEvents(): void {
|
|
969
|
+
const changes = this.runtimeSpans.drainChanges();
|
|
970
|
+
const at = Date.now();
|
|
971
|
+
for (const started of changes.started) {
|
|
972
|
+
this.recordEvent({
|
|
973
|
+
type: 'run/start',
|
|
974
|
+
at,
|
|
975
|
+
span: {
|
|
976
|
+
id: started.id,
|
|
977
|
+
kind: started.kind,
|
|
978
|
+
parent: started.parent,
|
|
979
|
+
...(started.agent ? { agent: started.agent } : {}),
|
|
980
|
+
...(started.label ? { label: started.label } : {}),
|
|
981
|
+
stage: started.stage,
|
|
982
|
+
stageDeclared: false,
|
|
983
|
+
since: at,
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
for (const ended of changes.ended) {
|
|
988
|
+
this.recordEvent({ type: 'run/end', at, id: ended });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
updateRuntimeRun(toolCallId: string, partialResult: unknown, args?: unknown): void {
|
|
993
|
+
if (!this.runtimeSpans.update(toolCallId, partialResult, args)) return;
|
|
994
|
+
this.syncSpanEvents();
|
|
995
|
+
this.publish();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
finishRuntimeRun(toolCallId: string): void {
|
|
999
|
+
if (!this.runtimeSpans.finish(toolCallId)) return;
|
|
1000
|
+
this.syncSpanEvents();
|
|
1001
|
+
this.publish();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
setTaskflowPhase(phase: string | undefined): void {
|
|
1005
|
+
if (this.liveTaskflowPhase === phase) return;
|
|
1006
|
+
this.liveTaskflowPhase = phase;
|
|
1007
|
+
if (phase) {
|
|
1008
|
+
this.recordEvent({
|
|
1009
|
+
type: 'taskflow/phase',
|
|
1010
|
+
at: Date.now(),
|
|
1011
|
+
phase: boundedPhase(phase),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
if (phase) {
|
|
1015
|
+
this.liveLifecycle = taskflowPhaseHeaderLifecycle(phase);
|
|
1016
|
+
this.liveActivity = undefined;
|
|
1017
|
+
}
|
|
1018
|
+
this.publish();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
markAborted(): void {
|
|
1022
|
+
this.setLiveProjection('aborted', { kind: 'operation-aborted' });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
markAnswering(): void {
|
|
1026
|
+
if (this.liveLifecycle !== 'aborted') {
|
|
1027
|
+
this.setLiveProjection('answering');
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
settle(): void {
|
|
1032
|
+
this.directToolActivities.clear();
|
|
1033
|
+
this.directToolFailed = false;
|
|
1034
|
+
this.titleActivity.reset();
|
|
1035
|
+
this.turnCost.settle();
|
|
1036
|
+
if (this.currentTurnId) {
|
|
1037
|
+
this.runtimeSpans.endRoot(this.currentTurnId);
|
|
1038
|
+
this.currentTurnId = null;
|
|
1039
|
+
this.syncSpanEvents();
|
|
1040
|
+
}
|
|
1041
|
+
if (this.liveLifecycle === 'aborted') {
|
|
1042
|
+
this.publish();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const goal = this.projectedGoal();
|
|
1046
|
+
if (goal) {
|
|
1047
|
+
if (goal.status === 'active' && !goal.waiting) {
|
|
1048
|
+
this.setLiveProjection('working');
|
|
1049
|
+
} else {
|
|
1050
|
+
this.setLiveProjection(goalHeaderLifecycle(goal));
|
|
1051
|
+
}
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (this.focus.mode === 'task' || this.workFocus.state === 'validation') {
|
|
1055
|
+
this.setLiveProjection('waiting', { kind: 'awaiting-validation' });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (this.workFocus.state === 'active') {
|
|
1059
|
+
this.setLiveProjection('waiting', { kind: 'awaiting-input' });
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
this.setLiveProjection('listening');
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
setSessionName(name: string | undefined): void {
|
|
1066
|
+
this.sessionName = name;
|
|
1067
|
+
this.updateTitle();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async forceRefresh(): Promise<void> {
|
|
1071
|
+
this.loadedConfig = await loadConfig();
|
|
1072
|
+
this.store.setConfig(this.loadedConfig.path, this.loadedConfig.warnings);
|
|
1073
|
+
await this.refresh(true);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
summary(): string {
|
|
1077
|
+
const snapshot = this.store.get();
|
|
1078
|
+
const lines = [`root: ${snapshot.projectRoot}`];
|
|
1079
|
+
lines.push(
|
|
1080
|
+
this.openSpecOverview
|
|
1081
|
+
? `OpenSpec portfolio: ${formatOpenSpecOverview(this.openSpecOverview)}`
|
|
1082
|
+
: 'OpenSpec portfolio: hidden (no project)',
|
|
1083
|
+
);
|
|
1084
|
+
lines.push(
|
|
1085
|
+
snapshot.openspec
|
|
1086
|
+
? `OpenSpec focus: ${formatOpenSpec(
|
|
1087
|
+
snapshot.openspec,
|
|
1088
|
+
this.focus.mode === 'task' ? this.focus.taskId : null,
|
|
1089
|
+
)}`
|
|
1090
|
+
: this.openSpecFeedback
|
|
1091
|
+
? `OpenSpec focus: ${this.openSpecFeedback}`
|
|
1092
|
+
: 'OpenSpec focus: none',
|
|
1093
|
+
);
|
|
1094
|
+
lines.push(
|
|
1095
|
+
this.workFocus.state === 'clear'
|
|
1096
|
+
? 'Work: clear'
|
|
1097
|
+
: `Work: ${this.workFocus.state} · ${this.workFocus.intent}`,
|
|
1098
|
+
);
|
|
1099
|
+
lines.push(
|
|
1100
|
+
this.goal
|
|
1101
|
+
? `Goal: ${this.goal.status} · automatic ${this.goal.automaticModelTurns}/${this.goalAutomaticTurnLimit ?? '?'}`
|
|
1102
|
+
: 'Goal: none',
|
|
1103
|
+
);
|
|
1104
|
+
lines.push(
|
|
1105
|
+
snapshot.diagnostics
|
|
1106
|
+
? `Diagnostics: ${formatDiagnostics(snapshot.diagnostics)}`
|
|
1107
|
+
: 'Diagnostics: hidden (no source)',
|
|
1108
|
+
);
|
|
1109
|
+
lines.push(
|
|
1110
|
+
snapshot.orchestration
|
|
1111
|
+
? `Orchestration: ${formatOrchestration(snapshot.orchestration)}`
|
|
1112
|
+
: 'Orchestration: hidden (no state file)',
|
|
1113
|
+
);
|
|
1114
|
+
return lines.join('\n');
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
debug(): string {
|
|
1118
|
+
const snapshot = this.store.get();
|
|
1119
|
+
return `${JSON.stringify(
|
|
1120
|
+
{
|
|
1121
|
+
projectRoot: snapshot.projectRoot,
|
|
1122
|
+
configPath: snapshot.configPath,
|
|
1123
|
+
configWarnings: snapshot.configWarnings,
|
|
1124
|
+
focus: this.focus,
|
|
1125
|
+
workFocus: this.workFocus,
|
|
1126
|
+
goal: this.goal,
|
|
1127
|
+
goalProjected: this.projectedGoal(),
|
|
1128
|
+
goalAutomaticTurnLimit: this.goalAutomaticTurnLimit ?? null,
|
|
1129
|
+
openSpecFeedback: this.openSpecFeedback ?? null,
|
|
1130
|
+
openSpecOverview: this.openSpecOverview ?? null,
|
|
1131
|
+
detected: {
|
|
1132
|
+
openspecChange: snapshot.openspec?.changeId ?? null,
|
|
1133
|
+
openspecTasksPath: snapshot.openspec?.tasksPath ?? null,
|
|
1134
|
+
diagnosticsSource:
|
|
1135
|
+
snapshot.diagnostics?.meta.source ?? this.diagnosticsPath ?? null,
|
|
1136
|
+
orchestrationStateFile:
|
|
1137
|
+
snapshot.orchestration?.meta.source ?? this.orchestrationPath ?? null,
|
|
1138
|
+
},
|
|
1139
|
+
refresh: {
|
|
1140
|
+
all: snapshot.lastRefreshAt ?? null,
|
|
1141
|
+
openspec: snapshot.openspec?.meta ?? null,
|
|
1142
|
+
diagnostics: snapshot.diagnostics?.meta ?? null,
|
|
1143
|
+
orchestration: snapshot.orchestration?.meta ?? null,
|
|
1144
|
+
},
|
|
1145
|
+
errors: snapshot.errors,
|
|
1146
|
+
widgets: this.publisher.snapshots(),
|
|
1147
|
+
},
|
|
1148
|
+
null,
|
|
1149
|
+
2,
|
|
1150
|
+
)}\n${titleStateDebugLine(this.currentTitleState(snapshot))}`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
stop(): void {
|
|
1154
|
+
if (this.stopped) return;
|
|
1155
|
+
this.stopped = true;
|
|
1156
|
+
this.generation += 1;
|
|
1157
|
+
this.clearWatchers();
|
|
1158
|
+
this.resources.close();
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export default function galacticaStatus(pi: ExtensionAPI): void {
|
|
1163
|
+
const publisher = new FancyFooterPublisher((message) => {
|
|
1164
|
+
pi.events.emit(FANCY_FOOTER_WIDGET_CHANNEL, message);
|
|
1165
|
+
});
|
|
1166
|
+
let runtime: GalacticaStatusRuntime | undefined;
|
|
1167
|
+
|
|
1168
|
+
const stopReady = pi.events.on(FANCY_FOOTER_READY_CHANNEL, (message: unknown) => {
|
|
1169
|
+
if (
|
|
1170
|
+
typeof message === 'object' &&
|
|
1171
|
+
message !== null &&
|
|
1172
|
+
'protocol' in message &&
|
|
1173
|
+
message.protocol === 1
|
|
1174
|
+
) {
|
|
1175
|
+
publisher.republish();
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
function persistAndApplyWorkFocus(focus: SessionWorkFocus): void {
|
|
1180
|
+
pi.appendEntry(SESSION_WORK_FOCUS_ENTRY_TYPE, focus);
|
|
1181
|
+
runtime?.setWorkFocus(focus);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function persistAndApplySubject(subject: SessionSubject): void {
|
|
1185
|
+
pi.appendEntry(SESSION_SUBJECT_ENTRY_TYPE, subject);
|
|
1186
|
+
runtime?.setSubject(subject);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function persistAndApplyFocus(focus: OpenSpecFocus): void {
|
|
1190
|
+
if (
|
|
1191
|
+
focus.mode === 'task' &&
|
|
1192
|
+
(runtime?.currentWorkFocus() ?? NO_SESSION_WORK_FOCUS).state !== 'clear'
|
|
1193
|
+
) {
|
|
1194
|
+
persistAndApplyWorkFocus(NO_SESSION_WORK_FOCUS);
|
|
1195
|
+
}
|
|
1196
|
+
pi.appendEntry(OPEN_SPEC_FOCUS_ENTRY_TYPE, focus);
|
|
1197
|
+
runtime?.setFocus(focus);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function workStatus(focus: SessionWorkFocus): string {
|
|
1201
|
+
return focus.state === 'clear'
|
|
1202
|
+
? 'Work focus: clear'
|
|
1203
|
+
: `Work focus: ${focus.state} · ${focus.intent}`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function subjectStatus(subject: SessionSubject): string {
|
|
1207
|
+
return subject.state === 'clear'
|
|
1208
|
+
? 'Session subject: clear'
|
|
1209
|
+
: `Session subject: ${subject.title}`;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function applySubjectAction(
|
|
1213
|
+
action: 'status' | 'set',
|
|
1214
|
+
title?: string,
|
|
1215
|
+
): { ok: boolean; message: string; subject: SessionSubject } {
|
|
1216
|
+
const current = runtime?.currentSubject() ?? NO_SESSION_SUBJECT;
|
|
1217
|
+
|
|
1218
|
+
if (action === 'status') {
|
|
1219
|
+
return { ok: true, message: subjectStatus(current), subject: current };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const next = sessionSubject('set', title);
|
|
1223
|
+
if (!next || next.state !== 'set') {
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
message: 'Session subject requires a non-empty title',
|
|
1227
|
+
subject: current,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
if (!sameSessionSubject(current, next)) persistAndApplySubject(next);
|
|
1231
|
+
return { ok: true, message: subjectStatus(next), subject: next };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function applyWorkAction(
|
|
1235
|
+
action: 'status' | 'set' | 'validate' | 'clear',
|
|
1236
|
+
intent?: string,
|
|
1237
|
+
): { ok: boolean; message: string; focus: SessionWorkFocus } {
|
|
1238
|
+
const current = runtime?.currentWorkFocus() ?? NO_SESSION_WORK_FOCUS;
|
|
1239
|
+
const openSpec = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
|
|
1240
|
+
|
|
1241
|
+
if (action === 'status') {
|
|
1242
|
+
return { ok: true, message: workStatus(current), focus: current };
|
|
1243
|
+
}
|
|
1244
|
+
if (action === 'clear') {
|
|
1245
|
+
if (current.state !== 'clear') persistAndApplyWorkFocus(NO_SESSION_WORK_FOCUS);
|
|
1246
|
+
return {
|
|
1247
|
+
ok: true,
|
|
1248
|
+
message: 'Work focus cleared',
|
|
1249
|
+
focus: NO_SESSION_WORK_FOCUS,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
if (openSpec.mode === 'task') {
|
|
1253
|
+
return {
|
|
1254
|
+
ok: false,
|
|
1255
|
+
message:
|
|
1256
|
+
`OpenSpec focus is ${openSpec.change} › ${openSpec.taskId}; ` +
|
|
1257
|
+
'clear it before setting ephemeral work',
|
|
1258
|
+
focus: current,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
if (action === 'validate') {
|
|
1262
|
+
if (current.state === 'clear') {
|
|
1263
|
+
return {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
message: 'No active Work focus to validate',
|
|
1266
|
+
focus: current,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
const validation = sessionWorkFocus('validation', current.intent);
|
|
1270
|
+
if (!validation) {
|
|
1271
|
+
return { ok: false, message: 'Invalid Work focus', focus: current };
|
|
1272
|
+
}
|
|
1273
|
+
if (!sameSessionWorkFocus(current, validation)) {
|
|
1274
|
+
persistAndApplyWorkFocus(validation);
|
|
1275
|
+
}
|
|
1276
|
+
return { ok: true, message: workStatus(validation), focus: validation };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const active = sessionWorkFocus('active', intent);
|
|
1280
|
+
if (!active) {
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
message: 'Work focus requires a non-empty intent',
|
|
1284
|
+
focus: current,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
if (!sameSessionWorkFocus(current, active)) persistAndApplyWorkFocus(active);
|
|
1288
|
+
return { ok: true, message: workStatus(active), focus: active };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function applyOpenSpecFocusAction(
|
|
1292
|
+
action: OpenSpecFocusAction,
|
|
1293
|
+
change?: string,
|
|
1294
|
+
taskId?: string,
|
|
1295
|
+
): { ok: boolean; message: string; focus: OpenSpecFocus } {
|
|
1296
|
+
const current = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
|
|
1297
|
+
|
|
1298
|
+
if (action === 'status') {
|
|
1299
|
+
return {
|
|
1300
|
+
ok: true,
|
|
1301
|
+
message:
|
|
1302
|
+
current.mode === 'task'
|
|
1303
|
+
? `OpenSpec focus: ${current.change} › ${current.taskId}`
|
|
1304
|
+
: 'OpenSpec focus: none',
|
|
1305
|
+
focus: current,
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
if (action === 'clear') {
|
|
1309
|
+
persistAndApplyFocus(NO_OPEN_SPEC_FOCUS);
|
|
1310
|
+
return {
|
|
1311
|
+
ok: true,
|
|
1312
|
+
message: 'OpenSpec focus cleared',
|
|
1313
|
+
focus: NO_OPEN_SPEC_FOCUS,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const requested = taskFocus(change, taskId);
|
|
1318
|
+
if (!requested) {
|
|
1319
|
+
return {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
message: 'OpenSpec focus requires non-empty change and task identifiers',
|
|
1322
|
+
focus: current,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
if (
|
|
1326
|
+
action === 'set' &&
|
|
1327
|
+
current.mode === 'task' &&
|
|
1328
|
+
!sameOpenSpecFocus(current, requested)
|
|
1329
|
+
) {
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
message:
|
|
1333
|
+
`OpenSpec focus is ${current.change} › ${current.taskId}; ` +
|
|
1334
|
+
'use replace to change it deliberately',
|
|
1335
|
+
focus: current,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
if (!sameOpenSpecFocus(current, requested)) persistAndApplyFocus(requested);
|
|
1339
|
+
return {
|
|
1340
|
+
ok: true,
|
|
1341
|
+
message: `OpenSpec focus: ${requested.change} › ${requested.taskId}`,
|
|
1342
|
+
focus: requested,
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
pi.on('session_start', (_event, ctx) => {
|
|
1347
|
+
runtime?.stop();
|
|
1348
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1349
|
+
const focus = restoreOpenSpecFocus(branch);
|
|
1350
|
+
const workFocus = restoreSessionWorkFocus(branch);
|
|
1351
|
+
const subject = restoreSessionSubject(branch);
|
|
1352
|
+
const goal = readGoalHeadline(ctx) ?? restoreGoalHeaderState(branch);
|
|
1353
|
+
const sessionName = pi.getSessionName() ?? ctx.sessionManager.getSessionName();
|
|
1354
|
+
runtime = new GalacticaStatusRuntime(
|
|
1355
|
+
pi,
|
|
1356
|
+
ctx,
|
|
1357
|
+
publisher,
|
|
1358
|
+
focus,
|
|
1359
|
+
workFocus,
|
|
1360
|
+
subject,
|
|
1361
|
+
goal,
|
|
1362
|
+
sessionName,
|
|
1363
|
+
);
|
|
1364
|
+
runtime.start();
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
pi.on('session_info_changed', (event) => {
|
|
1368
|
+
runtime?.setSessionName(event.name);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
pi.on('session_tree', (_event, ctx) => {
|
|
1372
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1373
|
+
runtime?.setFocus(restoreOpenSpecFocus(branch));
|
|
1374
|
+
runtime?.setWorkFocus(restoreSessionWorkFocus(branch));
|
|
1375
|
+
runtime?.setSubject(restoreSessionSubject(branch));
|
|
1376
|
+
runtime?.setGoal(readGoalHeadline(ctx) ?? restoreGoalHeaderState(branch));
|
|
1377
|
+
runtime?.settle();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
pi.on('before_agent_start', (event) => {
|
|
1381
|
+
runtime?.beginTurn(event.prompt);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
pi.on('tool_execution_start', (event) => {
|
|
1385
|
+
runtime?.beginRuntimeRun(event.toolCallId, event.toolName);
|
|
1386
|
+
runtime?.beginTitleActivity(event.toolCallId, event.toolName, event.args);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
pi.on('tool_call', (event) => {
|
|
1390
|
+
if (event.toolName !== 'taskflow') {
|
|
1391
|
+
runtime?.beginDirectTool(
|
|
1392
|
+
event.toolCallId,
|
|
1393
|
+
directToolHeaderActivity(event.toolName, event.input),
|
|
1394
|
+
);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const requested = taskflowRunFocus(event.input);
|
|
1398
|
+
|
|
1399
|
+
if (requested) {
|
|
1400
|
+
const current = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
|
|
1401
|
+
if (current.mode === 'task' && !sameOpenSpecFocus(current, requested)) {
|
|
1402
|
+
return {
|
|
1403
|
+
block: true,
|
|
1404
|
+
reason:
|
|
1405
|
+
`OpenSpec focus is ${current.change} › ${current.taskId}; ` +
|
|
1406
|
+
'use /openspec-focus clear or /openspec-focus replace <change> <task> before starting a conflicting run',
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
if (!sameOpenSpecFocus(current, requested)) {
|
|
1410
|
+
persistAndApplyFocus(requested);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const initialPhase = taskflowInitialHeaderPhase(event.input);
|
|
1415
|
+
if (initialPhase) {
|
|
1416
|
+
runtime?.setTaskflowPhase(initialPhase);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
runtime?.setTaskflowPhase(undefined);
|
|
1421
|
+
const activity = taskflowCallHeaderActivity(event.input);
|
|
1422
|
+
runtime?.setLiveProjection(
|
|
1423
|
+
headerLifecycleForActivity(activity) ?? 'working',
|
|
1424
|
+
activity,
|
|
1425
|
+
);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
pi.on('tool_execution_update', (event) => {
|
|
1429
|
+
runtime?.updateRuntimeRun(event.toolCallId, event.partialResult, event.args);
|
|
1430
|
+
runtime?.updateTitleActivity(
|
|
1431
|
+
event.toolCallId,
|
|
1432
|
+
event.toolName,
|
|
1433
|
+
event.args,
|
|
1434
|
+
event.partialResult,
|
|
1435
|
+
);
|
|
1436
|
+
if (event.toolName !== 'taskflow') return;
|
|
1437
|
+
const phase = taskflowUpdateHeaderPhase(event.partialResult);
|
|
1438
|
+
if (phase) runtime?.setTaskflowPhase(phase);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
pi.on('tool_execution_end', (event) => {
|
|
1442
|
+
runtime?.finishRuntimeRun(event.toolCallId);
|
|
1443
|
+
queueMicrotask(() => runtime?.syncGoalFromSession());
|
|
1444
|
+
if (event.toolName !== 'taskflow') {
|
|
1445
|
+
runtime?.finishDirectTool(event.toolCallId, event.isError);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
runtime?.setTaskflowPhase(undefined);
|
|
1449
|
+
runtime?.setLiveActivity(undefined);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
pi.on('message_update', (event) => {
|
|
1453
|
+
if (assistantHasVisibleText(event.message)) runtime?.markAnswering();
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
pi.on('message_end', (event) => {
|
|
1457
|
+
runtime?.recordTurnUsage(event.message);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
pi.on('turn_end', (event) => {
|
|
1461
|
+
if (assistantStopReason(event.message) === 'aborted') runtime?.markAborted();
|
|
1462
|
+
queueMicrotask(() => runtime?.syncGoalFromSession());
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
pi.on('agent_end', (event) => {
|
|
1466
|
+
if (event.messages.some((message) => assistantStopReason(message) === 'aborted')) {
|
|
1467
|
+
runtime?.markAborted();
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
pi.on('agent_settled', () => {
|
|
1472
|
+
queueMicrotask(() => {
|
|
1473
|
+
runtime?.syncGoalFromSession();
|
|
1474
|
+
runtime?.settle();
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
pi.on('session_shutdown', () => {
|
|
1479
|
+
runtime?.stop();
|
|
1480
|
+
runtime = undefined;
|
|
1481
|
+
publisher.removeAll();
|
|
1482
|
+
stopReady();
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
pi.registerTool({
|
|
1486
|
+
name: 'work_focus',
|
|
1487
|
+
label: 'Work Focus',
|
|
1488
|
+
description:
|
|
1489
|
+
'Show, set, mark for validation, or clear explicit ephemeral work metadata for this Pi session',
|
|
1490
|
+
promptSnippet: 'Manage explicit ephemeral Session Work focus metadata',
|
|
1491
|
+
parameters: Type.Object({
|
|
1492
|
+
action: StringEnum(['status', 'set', 'validate', 'clear'] as const),
|
|
1493
|
+
intent: Type.Optional(
|
|
1494
|
+
Type.String({
|
|
1495
|
+
description: 'Required only for set: the concise current intent',
|
|
1496
|
+
}),
|
|
1497
|
+
),
|
|
1498
|
+
}),
|
|
1499
|
+
async execute(_toolCallId, params) {
|
|
1500
|
+
const result = applyWorkAction(params.action, params.intent);
|
|
1501
|
+
return {
|
|
1502
|
+
content: [
|
|
1503
|
+
{
|
|
1504
|
+
type: 'text',
|
|
1505
|
+
text: result.ok ? result.message : `Error: ${result.message}`,
|
|
1506
|
+
},
|
|
1507
|
+
],
|
|
1508
|
+
details: {
|
|
1509
|
+
ok: result.ok,
|
|
1510
|
+
focus: result.focus,
|
|
1511
|
+
},
|
|
1512
|
+
};
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
pi.registerTool({
|
|
1517
|
+
name: 'stage',
|
|
1518
|
+
label: 'Stage',
|
|
1519
|
+
description:
|
|
1520
|
+
'Declare the current stage of the Ramona loop (status truth; one call per transition)',
|
|
1521
|
+
promptSnippet: 'Declare the current loop stage for status truth',
|
|
1522
|
+
parameters: Type.Object({
|
|
1523
|
+
stage: StringEnum([
|
|
1524
|
+
'understanding',
|
|
1525
|
+
'working',
|
|
1526
|
+
'assuring',
|
|
1527
|
+
'learning',
|
|
1528
|
+
'answering',
|
|
1529
|
+
'waiting',
|
|
1530
|
+
'blocked',
|
|
1531
|
+
'listening',
|
|
1532
|
+
'aborted',
|
|
1533
|
+
] as const),
|
|
1534
|
+
}),
|
|
1535
|
+
async execute(_toolCallId, params) {
|
|
1536
|
+
const declared = runtime?.declareStage(params.stage) ?? false;
|
|
1537
|
+
return {
|
|
1538
|
+
content: [
|
|
1539
|
+
{
|
|
1540
|
+
type: 'text' as const,
|
|
1541
|
+
text: declared
|
|
1542
|
+
? `Stage declared: ${params.stage}`
|
|
1543
|
+
: `Stage recorded: ${params.stage} (no active turn span)`,
|
|
1544
|
+
},
|
|
1545
|
+
],
|
|
1546
|
+
details: { kind: 'stage-declaration', stage: params.stage },
|
|
1547
|
+
};
|
|
1548
|
+
},
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
pi.registerTool({
|
|
1552
|
+
name: 'subject',
|
|
1553
|
+
label: 'Session Subject',
|
|
1554
|
+
description:
|
|
1555
|
+
'Show or set the sanitized session subject that keeps the focus row populated beneath OpenSpec, Goal, and Session Work',
|
|
1556
|
+
promptSnippet: 'Manage the automatic session subject title',
|
|
1557
|
+
promptGuidelines: [
|
|
1558
|
+
'Author a short sanitized subject (at most 120 characters, never raw prompt text) at the first conversational turn; revise it only when the topic clearly shifts. Never require Human action for it.',
|
|
1559
|
+
],
|
|
1560
|
+
parameters: Type.Object({
|
|
1561
|
+
action: StringEnum(['status', 'set'] as const),
|
|
1562
|
+
title: Type.Optional(
|
|
1563
|
+
Type.String({
|
|
1564
|
+
description: 'Required only for set: the short sanitized subject title',
|
|
1565
|
+
}),
|
|
1566
|
+
),
|
|
1567
|
+
}),
|
|
1568
|
+
async execute(_toolCallId, params) {
|
|
1569
|
+
const result = applySubjectAction(params.action, params.title);
|
|
1570
|
+
return {
|
|
1571
|
+
content: [
|
|
1572
|
+
{
|
|
1573
|
+
type: 'text',
|
|
1574
|
+
text: result.ok ? result.message : `Error: ${result.message}`,
|
|
1575
|
+
},
|
|
1576
|
+
],
|
|
1577
|
+
details: {
|
|
1578
|
+
ok: result.ok,
|
|
1579
|
+
subject: result.subject,
|
|
1580
|
+
},
|
|
1581
|
+
};
|
|
1582
|
+
},
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
pi.registerTool({
|
|
1586
|
+
name: 'openspec_focus',
|
|
1587
|
+
label: 'OpenSpec Focus',
|
|
1588
|
+
description:
|
|
1589
|
+
'Show, safely set, deliberately replace, or clear branch-local OpenSpec Task focus',
|
|
1590
|
+
promptSnippet:
|
|
1591
|
+
'Manage exact branch-local OpenSpec Task focus with replacement safeguards',
|
|
1592
|
+
promptGuidelines: [
|
|
1593
|
+
'Use openspec_focus only for explicit Human direction or an exact declared OpenSpec Task handoff; never infer material Plan selection or Human validation.',
|
|
1594
|
+
],
|
|
1595
|
+
parameters: Type.Object({
|
|
1596
|
+
action: StringEnum(['status', 'set', 'replace', 'clear'] as const),
|
|
1597
|
+
change: Type.Optional(
|
|
1598
|
+
Type.String({
|
|
1599
|
+
description: 'Required for set and replace: the exact OpenSpec change ID',
|
|
1600
|
+
}),
|
|
1601
|
+
),
|
|
1602
|
+
task: Type.Optional(
|
|
1603
|
+
Type.String({
|
|
1604
|
+
description: 'Required for set and replace: the exact OpenSpec Task ID',
|
|
1605
|
+
}),
|
|
1606
|
+
),
|
|
1607
|
+
}),
|
|
1608
|
+
async execute(_toolCallId, params) {
|
|
1609
|
+
const result = applyOpenSpecFocusAction(
|
|
1610
|
+
params.action,
|
|
1611
|
+
params.change,
|
|
1612
|
+
params.task,
|
|
1613
|
+
);
|
|
1614
|
+
return {
|
|
1615
|
+
content: [
|
|
1616
|
+
{
|
|
1617
|
+
type: 'text',
|
|
1618
|
+
text: result.ok ? result.message : `Error: ${result.message}`,
|
|
1619
|
+
},
|
|
1620
|
+
],
|
|
1621
|
+
details: {
|
|
1622
|
+
ok: result.ok,
|
|
1623
|
+
focus: result.focus,
|
|
1624
|
+
},
|
|
1625
|
+
};
|
|
1626
|
+
},
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
pi.registerCommand('work', {
|
|
1630
|
+
description: "Show, set, validate, or clear this session's ephemeral Work focus",
|
|
1631
|
+
handler: async (args, ctx) => {
|
|
1632
|
+
const trimmed = args.trim();
|
|
1633
|
+
if (!trimmed || trimmed.toLowerCase() === 'status') {
|
|
1634
|
+
ctx.ui.notify(applyWorkAction('status').message, 'info');
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const [rawAction, ...rest] = trimmed.split(/\s+/u);
|
|
1639
|
+
const action = rawAction?.toLowerCase();
|
|
1640
|
+
if (action === 'set') {
|
|
1641
|
+
const result = applyWorkAction('set', rest.join(' '));
|
|
1642
|
+
ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if ((action === 'validate' || action === 'clear') && rest.length === 0) {
|
|
1646
|
+
const result = applyWorkAction(action);
|
|
1647
|
+
ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
ctx.ui.notify('Usage: /work [status|set <intent>|validate|clear]', 'warning');
|
|
1652
|
+
},
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
pi.registerCommand('openspec-focus', {
|
|
1656
|
+
description: "Show, clear, or explicitly set this session's OpenSpec task focus",
|
|
1657
|
+
handler: async (args, ctx) => {
|
|
1658
|
+
const parts = args.trim().split(/\s+/u).filter(Boolean);
|
|
1659
|
+
if (parts.length === 0) {
|
|
1660
|
+
ctx.ui.notify(applyOpenSpecFocusAction('status').message, 'info');
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
if (parts.length === 1 && parts[0]?.toLowerCase() === 'clear') {
|
|
1665
|
+
const result = applyOpenSpecFocusAction('clear');
|
|
1666
|
+
ctx.ui.notify(result.message, 'info');
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const rawAction = parts[0]?.toLowerCase();
|
|
1671
|
+
const action: 'set' | 'replace' = ['set', 'replace'].includes(rawAction ?? '')
|
|
1672
|
+
? (rawAction as 'set' | 'replace')
|
|
1673
|
+
: 'set';
|
|
1674
|
+
const identifiers = rawAction === action ? parts.slice(1) : parts;
|
|
1675
|
+
if (identifiers.length !== 2) {
|
|
1676
|
+
ctx.ui.notify(
|
|
1677
|
+
'Usage: /openspec-focus [set|replace] <change> <task> | clear',
|
|
1678
|
+
'warning',
|
|
1679
|
+
);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const result = applyOpenSpecFocusAction(action, identifiers[0], identifiers[1]);
|
|
1684
|
+
ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
pi.registerCommand('galactica-status', {
|
|
1689
|
+
description: 'Show detected project status integrations',
|
|
1690
|
+
handler: async (_args, ctx) => {
|
|
1691
|
+
ctx.ui.notify(runtime?.summary() ?? 'galactica-status is starting', 'info');
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
pi.registerCommand('galactica-status-refresh', {
|
|
1696
|
+
description: 'Refresh all enabled Galactica status sources',
|
|
1697
|
+
handler: async (_args, ctx) => {
|
|
1698
|
+
if (!runtime) {
|
|
1699
|
+
ctx.ui.notify('galactica-status is not active', 'warning');
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
await runtime.forceRefresh();
|
|
1703
|
+
ctx.ui.notify(runtime.summary(), 'info');
|
|
1704
|
+
},
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
pi.registerCommand('galactica-status-debug', {
|
|
1708
|
+
description: 'Show Galactica status detection and publication details',
|
|
1709
|
+
handler: async (_args, ctx) => {
|
|
1710
|
+
ctx.ui.notify(runtime?.debug() ?? 'galactica-status is not active', 'info');
|
|
1711
|
+
},
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// Registered last: the goal widget takeover must never precede the
|
|
1715
|
+
// status publisher's synchronous session-start publication order.
|
|
1716
|
+
void installGoalBlockTakeover(pi);
|
|
1717
|
+
}
|