nfo-cli 0.0.3 → 0.0.5

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 (173) hide show
  1. package/dist/claude-command.js +6 -1
  2. package/dist/claude-command.js.map +1 -1
  3. package/dist/claude-trust.js +46 -0
  4. package/dist/claude-trust.js.map +1 -0
  5. package/dist/cli.js +64 -54
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/restore.js +0 -1
  8. package/dist/commands/restore.js.map +1 -1
  9. package/dist/commands/tui.js +6 -4
  10. package/dist/commands/tui.js.map +1 -1
  11. package/dist/mcp/handlers.js +5 -0
  12. package/dist/mcp/handlers.js.map +1 -1
  13. package/dist/mcp/tool-defs.js +10 -0
  14. package/dist/mcp/tool-defs.js.map +1 -1
  15. package/dist/musicians/dismiss.js +1 -1
  16. package/dist/musicians/dismiss.js.map +1 -1
  17. package/dist/musicians/roles.js +15 -0
  18. package/dist/musicians/roles.js.map +1 -0
  19. package/dist/musicians/spawn.js +53 -18
  20. package/dist/musicians/spawn.js.map +1 -1
  21. package/dist/permission.js +14 -8
  22. package/dist/permission.js.map +1 -1
  23. package/dist/prompts/musician-role.js +2 -1
  24. package/dist/prompts/musician-role.js.map +1 -1
  25. package/dist/prompts/orchestrator-role.js +42 -8
  26. package/dist/prompts/orchestrator-role.js.map +1 -1
  27. package/dist/prompts/tool-discipline.js +10 -0
  28. package/dist/prompts/tool-discipline.js.map +1 -1
  29. package/dist/tui/{App.js → components/App.js} +20 -20
  30. package/dist/tui/components/App.js.map +1 -0
  31. package/dist/tui/components/AppView.js +13 -0
  32. package/dist/tui/components/AppView.js.map +1 -0
  33. package/dist/tui/{Auditorium.js → components/Auditorium.js} +2 -2
  34. package/dist/tui/components/Auditorium.js.map +1 -0
  35. package/dist/tui/components/ConcertHall.js.map +1 -0
  36. package/dist/tui/{Help.js → components/Help.js} +0 -8
  37. package/dist/tui/components/Help.js.map +1 -0
  38. package/dist/tui/components/OrchestratorPane.js.map +1 -0
  39. package/dist/tui/components/SidebarHeader.js +6 -0
  40. package/dist/tui/components/SidebarHeader.js.map +1 -0
  41. package/dist/tui/{StatusBar.js → components/StatusBar.js} +1 -1
  42. package/dist/tui/components/StatusBar.js.map +1 -0
  43. package/package.json +8 -1
  44. package/assets/agent-screen.png +0 -0
  45. package/assets/main-screen.png +0 -0
  46. package/assets/orche-clawd.png +0 -0
  47. package/dist/tui/App.js.map +0 -1
  48. package/dist/tui/AppView.js +0 -13
  49. package/dist/tui/AppView.js.map +0 -1
  50. package/dist/tui/Auditorium.js.map +0 -1
  51. package/dist/tui/ConcertHall.js.map +0 -1
  52. package/dist/tui/Help.js.map +0 -1
  53. package/dist/tui/OrchestratorPane.js.map +0 -1
  54. package/dist/tui/SidebarHeader.js +0 -6
  55. package/dist/tui/SidebarHeader.js.map +0 -1
  56. package/dist/tui/StatusBar.js.map +0 -1
  57. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  58. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  59. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  60. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  61. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  62. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  63. package/src/claude-command.ts +0 -35
  64. package/src/claude-detect.ts +0 -42
  65. package/src/cli.ts +0 -164
  66. package/src/commands/attach.ts +0 -24
  67. package/src/commands/dashboard-window.ts +0 -33
  68. package/src/commands/kill.ts +0 -50
  69. package/src/commands/launch.ts +0 -134
  70. package/src/commands/list.ts +0 -43
  71. package/src/commands/mcp-server.ts +0 -18
  72. package/src/commands/notes.ts +0 -18
  73. package/src/commands/restore.ts +0 -153
  74. package/src/commands/tui.tsx +0 -16
  75. package/src/config.ts +0 -44
  76. package/src/dashboard.ts +0 -1
  77. package/src/mcp/config.ts +0 -39
  78. package/src/mcp/handlers.ts +0 -141
  79. package/src/mcp/server.ts +0 -50
  80. package/src/mcp/tool-defs.ts +0 -151
  81. package/src/musicians/dismiss.ts +0 -60
  82. package/src/musicians/ids.ts +0 -21
  83. package/src/musicians/lookup.ts +0 -13
  84. package/src/musicians/message-log.ts +0 -152
  85. package/src/musicians/message.ts +0 -99
  86. package/src/musicians/query.ts +0 -19
  87. package/src/musicians/spawn.ts +0 -139
  88. package/src/notes.ts +0 -39
  89. package/src/notify.ts +0 -62
  90. package/src/orchestrator/report-back.ts +0 -33
  91. package/src/permission.ts +0 -30
  92. package/src/project-key.ts +0 -12
  93. package/src/prompts/musician-role.ts +0 -22
  94. package/src/prompts/orchestrator-role.ts +0 -60
  95. package/src/prompts/tool-discipline.ts +0 -35
  96. package/src/repo.ts +0 -14
  97. package/src/shell-quote.ts +0 -7
  98. package/src/state-updaters.ts +0 -132
  99. package/src/state.ts +0 -49
  100. package/src/state.types.ts +0 -67
  101. package/src/tmux.ts +0 -226
  102. package/src/tui/App.tsx +0 -532
  103. package/src/tui/AppView.tsx +0 -96
  104. package/src/tui/Auditorium.tsx +0 -56
  105. package/src/tui/ConcertHall.tsx +0 -31
  106. package/src/tui/Help.tsx +0 -72
  107. package/src/tui/OrchestratorPane.tsx +0 -98
  108. package/src/tui/SidebarHeader.tsx +0 -32
  109. package/src/tui/StatusBar.tsx +0 -44
  110. package/src/tui/activity-line.ts +0 -16
  111. package/src/tui/detect-permission.ts +0 -93
  112. package/src/tui/embedded-session-lifecycle.ts +0 -44
  113. package/src/tui/embedded-terminal.ts +0 -325
  114. package/src/tui/format-time.ts +0 -25
  115. package/src/tui/keymap.ts +0 -104
  116. package/src/tui/poll-activity.ts +0 -25
  117. package/src/tui/poll-idle.ts +0 -149
  118. package/src/tui/poll-permission.ts +0 -50
  119. package/src/tui/status-icon.ts +0 -35
  120. package/src/tui/terminal-input.ts +0 -136
  121. package/src/tui/watch-state.ts +0 -43
  122. package/src/worktree.ts +0 -41
  123. package/tests/claude-command.test.ts +0 -30
  124. package/tests/claude-detect.test.ts +0 -14
  125. package/tests/commands/attach.test.ts +0 -60
  126. package/tests/commands/kill.test.ts +0 -66
  127. package/tests/commands/launch.test.ts +0 -75
  128. package/tests/commands/list.test.ts +0 -47
  129. package/tests/commands/notes.test.ts +0 -53
  130. package/tests/commands/restore.test.ts +0 -126
  131. package/tests/helpers/tmp-config.ts +0 -16
  132. package/tests/helpers/tmp-repo.ts +0 -29
  133. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  134. package/tests/mcp/handlers.test.ts +0 -163
  135. package/tests/mcp/tool-defs.test.ts +0 -35
  136. package/tests/musicians/dismiss.test.ts +0 -102
  137. package/tests/musicians/message.test.ts +0 -159
  138. package/tests/musicians/query.test.ts +0 -65
  139. package/tests/musicians/spawn.test.ts +0 -125
  140. package/tests/notes.test.ts +0 -56
  141. package/tests/notify.test.ts +0 -80
  142. package/tests/orchestrator/report-back.test.ts +0 -18
  143. package/tests/permission.test.ts +0 -29
  144. package/tests/project-key.test.ts +0 -33
  145. package/tests/prompts/tool-discipline.test.ts +0 -25
  146. package/tests/repo.test.ts +0 -38
  147. package/tests/state-updaters.test.ts +0 -126
  148. package/tests/state.test.ts +0 -85
  149. package/tests/tmux.test.ts +0 -126
  150. package/tests/tui/AppView.test.tsx +0 -92
  151. package/tests/tui/Auditorium.test.tsx +0 -67
  152. package/tests/tui/ConcertHall.test.tsx +0 -22
  153. package/tests/tui/Help.test.tsx +0 -38
  154. package/tests/tui/OrchestratorPane.test.ts +0 -30
  155. package/tests/tui/SidebarHeader.test.tsx +0 -20
  156. package/tests/tui/StatusBar.test.tsx +0 -51
  157. package/tests/tui/activity-line.test.ts +0 -21
  158. package/tests/tui/detect-permission.test.ts +0 -92
  159. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  160. package/tests/tui/embedded-terminal.test.ts +0 -80
  161. package/tests/tui/format-time.test.ts +0 -25
  162. package/tests/tui/keymap.test.ts +0 -93
  163. package/tests/tui/poll-activity.test.ts +0 -81
  164. package/tests/tui/poll-idle.test.ts +0 -159
  165. package/tests/tui/poll-permission.test.ts +0 -222
  166. package/tests/tui/status-icon.test.ts +0 -27
  167. package/tests/tui/terminal-input.test.ts +0 -113
  168. package/tests/tui/watch-state.test.ts +0 -54
  169. package/tests/worktree.test.ts +0 -73
  170. package/tsconfig.json +0 -19
  171. package/vitest.config.ts +0 -12
  172. /package/dist/tui/{ConcertHall.js → components/ConcertHall.js} +0 -0
  173. /package/dist/tui/{OrchestratorPane.js → components/OrchestratorPane.js} +0 -0
