pi-adx-workflow 1.0.1 → 1.1.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/README.md CHANGED
@@ -188,9 +188,12 @@ saves it to `.pi/specs/<slug>.md`, exits plan mode, and launches nothing.
188
188
  State lives **per pi session** in `.pi/adx-flow/state/<sessionId>.json`
189
189
  (criteria, findings, execution state, plan-mode and spec-mode flags) — each session has its
190
190
  own plan, and resuming a session restores exactly that session's plan/run.
191
- A new session starts fresh. On the first per-session load, an in-flight run
192
- still in the legacy shared `.pi/adx-flow/state.json` (status ≠ done) is
193
- adopted into the new session's file once, so pre-existing runs keep working.
191
+ **New sessions (`/new`, fork) start 100% clean**: no execution is restored and no
192
+ step auto-executes — an in-flight run left behind by a previous session stays in
193
+ *that* session's own file and comes back only when you re-resume it. When a resumed
194
+ session has no per-session file yet, an in-flight run still in the legacy shared
195
+ `.pi/adx-flow/state.json` (status ≠ done) is adopted into its file once, so
196
+ pre-per-session runs keep working — new sessions never touch the legacy file.
194
197
  Flow sub-agents (verifier / code reviewer) write to the *parent's* state file
195
198
  (passed via the `ADX_FLOW_STATE` env var) so their `flow_meta` updates land
196
199
  in the driving session's store. It survives pi restarts — `session_start`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-adx-workflow",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Declarative plan → build → verify → review workflow for pi-coding-agent, with in-house isolated sub-agents and never-stop auto-continue.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/executor.ts CHANGED
@@ -156,6 +156,31 @@ export class AdxExecutor {
156
156
  )
157
157
  }
158
158
 
159
+ /**
160
+ * Clear in-memory run state when the pi process switches to a brand-new
161
+ * session (/new, fork): the previous session's run/workflow/sessionId must
162
+ * not leak into the new one (builder prompt section, persist() target,
163
+ * isActive() checks). Called from session_start (reason "new"/"fork")
164
+ * before any restore; the new session's own state file is (by definition)
165
+ * empty, so nothing is re-entered.
166
+ */
167
+ resetForNewSession(): void {
168
+ this.run = undefined
169
+ this.workflow = undefined
170
+ this.sessionId = undefined
171
+ this.cwd = undefined
172
+ this.stepDoneCalled = false
173
+ this.autoContinueCount = 0
174
+ this.resumeReentry = false
175
+ // Template-context caches: clear them too so no cross-session value can
176
+ // surface before the new session's first persist refreshes them (all
177
+ // readers are already guarded against undefined).
178
+ this.lastCtx = undefined
179
+ this.projectStatsCache = undefined
180
+ this._lastState = undefined
181
+ this.deps.updateWidget?.()
182
+ }
183
+
159
184
  // ------------------------------------------------------------------ launch
