@aemonge-dev/pi-stardeck 0.9.4 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.9.4",
3
+ "version": "0.11.0",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -4,73 +4,40 @@ A dependency-free Pi extension that coordinates the prompt information architect
4
4
  through Pi's supported widget and event APIs.
5
5
 
6
6
  ```text
7
- ─ 󰠭 › ─────────────────────────────────────────────────────────────────────
8
- ( 3 › 2 · 00:05'12) working ⟩ checking Stabilize runtime and focus · step 3/5  task 1/2 › stps 3/5
9
- 󰓾 Stabilize runtime and focus in the cockpit
10
- ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
11
- [󰆧]  ~/projects/example  main ⟩  clean
12
- ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
13
- 󰚩 Opus › high ⟩ 󰾆 42% › 󰎞 2  10% › 󰜦 $0.10 ⟩  12% ›  375M ›  3/3
14
- ─ 󰏫 ─────────────────────────────────────────────────────────────── ‹ 󰠭 ─
7
+ ─ 󰠭 › ────────────────────────────────────────────────────────────────────
8
+ 󰠭 [󰆧] ~/projects/example › main › +0 ~1 1 󰚩 Opus › high › 󰃰 42%
9
+ ──────────────────────────────────────────────────────────────────────
10
+ Stabilize runtime and focus in the cockpit planning 6/7 › 󰄱 9/19
11
+ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
12
+ editing deck.ts 00:38'00 · 4.2k
13
+ └ (00'12) scout working
14
+ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
15
+ 󰃰 5h 52% › 󰆧 3% › 󰉡 48% › 󰞏 2 › 󰂻 268M › 󰄠 38% › 󰈛 $5.16 󰠭 abcd1234
15
16
  ```
16
17
 
17
- The preferred editor-deck surface keeps two stable semantic rows. The first is a
18
- left-packed operational strip: runtime capsule, lifecycle, activity, controlled next
19
- action, and Task/Step progress. The second is a full-width explicit selection canvas
20
- that wraps to at most two lines. Every slot remains present; zero, idle, unavailable,
21
- and unselected states are dim instead of appearing or disappearing. Long content may
22
- wrap responsively, but runtime state never changes the deck topology. Decorative rails
23
- remain slim and contain no telemetry.
24
-
25
- The editor deck uses blue for the active runtime capsule, the bold lifecycle anchor,
26
- and regular-weight explicit selection. Ordinary active activity remains neutral, except
27
- that `recovering` and its ordinary suggestion are blue together while only the lifecycle
28
- is bold. Other ordinary suggestions remain green. Incomplete Task/Step progress is
29
- neutral until each counter completes and stays right-aligned without a major boundary.
30
- The lifecycle/activity boundary and rails remain purple; minor separators are dim.
31
- Human-input and validation cues stay yellow, blockers stay red, and every inactive value
32
- stays dim.
33
-
34
- The legacy above-editor widget owns only current work identity and preserves the
35
- ` ready ⟩ no focused task` idle row when no work is selected. The leading icon is
36
- runtime-authoritative: `` while Pi is busy in an active lifecycle and `` while
37
- settled, waiting, blocked, listening, or aborted. Internal lifecycle states render as
38
- short readable verbs. The Ramona loop remains exact: `understand`, `work`, `assure`,
39
- `learn`, and `answer`. Runtime boundaries are `wait`, `blocked`, `ready`, or `stopped`.
40
- The title has four styled groups: lifecycle, a structured active ancestry, focus, and
41
- optional focus detail. Each activity-path segment receives the blue minor `›`; the
42
- violet-pink major `⟩` divides runtime ancestry from the green narrative focus. Human
43
- prompt text is never converted into focus automatically. OpenSpec may add its exact Task
44
- after another green `›`. Focus selection prefers an exact OpenSpec Task, then
45
- Session Work. Active turns without a selection use the explicit `OpenSpec › no focus`,
46
- `no OpenSpec`, or `no focus` state supplied by the status producer; `Direct response` is
47
- never presented as a focus. Empty states are dim while real focus remains bold semantic
48
- green.
49
-
50
- Wide rows render lifecycle → active ancestry → optional focus → optional focus detail,
51
- for example `assure › checking quality › assuring tests ⟩ Implement global flow`. The
52
- renderer generates every separator. Empty or rejected paths leave no separator or
53
- placeholder whitespace. As width falls it tries full labels, compact labels, a collapsed
54
- `… › deepest` ancestry, and finally compact lifecycle plus the deepest active segment.
55
- Narrative focus disappears before meaningful active ancestry. Terminal display-cell
56
- measurement and final truncation remain Unicode- and Nerd-Font-aware.
57
-
58
- Runtime anchor and lifecycle are bold blue `#076678`; activity ancestry and its minor
59
- `›` are regular blue; the major `⟩` is purple; focus is bold semantic success green; and
60
- optional focus hierarchy is regular success green. Dim is reserved for the two empty
61
- focus fallbacks; muted, text, cyan, yellow, and gray are never used in the title.
62
-
63
- The extension hides Pi's separate built-in `Working...` loader row with
64
- `ctx.ui.setWorkingVisible(false)`, including its vertical space. The header therefore
65
- uses its complete width for lifecycle, bounded activity, and focus. Runtime age is
66
- published for the bottom prompt rail as an eight-cell chronometer measuring time since
67
- the most recent observed foreground provider, assistant, or tool event. It stays
68
- fixed-width as it advances: `00:00'00`, `00:00'45`, `00:12'30`, or `12:34'56`; hour/day
69
- fallbacks also remain eight cells. Active timing uses the high-contrast deep-cyan
70
- `accent` color; settled states display dim `00:00'00` rather than repeating the header's
71
- `wait`, `ready`, or other lifecycle word. A session-scoped ticker publishes only when
72
- the formatted chronometer changes and is cleared on shutdown. No message content,
73
- thinking, reasoning, prompts, tool arguments, or child-agent output enters the widget.
18
+ The deck renders four stable rows above the editor:
19
+
20
+ 1. **Where and what** — identity and companion chips lead, then the project path
21
+ and Git segment; the right corner carries the engine group (model › thinking ›
22
+ quota) and any alert.
23
+ 2. **Focus** — the session subject (auto-titled from the first real prompt,
24
+ settable through the `title` tool) with the OpenSpec work tile on the right.
25
+ 3. **Pure activity band (optional)** — exists only while an observed activity is
26
+ live — `editing`, `reading`, `grepping`, `bash`, or `subagent` — with the turn
27
+ chronometer and token count riding on the right; subagent and bash child runs
28
+ keep their `└` span rows directly beneath. Between tools and at rest the band
29
+ retires entirely: Pi's own `Working…` indicator stays visible exactly as the
30
+ core renders it, and no phase words or `awaiting Human` filler are ever
31
+ generated.
32
+ 4. **Machine and status** — quota, CPU/RAM, context, compactions, and session
33
+ cost yield right-to-left into the width the status end leaves: notices when
34
+ present, otherwise the dim ladybug sentinel wearing the short session id.
35
+
36
+ Rows appear only when they speak — a silent band and spanless state collapse
37
+ their seam. Every slot is bounded, sanitized display vocabulary; no message
38
+ content, thinking, reasoning, prompts, tool arguments, or child-agent output
39
+ enters the widget. Terminal display-cell measurement and final truncation remain
40
+ Unicode- and Nerd-Font-aware.
74
41
 
75
42
  The additive header protocol projects selection independently from live work:
76
43
 
