@neolio42/pixel-office 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin.sh +16 -0
  4. package/bin.ts +162 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +7 -0
  7. package/package.json +51 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/assets/characters/char_0.png +0 -0
  10. package/public/assets/characters/char_1.png +0 -0
  11. package/public/assets/characters/char_2.png +0 -0
  12. package/public/assets/characters/char_3.png +0 -0
  13. package/public/assets/characters/char_4.png +0 -0
  14. package/public/assets/characters/char_5.png +0 -0
  15. package/public/assets/characters.png +0 -0
  16. package/public/assets/default-layout-1.json +92 -0
  17. package/public/assets/floors/floor_0.png +0 -0
  18. package/public/assets/floors/floor_1.png +0 -0
  19. package/public/assets/floors/floor_2.png +0 -0
  20. package/public/assets/floors/floor_3.png +0 -0
  21. package/public/assets/floors/floor_4.png +0 -0
  22. package/public/assets/floors/floor_5.png +0 -0
  23. package/public/assets/floors/floor_6.png +0 -0
  24. package/public/assets/floors/floor_7.png +0 -0
  25. package/public/assets/floors/floor_8.png +0 -0
  26. package/public/assets/furniture/BIN/BIN.png +0 -0
  27. package/public/assets/furniture/BIN/manifest.json +13 -0
  28. package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
  29. package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
  30. package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
  31. package/public/assets/furniture/CACTUS/manifest.json +13 -0
  32. package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
  33. package/public/assets/furniture/CLOCK/manifest.json +13 -0
  34. package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
  35. package/public/assets/furniture/COFFEE/manifest.json +13 -0
  36. package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
  37. package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
  38. package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
  39. package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
  40. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
  44. package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
  45. package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
  46. package/public/assets/furniture/DESK/manifest.json +33 -0
  47. package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
  48. package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
  49. package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
  50. package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
  51. package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
  52. package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
  53. package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
  54. package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
  55. package/public/assets/furniture/PC/PC_BACK.png +0 -0
  56. package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
  57. package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
  58. package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
  59. package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
  60. package/public/assets/furniture/PC/PC_SIDE.png +0 -0
  61. package/public/assets/furniture/PC/manifest.json +88 -0
  62. package/public/assets/furniture/PLANT/PLANT.png +0 -0
  63. package/public/assets/furniture/PLANT/manifest.json +13 -0
  64. package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
  65. package/public/assets/furniture/PLANT_2/manifest.json +13 -0
  66. package/public/assets/furniture/POT/POT.png +0 -0
  67. package/public/assets/furniture/POT/manifest.json +13 -0
  68. package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
  69. package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
  70. package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
  71. package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
  72. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
  73. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
  74. package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
  75. package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
  76. package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
  77. package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
  78. package/public/assets/furniture/SOFA/manifest.json +44 -0
  79. package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
  80. package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
  81. package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
  82. package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
  83. package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
  84. package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
  85. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
  86. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
  87. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
  88. package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
  89. package/public/assets/walls/wall_0.png +0 -0
  90. package/scripts/setup.ts +158 -0
  91. package/server.ts +53 -0
  92. package/src/app/api/focus-terminal/route.ts +65 -0
  93. package/src/app/api/hooks/notification/route.ts +19 -0
  94. package/src/app/api/hooks/post-tool-use/route.ts +26 -0
  95. package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
  96. package/src/app/api/hooks/session-end/route.ts +31 -0
  97. package/src/app/api/hooks/session-start/route.ts +47 -0
  98. package/src/app/api/hooks/stop/route.ts +19 -0
  99. package/src/app/api/hooks/user-prompt/route.ts +92 -0
  100. package/src/app/favicon.ico +0 -0
  101. package/src/app/globals.css +14 -0
  102. package/src/app/layout.tsx +21 -0
  103. package/src/app/page.tsx +5 -0
  104. package/src/components/ApprovalToast.tsx +132 -0
  105. package/src/components/OfficeCanvas.tsx +311 -0
  106. package/src/components/Terminal.tsx +177 -0
  107. package/src/components/TerminalTile.tsx +181 -0
  108. package/src/components/WorkerPanel.tsx +261 -0
  109. package/src/components/WorkerPopup.tsx +116 -0
  110. package/src/game/asset-loader.ts +172 -0
  111. package/src/game/office-layout.ts +287 -0
  112. package/src/game/renderer.ts +369 -0
  113. package/src/game/sprites.ts +133 -0
  114. package/src/game/worker-entity.ts +219 -0
  115. package/src/hooks/usePixelOffice.ts +318 -0
  116. package/src/hooks/useRecentCwds.ts +27 -0
  117. package/src/lib/approval-queue.ts +67 -0
  118. package/src/lib/pty-manager.ts +267 -0
  119. package/src/lib/store.ts +181 -0
  120. package/src/lib/tool-classifier.ts +224 -0
  121. package/src/lib/transcript.ts +109 -0
  122. package/src/lib/types.ts +58 -0
  123. package/src/lib/ws-server.ts +270 -0
  124. package/tsconfig.json +34 -0
@@ -0,0 +1,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
+ }