@neolio42/pixel-office 0.1.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin.sh +16 -0
  4. package/bin.ts +162 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +7 -0
  7. package/package.json +51 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/assets/characters/char_0.png +0 -0
  10. package/public/assets/characters/char_1.png +0 -0
  11. package/public/assets/characters/char_2.png +0 -0
  12. package/public/assets/characters/char_3.png +0 -0
  13. package/public/assets/characters/char_4.png +0 -0
  14. package/public/assets/characters/char_5.png +0 -0
  15. package/public/assets/characters.png +0 -0
  16. package/public/assets/default-layout-1.json +92 -0
  17. package/public/assets/floors/floor_0.png +0 -0
  18. package/public/assets/floors/floor_1.png +0 -0
  19. package/public/assets/floors/floor_2.png +0 -0
  20. package/public/assets/floors/floor_3.png +0 -0
  21. package/public/assets/floors/floor_4.png +0 -0
  22. package/public/assets/floors/floor_5.png +0 -0
  23. package/public/assets/floors/floor_6.png +0 -0
  24. package/public/assets/floors/floor_7.png +0 -0
  25. package/public/assets/floors/floor_8.png +0 -0
  26. package/public/assets/furniture/BIN/BIN.png +0 -0
  27. package/public/assets/furniture/BIN/manifest.json +13 -0
  28. package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
  29. package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
  30. package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
  31. package/public/assets/furniture/CACTUS/manifest.json +13 -0
  32. package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
  33. package/public/assets/furniture/CLOCK/manifest.json +13 -0
  34. package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
  35. package/public/assets/furniture/COFFEE/manifest.json +13 -0
  36. package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
  37. package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
  38. package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
  39. package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
  40. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
  44. package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
  45. package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
  46. package/public/assets/furniture/DESK/manifest.json +33 -0
  47. package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
  48. package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
  49. package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
  50. package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
  51. package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
  52. package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
  53. package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
  54. package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
  55. package/public/assets/furniture/PC/PC_BACK.png +0 -0
  56. package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
  57. package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
  58. package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
  59. package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
  60. package/public/assets/furniture/PC/PC_SIDE.png +0 -0
  61. package/public/assets/furniture/PC/manifest.json +88 -0
  62. package/public/assets/furniture/PLANT/PLANT.png +0 -0
  63. package/public/assets/furniture/PLANT/manifest.json +13 -0
  64. package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
  65. package/public/assets/furniture/PLANT_2/manifest.json +13 -0
  66. package/public/assets/furniture/POT/POT.png +0 -0
  67. package/public/assets/furniture/POT/manifest.json +13 -0
  68. package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
  69. package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
  70. package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
  71. package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
  72. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
  73. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
  74. package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
  75. package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
  76. package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
  77. package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
  78. package/public/assets/furniture/SOFA/manifest.json +44 -0
  79. package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
  80. package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
  81. package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
  82. package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
  83. package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
  84. package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
  85. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
  86. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
  87. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
  88. package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
  89. package/public/assets/walls/wall_0.png +0 -0
  90. package/scripts/setup.ts +158 -0
  91. package/server.ts +53 -0
  92. package/src/app/api/focus-terminal/route.ts +65 -0
  93. package/src/app/api/hooks/notification/route.ts +19 -0
  94. package/src/app/api/hooks/post-tool-use/route.ts +26 -0
  95. package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
  96. package/src/app/api/hooks/session-end/route.ts +31 -0
  97. package/src/app/api/hooks/session-start/route.ts +47 -0
  98. package/src/app/api/hooks/stop/route.ts +19 -0
  99. package/src/app/api/hooks/user-prompt/route.ts +92 -0
  100. package/src/app/favicon.ico +0 -0
  101. package/src/app/globals.css +14 -0
  102. package/src/app/layout.tsx +21 -0
  103. package/src/app/page.tsx +5 -0
  104. package/src/components/ApprovalToast.tsx +132 -0
  105. package/src/components/OfficeCanvas.tsx +311 -0
  106. package/src/components/Terminal.tsx +177 -0
  107. package/src/components/TerminalTile.tsx +181 -0
  108. package/src/components/WorkerPanel.tsx +261 -0
  109. package/src/components/WorkerPopup.tsx +116 -0
  110. package/src/game/asset-loader.ts +172 -0
  111. package/src/game/office-layout.ts +287 -0
  112. package/src/game/renderer.ts +369 -0
  113. package/src/game/sprites.ts +133 -0
  114. package/src/game/worker-entity.ts +219 -0
  115. package/src/hooks/usePixelOffice.ts +318 -0
  116. package/src/hooks/useRecentCwds.ts +27 -0
  117. package/src/lib/approval-queue.ts +67 -0
  118. package/src/lib/pty-manager.ts +267 -0
  119. package/src/lib/store.ts +181 -0
  120. package/src/lib/tool-classifier.ts +224 -0
  121. package/src/lib/transcript.ts +109 -0
  122. package/src/lib/types.ts +58 -0
  123. package/src/lib/ws-server.ts +270 -0
  124. package/tsconfig.json +34 -0