160
185
  async launch(workflowId: string, ctx: ExtensionContext, rawArgs?: string): Promise<void> {
161
186
  this.bindCtx(ctx)
package/src/index.ts CHANGED
@@ -37,10 +37,10 @@ import {
37
37
  getSpecModeTools,
38
38
  planModeBashGuard,
39
39
  } from './modes.js'
40
- import { loadState, mutateState, onStateWarning } from './store.js'
40
+ import { loadState, mutateState, onStateWarning, setLegacyAdoption } from './store.js'
41
41
  import { composeSpecMarkdown, defaultSpecPath, specPathError, writeSpecFile } from './spec.js'
42
42
  import { relative, resolve as resolvePath } from 'node:path'
43
- import { refreshFlowWidget, clearFlowWidget, setModeBadge } from './widgets.js'
43
+ import { refreshFlowWidget, clearFlowWidget, setModeBadge, type ModeBadgeSpec } from './widgets.js'
44
44
  import { createAskUserTool } from './ask-user.js'
45
45
  import { loadConfig, type AdxConfig } from './config.js'
46
46
  import {
@@ -76,30 +76,41 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
76
76
  let config: AdxConfig = {}
77
77
  /** Pi session id — keys the per-session adx state file (own plan per session). */
78
78
  let sessionId: string | undefined
79
+ /** Latest pending plan/spec flag persist — launch paths await this chain. */
80
+ let pendingModePersist: Promise<unknown> = Promise.resolve()
79
81
 
80
- /** Persist the plan-mode flag into the session's state file (resume-friendly). */
81
- function persistPlanMode(on: boolean): void {
82
+ /**
83
+ * Persist the plan-mode flag into the session's state file (resume-friendly).
84
+ * Returns the write promise: launch paths AWAIT it so the executor's next
85
+ * persist() (load + save) sees planMode=false — a fire-and-forget write
86
+ * used to lose that race, leaving finished runs with planMode=true.
87
+ */
88
+ function persistPlanMode(on: boolean): Promise<void> {
82
89
  const c = ctx
83
- if (!c) return
90
+ if (!c) return Promise.resolve()
84
91
  const sid = sessionId ?? c.sessionManager.getSessionId()
85
- void mutateState(c.cwd, sid, (state) => {
92
+ const p = mutateState(c.cwd, sid, (state) => {
86
93
  state.planMode = on
87
94
  }).catch(() => {
88
95
  /* best effort — the in-memory flag is authoritative */
89
96
  })
97
+ pendingModePersist = p
98
+ return p
90
99
  }
91
100
 
92
101
  /** Persist the spec-mode flag (and its pinned spec path) for resume. */
93
- function persistSpecMode(on: boolean, path?: string): void {
102
+ function persistSpecMode(on: boolean, path?: string): Promise<void> {
94
103
  const c = ctx
95
- if (!c) return
104
+ if (!c) return Promise.resolve()
96
105
  const sid = sessionId ?? c.sessionManager.getSessionId()
97
- void mutateState(c.cwd, sid, (state) => {
106
+ const p = mutateState(c.cwd, sid, (state) => {
98
107
  state.specMode = on
99
108
  state.specPath = on ? path : undefined
100
109
  }).catch(() => {
101
110
  /* best effort — the in-memory flag is authoritative */
102
111
  })
112
+ pendingModePersist = p
113
+ return p
103
114
  }
104
115
 
105
116
  // ------------------------------------------------------------------ flag
@@ -172,9 +183,9 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
172
183
  return executor.launch(workflow, c, args)
173
184
  },
174
185
  withConfigDefaults,
175
- exitPlanMode: () => {
186
+ exitPlanMode: async () => {
176
187
  const c = ctx
177
- if (c) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
188
+ if (c) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
178
189
  },
179
190
  specOnly: async () => {
180
191
  // "Spec only — stop": export the stored criteria as a spec document
@@ -204,13 +215,13 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
204
215
  onWritten: (absPath) => {
205
216
  const c = ctx
206
217
  specPath = absPath
207
- if (specMode) exitSpecMode((m, l) => c?.ui.notify(m, l ?? 'info'))
218
+ if (specMode) void exitSpecMode((m, l) => c?.ui.notify(m, l ?? 'info'))
208
219
  },
209
220
  }),
210
221
  )
211
222
 
212
223
  // ------------------------------------------------------------------ mode helpers
213
- function showBadge(badge: string | undefined): void {
224
+ function showBadge(badge: ModeBadgeSpec | undefined): void {
214
225
  if (config.showWidget === false) badge = undefined
215
226
  if (ctx) setModeBadge(ctx, badge, sessionId)
216
227
  }
@@ -218,26 +229,35 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
218
229
  function enterPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
219
230
  if (planMode) return
220
231
  // Plan mode and spec mode are mutually exclusive read-only modes.
221
- if (specMode) exitSpecMode(() => {})
232
+ if (specMode) void exitSpecMode(() => {})
222
233
  planMode = true
223
234
  toolsBeforePlanMode = pi.getActiveTools()
224
235
  let tools = activatePlanModeTools(pi.getActiveTools())
225
236
  // Auto mode is unattended: no interactive question tool.
226
237
  if (autoMode) tools = tools.filter((t) => t !== 'ask_user')
227
238
  pi.setActiveTools(tools)
228
- persistPlanMode(true)
229
- showBadge(autoMode ? 'adx: [AUTO] plan → build → verify → recap' : 'adx: [PLAN] read-only · /adx-approve to exit · /adx-run to start')
239
+ void persistPlanMode(true)
240
+ showBadge(
241
+ autoMode
242
+ ? { mode: 'auto', detail: 'plan → build → verify → recap' }
243
+ : { mode: 'plan', detail: 'read-only · /adx-approve to exit · /adx-run to start' },
244
+ )
230
245
  notify('adx: plan mode ON (read-only). Propose acceptance criteria with flow_meta. Exit: /adx-approve or Ctrl+Alt+A.', 'info')
231
246
  }
232
247
 
233
- function exitPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
248
+ async function exitPlanMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void): Promise<void> {
234
249
  if (!planMode) return
235
250
  planMode = false
236
251
  pi.setActiveTools(getBuildModeTools(toolsBeforePlanMode ?? pi.getActiveTools()))
237
252
  toolsBeforePlanMode = undefined
238
- persistPlanMode(false)
253
+ // Badge + notification run synchronously so a fire-and-forget plan↔spec
254
+ // switch (enterSpecMode → void exitPlanMode) can't be clobbered by the
255
+ // pending continuation after the new mode's badge is already set.
239
256
  showBadge(undefined)
240
257
  notify('adx: plan mode OFF (build mode).', 'info')
258
+ // Last: the persist must land before any launch persist that follows
259
+ // (exitReadOnlyModes / flow_plan_done await this function for that).
260
+ await persistPlanMode(false)
241
261
  }
242
262
 
243
263
  function enterSpecMode(
@@ -246,34 +266,47 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
246
266
  ): void {
247
267
  if (specMode) return
248
268
  // Spec mode and plan mode are mutually exclusive read-only modes.
249
- if (planMode) exitPlanMode(() => {})
269
+ if (planMode) void exitPlanMode(() => {})
250
270
  specMode = true
251
271
  toolsBeforeSpecMode = pi.getActiveTools()
252
272
  pi.setActiveTools(getSpecModeTools(pi.getActiveTools()))
253
273
  specPath = pathArg || undefined
254
- persistSpecMode(true, specPath)
255
- showBadge(`adx: [SPEC] read-only · /adx-approve to exit${specPath ? ` · spec: ${specPath}` : ''}`)
274
+ void persistSpecMode(true, specPath)
275
+ showBadge({ mode: 'spec', detail: `read-only · /adx-approve to exit${specPath ? ` · spec: ${specPath}` : ''}` })
256
276
  notify(
257
277
  'adx: spec mode ON (read-only brainstorm/research). Explore, research, then call spec_write ONCE with the full spec markdown. Exit: /adx-approve or Ctrl+Alt+A.',
258
278
  'info',
259
279
  )
260
280
  }
261
281
 
262
- function exitSpecMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void) {
282
+ async function exitSpecMode(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void): Promise<void> {
263
283
  if (!specMode) return
264
284
  specMode = false
265
285
  pi.setActiveTools(getBuildModeTools(toolsBeforeSpecMode ?? pi.getActiveTools()))
266
286
  toolsBeforeSpecMode = undefined
267
287
  specPath = undefined
268
- persistSpecMode(false)
288
+ // Badge + notification run synchronously (same reason as exitPlanMode).
269
289
  showBadge(undefined)
270
290
  notify('adx: spec mode OFF (build mode).', 'info')
291
+ await persistSpecMode(false)
271
292
  }
272
293
 
273
- /** Exit whichever read-only mode (plan/spec) is active — used before launching a build. */
274
- function exitReadOnlyModes(notify: (msg: string, level?: 'info' | 'warning' | 'error') => void): void {
275
- if (specMode) exitSpecMode(notify)
276
- if (planMode) exitPlanMode(notify)
294
+ /**
295
+ * Exit whichever read-only mode (plan/spec) is active — used before
296
+ * launching a build. Awaited: the flag persists must land BEFORE the
297
+ * executor's launch persist() (which loads then saves the state file),
298
+ * or the executor would write back the stale planMode=true.
299
+ */
300
+ async function exitReadOnlyModes(
301
+ notify: (msg: string, level?: 'info' | 'warning' | 'error') => void,
302
+ ): Promise<void> {
303
+ if (specMode) await exitSpecMode(notify)
304
+ if (planMode) await exitPlanMode(notify)
305
+ // A fire-and-forget exit (e.g. the Ctrl+Alt+A shortcut) may still be
306
+ // persisting planMode=false: await the pending persist so the launch's
307
+ // loadState (which reads the file directly) cannot read it back stale
308
+ // and write it over the flag.
309
+ await pendingModePersist
277
310
  }
278
311
 
279
312
  // ------------------------------------------------------------------ commands
@@ -309,7 +342,7 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
309
342
  return
310
343
  }
311
344
  }
312
- if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
345
+ if (planMode) void exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
313
346
  enterSpecMode((m, l) => c.ui.notify(m, l ?? 'info'), raw || undefined)
314
347
  },
315
348
  })
@@ -318,8 +351,8 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
318
351
  // without launching a workflow.
319
352
  const approveHandler = async (_args: string, c: ExtensionCommandContext) => {
320
353
  ctx = c
321
- if (specMode) exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
322
- else if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
354
+ if (specMode) await exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
355
+ else if (planMode) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
323
356
  else c.ui.notify('adx: already in build mode (plan mode is off)', 'info')
324
357
  }
325
358
  pi.registerCommand('adx-approve', {
@@ -331,7 +364,7 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
331
364
  // configured default one, or an explicit workflow id as the first token.
332
365
  const runHandler = async (args: string, c: ExtensionCommandContext) => {
333
366
  ctx = c
334
- exitReadOnlyModes((m, l) => c.ui.notify(m, l ?? 'info'))
367
+ await exitReadOnlyModes((m, l) => c.ui.notify(m, l ?? 'info'))
335
368
  // First bare token (no '=') names the workflow; the rest are key=value params.
336
369
  const tokens = (args ?? '').trim().split(/\s+/).filter(Boolean)
337
370
  const first = tokens[0]
@@ -488,22 +521,59 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
488
521
  description: 'Toggle adx plan/spec mode (spec mode exits; plan mode enters/toggles)',
489
522
  handler: async (c) => {
490
523
  ctx = c
491
- if (specMode) exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
492
- else if (planMode) exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
524
+ if (specMode) await exitSpecMode((m, l) => c.ui.notify(m, l ?? 'info'))
525
+ else if (planMode) await exitPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
493
526
  else enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
494
527
  },
495
528
  })
496
529
 
497
530
  // ------------------------------------------------------------------ events
498
- pi.on('session_start', async (_event, c) => {
531
+ pi.on('session_start', async (event, c) => {
499
532
  ctx = c
500
533
  sessionId = c.sessionManager.getSessionId()
501
534
  // First run: give the user a ready-to-edit config file with the defaults.
502
535
  const created = ensureUserConfig()
503
536
  if (created) c.ui.notify(`adx: created default config at ${created} (edit to customize)`, 'info')
504
537
  config = loadConfig(c.cwd)
505
- // Restore this session's persisted state (per-session file: resuming a
506
- // session brings back its plan/run; a new session starts fresh).
538
+
539
+ if (event.reason === 'new' || event.reason === 'fork') {
540
+ // /new or fork: a brand-new session starts 100% clean — no legacy
541
+ // adoption, no execution restore, no plan/spec mode inherited from a
542
+ // state file. The previous session's in-flight run (if any) stays in
543
+ // ITS per-session state file and is picked up by re-resuming that
544
+ // session. Config-driven entry (auto / defaultPlanMode) still applies.
545
+ setLegacyAdoption(false)
546
+ executor.resetForNewSession()
547
+ // The closure (and the widget's mode badge) lives for the whole pi
548
+ // process, so also drop the PREVIOUS session's in-memory mode state —
549
+ // without this, /new after a plan/spec session would start the new
550
+ // session still in read-only mode (prompt section + tool blocking +
551
+ // footer badge all inherited).
552
+ autoMode = false
553
+ if (planMode || specMode) {
554
+ const saved = planMode ? toolsBeforePlanMode : toolsBeforeSpecMode
555
+ planMode = false
556
+ specMode = false
557
+ specPath = undefined
558
+ toolsBeforePlanMode = undefined
559
+ toolsBeforeSpecMode = undefined
560
+ pendingModePersist = Promise.resolve()
561
+ pi.setActiveTools(getBuildModeTools(saved ?? pi.getActiveTools()))
562
+ showBadge(undefined)
563
+ }
564
+ if (pi.getFlag('adx-auto') === true || config.autoMode === true) {
565
+ autoMode = true
566
+ enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
567
+ } else if (config.defaultPlanMode === true) {
568
+ enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
569
+ }
570
+ return
571
+ }
572
+
573
+ // resume / startup / reload: restore this session's persisted state
574
+ // (per-session file; a legacy in-flight run is adopted as a one-time
575
+ // migration, resuming the plan/run from the last stop).
576
+ setLegacyAdoption(true)
507
577
  const state = await loadState(c.cwd, sessionId)
508
578
  if (state.execution && state.execution.status !== 'done') {
509
579
  c.ui.notify(`adx: restoring workflow "${state.execution.workflowName}" (status: ${state.execution.status})`, 'info')
@@ -520,6 +590,10 @@ export default function adxWorkflowExtension(pi: ExtensionAPI): void {
520
590
  } else if (state.planMode === true) {
521
591
  // The session was stopped mid-plan: restore plan mode so the user can
522
592
  // pick up the unfinished plan (criteria are already in the state file).
593
+ // A finished run leaves planMode=false (the launch path persists it
594
+ // before the first executor persist), so this only fires for a plan
595
+ // that was genuinely left open — including a fresh plan started after
596
+ // a previous run finished.
523
597
  enterPlanMode((m, l) => c.ui.notify(m, l ?? 'info'))
524
598
  c.ui.notify('adx: plan mode restored (unfinished plan from last stop — continue with flow_meta, then /adx-run)', 'info')
525
599
  } else if (state.specMode === true) {
package/src/store.ts CHANGED
@@ -265,6 +265,24 @@ async function adoptLegacy(cwd: string, sessionId: string): Promise<FlowState |
265
265
 
266
266
  let versionNotified = false
267
267
 
268
+ /**
269
+ * Legacy shared-file adoption switch — enabled only while a RESUMED session
270
+ * ("startup"/"resume" session_start) loads state: a legacy in-flight run in
271
+ * the shared state.json is then adopted as a one-time migration for sessions
272
+ * created before per-session state existed. Brand-new sessions (/new, fork)
273
+ * run with it disabled: they start 100% clean, so a stale in-flight legacy
274
+ * run cannot auto-execute in them (it stays in the previous session's own
275
+ * state file, resumable by re-resuming that session).
276
+ */
277
+ let legacyAdoption = true
278
+ export function setLegacyAdoption(enabled: boolean): void {
279
+ legacyAdoption = enabled
280
+ }
281
+ /** Current adoption gate (the widget's legacy fallback mirrors the store's). */
282
+ export function legacyAdoptionEnabled(): boolean {
283
+ return legacyAdoption
284
+ }
285
+
268
286
  export async function loadState(cwd: string, sessionId?: string): Promise<FlowState> {
269
287
  const parsed = await readRaw(stateFileFor(cwd, sessionId))
270
288
  if (parsed !== undefined) {
@@ -278,8 +296,9 @@ export async function loadState(cwd: string, sessionId?: string): Promise<FlowSt
278
296
  }
279
297
  return normalizeState(parsed)
280
298
  }
281
- // New per-session file: try to adopt a legacy in-flight run (shared state.json).
282
- if (sessionId && process.env.ADX_CHILD !== '1') {
299
+ // New per-session file: try to adopt a legacy in-flight run (shared
300
+ // state.json) — only for resumed sessions (see setLegacyAdoption).
301
+ if (sessionId && process.env.ADX_CHILD !== '1' && legacyAdoption) {
283
302
  const adopted = await adoptLegacy(cwd, sessionId)
284
303
  if (adopted) return adopted
285
304
  }
package/src/tools.ts CHANGED
@@ -278,8 +278,13 @@ export interface FlowPlanDoneDeps {
278
278
  launch: (workflow: 'build' | 'build-verify', args: string) => Promise<void>
279
279
  /** Apply the config's skipReview default to launch args. */
280
280
  withConfigDefaults: (rawArgs: string) => string
281
- /** Exit plan mode (restore build tools, clear badge) + notify. */
282
- exitPlanMode: () => void
281
+ /**
282
+ * Exit plan mode (restore build tools, clear badge) + notify. Awaited
283
+ * before launching: the planMode=false persist must land before the
284
+ * executor's launch persist() (load + save), or the stale flag would be
285
+ * written back over it.
286
+ */
287
+ exitPlanMode: () => Promise<void>
283
288
  /**
284
289
  * "Spec only — stop": compose a spec markdown from the stored criteria,
285
290
  * write it to the default .pi/specs/<slug>.md, return the written path.
@@ -313,7 +318,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
313
318
  ) {
314
319
  if (deps.autoMode()) {
315
320
  // Unattended pipeline: straight into build-verify, no dialogs.
316
- deps.exitPlanMode()
321
+ await deps.exitPlanMode()
317
322
  deps.notify('adx: auto mode — planning complete, launching build-verify (no review)', 'info')
318
323
  await deps.launch('build-verify', 'auto=true skip_review=true')
319
324
  return {
@@ -369,7 +374,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
369
374
  // Export the stored criteria as a spec document and stop here:
370
375
  // exit plan mode, launch nothing.
371
376
  const path = await deps.specOnly()
372
- deps.exitPlanMode()
377
+ await deps.exitPlanMode()
373
378
  deps.notify(`adx: spec saved to ${path} — no build launched`, 'info')
374
379
  return {
375
380
  content: [
@@ -400,7 +405,7 @@ export function createFlowPlanDoneTool(deps: FlowPlanDoneDeps) {
400
405
  const locArg = `worklocation=${loc.startsWith('Work in current') ? 'current' : 'new'}`
401
406
  args = workflow === 'build-verify' ? deps.withConfigDefaults(locArg) : locArg
402
407
  }
403
- deps.exitPlanMode()
408
+ await deps.exitPlanMode()
404
409
  deps.notify(`adx: launching ${workflow}${args ? ` (${args})` : ''}`, 'info')
405
410
  await deps.launch(workflow, args)
406
411
  return {
package/src/widgets.ts CHANGED
@@ -14,12 +14,34 @@
14
14
  import { readFileSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
17
- import { stateFileFor, type FlowState } from './store.js'
17
+ import { stateFileFor, legacyAdoptionEnabled, type FlowState } from './store.js'
18
18
 
19
19
  const ADX_KEY = 'adx-flow' // single shared key (widget + status line) — was two identical constants
20
20
 
21
+ /** Read-only mode the footer badge advertises. */
22
+ export type ModeKind = 'plan' | 'auto' | 'spec'
23
+ /**
24
+ * Footer mode badge, stored as a spec (mode + detail) rather than a
25
+ * pre-cooked ANSI string, so it re-renders with the *current* theme on
26
+ * every widget refresh — a mid-session theme switch leaves no stale colors.
27
+ */
28
+ export interface ModeBadgeSpec {
29
+ mode: ModeKind
30
+ /** Rest of the badge line, e.g. "read-only · /adx-approve to exit". */
31
+ detail: string
32
+ }
33
+ /**
34
+ * Per-mode chip styling: theme token pair (foreground text + background
35
+ * fill). The bg tokens are optional in user themes — a missing/empty token
36
+ * degrades the chip to bold colored text (graceful, still distinct).
37
+ */
38
+ const MODE_CHIP_STYLES: Record<ModeKind, { tag: string; fg: string; bg: string }> = {
39
+ plan: { tag: '[PLAN]', fg: 'warning', bg: 'toolPendingBg' },
40
+ auto: { tag: '[AUTO]', fg: 'accent', bg: 'selectedBg' },
41
+ spec: { tag: '[SPEC]', fg: 'success', bg: 'toolSuccessBg' },
42
+ }
21
43
  /** Current mode badge shown in the footer (set by the extension on mode changes). */
22
- let modeBadge: string | undefined
44
+ let modeBadge: ModeBadgeSpec | undefined
23
45
 
24
46
  /** Spinner frames for the sub-agent activity widget. */
25
47
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -64,7 +86,7 @@ function readRaw(path: string): FlowState | undefined {
64
86
  function readState(cwd: string, sessionId?: string): FlowState | undefined {
65
87
  const state = readRaw(stateFileFor(cwd, sessionId))
66
88
  if (state) return state
67
- if (sessionId && process.env.ADX_CHILD !== '1') {
89
+ if (sessionId && process.env.ADX_CHILD !== '1' && legacyAdoptionEnabled()) {
68
90
  const legacy = readRaw(join(cwd, '.pi', 'adx-flow', 'state.json'))
69
91
  if (legacy?.execution && legacy.execution.status !== 'done' && !legacy.execution.migratedTo) {
70
92
  return legacy
@@ -84,26 +106,56 @@ function truncate(text: string, max: number): string {
84
106
  return text.length > max ? text.slice(0, max - 1) + '…' : text
85
107
  }
86
108
 
109
+ /** Minimal theme surface used for footer styling (full Theme in the TUI,
110
+ * PLAIN_THEME fallback elsewhere). `bg` is optional: test fakes and
111
+ * older contexts may lack it. */
112
+ interface ThemeLike {
113
+ // `any` color: the real Theme takes a ThemeColor token union, the plain
114
+ // fallback takes anything — `any` keeps both assignable.
115
+ fg: (color: any, text: string) => string
116
+ bold: (text: string) => string
117
+ strikethrough: (text: string) => string
118
+ bg?: (color: any, text: string) => string
119
+ }
87
120
  /** Theme fallback for contexts without `ui.theme` (non-interactive modes, tests). */
88
- const PLAIN_THEME = {
89
- fg: (_color: string, text: string): string => text,
90
- bold: (text: string): string => text,
91
- strikethrough: (text: string): string => text,
121
+ const PLAIN_THEME: ThemeLike = {
122
+ fg: (_color, text) => text,
123
+ bold: (text) => text,
124
+ strikethrough: (text) => text,
125
+ bg: (_color, text) => text,
126
+ }
127
+ /** Current theme, or the plain-text fallback. */
128
+ function themeFor(ui: { theme?: ThemeLike }): ThemeLike {
129
+ return ui.theme ?? PLAIN_THEME
130
+ }
131
+ /**
132
+ * Render the footer mode badge: a themed chip (bold colored tag on a
133
+ * background fill, per mode) + muted detail text. Plain text when the
134
+ * theme is absent. The literal tag ([PLAN]/[AUTO]/[SPEC]) is preserved
135
+ * around the ANSI codes (e2e checks rely on it).
136
+ */
137
+ function renderModeBadge(theme: ThemeLike, spec: ModeBadgeSpec): string {
138
+ const s = MODE_CHIP_STYLES[spec.mode]
139
+ const inner = ` ${theme.fg(s.fg, theme.bold(s.tag))} `
140
+ const chip = theme.bg ? theme.bg(s.bg, inner) : inner
141
+ // The chip is padded on both sides, so the prefix carries no trailing
142
+ // space (avoids the doubled 'adx: [PLAN]' gap).
143
+ return `adx:${chip}${spec.detail ? ` ${theme.fg('muted', spec.detail)}` : ''}`
92
144
  }
93
145
 
94
146
  /** Render the adx-flow widget (criteria + step) or clear it when idle. */
95
147
  export function refreshFlowWidget(ctx: ExtensionContext, sessionId?: string): void {
96
148
  const state = readState(ctx.cwd, sessionId)
97
149
  const ui = ctx.ui
150
+ const theme = themeFor(ui)
98
151
 
99
152
  if (!state || (!state.execution && Object.keys(state.metadata).length === 0)) {
100
153
  ui.setWidget(ADX_KEY, undefined)
101
- ui.setStatus(ADX_KEY, modeBadge)
154
+ ui.setStatus(ADX_KEY, modeBadge ? renderModeBadge(theme, modeBadge) : undefined)
102
155
  return
103
156
  }
104
157
 
105
158
  const lines: string[] = []
106
- const theme = ui.theme ?? PLAIN_THEME
107
159
 
108
160
  // Header: workflow + step + iteration
109
161
  if (state.execution) {
@@ -151,28 +203,31 @@ export function refreshFlowWidget(ctx: ExtensionContext, sessionId?: string): vo
151
203
 
152
204
  ui.setWidget(ADX_KEY, lines)
153
205
 
154
- // Footer status line (compact)
206
+ // Footer status line (compact, theme-styled)
155
207
  if (state.execution) {
156
208
  const passed = criteria.filter((c) => c.status === 'passed').length
209
+ const name = theme.fg('accent', state.execution.workflowName)
157
210
  const footer =
158
211
  state.execution.status === 'done'
159
- ? `✅ adx: ${state.execution.workflowName} done`
160
- : `adx: ${state.execution.workflowName} · ${state.execution.currentStep} · ${passed}/${criteria.length || 0} criteria`
212
+ ? `${theme.fg('success', '✅')} adx: ${name} ${theme.fg('muted', 'done')}`
213
+ : `adx: ${name} ${theme.fg('muted', `· ${state.execution.currentStep} · ${passed}/${criteria.length || 0} criteria`)}`
161
214
  ui.setStatus(ADX_KEY, footer)
162
215
  } else if (modeBadge) {
163
- ui.setStatus(ADX_KEY, modeBadge)
216
+ ui.setStatus(ADX_KEY, renderModeBadge(theme, modeBadge))
164
217
  } else {
165
218
  ui.setStatus(ADX_KEY, undefined)
166
219
  }
167
220
  }
168
221
 
169
222
  /**
170
- * Set (or clear) the footer mode badge, e.g. `adx: [PLAN] read-only`.
223
+ * Set (or clear) the footer mode badge, e.g. the `[PLAN]` chip +
224
+ * "read-only · /adx-approve to exit". The spec is stored and re-rendered
225
+ * with the current theme on every refresh (theme switches stay in sync).
171
226
  * The badge survives widget refreshes while no workflow is running; an
172
227
  * active workflow's status line takes precedence over it (a finished run
173
228
  * does not block the badge).
174
229
  */
175
- export function setModeBadge(ctx: ExtensionContext, badge: string | undefined, sessionId?: string): void {
230
+ export function setModeBadge(ctx: ExtensionContext, badge: ModeBadgeSpec | undefined, sessionId?: string): void {
176
231
  modeBadge = badge
177
232
  const state = readState(ctx.cwd, sessionId)
178
233
  const active =
@@ -181,7 +236,7 @@ export function setModeBadge(ctx: ExtensionContext, badge: string | undefined, s
181
236
  if (!active) ctx.ui.setStatus(ADX_KEY, undefined)
182
237
  return
183
238
  }
184
- if (!active) ctx.ui.setStatus(ADX_KEY, badge)
239
+ if (!active) ctx.ui.setStatus(ADX_KEY, renderModeBadge(themeFor(ctx.ui), badge))
185
240
  }
186
241
 
187
242
  /** Clear the widget + status (e.g. on /adx-cancel). */
@@ -310,6 +365,6 @@ export function setSubAgentActivity(
310
365
 
311
366
  ui.setStatus(
312
367
  ADX_KEY,
313
- `adx: ${activity.agent} · "${activity.step}" running…`,
368
+ `adx: ${themeFor(ui).fg('accent', activity.agent)} ${themeFor(ui).fg('muted', `· "${activity.step}" running…`)}`,
314
369
  )
315
370
  }