pi-squad 0.17.0 → 0.17.2

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
@@ -90,6 +90,14 @@ squad_modify({ action: "resume_task", squadId: "<exact-squad-id>", taskId: "<exa
90
90
 
91
91
  Repeated reconciliation and restart do not create notification storms. A delivered stall remains visible until explicit resumption changes the state; a different suspended/blocked set is a new actionable episode.
92
92
 
93
+ ### Persistent Master Switch
94
+
95
+ `/squad disable` persists a global master switch in `~/.pi/squad/settings.json`. It first saves the disabled state, then stops schedulers, durably suspends in-progress tasks, kills child processes, clears focus, and hides the widget. On later Pi restarts, disabled mode does not reconstruct schedulers, recover mail, resume sessions, normalize tasks, or select a squad.
96
+
97
+ While disabled, all five squad tools and every `/squad` operation fail closed with `/squad enable` guidance; only exact `/squad enable` and the idempotent exact `/squad disable` control commands run. Tool schemas remain registered. Mandatory pending/failed review gates and suspended-stall reminders remain injected because they are durable safety obligations, but they do not authorize squad work while disabled.
98
+
99
+ `/squad enable` persists the enabled state and restores widget availability without selecting a squad or resuming anything. Suspended work remains suspended until an explicit exact-task resume or `/squad resume <squad-id>`.
100
+
93
101
  ### QA Rework Loop
94
102
 
95
103
  When a QA agent outputs `## Verdict: FAIL`, the scheduler automatically:
@@ -217,7 +225,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
217
225
  | `/squad cancel` | Cancel the visibly focused squad; the notification names it and the focused widget/status clear |
218
226
  | `/squad clear` | Dismiss widget |
219
227
  | `/squad cleanup` | Delete squad data |
220
- | `/squad enable/disable` | Enable/disable the extension |
228
+ | `/squad enable/disable` | Persistently enable/disable all squad execution; enabling never auto-resumes work |
221
229
 
222
230
  ## Tools (LLM-callable)
223
231
 
@@ -225,6 +233,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
225
233
  |---|---|
226
234
  | `squad` | Start a squad with goal + optional tasks/config |
227
235
  | `squad_status` | Check progress, costs, task states |
236
+ | `squad_review` | Record the main orchestrator's independent acceptance review |
228
237
  | `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
229
238
  | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`; exact `squadId` is required for destructive whole-squad `cancel` and recommended for all task actions |
230
239
 
@@ -331,7 +340,7 @@ Agents must complete at least one LLM turn and produce either a tool call or a s
331
340
 
332
341
  - Every task owns one durable Pi session. New tasks create it under their task directory; stale, suspended, failed, or explicitly reopened tasks resume that same session rather than starting over. A pre-prompt retry may refresh Pi's provisional session ID only while the bound file is the same and its JSONL has not materialized; afterward both file and ID are immutable.
333
342
  - Legacy tasks without a session binding migrate on first reopen by creating a task-owned session and seeding its first prompt with the complete persisted multiline message history and prior task output—without truncation.
334
- - In-progress tasks are **suspended** on orderly shutdown; ordinary orphaned work is paused for explicit resume. If a scheduler itself is reconstructed, stale `in_progress` state is resumed by reconciliation. Independently, extension startup always reconstructs any project squad with pending mailbox entries—including `review` or already accepted `done` squads—and resumes delivery without another user action.
343
+ - In-progress tasks are **suspended** on orderly shutdown; ordinary orphaned work is paused for explicit resume. When the persistent master switch is enabled, a reconstructed scheduler can resume stale `in_progress` state through reconciliation, and startup reconstructs project squads with pending mailbox entries—including `review` or already accepted `done` squads—to resume delivery. Disabled startup performs none of this recovery.
335
344
  - A durable message to a completed task clears its prior completion/review state, reopens the squad, keeps the task `in_progress` while its agent is live, and requires a fresh orchestrator review after the final `agent_settled`. Every transitive descendant is re-blocked and reruns in dependency order, so results derived from the reopened dependency cannot remain falsely complete.
336
345
  - Failure is never terminal: `resume` recovers failed squads (failed tasks reset to pending), `complete_task` marks recovered work done and schedules dependents, and a 60s reconcile loop re-derives scheduling from persisted state so out-of-band store edits can't strand ready tasks.
337
346
  - Mail is acknowledged only after Pi accepts the correlated RPC command. Pending and acknowledged entries remain task-addressed on disk, survive process restart, and remain visible in history. Queue and acknowledgement read/modify/write operations are serialized across processes so concurrent mutations cannot overwrite messages or delivery state.
@@ -355,6 +364,7 @@ All state in `~/.pi/squad/`. No database, no daemon. Writes are atomic. JSONL re
355
364
 
356
365
  ```
357
366
  ~/.pi/squad/
367
+ ├── settings.json — persistent master switch, defaults, and advisor settings
358
368
  ├── agents/ — agent definitions (user-editable)
359
369
  ├── debug.log — error and debug logging
360
370
  └── {squad-id}/
@@ -0,0 +1,148 @@
1
+ # Persistent squad master-switch contract
2
+
3
+ Status: implementation contract (not current implementation truth)
4
+
5
+ ## 1. State and persistence
6
+
7
+ The master switch is global, not project- or session-local.
8
+
9
+ ```ts
10
+ interface SquadSettings {
11
+ enabled: boolean;
12
+ // existing defaultModel, defaultThinking, and advisor fields remain unchanged
13
+ }
14
+
15
+ DEFAULT_SQUAD_SETTINGS.enabled = true;
16
+ ```
17
+
18
+ - Persist the value at `~/.pi/squad/settings.json` through the existing atomic settings writer.
19
+ - A missing settings file, a legacy file without `enabled`, or a non-boolean `enabled` uses the backward-compatible default `true`. Other settings keep their existing deep-merge behavior.
20
+ - Every whole-settings write (including `/squad defaults` and `/squad advisor`) must preserve the effective `enabled` value.
21
+ - The extension loads the effective setting before any `session_start` widget installation, focus restoration, orphan cleanup, attestation audit, scheduler construction/start, suspended-stall recovery, mailbox recovery, or squad notification.
22
+ - Tools and the `/squad` command remain statically registered in both states. Registration is not permission to execute.
23
+ - The conditional child-only `squad_spec_read` registration is not a main-session squad execution path; its existing file-spec guard remains unchanged. Disabling the parent kills its child processes.
24
+
25
+ A failed settings write fails the control command. It must not update the in-memory master state or claim success.
26
+
27
+ ## 2. Disabled startup
28
+
29
+ When the loaded value is `false`, `session_start` is read-only with respect to squad work:
30
+
31
+ - do not create or start a `Scheduler`;
32
+ - do not audit or rewrite file-spec/task/squad state;
33
+ - do not normalize orphaned `in_progress` tasks;
34
+ - do not recover pending mail, resume sessions, acknowledge attention, or spawn children;
35
+ - do not select/focus a squad, install/show squad widget content, open a panel, or register an operational Ctrl+Q path;
36
+ - do not emit ordinary paused/recovery/completion/failure notifications.
37
+
38
+ The only lifecycle exception is the durable safety injection in section 7. Reading persisted review/attention records for that injection is allowed; reconstructing or mutating work is not.
39
+
40
+ An enabled startup retains existing recovery behavior and contracts. In particular, explicitly `suspended` tasks still never resume merely because Pi restarted.
41
+
42
+ ## 3. Public tool gate
43
+
44
+ The enabled check is the first operation in every main-session tool `execute` handler, before target inference, store lookup, focus changes, scheduler construction, mailbox writes, review writes, or other mutations. While disabled, every tool returns a non-throwing result with clear guidance such as:
45
+
46
+ ```text
47
+ pi-squad is disabled. Run /squad enable, then retry this operation; no squad work was changed.
48
+ ```
49
+
50
+ All five public tools registered in `src/index.ts` are covered:
51
+
52
+ | Tool | Disabled behavior |
53
+ |---|---|
54
+ | `squad` | No planning, publication, scheduler, task, or child creation. |
55
+ | `squad_status` | No latest/active lookup or context refresh; fail closed rather than offering a read-only bypass. |
56
+ | `squad_review` | No attestation scheduler/audit and no review mutation. The durable review gate remains pending/failed and injected. |
57
+ | `squad_message` | No mailbox write, scheduler reconstruction, task reopening, or delivery. |
58
+ | `squad_modify` | Every action fails closed, including add/set-dependencies/cancel/pause/resume/complete and whole-squad actions. |
59
+
60
+ Static schemas/descriptions remain available to the model, but a call cannot execute until enabled.
61
+
62
+ ## 4. Slash-command and UI gate
63
+
64
+ The `/squad` handler checks the master state before defaulting, resolving a direct squad ID, reading targets, or opening interactive UI.
65
+
66
+ While disabled:
67
+
68
+ - exact `/squad enable` is allowed;
69
+ - repeated `/squad disable` is an idempotent control-plane no-op that reports the extension is already disabled and points to `/squad enable`; it never reconstructs work;
70
+ - every other form returns one clear disabled notification and performs no squad-work or settings mutation.
71
+
72
+ This covers every registered branch and fallback in `src/index.ts`:
73
+
74
+ | Form | Disabled result |
75
+ |---|---|
76
+ | `/squad`, `/squad select`, `/squad list`, `/squad all`, `/squad <squad-id>` | No selection, focus, or store-driven UI. |
77
+ | `/squad resume [id]` | No scheduler construction or task/squad transition. |
78
+ | `/squad msg ...` | No mailbox or task mutation. |
79
+ | `/squad widget`, `/squad panel`, `/squad clear` | No view activation or panel/widget bypass. |
80
+ | `/squad cancel`, `/squad cleanup [all]` | No stopping/deleting/mutating persisted squads. |
81
+ | `/squad agents`, `/squad defaults`, `/squad advisor` | No agent/settings editor mutation while the master switch is off. |
82
+
83
+ Ctrl+Q and all already-created panel callbacks obey the same gate: while disabled they may consume the input and show enable guidance, but may not resolve/focus a squad, construct/start a scheduler, send mail, pause/resume/cancel tasks, or mutate work. If an overlay is open during disable, close it when supported; regardless, its callbacks must fail closed.
84
+
85
+ ## 5. `/squad disable` transition
86
+
87
+ From enabled state, ordering is mandatory:
88
+
89
+ 1. Load current settings, set `enabled: false`, and atomically persist them. If this fails, abort without claiming or entering disabled mode.
90
+ 2. Set the in-memory master state to false immediately after persistence, so concurrent public paths fail closed during shutdown.
91
+ 3. Stop every registered scheduler. Existing `Scheduler.stop()` ordering remains authoritative: stop monitor/reconcile, durably change each `in_progress` task to `suspended`, then kill all child processes. Await every stop/kill before reporting success.
92
+ 4. Clear the scheduler registry and active focus. Do not reconstruct an absent scheduler merely to disable it.
93
+ 5. Force `widgetState.enabled = false`, clear its squad target/status, request a render clear (or dispose the widget), and prevent late scheduler events from showing it again.
94
+ 6. Leave all pending/failed review evidence, review history, mailbox data, sessions, cancellation state, file-spec evidence, and suspended-stall attention intact.
95
+ 7. Notify truthfully that pi-squad is disabled and `/squad enable` is required.
96
+
97
+ The operation is idempotent. Repeating it while already disabled does not rewrite tasks, resume work, create schedulers, or duplicate children; it reports the already-disabled state. A task already suspended remains suspended.
98
+
99
+ ## 6. `/squad enable` transition and no-auto-resume rule
100
+
101
+ Ordering is symmetric at the persistence boundary:
102
+
103
+ 1. Load current settings, set `enabled: true`, and atomically persist them. On failure, remain disabled.
104
+ 2. Set the in-memory master state to true.
105
+ 3. Restore widget *availability* (`widgetState.enabled = true` and install/request its controls when UI exists), but keep focus cleared unless the user explicitly selects a squad. An empty widget is valid.
106
+ 4. Do not construct/start a scheduler, audit work, recover mail, reopen a task, resume a child session, select a squad, clear an attention record, or change any squad/task status.
107
+ 5. Notify: pi-squad is enabled; no suspended work was resumed; `/squad select` and an explicit `/squad resume <id>` or exact `squad_modify resume_task` may be required.
108
+
109
+ Repeated enable is idempotent: it converges persisted/in-memory enabled state and widget availability without spawning, selecting, resuming, or duplicating anything.
110
+
111
+ **Explicit consent is required after disable.** Work suspended by disable remains suspended across `/squad enable` and across disabled restarts. Selection is view-only. Only a later explicit resume operation may revive it, using the existing exact-target, task-session, mailbox, descendant-invalidation, and failed-review-history contracts.
112
+
113
+ ## 7. Authoritative safety reminders while disabled
114
+
115
+ Master disable suppresses ordinary squad hints/status prompts and operational event notifications, but it must never hide or weaken these persisted safety obligations:
116
+
117
+ 1. Every project squad in `review` keeps its complete mandatory orchestrator review gate injected by `before_agent_start`, across focus changes and while disabled. Candidate work remains untrusted and cannot become accepted. Because `squad_review` is fail-closed while disabled, the injected guidance must state that `/squad enable` is required before recording the independent review.
118
+ 2. Every active persisted `suspendedStallAttention` keeps its complete exact squad ID, suspended IDs, blocked IDs, and “No task was resumed automatically” reminder injected project-wide while disabled. Guidance must state: enable first, then explicitly resume only intended exact tasks.
119
+
120
+ These are read-only, durable prompt injections, not permission to reconstruct a scheduler, acknowledge an outbox record, auto-resume work, or call a blocked tool. Review evidence and suspended-attention data remain authoritative and untruncated.
121
+
122
+ ## 8. Widget and event behavior
123
+
124
+ The master switch outranks the separate session-local `/squad widget` preference.
125
+
126
+ - Disabled means no widget body and no squad status-line content, even if a prior widget toggle was on or a late scheduler event arrives.
127
+ - Disable clears focus and hides immediately after children are quiesced; enable restores the widget mechanism but does not infer a target.
128
+ - Ordinary `before_agent_start` squad hints/active status are absent while disabled; only section 7 safety text remains.
129
+ - Late scheduler callbacks after disable must not emit ordinary review/failure/reply/escalation UI or re-enable/focus the widget. Their durable state must still obey existing cancellation, suspension, and review guards.
130
+
131
+ ## 9. Required regression matrix
132
+
133
+ Implementation tests must prove persisted state as well as returned text:
134
+
135
+ 1. Missing/legacy settings default enabled; `false` and `true` survive extension/session restart; other settings survive both writes.
136
+ 2. Disabled startup creates zero schedulers/children and performs zero orphan, attestation, mailbox, focus, widget, or task/squad mutation.
137
+ 3. Each of the five registered public tools fails before lookup/reconstruction/mutation and includes `/squad enable` guidance.
138
+ 4. Every slash branch/fallback listed in section 4, default `/squad`, direct-ID activation, Ctrl+Q, and panel actions have no disabled bypass.
139
+ 5. Disable persists before stop, suspends live tasks durably, kills all children, clears scheduler/focus, and hides widget; a persistence failure leaves enabled runtime unchanged.
140
+ 6. Enable restores widget availability and clear guidance but does not select, reconstruct, recover, or resume work.
141
+ 7. Repeated enable/disable causes no duplicate scheduler/child, task rewrite, or auto-resume.
142
+ 8. Disabled restart and later enable leave suspended tasks suspended; only explicit exact resume changes them.
143
+ 9. Pending/failed review and delivered/pending suspended-attention reminders remain complete and injected while disabled, with enable-first guidance; no review/attention record is mutated.
144
+ 10. Existing cancellation, mandatory review, file-spec/attestation, mailbox/session, no-truncation, and suspended-stall suites remain green.
145
+
146
+ ## 10. Registration cross-check
147
+
148
+ `src/index.ts` currently exposes exactly five main-session tools (`squad`, `squad_status`, `squad_review`, `squad_message`, `squad_modify`) and one `/squad` command whose branches are `list`, `all`, `select`, `resume`, `widget`, `panel`, `msg`, `cancel`, `clear`, `cleanup`, `enable`, `disable`, `defaults`, `advisor`, `agents`, plus default selection and direct-ID fallback. Sections 3 and 4 cover every registration and branch. The session-start Ctrl+Q panel path and panel callbacks are covered separately because they are public execution surfaces even though they are not slash commands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -20,9 +20,9 @@ import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent, type Sch
20
20
  import { runPlanner } from "./planner.js";
21
21
  import { validatePlan, PLAN_STRUCTURE_RULES } from "./plan-rules.js";
22
22
  import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
23
- import { completeSimple, type Message, type TextContent } from "@earendil-works/pi-ai";
23
+ import { completeSimple, type Message, type TextContent } from "@earendil-works/pi-ai/compat";
24
24
  import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
25
- import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
25
+ import { setupSquadWidget, type SquadWidgetControls, type SquadWidgetState } from "./panel/squad-widget.js";
26
26
  import * as store from "./store.js";
27
27
  import { debug, logError } from "./logger.js";
28
28
  import { buildCompletionSummary, buildFailureSummary } from "./report.js";
@@ -41,19 +41,34 @@ interface InlineSquadStart {
41
41
  config?: { maxConcurrency?: number; autoUnblock?: boolean; maxRetries?: number };
42
42
  }
43
43
 
44
- /** Master switch — when false, all squad tools, hooks, and widget are disabled */
44
+ /** Master switch — loaded from ~/.pi/squad/settings.json before lifecycle work. */
45
45
  let squadEnabled = true;
46
+ const DISABLED_GUIDANCE = "pi-squad is disabled. Run /squad enable, then retry this operation; no squad work was changed.";
47
+ const disabledToolResult = () => ({
48
+ content: [{ type: "text" as const, text: DISABLED_GUIDANCE }],
49
+ details: undefined,
50
+ });
46
51
  /** Registry of all running schedulers — supports multiple concurrent squads */
47
52
  const schedulers = new Map<string, Scheduler>();
48
53
  /** The currently viewed/focused squad (for widget, panel, status) */
49
54
  let activeSquadId: string | null = null;
50
55
  /** Whether an overlay panel is currently open (prevents double-open) */
51
56
  let overlayOpen = false;
57
+ /** Close an already-open overlay when the master switch is disabled. */
58
+ let closeOverlay: (() => void) | null = null;
52
59
  /** Stored ExtensionContext for widget updates from background scheduler events */
53
60
  let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
54
61
  /** Component-based widget state + controls */
55
62
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
56
- let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
63
+ let widgetControls: SquadWidgetControls | null = null;
64
+
65
+ /** Keep main-session focus, compact widget, and detail panel targeting identical. */
66
+ function focusSquad(squadId: string | null): void {
67
+ activeSquadId = squadId;
68
+ widgetState.squadId = squadId;
69
+ if (squadId && squadEnabled) widgetState.enabled = true;
70
+ widgetControls?.refreshNow();
71
+ }
57
72
 
58
73
  /** Format completion against active work while retaining cancelled history. */
59
74
  function formatTaskProgress(tasks: Task[]): string {
@@ -197,11 +212,10 @@ function getActiveScheduler(): Scheduler | null {
197
212
 
198
213
  /** Restore the single focus/widget invariant after destructive exact cancellation. */
199
214
  function repairFocusAfterCancellation(cancelledId: string): void {
200
- if (activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))) {
201
- activeSquadId = null;
202
- }
203
- widgetState.squadId = activeSquadId;
204
- widgetControls?.requestUpdate();
215
+ const nextFocus = activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))
216
+ ? null
217
+ : activeSquadId;
218
+ focusSquad(nextFocus);
205
219
  }
206
220
 
207
221
  function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
@@ -218,10 +232,7 @@ function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]
218
232
  schedulers.set(squadId, scheduler);
219
233
  wireSchedulerEvents(pi, scheduler, squadId);
220
234
  }
221
- activeSquadId = squadId;
222
- widgetState.squadId = squadId;
223
- widgetState.enabled = true;
224
- widgetControls?.requestUpdate();
235
+ focusSquad(squadId);
225
236
  return scheduler;
226
237
  }
227
238
 
@@ -251,6 +262,11 @@ export default function (pi: ExtensionAPI) {
251
262
  // File-spec children load only the non-recursive reader and fail-closed guard.
252
263
  if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
253
264
 
265
+ // Load the global master switch before any session lifecycle work can run.
266
+ squadEnabled = store.loadSquadSettings().enabled;
267
+ widgetState.enabled = squadEnabled;
268
+ if (!squadEnabled) focusSquad(null);
269
+
254
270
  // Wire main-session thinking lookup (needs `pi`, guarded against stale API)
255
271
  getMainSessionThinking = () => {
256
272
  try {
@@ -286,6 +302,33 @@ export default function (pi: ExtensionAPI) {
286
302
  return true;
287
303
  };
288
304
 
305
+ /** Register a dynamically gated Ctrl+Q path; disabled mode can only show guidance. */
306
+ const registerTerminalPanelToggle = (ctx: ExtensionContext): void => {
307
+ if (!ctx.hasUI) return;
308
+ ctx.ui.onTerminalInput((data) => {
309
+ if (data !== "\x11") return undefined;
310
+ if (!squadEnabled) {
311
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
312
+ return { consume: true };
313
+ }
314
+ // If overlay is already open, let the panel's own handler deal with it.
315
+ if (overlayOpen) return undefined;
316
+ if (!activeSquadId) {
317
+ const latest = store.findLatestSquad(ctx.cwd)
318
+ || store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
319
+ if (latest) activateSquadView(latest.id, ctx);
320
+ else {
321
+ ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
322
+ return { consume: true };
323
+ }
324
+ }
325
+ if (activeSquadId) {
326
+ openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
327
+ }
328
+ return { consume: true };
329
+ });
330
+ };
331
+
289
332
  // =========================================================================
290
333
  // Context Injection — give main agent awareness of squad state
291
334
  // =========================================================================
@@ -302,15 +345,17 @@ export default function (pi: ExtensionAPI) {
302
345
  const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
303
346
  if (!squadEnabled) {
304
347
  if (durablePrompts.length === 0) return;
305
- return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
348
+ const enableFirst = durablePrompts.map((prompt) =>
349
+ `${prompt}\npi-squad is disabled. Run /squad enable first; then perform only the explicit review or exact-task resume described above.`,
350
+ );
351
+ return { systemPrompt: event.systemPrompt + "\n\n" + enableFirst.join("\n\n") };
306
352
  }
307
353
 
308
354
  // When a squad is active, inject its status
309
355
  if (activeSquadId) {
310
356
  const squad = store.loadSquad(activeSquadId);
311
357
  if (!squad) {
312
- activeSquadId = null;
313
- widgetState.squadId = null;
358
+ focusSquad(null);
314
359
  if (durablePrompts.length > 0) {
315
360
  return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
316
361
  }
@@ -448,7 +493,7 @@ export default function (pi: ExtensionAPI) {
448
493
  ]),
449
494
 
450
495
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
451
- if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
496
+ if (!squadEnabled) return disabledToolResult();
452
497
  if (!uiCtx) uiCtx = ctx;
453
498
 
454
499
  // Check if the user cancelled before we start
@@ -487,6 +532,7 @@ export default function (pi: ExtensionAPI) {
487
532
  }),
488
533
 
489
534
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
535
+ if (!squadEnabled) return disabledToolResult();
490
536
  let id = params.squadId || activeSquadId;
491
537
 
492
538
  // If no active squad, find the most recent one for this project
@@ -567,6 +613,7 @@ export default function (pi: ExtensionAPI) {
567
613
  }),
568
614
 
569
615
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
616
+ if (!squadEnabled) return disabledToolResult();
570
617
  let id = params.squadId;
571
618
  if (!id && activeSquadId && store.loadSquad(activeSquadId)?.status === "review") {
572
619
  id = activeSquadId;
@@ -587,6 +634,7 @@ export default function (pi: ExtensionAPI) {
587
634
  const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
588
635
  if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
589
636
  const invalidAttestations = await attestationScheduler.auditSpecAttestations();
637
+ if (!squadEnabled) return disabledToolResult();
590
638
  if (invalidAttestations.length > 0) {
591
639
  void attestationScheduler.start();
592
640
  return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
@@ -631,6 +679,7 @@ export default function (pi: ExtensionAPI) {
631
679
  }),
632
680
 
633
681
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
682
+ if (!squadEnabled) return disabledToolResult();
634
683
  if (!activeSquadId) {
635
684
  return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
636
685
  }
@@ -711,6 +760,7 @@ export default function (pi: ExtensionAPI) {
711
760
  }),
712
761
 
713
762
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
763
+ if (!squadEnabled) return disabledToolResult();
714
764
  if (params.action === "cancel") {
715
765
  if (!params.squadId?.trim()) {
716
766
  return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
@@ -756,7 +806,7 @@ export default function (pi: ExtensionAPI) {
756
806
  if (!activeScheduler) {
757
807
  return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
758
808
  }
759
- activeSquadId = squadId;
809
+ focusSquad(squadId);
760
810
 
761
811
  switch (params.action) {
762
812
  case "add_task": {
@@ -880,8 +930,24 @@ export default function (pi: ExtensionAPI) {
880
930
  // =========================================================================
881
931
 
882
932
  pi.on("session_start", async (_event, ctx) => {
933
+ // Re-read before touching focus, widgets, schedulers, or persisted work.
934
+ squadEnabled = store.loadSquadSettings().enabled;
883
935
  uiCtx = ctx;
884
936
 
937
+ // A session replacement may not deliver shutdown first. Never carry a
938
+ // focused squad across projects, and never seed a new widget from stale state.
939
+ widgetControls?.dispose();
940
+ widgetControls = null;
941
+ registerTerminalPanelToggle(ctx);
942
+ if (!squadEnabled) {
943
+ widgetState.enabled = false;
944
+ focusSquad(null);
945
+ return;
946
+ }
947
+ widgetState.enabled = true;
948
+ const focused = activeSquadId ? store.loadSquad(activeSquadId) : null;
949
+ focusSquad(focused?.cwd === ctx.cwd ? activeSquadId : null);
950
+
885
951
  // Install component-based widget
886
952
  if (ctx.hasUI) {
887
953
  widgetControls = setupSquadWidget(ctx, widgetState);
@@ -948,12 +1014,7 @@ export default function (pi: ExtensionAPI) {
948
1014
  }
949
1015
  await scheduler.start();
950
1016
  }
951
- if (pendingMailSquads.length > 0) {
952
- activeSquadId = pendingMailSquads[0].id;
953
- widgetState.squadId = activeSquadId;
954
- widgetState.enabled = true;
955
- widgetControls?.requestUpdate();
956
- }
1017
+ if (pendingMailSquads.length > 0) focusSquad(pendingMailSquads[0].id);
957
1018
 
958
1019
  // Notify about paused squads only if they have real completed work
959
1020
  const paused = store.findActiveSquads()
@@ -979,10 +1040,7 @@ export default function (pi: ExtensionAPI) {
979
1040
  .filter((s) => s.cwd === ctx.cwd && s.status === "review");
980
1041
  if (pendingReviews.length > 0) {
981
1042
  const squad = pendingReviews.sort((a, b) => b.created.localeCompare(a.created))[0];
982
- activeSquadId = squad.id;
983
- widgetState.squadId = squad.id;
984
- widgetState.enabled = true;
985
- widgetControls?.requestUpdate();
1043
+ focusSquad(squad.id);
986
1044
  const tasks = store.loadAllTasks(squad.id);
987
1045
  pi.sendMessage({
988
1046
  customType: "squad-review-required",
@@ -991,43 +1049,16 @@ export default function (pi: ExtensionAPI) {
991
1049
  });
992
1050
  }
993
1051
 
994
- // Register Ctrl+Q terminal input handler for panel toggle
995
- if (ctx.hasUI) {
996
- ctx.ui.onTerminalInput((data) => {
997
- if (data === "\x11") {
998
- // If overlay is already open, let the panel's own handler deal with it
999
- if (overlayOpen) return undefined;
1000
-
1001
- // Auto-pick a squad if none active
1002
- if (!activeSquadId) {
1003
- const latest = store.findLatestSquad(ctx.cwd)
1004
- || store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
1005
- if (latest) {
1006
- activateSquadView(latest.id, ctx);
1007
- } else {
1008
- ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
1009
- return { consume: true };
1010
- }
1011
- }
1012
-
1013
- if (activeSquadId) {
1014
- openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
1015
- }
1016
- return { consume: true };
1017
- }
1018
- return undefined;
1019
- });
1020
- }
1021
1052
  });
1022
1053
 
1023
1054
  pi.on("session_shutdown", async () => {
1055
+ focusSquad(null);
1024
1056
  widgetControls?.dispose();
1025
1057
  widgetControls = null;
1026
1058
  for (const [id, sched] of schedulers) {
1027
1059
  await sched.stop();
1028
1060
  }
1029
1061
  schedulers.clear();
1030
- activeSquadId = null;
1031
1062
  uiCtx = null;
1032
1063
  });
1033
1064
 
@@ -1058,7 +1089,12 @@ export default function (pi: ExtensionAPI) {
1058
1089
  return subs.filter((s) => s.value.startsWith(prefix));
1059
1090
  },
1060
1091
  handler: async (args, ctx) => {
1061
- const parts = args.trim().split(/\s+/);
1092
+ const requested = args.trim();
1093
+ if (!squadEnabled && requested !== "enable" && requested !== "disable") {
1094
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1095
+ return;
1096
+ }
1097
+ const parts = requested.split(/\s+/);
1062
1098
  const sub = parts[0] || "select";
1063
1099
 
1064
1100
  switch (sub) {
@@ -1252,8 +1288,7 @@ export default function (pi: ExtensionAPI) {
1252
1288
 
1253
1289
  case "clear": {
1254
1290
  if (activeSquadId) schedulers.delete(activeSquadId);
1255
- activeSquadId = null;
1256
- widgetState.squadId = null;
1291
+ focusSquad(null);
1257
1292
  widgetControls?.dispose();
1258
1293
  ctx.ui.notify("Squad view cleared", "info");
1259
1294
  return;
@@ -1274,9 +1309,7 @@ export default function (pi: ExtensionAPI) {
1274
1309
  await sched.stop();
1275
1310
  }
1276
1311
  schedulers.clear();
1277
- activeSquadId = null;
1278
- widgetState.squadId = null;
1279
- widgetControls?.requestUpdate();
1312
+ focusSquad(null);
1280
1313
 
1281
1314
  let count = 0;
1282
1315
  for (const id of allSquadIds) {
@@ -1312,9 +1345,7 @@ export default function (pi: ExtensionAPI) {
1312
1345
  await sched.stop();
1313
1346
  }
1314
1347
  schedulers.clear();
1315
- activeSquadId = null;
1316
- widgetState.squadId = null;
1317
- widgetControls?.requestUpdate();
1348
+ focusSquad(null);
1318
1349
  let count = 0;
1319
1350
  for (const id of allSquadIds) {
1320
1351
  fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
@@ -1332,11 +1363,7 @@ export default function (pi: ExtensionAPI) {
1332
1363
  await sched.stop();
1333
1364
  schedulers.delete(squad.id);
1334
1365
  }
1335
- if (activeSquadId === squad.id) {
1336
- activeSquadId = null;
1337
- widgetState.squadId = null;
1338
- widgetControls?.requestUpdate();
1339
- }
1366
+ if (activeSquadId === squad.id) focusSquad(null);
1340
1367
  fs.rmSync(store.getSquadDir(squad.id), { recursive: true, force: true });
1341
1368
  ctx.ui.notify(`Deleted: ${squad.id}`, "info");
1342
1369
  }
@@ -1345,24 +1372,48 @@ export default function (pi: ExtensionAPI) {
1345
1372
  }
1346
1373
 
1347
1374
  case "enable": {
1375
+ const wasEnabled = squadEnabled;
1376
+ try {
1377
+ const settings = store.loadSquadSettings();
1378
+ settings.enabled = true;
1379
+ store.saveSquadSettings(settings);
1380
+ } catch (error) {
1381
+ ctx.ui.notify(`Could not enable pi-squad: ${(error as Error).message}`, "error");
1382
+ return;
1383
+ }
1348
1384
  squadEnabled = true;
1385
+ widgetState.enabled = true;
1386
+ if (!wasEnabled) widgetState.squadId = null;
1387
+ if (!widgetControls && uiCtx?.hasUI) widgetControls = setupSquadWidget(uiCtx, widgetState);
1349
1388
  widgetControls?.requestUpdate();
1350
- ctx.ui.notify("pi-squad enabled tools, widget, and system prompt active", "info");
1389
+ ctx.ui.notify("pi-squad enabled. No suspended work was resumed; use /squad select and an explicit /squad resume <id> or exact resume_task if needed.", "info");
1351
1390
  return;
1352
1391
  }
1353
1392
 
1354
1393
  case "disable": {
1355
- squadEnabled = false;
1356
- // Stop all running schedulers
1357
- for (const [id, sched] of schedulers) {
1358
- await sched.stop();
1394
+ if (!squadEnabled) {
1395
+ ctx.ui.notify("pi-squad is already disabled. Run /squad enable to use squad operations.", "info");
1396
+ return;
1359
1397
  }
1398
+ try {
1399
+ const settings = store.loadSquadSettings();
1400
+ settings.enabled = false;
1401
+ store.saveSquadSettings(settings);
1402
+ } catch (error) {
1403
+ ctx.ui.notify(`Could not disable pi-squad: ${(error as Error).message}`, "error");
1404
+ return;
1405
+ }
1406
+ // The persisted state is authoritative before shutdown begins.
1407
+ squadEnabled = false;
1408
+ closeOverlay?.();
1409
+ for (const sched of schedulers.values()) await sched.stop();
1360
1410
  schedulers.clear();
1361
- activeSquadId = null;
1362
- widgetState.squadId = null;
1363
1411
  widgetState.enabled = false;
1364
- widgetControls?.requestUpdate();
1365
- ctx.ui.notify("pi-squad disabled — all tools, widget, and system prompt injection stopped", "info");
1412
+ widgetState.squadId = null;
1413
+ focusSquad(null);
1414
+ widgetControls?.dispose();
1415
+ widgetControls = null;
1416
+ ctx.ui.notify("pi-squad disabled. Running work was durably suspended; run /squad enable before any squad operation.", "info");
1366
1417
  return;
1367
1418
  }
1368
1419
 
@@ -1627,19 +1678,19 @@ async function pickSquad(
1627
1678
  * Does NOT start a scheduler (view-only unless squad needs resuming).
1628
1679
  */
1629
1680
  function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext): void {
1681
+ if (!squadEnabled) {
1682
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1683
+ return;
1684
+ }
1630
1685
  const squad = store.loadSquad(squadId);
1631
1686
  if (!squad) {
1632
1687
  ctx.ui.notify(`Squad '${squadId}' not found`, "error");
1633
1688
  return;
1634
1689
  }
1635
1690
 
1636
- activeSquadId = squadId;
1637
-
1638
- // Update widget to show the new squad. The widget reads squadId on each
1639
- // render, so just updating the state and requesting a render is enough.
1640
- widgetState.squadId = squadId;
1641
- widgetState.enabled = true;
1642
- widgetControls?.requestUpdate();
1691
+ // Selection is one atomic focus operation: panel, widget, status, and tools
1692
+ // must all target this exact squad before control returns to the caller.
1693
+ focusSquad(squadId);
1643
1694
 
1644
1695
  // Compact notification — widget already shows full task details.
1645
1696
  // Avoid large multi-line notifications that can break TUI layout.
@@ -1660,6 +1711,9 @@ function forceWidgetUpdate(): void {
1660
1711
  /** Wire one scheduler to durable main-session notifications. */
1661
1712
  function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
1662
1713
  scheduler.onEvent((event: SchedulerEvent) => {
1714
+ // Durable scheduler state remains authoritative, but disabled mode suppresses
1715
+ // late operational UI, acknowledgements, focus, and ordinary notifications.
1716
+ if (!squadEnabled) return;
1663
1717
  forceWidgetUpdate();
1664
1718
  switch (event.type) {
1665
1719
  case "squad_review_required": {
@@ -1742,19 +1796,43 @@ function openPanel(
1742
1796
  scheduler: Scheduler,
1743
1797
  squadId: string,
1744
1798
  ): void {
1799
+ if (!squadEnabled) {
1800
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1801
+ return;
1802
+ }
1745
1803
  if (overlayOpen) return;
1804
+ // Opening details also establishes authoritative focus. This covers callers
1805
+ // that reconstructed or targeted a scheduler without going through selector UI.
1806
+ focusSquad(squadId);
1746
1807
  overlayOpen = true;
1747
1808
 
1748
1809
  // The promise resolves when the panel calls done()
1749
1810
  const panelPromise = ctx.ui.custom<SquadPanelResult>(
1750
1811
  (tui, theme, _kb, done) => {
1751
- const panel = new SquadPanel(tui, theme, scheduler, squadId, done);
1812
+ const panel = new SquadPanel(
1813
+ tui,
1814
+ theme,
1815
+ scheduler,
1816
+ squadId,
1817
+ done,
1818
+ () => squadEnabled,
1819
+ () => ctx.ui.notify(DISABLED_GUIDANCE, "warning"),
1820
+ );
1821
+ closeOverlay = () => panel.close();
1752
1822
 
1753
1823
  // Wire up message sending from panel
1754
1824
  panel.onSendMessage = async (taskId: string, _prefill: string) => {
1825
+ if (!squadEnabled) {
1826
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1827
+ return;
1828
+ }
1755
1829
  const task = store.loadTask(squadId, taskId);
1756
1830
  const agentName = task?.agent || taskId;
1757
1831
  const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
1832
+ if (!squadEnabled) {
1833
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1834
+ return;
1835
+ }
1758
1836
  if (input) {
1759
1837
  let panelSched = schedulers.get(squadId);
1760
1838
  if (!panelSched) {
@@ -1788,9 +1866,11 @@ function openPanel(
1788
1866
  // When panel closes (done() called), clean up
1789
1867
  panelPromise.then(() => {
1790
1868
  overlayOpen = false;
1869
+ closeOverlay = null;
1791
1870
  forceWidgetUpdate();
1792
1871
  }).catch(() => {
1793
1872
  overlayOpen = false;
1873
+ closeOverlay = null;
1794
1874
  });
1795
1875
  }
1796
1876
 
@@ -1841,6 +1921,10 @@ async function startSquad(
1841
1921
  }
1842
1922
  }
1843
1923
 
1924
+ // A planner may take long enough for /squad disable to run concurrently.
1925
+ // Re-check before publishing any squad or constructing a scheduler.
1926
+ if (!squadEnabled) return disabledToolResult();
1927
+
1844
1928
  // Merge agent roster
1845
1929
  const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
1846
1930
  if (params.agents) {
@@ -1914,12 +1998,8 @@ async function startSquad(
1914
1998
  // Start scheduler
1915
1999
  const scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
1916
2000
  schedulers.set(squadId, scheduler);
1917
- activeSquadId = squadId;
1918
-
1919
- // Activate widget for this squad
1920
- widgetState.squadId = squadId;
1921
- widgetState.enabled = true;
1922
- widgetControls?.requestUpdate();
2001
+ // Activate panel/widget/tool focus as one invariant.
2002
+ focusSquad(squadId);
1923
2003
 
1924
2004
  // Wire up completion/escalation notifications to main agent.
1925
2005
  wireSchedulerEvents(pi, scheduler, squadId);
@@ -41,6 +41,8 @@ export class SquadPanel implements Component, Focusable {
41
41
  private done: (result: SquadPanelResult) => void;
42
42
  private scheduler: Scheduler;
43
43
  private squadId: string;
44
+ private isEnabled: () => boolean;
45
+ private onDisabled: () => void;
44
46
 
45
47
  /** Callback for sending messages — set by the extension */
46
48
  onSendMessage?: (taskId: string, message: string) => Promise<void>;
@@ -69,12 +71,16 @@ export class SquadPanel implements Component, Focusable {
69
71
  scheduler: Scheduler,
70
72
  squadId: string,
71
73
  done: (result: SquadPanelResult) => void,
74
+ isEnabled: () => boolean = () => true,
75
+ onDisabled: () => void = () => {},
72
76
  ) {
73
77
  this.tui = tui;
74
78
  this.theme = theme;
75
79
  this.scheduler = scheduler;
76
80
  this.squadId = squadId;
77
81
  this.done = done;
82
+ this.isEnabled = isEnabled;
83
+ this.onDisabled = onDisabled;
78
84
 
79
85
  this.taskListView = new TaskListView(theme, squadId);
80
86
  this.messageView = new MessageView(theme, squadId);
@@ -112,6 +118,12 @@ export class SquadPanel implements Component, Focusable {
112
118
  // =========================================================================
113
119
 
114
120
  handleInput(data: string): void {
121
+ if (!this.isEnabled()) {
122
+ this.onDisabled();
123
+ this.finish({ action: "close" });
124
+ return;
125
+ }
126
+
115
127
  // Ctrl+Q or q: close panel
116
128
  if (data === "\x11") {
117
129
  this.finish({ action: "close" });
@@ -316,6 +328,10 @@ export class SquadPanel implements Component, Focusable {
316
328
  // Lifecycle
317
329
  // =========================================================================
318
330
 
331
+ close(): void {
332
+ this.finish({ action: "close" });
333
+ }
334
+
319
335
  dispose(): void {
320
336
  if (this.renderTimeout) {
321
337
  clearTimeout(this.renderTimeout);
@@ -43,6 +43,12 @@ export interface SquadWidgetState {
43
43
  enabled: boolean;
44
44
  }
45
45
 
46
+ export interface SquadWidgetControls {
47
+ requestUpdate: () => void;
48
+ refreshNow: () => void;
49
+ dispose: () => void;
50
+ }
51
+
46
52
  /**
47
53
  * Set up the squad widget. Returns control functions.
48
54
  *
@@ -53,11 +59,8 @@ export interface SquadWidgetState {
53
59
  export function setupSquadWidget(
54
60
  ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
55
61
  state: SquadWidgetState,
56
- ): {
57
- requestUpdate: () => void;
58
- dispose: () => void;
59
- } {
60
- if (!ctx.hasUI) return { requestUpdate: () => {}, dispose: () => {} };
62
+ ): SquadWidgetControls {
63
+ if (!ctx.hasUI) return { requestUpdate: () => {}, refreshNow: () => {}, dispose: () => {} };
61
64
 
62
65
  let durationTimer: ReturnType<typeof setInterval> | null = null;
63
66
  let renderTimer: ReturnType<typeof setTimeout> | null = null;
@@ -269,9 +272,14 @@ export function setupSquadWidget(
269
272
  }
270
273
  }
271
274
 
275
+ function refreshNow(): void {
276
+ if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
277
+ render();
278
+ manageDurationTimer();
279
+ }
280
+
272
281
  // Initial render
273
- render();
274
- manageDurationTimer();
282
+ refreshNow();
275
283
 
276
284
  return {
277
285
  requestUpdate(): void {
@@ -279,14 +287,17 @@ export function setupSquadWidget(
279
287
  if (renderTimer) return;
280
288
  renderTimer = setTimeout(() => {
281
289
  renderTimer = null;
282
- render();
283
- manageDurationTimer();
290
+ refreshNow();
284
291
  }, 50);
285
292
  },
293
+ refreshNow,
286
294
  dispose(): void {
287
295
  if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
288
296
  if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
289
297
  lastCacheKey = "";
298
+ currentLines = [];
299
+ widgetInstalled = false;
300
+ widgetComponent = null;
290
301
  ctx.ui.setWidget("squad-tasks", undefined);
291
302
  ctx.ui.setStatus("squad", undefined);
292
303
  },
package/src/store.ts CHANGED
@@ -62,12 +62,14 @@ export function getSquadSettingsPath(): string {
62
62
  return path.join(SQUAD_HOME, "settings.json");
63
63
  }
64
64
 
65
- /** Load global squad settings, merged over defaults (advisor merged deep) */
65
+ /** Load global squad settings, merged over defaults (advisor merged deep). */
66
66
  export function loadSquadSettings(): SquadSettings {
67
67
  const loaded = readJson<Partial<SquadSettings>>(getSquadSettingsPath());
68
68
  return {
69
69
  ...DEFAULT_SQUAD_SETTINGS,
70
70
  ...(loaded || {}),
71
+ // Legacy, missing, and malformed values remain backward-compatible.
72
+ enabled: typeof loaded?.enabled === "boolean" ? loaded.enabled : DEFAULT_SQUAD_SETTINGS.enabled,
71
73
  advisor: { ...DEFAULT_SQUAD_SETTINGS.advisor, ...(loaded?.advisor || {}) },
72
74
  };
73
75
  }
package/src/types.ts CHANGED
@@ -44,6 +44,7 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
44
44
  * - any other string: an explicit model id (defaultModel) or thinking level (defaultThinking)
45
45
  */
46
46
  export interface SquadSettings {
47
+ enabled: boolean;
47
48
  defaultModel: string;
48
49
  defaultThinking: string;
49
50
  advisor: {
@@ -56,6 +57,7 @@ export interface SquadSettings {
56
57
  }
57
58
 
58
59
  export const DEFAULT_SQUAD_SETTINGS: SquadSettings = {
60
+ enabled: true,
59
61
  defaultModel: "main",
60
62
  defaultThinking: "main",
61
63
  advisor: {