@@ -1,325 +0,0 @@
1
- import xtermHeadless from '@xterm/headless';
2
- import type { IBufferCell, IBufferLine, Terminal as XTermTerminal } from '@xterm/headless';
3
- import { spawn, type IPty } from 'node-pty';
4
-
5
- const { Terminal } = xtermHeadless;
6
-
7
- export interface EmbeddedTerminalSpan {
8
- text: string;
9
- color?: string;
10
- backgroundColor?: string;
11
- dimColor?: boolean;
12
- bold?: boolean;
13
- italic?: boolean;
14
- underline?: boolean;
15
- strikethrough?: boolean;
16
- inverse?: boolean;
17
- cursor?: boolean;
18
- }
19
-
20
- export interface EmbeddedTerminalLine {
21
- spans: EmbeddedTerminalSpan[];
22
- }
23
-
24
- export interface EmbeddedTerminalSnapshot {
25
- title: string;
26
- lines: EmbeddedTerminalLine[];
27
- connected: boolean;
28
- }
29
-
30
- export interface EmbeddedTerminalOptions {
31
- sessionName: string;
32
- cwd: string;
33
- cols: number;
34
- rows: number;
35
- }
36
-
37
- type Listener = (snapshot: EmbeddedTerminalSnapshot) => void;
38
- type Disposable = { dispose(): void };
39
-
40
- function toRgbColor(value: number): string {
41
- const red = (value >> 16) & 0xff;
42
- const green = (value >> 8) & 0xff;
43
- const blue = value & 0xff;
44
- return `rgb(${red}, ${green}, ${blue})`;
45
- }
46
-
47
- function toInkColor(cell: IBufferCell, layer: 'foreground' | 'background'): string | undefined {
48
- if (layer === 'foreground') {
49
- if (cell.isFgRGB()) {
50
- return toRgbColor(cell.getFgColor());
51
- }
52
- if (cell.isFgPalette()) {
53
- return `ansi256(${cell.getFgColor()})`;
54
- }
55
- return undefined;
56
- }
57
- if (cell.isBgRGB()) {
58
- return toRgbColor(cell.getBgColor());
59
- }
60
- if (cell.isBgPalette()) {
61
- return `ansi256(${cell.getBgColor()})`;
62
- }
63
- return undefined;
64
- }
65
-
66
- function getSpanStyle(cell: IBufferCell): Omit<EmbeddedTerminalSpan, 'text'> {
67
- const style: Omit<EmbeddedTerminalSpan, 'text'> = {};
68
- const color = toInkColor(cell, 'foreground');
69
- const backgroundColor = toInkColor(cell, 'background');
70
- if (color !== undefined) {
71
- style.color = color;
72
- }
73
- if (backgroundColor !== undefined) {
74
- style.backgroundColor = backgroundColor;
75
- }
76
- if (cell.isDim() === 1) {
77
- style.dimColor = true;
78
- }
79
- if (cell.isBold() === 1) {
80
- style.bold = true;
81
- }
82
- if (cell.isItalic() === 1) {
83
- style.italic = true;
84
- }
85
- if (cell.isUnderline() === 1) {
86
- style.underline = true;
87
- }
88
- if (cell.isStrikethrough() === 1) {
89
- style.strikethrough = true;
90
- }
91
- if (cell.isInverse() === 1) {
92
- style.inverse = true;
93
- }
94
- return style;
95
- }
96
-
97
- function sameStyle(left: Omit<EmbeddedTerminalSpan, 'text'>, right: Omit<EmbeddedTerminalSpan, 'text'>): boolean {
98
- return left.color === right.color
99
- && left.backgroundColor === right.backgroundColor
100
- && left.dimColor === right.dimColor
101
- && left.bold === right.bold
102
- && left.italic === right.italic
103
- && left.underline === right.underline
104
- && left.strikethrough === right.strikethrough
105
- && left.inverse === right.inverse
106
- && left.cursor === right.cursor;
107
- }
108
-
109
- function appendSpan(spans: EmbeddedTerminalSpan[], next: EmbeddedTerminalSpan): void {
110
- if (next.text.length === 0) {
111
- return;
112
- }
113
- const previous = spans.at(-1);
114
- if (previous && sameStyle(previous, next)) {
115
- previous.text += next.text;
116
- return;
117
- }
118
- spans.push({ ...next });
119
- }
120
-
121
- function hasStyle(span: EmbeddedTerminalSpan): boolean {
122
- return span.color !== undefined
123
- || span.backgroundColor !== undefined
124
- || span.dimColor === true
125
- || span.bold === true
126
- || span.italic === true
127
- || span.underline === true
128
- || span.strikethrough === true
129
- || span.inverse === true
130
- || span.cursor === true;
131
- }
132
-
133
- function trimTrailingPlainWhitespace(spans: EmbeddedTerminalSpan[]): EmbeddedTerminalSpan[] {
134
- const trimmed = spans.map((span) => {
135
- return { ...span };
136
- });
137
-
138
- while (trimmed.length > 0) {
139
- const last = trimmed.at(-1);
140
- if (!last || hasStyle(last)) {
141
- break;
142
- }
143
- const text = last.text.replace(/\s+$/u, '');
144
- if (text.length === 0) {
145
- trimmed.pop();
146
- continue;
147
- }
148
- if (text !== last.text) {
149
- last.text = text;
150
- }
151
- break;
152
- }
153
-
154
- return trimmed;
155
- }
156
-
157
- function buildLineSnapshot(
158
- line: IBufferLine | undefined,
159
- cols: number,
160
- blankCell: IBufferCell,
161
- cursorColumn: number | null,
162
- ): EmbeddedTerminalLine {
163
- if (!line) {
164
- return cursorColumn === cols
165
- ? { spans: [{ text: ' ', cursor: true }] }
166
- : { spans: [] };
167
- }
168
-
169
- const spans: EmbeddedTerminalSpan[] = [];
170
- for (let col = 0; col < cols; col += 1) {
171
- const cell = line.getCell(col, blankCell);
172
- if (!cell) {
173
- appendSpan(spans, { text: ' ' });
174
- continue;
175
- }
176
- if (cell.getWidth() === 0) {
177
- continue;
178
- }
179
-
180
- const chars = cell.isInvisible() === 1
181
- ? ' '.repeat(Math.max(1, cell.getWidth()))
182
- : (cell.getChars() || ' ');
183
-
184
- appendSpan(spans, {
185
- text: chars,
186
- ...getSpanStyle(cell),
187
- ...(cursorColumn === col ? { cursor: true } : {}),
188
- });
189
- }
190
-
191
- if (cursorColumn === cols) {
192
- appendSpan(spans, { text: ' ', cursor: true });
193
- }
194
-
195
- return {
196
- spans: trimTrailingPlainWhitespace(spans),
197
- };
198
- }
199
-
200
- export function buildSnapshot(terminal: XTermTerminal, title: string, connected: boolean): EmbeddedTerminalSnapshot {
201
- const buffer = terminal.buffer.active;
202
- const blankCell = buffer.getNullCell();
203
- const cursorRow = (buffer.baseY + buffer.cursorY) - buffer.viewportY;
204
- const visibleCursorRow = cursorRow >= 0 && cursorRow < terminal.rows ? cursorRow : null;
205
- const visibleCursorColumn = visibleCursorRow !== null ? Math.min(buffer.cursorX, terminal.cols) : null;
206
- const lines: EmbeddedTerminalLine[] = [];
207
- for (let row = 0; row < terminal.rows; row += 1) {
208
- const line = buffer.getLine(buffer.viewportY + row);
209
- lines.push(buildLineSnapshot(
210
- line,
211
- terminal.cols,
212
- blankCell,
213
- visibleCursorRow === row ? visibleCursorColumn : null,
214
- ));
215
- }
216
-
217
- return {
218
- title,
219
- lines,
220
- connected,
221
- };
222
- }
223
-
224
- export class EmbeddedTerminal {
225
- private readonly terminal: XTermTerminal;
226
- private readonly pty: IPty;
227
- private readonly listeners = new Set<Listener>();
228
- private readonly disposables: Disposable[] = [];
229
- private title = 'Claude';
230
- private connected = true;
231
-
232
- public constructor(options: EmbeddedTerminalOptions) {
233
- this.terminal = new Terminal({
234
- allowProposedApi: true,
235
- cols: options.cols,
236
- rows: options.rows,
237
- scrollback: 5000,
238
- });
239
- this.pty = spawn('tmux', ['attach-session', '-t', options.sessionName], {
240
- name: 'xterm-256color',
241
- cols: options.cols,
242
- rows: options.rows,
243
- cwd: options.cwd,
244
- env: {
245
- ...process.env,
246
- TERM: 'xterm-256color',
247
- },
248
- });
249
-
250
- this.disposables.push(
251
- this.pty.onData((data) => {
252
- this.terminal.write(data, () => {
253
- this.publish();
254
- });
255
- }),
256
- this.pty.onExit(() => {
257
- this.connected = false;
258
- this.publish();
259
- }),
260
- this.terminal.onTitleChange((title) => {
261
- this.title = title.length > 0 ? title : 'Claude';
262
- this.publish();
263
- }),
264
- );
265
- }
266
-
267
- public onChange(listener: Listener): () => void {
268
- this.listeners.add(listener);
269
- listener(this.snapshot());
270
- return () => {
271
- this.listeners.delete(listener);
272
- };
273
- }
274
-
275
- public snapshot(): EmbeddedTerminalSnapshot {
276
- return buildSnapshot(this.terminal, this.title, this.connected);
277
- }
278
-
279
- public resize(cols: number, rows: number): void {
280
- this.pty.resize(cols, rows);
281
- this.terminal.resize(cols, rows);
282
- this.publish();
283
- }
284
-
285
- public scrollPages(pageCount: number): void {
286
- this.terminal.scrollPages(pageCount);
287
- this.publish();
288
- }
289
-
290
- public scrollLines(lineCount: number): void {
291
- this.terminal.scrollLines(lineCount);
292
- this.publish();
293
- }
294
-
295
- public scrollToTop(): void {
296
- this.terminal.scrollToTop();
297
- this.publish();
298
- }
299
-
300
- public scrollToBottom(): void {
301
- this.terminal.scrollToBottom();
302
- this.publish();
303
- }
304
-
305
- public write(data: string): void {
306
- this.pty.write(data);
307
- }
308
-
309
- public dispose(): void {
310
- for (const disposable of this.disposables) {
311
- disposable.dispose();
312
- }
313
- this.disposables.length = 0;
314
- this.listeners.clear();
315
- this.terminal.dispose();
316
- this.pty.kill();
317
- }
318
-
319
- private publish(): void {
320
- const snapshot = this.snapshot();
321
- for (const listener of this.listeners) {
322
- listener(snapshot);
323
- }
324
- }
325
- }
@@ -1,25 +0,0 @@
1
- export function formatRelativeTime(iso: string, nowIso: string): string {
2
- const then = Date.parse(iso);
3
- const now = Date.parse(nowIso);
4
- if (Number.isNaN(then) || Number.isNaN(now)) {
5
- return '?';
6
- }
7
- const deltaMs = now - then;
8
- if (deltaMs < 1000) {
9
- return '<1s';
10
- }
11
- const seconds = Math.floor(deltaMs / 1000);
12
- if (seconds < 60) {
13
- return `${seconds}s`;
14
- }
15
- const minutes = Math.floor(seconds / 60);
16
- if (minutes < 60) {
17
- return `${minutes}m`;
18
- }
19
- const hours = Math.floor(minutes / 60);
20
- if (hours < 24) {
21
- return `${hours}h`;
22
- }
23
- const days = Math.floor(hours / 24);
24
- return `${days}d`;
25
- }
package/src/tui/keymap.ts DELETED
@@ -1,104 +0,0 @@
1
- export interface UiState {
2
- selectedIndex: number;
3
- musicianCount: number;
4
- pendingDismissIndex: number | null;
5
- }
6
-
7
- export interface KeyInput {
8
- input: string;
9
- downArrow: boolean;
10
- upArrow: boolean;
11
- tab: boolean;
12
- shiftTab: boolean;
13
- return: boolean;
14
- escape: boolean;
15
- }
16
-
17
- export type KeyAction =
18
- | { kind: 'open-target'; selectedIndex: number }
19
- | { kind: 'request-dismiss-musician'; index: number }
20
- | { kind: 'dismiss-musician'; index: number }
21
- | { kind: 'next-orchestra' }
22
- | { kind: 'prev-orchestra' }
23
- | { kind: 'open-notes' }
24
- | { kind: 'detach-session' }
25
- | { kind: 'jump-to-pending' }
26
- | { kind: 'toggle-help' };
27
-
28
- export interface ReduceResult {
29
- ui: UiState;
30
- action?: KeyAction;
31
- }
32
-
33
- function clamp(value: number, min: number, max: number): number {
34
- if (value < min) {
35
- return min;
36
- }
37
- if (value > max) {
38
- return max;
39
- }
40
- return value;
41
- }
42
-
43
- export function reduceKey(ui: UiState, key: KeyInput): ReduceResult {
44
- const maxIndex = Math.max(0, ui.musicianCount);
45
- const selectedMusicianIndex = ui.selectedIndex - 1;
46
-
47
- if (key.input === 'd') {
48
- if (selectedMusicianIndex < 0 || selectedMusicianIndex >= ui.musicianCount) {
49
- return { ui };
50
- }
51
- if (ui.pendingDismissIndex === selectedMusicianIndex) {
52
- return {
53
- ui: { ...ui, pendingDismissIndex: null },
54
- action: { kind: 'dismiss-musician', index: selectedMusicianIndex },
55
- };
56
- }
57
- return {
58
- ui: { ...ui, pendingDismissIndex: selectedMusicianIndex },
59
- action: { kind: 'request-dismiss-musician', index: selectedMusicianIndex },
60
- };
61
- }
62
-
63
- if (ui.pendingDismissIndex !== null) {
64
- if (key.input === 'y' || key.return) {
65
- return {
66
- ui: { ...ui, pendingDismissIndex: null },
67
- action: { kind: 'dismiss-musician', index: ui.pendingDismissIndex },
68
- };
69
- }
70
- if (key.input === 'n' || key.escape) {
71
- return { ui: { ...ui, pendingDismissIndex: null } };
72
- }
73
- ui = { ...ui, pendingDismissIndex: null };
74
- }
75
-
76
- if (key.downArrow || key.input === 'j') {
77
- return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex + 1, 0, maxIndex) } };
78
- }
79
- if (key.upArrow || key.input === 'k') {
80
- return { ui: { ...ui, selectedIndex: clamp(ui.selectedIndex - 1, 0, maxIndex) } };
81
- }
82
- if (key.tab) {
83
- return { ui, action: { kind: 'next-orchestra' } };
84
- }
85
- if (key.shiftTab) {
86
- return { ui, action: { kind: 'prev-orchestra' } };
87
- }
88
- if (key.return) {
89
- return { ui, action: { kind: 'open-target', selectedIndex: ui.selectedIndex } };
90
- }
91
- if (key.input === 'n') {
92
- return { ui, action: { kind: 'open-notes' } };
93
- }
94
- if (key.input === 'q') {
95
- return { ui, action: { kind: 'detach-session' } };
96
- }
97
- if (key.input === 'p') {
98
- return { ui, action: { kind: 'jump-to-pending' } };
99
- }
100
- if (key.input === '?') {
101
- return { ui, action: { kind: 'toggle-help' } };
102
- }
103
- return { ui };
104
- }
@@ -1,25 +0,0 @@
1
- import { capturePane, sessionName } from '../tmux.js';
2
- import { extractActivityLine } from './activity-line.js';
3
- import type { OrchestraState } from '../state.types.js';
4
-
5
- /**
6
- * For each non-stopped musician, capture the last lines of its tmux window
7
- * and reduce to a single activity hint. Failures (e.g. a window that no longer
8
- * exists) are swallowed per-musician so one dead pane never breaks the poll.
9
- */
10
- export async function pollActivity(state: OrchestraState): Promise<Record<string, string>> {
11
- const session = sessionName(state.orchestra_id);
12
- const result: Record<string, string> = {};
13
- for (const musician of state.musicians) {
14
- if (musician.status === 'stopped') {
15
- continue;
16
- }
17
- try {
18
- const pane = await capturePane(`${session}:${musician.tmux_window_id}`, 10);
19
- result[musician.id] = extractActivityLine(pane);
20
- } catch {
21
- result[musician.id] = '';
22
- }
23
- }
24
- return result;
25
- }
@@ -1,149 +0,0 @@
1
- import { readState } from '../state.js';
2
- import type { Musician, OrchestraState } from '../state.types.js';
3
- import { setMusicianStatus } from '../state-updaters.js';
4
- import { captureVisiblePane, sessionName } from '../tmux.js';
5
- import { drainQueuedMusicianMessages } from '../musicians/message.js';
6
-
7
- export const IDLE_THRESHOLD_MS = 30_000;
8
-
9
- export interface MusicianIdleSnapshot {
10
- signature: string;
11
- unchangedSince: string;
12
- }
13
-
14
- export type MusicianIdleTracker = Record<string, MusicianIdleSnapshot>;
15
-
16
- export interface IdlePollResult {
17
- nextTracker: MusicianIdleTracker;
18
- idleMusicianIds: string[];
19
- }
20
-
21
- export function hasClaudeInputPrompt(paneText: string): boolean {
22
- const lines = paneText
23
- .replace(/\u00a0/g, ' ')
24
- .replace(/\r/g, '')
25
- .split('\n')
26
- .map((line) => { return line.trim(); });
27
- let promptIndex = -1;
28
- for (let index = lines.length - 1; index >= 0; index -= 1) {
29
- if (/^(?:❯|>|›)$/.test(lines[index])) {
30
- promptIndex = index;
31
- break;
32
- }
33
- }
34
- if (promptIndex === -1) {
35
- return false;
36
- }
37
- const trailingNonEmpty = lines.slice(promptIndex + 1).filter((line) => { return line.length > 0; });
38
- return trailingNonEmpty.length <= 2;
39
- }
40
-
41
- export function normalisePaneSignature(paneText: string): string {
42
- return paneText
43
- .replace(/\u00a0/g, ' ')
44
- .replace(/\r/g, '')
45
- .trimEnd();
46
- }
47
-
48
- function millisSince(earlier: string, later: string): number {
49
- const earlierMs = Date.parse(earlier);
50
- const laterMs = Date.parse(later);
51
- if (Number.isNaN(earlierMs) || Number.isNaN(laterMs)) {
52
- return 0;
53
- }
54
- return laterMs - earlierMs;
55
- }
56
-
57
- function workingMusicians(state: OrchestraState): Musician[] {
58
- return state.musicians.filter((musician) => { return musician.status === 'working'; });
59
- }
60
-
61
- export function detectIdleMusicians(
62
- state: OrchestraState,
63
- panes: Record<string, string>,
64
- tracker: MusicianIdleTracker,
65
- now = new Date().toISOString(),
66
- ): IdlePollResult {
67
- const nextTracker: MusicianIdleTracker = {};
68
- const idleMusicianIds: string[] = [];
69
-
70
- for (const musician of workingMusicians(state)) {
71
- const paneText = panes[musician.id];
72
- if (!paneText) {
73
- continue;
74
- }
75
-
76
- const signature = normalisePaneSignature(paneText);
77
- const promptVisible = hasClaudeInputPrompt(signature);
78
- const previous = tracker[musician.id];
79
- const unchangedSince = previous?.signature === signature
80
- ? previous.unchangedSince
81
- : (promptVisible ? musician.last_activity : now);
82
-
83
- nextTracker[musician.id] = {
84
- signature,
85
- unchangedSince,
86
- };
87
-
88
- if (!promptVisible) {
89
- continue;
90
- }
91
-
92
- if (millisSince(unchangedSince, now) >= IDLE_THRESHOLD_MS) {
93
- idleMusicianIds.push(musician.id);
94
- }
95
- }
96
-
97
- return { nextTracker, idleMusicianIds };
98
- }
99
-
100
- export async function pollIdleMusicians(
101
- state: OrchestraState,
102
- tracker: MusicianIdleTracker,
103
- now = new Date().toISOString(),
104
- ): Promise<IdlePollResult> {
105
- const session = sessionName(state.orchestra_id);
106
- const panes = Object.fromEntries(await Promise.all(workingMusicians(state).map(async (musician) => {
107
- try {
108
- const pane = await captureVisiblePane(`${session}:${musician.tmux_window_id}`);
109
- return [musician.id, pane] as const;
110
- } catch {
111
- return [musician.id, ''] as const;
112
- }
113
- })));
114
- return detectIdleMusicians(state, panes, tracker, now);
115
- }
116
-
117
- export async function syncMusicianIdleState(
118
- orchestraId: string,
119
- tracker: MusicianIdleTracker,
120
- now = new Date().toISOString(),
121
- ): Promise<MusicianIdleTracker> {
122
- const state = await readState(orchestraId);
123
- if (!state) {
124
- return {};
125
- }
126
-
127
- const { nextTracker, idleMusicianIds } = await pollIdleMusicians(state, tracker, now);
128
- for (const musicianId of idleMusicianIds) {
129
- try {
130
- const fresh = await readState(orchestraId);
131
- const musician = fresh?.musicians.find((candidate) => { return candidate.id === musicianId; });
132
- if (!musician || musician.status !== 'working') {
133
- continue;
134
- }
135
-
136
- const deliveredMessages = await drainQueuedMusicianMessages(orchestraId, musicianId);
137
- if (deliveredMessages > 0) {
138
- await setMusicianStatus(orchestraId, musicianId, 'working', null);
139
- continue;
140
- }
141
-
142
- await setMusicianStatus(orchestraId, musicianId, 'idle', null);
143
- } catch {
144
- // Musician state can race with dismissals/restores; keep polling the rest.
145
- }
146
- }
147
-
148
- return nextTracker;
149
- }
@@ -1,50 +0,0 @@
1
- import { capturePane, sessionName } from '../tmux.js';
2
- import { detectPermissionPrompt } from './detect-permission.js';
3
- import type { OrchestraState } from '../state.types.js';
4
-
5
- export interface PermissionTransition {
6
- musicianId: string;
7
- newStatus: 'awaiting_permission' | 'working';
8
- pendingPermission: string | null;
9
- }
10
-
11
- /**
12
- * For each non-stopped musician, capture the last lines of its tmux window,
13
- * run the permission-prompt detector, and emit a transition only when the
14
- * detected state differs from the musician's current status. Failures (e.g.
15
- * a window that no longer exists) are swallowed per-musician so one dead pane
16
- * never breaks the poll.
17
- */
18
- export async function pollPermissions(state: OrchestraState): Promise<PermissionTransition[]> {
19
- const session = sessionName(state.orchestra_id);
20
- const transitions: PermissionTransition[] = [];
21
-
22
- for (const musician of state.musicians) {
23
- if (musician.status === 'stopped') {
24
- continue;
25
- }
26
-
27
- try {
28
- const paneText = await capturePane(`${session}:${musician.tmux_window_id}`, 20);
29
- const detected = detectPermissionPrompt(paneText);
30
-
31
- if (detected.pending && musician.status !== 'awaiting_permission') {
32
- transitions.push({
33
- musicianId: musician.id,
34
- newStatus: 'awaiting_permission',
35
- pendingPermission: detected.tool ?? 'tool',
36
- });
37
- } else if (!detected.pending && musician.status === 'awaiting_permission') {
38
- transitions.push({
39
- musicianId: musician.id,
40
- newStatus: 'working',
41
- pendingPermission: null,
42
- });
43
- }
44
- } catch {
45
- /* swallow — a dead window must not break the poll */
46
- }
47
- }
48
-
49
- return transitions;
50
- }
@@ -1,35 +0,0 @@
1
- import type { MusicianStatus } from '../state.types.js';
2
-
3
- export function statusIcon(status: MusicianStatus): string {
4
- switch (status) {
5
- case 'working': {
6
- return '●';
7
- }
8
- case 'idle': {
9
- return '◐';
10
- }
11
- case 'awaiting_permission': {
12
- return '⚠';
13
- }
14
- case 'stopped': {
15
- return '○';
16
- }
17
- }
18
- }
19
-
20
- export function statusColor(status: MusicianStatus): string {
21
- switch (status) {
22
- case 'working': {
23
- return 'green';
24
- }
25
- case 'idle': {
26
- return 'yellow';
27
- }
28
- case 'awaiting_permission': {
29
- return 'red';
30
- }
31
- case 'stopped': {
32
- return 'gray';
33
- }
34
- }
35
- }