pi-squad 0.17.1 → 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.1",
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,7 +20,7 @@ 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
25
  import { setupSquadWidget, type SquadWidgetControls, type SquadWidgetState } from "./panel/squad-widget.js";
26
26
  import * as store from "./store.js";
@@ -41,14 +41,21 @@ 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 */
@@ -59,7 +66,7 @@ let widgetControls: SquadWidgetControls | null = null;
59
66
  function focusSquad(squadId: string | null): void {
60
67
  activeSquadId = squadId;
61
68
  widgetState.squadId = squadId;
62
- if (squadId) widgetState.enabled = true;
69
+ if (squadId && squadEnabled) widgetState.enabled = true;
63
70
  widgetControls?.refreshNow();
64
71
  }
65
72
 
@@ -255,6 +262,11 @@ export default function (pi: ExtensionAPI) {
255
262
  // File-spec children load only the non-recursive reader and fail-closed guard.
256
263
  if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
257
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
+
258
270
  // Wire main-session thinking lookup (needs `pi`, guarded against stale API)
259
271
  getMainSessionThinking = () => {
260
272
  try {
@@ -290,6 +302,33 @@ export default function (pi: ExtensionAPI) {
290
302
  return true;
291
303
  };
292
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
+
293
332
  // =========================================================================
294
333
  // Context Injection — give main agent awareness of squad state
295
334
  // =========================================================================
@@ -306,7 +345,10 @@ export default function (pi: ExtensionAPI) {
306
345
  const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
307
346
  if (!squadEnabled) {
308
347
  if (durablePrompts.length === 0) return;
309
- 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") };
310
352
  }
311
353
 
312
354
  // When a squad is active, inject its status
@@ -451,7 +493,7 @@ export default function (pi: ExtensionAPI) {
451
493
  ]),
452
494
 
453
495
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
454
- 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();
455
497
  if (!uiCtx) uiCtx = ctx;
456
498
 
457
499
  // Check if the user cancelled before we start
@@ -490,6 +532,7 @@ export default function (pi: ExtensionAPI) {
490
532
  }),
491
533
 
492
534
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
535
+ if (!squadEnabled) return disabledToolResult();
493
536
  let id = params.squadId || activeSquadId;
494
537
 
495
538
  // If no active squad, find the most recent one for this project
@@ -570,6 +613,7 @@ export default function (pi: ExtensionAPI) {
570
613
  }),
571
614
 
572
615
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
616
+ if (!squadEnabled) return disabledToolResult();
573
617
  let id = params.squadId;
574
618
  if (!id && activeSquadId && store.loadSquad(activeSquadId)?.status === "review") {
575
619
  id = activeSquadId;
@@ -590,6 +634,7 @@ export default function (pi: ExtensionAPI) {
590
634
  const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
591
635
  if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
592
636
  const invalidAttestations = await attestationScheduler.auditSpecAttestations();
637
+ if (!squadEnabled) return disabledToolResult();
593
638
  if (invalidAttestations.length > 0) {
594
639
  void attestationScheduler.start();
595
640
  return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
@@ -634,6 +679,7 @@ export default function (pi: ExtensionAPI) {
634
679
  }),
635
680
 
636
681
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
682
+ if (!squadEnabled) return disabledToolResult();
637
683
  if (!activeSquadId) {
638
684
  return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
639
685
  }
@@ -714,6 +760,7 @@ export default function (pi: ExtensionAPI) {
714
760
  }),
715
761
 
716
762
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
763
+ if (!squadEnabled) return disabledToolResult();
717
764
  if (params.action === "cancel") {
718
765
  if (!params.squadId?.trim()) {
719
766
  return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
@@ -883,12 +930,21 @@ export default function (pi: ExtensionAPI) {
883
930
  // =========================================================================
884
931
 
885
932
  pi.on("session_start", async (_event, ctx) => {
933
+ // Re-read before touching focus, widgets, schedulers, or persisted work.
934
+ squadEnabled = store.loadSquadSettings().enabled;
886
935
  uiCtx = ctx;
887
936
 
888
937
  // A session replacement may not deliver shutdown first. Never carry a
889
938
  // focused squad across projects, and never seed a new widget from stale state.
890
939
  widgetControls?.dispose();
891
940
  widgetControls = null;
941
+ registerTerminalPanelToggle(ctx);
942
+ if (!squadEnabled) {
943
+ widgetState.enabled = false;
944
+ focusSquad(null);
945
+ return;
946
+ }
947
+ widgetState.enabled = true;
892
948
  const focused = activeSquadId ? store.loadSquad(activeSquadId) : null;
893
949
  focusSquad(focused?.cwd === ctx.cwd ? activeSquadId : null);
894
950
 
@@ -993,33 +1049,6 @@ export default function (pi: ExtensionAPI) {
993
1049
  });
994
1050
  }
995
1051
 
996
- // Register Ctrl+Q terminal input handler for panel toggle
997
- if (ctx.hasUI) {
998
- ctx.ui.onTerminalInput((data) => {
999
- if (data === "\x11") {
1000
- // If overlay is already open, let the panel's own handler deal with it
1001
- if (overlayOpen) return undefined;
1002
-
1003
- // Auto-pick a squad if none active
1004
- if (!activeSquadId) {
1005
- const latest = store.findLatestSquad(ctx.cwd)
1006
- || store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
1007
- if (latest) {
1008
- activateSquadView(latest.id, ctx);
1009
- } else {
1010
- ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
1011
- return { consume: true };
1012
- }
1013
- }
1014
-
1015
- if (activeSquadId) {
1016
- openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
1017
- }
1018
- return { consume: true };
1019
- }
1020
- return undefined;
1021
- });
1022
- }
1023
1052
  });
1024
1053
 
1025
1054
  pi.on("session_shutdown", async () => {
@@ -1060,7 +1089,12 @@ export default function (pi: ExtensionAPI) {
1060
1089
  return subs.filter((s) => s.value.startsWith(prefix));
1061
1090
  },
1062
1091
  handler: async (args, ctx) => {
1063
- 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+/);
1064
1098
  const sub = parts[0] || "select";
1065
1099
 
1066
1100
  switch (sub) {
@@ -1338,22 +1372,48 @@ export default function (pi: ExtensionAPI) {
1338
1372
  }
1339
1373
 
1340
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
+ }
1341
1384
  squadEnabled = true;
1385
+ widgetState.enabled = true;
1386
+ if (!wasEnabled) widgetState.squadId = null;
1387
+ if (!widgetControls && uiCtx?.hasUI) widgetControls = setupSquadWidget(uiCtx, widgetState);
1342
1388
  widgetControls?.requestUpdate();
1343
- 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");
1344
1390
  return;
1345
1391
  }
1346
1392
 
1347
1393
  case "disable": {
1348
- squadEnabled = false;
1349
- // Stop all running schedulers
1350
- for (const [id, sched] of schedulers) {
1351
- 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;
1352
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();
1353
1410
  schedulers.clear();
1354
1411
  widgetState.enabled = false;
1412
+ widgetState.squadId = null;
1355
1413
  focusSquad(null);
1356
- ctx.ui.notify("pi-squad disabled — all tools, widget, and system prompt injection stopped", "info");
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");
1357
1417
  return;
1358
1418
  }
1359
1419
 
@@ -1618,6 +1678,10 @@ async function pickSquad(
1618
1678
  * Does NOT start a scheduler (view-only unless squad needs resuming).
1619
1679
  */
1620
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
+ }
1621
1685
  const squad = store.loadSquad(squadId);
1622
1686
  if (!squad) {
1623
1687
  ctx.ui.notify(`Squad '${squadId}' not found`, "error");
@@ -1647,6 +1711,9 @@ function forceWidgetUpdate(): void {
1647
1711
  /** Wire one scheduler to durable main-session notifications. */
1648
1712
  function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
1649
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;
1650
1717
  forceWidgetUpdate();
1651
1718
  switch (event.type) {
1652
1719
  case "squad_review_required": {
@@ -1729,6 +1796,10 @@ function openPanel(
1729
1796
  scheduler: Scheduler,
1730
1797
  squadId: string,
1731
1798
  ): void {
1799
+ if (!squadEnabled) {
1800
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1801
+ return;
1802
+ }
1732
1803
  if (overlayOpen) return;
1733
1804
  // Opening details also establishes authoritative focus. This covers callers
1734
1805
  // that reconstructed or targeted a scheduler without going through selector UI.
@@ -1738,13 +1809,30 @@ function openPanel(
1738
1809
  // The promise resolves when the panel calls done()
1739
1810
  const panelPromise = ctx.ui.custom<SquadPanelResult>(
1740
1811
  (tui, theme, _kb, done) => {
1741
- 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();
1742
1822
 
1743
1823
  // Wire up message sending from panel
1744
1824
  panel.onSendMessage = async (taskId: string, _prefill: string) => {
1825
+ if (!squadEnabled) {
1826
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
1827
+ return;
1828
+ }
1745
1829
  const task = store.loadTask(squadId, taskId);
1746
1830
  const agentName = task?.agent || taskId;
1747
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
+ }
1748
1836
  if (input) {
1749
1837
  let panelSched = schedulers.get(squadId);
1750
1838
  if (!panelSched) {
@@ -1778,9 +1866,11 @@ function openPanel(
1778
1866
  // When panel closes (done() called), clean up
1779
1867
  panelPromise.then(() => {
1780
1868
  overlayOpen = false;
1869
+ closeOverlay = null;
1781
1870
  forceWidgetUpdate();
1782
1871
  }).catch(() => {
1783
1872
  overlayOpen = false;
1873
+ closeOverlay = null;
1784
1874
  });
1785
1875
  }
1786
1876
 
@@ -1831,6 +1921,10 @@ async function startSquad(
1831
1921
  }
1832
1922
  }
1833
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
+
1834
1928
  // Merge agent roster
1835
1929
  const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
1836
1930
  if (params.agents) {
@@ -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);
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: {