agent-tempo 1.7.0 → 2.0.0-beta.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.
Files changed (171) 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 +3 -10
  9. package/dist/activities/outbox.js +16 -2
  10. package/dist/activities/resolve.d.ts +18 -7
  11. package/dist/activities/resolve.js +58 -46
  12. package/dist/adapters/base.js +3 -0
  13. package/dist/adapters/claude-code/adapter.js +7 -0
  14. package/dist/cli/command-center-command.js +15 -1
  15. package/dist/cli/commands.d.ts +8 -0
  16. package/dist/cli/commands.js +79 -11
  17. package/dist/cli/daemon-command.js +19 -33
  18. package/dist/cli/help-text.js +9 -7
  19. package/dist/cli/home-command.d.ts +26 -0
  20. package/dist/cli/home-command.js +156 -0
  21. package/dist/cli/removed-verbs.d.ts +0 -4
  22. package/dist/cli/removed-verbs.js +23 -12
  23. package/dist/cli/sa-preflight.d.ts +0 -14
  24. package/dist/cli/sa-preflight.js +1 -17
  25. package/dist/cli/startup.d.ts +1 -10
  26. package/dist/cli/startup.js +0 -40
  27. package/dist/cli.js +34 -80
  28. package/dist/client/core.js +13 -15
  29. package/dist/client/index.d.ts +2 -1
  30. package/dist/client/interface.d.ts +5 -14
  31. package/dist/client/with-spawn.d.ts +9 -4
  32. package/dist/client/with-spawn.js +7 -10
  33. package/dist/config.d.ts +14 -13
  34. package/dist/config.js +14 -0
  35. package/dist/constants.d.ts +50 -0
  36. package/dist/constants.js +60 -1
  37. package/dist/daemon.js +81 -0
  38. package/dist/http/auth.d.ts +3 -21
  39. package/dist/http/auth.js +10 -31
  40. package/dist/http/event-types.d.ts +38 -4
  41. package/dist/http/orphans.d.ts +3 -3
  42. package/dist/http/orphans.js +3 -3
  43. package/dist/http/server.d.ts +0 -5
  44. package/dist/http/server.js +79 -29
  45. package/dist/http/snapshot.js +3 -0
  46. package/dist/http/writes.d.ts +1 -1
  47. package/dist/http/writes.js +25 -2
  48. package/dist/observability/nondeterminism-alarm.d.ts +113 -0
  49. package/dist/observability/nondeterminism-alarm.js +162 -0
  50. package/dist/palette/index.d.ts +6 -7
  51. package/dist/palette/index.js +6 -7
  52. package/dist/pi/mission-control/actions.d.ts +67 -1
  53. package/dist/pi/mission-control/actions.js +61 -0
  54. package/dist/pi/mission-control/extension.d.ts +77 -0
  55. package/dist/pi/mission-control/extension.js +171 -0
  56. package/dist/pi/workflow-client.d.ts +14 -16
  57. package/dist/pi/workflow-client.js +18 -44
  58. package/dist/server-tools.js +30 -29
  59. package/dist/server.js +42 -0
  60. package/dist/tools/action-guard.d.ts +23 -0
  61. package/dist/tools/action-guard.js +33 -0
  62. package/dist/tools/coat-check.d.ts +20 -0
  63. package/dist/tools/coat-check.js +129 -0
  64. package/dist/tools/gate.d.ts +13 -0
  65. package/dist/tools/gate.js +88 -0
  66. package/dist/tools/schedule.d.ts +18 -0
  67. package/dist/tools/schedule.js +93 -1
  68. package/dist/tools/stage.d.ts +17 -0
  69. package/dist/tools/stage.js +85 -1
  70. package/dist/tools/state.d.ts +14 -0
  71. package/dist/tools/state.js +95 -0
  72. package/dist/types.d.ts +54 -7
  73. package/dist/upgrade/boot-guard.d.ts +95 -0
  74. package/dist/upgrade/boot-guard.js +88 -0
  75. package/dist/upgrade/from-upgrade.d.ts +95 -0
  76. package/dist/upgrade/from-upgrade.js +330 -0
  77. package/dist/upgrade/phase-engine.js +4 -1
  78. package/dist/utils/duration.d.ts +3 -6
  79. package/dist/utils/duration.js +3 -6
  80. package/dist/utils/format-hosts.d.ts +1 -1
  81. package/dist/utils/format-hosts.js +0 -2
  82. package/dist/utils/grpc-shutdown-guard.js +0 -4
  83. package/dist/utils/hosts.d.ts +4 -10
  84. package/dist/utils/hosts.js +12 -28
  85. package/dist/utils/orphan-guard.d.ts +37 -0
  86. package/dist/utils/orphan-guard.js +26 -0
  87. package/dist/utils/search-attributes.d.ts +28 -18
  88. package/dist/utils/search-attributes.js +48 -18
  89. package/dist/utils/visibility-deadline.d.ts +16 -0
  90. package/dist/utils/visibility-deadline.js +16 -0
  91. package/dist/workflows/maestro-signals.d.ts +0 -13
  92. package/dist/workflows/maestro-signals.js +6 -14
  93. package/dist/workflows/maestro.js +27 -32
  94. package/dist/workflows/scheduler.js +7 -1
  95. package/dist/workflows/session.js +237 -153
  96. package/dist/workflows/signals.d.ts +9 -1
  97. package/dist/workflows/signals.js +8 -6
  98. package/package.json +1 -6
  99. package/workflow-bundle.js +478 -225
  100. package/dist/cli/legacy-migration.d.ts +0 -35
  101. package/dist/cli/legacy-migration.js +0 -341
  102. package/dist/client/ensure-conductor-spawned.d.ts +0 -35
  103. package/dist/client/ensure-conductor-spawned.js +0 -48
  104. package/dist/tui/App.d.ts +0 -85
  105. package/dist/tui/App.js +0 -1791
  106. package/dist/tui/bootstrap-types.d.ts +0 -46
  107. package/dist/tui/bootstrap-types.js +0 -7
  108. package/dist/tui/commands.d.ts +0 -71
  109. package/dist/tui/commands.js +0 -1375
  110. package/dist/tui/components/ChatView.d.ts +0 -35
  111. package/dist/tui/components/ChatView.js +0 -54
  112. package/dist/tui/components/CommandPalette.d.ts +0 -21
  113. package/dist/tui/components/CommandPalette.js +0 -67
  114. package/dist/tui/components/ConversationStream.d.ts +0 -114
  115. package/dist/tui/components/ConversationStream.js +0 -307
  116. package/dist/tui/components/CreateEnsembleWizard.d.ts +0 -19
  117. package/dist/tui/components/CreateEnsembleWizard.js +0 -223
  118. package/dist/tui/components/DestroyConfirmModal.d.ts +0 -17
  119. package/dist/tui/components/DestroyConfirmModal.js +0 -62
  120. package/dist/tui/components/ErrorView.d.ts +0 -31
  121. package/dist/tui/components/ErrorView.js +0 -129
  122. package/dist/tui/components/HomeView.d.ts +0 -54
  123. package/dist/tui/components/HomeView.js +0 -306
  124. package/dist/tui/components/LoadLineupModal.d.ts +0 -18
  125. package/dist/tui/components/LoadLineupModal.js +0 -79
  126. package/dist/tui/components/MainView.d.ts +0 -21
  127. package/dist/tui/components/MainView.js +0 -107
  128. package/dist/tui/components/NewEnsembleModal.d.ts +0 -9
  129. package/dist/tui/components/NewEnsembleModal.js +0 -73
  130. package/dist/tui/components/Picker.d.ts +0 -23
  131. package/dist/tui/components/Picker.js +0 -70
  132. package/dist/tui/components/PlayerDetailView.d.ts +0 -26
  133. package/dist/tui/components/PlayerDetailView.js +0 -118
  134. package/dist/tui/components/PromptArea.d.ts +0 -50
  135. package/dist/tui/components/PromptArea.js +0 -303
  136. package/dist/tui/components/RecruitWizard.d.ts +0 -17
  137. package/dist/tui/components/RecruitWizard.js +0 -221
  138. package/dist/tui/components/RestoreConfirmModal.d.ts +0 -18
  139. package/dist/tui/components/RestoreConfirmModal.js +0 -71
  140. package/dist/tui/components/ScheduleWizard.d.ts +0 -19
  141. package/dist/tui/components/ScheduleWizard.js +0 -259
  142. package/dist/tui/components/Splash.d.ts +0 -23
  143. package/dist/tui/components/Splash.js +0 -221
  144. package/dist/tui/components/StatusBar.d.ts +0 -48
  145. package/dist/tui/components/StatusBar.js +0 -128
  146. package/dist/tui/components/StatusOverlay.d.ts +0 -15
  147. package/dist/tui/components/StatusOverlay.js +0 -76
  148. package/dist/tui/components/TitleBar.d.ts +0 -10
  149. package/dist/tui/components/TitleBar.js +0 -21
  150. package/dist/tui/index.d.ts +0 -15
  151. package/dist/tui/index.js +0 -162
  152. package/dist/tui/ink-context.d.ts +0 -18
  153. package/dist/tui/ink-context.js +0 -59
  154. package/dist/tui/ink-loader.d.ts +0 -26
  155. package/dist/tui/ink-loader.js +0 -42
  156. package/dist/tui/removed-commands.d.ts +0 -9
  157. package/dist/tui/removed-commands.js +0 -22
  158. package/dist/tui/sse-handler.d.ts +0 -52
  159. package/dist/tui/sse-handler.js +0 -157
  160. package/dist/tui/store.d.ts +0 -598
  161. package/dist/tui/store.js +0 -753
  162. package/dist/tui/utils/format.d.ts +0 -56
  163. package/dist/tui/utils/format.js +0 -155
  164. package/dist/tui/utils/fullscreen.d.ts +0 -23
  165. package/dist/tui/utils/fullscreen.js +0 -71
  166. package/dist/tui/utils/history.d.ts +0 -10
  167. package/dist/tui/utils/history.js +0 -85
  168. package/dist/tui/utils/platform.d.ts +0 -45
  169. package/dist/tui/utils/platform.js +0 -258
  170. package/dist/tui/utils/theme.d.ts +0 -21
  171. 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
- }