@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,109 @@
|
|
|
1
|
+
import { access, constants, stat } from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read the initial user task from a Claude Code JSONL transcript.
|
|
5
|
+
* Reads the first ~20KB, finds the first `type: "user"` entry.
|
|
6
|
+
*/
|
|
7
|
+
export async function readTaskFromTranscript(path: string): Promise<string | null> {
|
|
8
|
+
try {
|
|
9
|
+
await access(path, constants.R_OK);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const info = await stat(path);
|
|
16
|
+
const readSize = Math.min(info.size, 20_000);
|
|
17
|
+
const buf = Buffer.alloc(readSize);
|
|
18
|
+
const { open } = await import('fs/promises');
|
|
19
|
+
const fh = await open(path, 'r');
|
|
20
|
+
await fh.read(buf, 0, readSize, 0);
|
|
21
|
+
await fh.close();
|
|
22
|
+
|
|
23
|
+
const chunk = buf.toString('utf-8');
|
|
24
|
+
const lines = chunk.split('\n').filter(l => l.length > 0);
|
|
25
|
+
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
try {
|
|
28
|
+
const entry = JSON.parse(line);
|
|
29
|
+
if (entry.type !== 'user') continue;
|
|
30
|
+
|
|
31
|
+
const raw = extractText(entry);
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
|
|
34
|
+
const firstLine = raw.split('\n')
|
|
35
|
+
.map(l => l.trim())
|
|
36
|
+
.filter(l => l.length > 0)
|
|
37
|
+
.map(l => l.replace(/^#+\s*/, '').replace(/^implement\s+the\s+following\s+(plan|task|request):\s*/i, '').trim())
|
|
38
|
+
.filter(l => l.length > 3)[0];
|
|
39
|
+
|
|
40
|
+
return firstLine?.slice(0, 120) ?? raw.slice(0, 120);
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the latest assistant message from a transcript JSONL.
|
|
54
|
+
* Reads the last ~30KB of the file (tail), finds the last `type: "assistant"`
|
|
55
|
+
* entry with text content.
|
|
56
|
+
*/
|
|
57
|
+
export async function readLatestAssistantMessage(path: string): Promise<string | null> {
|
|
58
|
+
try {
|
|
59
|
+
await access(path, constants.R_OK);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const info = await stat(path);
|
|
66
|
+
const tailSize = Math.min(info.size, 200_000);
|
|
67
|
+
const buf = Buffer.alloc(tailSize);
|
|
68
|
+
const { open } = await import('fs/promises');
|
|
69
|
+
const fh = await open(path, 'r');
|
|
70
|
+
await fh.read(buf, 0, tailSize, Math.max(0, info.size - tailSize));
|
|
71
|
+
await fh.close();
|
|
72
|
+
|
|
73
|
+
const chunk = buf.toString('utf-8');
|
|
74
|
+
const lines = chunk.split('\n').filter(l => l.length > 0);
|
|
75
|
+
|
|
76
|
+
// Walk backwards to find the last assistant text
|
|
77
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
78
|
+
try {
|
|
79
|
+
const entry = JSON.parse(lines[i]);
|
|
80
|
+
if (entry.type !== 'assistant') continue;
|
|
81
|
+
|
|
82
|
+
const text = extractText(entry);
|
|
83
|
+
if (text && text.length > 5) return text;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Extract text content from a JSONL entry (user or assistant). */
|
|
96
|
+
function extractText(entry: { message?: { content?: unknown } }): string | null {
|
|
97
|
+
const content = entry.message?.content;
|
|
98
|
+
if (typeof content === 'string') return content;
|
|
99
|
+
if (Array.isArray(content)) {
|
|
100
|
+
for (const block of content) {
|
|
101
|
+
if (typeof block === 'object' && block !== null && 'type' in block && 'text' in block) {
|
|
102
|
+
if ((block as { type: string }).type === 'text') {
|
|
103
|
+
return (block as { text: string }).text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type WorkerState = 'idle' | 'typing' | 'reading' | 'waiting' | 'walking';
|
|
2
|
+
|
|
3
|
+
export interface Session {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
deskIndex: number;
|
|
6
|
+
state: WorkerState;
|
|
7
|
+
currentTool: string | null;
|
|
8
|
+
cwd: string;
|
|
9
|
+
tty: string;
|
|
10
|
+
startedAt: number;
|
|
11
|
+
lastSeen: number;
|
|
12
|
+
recentTools: { toolName: string; summary: string; timestamp: number }[];
|
|
13
|
+
task?: string;
|
|
14
|
+
currentFocus?: string;
|
|
15
|
+
transcriptPath?: string;
|
|
16
|
+
inPlanMode?: boolean;
|
|
17
|
+
/** Set by UserPromptSubmit, cleared after first PreToolUse reads transcript. */
|
|
18
|
+
needsFocusUpdate?: boolean;
|
|
19
|
+
/** Present if this session was spawned by Pixel Office's embedded terminal. */
|
|
20
|
+
ptyId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PendingApproval {
|
|
24
|
+
id: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
toolName: string;
|
|
27
|
+
toolInput: Record<string, unknown>;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
resolve: (decision: 'allow' | 'deny') => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type WSMessageToClient =
|
|
33
|
+
| { type: 'sessions'; sessions: Session[] }
|
|
34
|
+
| { type: 'session-update'; session: Session }
|
|
35
|
+
| { type: 'session-remove'; sessionId: string }
|
|
36
|
+
| { type: 'approval-request'; approval: { id: string; sessionId: string; toolName: string; toolInput: Record<string, unknown> } }
|
|
37
|
+
| { type: 'approval-resolved'; approvalId: string }
|
|
38
|
+
| { type: 'notification'; sessionId: string; message: string }
|
|
39
|
+
| { type: 'terminal-output'; ptyId: string; data: string }
|
|
40
|
+
| { type: 'terminal-scrollback'; ptyId: string; data: string }
|
|
41
|
+
| { type: 'terminal-exited'; ptyId: string; exitCode: number }
|
|
42
|
+
| { type: 'spawn-result'; ptyId: string; success: boolean; error?: string };
|
|
43
|
+
|
|
44
|
+
export type WSMessageFromClient =
|
|
45
|
+
| { type: 'approval-response'; approvalId: string; decision: 'allow' | 'deny' }
|
|
46
|
+
| { type: 'terminal-input'; ptyId: string; data: string }
|
|
47
|
+
| { type: 'terminal-resize'; ptyId: string; cols: number; rows: number }
|
|
48
|
+
| { type: 'terminal-subscribe'; ptyId: string; cols?: number; rows?: number }
|
|
49
|
+
| { type: 'terminal-unsubscribe'; ptyId: string }
|
|
50
|
+
| { type: 'spawn-session'; cwd: string; prompt?: string };
|
|
51
|
+
|
|
52
|
+
export interface HookPayload {
|
|
53
|
+
session_id: string;
|
|
54
|
+
tool_name?: string;
|
|
55
|
+
tool_input?: Record<string, unknown>;
|
|
56
|
+
cwd?: string;
|
|
57
|
+
transcript_path?: string;
|
|
58
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { IncomingMessage } from 'http';
|
|
3
|
+
import { Server } from 'http';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { WSMessageToClient, WSMessageFromClient } from './types';
|
|
8
|
+
import { resolveApproval } from './approval-queue';
|
|
9
|
+
import { getAllSessions } from './store';
|
|
10
|
+
import {
|
|
11
|
+
spawnSession,
|
|
12
|
+
writeToPty,
|
|
13
|
+
resizePty,
|
|
14
|
+
getScrollback,
|
|
15
|
+
getPtyEntry,
|
|
16
|
+
setPtyOutputHandler,
|
|
17
|
+
setPtyExitHandler,
|
|
18
|
+
} from './pty-manager';
|
|
19
|
+
|
|
20
|
+
// Use globalThis so the same WSS instance is shared across
|
|
21
|
+
// Next.js App Router module instances and the custom server.ts
|
|
22
|
+
declare global {
|
|
23
|
+
// eslint-disable-next-line no-var
|
|
24
|
+
var __wss: WebSocketServer | undefined;
|
|
25
|
+
// eslint-disable-next-line no-var
|
|
26
|
+
var __wsClientMeta: WeakMap<WebSocket, { subscriptions: Set<string> }> | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getWSS(): WebSocketServer | null {
|
|
30
|
+
return globalThis.__wss ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getClientMeta(): WeakMap<WebSocket, { subscriptions: Set<string> }> {
|
|
34
|
+
if (!globalThis.__wsClientMeta) {
|
|
35
|
+
globalThis.__wsClientMeta = new WeakMap();
|
|
36
|
+
}
|
|
37
|
+
return globalThis.__wsClientMeta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureMeta(ws: WebSocket): { subscriptions: Set<string> } {
|
|
41
|
+
const map = getClientMeta();
|
|
42
|
+
let meta = map.get(ws);
|
|
43
|
+
if (!meta) {
|
|
44
|
+
meta = { subscriptions: new Set() };
|
|
45
|
+
map.set(ws, meta);
|
|
46
|
+
}
|
|
47
|
+
return meta;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function initWSS(server: Server) {
|
|
51
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
52
|
+
globalThis.__wss = wss;
|
|
53
|
+
|
|
54
|
+
// Wire PTY output/exit to subscriber-only delivery
|
|
55
|
+
setPtyOutputHandler((ptyId, data) => {
|
|
56
|
+
sendToSubscribers(ptyId, { type: 'terminal-output', ptyId, data });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
setPtyExitHandler((ptyId, exitCode) => {
|
|
60
|
+
sendToSubscribers(ptyId, { type: 'terminal-exited', ptyId, exitCode });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
server.on('upgrade', (req: IncomingMessage, socket, head) => {
|
|
64
|
+
if (req.url === '/ws') {
|
|
65
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
66
|
+
wss.emit('connection', ws, req);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
wss.on('connection', (ws: WebSocket) => {
|
|
72
|
+
console.log('[WS] Client connected');
|
|
73
|
+
ensureMeta(ws);
|
|
74
|
+
|
|
75
|
+
// Send current state snapshot so late-joining clients are in sync
|
|
76
|
+
// Strip ptyId from sessions whose PTY no longer exists
|
|
77
|
+
const sessions = getAllSessions().map(s => {
|
|
78
|
+
if (s.ptyId && !getPtyEntry(s.ptyId)) {
|
|
79
|
+
return { ...s, ptyId: undefined };
|
|
80
|
+
}
|
|
81
|
+
return s;
|
|
82
|
+
});
|
|
83
|
+
ws.send(JSON.stringify({ type: 'sessions', sessions }));
|
|
84
|
+
|
|
85
|
+
ws.on('message', (raw) => {
|
|
86
|
+
try {
|
|
87
|
+
const msg: WSMessageFromClient = JSON.parse(raw.toString());
|
|
88
|
+
handleClientMessage(ws, msg);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error('[WS] Bad message:', e);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ws.on('close', () => {
|
|
95
|
+
console.log('[WS] Client disconnected');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ws.on('error', (err) => {
|
|
99
|
+
console.error('[WS] Socket error:', err);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleClientMessage(ws: WebSocket, msg: WSMessageFromClient) {
|
|
105
|
+
switch (msg.type) {
|
|
106
|
+
case 'approval-response': {
|
|
107
|
+
const resolved = resolveApproval(msg.approvalId, msg.decision);
|
|
108
|
+
if (resolved) {
|
|
109
|
+
broadcast({ type: 'approval-resolved', approvalId: msg.approvalId });
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case 'spawn-session': {
|
|
115
|
+
console.log('[WS] spawn-session received:', msg.cwd);
|
|
116
|
+
try {
|
|
117
|
+
// Normalize cwd to a clean absolute path
|
|
118
|
+
const home: string = process.env.HOME || os.homedir() || '/tmp';
|
|
119
|
+
let cwd = msg.cwd.trim() || home;
|
|
120
|
+
// Expand ~ to home
|
|
121
|
+
if (cwd === '~' || cwd === '~/') {
|
|
122
|
+
cwd = home;
|
|
123
|
+
} else if (cwd.startsWith('~/')) {
|
|
124
|
+
cwd = path.join(home, cwd.slice(2));
|
|
125
|
+
} else if (!cwd.startsWith('/')) {
|
|
126
|
+
cwd = path.join(home, cwd);
|
|
127
|
+
}
|
|
128
|
+
cwd = path.resolve(cwd);
|
|
129
|
+
// If doesn't exist, try prepending home (catches "/Desktop" → "/Users/ned/Desktop")
|
|
130
|
+
// Uses realpathSync which is case-insensitive on macOS (HFS+/APFS)
|
|
131
|
+
let resolved: string | null = null;
|
|
132
|
+
try {
|
|
133
|
+
resolved = fs.realpathSync(cwd);
|
|
134
|
+
} catch {
|
|
135
|
+
try {
|
|
136
|
+
resolved = fs.realpathSync(path.join(home, cwd));
|
|
137
|
+
} catch { /* neither exists */ }
|
|
138
|
+
}
|
|
139
|
+
if (!resolved) {
|
|
140
|
+
throw new Error(`Directory not found: ${cwd}`);
|
|
141
|
+
}
|
|
142
|
+
cwd = resolved;
|
|
143
|
+
const isHome = cwd === home || cwd.startsWith(home + '/');
|
|
144
|
+
const isTmp = cwd === '/tmp' || cwd.startsWith('/tmp/') || cwd.startsWith('/private/tmp');
|
|
145
|
+
if (!isHome && !isTmp) {
|
|
146
|
+
throw new Error('Directory is outside home');
|
|
147
|
+
}
|
|
148
|
+
const entry = spawnSession(cwd);
|
|
149
|
+
ws.send(JSON.stringify({
|
|
150
|
+
type: 'spawn-result',
|
|
151
|
+
ptyId: entry.ptyId,
|
|
152
|
+
success: true,
|
|
153
|
+
} satisfies WSMessageToClient));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
156
|
+
console.error('[WS] Spawn failed:', error);
|
|
157
|
+
ws.send(JSON.stringify({
|
|
158
|
+
type: 'spawn-result',
|
|
159
|
+
ptyId: '',
|
|
160
|
+
success: false,
|
|
161
|
+
error,
|
|
162
|
+
} satisfies WSMessageToClient));
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'terminal-input': {
|
|
168
|
+
writeToPty(msg.ptyId, msg.data);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'terminal-resize': {
|
|
173
|
+
resizePty(msg.ptyId, msg.cols, msg.rows);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'terminal-subscribe': {
|
|
178
|
+
const meta = ensureMeta(ws);
|
|
179
|
+
const ptyEntry = getPtyEntry(msg.ptyId);
|
|
180
|
+
// If PTY doesn't exist or already exited, notify client immediately
|
|
181
|
+
if (!ptyEntry) {
|
|
182
|
+
ws.send(JSON.stringify({
|
|
183
|
+
type: 'terminal-exited',
|
|
184
|
+
ptyId: msg.ptyId,
|
|
185
|
+
exitCode: -1,
|
|
186
|
+
} satisfies WSMessageToClient));
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
meta.subscriptions.add(msg.ptyId);
|
|
190
|
+
// Resize PTY to match client before sending scrollback
|
|
191
|
+
if (msg.cols && msg.rows) {
|
|
192
|
+
resizePty(msg.ptyId, msg.cols, msg.rows);
|
|
193
|
+
}
|
|
194
|
+
// Send scrollback to this client
|
|
195
|
+
const scrollback = getScrollback(msg.ptyId);
|
|
196
|
+
if (scrollback) {
|
|
197
|
+
ws.send(JSON.stringify({
|
|
198
|
+
type: 'terminal-scrollback',
|
|
199
|
+
ptyId: msg.ptyId,
|
|
200
|
+
data: scrollback,
|
|
201
|
+
} satisfies WSMessageToClient));
|
|
202
|
+
}
|
|
203
|
+
// If already exited, send exit event after scrollback
|
|
204
|
+
if (ptyEntry.exited) {
|
|
205
|
+
ws.send(JSON.stringify({
|
|
206
|
+
type: 'terminal-exited',
|
|
207
|
+
ptyId: msg.ptyId,
|
|
208
|
+
exitCode: ptyEntry.exitCode ?? -1,
|
|
209
|
+
} satisfies WSMessageToClient));
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case 'terminal-unsubscribe': {
|
|
215
|
+
const meta = ensureMeta(ws);
|
|
216
|
+
meta.subscriptions.delete(msg.ptyId);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Send a message only to clients subscribed to a specific ptyId. */
|
|
223
|
+
function sendToSubscribers(ptyId: string, data: WSMessageToClient) {
|
|
224
|
+
const wss = getWSS();
|
|
225
|
+
if (!wss) return;
|
|
226
|
+
const json = JSON.stringify(data);
|
|
227
|
+
const clientMeta = getClientMeta();
|
|
228
|
+
for (const client of wss.clients) {
|
|
229
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
230
|
+
const meta = clientMeta.get(client);
|
|
231
|
+
if (meta?.subscriptions.has(ptyId)) {
|
|
232
|
+
client.send(json);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function hasConnectedClients(): boolean {
|
|
239
|
+
const wss = getWSS();
|
|
240
|
+
if (!wss) return false;
|
|
241
|
+
for (const client of wss.clients) {
|
|
242
|
+
if (client.readyState === WebSocket.OPEN) return true;
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function broadcast(data: WSMessageToClient) {
|
|
248
|
+
const wss = getWSS();
|
|
249
|
+
if (!wss) {
|
|
250
|
+
console.warn('[WS] broadcast called but WSS not initialized');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Strip ptyId from sessions whose PTY no longer exists on server
|
|
254
|
+
if (data.type === 'sessions') {
|
|
255
|
+
data = {
|
|
256
|
+
...data,
|
|
257
|
+
sessions: data.sessions.map(s =>
|
|
258
|
+
s.ptyId && !getPtyEntry(s.ptyId) ? { ...s, ptyId: undefined } : s
|
|
259
|
+
),
|
|
260
|
+
};
|
|
261
|
+
} else if (data.type === 'session-update' && data.session.ptyId && !getPtyEntry(data.session.ptyId)) {
|
|
262
|
+
data = { ...data, session: { ...data.session, ptyId: undefined } };
|
|
263
|
+
}
|
|
264
|
+
const json = JSON.stringify(data);
|
|
265
|
+
for (const client of wss.clients) {
|
|
266
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
267
|
+
client.send(json);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [
|
|
17
|
+
{
|
|
18
|
+
"name": "next"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"paths": {
|
|
22
|
+
"@/*": ["./src/*"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": [
|
|
26
|
+
"next-env.d.ts",
|
|
27
|
+
"**/*.ts",
|
|
28
|
+
"**/*.tsx",
|
|
29
|
+
".next/types/**/*.ts",
|
|
30
|
+
".next/dev/types/**/*.ts",
|
|
31
|
+
"**/*.mts"
|
|
32
|
+
],
|
|
33
|
+
"exclude": ["node_modules"]
|
|
34
|
+
}
|