agent-tempo 1.7.0 → 2.0.0-beta.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.
Files changed (147) hide show
  1. package/CLAUDE.md +10 -13
  2. package/README.md +21 -15
  3. package/dashboard/dist/assets/index-BbR7zIdK.js.map +1 -1
  4. package/dashboard/package.json +1 -1
  5. package/dist/activities/hard-terminate.d.ts +2 -2
  6. package/dist/activities/hard-terminate.js +3 -9
  7. package/dist/activities/maestro.d.ts +8 -13
  8. package/dist/activities/maestro.js +0 -10
  9. package/dist/activities/outbox.js +4 -2
  10. package/dist/adapters/base.js +3 -0
  11. package/dist/cli/commands.d.ts +8 -0
  12. package/dist/cli/commands.js +79 -11
  13. package/dist/cli/daemon-command.js +19 -33
  14. package/dist/cli/help-text.js +7 -7
  15. package/dist/cli/home-command.d.ts +26 -0
  16. package/dist/cli/home-command.js +156 -0
  17. package/dist/cli/removed-verbs.d.ts +0 -4
  18. package/dist/cli/removed-verbs.js +23 -12
  19. package/dist/cli/sa-preflight.d.ts +0 -14
  20. package/dist/cli/sa-preflight.js +1 -17
  21. package/dist/cli/startup.d.ts +1 -10
  22. package/dist/cli/startup.js +0 -40
  23. package/dist/cli.js +34 -80
  24. package/dist/client/core.js +13 -15
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/interface.d.ts +5 -14
  27. package/dist/client/with-spawn.d.ts +9 -4
  28. package/dist/client/with-spawn.js +7 -10
  29. package/dist/config.d.ts +0 -13
  30. package/dist/constants.d.ts +22 -0
  31. package/dist/constants.js +23 -1
  32. package/dist/daemon.js +52 -0
  33. package/dist/http/auth.d.ts +3 -21
  34. package/dist/http/auth.js +10 -31
  35. package/dist/http/event-types.d.ts +5 -4
  36. package/dist/http/orphans.d.ts +3 -3
  37. package/dist/http/orphans.js +3 -3
  38. package/dist/http/server.d.ts +0 -5
  39. package/dist/http/server.js +69 -29
  40. package/dist/http/writes.d.ts +1 -1
  41. package/dist/http/writes.js +25 -2
  42. package/dist/palette/index.d.ts +6 -7
  43. package/dist/palette/index.js +6 -7
  44. package/dist/pi/mission-control/actions.d.ts +67 -1
  45. package/dist/pi/mission-control/actions.js +61 -0
  46. package/dist/pi/mission-control/extension.d.ts +77 -0
  47. package/dist/pi/mission-control/extension.js +171 -0
  48. package/dist/pi/workflow-client.d.ts +14 -16
  49. package/dist/pi/workflow-client.js +18 -44
  50. package/dist/types.d.ts +15 -6
  51. package/dist/upgrade/boot-guard.d.ts +95 -0
  52. package/dist/upgrade/boot-guard.js +88 -0
  53. package/dist/upgrade/from-upgrade.d.ts +95 -0
  54. package/dist/upgrade/from-upgrade.js +330 -0
  55. package/dist/upgrade/phase-engine.js +4 -1
  56. package/dist/utils/duration.d.ts +3 -6
  57. package/dist/utils/duration.js +3 -6
  58. package/dist/utils/format-hosts.d.ts +1 -1
  59. package/dist/utils/format-hosts.js +0 -2
  60. package/dist/utils/grpc-shutdown-guard.js +0 -4
  61. package/dist/utils/hosts.d.ts +4 -10
  62. package/dist/utils/hosts.js +12 -28
  63. package/dist/utils/search-attributes.d.ts +27 -18
  64. package/dist/utils/search-attributes.js +39 -18
  65. package/dist/utils/visibility-deadline.d.ts +16 -0
  66. package/dist/utils/visibility-deadline.js +16 -0
  67. package/dist/workflows/maestro-signals.d.ts +0 -13
  68. package/dist/workflows/maestro-signals.js +6 -14
  69. package/dist/workflows/maestro.js +27 -32
  70. package/dist/workflows/scheduler.js +7 -1
  71. package/dist/workflows/session.js +44 -110
  72. package/dist/workflows/signals.d.ts +9 -1
  73. package/dist/workflows/signals.js +8 -6
  74. package/package.json +1 -6
  75. package/workflow-bundle.js +239 -182
  76. package/dist/cli/legacy-migration.d.ts +0 -35
  77. package/dist/cli/legacy-migration.js +0 -341
  78. package/dist/client/ensure-conductor-spawned.d.ts +0 -35
  79. package/dist/client/ensure-conductor-spawned.js +0 -48
  80. package/dist/tui/App.d.ts +0 -85
  81. package/dist/tui/App.js +0 -1791
  82. package/dist/tui/bootstrap-types.d.ts +0 -46
  83. package/dist/tui/bootstrap-types.js +0 -7
  84. package/dist/tui/commands.d.ts +0 -71
  85. package/dist/tui/commands.js +0 -1375
  86. package/dist/tui/components/ChatView.d.ts +0 -35
  87. package/dist/tui/components/ChatView.js +0 -54
  88. package/dist/tui/components/CommandPalette.d.ts +0 -21
  89. package/dist/tui/components/CommandPalette.js +0 -67
  90. package/dist/tui/components/ConversationStream.d.ts +0 -114
  91. package/dist/tui/components/ConversationStream.js +0 -307
  92. package/dist/tui/components/CreateEnsembleWizard.d.ts +0 -19
  93. package/dist/tui/components/CreateEnsembleWizard.js +0 -223
  94. package/dist/tui/components/DestroyConfirmModal.d.ts +0 -17
  95. package/dist/tui/components/DestroyConfirmModal.js +0 -62
  96. package/dist/tui/components/ErrorView.d.ts +0 -31
  97. package/dist/tui/components/ErrorView.js +0 -129
  98. package/dist/tui/components/HomeView.d.ts +0 -54
  99. package/dist/tui/components/HomeView.js +0 -306
  100. package/dist/tui/components/LoadLineupModal.d.ts +0 -18
  101. package/dist/tui/components/LoadLineupModal.js +0 -79
  102. package/dist/tui/components/MainView.d.ts +0 -21
  103. package/dist/tui/components/MainView.js +0 -107
  104. package/dist/tui/components/NewEnsembleModal.d.ts +0 -9
  105. package/dist/tui/components/NewEnsembleModal.js +0 -73
  106. package/dist/tui/components/Picker.d.ts +0 -23
  107. package/dist/tui/components/Picker.js +0 -70
  108. package/dist/tui/components/PlayerDetailView.d.ts +0 -26
  109. package/dist/tui/components/PlayerDetailView.js +0 -118
  110. package/dist/tui/components/PromptArea.d.ts +0 -50
  111. package/dist/tui/components/PromptArea.js +0 -303
  112. package/dist/tui/components/RecruitWizard.d.ts +0 -17
  113. package/dist/tui/components/RecruitWizard.js +0 -221
  114. package/dist/tui/components/RestoreConfirmModal.d.ts +0 -18
  115. package/dist/tui/components/RestoreConfirmModal.js +0 -71
  116. package/dist/tui/components/ScheduleWizard.d.ts +0 -19
  117. package/dist/tui/components/ScheduleWizard.js +0 -259
  118. package/dist/tui/components/Splash.d.ts +0 -23
  119. package/dist/tui/components/Splash.js +0 -221
  120. package/dist/tui/components/StatusBar.d.ts +0 -48
  121. package/dist/tui/components/StatusBar.js +0 -128
  122. package/dist/tui/components/StatusOverlay.d.ts +0 -15
  123. package/dist/tui/components/StatusOverlay.js +0 -76
  124. package/dist/tui/components/TitleBar.d.ts +0 -10
  125. package/dist/tui/components/TitleBar.js +0 -21
  126. package/dist/tui/index.d.ts +0 -15
  127. package/dist/tui/index.js +0 -162
  128. package/dist/tui/ink-context.d.ts +0 -18
  129. package/dist/tui/ink-context.js +0 -59
  130. package/dist/tui/ink-loader.d.ts +0 -26
  131. package/dist/tui/ink-loader.js +0 -42
  132. package/dist/tui/removed-commands.d.ts +0 -9
  133. package/dist/tui/removed-commands.js +0 -22
  134. package/dist/tui/sse-handler.d.ts +0 -52
  135. package/dist/tui/sse-handler.js +0 -157
  136. package/dist/tui/store.d.ts +0 -598
  137. package/dist/tui/store.js +0 -753
  138. package/dist/tui/utils/format.d.ts +0 -56
  139. package/dist/tui/utils/format.js +0 -155
  140. package/dist/tui/utils/fullscreen.d.ts +0 -23
  141. package/dist/tui/utils/fullscreen.js +0 -71
  142. package/dist/tui/utils/history.d.ts +0 -10
  143. package/dist/tui/utils/history.js +0 -85
  144. package/dist/tui/utils/platform.d.ts +0 -45
  145. package/dist/tui/utils/platform.js +0 -258
  146. package/dist/tui/utils/theme.d.ts +0 -21
  147. package/dist/tui/utils/theme.js +0 -24