@@ -3,32 +3,16 @@ import type {
3
3
  ExtensionContext,
4
4
  Theme,
5
5
  } from '@earendil-works/pi-coding-agent';
6
- import { truncateToWidth } from '@earendil-works/pi-tui';
7
6
 
8
7
  import {
9
- buildAgentFooterWidgets,
10
- buildPromptStatusSnapshot,
11
- COMPACTION_WIDGET_ID,
12
- CONTEXT_WIDGET_ID,
13
8
  countCompactions,
14
- ENVIRONMENT_WIDGET_ID,
15
- FANCY_FOOTER_READY_CHANNEL,
16
9
  FOOTER_TELEMETRY_CHANNEL,
17
- FANCY_FOOTER_WIDGET_CHANNEL,
18
10
  formatFooterBranch,
19
11
  formatFooterPath,
20
12
  GALACTICA_HEADER_CHANNEL,
21
- GIT_BRANCH_WIDGET_ID,
22
- GIT_CONFLICT_WIDGET_ID,
23
- GIT_SCOPE_SEPARATOR_WIDGET_ID,
24
- GIT_MODIFIED_WIDGET_ID,
25
- GIT_STAGED_WIDGET_ID,
26
- GIT_UNTRACKED_WIDGET_ID,
27
13
  type FooterTelemetrySnapshot,
28
14
  type GitStatusSummary,
29
15
  type HeaderSnapshot,
30
- LEGACY_GIT_WIDGET_ID,
31
- LOCATION_WIDGET_ID,
32
16
  normalizeContextPercent,
33
17
  parseFooterTelemetry,
34
18
  parseGitBranch,
@@ -37,24 +21,12 @@ import {
37
21
  parsePiStatusSnapshot,
38
22
  PI_NATIVE_STATUS_CHANNEL,
39
23
  PI_STATUS_SNAPSHOT_CHANNEL,
40
- PROMPT_STATUS_CHANNEL,
41
- REASONING_WIDGET_ID,
42
- SCOPE_SEPARATOR_WIDGET_ID,
43
- runtimeHeaderWork,
44
24
  parseVimMode,
45
25
  VIM_MODE_CHANNEL,
46
26
  type PiStatusSnapshot,
47
27
  type VimMode,
48
28
  } from './src/gauge.ts';
49
29
  import {
50
- buildRuntimeFooterWidgets,
51
- formatRailChronometer,
52
- RUNTIME_SCOPE_SEPARATOR_WIDGET_ID,
53
- RUNTIME_STATUS_WIDGET_ID,
54
- } from './src/activity-age.ts';
55
- import {
56
- buildCapabilityFooterWidget,
57
- CAPABILITY_WIDGET_ID,
58
30
  LSP_STATUS_EVENT,
59
31
  MCP_STATUS_EVENT,
60
32
  parseLspCapability,
@@ -64,11 +36,8 @@ import {
64
36
  } from './src/capabilities.ts';
65
37
  import { shouldRefreshGitAfterTool } from './src/git-refresh.ts';
66
38
  import {
67
- buildResourceFooterWidgets,
68
39
  buildResourceTelemetry,
69
40
  calculateCpuPercent,
70
- CPU_WIDGET_ID,
71
- MEMORY_WIDGET_ID,
72
41
  RESOURCE_REFRESH_MS,
73
42
  sampleProcessTreeResources,
74
43
  updateCpuSmoothing,
@@ -77,7 +46,6 @@ import {
77
46
  type ResourceTelemetry,
78
47
  } from './src/process-resources.ts';
79
48
  import { renderHeaderDeck } from './src/deck.ts';
80
- import { layoutLifecycleTitle, renderLifecycleTitleSections } from './src/title.ts';
81
49
 
82
50
  const WIDGET_KEY = 'galactica.work-identity';
83
51
  const PI_STATUS_SOURCE_CHANNEL = 'pi-stardeck:status-source/v1';
@@ -103,7 +71,11 @@ export interface ContextHeaderDeckSurface {
103
71
  }
104
72
 
105
73
  export interface ContextHeaderOptions {
106
- surface?: 'legacy' | 'deck' | 'editor-deck';
74
+ /** The deck surface is the product: `'deck'` registers the above-editor
75
+ * widget, `'editor-deck'` renders into an embedder's component instead
76
+ * (vim border). The pre-deck legacy title line is retired
77
+ * (simplify-deck-defaults). */
78
+ surface?: 'deck' | 'editor-deck';
107
79
  deckSurface?: ContextHeaderDeckSurface;
108
80
  }
109
81
 
@@ -164,7 +136,6 @@ export default function galacticaContextHeader(
164
136
  pi: ExtensionAPI,
165
137
  options: ContextHeaderOptions = {},
166
138
  ): void {
167
- const deckMode = options.surface === 'deck' || options.surface === 'editor-deck';
168
139
  const editorDeckSurface =
169
140
  options.surface === 'editor-deck' ? options.deckSurface : undefined;
170
141
  let clearWidget: (() => void) | undefined;
@@ -176,7 +147,6 @@ export default function galacticaContextHeader(
176
147
  let header: HeaderSnapshot | null = null;
177
148
  let agentBusy = false;
178
149
  let lastObservedActivityAt: number | null = null;
179
- let lastActivityAgeSignature = '';
180
150
  let activityAgeTimer: ReturnType<typeof setInterval> | undefined;
181
151
  let sessionGeneration = 0;
182
152
  let sessionShortId: string | undefined;
@@ -196,64 +166,9 @@ export default function galacticaContextHeader(
196
166
  let piStatus: PiStatusSnapshot | undefined;
197
167
  let nativeStatusBridgeAvailable = true;
198
168
  let vimMode: VimMode = 'insert';
199
- let lastCapabilitySignature = '';
200
169
 
201
170
  const currentHeader = () => header;
202
171
 
203
- const publishPromptStatus = () => {
204
- if (deckMode) return;
205
- pi.events.emit(
206
- PROMPT_STATUS_CHANNEL,
207
- buildPromptStatusSnapshot(currentHeader(), branch, gitStatus),
208
- );
209
- };
210
-
211
- const publishFooter = () => {
212
- if (deckMode) return;
213
- const ctx = activeCtx;
214
- if (!ctx) return;
215
- const usage = ctx.getContextUsage();
216
- const contextPercent = normalizeContextPercent(
217
- usage?.percent,
218
- usage?.tokens,
219
- usage?.contextWindow ?? ctx.model?.contextWindow,
220
- );
221
- const thinking = pi.getThinkingLevel();
222
- for (const message of buildAgentFooterWidgets({
223
- cwd: formatFooterPath(activeCwd || ctx.cwd, process.env.HOME),
224
- devbox: process.env.DEVBOX_PROJECT_ROOT !== undefined,
225
- branch: formatFooterBranch(branch),
226
- git: gitStatus,
227
- gitAvailable,
228
- model: ctx.model?.name || ctx.model?.id || 'no model',
229
- ...(thinking && thinking !== 'off' ? { thinking } : {}),
230
- contextPercent,
231
- compactionCount: countCompactions(ctx.sessionManager.getBranch()),
232
- })) {
233
- pi.events.emit(FANCY_FOOTER_WIDGET_CHANNEL, message);
234
- }
235
- };
236
-
237
- const publishLatestResourceFooter = () => {
238
- if (deckMode || !latestResourceTelemetry) return;
239
- for (const message of buildResourceFooterWidgets(latestResourceTelemetry)) {
240
- pi.events.emit(FANCY_FOOTER_WIDGET_CHANNEL, message);
241
- }
242
- };
243
-
244
- const publishCapabilityFooter = (force = false) => {
245
- if (deckMode) return;
246
- const signature = [mcpCapability?.healthy ?? '', mcpCapability?.total ?? ''].join(
247
- ':',
248
- );
249
- if (!force && signature === lastCapabilitySignature) return;
250
- lastCapabilitySignature = signature;
251
- pi.events.emit(
252
- FANCY_FOOTER_WIDGET_CHANNEL,
253
- buildCapabilityFooterWidget(mcpCapability),
254
- );
255
- };
256
-
257
172
  const refreshResources = async (): Promise<void> => {
258
173
  if (!activeCtx || resourceRefreshInFlight) return;
259
174
  resourceRefreshInFlight = true;
@@ -274,58 +189,28 @@ export default function galacticaContextHeader(
274
189
  latestResourceTelemetry = telemetry;
275
190
  if (signature === lastResourceSignature) return;
276
191
  lastResourceSignature = signature;
277
- if (deckMode) requestRender?.();
278
- else publishLatestResourceFooter();
192
+ requestRender?.();
279
193
  } finally {
280
194
  resourceRefreshInFlight = false;
281
195
  }
282
196
  };
283
197
 
284
- const removeFooterWidgets = () => {
285
- for (const id of [
286
- LOCATION_WIDGET_ID,
287
- LEGACY_GIT_WIDGET_ID,
288
- GIT_BRANCH_WIDGET_ID,
289
- GIT_SCOPE_SEPARATOR_WIDGET_ID,
290
- GIT_STAGED_WIDGET_ID,
291
- GIT_MODIFIED_WIDGET_ID,
292
- GIT_UNTRACKED_WIDGET_ID,
293
- GIT_CONFLICT_WIDGET_ID,
294
- ENVIRONMENT_WIDGET_ID,
295
- REASONING_WIDGET_ID,
296
- CONTEXT_WIDGET_ID,
297
- COMPACTION_WIDGET_ID,
298
- CPU_WIDGET_ID,
299
- MEMORY_WIDGET_ID,
300
- RUNTIME_STATUS_WIDGET_ID,
301
- RUNTIME_SCOPE_SEPARATOR_WIDGET_ID,
302
- SCOPE_SEPARATOR_WIDGET_ID,
303
- CAPABILITY_WIDGET_ID,
304
- ]) {
305
- pi.events.emit(FANCY_FOOTER_WIDGET_CHANNEL, {
306
- protocol: 1,
307
- type: 'remove',
308
- id,
309
- });
310
- }
311
- };
312
-
313
198
  const currentActivityAge = (): number | null =>
314
199
  agentBusy && lastObservedActivityAt !== null
315
200
  ? Math.max(0, Date.now() - lastObservedActivityAt)
316
201
  : null;
317
202
 
318
- const publishRuntimeFooter = () => {
319
- if (deckMode) return;
320
- const activityAge = currentActivityAge();
321
- const status = formatRailChronometer(activityAge);
322
- const active = activityAge !== null;
323
- const signature = `${active ? 'active' : 'idle'}:${status}`;
324
- if (signature === lastActivityAgeSignature) return;
325
- lastActivityAgeSignature = signature;
326
- for (const message of buildRuntimeFooterWidgets(status, active)) {
327
- pi.events.emit(FANCY_FOOTER_WIDGET_CHANNEL, message);
328
- }
203
+ const startActivityTicker = (): void => {
204
+ // Duty-cycled: the 50 ms chronometer ticks only while an activity can
205
+ // render — idle sessions cost zero wakeups (simplify-deck-defaults).
206
+ if (activityAgeTimer) return;
207
+ activityAgeTimer = setInterval(refreshActivityAge, 50);
208
+ };
209
+
210
+ const stopActivityTicker = (): void => {
211
+ if (activityAgeTimer === undefined) return;
212
+ clearInterval(activityAgeTimer);
213
+ activityAgeTimer = undefined;
329
214
  };
330
215
 
331
216
  const publishCapabilityHealth = () => {
@@ -363,12 +248,6 @@ export default function galacticaContextHeader(
363
248
  });
364
249
  };
365
250
 
366
- const stopReady = pi.events.on(FANCY_FOOTER_READY_CHANNEL, () => {
367
- publishFooter();
368
- publishLatestResourceFooter();
369
- publishRuntimeFooter();
370
- publishCapabilityFooter(true);
371
- });
372
251
  const stopLspStatus = pi.events.on(LSP_STATUS_EVENT, (raw) => {
373
252
  lspCapability = parseLspCapability(raw);
374
253
  requestRender?.();
@@ -377,7 +256,6 @@ export default function galacticaContextHeader(
377
256
  const stopMcpStatus = pi.events.on(MCP_STATUS_EVENT, (raw) => {
378
257
  mcpCapability = parseMcpCapability(raw);
379
258
  requestRender?.();
380
- publishCapabilityFooter();
381
259
  publishCapabilityHealth();
382
260
  });
383
261
  const stopFooterTelemetry = pi.events.on(FOOTER_TELEMETRY_CHANNEL, (raw) => {
@@ -401,33 +279,28 @@ export default function galacticaContextHeader(
401
279
  const stopHeader = pi.events.on(GALACTICA_HEADER_CHANNEL, (raw) => {
402
280
  header = parseHeaderSnapshot(raw);
403
281
  requestRender?.();
404
- publishPromptStatus();
405
- publishRuntimeFooter();
406
282
  });
407
283
 
408
284
  const markObservedActivity = () => {
409
285
  lastObservedActivityAt = Date.now();
410
- lastActivityAgeSignature = '';
286
+ startActivityTicker();
411
287
  requestRender?.();
412
- publishRuntimeFooter();
413
288
  };
414
289
 
415
290
  const clearObservedActivity = () => {
416
291
  lastObservedActivityAt = null;
417
- lastActivityAgeSignature = '';
292
+ stopActivityTicker();
418
293
  };
419
294
 
420
295
  const refreshActivityAge = () => {
421
296
  if (currentActivityAge() === null) return;
422
- if (deckMode) requestRender?.();
423
- else publishRuntimeFooter();
297
+ requestRender?.();
424
298
  };
425
299
 
426
300
  pi.on('before_agent_start', (_event, ctx) => {
427
301
  if (ctx.mode !== 'tui') return;
428
302
  agentBusy = true;
429
303
  markObservedActivity();
430
- publishPromptStatus();
431
304
  });
432
305
 
433
306
  pi.on('turn_start', markObservedActivity);
@@ -480,11 +353,7 @@ export default function galacticaContextHeader(
480
353
  gitStatus = gitAvailable
481
354
  ? parseGitStatus(String(statusResult?.stdout ?? ''))
482
355
  : { ...EMPTY_GIT };
483
- if (deckMode) requestRender?.();
484
- else {
485
- publishPromptStatus();
486
- publishFooter();
487
- }
356
+ requestRender?.();
488
357
  } while (gitRefreshQueued && activeCwd);
489
358
  } finally {
490
359
  gitRefreshInFlight = false;
@@ -495,9 +364,8 @@ export default function galacticaContextHeader(
495
364
  clearWidget?.();
496
365
  clearFooter?.();
497
366
  clearFooter = undefined;
498
- if (activityAgeTimer) clearInterval(activityAgeTimer);
367
+ stopActivityTicker();
499
368
  if (resourceTimer) clearInterval(resourceTimer);
500
- activityAgeTimer = undefined;
501
369
  resourceTimer = undefined;
502
370
  ++sessionGeneration;
503
371
  activeCtx = ctx;
@@ -519,34 +387,23 @@ export default function galacticaContextHeader(
519
387
  cpuSmoothing = undefined;
520
388
  latestResourceTelemetry = undefined;
521
389
  lastResourceSignature = '';
522
- lastCapabilitySignature = '';
523
390
  clearObservedActivity();
524
- publishFooter();
525
- publishPromptStatus();
526
- publishRuntimeFooter();
527
- publishCapabilityFooter();
528
391
  publishCapabilityHealth();
529
- if (ctx.mode === 'tui') {
530
- ctx.ui.setWorkingVisible(false);
531
- activityAgeTimer = setInterval(refreshActivityAge, 50);
532
- }
533
392
  if (!ctx.hasUI) return;
534
- if (deckMode) {
535
- clearFooter = installEmptyDeckFooter(
536
- ctx,
537
- (statuses) => {
538
- pi.events.emit(PI_NATIVE_STATUS_CHANNEL, {
539
- protocol: 1,
540
- type: 'snapshot',
541
- statuses,
542
- });
543
- },
544
- (available) => {
545
- nativeStatusBridgeAvailable = available;
546
- publishCapabilityHealth();
547
- },
548
- );
549
- }
393
+ clearFooter = installEmptyDeckFooter(
394
+ ctx,
395
+ (statuses) => {
396
+ pi.events.emit(PI_NATIVE_STATUS_CHANNEL, {
397
+ protocol: 1,
398
+ type: 'snapshot',
399
+ statuses,
400
+ });
401
+ },
402
+ (available) => {
403
+ nativeStatusBridgeAvailable = available;
404
+ publishCapabilityHealth();
405
+ },
406
+ );
550
407
  void refreshResources();
551
408
  resourceTimer = setInterval(() => void refreshResources(), RESOURCE_REFRESH_MS);
552
409
  activeCwd = ctx.cwd;
@@ -610,16 +467,7 @@ export default function galacticaContextHeader(
610
467
  requestRender = () => tui.requestRender();
611
468
  return {
612
469
  render(width: number): string[] {
613
- if (deckMode) return renderDeck(width, theme);
614
- const { work, idle } = runtimeHeaderWork(currentHeader()?.work, agentBusy);
615
- const layout = layoutLifecycleTitle(work, width, agentBusy);
616
- const line = renderLifecycleTitleSections(
617
- layout,
618
- work.lifecycle,
619
- idle,
620
- theme,
621
- );
622
- return [truncateToWidth(line, width, '…')];
470
+ return renderDeck(width, theme);
623
471
  },
624
472
  invalidate() {},
625
473
  };
@@ -634,21 +482,17 @@ export default function galacticaContextHeader(
634
482
  });
635
483
 
636
484
  pi.on('model_select', () => {
637
- publishFooter();
638
485
  requestRender?.();
639
486
  });
640
487
 
641
488
  pi.on('thinking_level_select', () => {
642
- if (deckMode) requestRender?.();
643
- else publishFooter();
489
+ requestRender?.();
644
490
  });
645
491
  pi.on('message_end', (event) => {
646
492
  if (event.message.role === 'assistant') markObservedActivity();
647
- publishFooter();
648
493
  });
649
494
  pi.on('session_compact', () => {
650
- if (deckMode) requestRender?.();
651
- else publishFooter();
495
+ requestRender?.();
652
496
  });
653
497
 
654
498
  pi.on('tool_execution_end', (event) => {
@@ -662,9 +506,6 @@ export default function galacticaContextHeader(
662
506
  agentBusy = false;
663
507
  clearObservedActivity();
664
508
  requestRender?.();
665
- publishPromptStatus();
666
- publishFooter();
667
- publishRuntimeFooter();
668
509
  void refreshGit();
669
510
  });
670
511
 
@@ -675,9 +516,7 @@ export default function galacticaContextHeader(
675
516
  gitAvailable = false;
676
517
  agentBusy = false;
677
518
  clearObservedActivity();
678
- if (activityAgeTimer) clearInterval(activityAgeTimer);
679
519
  if (resourceTimer) clearInterval(resourceTimer);
680
- activityAgeTimer = undefined;
681
520
  resourceTimer = undefined;
682
521
  resourceRefreshInFlight = false;
683
522
  previousResourceSample = undefined;
@@ -689,9 +528,7 @@ export default function galacticaContextHeader(
689
528
  clearWidget = undefined;
690
529
  clearFooter?.();
691
530
  clearFooter = undefined;
692
- removeFooterWidgets();
693
531
  stopHeader();
694
- stopReady();
695
532
  stopLspStatus();
696
533
  stopMcpStatus();
697
534
  stopPiStatus();
@@ -5,7 +5,6 @@ import type { LspCapability, McpCapability } from './capabilities.ts';
5
5
  import type {
6
6
  FooterTelemetrySnapshot,
7
7
  GitStatusSummary,
8
- HeaderActivity,
9
8
  HeaderSnapshot,
10
9
  PiStatusSnapshot,
11
10
  VimMode,
@@ -279,82 +278,34 @@ function spanTreeRows(state: HeaderDeckState, width: number, theme: Theme): stri
279
278
  });
280
279
  }
281
280
 
282
- const ACTIVITY_LABELS: Record<HeaderActivity['kind'], string> = {
283
- understanding: 'interpreting',
284
- inspection: 'inspecting',
285
- synthesis: 'synthesizing',
286
- planning: 'planning',
287
- 'skill-proposal': 'proposing skill',
288
- mutation: 'updating',
289
- implementation: 'implementing',
290
- 'change-review': 'reviewing change',
291
- regression: 'testing',
292
- verification: 'checking',
293
- 'result-review': 'reviewing result',
294
- answering: 'answering',
295
- recovery: 'recovering',
296
- 'awaiting-validation': 'validation',
297
- 'awaiting-input': 'input',
298
- 'operation-aborted': 'stopped',
299
- };
300
-
301
- function activityLabel(state: HeaderDeckState): string {
302
- const pathActivity = state.header?.work?.activityPath?.at(-1);
303
- const pathLabel = safeText(pathActivity?.compact || pathActivity?.label || '');
304
- if (pathLabel) return pathLabel;
305
- const activity = state.header?.work?.activity;
306
- // `awaiting-*` kinds are the rest face — they never speak for the band
307
- // (a focused session still retires; quiet-activity-row amendment).
308
- if (!activity || activity.kind.startsWith('awaiting-')) return '';
309
- return ACTIVITY_LABELS[activity.kind];
310
- }
311
-
312
- /** `editing deck.ts` / `reading schema.yaml` / `subagent: scout`. */
281
+ /** The band speaks only when an observed activity is live — one of the
282
+ * five title-state classes (editing/reading/grepping/bash/subagent).
283
+ * Fabricated phase words (`interpreting`, `planning`, …), the legacy
284
+ * activity projection, and the `awaiting Human` placeholder are gone
285
+ * (Human-directed simplify-deck-defaults): Pi's restored native working
286
+ * indicator owns mid-turn liveness, and the deck narrates only observed
287
+ * reality. */
313
288
  function titleActivityLabel(activity: { class: string; target: string }): string {
314
289
  return activity.class === 'subagent'
315
290
  ? `subagent: ${activity.target}`
316
291
  : `${activity.class} ${activity.target}`;
317
292
  }
318
293
 
319
- /** The line-3 band speaks when either side carries a real signal:
320
- * an observed activity (title-state or legacy) or a turn meter that
321
- * cost tokens or time. Both idle (`awaiting Human` + `— · 0 tok` or
322
- * no meter) is zero activity — the band and its seam retire
323
- * (Human-directed 2026-10-20, quiet-activity-row). */
324
- /** Live phase word from the header's kind-based activity projection —
325
- * fills the gaps between tools (thinking, planning, checking) so the
326
- * band never claims `awaiting Human` mid-turn. The `awaiting-*` kinds
327
- * are the REST face (awaiting Human review/input after settle): they
328
- * never speak for the band, or a focused session would never rest
329
- * (quiet-activity-row amendment). */
330
- function liveKindLabel(state: HeaderDeckState): string {
331
- const activity = state.header?.activity ?? null;
332
- if (!activity || activity.kind.startsWith('awaiting-')) return '';
333
- return ACTIVITY_LABELS[activity.kind] ?? '';
334
- }
335
-
336
- /** The band's current left label: the precise title-state activity when a
337
- * tool is observed, else the live phase word; empty when truly idle. */
294
+ /** The band's current label: the live observed activity; empty when
295
+ * nothing is observed — the band then retires wholesale. */
338
296
  function observedActivityLabel(state: HeaderDeckState): string {
339
- const title = state.header?.title ?? null;
340
- if (title) {
341
- return title.activity ? titleActivityLabel(title.activity) : liveKindLabel(state);
342
- }
343
- return activityLabel(state) || liveKindLabel(state);
297
+ const activity = state.header?.title?.activity ?? null;
298
+ return activity ? titleActivityLabel(activity) : '';
344
299
  }
345
300
 
346
301
  function activityBandSpeaks(state: HeaderDeckState): boolean {
347
- const activitySpeaks = observedActivityLabel(state) !== '';
348
- const turn = state.header?.turn ?? null;
349
- const costSpeaks = turn !== null && (turn.tokens > 0 || turn.elapsedMs > 0);
350
- return activitySpeaks || costSpeaks;
302
+ return observedActivityLabel(state) !== '';
351
303
  }
352
304
 
353
305
  function activityTile(state: HeaderDeckState, theme: Theme): string {
354
- // Line 3 left: the observed activity owns the slot — the live phase word
355
- // between tools, `awaiting Human` only when the session truly rests.
356
- const label = observedActivityLabel(state);
357
- return label ? color(theme, 'text', label) : color(theme, 'dim', 'awaiting Human');
306
+ // Line 3 left: the observed activity owns the slot; the turn cluster
307
+ // rides its right side and retires with it.
308
+ return color(theme, 'text', observedActivityLabel(state));
358
309
  }
359
310
 
360
311
  function turnCostTile(state: HeaderDeckState, theme: Theme): string {
@@ -191,40 +191,6 @@ export const HEADER_PRESENTATION = {
191
191
  locationColor: 'success',
192
192
  } as const;
193
193
 
194
- export const IDLE_HEADER_WORK: HeaderWork = {
195
- lifecycle: 'listening',
196
- titles: ['no focused task'],
197
- color: 'accent',
198
- };
199
-
200
- export function displayedHeaderWork(work: HeaderWork | null | undefined): {
201
- work: HeaderWork;
202
- idle: boolean;
203
- } {
204
- return work ? { work, idle: false } : { work: IDLE_HEADER_WORK, idle: true };
205
- }
206
-
207
- export function runtimeHeaderWork(
208
- work: HeaderWork | null | undefined,
209
- agentBusy: boolean,
210
- ): { work: HeaderWork; idle: boolean } {
211
- const displayed = displayedHeaderWork(work);
212
- if (!agentBusy || !displayed.idle) return displayed;
213
- return {
214
- work: {
215
- lifecycle: 'understanding',
216
- titles: ['no focus'],
217
- color: 'accent',
218
- activity: { kind: 'understanding' },
219
- },
220
- idle: false,
221
- };
222
- }
223
-
224
- export function lifecycleStatusLabel(work: HeaderWork | null | undefined): string {
225
- return work ? `⟩ ${work.lifecycle}` : '⟩';
226
- }
227
-
228
194
  export type ContextGauge = {
229
195
  percent: number;
230
196
  percentText: string;
@@ -69,14 +69,16 @@ ephemeral Session Work state. Display priority is OpenSpec
69
69
  Task → Session Work → safe direct fallback. OpenSpec focus clears active Session
70
70
  Work when acquired, preventing hidden or stale work from resurfacing later.
71
71
 
72
- Session Work is stored only as versioned custom entries on the active Pi session branch;
73
- it never creates a repository status file. It supports this lifecycle:
72
+ Session Work is a legacy surface: its writer tool and `/work` command are retired, and
73
+ only old sessions' versioned custom entries remain, restored fail-soft on the active
74
+ Pi session branch (never a repository status file). OpenSpec focus is stored the same
75
+ way and supports this lifecycle:
74
76
 
75
77
  ```text
76
- /work
77
- /work set Implement session metadata
78
- /work validate
79
- /work clear
78
+ /openspec-focus set <change> <task>
79
+ /openspec-focus validate
80
+ /openspec-focus replace <change> <task>
81
+ /openspec-focus clear
80
82
  ```
81
83
 
82
84
  When no OpenSpec portfolio is available, the matching row-one footer fallback is the
@@ -84,10 +86,14 @@ compact `▶ Focus` or `◇ Validate`; an entirely empty
84
86
  project uses ` no OpenSpec`. The detailed Session Work intent remains in the header
85
87
  instead of being duplicated in the footer. Active and validation states also replace the
86
88
  fallback terminal title. Reload, resume, and tree navigation restore the newest valid
87
- branch entry. The agent-callable `work_focus` tool exposes the same `status`, `set`,
88
- `validate`, and `clear` actions. The agent-callable `openspec_focus` tool likewise
89
- shares the command's `status`, safeguarded `set`, deliberate `replace`, and persistent
90
- `clear` transitions.
89
+ branch entry. The agent-callable `openspec_focus` tool shares the command's `status`,
90
+ safeguarded `set`, deliberate `replace`, validating `validate`, and persistent `clear`
91
+ transitions; the `awaitingValidation` flag marks a focused task as awaiting Human
92
+ validation and auto-clears on the next `set`, `replace`, or `clear`. The
93
+ agent-callable `title` tool sets the sanitized session title shown when no OpenSpec
94
+ focus owns the row. The retired `work_focus` and `stage` instruments are gone: stages
95
+ derive from events and Taskflow phases, and legacy `session-work` entries from old
96
+ sessions still restore and render, fail-soft.
91
97
 
92
98
  Global routing belongs to native Ramona policy rather than this status package. This
93
99
  package does not inject parent routing prompts, block parent tools, create Session Work
@@ -20,14 +20,15 @@ import {
20
20
  sameOpenSpecFocus,
21
21
  sameSessionSubject,
22
22
  sameSessionWorkFocus,
23
+ sameTaskFocus,
23
24
  SESSION_SUBJECT_ENTRY_TYPE,
24
25
  SESSION_WORK_FOCUS_ENTRY_TYPE,
25
26
  type SessionSubject,
26
27
  type SessionWorkFocus,
27
28
  sessionSubject,
28
- sessionWorkFocus,
29
29
  taskflowRunFocus,
30
30
  taskFocus,
31
+ titleFromPrompt,
31
32
  } from './src/active-work.ts';
32
33
  import { collectDiagnostics } from './src/diagnostics.ts';
33
34
  import { loadConfig } from './src/config.ts';
@@ -77,7 +78,6 @@ import {
77
78
  } from './src/reducer.ts';
78
79
  import type {
79
80
  CockpitSnapshot,
80
- StageValue,
81
81
  CommandRunner,
82
82
  DiagnosticsState,
83
83
  LoadedConfig,
@@ -90,7 +90,7 @@ import type {
90
90
 
91
91
  type SessionContext = ExtensionContext | ExtensionCommandContext;
92
92
  type Domain = RuntimeErrorRecord['domain'];
93
- type OpenSpecFocusAction = 'status' | 'set' | 'replace' | 'clear';
93
+ type OpenSpecFocusAction = 'status' | 'set' | 'replace' | 'validate' | 'clear';
94
94
 
95
95
  type StatusHealthCondition = {
96
96
  id: string;
@@ -229,9 +229,13 @@ class GalacticaStatusRuntime {
229
229
  this.workFocus = workFocus;
230
230
  this.subject = subject;
231
231
  this.sessionName = sessionName;
232
- this.liveLifecycle = workFocus.state === 'validation' ? 'waiting' : 'listening';
233
- this.liveActivity =
234
- workFocus.state === 'validation' ? { kind: 'awaiting-validation' } : undefined;
232
+ const restoredAwaitingValidation =
233
+ (focus.mode === 'task' && focus.awaitingValidation) ||
234
+ workFocus.state === 'validation';
235
+ this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
236
+ this.liveActivity = restoredAwaitingValidation
237
+ ? { kind: 'awaiting-validation' }
238
+ : undefined;
235
239
  this.store = new AtomicStatusStore({
236
240
  cwd: ctx.cwd,
237
241
  projectRoot: ctx.cwd,
@@ -729,6 +733,11 @@ class GalacticaStatusRuntime {
729
733
  this.focus = focus;
730
734
  this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
731
735
  this.store.clearOpenSpec();
736
+ if (focus.mode === 'task' && focus.awaitingValidation) {
737
+ this.setLiveProjection('waiting', { kind: 'awaiting-validation' });
738
+ } else if (focus.mode === 'none' && this.workFocus.state === 'clear') {
739
+ this.setLiveProjection('listening');
740
+ }
732
741
  this.publish();
733
742
  this.launch(this.refresh(false), 'openspec');
734
743
  }
@@ -754,20 +763,6 @@ class GalacticaStatusRuntime {
754
763
  this.publish();
755
764
  }
756
765
 
757
- declareStage(stage: StageValue): boolean {
758
- if (!this.currentTurnId) return false;
759
- const changed = this.runtimeSpans.setStage(this.currentTurnId, stage, true);
760
- this.recordEvent({
761
- type: 'run/stage',
762
- at: Date.now(),
763
- id: this.currentTurnId,
764
- stage,
765
- declared: true,
766
- });
767
- this.setLiveProjection(stage);
768
- return changed;
769
- }
770
-
771
766
  setLiveProjection(lifecycle: HeaderLifecycle, activity?: HeaderActivity): void {
772
767
  if (
773
768
  this.liveLifecycle === lifecycle &&
@@ -1081,19 +1076,11 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1081
1076
  runtime?.setFocus(focus);
1082
1077
  }
1083
1078
 
1084
- function workStatus(focus: SessionWorkFocus): string {
1085
- return focus.state === 'clear'
1086
- ? 'Work focus: clear'
1087
- : `Work focus: ${focus.state} · ${focus.intent}`;
1088
- }
1089
-
1090
1079
  function subjectStatus(subject: SessionSubject): string {
1091
- return subject.state === 'clear'
1092
- ? 'Session subject: clear'
1093
- : `Session subject: ${subject.title}`;
1080
+ return subject.state === 'clear' ? 'Title: clear' : `Title: ${subject.title}`;
1094
1081
  }
1095
1082
 
1096
- function applySubjectAction(
1083
+ function applyTitleAction(
1097
1084
  action: 'status' | 'set',
1098
1085
  title?: string,
1099
1086
  ): { ok: boolean; message: string; subject: SessionSubject } {
@@ -1107,7 +1094,7 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1107
1094
  if (!next || next.state !== 'set') {
1108
1095
  return {
1109
1096
  ok: false,
1110
- message: 'Session subject requires a non-empty title',
1097
+ message: 'Title requires a non-empty title',
1111
1098
  subject: current,
1112
1099
  };
1113
1100
  }
@@ -1115,79 +1102,20 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1115
1102
  return { ok: true, message: subjectStatus(next), subject: next };
1116
1103
  }
1117
1104
 
1118
- function applyWorkAction(
1119
- action: 'status' | 'set' | 'validate' | 'clear',
1120
- intent?: string,
1121
- ): { ok: boolean; message: string; focus: SessionWorkFocus } {
1122
- const current = runtime?.currentWorkFocus() ?? NO_SESSION_WORK_FOCUS;
1123
- const openSpec = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
1124
-
1125
- if (action === 'status') {
1126
- return { ok: true, message: workStatus(current), focus: current };
1127
- }
1128
- if (action === 'clear') {
1129
- if (current.state !== 'clear') persistAndApplyWorkFocus(NO_SESSION_WORK_FOCUS);
1130
- return {
1131
- ok: true,
1132
- message: 'Work focus cleared',
1133
- focus: NO_SESSION_WORK_FOCUS,
1134
- };
1135
- }
1136
- if (openSpec.mode === 'task') {
1137
- return {
1138
- ok: false,
1139
- message:
1140
- `OpenSpec focus is ${openSpec.change} › ${openSpec.taskId}; ` +
1141
- 'clear it before setting ephemeral work',
1142
- focus: current,
1143
- };
1144
- }
1145
- if (action === 'validate') {
1146
- if (current.state === 'clear') {
1147
- return {
1148
- ok: false,
1149
- message: 'No active Work focus to validate',
1150
- focus: current,
1151
- };
1152
- }
1153
- const validation = sessionWorkFocus('validation', current.intent);
1154
- if (!validation) {
1155
- return { ok: false, message: 'Invalid Work focus', focus: current };
1156
- }
1157
- if (!sameSessionWorkFocus(current, validation)) {
1158
- persistAndApplyWorkFocus(validation);
1159
- }
1160
- return { ok: true, message: workStatus(validation), focus: validation };
1161
- }
1162
-
1163
- const active = sessionWorkFocus('active', intent);
1164
- if (!active) {
1165
- return {
1166
- ok: false,
1167
- message: 'Work focus requires a non-empty intent',
1168
- focus: current,
1169
- };
1170
- }
1171
- if (!sameSessionWorkFocus(current, active)) persistAndApplyWorkFocus(active);
1172
- return { ok: true, message: workStatus(active), focus: active };
1173
- }
1174
-
1175
1105
  function applyOpenSpecFocusAction(
1176
1106
  action: OpenSpecFocusAction,
1177
1107
  change?: string,
1178
1108
  taskId?: string,
1179
1109
  ): { ok: boolean; message: string; focus: OpenSpecFocus } {
1180
1110
  const current = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
1111
+ const statusMessage = (focus: OpenSpecFocus): string =>
1112
+ focus.mode === 'task'
1113
+ ? `OpenSpec focus: ${focus.change} › ${focus.taskId}` +
1114
+ (focus.awaitingValidation ? ' — awaiting Human validation' : '')
1115
+ : 'OpenSpec focus: none';
1181
1116
 
1182
1117
  if (action === 'status') {
1183
- return {
1184
- ok: true,
1185
- message:
1186
- current.mode === 'task'
1187
- ? `OpenSpec focus: ${current.change} › ${current.taskId}`
1188
- : 'OpenSpec focus: none',
1189
- focus: current,
1190
- };
1118
+ return { ok: true, message: statusMessage(current), focus: current };
1191
1119
  }
1192
1120
  if (action === 'clear') {
1193
1121
  persistAndApplyFocus(NO_OPEN_SPEC_FOCUS);
@@ -1197,6 +1125,18 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1197
1125
  focus: NO_OPEN_SPEC_FOCUS,
1198
1126
  };
1199
1127
  }
1128
+ if (action === 'validate') {
1129
+ if (current.mode !== 'task') {
1130
+ return {
1131
+ ok: false,
1132
+ message: 'OpenSpec focus has no task to validate',
1133
+ focus: current,
1134
+ };
1135
+ }
1136
+ const validated = { ...current, awaitingValidation: true };
1137
+ if (!sameOpenSpecFocus(current, validated)) persistAndApplyFocus(validated);
1138
+ return { ok: true, message: statusMessage(validated), focus: validated };
1139
+ }
1200
1140
 
1201
1141
  const requested = taskFocus(change, taskId);
1202
1142
  if (!requested) {
@@ -1209,7 +1149,7 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1209
1149
  if (
1210
1150
  action === 'set' &&
1211
1151
  current.mode === 'task' &&
1212
- !sameOpenSpecFocus(current, requested)
1152
+ !sameTaskFocus(current, requested)
1213
1153
  ) {
1214
1154
  return {
1215
1155
  ok: false,
@@ -1222,7 +1162,7 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1222
1162
  if (!sameOpenSpecFocus(current, requested)) persistAndApplyFocus(requested);
1223
1163
  return {
1224
1164
  ok: true,
1225
- message: `OpenSpec focus: ${requested.change} › ${requested.taskId}`,
1165
+ message: statusMessage(requested),
1226
1166
  focus: requested,
1227
1167
  };
1228
1168
  }
@@ -1258,8 +1198,23 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1258
1198
  runtime?.settle();
1259
1199
  });
1260
1200
 
1261
- pi.on('before_agent_start', () => {
1201
+ pi.on('before_agent_start', (event) => {
1262
1202
  runtime?.beginTurn();
1203
+ // Auto-title: the first real user prompt names the session while the
1204
+ // subject is still clear — the agent's `title` call stays optional
1205
+ // polish (every agent skipped the first-turn rule; verified
1206
+ // systematically in simplify-session-focus-surface). Human- or
1207
+ // agent-set titles and restored sessions are never overridden.
1208
+ // The event fields are narrowed structurally: the registry-pinned
1209
+ // @earendil-works/pi-coding-agent types predate messageType/prompt,
1210
+ // while the runtime event carries both (extensions.md, before_agent_start).
1211
+ const promptRun = event as { messageType?: unknown; prompt?: unknown };
1212
+ if (promptRun.messageType !== 'user' || typeof promptRun.prompt !== 'string') {
1213
+ return;
1214
+ }
1215
+ if ((runtime?.currentSubject() ?? NO_SESSION_SUBJECT).state !== 'clear') return;
1216
+ const next = titleFromPrompt(promptRun.prompt);
1217
+ if (next && next.state === 'set') persistAndApplySubject(next);
1263
1218
  });
1264
1219
 
1265
1220
  pi.on('tool_execution_start', (event) => {
@@ -1360,80 +1315,11 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1360
1315
  });
1361
1316
 
1362
1317
  pi.registerTool({
1363
- name: 'work_focus',
1364
- label: 'Work Focus',
1365
- description:
1366
- 'Show, set, mark for validation, or clear explicit ephemeral work metadata for this Pi session',
1367
- promptSnippet: 'Manage explicit ephemeral Session Work focus metadata',
1368
- parameters: Type.Object({
1369
- action: StringEnum(['status', 'set', 'validate', 'clear'] as const),
1370
- intent: Type.Optional(
1371
- Type.String({
1372
- description: 'Required only for set: the concise current intent',
1373
- }),
1374
- ),
1375
- }),
1376
- async execute(_toolCallId, params) {
1377
- const result = applyWorkAction(params.action, params.intent);
1378
- return {
1379
- content: [
1380
- {
1381
- type: 'text',
1382
- text: result.ok ? result.message : `Error: ${result.message}`,
1383
- },
1384
- ],
1385
- details: {
1386
- ok: result.ok,
1387
- focus: result.focus,
1388
- },
1389
- };
1390
- },
1391
- });
1392
-
1393
- pi.registerTool({
1394
- name: 'stage',
1395
- label: 'Stage',
1396
- description:
1397
- 'Declare the current stage of the Ramona loop (status truth; one call per transition)',
1398
- promptSnippet: 'Declare the current loop stage for status truth',
1399
- parameters: Type.Object({
1400
- stage: StringEnum([
1401
- 'understanding',
1402
- 'working',
1403
- 'assuring',
1404
- 'learning',
1405
- 'answering',
1406
- 'waiting',
1407
- 'blocked',
1408
- 'listening',
1409
- 'aborted',
1410
- ] as const),
1411
- }),
1412
- async execute(_toolCallId, params) {
1413
- const declared = runtime?.declareStage(params.stage) ?? false;
1414
- return {
1415
- content: [
1416
- {
1417
- type: 'text' as const,
1418
- text: declared
1419
- ? `Stage declared: ${params.stage}`
1420
- : `Stage recorded: ${params.stage} (no active turn span)`,
1421
- },
1422
- ],
1423
- details: { kind: 'stage-declaration', stage: params.stage },
1424
- };
1425
- },
1426
- });
1427
-
1428
- pi.registerTool({
1429
- name: 'subject',
1430
- label: 'Session Subject',
1318
+ name: 'title',
1319
+ label: 'Session Title',
1431
1320
  description:
1432
- 'Show or set the sanitized session subject that keeps the focus row populated beneath OpenSpec and Session Work',
1433
- promptSnippet: 'Manage the automatic session subject title',
1434
- promptGuidelines: [
1435
- '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.',
1436
- ],
1321
+ 'Show or set the sanitized session title that keeps the focus row populated beneath OpenSpec and Session Work. The session titles itself from the first real prompt; call this only to name or rename deliberately (at most 120 characters, never raw prompt text), revising when the topic clearly shifts; never require Human action for it',
1322
+ promptSnippet: 'Manage the automatic session title',
1437
1323
  parameters: Type.Object({
1438
1324
  action: StringEnum(['status', 'set'] as const),
1439
1325
  title: Type.Optional(
@@ -1443,7 +1329,7 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1443
1329
  ),
1444
1330
  }),
1445
1331
  async execute(_toolCallId, params) {
1446
- const result = applySubjectAction(params.action, params.title);
1332
+ const result = applyTitleAction(params.action, params.title);
1447
1333
  return {
1448
1334
  content: [
1449
1335
  {
@@ -1463,14 +1349,11 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1463
1349
  name: 'openspec_focus',
1464
1350
  label: 'OpenSpec Focus',
1465
1351
  description:
1466
- 'Show, safely set, deliberately replace, or clear branch-local OpenSpec Task focus',
1352
+ 'Show, safely set, deliberately replace, validate, or clear branch-local OpenSpec Task focus. Use only for explicit Human direction or an exact declared OpenSpec Task handoff; never infer material Plan selection or Human validation',
1467
1353
  promptSnippet:
1468
1354
  'Manage exact branch-local OpenSpec Task focus with replacement safeguards',
1469
- promptGuidelines: [
1470
- 'Use openspec_focus only for explicit Human direction or an exact declared OpenSpec Task handoff; never infer material Plan selection or Human validation.',
1471
- ],
1472
1355
  parameters: Type.Object({
1473
- action: StringEnum(['status', 'set', 'replace', 'clear'] as const),
1356
+ action: StringEnum(['status', 'set', 'replace', 'validate', 'clear'] as const),
1474
1357
  change: Type.Optional(
1475
1358
  Type.String({
1476
1359
  description: 'Required for set and replace: the exact OpenSpec change ID',
@@ -1503,32 +1386,6 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1503
1386
  },
1504
1387
  });
1505
1388
 
1506
- pi.registerCommand('work', {
1507
- description: "Show, set, validate, or clear this session's ephemeral Work focus",
1508
- handler: async (args, ctx) => {
1509
- const trimmed = args.trim();
1510
- if (!trimmed || trimmed.toLowerCase() === 'status') {
1511
- ctx.ui.notify(applyWorkAction('status').message, 'info');
1512
- return;
1513
- }
1514
-
1515
- const [rawAction, ...rest] = trimmed.split(/\s+/u);
1516
- const action = rawAction?.toLowerCase();
1517
- if (action === 'set') {
1518
- const result = applyWorkAction('set', rest.join(' '));
1519
- ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
1520
- return;
1521
- }
1522
- if ((action === 'validate' || action === 'clear') && rest.length === 0) {
1523
- const result = applyWorkAction(action);
1524
- ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
1525
- return;
1526
- }
1527
-
1528
- ctx.ui.notify('Usage: /work [status|set <intent>|validate|clear]', 'warning');
1529
- },
1530
- });
1531
-
1532
1389
  pi.registerCommand('openspec-focus', {
1533
1390
  description: "Show, clear, or explicitly set this session's OpenSpec task focus",
1534
1391
  handler: async (args, ctx) => {
@@ -1544,6 +1401,12 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1544
1401
  return;
1545
1402
  }
1546
1403
 
1404
+ if (parts.length === 1 && parts[0]?.toLowerCase() === 'validate') {
1405
+ const result = applyOpenSpecFocusAction('validate');
1406
+ ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
1407
+ return;
1408
+ }
1409
+
1547
1410
  const rawAction = parts[0]?.toLowerCase();
1548
1411
  const action: 'set' | 'replace' = ['set', 'replace'].includes(rawAction ?? '')
1549
1412
  ? (rawAction as 'set' | 'replace')
@@ -8,6 +8,7 @@ export type TaskOpenSpecFocus = {
8
8
  mode: 'task';
9
9
  change: string;
10
10
  taskId: string;
11
+ awaitingValidation: boolean;
11
12
  };
12
13
 
13
14
  export type OpenSpecFocus = { mode: 'none' } | TaskOpenSpecFocus;
@@ -52,18 +53,24 @@ function explicitId(value: unknown): string | undefined {
52
53
  export function taskFocus(
53
54
  change: unknown,
54
55
  taskId: unknown,
56
+ awaitingValidation?: unknown,
55
57
  ): TaskOpenSpecFocus | undefined {
56
58
  const normalizedChange = explicitId(change);
57
59
  const normalizedTaskId = explicitId(taskId);
58
60
  if (!normalizedChange || !normalizedTaskId) return undefined;
59
- return { mode: 'task', change: normalizedChange, taskId: normalizedTaskId };
61
+ return {
62
+ mode: 'task',
63
+ change: normalizedChange,
64
+ taskId: normalizedTaskId,
65
+ awaitingValidation: awaitingValidation === true,
66
+ };
60
67
  }
61
68
 
62
69
  export function parseOpenSpecFocus(value: unknown): OpenSpecFocus | undefined {
63
70
  if (!isRecord(value)) return undefined;
64
71
  if (value.mode === 'none') return NO_OPEN_SPEC_FOCUS;
65
72
  if (value.mode !== 'task') return undefined;
66
- return taskFocus(value.change, value.taskId);
73
+ return taskFocus(value.change, value.taskId, value.awaitingValidation);
67
74
  }
68
75
 
69
76
  export function sessionWorkFocus(
@@ -104,6 +111,18 @@ export function parseSessionSubject(value: unknown): SessionSubject | undefined
104
111
  return sessionSubject(value.state, value.title);
105
112
  }
106
113
 
114
+ /** Derive a session title from a raw user prompt — the extension-side
115
+ * fallback that makes the agent's first-turn `title` call optional
116
+ * polish instead of a load-bearing bet (every agent skipped it). The
117
+ * same sanitizer bounds it; slash commands are commands, not subjects:
118
+ * they never title. */
119
+ export function titleFromPrompt(prompt: unknown): SessionSubject | undefined {
120
+ if (typeof prompt !== 'string') return undefined;
121
+ const normalized = normalizeTitlePart(prompt);
122
+ if (!normalized || normalized.startsWith('/')) return undefined;
123
+ return sessionSubject('set', normalized);
124
+ }
125
+
107
126
  export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus {
108
127
  for (let index = entries.length - 1; index >= 0; index -= 1) {
109
128
  const entry = entries[index];
@@ -162,7 +181,9 @@ export function taskflowRunFocus(input: unknown): TaskOpenSpecFocus | undefined
162
181
  : undefined;
163
182
  }
164
183
 
165
- export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
184
+ /** Task identity (mode · change · taskId) — the replace-safeguard's
185
+ * question; the validation flag is state, not identity. */
186
+ export function sameTaskFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
166
187
  if (left.mode !== right.mode) return false;
167
188
  return (
168
189
  left.mode === 'none' ||
@@ -172,6 +193,17 @@ export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): bo
172
193
  );
173
194
  }
174
195
 
196
+ export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
197
+ if (left.mode !== right.mode) return false;
198
+ return (
199
+ left.mode === 'none' ||
200
+ (right.mode === 'task' &&
201
+ left.change === right.change &&
202
+ left.taskId === right.taskId &&
203
+ left.awaitingValidation === right.awaitingValidation)
204
+ );
205
+ }
206
+
175
207
  export function sameSessionWorkFocus(
176
208
  left: SessionWorkFocus,
177
209
  right: SessionWorkFocus,
@@ -408,14 +408,20 @@ export async function collectOpenSpec(options: {
408
408
  // Progress from `list` remains valid even if apply instructions are unavailable.
409
409
  }
410
410
 
411
- const completedTasks = Math.max(
412
- 0,
413
- Math.trunc(instructionProgress?.complete ?? change.completedTasks),
414
- );
415
- const totalTasks = Math.max(
416
- 0,
417
- Math.trunc(instructionProgress?.total ?? change.totalTasks),
418
- );
411
+ // File truth first: when the artifact (or apply) task list parsed, its
412
+ // done flags are what the Human actually ticks — the CLI's done-counting
413
+ // does not track custom-schema execution artifacts and pinned the Work
414
+ // tile at 0/N (simplify-deck-defaults). CLI progress and list counts
415
+ // remain fallbacks; the blocked-apply zero-guard stays for the
416
+ // no-tasks case.
417
+ const completedTasks =
418
+ tasks.length > 0
419
+ ? tasks.filter((task) => task.done).length
420
+ : Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
421
+ const totalTasks =
422
+ tasks.length > 0
423
+ ? tasks.length
424
+ : Math.max(0, Math.trunc(instructionProgress?.total ?? change.totalTasks));
419
425
  const complete =
420
426
  change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
421
427