pi-crew 0.10.6 → 0.11.1
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/CHANGELOG.md +215 -0
- package/README.md +0 -1
- package/dist/index.mjs +30623 -29844
- package/package.json +170 -169
- package/schema.json +10 -0
- package/scripts/watch-agent-transcript.mjs +120 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +181 -1
- package/src/config/config-validation.ts +0 -2
- package/src/config/defaults.ts +0 -1
- package/src/config/types.ts +0 -5
- package/src/extension/crew-input-router.ts +2 -0
- package/src/extension/crew-vibes/config.ts +1 -77
- package/src/extension/crew-vibes/index.ts +36 -330
- package/src/extension/crew-vibes/render.ts +1 -106
- package/src/extension/help.ts +1 -2
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-registration.ts +5 -1
- package/src/extension/registration/commands/manage.ts +5 -4
- package/src/extension/registration/commands/schedules.ts +212 -0
- package/src/extension/registration/commands/shared.ts +56 -2
- package/src/extension/registration/lifecycle-handlers.ts +16 -2
- package/src/extension/registration/schedule-toast-bridge.ts +109 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +72 -1
- package/src/extension/team-tool/handle-schedule.ts +120 -1
- package/src/extension/team-tool/handle-settings.ts +14 -2
- package/src/runtime/scheduling/scheduler.ts +120 -6
- package/src/runtime/settings-store.ts +28 -2
- package/src/runtime/task-display.ts +1 -1
- package/src/schema/config-schema.ts +4 -0
- package/src/state/coordination/locks.ts +17 -2
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +791 -0
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +52 -17
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +177 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/inline-panel/crew-editor.ts +32 -9
- package/src/ui/inline-panel/index.ts +66 -4
- package/src/ui/key-utils.ts +3 -0
- package/src/ui/keybinding-map.ts +286 -34
- package/src/ui/live-conversation-overlay.ts +176 -46
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +34 -17
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -28
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -49
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +344 -72
- package/src/ui/settings-overlay.ts +84 -42
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +126 -8
- package/src/ui/tool-renderers/brief-mode.ts +8 -66
- package/src/ui/tool-renderers/index.ts +372 -521
- package/src/ui/transcript-viewer.ts +33 -14
- package/src/ui/widget/index.ts +86 -133
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -399
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +298 -265
- package/src/ui/widget/widget-types.ts +0 -9
- package/src/utils/relative-time.ts +33 -0
- package/src/utils/visual.ts +0 -4
- package/src/extension/crew-vibes/figures.ts +0 -101
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/extension/crew-vibes/footer.ts +0 -313
- package/src/extension/crew-vibes/speed.ts +0 -286
- package/src/ui/dock-footer.ts +0 -49
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3-1 (UI-AUDIT P0-2/P1-1): wire the terminal-status controller (tab title +
|
|
3
|
+
* Ghostty OSC 9;4 progress) to the runEventBus.
|
|
4
|
+
*
|
|
5
|
+
* History: `src/ui/terminal-status.ts` shipped in v0.8.3 (819d71ad) with unit
|
|
6
|
+
* tests but NO construction site — `ctx.terminalStatus` was never assigned, so
|
|
7
|
+
* the feature never ran. This module is the missing wire, installed once from
|
|
8
|
+
* `register.ts` in place of the old no-op cleanup handler.
|
|
9
|
+
*
|
|
10
|
+
* Design:
|
|
11
|
+
* - Subscribe to `runEventBus.onAny` and debounce evaluation (250ms) so a
|
|
12
|
+
* burst of task events collapses into one state transition.
|
|
13
|
+
* - State is derived from `listLiveAgents()`: >0 live → active; 0 → idle.
|
|
14
|
+
* `ctx.terminalStatusActive` (already in RegistrationContext) is the single
|
|
15
|
+
* source of truth for the current phase, so `runtime-cleanup.ts` resetting
|
|
16
|
+
* it also resets this state machine on the next event.
|
|
17
|
+
* - On the idle transition, `onRunCompleted()` fires the green Ghostty flash
|
|
18
|
+
* and `onIdle()` runs COMPLETE_FLASH_MS later (title restore + re-assert
|
|
19
|
+
* loop), so the flash is visible before the bar clears.
|
|
20
|
+
* - The `TerminalStatusUi` adapter reads `ctx.currentCtx` dynamically (getter
|
|
21
|
+
* + call-time lookup), so a session swap never leaves the controller
|
|
22
|
+
* holding a stale session's `ui.setTitle` (the P1-10 class of bug).
|
|
23
|
+
* - Everything is best-effort and guarded by `hasUI`: headless / subagent
|
|
24
|
+
* contexts evaluate to a no-op and write nothing.
|
|
25
|
+
* - All timers are `unref()`-ed; dispose unsubscribes, clears timers and
|
|
26
|
+
* disposes the controller (registered via `registerCleanupHandler`, which
|
|
27
|
+
* replaces the old no-op `disposeTerminalStatus` handler in register.ts).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
32
|
+
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildCrewTitleSegment,
|
|
35
|
+
COMPLETE_FLASH_MS,
|
|
36
|
+
createTerminalStatusController,
|
|
37
|
+
setTerminalTitle,
|
|
38
|
+
type TerminalStatusController,
|
|
39
|
+
type TerminalStatusUi,
|
|
40
|
+
} from "../../ui/terminal-status.ts";
|
|
41
|
+
import { registerCleanupHandler } from "../crew-cleanup.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Structural subset of RegistrationContext this module needs. Declared
|
|
45
|
+
* locally (not imported) so tests can pass a 3-field fake without building
|
|
46
|
+
* the full context, while `register.ts` passes the real context unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export interface TerminalStatusWiringDeps {
|
|
49
|
+
currentCtx: { hasUI: boolean; ui: { setTitle(title: string): void } } | undefined;
|
|
50
|
+
terminalStatus: TerminalStatusController | undefined;
|
|
51
|
+
terminalStatusActive: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Debounce window that collapses an event burst into one evaluation. */
|
|
55
|
+
const EVAL_DEBOUNCE_MS = 250;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Live-agent probe (dependency inversion for tests). Production reads the real
|
|
59
|
+
* registry; tests inject a deterministic count source.
|
|
60
|
+
*/
|
|
61
|
+
let liveAgentProbe: () => number = () => listLiveAgents().length;
|
|
62
|
+
|
|
63
|
+
/** @internal Re-subscribe guard: unsubscribe fn from a previous registration. */
|
|
64
|
+
let currentUnsubscribe: (() => void) | undefined;
|
|
65
|
+
/** @internal Latest install's full dispose (unsubscribe + timers + controller). */
|
|
66
|
+
let currentDispose: (() => void) | undefined;
|
|
67
|
+
|
|
68
|
+
/** Test seam: override the live-agent source (restore with `() => default`). */
|
|
69
|
+
export function __test__setLiveAgentProbe(probe: () => number): void {
|
|
70
|
+
liveAgentProbe = probe;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Test seam: fully dispose any wiring left by a previous install in this process. */
|
|
74
|
+
export function __test__resetTerminalStatusWiring(): void {
|
|
75
|
+
currentDispose?.();
|
|
76
|
+
currentDispose = undefined;
|
|
77
|
+
currentUnsubscribe = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function installTerminalStatus(pi: ExtensionAPI, deps: TerminalStatusWiringDeps): void {
|
|
81
|
+
// A previous registration in this process must not leave a second
|
|
82
|
+
// subscription behind (registerCleanupHandler only keeps the latest
|
|
83
|
+
// dispose fn, so an older subscription would leak otherwise).
|
|
84
|
+
currentUnsubscribe?.();
|
|
85
|
+
|
|
86
|
+
// Dynamic adapter: resolves the CURRENT session's UI at call time, so the
|
|
87
|
+
// controller never captures a session-scoped reference.
|
|
88
|
+
const adapter: TerminalStatusUi = {
|
|
89
|
+
get hasUI(): boolean {
|
|
90
|
+
return deps.currentCtx?.hasUI ?? false;
|
|
91
|
+
},
|
|
92
|
+
ui: {
|
|
93
|
+
setTitle(title: string): void {
|
|
94
|
+
deps.currentCtx?.ui.setTitle(title);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let evalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
let lastSegment = "";
|
|
102
|
+
|
|
103
|
+
const clearTimers = (): void => {
|
|
104
|
+
if (evalTimer) {
|
|
105
|
+
clearTimeout(evalTimer);
|
|
106
|
+
evalTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
if (idleTimer) {
|
|
109
|
+
clearTimeout(idleTimer);
|
|
110
|
+
idleTimer = undefined;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const evaluate = (): void => {
|
|
115
|
+
evalTimer = undefined;
|
|
116
|
+
if (!adapter.hasUI) return;
|
|
117
|
+
if (!deps.terminalStatus) deps.terminalStatus = createTerminalStatusController(adapter);
|
|
118
|
+
const controller = deps.terminalStatus;
|
|
119
|
+
|
|
120
|
+
if (liveAgentProbe() > 0) {
|
|
121
|
+
// Active: cancel any pending idle transition, then (re)assert.
|
|
122
|
+
if (idleTimer) {
|
|
123
|
+
clearTimeout(idleTimer);
|
|
124
|
+
idleTimer = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (!deps.terminalStatusActive) {
|
|
127
|
+
deps.terminalStatusActive = true;
|
|
128
|
+
controller.onRunsActive(); // title (best-effort) + Ghostty INDETERMINATE
|
|
129
|
+
lastSegment = buildCrewTitleSegment();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Still active: refresh the title only when the role summary changed
|
|
133
|
+
// (never re-emit the OSC sequence — state 3 persists on its own).
|
|
134
|
+
const segment = buildCrewTitleSegment();
|
|
135
|
+
if (segment && segment !== lastSegment) {
|
|
136
|
+
lastSegment = segment;
|
|
137
|
+
setTerminalTitle(adapter, segment);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (deps.terminalStatusActive) {
|
|
143
|
+
deps.terminalStatusActive = false;
|
|
144
|
+
lastSegment = "";
|
|
145
|
+
controller.onRunCompleted(); // green flash, self-re-evaluates after COMPLETE_FLASH_MS
|
|
146
|
+
idleTimer = setTimeout(() => {
|
|
147
|
+
idleTimer = undefined;
|
|
148
|
+
controller.onIdle(); // title restore + idle re-assert loop + Ghostty CLEAR
|
|
149
|
+
}, COMPLETE_FLASH_MS);
|
|
150
|
+
idleTimer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const unsubscribe = runEventBus.onAny(() => {
|
|
155
|
+
if (evalTimer) return;
|
|
156
|
+
evalTimer = setTimeout(evaluate, EVAL_DEBOUNCE_MS);
|
|
157
|
+
evalTimer.unref?.();
|
|
158
|
+
});
|
|
159
|
+
currentUnsubscribe = unsubscribe;
|
|
160
|
+
const dispose = (): void => {
|
|
161
|
+
unsubscribe();
|
|
162
|
+
currentUnsubscribe = undefined;
|
|
163
|
+
currentDispose = undefined;
|
|
164
|
+
clearTimers();
|
|
165
|
+
deps.terminalStatus?.dispose();
|
|
166
|
+
deps.terminalStatus = undefined;
|
|
167
|
+
deps.terminalStatusActive = false;
|
|
168
|
+
};
|
|
169
|
+
currentDispose = dispose;
|
|
170
|
+
|
|
171
|
+
registerCleanupHandler(pi, { disposeTerminalStatus: dispose });
|
|
172
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
3
|
import { loadConfig } from "../../config/config.ts";
|
|
3
4
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
4
5
|
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
5
6
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
7
|
+
import { AgentsJobsBrowser } from "../../ui/agents-jobs-browser.ts";
|
|
6
8
|
import { LiveConversationOverlay } from "../../ui/live-conversation-overlay.ts";
|
|
9
|
+
import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
|
|
7
10
|
import { asCrewTheme } from "../../ui/theme-adapter.ts";
|
|
8
11
|
// Lazy-loaded: DurableTranscriptViewer is 658ms — only needed for /crew transcript command
|
|
9
12
|
import type { DurableTranscriptViewer as DurableTranscriptViewerType } from "../../ui/transcript-viewer.ts";
|
|
@@ -87,10 +90,16 @@ export async function openLiveConversation(
|
|
|
87
90
|
return overlay.render(width);
|
|
88
91
|
},
|
|
89
92
|
handleInput(data: string) {
|
|
90
|
-
|
|
93
|
+
// Close keys stay exactly as they were: close the overlay and
|
|
94
|
+
// resolve the custom() promise.
|
|
95
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
91
96
|
overlay.close();
|
|
92
97
|
done(undefined);
|
|
98
|
+
return;
|
|
93
99
|
}
|
|
100
|
+
// Everything else scrolls the live transcript (P1-3):
|
|
101
|
+
// j/k, ↓/↑, g/G, PgUp/PgDn and the `a` auto-scroll toggle.
|
|
102
|
+
overlay.handleInput(data);
|
|
94
103
|
},
|
|
95
104
|
invalidate() {
|
|
96
105
|
/* overlay polls */
|
|
@@ -111,3 +120,65 @@ export async function openLiveConversation(
|
|
|
111
120
|
);
|
|
112
121
|
return true;
|
|
113
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* feat/agents-browser: open the unified Agents & Jobs browser overlay.
|
|
126
|
+
*
|
|
127
|
+
* Full-view component (the SAME custom() precedent as openLiveConversation —
|
|
128
|
+
* pi's overlay-anchor option was evaluated and set aside: overlays float over
|
|
129
|
+
* chat with different key routing; the modal form matches every existing
|
|
130
|
+
* pi-crew viewer, so users get one consistent interaction model). Opened from the run
|
|
131
|
+
* dashboard with ONE keypress (`b`); lists ALL live agents + scheduled jobs
|
|
132
|
+
* via the G17 providers — independent of any single run. Headless sessions
|
|
133
|
+
* (`ctx.hasUI === false`) are a silent no-op returning false.
|
|
134
|
+
*/
|
|
135
|
+
export async function openAgentsJobsBrowser(
|
|
136
|
+
ctx: Pick<ExtensionCommandContext, "hasUI" | "cwd" | "ui" | "sessionManager">,
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
if (!ctx.hasUI) return false;
|
|
139
|
+
const theme = asCrewTheme({});
|
|
140
|
+
const workspaceId = ctx.sessionManager?.getSessionId?.();
|
|
141
|
+
await ctx.ui.custom<undefined>(
|
|
142
|
+
(tui, _theme, _keybindings, done) => {
|
|
143
|
+
const columns = tui?.terminal?.columns ?? 80;
|
|
144
|
+
const rows = tui?.terminal?.rows ?? 24;
|
|
145
|
+
const browser = new AgentsJobsBrowser({
|
|
146
|
+
cwd: ctx.cwd,
|
|
147
|
+
workspaceId,
|
|
148
|
+
theme,
|
|
149
|
+
columns,
|
|
150
|
+
rows,
|
|
151
|
+
requestRender: () => requestRenderTarget(tui),
|
|
152
|
+
// Enter on an agent opens the full transcript overlay — the
|
|
153
|
+
// `/crew transcript` experience ("chi tiết như subagent cũ").
|
|
154
|
+
onOpenTranscript: ({ runId, taskId }) => {
|
|
155
|
+
void openTranscriptViewer(ctx as ExtensionCommandContext, runId, taskId);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
render(width: number) {
|
|
160
|
+
return browser.render(width);
|
|
161
|
+
},
|
|
162
|
+
handleInput(data: string) {
|
|
163
|
+
browser.handleInput(data);
|
|
164
|
+
if (browser.isClosed) done(undefined);
|
|
165
|
+
},
|
|
166
|
+
invalidate() {
|
|
167
|
+
browser.refreshData(true);
|
|
168
|
+
},
|
|
169
|
+
dispose() {
|
|
170
|
+
browser.dispose();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
overlay: true,
|
|
176
|
+
overlayOptions: {
|
|
177
|
+
width: "92%",
|
|
178
|
+
maxHeight: "70%",
|
|
179
|
+
anchor: "center",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import { humanizeSchedule, nextRunTime, parseSchedule } from "../../runtime/scheduling/scheduler.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type CrewSettings,
|
|
5
|
+
getScheduledJobsHiddenCount as computeScheduledJobsHiddenCount,
|
|
6
|
+
loadCrewSettingsTiers,
|
|
7
|
+
updateCrewSettings,
|
|
8
|
+
} from "../../runtime/settings-store.ts";
|
|
4
9
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
5
10
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
6
11
|
import { result, type TeamContext } from "./context.ts";
|
|
@@ -17,6 +22,8 @@ type SchedulerRef = {
|
|
|
17
22
|
id: string,
|
|
18
23
|
patch: Partial<import("../../runtime/scheduling/scheduler.ts").ScheduledJob>,
|
|
19
24
|
): import("../../runtime/scheduling/scheduler.ts").ScheduledJob | undefined;
|
|
25
|
+
/** Trigger a job immediately (force — bypasses the enabled gate). */
|
|
26
|
+
runNow(jobId: string): { ok: true } | { ok: false; error: string };
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
// Module-scoped scheduler instance — one per extension load (EXT-9).
|
|
@@ -31,9 +38,86 @@ export function registerCrewScheduler(scheduler: SchedulerRef): void {
|
|
|
31
38
|
crewSchedulerInstance = scheduler;
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
// Module-scoped hidden-count stash (P2-1, EXT-9 pattern): computed ONCE at
|
|
42
|
+
// session_start registration from the SAME tiers read that drove registration
|
|
43
|
+
// (no extra disk I/O), then served to paint-path consumers — the crew widget
|
|
44
|
+
// line and the dashboard pane render ticks must never hit the settings store
|
|
45
|
+
// (P0-6). undefined = never stashed (pre-registration / cleaned up / unit
|
|
46
|
+
// tests with a fake scheduler) → consumers treat it as 0.
|
|
47
|
+
let stashedScheduledJobsHiddenCount: number | undefined;
|
|
48
|
+
|
|
49
|
+
/** @internal — lifecycle wiring: stash the registration-time hidden count. */
|
|
50
|
+
export function stashScheduledJobsHiddenCount(count: number | undefined): void {
|
|
51
|
+
stashedScheduledJobsHiddenCount = count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal — test seam: read the raw stash (undefined = never stashed). */
|
|
55
|
+
export function getStashedScheduledJobsHiddenCount(): number | undefined {
|
|
56
|
+
return stashedScheduledJobsHiddenCount;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/** Remove the scheduler singleton. Call during session cleanup. */
|
|
35
60
|
export function unregisterCrewScheduler(): void {
|
|
36
61
|
crewSchedulerInstance = undefined;
|
|
62
|
+
// P2-1: the hidden-count stash is registration-time state — clear it with
|
|
63
|
+
// the singleton so a later session can never paint a stale gate hint.
|
|
64
|
+
stashedScheduledJobsHiddenCount = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* SINGLE SOURCE OF TRUTH for scheduled-job READS (G17). Every consumer —
|
|
69
|
+
* dashboard pane, /schedules command, crew widget — must read jobs through
|
|
70
|
+
* this provider; no module may keep its own defaults/settings copy.
|
|
71
|
+
*
|
|
72
|
+
* Reads the module-scoped scheduler singleton when registered (the live view
|
|
73
|
+
* the mutation handlers target); otherwise falls back to the SAME gated tiers
|
|
74
|
+
* view the session-start registration loop consumes (`effectiveScheduledJobs`:
|
|
75
|
+
* user-tier jobs always, project-tier only on explicit opt-in) so the fallback
|
|
76
|
+
* can never disagree with what would register on next session start.
|
|
77
|
+
*/
|
|
78
|
+
export function getScheduledJobs(
|
|
79
|
+
cwd: string = process.cwd(),
|
|
80
|
+
globalFile?: string,
|
|
81
|
+
): import("../../runtime/scheduling/scheduler.ts").ScheduledJob[] {
|
|
82
|
+
const scheduler = getCrewScheduler();
|
|
83
|
+
if (scheduler) return scheduler.list();
|
|
84
|
+
try {
|
|
85
|
+
const tiers = loadCrewSettingsTiers(cwd, globalFile);
|
|
86
|
+
return tiers.effectiveScheduledJobs.filter(isScheduledJobLike);
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Shape guard for the settings fallback — mirrors settings-store's private
|
|
93
|
+
* `validateScheduledJob` (id + scheduleType + enabled) so invalid persisted
|
|
94
|
+
* entries are skipped instead of reaching renderers. */
|
|
95
|
+
function isScheduledJobLike(job: unknown): job is import("../../runtime/scheduling/scheduler.ts").ScheduledJob {
|
|
96
|
+
if (!job || typeof job !== "object") return false;
|
|
97
|
+
const obj = job as Record<string, unknown>;
|
|
98
|
+
return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.scheduleType === "string" && typeof obj.enabled === "boolean";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* P2-1 companion read to getScheduledJobs(): how many project-tier
|
|
103
|
+
* scheduledJobs the B2 gate is hiding right now (for the "N project-tier jobs
|
|
104
|
+
* hidden" hint).
|
|
105
|
+
*
|
|
106
|
+
* With the scheduler singleton registered, serves the REGISTRATION-TIME stash
|
|
107
|
+
* (in-memory — paint-path safe, P0-6) computed from the exact tiers view the
|
|
108
|
+
* registration loop gated. Without a scheduler (pre-registration, headless
|
|
109
|
+
* command usage), computes from the same gated tiers view the provider
|
|
110
|
+
* fallback uses — user-initiated surfaces only (pane/command); the widget's
|
|
111
|
+
* default reader never reaches the compute branch because it gates on the
|
|
112
|
+
* scheduler singleton exactly like defaultScheduledJobsReader.
|
|
113
|
+
*/
|
|
114
|
+
export function getScheduledJobsHiddenCountView(cwd: string = process.cwd(), globalFile?: string): number {
|
|
115
|
+
if (getCrewScheduler()) return stashedScheduledJobsHiddenCount ?? 0;
|
|
116
|
+
try {
|
|
117
|
+
return computeScheduledJobsHiddenCount(cwd, globalFile);
|
|
118
|
+
} catch {
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
37
121
|
}
|
|
38
122
|
|
|
39
123
|
interface ScheduleParams {
|
|
@@ -108,6 +192,9 @@ export function handleSchedule(params: TeamToolParamsValue, ctx: TeamContext): P
|
|
|
108
192
|
if (subAction === "disable" || subAction === "enable" || subAction === "update") {
|
|
109
193
|
return handleUpdateScheduled(params, ctx);
|
|
110
194
|
}
|
|
195
|
+
if (subAction === "run-now") {
|
|
196
|
+
return handleRunNowScheduled(params);
|
|
197
|
+
}
|
|
111
198
|
|
|
112
199
|
const team = params.team ?? "default";
|
|
113
200
|
const goal = params.goal ?? params.task ?? "";
|
|
@@ -348,3 +435,35 @@ export function handleUpdateScheduled(params: TeamToolParamsValue, ctx: TeamCont
|
|
|
348
435
|
{ action: "schedule", status: "ok", data: { jobId, enabled: updated.enabled, schedule: updated.schedule } },
|
|
349
436
|
);
|
|
350
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Trigger a scheduled job immediately — the UI "run now" channel. Runs even
|
|
441
|
+
* when the job is disabled (explicit user action); completion tracking stays
|
|
442
|
+
* on the scheduler's async finalization path.
|
|
443
|
+
*
|
|
444
|
+
* Usage:
|
|
445
|
+
* team action='schedule' subAction='run-now' jobId='<uuid>'
|
|
446
|
+
*/
|
|
447
|
+
export function handleRunNowScheduled(params: TeamToolParamsValue): PiTeamsToolResult {
|
|
448
|
+
const jobId = getJobIdParam(params);
|
|
449
|
+
if (!jobId) {
|
|
450
|
+
return result(
|
|
451
|
+
"subAction=run-now requires jobId. Usage: team action='schedule' subAction='run-now' jobId='<uuid>'",
|
|
452
|
+
{ action: "schedule", status: "error" },
|
|
453
|
+
true,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
const scheduler = getCrewScheduler();
|
|
457
|
+
if (!scheduler) {
|
|
458
|
+
return result("Scheduler not running.", { action: "schedule", status: "error" }, true);
|
|
459
|
+
}
|
|
460
|
+
const outcome = scheduler.runNow(jobId);
|
|
461
|
+
if (!outcome.ok) {
|
|
462
|
+
return result(outcome.error, { action: "schedule", status: "error" }, true);
|
|
463
|
+
}
|
|
464
|
+
return result([`Scheduled job triggered.`, ` Job ID: ${jobId}`].join("\n"), {
|
|
465
|
+
action: "schedule",
|
|
466
|
+
status: "ok",
|
|
467
|
+
data: { jobId, triggered: true },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
@@ -245,11 +245,19 @@ const KNOWN_KEYS = new Set([
|
|
|
245
245
|
// ui
|
|
246
246
|
"ui.widgetPlacement",
|
|
247
247
|
"ui.widgetMaxLines",
|
|
248
|
+
// M1-9/P1-8: inlinePanel is a schema key (config-schema.ts
|
|
249
|
+
// PiTeamsUiConfigSchema) and ui.autoCloseDashboardMs is parsed by
|
|
250
|
+
// parseUiConfig — keep this list mirroring the schema's ui block so
|
|
251
|
+
// `team-settings schema` lists what the schema actually accepts.
|
|
252
|
+
// (ui.widgetRowStyle was REMOVED 2026-09-16: the RAIL dock renders one
|
|
253
|
+
// layout; the key no longer has any reader.)
|
|
254
|
+
"ui.inlinePanel",
|
|
248
255
|
"ui.dashboardPlacement",
|
|
249
256
|
"ui.dashboardWidth",
|
|
250
257
|
"ui.dashboardLiveRefreshMs",
|
|
251
258
|
"ui.autoOpenDashboard",
|
|
252
259
|
"ui.autoOpenDashboardForForegroundRuns",
|
|
260
|
+
"ui.autoCloseDashboardMs",
|
|
253
261
|
"ui.showModel",
|
|
254
262
|
"ui.showTokens",
|
|
255
263
|
"ui.showTools",
|
|
@@ -440,9 +448,13 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
|
|
|
440
448
|
const key = args.slice(4).trim();
|
|
441
449
|
if (!key) return result("Usage: team-settings get <key>\nUse 'team-settings schema' to see all known keys.", { ...ERR }, true);
|
|
442
450
|
const value = getNested(effective, key);
|
|
443
|
-
// Try to provide helpful note for unknown keys
|
|
451
|
+
// Try to provide helpful note for unknown keys.
|
|
452
|
+
// M1-9/P1-8: `ui.*` is exempt exactly like the `set` path below (:497) —
|
|
453
|
+
// every ui.* key is accepted by the schema's ui block, so `get
|
|
454
|
+
// ui.<key>` must not answer "(unknown key — may not take
|
|
455
|
+
// effect)" for a key the config layer actually honours.
|
|
444
456
|
let note = "";
|
|
445
|
-
if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.")) {
|
|
457
|
+
if (!KNOWN_KEYS.has(key) && !key.startsWith("agents.overrides.") && !key.startsWith("ui.")) {
|
|
446
458
|
const suggestion = suggestConfigKey(key, KNOWN_SORTED);
|
|
447
459
|
if (suggestion) note = `\n(did you mean '${suggestion}'?)`;
|
|
448
460
|
else note = "\n(unknown key — may not take effect)";
|
|
@@ -26,6 +26,15 @@ export type ScheduleChangeEvent =
|
|
|
26
26
|
| { type: "fired"; jobId: string; agentId: string; name: string }
|
|
27
27
|
| { type: "error"; jobId: string; error: string };
|
|
28
28
|
|
|
29
|
+
export interface CrewSchedulerOptions {
|
|
30
|
+
/** Injectable clock for tests. Defaults to real time (`new Date()`). */
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Node clamps setTimeout delays > 2^31-1 ms to fire ~immediately; longer
|
|
35
|
+
* waits (e.g. a yearly cron) must be chained in hops below this ceiling. */
|
|
36
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_000;
|
|
37
|
+
|
|
29
38
|
export class CrewScheduler {
|
|
30
39
|
private jobs = new Map<string, ScheduledJob>();
|
|
31
40
|
private timers = new Map<string, ReturnType<typeof setInterval | typeof setTimeout>>();
|
|
@@ -33,6 +42,16 @@ export class CrewScheduler {
|
|
|
33
42
|
private executor?: (job: ScheduledJob) => string;
|
|
34
43
|
private finalizer?: (jobId: string, agentId: string) => void;
|
|
35
44
|
private runCancelFn?: (runId: string) => void;
|
|
45
|
+
private nowFn: () => Date;
|
|
46
|
+
|
|
47
|
+
constructor(options: CrewSchedulerOptions = {}) {
|
|
48
|
+
this.nowFn = options.now ?? (() => new Date());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Scheduler clock. Injected in tests; real time in production. */
|
|
52
|
+
private now(): Date {
|
|
53
|
+
return this.nowFn();
|
|
54
|
+
}
|
|
36
55
|
|
|
37
56
|
start(options: {
|
|
38
57
|
emit: (event: ScheduleChangeEvent) => void;
|
|
@@ -107,6 +126,29 @@ export class CrewScheduler {
|
|
|
107
126
|
this.jobs.set(jobId, { ...job, spawnedRunIds });
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Trigger a job immediately (dashboard "run now" / subAction='run-now').
|
|
131
|
+
* Bypasses the enabled gate — an explicit user action — but otherwise reuses
|
|
132
|
+
* fire()'s exact path: marks lastStatus=running, invokes the SAME executor
|
|
133
|
+
* callback, emits fired/error events, and calls the finalizer. Completion
|
|
134
|
+
* (runCount/lastRun/lastStatus=persisted success) is handled by the same
|
|
135
|
+
* async finalization the timer-driven path uses.
|
|
136
|
+
*
|
|
137
|
+
* Returns ok:false (no throw) when the job or the executor is missing.
|
|
138
|
+
*/
|
|
139
|
+
runNow(jobId: string): { ok: true } | { ok: false; error: string } {
|
|
140
|
+
const job = this.jobs.get(jobId);
|
|
141
|
+
if (!job) return { ok: false, error: `No scheduled job with id '${jobId}'.` };
|
|
142
|
+
if (!this.executor) return { ok: false, error: "Scheduler is not running." };
|
|
143
|
+
this.fire(jobId, true);
|
|
144
|
+
// A run-now on a scheduled ONCE job CONSUMES it: the timer-driven path
|
|
145
|
+
// self-disables after firing (arm()'s setTimeout callback), and without
|
|
146
|
+
// the same step here the still-armed timer would fire the job a SECOND
|
|
147
|
+
// time at its scheduled time — a one-shot executing twice.
|
|
148
|
+
if (job.scheduleType === "once") this.update(jobId, { enabled: false });
|
|
149
|
+
return { ok: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
110
152
|
private arm(job: ScheduledJob): void {
|
|
111
153
|
if (this.timers.has(job.id)) return;
|
|
112
154
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
@@ -115,7 +157,7 @@ export class CrewScheduler {
|
|
|
115
157
|
this.timers.set(job.id, t);
|
|
116
158
|
} else if (job.scheduleType === "once") {
|
|
117
159
|
const target = new Date(job.schedule).getTime();
|
|
118
|
-
const delay = target -
|
|
160
|
+
const delay = target - this.now().getTime();
|
|
119
161
|
if (delay > 0) {
|
|
120
162
|
const t = setTimeout(() => {
|
|
121
163
|
this.fire(job.id);
|
|
@@ -131,7 +173,77 @@ export class CrewScheduler {
|
|
|
131
173
|
error: `Scheduled time ${job.schedule} is in the past`,
|
|
132
174
|
});
|
|
133
175
|
}
|
|
176
|
+
} else if (job.scheduleType === "cron") {
|
|
177
|
+
this.armCron(job);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Arm a cron job: schedule a chained setTimeout at the next occurrence
|
|
183
|
+
* (hops are clamped below the 2^31-1 ms setTimeout ceiling so yearly crons
|
|
184
|
+
* cannot mis-fire), fire once on arrival, then let the existing
|
|
185
|
+
* update() → disarm+arm lifecycle (triggered by fire()'s
|
|
186
|
+
* lastStatus='running' update) re-arm the NEXT occurrence. fire() itself
|
|
187
|
+
* stays persistence-blind; the post-fire nextRun advance happens in
|
|
188
|
+
* advanceCronNextRun and persists via the usual `updated` event flow.
|
|
189
|
+
*/
|
|
190
|
+
private armCron(job: ScheduledJob): void {
|
|
191
|
+
const now = this.now();
|
|
192
|
+
const next = nextRunTime({ kind: "cron", spec: job.schedule }, now);
|
|
193
|
+
if (!(next instanceof Date) || next.getTime() <= now.getTime()) {
|
|
194
|
+
this.disableCronForUncomputableNext(job.id, next);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.setCronTimeout(job.id, next.getTime());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Chain a clamped timeout hop toward the target cron occurrence. */
|
|
201
|
+
private setCronTimeout(jobId: string, targetMs: number): void {
|
|
202
|
+
const delay = Math.max(targetMs - this.now().getTime(), 0);
|
|
203
|
+
const t = setTimeout(() => this.cronTick(jobId, targetMs), Math.min(delay, MAX_TIMER_DELAY_MS));
|
|
204
|
+
t.unref();
|
|
205
|
+
this.timers.set(jobId, t);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Timer callback for a cron hop/arrival. Fires at most once per occurrence. */
|
|
209
|
+
private cronTick(jobId: string, targetMs: number): void {
|
|
210
|
+
const job = this.jobs.get(jobId);
|
|
211
|
+
if (!job?.enabled) return; // disabled/removed mid-flight: hop must die
|
|
212
|
+
if (this.now().getTime() < targetMs) {
|
|
213
|
+
// Clamped hop landed early — chain again for the remaining time.
|
|
214
|
+
this.setCronTimeout(jobId, targetMs);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Occurrence reached. fire()'s internal update({lastStatus:'running'})
|
|
218
|
+
// runs disarm→arm, which re-arms the NEXT occurrence exactly once; the
|
|
219
|
+
// hop that just fired is dead, so no timer doubles up.
|
|
220
|
+
this.fire(jobId);
|
|
221
|
+
this.advanceCronNextRun(jobId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** After a cron fire, advance the persisted nextRun to the next occurrence
|
|
225
|
+
* (or self-disable when no further occurrence is computable). */
|
|
226
|
+
private advanceCronNextRun(jobId: string): void {
|
|
227
|
+
const job = this.jobs.get(jobId);
|
|
228
|
+
if (!job?.enabled || job.scheduleType !== "cron") return;
|
|
229
|
+
const next = nextRunTime({ kind: "cron", spec: job.schedule }, this.now());
|
|
230
|
+
if (!(next instanceof Date)) {
|
|
231
|
+
this.disableCronForUncomputableNext(jobId, next);
|
|
232
|
+
return;
|
|
134
233
|
}
|
|
234
|
+
this.update(jobId, { nextRun: next.toISOString() });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** No computable next occurrence (e.g. Feb-29 beyond the 366-day search
|
|
238
|
+
* window): disable the job and record why. */
|
|
239
|
+
private disableCronForUncomputableNext(jobId: string, next: Date | { error: string }): void {
|
|
240
|
+
const reason = next && typeof next === "object" && "error" in next ? next.error : "next occurrence is not in the future";
|
|
241
|
+
this.update(jobId, { enabled: false, lastStatus: "error" });
|
|
242
|
+
this.emit?.({
|
|
243
|
+
type: "error",
|
|
244
|
+
jobId,
|
|
245
|
+
error: `Cron schedule cannot compute a next occurrence (${reason}); job disabled`,
|
|
246
|
+
});
|
|
135
247
|
}
|
|
136
248
|
|
|
137
249
|
private disarm(id: string): void {
|
|
@@ -139,18 +251,20 @@ export class CrewScheduler {
|
|
|
139
251
|
if (t) {
|
|
140
252
|
// Branch on timer type to use correct clear function
|
|
141
253
|
const job = this.jobs.get(id);
|
|
142
|
-
if (job?.scheduleType === "
|
|
143
|
-
clearTimeout(t as ReturnType<typeof setTimeout>);
|
|
144
|
-
} else {
|
|
254
|
+
if (job?.scheduleType === "interval") {
|
|
145
255
|
clearInterval(t as ReturnType<typeof setInterval>);
|
|
256
|
+
} else {
|
|
257
|
+
// `once` and `cron` both arm setTimeout handles (cron chains hops).
|
|
258
|
+
clearTimeout(t as ReturnType<typeof setTimeout>);
|
|
146
259
|
}
|
|
147
260
|
this.timers.delete(id);
|
|
148
261
|
}
|
|
149
262
|
}
|
|
150
263
|
|
|
151
|
-
private fire(id: string): void {
|
|
264
|
+
private fire(id: string, force = false): void {
|
|
152
265
|
const job = this.jobs.get(id);
|
|
153
|
-
if (!job
|
|
266
|
+
if (!job || !this.executor) return;
|
|
267
|
+
if (!job.enabled && !force) return;
|
|
154
268
|
this.update(id, { lastStatus: "running" });
|
|
155
269
|
let agentId: string;
|
|
156
270
|
try {
|
|
@@ -114,9 +114,9 @@ function readSettingsFile(filePath: string): CrewSettings {
|
|
|
114
114
|
* fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
|
|
115
115
|
* and the Wave 2B execution log in docs/refactor-plan.md).
|
|
116
116
|
*/
|
|
117
|
-
export function loadCrewSettings(cwd: string = process.cwd()): CrewSettings {
|
|
117
|
+
export function loadCrewSettings(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettings {
|
|
118
118
|
return {
|
|
119
|
-
...readSettingsFile(
|
|
119
|
+
...readSettingsFile(globalFile),
|
|
120
120
|
...readSettingsFile(projectPath(cwd)),
|
|
121
121
|
};
|
|
122
122
|
}
|
|
@@ -249,6 +249,32 @@ export function loadCrewSettingsTiers(cwd: string = process.cwd(), globalFile: s
|
|
|
249
249
|
return { user, project, merged: { ...user, ...project }, effectiveScheduledJobs, projectPath: projectFilePath };
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** P2-1 (B2 gate visibility): how many project-tier scheduledJobs the Wave B2
|
|
253
|
+
* opt-in gate is currently hiding, derived from an ALREADY-LOADED tiers view
|
|
254
|
+
* (no disk read — the lifecycle session_start stash reuses its single tiers
|
|
255
|
+
* read through this helper). Mirrors what `effectiveScheduledJobs` would add
|
|
256
|
+
* after opt-in: only shape-valid entries count (the same id + scheduleType +
|
|
257
|
+
* enabled predicate the provider fallback and the scheduler apply), so the
|
|
258
|
+
* hint never counts junk that would be skipped anyway. 0 whenever the user
|
|
259
|
+
* tier opted in (nothing is hidden). */
|
|
260
|
+
export function scheduledJobsHiddenCountOf(tiers: CrewSettingsTiers): number {
|
|
261
|
+
if (projectScheduledJobsOptIn(tiers.user)) return 0;
|
|
262
|
+
const jobs = tiers.project.scheduledJobs;
|
|
263
|
+
if (!Array.isArray(jobs)) return 0;
|
|
264
|
+
return jobs.filter(validateScheduledJob).length;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** P2-1 companion getter: how many project-tier scheduledJobs the B2 gate is
|
|
268
|
+
* filtering out of `effectiveScheduledJobs` (and therefore out of every
|
|
269
|
+
* read surface). `getScheduledJobs()`'s signature is intentionally unchanged
|
|
270
|
+
* — consumers that want the hidden count call this next to their existing
|
|
271
|
+
* provider read. Reads the tiers (one disk read per call): user-initiated
|
|
272
|
+
* surfaces only (pane render/command handlers); paint-path consumers use the
|
|
273
|
+
* registration-time stash in handle-schedule.ts (P0-6). */
|
|
274
|
+
export function getScheduledJobsHiddenCount(cwd: string = process.cwd(), globalFile: string = globalPath()): number {
|
|
275
|
+
return scheduledJobsHiddenCountOf(loadCrewSettingsTiers(cwd, globalFile));
|
|
276
|
+
}
|
|
277
|
+
|
|
252
278
|
/**
|
|
253
279
|
* Apply crew settings to a config with correct trust tiering. Returns the
|
|
254
280
|
* list of drop warnings (standard project-override format).
|