@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
package/scripts/setup.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
6
|
+
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
7
|
+
const BASE_URL = 'http://localhost:3000/api/hooks';
|
|
8
|
+
const MARKER = 'pixel-office'; // used to identify our hooks
|
|
9
|
+
|
|
10
|
+
// TTY enrichment prefix — detects the terminal device for session linking
|
|
11
|
+
const TTY_PREFIX = [
|
|
12
|
+
'[ "$PIXEL_OFFICE_HAIKU" = "1" ] && exit 0',
|
|
13
|
+
'RAW_TTY=$(ps -o tty= -p $PPID 2>/dev/null | tr -d \' \')',
|
|
14
|
+
'[ -n "$RAW_TTY" ] && [ "$RAW_TTY" != "??" ] && TTY="/dev/$RAW_TTY" || TTY=""',
|
|
15
|
+
'INPUT=$(cat)',
|
|
16
|
+
].join('; ');
|
|
17
|
+
|
|
18
|
+
// Simple prefix — just read stdin and bail if disabled
|
|
19
|
+
const SIMPLE_PREFIX = '[ "$PIXEL_OFFICE_HAIKU" = "1" ] && exit 0; INPUT=$(cat)';
|
|
20
|
+
|
|
21
|
+
function curlCmd(endpoint: string, maxTime: number, enrichTty: boolean): string {
|
|
22
|
+
if (enrichTty) {
|
|
23
|
+
const body = `$(echo "$INPUT" | jq -c --arg tty "$TTY" '. + {tty: $tty}' 2>/dev/null || echo "$INPUT")`;
|
|
24
|
+
return `${TTY_PREFIX}; curl -sf -X POST ${BASE_URL}/${endpoint} -H 'Content-Type: application/json' -d "${body}" --max-time ${maxTime} 2>/dev/null || true`;
|
|
25
|
+
}
|
|
26
|
+
return `${SIMPLE_PREFIX}; curl -sf -X POST ${BASE_URL}/${endpoint} -H 'Content-Type: application/json' -d "$INPUT" --max-time ${maxTime} 2>/dev/null || true`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HookEntry {
|
|
30
|
+
matcher?: string;
|
|
31
|
+
hooks: { type: string; command: string; timeout: number }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PIXEL_OFFICE_HOOKS: Record<string, HookEntry> = {
|
|
35
|
+
SessionStart: {
|
|
36
|
+
hooks: [{ type: 'command', command: curlCmd('session-start', 5, true), timeout: 5 }],
|
|
37
|
+
},
|
|
38
|
+
PreToolUse: {
|
|
39
|
+
hooks: [{ type: 'command', command: curlCmd('pre-tool-use', 30, true), timeout: 30 }],
|
|
40
|
+
},
|
|
41
|
+
PostToolUse: {
|
|
42
|
+
hooks: [{ type: 'command', command: curlCmd('post-tool-use', 5, false), timeout: 5 }],
|
|
43
|
+
},
|
|
44
|
+
Notification: {
|
|
45
|
+
hooks: [{ type: 'command', command: curlCmd('notification', 5, false), timeout: 5 }],
|
|
46
|
+
},
|
|
47
|
+
UserPromptSubmit: {
|
|
48
|
+
hooks: [{ type: 'command', command: curlCmd('user-prompt', 5, false), timeout: 5 }],
|
|
49
|
+
},
|
|
50
|
+
Stop: {
|
|
51
|
+
hooks: [{ type: 'command', command: curlCmd('stop', 5, false), timeout: 5 }],
|
|
52
|
+
},
|
|
53
|
+
SessionEnd: {
|
|
54
|
+
hooks: [{ type: 'command', command: curlCmd('session-end', 5, false), timeout: 5 }],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function readSettings(): Record<string, unknown> {
|
|
59
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
60
|
+
mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeSettings(settings: Record<string, unknown>): void {
|
|
67
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasPixelOfficeHook(entries: HookEntry[]): boolean {
|
|
71
|
+
return entries.some((entry) =>
|
|
72
|
+
entry.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Install ---
|
|
77
|
+
function install(): void {
|
|
78
|
+
const settings = readSettings();
|
|
79
|
+
const hooks = (settings.hooks ?? {}) as Record<string, HookEntry[]>;
|
|
80
|
+
let added = 0;
|
|
81
|
+
|
|
82
|
+
for (const [event, entry] of Object.entries(PIXEL_OFFICE_HOOKS)) {
|
|
83
|
+
const existing = hooks[event] ?? [];
|
|
84
|
+
if (hasPixelOfficeHook(existing)) {
|
|
85
|
+
console.log(` ⏭ ${event} — already registered`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
hooks[event] = [...existing, entry];
|
|
89
|
+
console.log(` ✓ ${event}`);
|
|
90
|
+
added++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
settings.hooks = hooks;
|
|
94
|
+
writeSettings(settings);
|
|
95
|
+
|
|
96
|
+
if (added === 0) {
|
|
97
|
+
console.log('\nAll hooks already registered. Nothing to do.');
|
|
98
|
+
} else {
|
|
99
|
+
console.log(`\n${added} hook(s) added to ${SETTINGS_PATH}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log('\nStart Pixel Office: npm run dev');
|
|
103
|
+
console.log('Open: http://localhost:3000');
|
|
104
|
+
console.log('Then start any Claude Code session — a worker will appear.\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Uninstall ---
|
|
108
|
+
function uninstall(): void {
|
|
109
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
110
|
+
console.log('No settings file found. Nothing to remove.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const settings = readSettings();
|
|
115
|
+
const hooks = (settings.hooks ?? {}) as Record<string, HookEntry[]>;
|
|
116
|
+
let removed = 0;
|
|
117
|
+
|
|
118
|
+
for (const event of Object.keys(hooks)) {
|
|
119
|
+
const before = hooks[event].length;
|
|
120
|
+
hooks[event] = hooks[event].filter(
|
|
121
|
+
(entry) => !entry.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
|
|
122
|
+
);
|
|
123
|
+
const diff = before - hooks[event].length;
|
|
124
|
+
if (diff > 0) {
|
|
125
|
+
console.log(` ✓ ${event} — removed`);
|
|
126
|
+
removed += diff;
|
|
127
|
+
}
|
|
128
|
+
// Clean up empty arrays
|
|
129
|
+
if (hooks[event].length === 0) {
|
|
130
|
+
delete hooks[event];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Object.keys(hooks).length === 0) {
|
|
135
|
+
delete settings.hooks;
|
|
136
|
+
} else {
|
|
137
|
+
settings.hooks = hooks;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
writeSettings(settings);
|
|
141
|
+
|
|
142
|
+
if (removed === 0) {
|
|
143
|
+
console.log('No Pixel Office hooks found. Nothing to remove.');
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`\n${removed} hook(s) removed from ${SETTINGS_PATH}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- CLI ---
|
|
150
|
+
const cmd = process.argv[2];
|
|
151
|
+
|
|
152
|
+
console.log('\n Pixel Office — Hook Manager\n');
|
|
153
|
+
|
|
154
|
+
if (cmd === 'uninstall') {
|
|
155
|
+
uninstall();
|
|
156
|
+
} else {
|
|
157
|
+
install();
|
|
158
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import next from 'next';
|
|
3
|
+
import { initWSS, broadcast } from './src/lib/ws-server';
|
|
4
|
+
import { cleanupStaleSessions, getAllSessions } from './src/lib/store';
|
|
5
|
+
import { getAllPtyEntries, killPty } from './src/lib/pty-manager';
|
|
6
|
+
|
|
7
|
+
// Use globalThis to avoid duplicate intervals across module re-evaluations.
|
|
8
|
+
declare global {
|
|
9
|
+
// eslint-disable-next-line no-var
|
|
10
|
+
var __staleSessionCleanup: ReturnType<typeof setInterval> | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dev = process.env.NODE_ENV !== 'production';
|
|
14
|
+
const port = parseInt(process.env.PORT || '3000', 10);
|
|
15
|
+
|
|
16
|
+
const app = next({ dev });
|
|
17
|
+
const handle = app.getRequestHandler();
|
|
18
|
+
|
|
19
|
+
app.prepare().then(() => {
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
handle(req, res);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
initWSS(server);
|
|
25
|
+
|
|
26
|
+
// Stale session cleanup is a safety net only — normal cleanup happens via
|
|
27
|
+
// SessionEnd hooks when terminals close. This catches crashed/force-killed
|
|
28
|
+
// sessions that never sent SessionEnd. 30 min timeout, checks every 5 min.
|
|
29
|
+
if (!globalThis.__staleSessionCleanup) {
|
|
30
|
+
globalThis.__staleSessionCleanup = setInterval(() => {
|
|
31
|
+
const isAlivePty = (ptyId: string) => { const e = getAllPtyEntries().find(p => p.ptyId === ptyId); return !!e && !e.exited; };
|
|
32
|
+
const removed = cleanupStaleSessions(30 * 60_000, isAlivePty);
|
|
33
|
+
for (const sessionId of removed) {
|
|
34
|
+
console.log(`[cleanup] Removed stale session: ${sessionId}`);
|
|
35
|
+
broadcast({ type: 'session-remove', sessionId });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Clean up orphaned PTYs — exited PTYs with no active session
|
|
39
|
+
// Don't kill live unlinked PTYs — they may be waiting for re-link after /clear
|
|
40
|
+
const activePtyIds = new Set(getAllSessions().filter(s => s.ptyId).map(s => s.ptyId!));
|
|
41
|
+
for (const entry of getAllPtyEntries()) {
|
|
42
|
+
if (!activePtyIds.has(entry.ptyId) && entry.exited) {
|
|
43
|
+
killPty(entry.ptyId);
|
|
44
|
+
console.log(`[cleanup] Removed orphaned PTY: ${entry.ptyId}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}, 5 * 60_000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
server.listen(port, () => {
|
|
51
|
+
console.log(`> Pixel Office ready on http://localhost:${port}`);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { getSession } from '@/lib/store';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const body = await req.json();
|
|
7
|
+
const { sessionId } = body;
|
|
8
|
+
|
|
9
|
+
if (!sessionId) {
|
|
10
|
+
return NextResponse.json({ error: 'Missing sessionId' }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const session = getSession(sessionId);
|
|
14
|
+
if (!session) {
|
|
15
|
+
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tty = session.tty; // e.g. /dev/ttys003
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
if (tty) {
|
|
22
|
+
// Match iTerm2 session by tty — this is the exact terminal
|
|
23
|
+
const script = `
|
|
24
|
+
tell application "iTerm2"
|
|
25
|
+
activate
|
|
26
|
+
repeat with w in windows
|
|
27
|
+
repeat with t in tabs of w
|
|
28
|
+
repeat with s in sessions of t
|
|
29
|
+
set sessionTTY to tty of s
|
|
30
|
+
if sessionTTY is "${tty}" then
|
|
31
|
+
select t
|
|
32
|
+
select s
|
|
33
|
+
set index of w to 1
|
|
34
|
+
return "found"
|
|
35
|
+
end if
|
|
36
|
+
end repeat
|
|
37
|
+
end repeat
|
|
38
|
+
end repeat
|
|
39
|
+
end tell
|
|
40
|
+
return "not_found"
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const result = execSync('osascript -', {
|
|
44
|
+
input: script,
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 3000,
|
|
47
|
+
}).trim();
|
|
48
|
+
|
|
49
|
+
if (result === 'found') {
|
|
50
|
+
return NextResponse.json({ ok: true, cwd: session.cwd, tty, matched: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback: just activate iTerm2
|
|
55
|
+
execSync('osascript -e \'tell application "iTerm2" to activate\'', { timeout: 2000 });
|
|
56
|
+
return NextResponse.json({ ok: true, cwd: session.cwd, tty, matched: false });
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
execSync('osascript -e \'tell application "iTerm2" to activate\'', { timeout: 2000 });
|
|
60
|
+
} catch {
|
|
61
|
+
// iTerm2 not running
|
|
62
|
+
}
|
|
63
|
+
return NextResponse.json({ ok: true, cwd: session.cwd, tty, matched: false });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSession, updateSession } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const body = await req.json();
|
|
7
|
+
const sessionId = body.session_id;
|
|
8
|
+
const message = body.message || body.notification || '';
|
|
9
|
+
|
|
10
|
+
// Notification = Claude is asking the user something, worker goes idle
|
|
11
|
+
const session = updateSession(sessionId, 'idle', null);
|
|
12
|
+
if (session) {
|
|
13
|
+
broadcast({ type: 'session-update', session });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
broadcast({ type: 'notification', sessionId, message });
|
|
17
|
+
|
|
18
|
+
return NextResponse.json({ status: 'ok' });
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSession, addSession, updateSession } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const body = await req.json();
|
|
7
|
+
const sessionId = body.session_id;
|
|
8
|
+
const cwd = body.cwd || '';
|
|
9
|
+
|
|
10
|
+
// Auto-create session if it doesn't exist (e.g. session-start was missed)
|
|
11
|
+
if (!getSession(sessionId)) {
|
|
12
|
+
addSession(sessionId, cwd);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Don't set idle immediately — the next pre-tool-use will come within ms
|
|
16
|
+
// for rapid tool calls. Instead, just update lastSeen and let the client
|
|
17
|
+
// handle the idle transition after a timeout.
|
|
18
|
+
const session = getSession(sessionId);
|
|
19
|
+
if (session) {
|
|
20
|
+
session.lastSeen = Date.now();
|
|
21
|
+
session.currentTool = null;
|
|
22
|
+
broadcast({ type: 'session-update', session });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return NextResponse.json({ status: 'ok' });
|
|
26
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { getSession, addSession, updateSession, updateSessionTty, addToolCall, setSessionPlanMode, setSessionTask, setSessionFocus } from '@/lib/store';
|
|
4
|
+
import { classifyTool } from '@/lib/tool-classifier';
|
|
5
|
+
import { createApproval } from '@/lib/approval-queue';
|
|
6
|
+
import { broadcast, hasConnectedClients } from '@/lib/ws-server';
|
|
7
|
+
import { readTaskFromTranscript, readLatestAssistantMessage } from '@/lib/transcript';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a focus title from Claude's assistant response text.
|
|
11
|
+
* Claude's first sentence typically states what it's going to do:
|
|
12
|
+
* "Let me fix the heuristic extraction..." → "Fix the heuristic extraction"
|
|
13
|
+
* "I'll update the WorkerPanel component" → "Update the WorkerPanel component"
|
|
14
|
+
*/
|
|
15
|
+
/** Action verbs — things Claude says it's DOING. Matches verb roots + suffixes (fixing, edited, etc). */
|
|
16
|
+
const ACTION_RE = /\b(fix|add|remove|delet|creat|updat|build|clean|mak|implement|refactor|debug|check|test|writ|mov|renam|chang|set|configur|deploy|push|install|upgrad|migrat|convert|pars|extract|handl|show|hid|enabl|disabl|run|start|stop|appl|us|open|clos|review|audit|verif|ensur|improv|optimiz|rewrit|redesign|simplif|merg|split|connect|wir|hook|scaffold|setup|integrat|strip|display|render|put|read|edit|search|reload|restart|clear|address|increas|bump|simulat|forc)\w*\b/i;
|
|
17
|
+
|
|
18
|
+
function extractFocusFromAssistant(text: string): string | null {
|
|
19
|
+
// Split into sentences (by newlines and punctuation), scan first ~8
|
|
20
|
+
const sentences = text.split(/(?<=[.!?\n])\s+/).slice(0, 8);
|
|
21
|
+
|
|
22
|
+
const STRIP = [
|
|
23
|
+
/^(let me|I'll|I will|I'm going to|I need to|I want to|I should|I can)\s+/i,
|
|
24
|
+
/^(now |first |here's what|okay |ok |sure |right |alright |also )/i,
|
|
25
|
+
/^(let's |we'll |we need to |we should )/i,
|
|
26
|
+
/^(you're right\.?\s*)/i,
|
|
27
|
+
/^(good news:?\s*)/i,
|
|
28
|
+
/^(I'?m sorry\.?\s*)/i,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Skip patterns — observations, not actions
|
|
32
|
+
const SKIP = /^(here|the |this |that |there |it |I see|I can see|looking at|based on|but |so |and |two |one |a |an |for |with |since |because |if |when |after |before |no |yes |not )/i;
|
|
33
|
+
|
|
34
|
+
for (const sent of sentences) {
|
|
35
|
+
let s = sent.trim();
|
|
36
|
+
if (s.length < 10) continue;
|
|
37
|
+
if (SKIP.test(s) && !ACTION_RE.test(s.slice(0, 40))) continue;
|
|
38
|
+
|
|
39
|
+
// Strip assistant-style prefixes
|
|
40
|
+
for (const p of STRIP) {
|
|
41
|
+
s = s.replace(p, '');
|
|
42
|
+
}
|
|
43
|
+
s = s.trim();
|
|
44
|
+
|
|
45
|
+
if (s.length < 12) continue;
|
|
46
|
+
// After stripping, must contain an action verb
|
|
47
|
+
if (!ACTION_RE.test(s.slice(0, 50))) continue;
|
|
48
|
+
|
|
49
|
+
// Clean up
|
|
50
|
+
s = s.replace(/[.!:]+$/, '').replace(/\s*[—–\-]\s*$/, '').trim();
|
|
51
|
+
|
|
52
|
+
// Capitalize
|
|
53
|
+
if (s.length > 0) s = s[0].toUpperCase() + s.slice(1);
|
|
54
|
+
|
|
55
|
+
if (s.length < 10) continue;
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function POST(req: NextRequest) {
|
|
63
|
+
const body = await req.json();
|
|
64
|
+
const sessionId = body.session_id;
|
|
65
|
+
const toolName = body.tool_name || 'Unknown';
|
|
66
|
+
const toolInput = body.tool_input || {};
|
|
67
|
+
const cwd = body.cwd || '';
|
|
68
|
+
const tty = body.tty || '';
|
|
69
|
+
const transcriptPath = body.transcript_path || undefined;
|
|
70
|
+
|
|
71
|
+
// Auto-create session if it doesn't exist (e.g. session-start was missed)
|
|
72
|
+
if (!getSession(sessionId)) {
|
|
73
|
+
addSession(sessionId, cwd, tty, transcriptPath);
|
|
74
|
+
} else if (tty) {
|
|
75
|
+
updateSessionTty(sessionId, tty);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback: if session has no task yet and transcript_path exists, try reading it
|
|
79
|
+
const existingSession = getSession(sessionId);
|
|
80
|
+
if (existingSession && !existingSession.task && (transcriptPath || existingSession.transcriptPath)) {
|
|
81
|
+
const tp = transcriptPath || existingSession.transcriptPath;
|
|
82
|
+
if (tp) {
|
|
83
|
+
if (!existingSession.transcriptPath) existingSession.transcriptPath = tp;
|
|
84
|
+
readTaskFromTranscript(tp).then((task) => {
|
|
85
|
+
if (task) {
|
|
86
|
+
const updated = setSessionTask(sessionId, task);
|
|
87
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
88
|
+
}
|
|
89
|
+
}).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If UserPromptSubmit flagged a focus update, read the latest assistant message
|
|
94
|
+
if (existingSession?.needsFocusUpdate && (transcriptPath || existingSession.transcriptPath)) {
|
|
95
|
+
existingSession.needsFocusUpdate = false;
|
|
96
|
+
const tp = transcriptPath || existingSession.transcriptPath;
|
|
97
|
+
if (tp) {
|
|
98
|
+
readLatestAssistantMessage(tp).then((text) => {
|
|
99
|
+
if (!text) return;
|
|
100
|
+
const focus = extractFocusFromAssistant(text);
|
|
101
|
+
if (focus) {
|
|
102
|
+
const updated = setSessionFocus(sessionId, focus);
|
|
103
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle plan mode transitions
|
|
110
|
+
if (toolName === 'EnterPlanMode') {
|
|
111
|
+
const updated = setSessionPlanMode(sessionId, true);
|
|
112
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
113
|
+
return NextResponse.json({
|
|
114
|
+
hookSpecificOutput: {
|
|
115
|
+
hookEventName: 'PreToolUse',
|
|
116
|
+
permissionDecision: 'allow',
|
|
117
|
+
permissionDecisionReason: 'Auto-approved by Pixel Office',
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (toolName === 'ExitPlanMode') {
|
|
122
|
+
const updated = setSessionPlanMode(sessionId, false);
|
|
123
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
124
|
+
return NextResponse.json({
|
|
125
|
+
hookSpecificOutput: {
|
|
126
|
+
hookEventName: 'PreToolUse',
|
|
127
|
+
permissionDecision: 'allow',
|
|
128
|
+
permissionDecisionReason: 'Auto-approved by Pixel Office',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Record the tool call before classification
|
|
134
|
+
addToolCall(sessionId, toolName, toolInput);
|
|
135
|
+
|
|
136
|
+
const { state, needsApproval } = classifyTool(toolName, toolInput);
|
|
137
|
+
|
|
138
|
+
// Update worker state
|
|
139
|
+
const session = updateSession(sessionId, state, toolName);
|
|
140
|
+
if (session) {
|
|
141
|
+
broadcast({ type: 'session-update', session });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!needsApproval) {
|
|
145
|
+
return NextResponse.json({
|
|
146
|
+
hookSpecificOutput: {
|
|
147
|
+
hookEventName: 'PreToolUse',
|
|
148
|
+
permissionDecision: 'allow',
|
|
149
|
+
permissionDecisionReason: 'Auto-approved by Pixel Office',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// No browser connected — fall through so Claude Code handles approval in terminal
|
|
155
|
+
if (!hasConnectedClients()) {
|
|
156
|
+
console.log(`[Hook] No browser connected, falling through for ${toolName}`);
|
|
157
|
+
return NextResponse.json({});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Play notification sound so the boss knows
|
|
161
|
+
try {
|
|
162
|
+
execSync('afplay /System/Library/Sounds/Bottle.aiff &', { timeout: 1000 });
|
|
163
|
+
} catch { /* ignore */ }
|
|
164
|
+
|
|
165
|
+
// Needs approval — block until boss decides in the browser
|
|
166
|
+
const { approval, promise } = createApproval(sessionId, toolName, toolInput);
|
|
167
|
+
|
|
168
|
+
broadcast({
|
|
169
|
+
type: 'approval-request',
|
|
170
|
+
approval: {
|
|
171
|
+
id: approval.id,
|
|
172
|
+
sessionId: approval.sessionId,
|
|
173
|
+
toolName: approval.toolName,
|
|
174
|
+
toolInput: approval.toolInput,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
console.log(`[Hook] Awaiting approval for ${toolName} in session ${sessionId}`);
|
|
179
|
+
const decision = await promise;
|
|
180
|
+
console.log(`[Hook] Decision for ${toolName}: ${decision}`);
|
|
181
|
+
|
|
182
|
+
return NextResponse.json({
|
|
183
|
+
hookSpecificOutput: {
|
|
184
|
+
hookEventName: 'PreToolUse',
|
|
185
|
+
permissionDecision: decision,
|
|
186
|
+
permissionDecisionReason: decision === 'allow' ? 'Boss approved' : 'Boss denied',
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSession, removeSession, updateSession } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
import { unlinkSessionFromPty, getPtyEntry, killPty } from '@/lib/pty-manager';
|
|
5
|
+
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
const body = await req.json();
|
|
8
|
+
const sessionId = body.session_id;
|
|
9
|
+
|
|
10
|
+
const session = getSession(sessionId);
|
|
11
|
+
if (session?.ptyId) {
|
|
12
|
+
const ptyEntry = getPtyEntry(session.ptyId);
|
|
13
|
+
if (ptyEntry && !ptyEntry.exited) {
|
|
14
|
+
// PTY is still alive — keep the session but mark it idle and unlink.
|
|
15
|
+
// The terminal is still usable (user can type new prompts).
|
|
16
|
+
// A new session-start will re-link when Claude restarts.
|
|
17
|
+
unlinkSessionFromPty(session.ptyId);
|
|
18
|
+
const updated = updateSession(sessionId, 'idle', null);
|
|
19
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
20
|
+
console.log(`[Hook] Session ended (PTY alive, keeping): ${sessionId}`);
|
|
21
|
+
return NextResponse.json({ status: 'ok' });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// No PTY or PTY already exited — fully remove
|
|
26
|
+
if (session?.ptyId) killPty(session.ptyId);
|
|
27
|
+
removeSession(sessionId);
|
|
28
|
+
broadcast({ type: 'session-remove', sessionId });
|
|
29
|
+
console.log(`[Hook] Session ended: ${sessionId}`);
|
|
30
|
+
return NextResponse.json({ status: 'ok' });
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { addSession, getAllSessions, removeSession, setSessionTask } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
import { readTaskFromTranscript } from '@/lib/transcript';
|
|
5
|
+
import { findPtyByTty, findPtyByCwd, linkSessionToPty } from '@/lib/pty-manager';
|
|
6
|
+
|
|
7
|
+
export async function POST(req: NextRequest) {
|
|
8
|
+
const body = await req.json();
|
|
9
|
+
const sessionId = body.session_id;
|
|
10
|
+
const cwd = body.cwd || '';
|
|
11
|
+
const tty = body.tty || '';
|
|
12
|
+
const transcriptPath = body.transcript_path || undefined;
|
|
13
|
+
|
|
14
|
+
const session = addSession(sessionId, cwd, tty, transcriptPath);
|
|
15
|
+
|
|
16
|
+
// Link to embedded PTY — try tty match first, then cwd match as fallback
|
|
17
|
+
let ptyEntry = tty ? findPtyByTty(tty) : undefined;
|
|
18
|
+
if (!ptyEntry && cwd) {
|
|
19
|
+
ptyEntry = findPtyByCwd(cwd);
|
|
20
|
+
}
|
|
21
|
+
if (ptyEntry) {
|
|
22
|
+
linkSessionToPty(sessionId, ptyEntry.ptyId);
|
|
23
|
+
session.ptyId = ptyEntry.ptyId;
|
|
24
|
+
// Remove orphan sessions from previous /clear cycles on the same PTY
|
|
25
|
+
for (const s of getAllSessions()) {
|
|
26
|
+
if (s.sessionId !== sessionId && s.ptyId === ptyEntry.ptyId) {
|
|
27
|
+
removeSession(s.sessionId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
broadcast({ type: 'sessions', sessions: getAllSessions() });
|
|
33
|
+
|
|
34
|
+
console.log(`[Hook] Session started: ${sessionId}`);
|
|
35
|
+
|
|
36
|
+
// Async: read the task from the JSONL transcript (don't block the hook response)
|
|
37
|
+
if (transcriptPath) {
|
|
38
|
+
readTaskFromTranscript(transcriptPath).then((task) => {
|
|
39
|
+
if (task) {
|
|
40
|
+
const updated = setSessionTask(sessionId, task);
|
|
41
|
+
if (updated) broadcast({ type: 'session-update', session: updated });
|
|
42
|
+
}
|
|
43
|
+
}).catch(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return NextResponse.json({ status: 'ok' });
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSession, addSession, updateSession } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const body = await req.json();
|
|
7
|
+
const sessionId = body.session_id;
|
|
8
|
+
|
|
9
|
+
if (!getSession(sessionId)) {
|
|
10
|
+
addSession(sessionId, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const session = updateSession(sessionId, 'idle', null);
|
|
14
|
+
if (session) {
|
|
15
|
+
broadcast({ type: 'session-update', session });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return NextResponse.json({ status: 'ok' });
|
|
19
|
+
}
|