package/dist/tui/store.js DELETED
@@ -1,753 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_CREATE_ENSEMBLE_ANSWERS = exports.CREATE_ENSEMBLE_STEPS = exports.DEFAULT_SCHEDULE_ANSWERS = exports.SCHEDULE_STEPS = exports.RECRUIT_STEPS = exports.NOTIFICATION_CAP = exports.NOTIFICATION_TTL_MS = void 0;
4
- exports.toConversationEntry = toConversationEntry;
5
- exports.defaultRecruitAnswers = defaultRecruitAnswers;
6
- exports.initialState = initialState;
7
- exports.tuiReducer = tuiReducer;
8
- /**
9
- * Project an `EnsembleChatMessage` into a `ConversationEntry`. Shared by
10
- * the reducer's incremental path (`APPEND_CHAT_MESSAGE`) and the SSE
11
- * snapshot/recovery paths (`src/tui/sse-handler.ts`) so both produce the
12
- * same shape.
13
- */
14
- function toConversationEntry(m) {
15
- return {
16
- id: m.id,
17
- from: m.from,
18
- to: m.to,
19
- text: m.text,
20
- timestamp: m.timestamp,
21
- direction: m.role === 'maestro-out' ? 'out' : 'in',
22
- role: m.role,
23
- thirdParty: m.role === 'conductor-out' || m.role === 'conductor-in',
24
- // #357: forward broadcastId so the renderer can fold fan-out groups.
25
- ...(m.broadcastId !== undefined ? { broadcastId: m.broadcastId } : {}),
26
- };
27
- }
28
- /** Default TTLs by kind — errors get extra time to read. */
29
- exports.NOTIFICATION_TTL_MS = {
30
- error: 8000,
31
- warn: 5000,
32
- info: 5000,
33
- };
34
- /** Maximum concurrent notifications; oldest is dropped when exceeded. */
35
- exports.NOTIFICATION_CAP = 3;
36
- exports.RECRUIT_STEPS = ['name', 'agent', 'type', 'workDir', 'message', 'host', 'confirm'];
37
- exports.SCHEDULE_STEPS = ['target', 'message', 'schedType', 'timing', 'timezone', 'confirm'];
38
- exports.DEFAULT_SCHEDULE_ANSWERS = {
39
- target: '',
40
- message: '',
41
- schedType: 'every',
42
- timing: '',
43
- timezone: '',
44
- name: '',
45
- };
46
- function defaultRecruitAnswers(defaultAgent = 'claude') {
47
- return {
48
- name: '',
49
- agent: defaultAgent,
50
- playerType: '',
51
- workDir: process.cwd(),
52
- initialMessage: '',
53
- host: 'localhost',
54
- };
55
- }
56
- exports.CREATE_ENSEMBLE_STEPS = ['name', 'workDir', 'lineup', 'confirm'];
57
- exports.DEFAULT_CREATE_ENSEMBLE_ANSWERS = {
58
- name: '',
59
- workDir: process.cwd(),
60
- lineup: '',
61
- };
62
- function initialState(ensemble) {
63
- return {
64
- phase: ensemble ? 'main' : 'splash',
65
- view: ensemble ? 'ensemble' : 'home',
66
- splashStatus: 'Starting up...',
67
- splashChecks: [],
68
- splashConnected: false,
69
- splashSummary: undefined,
70
- ensembles: null,
71
- selectedEnsembleIndex: 0,
72
- activeEnsemble: ensemble || null,
73
- players: [],
74
- playersLoaded: false,
75
- messages: [],
76
- conductorHistory: [],
77
- schedules: [],
78
- conversation: null,
79
- ensembleChat: [],
80
- hasConductor: false,
81
- ensemblePaused: false,
82
- ensembleHeld: false,
83
- selectedPlayerIndex: 0,
84
- activePlayer: null,
85
- playerMetadata: null,
86
- playerMessages: [],
87
- playerScrollOffset: 0,
88
- staticItems: [],
89
- notifications: [],
90
- notificationTick: 0,
91
- chatTarget: undefined,
92
- sentMessages: [],
93
- statusOverlay: false,
94
- overlay: null,
95
- statusScrollOffset: 0,
96
- paletteVisible: false,
97
- paletteIndex: 0,
98
- pickerVisible: false,
99
- pickerType: null,
100
- pickerIntent: null,
101
- pickerIndex: 0,
102
- pickerStatusFilter: null,
103
- confirmingStop: undefined,
104
- confirmingStopReason: undefined,
105
- scheduleWizard: undefined,
106
- };
107
- }
108
- // ── Reducer ──
109
- function tuiReducer(state, action) {
110
- switch (action.type) {
111
- case 'SET_PHASE':
112
- return { ...state, phase: action.phase, error: action.error };
113
- case 'SET_SPLASH_STATUS':
114
- return { ...state, splashStatus: action.status };
115
- case 'SET_SPLASH_CHECKS':
116
- return { ...state, splashChecks: action.checks };
117
- case 'SET_SPLASH_CONNECTED':
118
- return { ...state, splashConnected: true, splashSummary: action.summary };
119
- // ── Navigation ──
120
- case 'NAVIGATE_HOME':
121
- // Auto-select was removed from the poller (#306 follow-up): HomeView
122
- // is the explicit ensemble picker, so once the user lands on home they
123
- // stay there until they pick an ensemble themselves. This action just
124
- // resets ensemble/player state and the chat shell.
125
- return {
126
- ...state,
127
- view: 'home',
128
- phase: 'main',
129
- activeEnsemble: null,
130
- activePlayer: null,
131
- chatTarget: undefined,
132
- players: [],
133
- playersLoaded: false,
134
- messages: [],
135
- sentMessages: [],
136
- conductorHistory: [],
137
- schedules: [],
138
- conversation: null,
139
- playerMetadata: null,
140
- playerMessages: [],
141
- ensemblePaused: false,
142
- ensembleHeld: false,
143
- selectedPlayerIndex: 0,
144
- };
145
- case 'NAVIGATE_ENSEMBLE':
146
- return {
147
- ...state,
148
- view: 'ensemble',
149
- phase: 'main',
150
- activeEnsemble: action.ensemble,
151
- activePlayer: null,
152
- chatTarget: undefined,
153
- players: [],
154
- playersLoaded: false,
155
- messages: [],
156
- sentMessages: [],
157
- conductorHistory: [],
158
- schedules: [],
159
- conversation: null,
160
- playerMetadata: null,
161
- playerMessages: [],
162
- ensemblePaused: false,
163
- ensembleHeld: false,
164
- selectedPlayerIndex: 0,
165
- };
166
- case 'NAVIGATE_PLAYER':
167
- return {
168
- ...state,
169
- view: 'player',
170
- activePlayer: action.playerId,
171
- playerMetadata: null,
172
- playerMessages: [],
173
- playerScrollOffset: 0,
174
- };
175
- // ── Data refresh ──
176
- case 'REFRESH_ENSEMBLES':
177
- return {
178
- ...state,
179
- ensembles: action.ensembles,
180
- // Clamp selection index
181
- selectedEnsembleIndex: Math.min(state.selectedEnsembleIndex, Math.max(0, action.ensembles.length - 1)),
182
- };
183
- case 'REFRESH_ENSEMBLE_DATA': {
184
- const lastMsg = action.messages.length > 0 ? action.messages[action.messages.length - 1] : null;
185
- return {
186
- ...state,
187
- players: action.players,
188
- playersLoaded: true,
189
- messages: action.messages,
190
- conductorHistory: action.history,
191
- schedules: action.schedules ?? state.schedules,
192
- lastSeenMessageId: lastMsg?.id ?? state.lastSeenMessageId,
193
- // Clamp selection index
194
- selectedPlayerIndex: Math.min(state.selectedPlayerIndex, Math.max(0, action.players.length - 1)),
195
- };
196
- }
197
- case 'SET_CONVERSATION':
198
- return { ...state, conversation: action.conversation };
199
- case 'SET_ENSEMBLE_CHAT':
200
- return {
201
- ...state,
202
- ensembleChat: action.chat.messages,
203
- hasConductor: action.chat.hasConductor,
204
- };
205
- // ── #94/#95 PR-4a — SSE event stream incremental updates ──
206
- case 'APPEND_CHAT_MESSAGE': {
207
- // `chat.appended` SSE events arrive one-per-message. We push onto
208
- // `ensembleChat` and derive a matching conversation entry via
209
- // `toConversationEntry` (also used by the snapshot path), so
210
- // ChatView/ConversationStream see a continuous timeline. Cap the
211
- // in-memory slice at 500 to bound memory on long-running sessions;
212
- // PR-4b will adjust this as part of the scroll-view migration.
213
- const m = action.message;
214
- const newChat = [...state.ensembleChat, m];
215
- const cappedChat = newChat.length > 500 ? newChat.slice(-500) : newChat;
216
- const baseConv = state.conversation ?? [];
217
- const newConv = [...baseConv, toConversationEntry(m)];
218
- const cappedConv = newConv.length > 500 ? newConv.slice(-500) : newConv;
219
- return { ...state, ensembleChat: cappedChat, conversation: cappedConv };
220
- }
221
- case 'UPSERT_PLAYER': {
222
- // `player.added` and `player.phase_changed` both land here — added
223
- // creates a new entry; phase_changed updates fields on the existing
224
- // entry. Identity-preserving when the wire payload matches the
225
- // current cached entry exactly so StatusBar/MainView don't churn
226
- // on duplicate events (e.g. a quick disconnect/reconnect).
227
- const incoming = action.player;
228
- const idx = state.players.findIndex((p) => p.playerId === incoming.playerId);
229
- if (idx === -1) {
230
- return { ...state, players: [...state.players, incoming], playersLoaded: true };
231
- }
232
- const existing = state.players[idx];
233
- const merged = { ...existing, ...incoming };
234
- const fieldsEqual = existing.phase === merged.phase
235
- && existing.part === merged.part
236
- && existing.hostname === merged.hostname
237
- && existing.workDir === merged.workDir
238
- && existing.isConductor === merged.isConductor
239
- && existing.gitBranch === merged.gitBranch
240
- && existing.playerType === merged.playerType
241
- && existing.agentType === merged.agentType;
242
- if (fieldsEqual)
243
- return state;
244
- const next = state.players.slice();
245
- next[idx] = merged;
246
- return { ...state, players: next };
247
- }
248
- case 'PATCH_PLAYER_PHASE': {
249
- // Rationale on the action type. Identity-preserving guard mirrors
250
- // SET_ENSEMBLE_PAUSED — same StatusBar churn rationale.
251
- const idx = state.players.findIndex((p) => p.playerId === action.playerId);
252
- if (idx === -1)
253
- return state;
254
- if (state.players[idx].phase === action.phase)
255
- return state;
256
- const next = state.players.slice();
257
- next[idx] = { ...state.players[idx], phase: action.phase };
258
- return { ...state, players: next };
259
- }
260
- case 'REMOVE_PLAYER': {
261
- const next = state.players.filter((p) => p.playerId !== action.playerId);
262
- if (next.length === state.players.length)
263
- return state;
264
- return {
265
- ...state,
266
- players: next,
267
- selectedPlayerIndex: Math.min(state.selectedPlayerIndex, Math.max(0, next.length - 1)),
268
- };
269
- }
270
- case 'SET_SCHEDULES':
271
- return { ...state, schedules: action.schedules };
272
- case 'REFRESH_PLAYER_DATA':
273
- return {
274
- ...state,
275
- playerMetadata: action.metadata,
276
- playerMessages: action.messages,
277
- };
278
- case 'PLAYER_SCROLL_UP':
279
- if (state.playerScrollOffset <= 0)
280
- return state;
281
- return { ...state, playerScrollOffset: state.playerScrollOffset - 1 };
282
- case 'PLAYER_SCROLL_DOWN': {
283
- const maxScroll = Math.max(0, state.playerMessages.length - 20);
284
- if (state.playerScrollOffset >= maxScroll)
285
- return state;
286
- return { ...state, playerScrollOffset: state.playerScrollOffset + 1 };
287
- }
288
- // ── Selection & Focus ──
289
- // ── Chat shell ──
290
- case 'COMMIT_STATIC': {
291
- const newItems = [...state.staticItems, action.item];
292
- // Trim to last 500 entries for memory management
293
- const trimmed = newItems.length > 500 ? newItems.slice(-500) : newItems;
294
- return { ...state, staticItems: trimmed };
295
- }
296
- // ── Bottom-pinned notifications (#306) ──
297
- case 'ADD_NOTIFICATION': {
298
- const now = Date.now();
299
- // Filter out expired entries before appending so the cap check isn't
300
- // polluted by stale notifications that should already be gone.
301
- const live = state.notifications.filter(n => n.expiresAt > now);
302
- const appended = [...live, action.notification];
303
- // Cap at NOTIFICATION_CAP — oldest drops off when exceeded.
304
- const capped = appended.length > exports.NOTIFICATION_CAP
305
- ? appended.slice(appended.length - exports.NOTIFICATION_CAP)
306
- : appended;
307
- return { ...state, notifications: capped };
308
- }
309
- case 'DISMISS_NOTIFICATION': {
310
- const next = state.notifications.filter(n => n.id !== action.id);
311
- if (next.length === state.notifications.length)
312
- return state;
313
- return { ...state, notifications: next };
314
- }
315
- case 'DISMISS_OLDEST_NOTIFICATION': {
316
- // Filter-expired-first so Esc acts on what the user actually sees.
317
- const now = Date.now();
318
- const live = state.notifications.filter(n => n.expiresAt > now);
319
- if (live.length === 0) {
320
- // Still compact if any expired slipped through — keeps the array clean
321
- if (live.length !== state.notifications.length) {
322
- return { ...state, notifications: live };
323
- }
324
- return state;
325
- }
326
- return { ...state, notifications: live.slice(1) };
327
- }
328
- case 'CLEAR_NOTIFICATIONS': {
329
- if (state.notifications.length === 0)
330
- return state;
331
- return { ...state, notifications: [] };
332
- }
333
- case 'NOTIFICATION_TICK': {
334
- // Cheap re-render trigger so expired notifications disappear from the
335
- // rendered stack without requiring a reducer action per expiration.
336
- // The render pass filters by `expiresAt > Date.now()`. Only the counter
337
- // changes — we leave the notifications array alone to avoid thrashing
338
- // the React children identity.
339
- return { ...state, notificationTick: state.notificationTick + 1 };
340
- }
341
- // ── Command palette ──
342
- case 'SHOW_PALETTE':
343
- if (state.paletteVisible && state.paletteIndex === 0)
344
- return state;
345
- return { ...state, paletteVisible: true, paletteIndex: 0 };
346
- case 'HIDE_PALETTE':
347
- if (!state.paletteVisible && state.paletteIndex === 0)
348
- return state;
349
- return { ...state, paletteVisible: false, paletteIndex: 0 };
350
- case 'PALETTE_UP': {
351
- // #108: identity-preserving — returning `{ ...state, paletteIndex: same }`
352
- // forces a re-render of the full app tree even when the value didn't change
353
- // (e.g. holding arrow-up at index 0). Mirrors the OVERLAY_SELECT pattern
354
- // below. See CLAUDE.md "Reducer state identity matters".
355
- const next = Math.max(0, state.paletteIndex - 1);
356
- if (next === state.paletteIndex)
357
- return state;
358
- return { ...state, paletteIndex: next };
359
- }
360
- case 'PALETTE_DOWN': {
361
- // #108: same identity-preserving guard — holding arrow-down at the clamped
362
- // max must not trigger spurious re-renders.
363
- const next = state.paletteIndex + 1;
364
- const clamped = action.max != null ? Math.min(next, action.max) : next;
365
- if (clamped === state.paletteIndex)
366
- return state;
367
- return { ...state, paletteIndex: clamped };
368
- }
369
- case 'PALETTE_SET_INDEX':
370
- return { ...state, paletteIndex: action.index };
371
- // ── Picker overlay ──
372
- case 'SHOW_STATUS':
373
- return { ...state, statusOverlay: true, statusScrollOffset: 0 };
374
- case 'HIDE_STATUS':
375
- return { ...state, statusOverlay: false, statusScrollOffset: 0 };
376
- case 'SHOW_COMMAND_OVERLAY':
377
- return { ...state, overlay: { type: 'command', title: action.title, items: [{ id: '_', label: action.content }], selectedIndex: 0, hint: 'esc \u2014 close' } };
378
- case 'SHOW_OVERLAY':
379
- return { ...state, overlay: { ...action.overlay, selectedIndex: 0 } };
380
- case 'HIDE_OVERLAY':
381
- if (!state.overlay)
382
- return state;
383
- return { ...state, overlay: null };
384
- case 'OVERLAY_SELECT': {
385
- if (!state.overlay || state.overlay.items.length === 0)
386
- return state;
387
- const len = state.overlay.items.length;
388
- const idx = action.direction === 'up'
389
- ? (state.overlay.selectedIndex - 1 + len) % len
390
- : (state.overlay.selectedIndex + 1) % len;
391
- if (idx === state.overlay.selectedIndex)
392
- return state;
393
- return { ...state, overlay: { ...state.overlay, selectedIndex: idx } };
394
- }
395
- case 'STATUS_SCROLL_UP': {
396
- const next = Math.max(0, state.statusScrollOffset - 1);
397
- if (next === state.statusScrollOffset)
398
- return state;
399
- return { ...state, statusScrollOffset: next };
400
- }
401
- case 'STATUS_SCROLL_DOWN':
402
- // #250: intentional — no upper-bound clamp lives in the reducer. The max
403
- // offset depends on `players.length` and `contentHeight`, neither of which
404
- // the reducer can see. `StatusOverlay` performs the final clamp via
405
- // `Math.min(scrollOffset, max)` at render (components/StatusOverlay.tsx).
406
- // Leaving the reducer unbounded + render-time clamping is cheaper than
407
- // threading the render-time max back through every dispatch.
408
- return { ...state, statusScrollOffset: state.statusScrollOffset + 1 };
409
- case 'SHOW_PICKER':
410
- return { ...state, pickerVisible: true, pickerType: action.pickerType, pickerIntent: action.intent || null, pickerIndex: 0, pickerStatusFilter: action.statusFilter || null };
411
- case 'HIDE_PICKER':
412
- return { ...state, pickerVisible: false, pickerType: null, pickerIntent: null, pickerIndex: 0, pickerStatusFilter: null };
413
- case 'PICKER_UP': {
414
- const next = Math.max(0, state.pickerIndex - 1);
415
- if (next === state.pickerIndex)
416
- return state;
417
- return { ...state, pickerIndex: next };
418
- }
419
- case 'PICKER_DOWN': {
420
- const maxIdx = (state.pickerType === 'ensembles' ? (state.ensembles?.length ?? 0) : state.players.length - 1);
421
- if (state.pickerIndex >= maxIdx)
422
- return state;
423
- return { ...state, pickerIndex: state.pickerIndex + 1 };
424
- }
425
- case 'SET_ENSEMBLE_PAUSED':
426
- // Identity-preserving: skip the dispatch when value didn't change so
427
- // the StatusBar tree doesn't re-render every poll tick.
428
- if (state.ensemblePaused === action.paused)
429
- return state;
430
- return { ...state, ensemblePaused: action.paused };
431
- case 'SET_ENSEMBLE_HELD':
432
- // Identity-preserving — same rationale as SET_ENSEMBLE_PAUSED. The
433
- // 2s held-poll would otherwise reconcile the StatusBar every tick
434
- // even when nothing changed.
435
- if (state.ensembleHeld === action.held)
436
- return state;
437
- return { ...state, ensembleHeld: action.held };
438
- case 'APPEND_SENT_MESSAGE': {
439
- const newSent = [...state.sentMessages, { to: action.to, text: action.text, timestamp: new Date().toISOString() }];
440
- const trimmedSent = newSent.length > 200 ? newSent.slice(-200) : newSent;
441
- return { ...state, sentMessages: trimmedSent };
442
- }
443
- case 'HYDRATE_SENT_MESSAGES': {
444
- // Merge server-side sent messages, dedup by text+timestamp
445
- const existing = new Set(state.sentMessages.map(m => `${m.text.slice(0, 60)}:${m.timestamp}`));
446
- const newMsgs = action.messages.filter(m => !existing.has(`${m.text.slice(0, 60)}:${m.timestamp}`));
447
- if (newMsgs.length === 0)
448
- return state;
449
- return { ...state, sentMessages: [...state.sentMessages, ...newMsgs] };
450
- }
451
- case 'ENTER_CHAT':
452
- return { ...state, phase: 'chat', chatTarget: action.target };
453
- case 'EXIT_CHAT':
454
- return { ...state, phase: 'main', chatTarget: undefined };
455
- // ── Destroy confirmation (PR-H: was `/stop`; renamed to `/destroy`) ──
456
- case 'CONFIRM_STOP':
457
- return {
458
- ...state,
459
- confirmingStop: action.player,
460
- ...(action.reason !== undefined ? { confirmingStopReason: action.reason } : { confirmingStopReason: undefined }),
461
- };
462
- case 'CANCEL_STOP':
463
- return { ...state, confirmingStop: undefined, confirmingStopReason: undefined };
464
- // ── Disband confirmation ──
465
- case 'CONFIRM_DISBAND':
466
- return { ...state, confirmingDisband: action.ensemble };
467
- case 'CANCEL_DISBAND':
468
- return { ...state, confirmingDisband: undefined };
469
- // ── Lineup confirmation ──
470
- case 'CONFIRM_LINEUP':
471
- return { ...state, confirmingLineup: { action: action.action, path: action.path, summary: action.summary } };
472
- case 'CANCEL_LINEUP':
473
- return { ...state, confirmingLineup: undefined };
474
- // ── Recruit wizard ──
475
- case 'ENTER_RECRUIT':
476
- return {
477
- ...state,
478
- phase: 'recruit',
479
- recruitState: {
480
- step: 'name',
481
- answers: { ...defaultRecruitAnswers(action.defaultAgent), ...action.answers },
482
- preRecruitPhase: state.phase,
483
- preRecruitChatTarget: state.chatTarget,
484
- },
485
- };
486
- case 'RECRUIT_NEXT_STEP': {
487
- if (!state.recruitState)
488
- return state;
489
- const answers = { ...state.recruitState.answers, ...action.answer };
490
- const currentIdx = exports.RECRUIT_STEPS.indexOf(state.recruitState.step);
491
- const nextStep = exports.RECRUIT_STEPS[currentIdx + 1] ?? state.recruitState.step;
492
- return {
493
- ...state,
494
- recruitState: { ...state.recruitState, step: nextStep, answers },
495
- };
496
- }
497
- case 'RECRUIT_PREV_STEP': {
498
- if (!state.recruitState)
499
- return state;
500
- const currentIdx = exports.RECRUIT_STEPS.indexOf(state.recruitState.step);
501
- if (currentIdx <= 0)
502
- return state;
503
- const prevStep = exports.RECRUIT_STEPS[currentIdx - 1];
504
- return {
505
- ...state,
506
- recruitState: { ...state.recruitState, step: prevStep },
507
- };
508
- }
509
- case 'RECRUIT_SUBMIT':
510
- if (!state.recruitState)
511
- return state;
512
- return {
513
- ...state,
514
- recruitState: { ...state.recruitState, submitting: true },
515
- };
516
- case 'RECRUIT_DONE':
517
- if (!state.recruitState)
518
- return state;
519
- return {
520
- ...state,
521
- recruitState: {
522
- ...state.recruitState,
523
- step: 'done',
524
- submitting: false,
525
- error: action.error,
526
- },
527
- };
528
- case 'EXIT_RECRUIT': {
529
- const restorePhase = state.recruitState?.preRecruitPhase || 'main';
530
- const restoreChat = state.recruitState?.preRecruitChatTarget;
531
- return {
532
- ...state,
533
- phase: restorePhase,
534
- chatTarget: restoreChat,
535
- recruitState: undefined,
536
- };
537
- }
538
- // ── Schedule wizard ──
539
- case 'ENTER_SCHEDULE_WIZARD':
540
- return {
541
- ...state,
542
- phase: 'schedule-create',
543
- scheduleWizard: {
544
- step: 'target',
545
- answers: { ...exports.DEFAULT_SCHEDULE_ANSWERS, ...action.answers },
546
- prePhase: state.phase,
547
- preChatTarget: state.chatTarget,
548
- },
549
- };
550
- case 'SCHEDULE_NEXT_STEP': {
551
- if (!state.scheduleWizard)
552
- return state;
553
- const answers = { ...state.scheduleWizard.answers, ...action.answer };
554
- const currentIdx = exports.SCHEDULE_STEPS.indexOf(state.scheduleWizard.step);
555
- // Skip timezone step for non-cron types
556
- let nextIdx = currentIdx + 1;
557
- if (exports.SCHEDULE_STEPS[nextIdx] === 'timezone' && answers.schedType !== 'cron') {
558
- nextIdx++;
559
- }
560
- const nextStep = exports.SCHEDULE_STEPS[nextIdx] ?? state.scheduleWizard.step;
561
- return {
562
- ...state,
563
- scheduleWizard: { ...state.scheduleWizard, step: nextStep, answers },
564
- };
565
- }
566
- case 'SCHEDULE_PREV_STEP': {
567
- if (!state.scheduleWizard)
568
- return state;
569
- const currentIdx = exports.SCHEDULE_STEPS.indexOf(state.scheduleWizard.step);
570
- if (currentIdx <= 0)
571
- return state;
572
- let prevIdx = currentIdx - 1;
573
- // Skip timezone going back for non-cron
574
- if (exports.SCHEDULE_STEPS[prevIdx] === 'timezone' && state.scheduleWizard.answers.schedType !== 'cron') {
575
- prevIdx--;
576
- }
577
- const prevStep = exports.SCHEDULE_STEPS[Math.max(0, prevIdx)];
578
- return {
579
- ...state,
580
- scheduleWizard: { ...state.scheduleWizard, step: prevStep },
581
- };
582
- }
583
- case 'SCHEDULE_SUBMIT':
584
- if (!state.scheduleWizard)
585
- return state;
586
- return {
587
- ...state,
588
- scheduleWizard: { ...state.scheduleWizard, submitting: true },
589
- };
590
- case 'SCHEDULE_DONE':
591
- if (!state.scheduleWizard)
592
- return state;
593
- return {
594
- ...state,
595
- scheduleWizard: {
596
- ...state.scheduleWizard,
597
- step: 'done',
598
- submitting: false,
599
- error: action.error,
600
- },
601
- };
602
- case 'EXIT_SCHEDULE_WIZARD': {
603
- const restoreP = state.scheduleWizard?.prePhase || 'main';
604
- const restoreC = state.scheduleWizard?.preChatTarget;
605
- return {
606
- ...state,
607
- phase: restoreP,
608
- chatTarget: restoreC,
609
- scheduleWizard: undefined,
610
- };
611
- }
612
- // ── Create Ensemble wizard ──
613
- case 'ENTER_CREATE_ENSEMBLE':
614
- return {
615
- ...state,
616
- phase: 'create-ensemble',
617
- createEnsembleState: {
618
- step: 'name',
619
- answers: { ...exports.DEFAULT_CREATE_ENSEMBLE_ANSWERS },
620
- prePhase: state.phase,
621
- },
622
- };
623
- case 'CREATE_ENSEMBLE_NEXT_STEP': {
624
- if (!state.createEnsembleState)
625
- return state;
626
- const answers = { ...state.createEnsembleState.answers, ...action.answer };
627
- const currentIdx = exports.CREATE_ENSEMBLE_STEPS.indexOf(state.createEnsembleState.step);
628
- const nextStep = exports.CREATE_ENSEMBLE_STEPS[currentIdx + 1] ?? state.createEnsembleState.step;
629
- return {
630
- ...state,
631
- createEnsembleState: { ...state.createEnsembleState, step: nextStep, answers },
632
- };
633
- }
634
- case 'CREATE_ENSEMBLE_PREV_STEP': {
635
- if (!state.createEnsembleState)
636
- return state;
637
- const currentIdx = exports.CREATE_ENSEMBLE_STEPS.indexOf(state.createEnsembleState.step);
638
- if (currentIdx <= 0)
639
- return state;
640
- const prevStep = exports.CREATE_ENSEMBLE_STEPS[currentIdx - 1];
641
- return {
642
- ...state,
643
- createEnsembleState: { ...state.createEnsembleState, step: prevStep },
644
- };
645
- }
646
- case 'CREATE_ENSEMBLE_SUBMIT':
647
- if (!state.createEnsembleState)
648
- return state;
649
- return {
650
- ...state,
651
- createEnsembleState: { ...state.createEnsembleState, submitting: true },
652
- };
653
- case 'CREATE_ENSEMBLE_DONE':
654
- if (!state.createEnsembleState)
655
- return state;
656
- if (!action.error && action.ensemble) {
657
- // Success — navigate to new ensemble
658
- return {
659
- ...state,
660
- phase: 'main',
661
- view: 'ensemble',
662
- activeEnsemble: action.ensemble,
663
- createEnsembleState: undefined,
664
- players: [],
665
- messages: [],
666
- sentMessages: [],
667
- conductorHistory: [],
668
- schedules: [],
669
- conversation: null,
670
- selectedPlayerIndex: 0,
671
- };
672
- }
673
- return {
674
- ...state,
675
- createEnsembleState: {
676
- ...state.createEnsembleState,
677
- step: 'done',
678
- submitting: false,
679
- error: action.error,
680
- },
681
- };
682
- case 'EXIT_CREATE_ENSEMBLE': {
683
- const restoreCE = state.createEnsembleState?.prePhase || 'main';
684
- return {
685
- ...state,
686
- phase: restoreCE,
687
- createEnsembleState: undefined,
688
- };
689
- }
690
- case 'OPEN_HOME_MODAL':
691
- return {
692
- ...state,
693
- homeModal: action.modal,
694
- homeModalSubmitting: false,
695
- homeModalError: undefined,
696
- };
697
- case 'CLOSE_HOME_MODAL':
698
- return {
699
- ...state,
700
- homeModal: undefined,
701
- homeModalSubmitting: false,
702
- homeModalError: undefined,
703
- };
704
- case 'SET_HOME_MODAL_STATUS':
705
- return {
706
- ...state,
707
- homeModalSubmitting: action.submitting ?? state.homeModalSubmitting,
708
- homeModalError: action.error,
709
- };
710
- case 'CONFIRM_ENSEMBLE_DESTROY':
711
- return {
712
- ...state,
713
- confirmingEnsembleDestroy: { ensemble: action.ensemble, input: '' },
714
- };
715
- case 'ENSEMBLE_DESTROY_INPUT': {
716
- if (!state.confirmingEnsembleDestroy)
717
- return state;
718
- return {
719
- ...state,
720
- confirmingEnsembleDestroy: {
721
- ...state.confirmingEnsembleDestroy,
722
- input: action.input,
723
- error: undefined,
724
- },
725
- };
726
- }
727
- case 'ENSEMBLE_DESTROY_SUBMIT_BUSY': {
728
- if (!state.confirmingEnsembleDestroy)
729
- return state;
730
- return {
731
- ...state,
732
- confirmingEnsembleDestroy: { ...state.confirmingEnsembleDestroy, submitting: true, error: undefined },
733
- };
734
- }
735
- case 'ENSEMBLE_DESTROY_MISMATCH': {
736
- if (!state.confirmingEnsembleDestroy)
737
- return state;
738
- const { ensemble, input } = state.confirmingEnsembleDestroy;
739
- return {
740
- ...state,
741
- confirmingEnsembleDestroy: {
742
- ...state.confirmingEnsembleDestroy,
743
- submitting: false,
744
- error: `"${input}" \u2260 "${ensemble}" \u2014 type the ensemble name exactly to confirm.`,
745
- },
746
- };
747
- }
748
- case 'CANCEL_ENSEMBLE_DESTROY':
749
- return { ...state, confirmingEnsembleDestroy: undefined };
750
- default:
751
- return state;
752
- }
753
- }