@@ -0,0 +1,267 @@
1
+ import { spawn as ptySpawn, IPty } from 'node-pty';
2
+ import { execSync } from 'child_process';
3
+
4
+ /** Resolve full path to claude binary so node-pty can find it regardless of server PATH */
5
+ function resolveClaudePath(): string {
6
+ // Try common locations first (fastest, no shell needed)
7
+ const candidates = ['/opt/homebrew/bin/claude', '/usr/local/bin/claude', '/usr/bin/claude'];
8
+ for (const c of candidates) {
9
+ try {
10
+ execSync(`test -x "${c}"`, { stdio: 'ignore' });
11
+ return c;
12
+ } catch { /* continue */ }
13
+ }
14
+ // Fall back to shell lookup (may fail if shell doesn't source profile)
15
+ try {
16
+ // Use login shell to source PATH properly
17
+ const shell = process.env.SHELL || '/bin/zsh';
18
+ return execSync(`${shell} -lc "which claude"`, { encoding: 'utf8' }).trim();
19
+ } catch { /* continue */ }
20
+ return 'claude';
21
+ }
22
+
23
+ declare global {
24
+ // eslint-disable-next-line no-var
25
+ var __claudePath: string | undefined;
26
+ }
27
+
28
+ function getClaudePath(): string {
29
+ if (!globalThis.__claudePath) {
30
+ globalThis.__claudePath = resolveClaudePath();
31
+ console.log(`[PTY] Resolved claude binary: ${globalThis.__claudePath}`);
32
+ }
33
+ return globalThis.__claudePath;
34
+ }
35
+
36
+ // Force re-resolve on module reload (HMR)
37
+ globalThis.__claudePath = undefined;
38
+
39
+ export interface PtyEntry {
40
+ ptyId: string;
41
+ pty: IPty;
42
+ ttyPath: string;
43
+ sessionId?: string;
44
+ cwd: string;
45
+ cols: number;
46
+ rows: number;
47
+ spawnedAt: number;
48
+ scrollback: string[];
49
+ scrollbackBytes: number;
50
+ exited: boolean;
51
+ exitCode?: number;
52
+ }
53
+
54
+ const MAX_SCROLLBACK_BYTES = 1_000_000; // ~1MB cap
55
+
56
+ declare global {
57
+ // eslint-disable-next-line no-var
58
+ var __ptyRegistry: Map<string, PtyEntry> | undefined;
59
+ // eslint-disable-next-line no-var
60
+ var __ptyCounter: number | undefined;
61
+ }
62
+
63
+ function getRegistry(): Map<string, PtyEntry> {
64
+ if (!globalThis.__ptyRegistry) {
65
+ globalThis.__ptyRegistry = new Map();
66
+ }
67
+ return globalThis.__ptyRegistry;
68
+ }
69
+
70
+ function getPtyCounter(): number {
71
+ if (globalThis.__ptyCounter === undefined) {
72
+ let max = 0;
73
+ for (const entry of getRegistry().values()) {
74
+ const n = parseInt(entry.ptyId.split('-')[1] || '0', 10);
75
+ if (n > max) max = n;
76
+ }
77
+ globalThis.__ptyCounter = max;
78
+ }
79
+ return globalThis.__ptyCounter;
80
+ }
81
+
82
+ /**
83
+ * Callbacks wired by ws-server to send terminal output to subscribers only.
84
+ * Stored on globalThis so HMR doesn't reset them.
85
+ */
86
+ declare global {
87
+ // eslint-disable-next-line no-var
88
+ var __ptyOutputHandler: ((ptyId: string, data: string) => void) | undefined;
89
+ // eslint-disable-next-line no-var
90
+ var __ptyExitHandler: ((ptyId: string, exitCode: number) => void) | undefined;
91
+ }
92
+
93
+ export function setPtyOutputHandler(handler: (ptyId: string, data: string) => void) {
94
+ globalThis.__ptyOutputHandler = handler;
95
+ }
96
+
97
+ export function setPtyExitHandler(handler: (ptyId: string, exitCode: number) => void) {
98
+ globalThis.__ptyExitHandler = handler;
99
+ }
100
+
101
+ export function spawnSession(cwd: string, cols = 120, rows = 30): PtyEntry {
102
+ globalThis.__ptyCounter = getPtyCounter() + 1;
103
+ const ptyId = `pty-${globalThis.__ptyCounter}-${Date.now()}`;
104
+
105
+ const pty = ptySpawn(getClaudePath(), [], {
106
+ name: 'xterm-256color',
107
+ cols,
108
+ rows,
109
+ cwd,
110
+ env: {
111
+ ...process.env,
112
+ TERM: 'xterm-256color',
113
+ // Ensure homebrew paths are in PATH for child processes
114
+ PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || '/usr/bin:/bin'}`,
115
+ } as Record<string, string>,
116
+ });
117
+
118
+ // Get the tty path from node-pty's internal _pty field (the slave PTY device)
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
+ const ttyPath: string = (pty as any)._pty || '';
121
+
122
+ const entry: PtyEntry = {
123
+ ptyId,
124
+ pty,
125
+ ttyPath,
126
+ cwd,
127
+ cols,
128
+ rows,
129
+ spawnedAt: Date.now(),
130
+ scrollback: [],
131
+ scrollbackBytes: 0,
132
+ exited: false,
133
+ };
134
+
135
+ pty.onData((data: string) => {
136
+ // Buffer scrollback
137
+ entry.scrollback.push(data);
138
+ entry.scrollbackBytes += data.length;
139
+ // Trim if over cap
140
+ while (entry.scrollbackBytes > MAX_SCROLLBACK_BYTES && entry.scrollback.length > 0) {
141
+ const removed = entry.scrollback.shift()!;
142
+ entry.scrollbackBytes -= removed.length;
143
+ }
144
+ if (entry.scrollback.length === 0) entry.scrollbackBytes = 0;
145
+ // Send to subscribers
146
+ globalThis.__ptyOutputHandler?.(ptyId, data);
147
+ });
148
+
149
+ pty.onExit(({ exitCode }) => {
150
+ entry.exited = true;
151
+ entry.exitCode = exitCode;
152
+ globalThis.__ptyExitHandler?.(ptyId, exitCode);
153
+ });
154
+
155
+ getRegistry().set(ptyId, entry);
156
+ console.log(`[PTY] Spawned ${ptyId} (pid=${pty.pid}, tty=${ttyPath}, cwd=${cwd})`);
157
+ return entry;
158
+ }
159
+
160
+ export function writeToPty(ptyId: string, data: string): boolean {
161
+ const entry = getRegistry().get(ptyId);
162
+ if (!entry || entry.exited) return false;
163
+ entry.pty.write(data);
164
+ return true;
165
+ }
166
+
167
+ export function resizePty(ptyId: string, cols: number, rows: number): boolean {
168
+ const entry = getRegistry().get(ptyId);
169
+ if (!entry || entry.exited) return false;
170
+ entry.pty.resize(cols, rows);
171
+ entry.cols = cols;
172
+ entry.rows = rows;
173
+ return true;
174
+ }
175
+
176
+ export function killPty(ptyId: string): boolean {
177
+ const entry = getRegistry().get(ptyId);
178
+ if (!entry) return false;
179
+ if (!entry.exited) {
180
+ entry.pty.kill('SIGHUP');
181
+ }
182
+ getRegistry().delete(ptyId);
183
+ console.log(`[PTY] Killed ${ptyId}`);
184
+ return true;
185
+ }
186
+
187
+ export function unlinkSessionFromPty(ptyId: string): boolean {
188
+ const entry = getRegistry().get(ptyId);
189
+ if (!entry) return false;
190
+ entry.sessionId = undefined;
191
+ return true;
192
+ }
193
+
194
+ export function linkSessionToPty(sessionId: string, ptyId: string): boolean {
195
+ const entry = getRegistry().get(ptyId);
196
+ if (!entry) return false;
197
+ entry.sessionId = sessionId;
198
+ console.log(`[PTY] Linked session ${sessionId} → ${ptyId}`);
199
+ return true;
200
+ }
201
+
202
+ /** Normalize tty path to canonical form /dev/ttysNNN */
203
+ function normalizeTty(tty: string): string {
204
+ if (!tty) return '';
205
+ // Already canonical: /dev/ttys008
206
+ if (tty.startsWith('/dev/tty')) return tty;
207
+ // Just the device name: ttys008
208
+ if (tty.startsWith('tty')) return `/dev/${tty}`;
209
+ // Short form: s008
210
+ if (/^s\d+$/.test(tty)) return `/dev/tty${tty}`;
211
+ // /dev/sNNN → /dev/ttysNNN
212
+ if (/^\/dev\/s\d+$/.test(tty)) return `/dev/tty${tty.slice(5)}`;
213
+ if (tty.startsWith('/dev/')) return tty;
214
+ return tty;
215
+ }
216
+
217
+ export function findPtyByTty(ttyPath: string): PtyEntry | undefined {
218
+ if (!ttyPath) return undefined;
219
+ const normalized = normalizeTty(ttyPath);
220
+ for (const entry of getRegistry().values()) {
221
+ if (!entry.sessionId && !entry.exited && entry.ttyPath) {
222
+ if (entry.ttyPath === normalized || normalizeTty(entry.ttyPath) === normalized) {
223
+ return entry;
224
+ }
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ /** Find an unlinked PTY by matching cwd — fallback when tty matching fails.
231
+ * Only matches PTYs spawned within the last 30s to prevent external sessions
232
+ * from accidentally linking to an embedded PTY with the same cwd. */
233
+ export function findPtyByCwd(cwd: string): PtyEntry | undefined {
234
+ if (!cwd) return undefined;
235
+ const cutoff = Date.now() - 30_000;
236
+ for (const entry of getRegistry().values()) {
237
+ if (!entry.sessionId && !entry.exited && entry.cwd === cwd && entry.spawnedAt > cutoff) {
238
+ return entry;
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ export function getPtyEntry(ptyId: string): PtyEntry | undefined {
245
+ return getRegistry().get(ptyId);
246
+ }
247
+
248
+ export function getScrollback(ptyId: string): string {
249
+ const entry = getRegistry().get(ptyId);
250
+ if (!entry) return '';
251
+ return entry.scrollback.join('');
252
+ }
253
+
254
+ export function getAllPtyEntries(): PtyEntry[] {
255
+ return [...getRegistry().values()];
256
+ }
257
+
258
+ export function cleanupOrphanedPtys(activePtyIds: Set<string>): string[] {
259
+ const removed: string[] = [];
260
+ for (const [ptyId, entry] of getRegistry()) {
261
+ if (entry.exited && !activePtyIds.has(ptyId)) {
262
+ getRegistry().delete(ptyId);
263
+ removed.push(ptyId);
264
+ }
265
+ }
266
+ return removed;
267
+ }
@@ -0,0 +1,181 @@
1
+ import { Session, WorkerState } from './types';
2
+
3
+ // Use globalThis so the same sessions Map is shared across
4
+ // Next.js App Router module instances and the custom server.ts
5
+ declare global {
6
+ // eslint-disable-next-line no-var
7
+ var __sessions: Map<string, Session> | undefined;
8
+ }
9
+
10
+ function getSessions(): Map<string, Session> {
11
+ if (!globalThis.__sessions) {
12
+ globalThis.__sessions = new Map();
13
+ }
14
+ return globalThis.__sessions;
15
+ }
16
+
17
+ const MAX_DESKS = 5;
18
+
19
+ function getNextDeskIndex(): number {
20
+ const sessions = getSessions();
21
+ const taken = new Set([...sessions.values()].map(s => s.deskIndex));
22
+ for (let i = 0; i < MAX_DESKS; i++) {
23
+ if (!taken.has(i)) return i;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ export function addSession(sessionId: string, cwd: string, tty = '', transcriptPath?: string): Session {
29
+ const sessions = getSessions();
30
+ const now = Date.now();
31
+ const session: Session = {
32
+ sessionId,
33
+ deskIndex: getNextDeskIndex(),
34
+ state: 'walking',
35
+ currentTool: null,
36
+ cwd,
37
+ tty: tty.startsWith('/dev/') ? tty : '',
38
+ startedAt: now,
39
+ lastSeen: now,
40
+ recentTools: [],
41
+ transcriptPath,
42
+ };
43
+ sessions.set(sessionId, session);
44
+ return session;
45
+ }
46
+
47
+ export function updateSessionTty(sessionId: string, tty: string): void {
48
+ const session = getSessions().get(sessionId);
49
+ if (session && tty && tty.startsWith('/dev/')) {
50
+ session.tty = tty;
51
+ }
52
+ }
53
+
54
+ export function updateSession(sessionId: string, state: WorkerState, tool: string | null): Session | null {
55
+ const session = getSessions().get(sessionId);
56
+ if (!session) return null;
57
+ session.state = state;
58
+ session.currentTool = tool;
59
+ session.lastSeen = Date.now();
60
+ return session;
61
+ }
62
+
63
+ export function cleanupStaleSessions(maxAgeMs = 60_000, isAlivePtyId?: (ptyId: string) => boolean): string[] {
64
+ const sessions = getSessions();
65
+ const cutoff = Date.now() - maxAgeMs;
66
+ const removed: string[] = [];
67
+ for (const [id, session] of sessions) {
68
+ if (session.lastSeen < cutoff) {
69
+ // Don't remove sessions with a live embedded PTY — the terminal is still open
70
+ if (session.ptyId && isAlivePtyId?.(session.ptyId)) continue;
71
+ sessions.delete(id);
72
+ removed.push(id);
73
+ }
74
+ }
75
+ return removed;
76
+ }
77
+
78
+ export function removeSession(sessionId: string): boolean {
79
+ return getSessions().delete(sessionId);
80
+ }
81
+
82
+ export function getSession(sessionId: string): Session | undefined {
83
+ return getSessions().get(sessionId);
84
+ }
85
+
86
+ export function getAllSessions(): Session[] {
87
+ return [...getSessions().values()];
88
+ }
89
+
90
+ export function setSessionTask(sessionId: string, task: string): Session | null {
91
+ const session = getSessions().get(sessionId);
92
+ if (!session) return null;
93
+ session.task = task;
94
+ return session;
95
+ }
96
+
97
+ export function setSessionFocus(sessionId: string, focus: string): Session | null {
98
+ const session = getSessions().get(sessionId);
99
+ if (!session) return null;
100
+ session.currentFocus = focus;
101
+ return session;
102
+ }
103
+
104
+ export function setSessionPlanMode(sessionId: string, inPlanMode: boolean): Session | null {
105
+ const session = getSessions().get(sessionId);
106
+ if (!session) return null;
107
+ session.inPlanMode = inPlanMode;
108
+ return session;
109
+ }
110
+
111
+ function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
112
+ switch (toolName) {
113
+ case 'Read': {
114
+ const p = toolInput.file_path as string | undefined;
115
+ return p ? `Reading ${p.split('/').pop()}` : 'Reading file';
116
+ }
117
+ case 'Edit':
118
+ case 'Write':
119
+ case 'MultiEdit': {
120
+ const p = toolInput.file_path as string | undefined;
121
+ return p ? `Editing ${p.split('/').pop()}` : 'Editing file';
122
+ }
123
+ case 'Grep': {
124
+ const pattern = toolInput.pattern as string | undefined;
125
+ return pattern ? `Searching: ${pattern.slice(0, 50)}` : 'Searching';
126
+ }
127
+ case 'Glob': {
128
+ const pattern = toolInput.pattern as string | undefined;
129
+ return pattern ? `Globbing: ${pattern.slice(0, 50)}` : 'Globbing';
130
+ }
131
+ case 'Bash': {
132
+ const cmd = toolInput.command as string | undefined;
133
+ return cmd ? `Running: ${cmd.slice(0, 60)}` : 'Running command';
134
+ }
135
+ case 'WebFetch':
136
+ case 'WebSearch': {
137
+ const url = (toolInput.url ?? toolInput.query) as string | undefined;
138
+ return url ? `Fetching: ${url.slice(0, 50)}` : 'Web request';
139
+ }
140
+ case 'Agent': return 'Delegating task';
141
+ case 'TodoWrite': return 'Updating tasks';
142
+ case 'ToolSearch': return 'Loading tools';
143
+ case 'Skill': return 'Running skill';
144
+ default: {
145
+ // Clean up MCP tool names: mcp__server__action → Server: action
146
+ if (toolName.startsWith('mcp__')) {
147
+ const match = toolName.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
148
+ if (match) {
149
+ const server = match[1].replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
150
+ const action = match[2].replace(/[-_]+/g, ' ');
151
+ // For well-known servers, shorten and use toolInput for details
152
+ const shortServer = server.toLowerCase();
153
+ // Browser tools: use the action param (screenshot, left_click, etc.)
154
+ if (shortServer.includes('chrome') || shortServer.includes('browser')) {
155
+ const act = toolInput.action as string | undefined;
156
+ if (act) return `Browser: ${act.replace(/_/g, ' ')}`;
157
+ return `Browser: ${action.replace(/_/g, ' ').slice(0, 30)}`;
158
+ }
159
+ if (shortServer.includes('clickup')) return `ClickUp: ${action.replace(/_/g, ' ').slice(0, 30)}`;
160
+ if (shortServer.includes('calendar')) return `Calendar: ${action.replace(/_/g, ' ').slice(0, 30)}`;
161
+ return `${server}: ${action.replace(/_/g, ' ').slice(0, 30)}`;
162
+ }
163
+ }
164
+ // Generic: grab first string value from input
165
+ const first = Object.values(toolInput).find(v => typeof v === 'string') as string | undefined;
166
+ return first ? `${toolName}: ${first.slice(0, 50)}` : toolName;
167
+ }
168
+ }
169
+ }
170
+
171
+ export function addToolCall(sessionId: string, toolName: string, toolInput: Record<string, unknown>): void {
172
+ const session = getSessions().get(sessionId);
173
+ if (!session) return;
174
+
175
+ const summary = getToolSummary(toolName, toolInput);
176
+ session.recentTools.push({ toolName, summary, timestamp: Date.now() });
177
+ if (session.recentTools.length > 10) {
178
+ session.recentTools = session.recentTools.slice(-10);
179
+ }
180
+ session.lastSeen = Date.now();
181
+ }
@@ -0,0 +1,224 @@
1
+ import { WorkerState } from './types';
2
+ import { execSync } from 'child_process';
3
+
4
+ interface Classification {
5
+ state: WorkerState;
6
+ needsApproval: boolean;
7
+ }
8
+
9
+ const READING_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'ToolSearch']);
10
+ const TYPING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
11
+ const AGENT_TOOLS = new Set(['Agent', 'TodoWrite', 'AskUserQuestion', 'Skill', 'EnterPlanMode', 'ExitPlanMode']);
12
+
13
+ // Commands that are always safe regardless of arguments
14
+ const SAFE_COMMANDS = new Set([
15
+ 'ls', 'pwd', 'echo', 'cat', 'head', 'tail', 'wc', 'sort', 'uniq',
16
+ 'date', 'whoami', 'which', 'type', 'file', 'stat', 'du', 'df',
17
+ 'grep', 'rg', 'find', 'sed', 'awk', 'tr', 'cut', 'paste',
18
+ 'node', 'npx', 'pnpm', 'yarn', 'bun', 'deno',
19
+ 'tsc', 'eslint', 'prettier', 'jest', 'vitest', 'mocha', 'playwright',
20
+ 'python', 'python3', 'cargo', 'go', 'rustc', 'gcc', 'g++', 'make', 'cmake',
21
+ 'mkdir', 'cp', 'mv', 'touch', 'ln',
22
+ 'tar', 'zip', 'unzip', 'gzip', 'gunzip',
23
+ 'curl', 'wget', 'http',
24
+ 'jq', 'yq', 'xargs', 'tee', 'diff', 'patch',
25
+ 'docker', 'brew', 'apt', 'apk',
26
+ 'sleep', 'true', 'false', 'test', '[',
27
+ 'printf', 'read', 'set', 'export', 'source', '.',
28
+ 'cd', 'pushd', 'popd', 'dirs',
29
+ 'shfmt', 'shellcheck',
30
+ ]);
31
+
32
+ // Git subcommands that are safe (read-only or local-only)
33
+ const SAFE_GIT = new Set([
34
+ 'status', 'log', 'diff', 'add', 'commit', 'branch', 'show',
35
+ 'stash', 'fetch', 'checkout', 'switch', 'merge', 'rebase',
36
+ 'tag', 'blame', 'bisect', 'cherry-pick', 'am', 'format-patch',
37
+ 'rev-parse', 'ls-files', 'ls-tree', 'config', 'remote',
38
+ 'describe', 'shortlog', 'reflog', 'worktree',
39
+ ]);
40
+
41
+ // npm/pip subcommands that are safe
42
+ const SAFE_NPM = new Set(['run', 'test', 'start', 'build', 'exec', 'init', 'info', 'ls', 'list', 'outdated', 'audit', 'pack', 'version', 'why']);
43
+
44
+ // Commands that need boss approval (not auto-deny — boss decides)
45
+ const RISKY_COMMANDS = new Set([
46
+ 'rm', 'rmdir', 'sudo', 'su',
47
+ 'chmod', 'chown', 'chgrp',
48
+ 'dd', 'mkfs', 'fdisk',
49
+ 'reboot', 'shutdown', 'halt', 'poweroff',
50
+ 'iptables', 'ufw', 'systemctl',
51
+ ]);
52
+
53
+ // Shell metacharacters that indicate compound commands
54
+ const COMPOUND_CHARS = /[|&;$`(){}]/;
55
+
56
+ /**
57
+ * Extract all command names from a compound bash command using shfmt AST.
58
+ * Returns null if shfmt isn't available or parsing fails (fallback to simple).
59
+ */
60
+ function extractCommandsViaAST(command: string): string[][] | null {
61
+ try {
62
+ const ast = execSync(
63
+ `echo ${JSON.stringify(command)} | shfmt --tojson 2>/dev/null`,
64
+ { encoding: 'utf-8', timeout: 2000 }
65
+ );
66
+ const parsed = JSON.parse(ast);
67
+
68
+ // Recursively extract all CallExpr nodes — each is a command invocation
69
+ const commands: string[][] = [];
70
+
71
+ function walk(node: unknown): void {
72
+ if (!node || typeof node !== 'object') return;
73
+ const obj = node as Record<string, unknown>;
74
+
75
+ if (obj.Type === 'CallExpr' && Array.isArray(obj.Args)) {
76
+ // Extract all argument parts as a single command line
77
+ const args: string[] = [];
78
+ for (const arg of obj.Args as Array<{ Parts?: Array<{ Value?: string }> }>) {
79
+ if (arg.Parts) {
80
+ for (const part of arg.Parts) {
81
+ if (part.Value) args.push(part.Value);
82
+ }
83
+ }
84
+ }
85
+ if (args.length > 0) commands.push(args);
86
+ }
87
+
88
+ // Recurse into all object values and arrays
89
+ for (const value of Object.values(obj)) {
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) walk(item);
92
+ } else if (typeof value === 'object' && value !== null) {
93
+ walk(value);
94
+ }
95
+ }
96
+ }
97
+
98
+ walk(parsed);
99
+ return commands.length > 0 ? commands : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Check if a single command (as array of args) is risky.
107
+ * Returns 'safe', 'risky', or 'unknown'.
108
+ */
109
+ function classifySingleCommand(args: string[]): 'safe' | 'risky' | 'unknown' {
110
+ const cmd = args[0];
111
+ if (!cmd) return 'unknown';
112
+
113
+ // Strip path prefix (e.g., /usr/bin/ls → ls)
114
+ const base = cmd.split('/').pop() || cmd;
115
+
116
+ // Check deny list first (deny > allow)
117
+ if (RISKY_COMMANDS.has(base)) return 'risky';
118
+
119
+ // Git: find the subcommand (skip flags like -C, --no-pager, etc.)
120
+ if (base === 'git') {
121
+ // Git flags that take a value argument: skip both the flag and its value
122
+ const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace']);
123
+ let sub: string | undefined;
124
+ for (let i = 1; i < args.length; i++) {
125
+ const a = args[i];
126
+ if (GIT_VALUE_FLAGS.has(a)) { i++; continue; } // skip flag + its value
127
+ if (a.startsWith('-')) continue; // skip other flags (--no-pager, --bare, etc.)
128
+ sub = a;
129
+ break;
130
+ }
131
+ if (!sub) return 'safe'; // bare `git` is fine
132
+ if (sub === 'push') return 'risky';
133
+ if (sub === 'reset' && args.includes('--hard')) return 'risky';
134
+ if (sub === 'clean' && args.includes('-f')) return 'risky';
135
+ if (SAFE_GIT.has(sub)) return 'safe';
136
+ return 'unknown';
137
+ }
138
+
139
+ // npm/pip: check subcommand
140
+ if (base === 'npm' || base === 'pip' || base === 'pip3') {
141
+ const sub = args[1];
142
+ if (sub === 'install' || sub === 'i' || sub === 'uninstall' || sub === 'remove') return 'risky';
143
+ if (base === 'npm' && SAFE_NPM.has(sub || '')) return 'safe';
144
+ return 'unknown';
145
+ }
146
+
147
+ // Check --force / --no-verify flags on any command
148
+ if (args.some(a => a === '--force' || a === '--no-verify' || a === '-f' && base === 'git')) {
149
+ return 'risky';
150
+ }
151
+
152
+ // Bash/sh -c: the inner command matters, not the shell itself
153
+ if ((base === 'bash' || base === 'sh' || base === 'zsh') && args.includes('-c')) {
154
+ return 'unknown'; // can't easily parse the inner command
155
+ }
156
+
157
+ // Check safe list
158
+ if (SAFE_COMMANDS.has(base)) return 'safe';
159
+
160
+ return 'unknown';
161
+ }
162
+
163
+ /**
164
+ * Classify a bash command — uses shfmt AST for compound commands,
165
+ * simple parsing for single commands.
166
+ */
167
+ function classifyBashCommand(command: string): { needsApproval: boolean } {
168
+ const trimmed = command.trim();
169
+
170
+ // For compound commands, try AST parsing
171
+ if (COMPOUND_CHARS.test(trimmed)) {
172
+ const commands = extractCommandsViaAST(trimmed);
173
+
174
+ if (commands) {
175
+ // Deny > Allow: if ANY command is risky, the whole thing is risky
176
+ let allSafe = true;
177
+ for (const args of commands) {
178
+ const result = classifySingleCommand(args);
179
+ if (result !== 'safe') {
180
+ allSafe = false;
181
+ break;
182
+ }
183
+ }
184
+ return { needsApproval: !allSafe };
185
+ }
186
+ // shfmt failed — fall through to simple parsing
187
+ }
188
+
189
+ // Simple command — split on whitespace
190
+ const args = trimmed.split(/\s+/);
191
+ const result = classifySingleCommand(args);
192
+ return { needsApproval: result !== 'safe' };
193
+ }
194
+
195
+ export function classifyTool(toolName: string, toolInput: Record<string, unknown>): Classification {
196
+ // MCP tools — auto-approve all (browser automation, ClickUp, calendar, etc.)
197
+ if (toolName.startsWith('mcp__')) {
198
+ return { state: 'typing', needsApproval: false };
199
+ }
200
+
201
+ if (READING_TOOLS.has(toolName)) {
202
+ return { state: 'reading', needsApproval: false };
203
+ }
204
+
205
+ if (TYPING_TOOLS.has(toolName)) {
206
+ return { state: 'typing', needsApproval: false };
207
+ }
208
+
209
+ if (AGENT_TOOLS.has(toolName)) {
210
+ return { state: 'typing', needsApproval: false };
211
+ }
212
+
213
+ if (toolName === 'Bash' || toolName === 'BashOutput') {
214
+ const command = String(toolInput.command || '');
215
+ const { needsApproval } = classifyBashCommand(command);
216
+ return {
217
+ state: needsApproval ? 'waiting' : 'typing',
218
+ needsApproval,
219
+ };
220
+ }
221
+
222
+ // Unknown tool — needs approval
223
+ return { state: 'waiting', needsApproval: true };
224
+ }