nfo-cli 0.0.4-improve-prompting → 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 (159) 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 +0 -0
  6. package/dist/mcp/handlers.js +5 -0
  7. package/dist/mcp/handlers.js.map +1 -1
  8. package/dist/mcp/tool-defs.js +10 -0
  9. package/dist/mcp/tool-defs.js.map +1 -1
  10. package/dist/musicians/dismiss.js +1 -1
  11. package/dist/musicians/dismiss.js.map +1 -1
  12. package/dist/musicians/roles.js +15 -0
  13. package/dist/musicians/roles.js.map +1 -0
  14. package/dist/musicians/spawn.js +53 -18
  15. package/dist/musicians/spawn.js.map +1 -1
  16. package/dist/permission.js +6 -0
  17. package/dist/permission.js.map +1 -1
  18. package/dist/prompts/musician-role.js +2 -1
  19. package/dist/prompts/musician-role.js.map +1 -1
  20. package/dist/prompts/orchestrator-role.js +18 -6
  21. package/dist/prompts/orchestrator-role.js.map +1 -1
  22. package/dist/prompts/tool-discipline.js +7 -3
  23. package/dist/prompts/tool-discipline.js.map +1 -1
  24. package/package.json +8 -1
  25. package/assets/agent-screen.png +0 -0
  26. package/assets/main-screen.png +0 -0
  27. package/assets/orche-clawd.png +0 -0
  28. package/dist/tui/App.js +0 -428
  29. package/dist/tui/App.js.map +0 -1
  30. package/dist/tui/AppView.js +0 -13
  31. package/dist/tui/AppView.js.map +0 -1
  32. package/dist/tui/Auditorium.js +0 -17
  33. package/dist/tui/Auditorium.js.map +0 -1
  34. package/dist/tui/ConcertHall.js +0 -11
  35. package/dist/tui/ConcertHall.js.map +0 -1
  36. package/dist/tui/Help.js +0 -49
  37. package/dist/tui/Help.js.map +0 -1
  38. package/dist/tui/OrchestratorPane.js +0 -34
  39. package/dist/tui/OrchestratorPane.js.map +0 -1
  40. package/dist/tui/SidebarHeader.js +0 -6
  41. package/dist/tui/SidebarHeader.js.map +0 -1
  42. package/dist/tui/StatusBar.js +0 -6
  43. package/dist/tui/StatusBar.js.map +0 -1
  44. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  45. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  46. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  47. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  48. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  49. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  50. package/plan-explorer-musician-hardening.md +0 -56
  51. package/src/claude-command.ts +0 -35
  52. package/src/claude-detect.ts +0 -42
  53. package/src/cli.ts +0 -197
  54. package/src/commands/attach.ts +0 -24
  55. package/src/commands/dashboard-window.ts +0 -33
  56. package/src/commands/kill.ts +0 -50
  57. package/src/commands/launch.ts +0 -134
  58. package/src/commands/list.ts +0 -43
  59. package/src/commands/mcp-server.ts +0 -18
  60. package/src/commands/notes.ts +0 -18
  61. package/src/commands/restore.ts +0 -153
  62. package/src/commands/tui.tsx +0 -22
  63. package/src/config.ts +0 -44
  64. package/src/dashboard.ts +0 -1
  65. package/src/mcp/config.ts +0 -39
  66. package/src/mcp/handlers.ts +0 -141
  67. package/src/mcp/server.ts +0 -50
  68. package/src/mcp/tool-defs.ts +0 -151
  69. package/src/musicians/dismiss.ts +0 -60
  70. package/src/musicians/ids.ts +0 -21
  71. package/src/musicians/lookup.ts +0 -13
  72. package/src/musicians/message-log.ts +0 -152
  73. package/src/musicians/message.ts +0 -99
  74. package/src/musicians/query.ts +0 -19
  75. package/src/musicians/spawn.ts +0 -139
  76. package/src/notes.ts +0 -39
  77. package/src/notify.ts +0 -62
  78. package/src/orchestrator/report-back.ts +0 -33
  79. package/src/permission.ts +0 -30
  80. package/src/project-key.ts +0 -12
  81. package/src/prompts/musician-role.ts +0 -22
  82. package/src/prompts/orchestrator-role.ts +0 -84
  83. package/src/prompts/tool-discipline.ts +0 -41
  84. package/src/repo.ts +0 -14
  85. package/src/shell-quote.ts +0 -7
  86. package/src/state-updaters.ts +0 -132
  87. package/src/state.ts +0 -49
  88. package/src/state.types.ts +0 -67
  89. package/src/tmux.ts +0 -226
  90. package/src/tui/activity-line.ts +0 -16
  91. package/src/tui/components/App.tsx +0 -534
  92. package/src/tui/components/AppView.tsx +0 -98
  93. package/src/tui/components/Auditorium.tsx +0 -56
  94. package/src/tui/components/ConcertHall.tsx +0 -31
  95. package/src/tui/components/Help.tsx +0 -63
  96. package/src/tui/components/OrchestratorPane.tsx +0 -98
  97. package/src/tui/components/SidebarHeader.tsx +0 -34
  98. package/src/tui/components/StatusBar.tsx +0 -42
  99. package/src/tui/detect-permission.ts +0 -93
  100. package/src/tui/embedded-session-lifecycle.ts +0 -44
  101. package/src/tui/embedded-terminal.ts +0 -325
  102. package/src/tui/format-time.ts +0 -25
  103. package/src/tui/keymap.ts +0 -104
  104. package/src/tui/poll-activity.ts +0 -25
  105. package/src/tui/poll-idle.ts +0 -149
  106. package/src/tui/poll-permission.ts +0 -50
  107. package/src/tui/status-icon.ts +0 -35
  108. package/src/tui/terminal-input.ts +0 -136
  109. package/src/tui/watch-state.ts +0 -43
  110. package/src/worktree.ts +0 -41
  111. package/tests/claude-command.test.ts +0 -30
  112. package/tests/claude-detect.test.ts +0 -14
  113. package/tests/commands/attach.test.ts +0 -60
  114. package/tests/commands/kill.test.ts +0 -66
  115. package/tests/commands/launch.test.ts +0 -75
  116. package/tests/commands/list.test.ts +0 -47
  117. package/tests/commands/notes.test.ts +0 -53
  118. package/tests/commands/restore.test.ts +0 -126
  119. package/tests/helpers/tmp-config.ts +0 -16
  120. package/tests/helpers/tmp-repo.ts +0 -29
  121. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  122. package/tests/mcp/handlers.test.ts +0 -163
  123. package/tests/mcp/tool-defs.test.ts +0 -35
  124. package/tests/musicians/dismiss.test.ts +0 -102
  125. package/tests/musicians/message.test.ts +0 -159
  126. package/tests/musicians/query.test.ts +0 -65
  127. package/tests/musicians/spawn.test.ts +0 -125
  128. package/tests/notes.test.ts +0 -56
  129. package/tests/notify.test.ts +0 -80
  130. package/tests/orchestrator/report-back.test.ts +0 -18
  131. package/tests/permission.test.ts +0 -39
  132. package/tests/project-key.test.ts +0 -33
  133. package/tests/prompts/tool-discipline.test.ts +0 -25
  134. package/tests/repo.test.ts +0 -38
  135. package/tests/state-updaters.test.ts +0 -126
  136. package/tests/state.test.ts +0 -85
  137. package/tests/tmux.test.ts +0 -126
  138. package/tests/tui/AppView.test.tsx +0 -92
  139. package/tests/tui/Auditorium.test.tsx +0 -67
  140. package/tests/tui/ConcertHall.test.tsx +0 -22
  141. package/tests/tui/Help.test.tsx +0 -38
  142. package/tests/tui/OrchestratorPane.test.ts +0 -30
  143. package/tests/tui/SidebarHeader.test.tsx +0 -20
  144. package/tests/tui/StatusBar.test.tsx +0 -51
  145. package/tests/tui/activity-line.test.ts +0 -21
  146. package/tests/tui/detect-permission.test.ts +0 -92
  147. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  148. package/tests/tui/embedded-terminal.test.ts +0 -80
  149. package/tests/tui/format-time.test.ts +0 -25
  150. package/tests/tui/keymap.test.ts +0 -93
  151. package/tests/tui/poll-activity.test.ts +0 -81
  152. package/tests/tui/poll-idle.test.ts +0 -159
  153. package/tests/tui/poll-permission.test.ts +0 -222
  154. package/tests/tui/status-icon.test.ts +0 -27
  155. package/tests/tui/terminal-input.test.ts +0 -113
  156. package/tests/tui/watch-state.test.ts +0 -54
  157. package/tests/worktree.test.ts +0 -73
  158. package/tsconfig.json +0 -19
  159. package/vitest.config.ts +0 -12
@@ -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
- }