@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin.sh +16 -0
- package/bin.ts +162 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +51 -0
- package/postcss.config.mjs +7 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/characters.png +0 -0
- package/public/assets/default-layout-1.json +92 -0
- package/public/assets/floors/floor_0.png +0 -0
- package/public/assets/floors/floor_1.png +0 -0
- package/public/assets/floors/floor_2.png +0 -0
- package/public/assets/floors/floor_3.png +0 -0
- package/public/assets/floors/floor_4.png +0 -0
- package/public/assets/floors/floor_5.png +0 -0
- package/public/assets/floors/floor_6.png +0 -0
- package/public/assets/floors/floor_7.png +0 -0
- package/public/assets/floors/floor_8.png +0 -0
- package/public/assets/furniture/BIN/BIN.png +0 -0
- package/public/assets/furniture/BIN/manifest.json +13 -0
- package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
- package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
- package/public/assets/furniture/CACTUS/manifest.json +13 -0
- package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
- package/public/assets/furniture/CLOCK/manifest.json +13 -0
- package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
- package/public/assets/furniture/COFFEE/manifest.json +13 -0
- package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
- package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
- package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
- package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
- package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
- package/public/assets/furniture/DESK/manifest.json +33 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
- package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
- package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
- package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
- package/public/assets/furniture/PC/PC_BACK.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
- package/public/assets/furniture/PC/PC_SIDE.png +0 -0
- package/public/assets/furniture/PC/manifest.json +88 -0
- package/public/assets/furniture/PLANT/PLANT.png +0 -0
- package/public/assets/furniture/PLANT/manifest.json +13 -0
- package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
- package/public/assets/furniture/PLANT_2/manifest.json +13 -0
- package/public/assets/furniture/POT/POT.png +0 -0
- package/public/assets/furniture/POT/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
- package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
- package/public/assets/furniture/SOFA/manifest.json +44 -0
- package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
- package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
- package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
- package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
- package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
- package/public/assets/walls/wall_0.png +0 -0
- package/scripts/setup.ts +158 -0
- package/server.ts +53 -0
- package/src/app/api/focus-terminal/route.ts +65 -0
- package/src/app/api/hooks/notification/route.ts +19 -0
- package/src/app/api/hooks/post-tool-use/route.ts +26 -0
- package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
- package/src/app/api/hooks/session-end/route.ts +31 -0
- package/src/app/api/hooks/session-start/route.ts +47 -0
- package/src/app/api/hooks/stop/route.ts +19 -0
- package/src/app/api/hooks/user-prompt/route.ts +92 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +21 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ApprovalToast.tsx +132 -0
- package/src/components/OfficeCanvas.tsx +311 -0
- package/src/components/Terminal.tsx +177 -0
- package/src/components/TerminalTile.tsx +181 -0
- package/src/components/WorkerPanel.tsx +261 -0
- package/src/components/WorkerPopup.tsx +116 -0
- package/src/game/asset-loader.ts +172 -0
- package/src/game/office-layout.ts +287 -0
- package/src/game/renderer.ts +369 -0
- package/src/game/sprites.ts +133 -0
- package/src/game/worker-entity.ts +219 -0
- package/src/hooks/usePixelOffice.ts +318 -0
- package/src/hooks/useRecentCwds.ts +27 -0
- package/src/lib/approval-queue.ts +67 -0
- package/src/lib/pty-manager.ts +267 -0
- package/src/lib/store.ts +181 -0
- package/src/lib/tool-classifier.ts +224 -0
- package/src/lib/transcript.ts +109 -0
- package/src/lib/types.ts +58 -0
- package/src/lib/ws-server.ts +270 -0
- 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
|
+
}
|
package/src/lib/store.ts
ADDED
|
@@ -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
|
+
}
|