@office-kit/pptx-dev 0.4.2 → 0.6.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/README.md CHANGED
@@ -50,7 +50,7 @@ latency. The deck is still evaluated in a fresh worker on every build. Changes t
50
50
  shared resources, including themes, relationships, charts and images, redraw all
51
51
  slides. Update time depends on the deck size and any code it runs.
52
52
 
53
- ## Chat in the preview
53
+ ## Agents in the preview
54
54
 
55
55
  The right panel defaults to **Claude Code**. Click **Start** to open the locally
56
56
  installed `claude` CLI in an interactive terminal. Install and sign in to Claude
@@ -75,19 +75,32 @@ CLI. Navigation while Claude is replying does not change that turn's context.
75
75
  Stale input is rejected with a retry message. If your Claude settings or managed
76
76
  policy disable hooks, automatic context attachment is unavailable; enable the
77
77
  preview hook in Claude Code to use it. No project settings file is rewritten.
78
+
79
+ Use **Shift+Enter** to insert a newline in the embedded Claude prompt. On completion,
80
+ the preview checks the actual deck build. Build errors are returned to Claude's
81
+ Stop hook or to the Codex conversation for up to three repair attempts per user
82
+ request. If repair fails, the error stays visible and the last successful preview
83
+ is retained. Stopping the agent cancels further repair attempts.
84
+
85
+ Both agents receive guidance to check text against its actual background, including
86
+ inherited theme colors, and to use native `Text` paragraphs or mapped rows for lists
87
+ and agendas. `Bullets` and `TableOfContents` are not built-in DSL components; define
88
+ or import project helpers if using those names. See the
89
+ [TSX authoring reference](../../skill/references/tsx.md#lists-and-tables-of-contents)
90
+ for a complete agenda example. Contrast guidance still requires visual review;
91
+ a successful build is not a visual accessibility check.
78
92
  Dependency paths are source candidates, not an exact slide-to-file mapping.
79
93
  “This slide” requests a focused patch; requests about another slide or the
80
94
  whole deck can edit other files or shared styling.
81
95
 
82
96
  Reloading the browser reconnects to the same process and replays its recent
83
- terminal output (up to 2 MB). One browser tab controls the session; other tabs
84
- can view it. Keep the owning tab open, or end the session there before moving to
85
- another tab. **End session** stops the process; saved edits remain. Start opens
97
+ terminal output (up to 2 MB). Each pane has its own session and terminal controls. Keep the owning tab open
98
+ while agents are working. **End session** stops the process; saved edits remain. Start opens
86
99
  a new session, and Claude's `/resume` command can restore an earlier conversation.
87
- Stopping the dev server also stops the embedded session. This is independent of
100
+ Stopping the dev server also stops all embedded sessions. This is independent of
88
101
  the Claude Code conversation that may have launched your dev server.
89
102
 
90
- Select **Codex** while Claude Code is stopped to use the existing message-based
103
+ Select **Codex** while Claude Code in that pane is stopped to use the existing message-based
91
104
  chat. Codex replies render Markdown (including lists, code blocks and tables);
92
105
  raw HTML, unsafe links and remote images are not rendered. Codex still uses
93
106
  `exec --json` with the `workspace-write` sandbox and its installed CLI login.
@@ -98,16 +111,25 @@ each request. The dev server retains up to 100 messages until it exits.
98
111
  sends a message. Model/settings menus in the embedded terminal are currently
99
112
  specific to Claude Code.
100
113
 
114
+ Use the **Split right** or **Split down** icons in a pane's title bar to run up to
115
+ four agents side by side or stacked. Each pane independently selects Claude Code
116
+ or Codex, retains its own conversation and receives the currently selected slide.
117
+ Drag the divider to adjust the split, or focus it and use the arrow keys. Layout
118
+ and Claude terminal sessions survive a browser reload. Splitting preserves running
119
+ sessions and unsent drafts. **Close** ends only that pane's agent; closing the last
120
+ pane opens a fresh one. All agents edit the same project directory, so assign
121
+ separate slides or files when working simultaneously.
122
+
101
123
  Drag the left edge of the chat panel to adjust its width. The preview remembers the
102
124
  width in this browser; double-click the edge to reset it. You can also focus the
103
125
  edge and use the left/right arrow keys (Shift for larger steps), or Home/End for
104
126
  the minimum/maximum width. Narrow screens keep the stacked layout.
105
127
 
106
- Use **Chat** in the header to hide/show the panel. On narrow screens the panel
128
+ Use **Agents** in the header to hide/show the panel. On narrow screens the panel
107
129
  moves below the preview. Presentation mode hides it without ending the session.
108
130
  Both providers save TSX directly, which triggers the usual automatic rebuild.
109
131
  The server binds to loopback and requires same-origin JSON for terminal control;
110
- the local context hook uses a separate per-server token.
132
+ the local context hook uses a separate per-session token.
111
133
 
112
134
  The terminal uses `node-pty` and xterm.js. Linux systems without a matching PTY
113
135
  prebuild need the native build prerequisites documented by
package/dist/cli.mjs CHANGED
@@ -8,38 +8,32 @@ import { createServer } from "node:http";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import MarkdownIt from "markdown-it";
10
10
  import { spawn } from "node:child_process";
11
- //#region src/page.ts
12
- const page = `<!doctype html>
13
- <html lang="en">
14
- <meta charset="utf-8"><meta name="viewport" content="width=device-width">
15
- <title>Office Kit — PowerPoint preview</title>
16
- <link rel="stylesheet" href="/terminal.css">
17
- <style>
18
- *{box-sizing:border-box}
19
- body{margin:0;height:100dvh;overflow:hidden;background:#e9ecf1;color:#202735;font:13px system-ui;display:grid;grid-template-rows:60px minmax(0,1fr) 42px}
20
- button,a,select{font:inherit;color:inherit}button,select,.download{border:1px solid #d4d9e2;border-radius:6px;background:#fff;padding:7px 12px;text-decoration:none;cursor:pointer}
21
- button:hover:not(:disabled),.download:hover{background:#f0f3f9}button:disabled{opacity:.4;cursor:default}
22
- :focus-visible{outline:2px solid #4967dd;outline-offset:3px}
23
- header{display:flex;align-items:center;gap:16px;padding:0 20px;background:#fff;border-bottom:1px solid #d4d9e2;min-width:0}
24
- .brand{font-weight:700;font-size:17px;white-space:nowrap}.badge{font-size:11px;color:#616b7c;background:#f1f3f7;border-radius:4px;padding:3px 6px}
25
- #status{flex:1;color:#667085;min-width:0}#present{background:#293c73;color:#fff;border-color:#293c73}
11
+ //#region src/styles.ts
12
+ const previewStyles = `*{box-sizing:border-box}
13
+ body{margin:0;height:100dvh;overflow:hidden;background:#e9eaf4;color:#dce1f1;font:13px system-ui;display:grid;grid-template-rows:60px minmax(0,1fr) 42px}
14
+ button,a,select{font:inherit;color:inherit}button,select,.download{border:1px solid #30364d;border-radius:9px;background:#191e30;padding:7px 12px;text-decoration:none;cursor:pointer}
15
+ button:hover:not(:disabled),.download:hover{background:#292f48}button:disabled{opacity:.4;cursor:default}
16
+ :focus-visible{outline:2px solid #9b87ff;outline-offset:3px}
17
+ header{display:flex;align-items:center;gap:16px;padding:0 20px;background:linear-gradient(110deg,#171b2f,#211c38 60%,#171b2f);border-bottom:1px solid #38304d;min-width:0}
18
+ .brand{font-weight:700;font-size:17px;white-space:nowrap}.badge{font-size:11px;color:#b2a4fb;background:#342c54;border-radius:4px;padding:3px 6px}
19
+ #status{flex:1;color:#9ba5be;min-width:0}#present{background:#7154e8;color:#191e30;border-color:#7154e8}
26
20
  .workspace{--filmstrip-width:190px;--chat-width:38vw;display:grid;grid-template-columns:var(--filmstrip-width) minmax(0,1fr) clamp(280px,var(--chat-width),calc(100vw - var(--filmstrip-width) - 240px));min-height:0}
27
- .filmstrip{background:#f7f8fa;border-right:1px solid #d4d9e2;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
28
- .filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#7c8596;font-weight:600}
21
+ .filmstrip{background:#111625;border-right:1px solid #30364d;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
22
+ .filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#8490ac;font-weight:600}
29
23
  #thumbnails{display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
30
24
  .thumbnail{display:flex;align-items:flex-start;gap:8px;width:100%;padding:4px 3px;border:0;background:transparent;border-radius:5px;text-align:left}
31
- .slide-number{width:18px;flex:none;text-align:right;font-size:11px;color:#737d8e;padding-top:5px}
32
- .thumbnail img{display:block;min-width:0;width:calc(100% - 26px);background:white;box-shadow:0 1px 4px #19212d18;border:2px solid transparent;border-radius:3px;aspect-ratio:var(--slide-ratio,16/9);object-fit:contain}
33
- .thumbnail[aria-current="true"]{background:#e7edff}.thumbnail[aria-current="true"] img{border-color:#4967dd}.thumbnail[aria-current="true"] .slide-number{color:#3654c1;font-weight:700}
25
+ .slide-number{width:18px;flex:none;text-align:right;font-size:11px;color:#8995b0;padding-top:5px}
26
+ .thumbnail img{display:block;min-width:0;width:calc(100% - 26px);background:white;box-shadow:0 1px 4px #080b1933;border:2px solid transparent;border-radius:3px;aspect-ratio:var(--slide-ratio,16/9);object-fit:contain}
27
+ .thumbnail[aria-current="true"]{background:#31274e}.thumbnail[aria-current="true"] img{border-color:#9b87ff}.thumbnail[aria-current="true"] .slide-number{color:#b6a5ff;font-weight:700}
34
28
  main{min-width:0;min-height:0;display:flex;flex-direction:column}
35
29
  #error{flex:none;max-height:30%;overflow:auto;background:#fff0ef;color:#922e25;margin:0;padding:16px 20px;white-space:pre-wrap;border-bottom:1px solid #e8bbb7}
36
30
  #error[hidden]{display:none}
37
- #stage{flex:1;min-height:0;overflow:auto;display:flex;padding:32px;overscroll-behavior:contain}
38
- #slide{position:relative;flex:none;margin:auto;background:white;box-shadow:0 3px 24px #19212d20;overflow:hidden}
31
+ #stage{background:radial-gradient(ellipse at 20% 15%,#ddd8f6aa,transparent 65%),radial-gradient(#a0a4bf33 1px,transparent 1px),#edeef6;background-size:auto,18px 18px,auto;flex:1;min-height:0;overflow:auto;display:flex;padding:32px;overscroll-behavior:contain}
32
+ #slide{position:relative;flex:none;margin:auto;background:white;box-shadow:0 20px 70px #28234825,0 2px 8px #28234815;overflow:hidden}
39
33
  #slide{user-select:text;-webkit-user-select:text}
40
- #empty{margin:auto;color:#737d8e}
41
- footer{display:flex;align-items:center;gap:14px;padding:0 16px;background:#fff;border-top:1px solid #d4d9e2;font-size:12px}
42
- #count{min-width:90px}footer .hint{flex:1;color:#737d8e}footer button{padding:3px 10px}footer select{padding:3px 8px}
34
+ #empty{margin:auto;color:#8995b0}
35
+ footer{display:flex;align-items:center;gap:14px;padding:0 16px;background:#191e30;border-top:1px solid #30364d;font-size:12px}
36
+ #count{min-width:90px}footer .hint{flex:1;color:#8995b0}footer button{padding:3px 10px}footer select{padding:3px 8px}
43
37
  #presentation-controls{display:none}
44
38
  body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
45
39
  .presenting header,.presenting footer,.presenting .filmstrip,.presenting #chat{display:none}
@@ -48,44 +42,50 @@ body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
48
42
  .presenting #presentation-controls:hover,.presenting #presentation-controls:focus-within{opacity:1}
49
43
  #presentation-controls button{background:transparent;color:white;border-color:#5d6575}
50
44
  @media(max-width:700px){.workspace{grid-template-columns:140px minmax(0,1fr)}.filmstrip{padding:12px 5px}header{padding:0 12px;gap:10px}.badge,footer .hint{display:none}#stage{padding:16px}footer{gap:8px}#status{font-size:11px}.download{padding:7px 8px}}
51
- #chat{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column;background:#fff;border-left:1px solid #d4d9e2}
52
- #chat-resizer{position:absolute;left:-5px;top:0;bottom:0;width:10px;z-index:3;cursor:col-resize;touch-action:none}#chat-resizer::after{content:"";position:absolute;left:4px;top:0;bottom:0;width:2px}#chat-resizer:hover::after,#chat-resizer:focus-visible::after,.resizing-chat #chat-resizer::after{background:#4967dd}body.resizing-chat,body.resizing-chat *{cursor:col-resize!important;user-select:none!important}
53
- .chat-heading{display:flex;align-items:center;gap:8px;padding:16px;border-bottom:1px solid #e6e9ef}.chat-heading strong{flex:1}.chat-heading button{padding:5px 8px}
45
+ #chat{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column;background:#191e30;border-left:1px solid #30364d}
46
+ #chat-resizer{position:absolute;left:-5px;top:0;bottom:0;width:10px;z-index:3;cursor:col-resize;touch-action:none}#chat-resizer::after{content:"";position:absolute;left:4px;top:0;bottom:0;width:2px}#chat-resizer:hover::after,#chat-resizer:focus-visible::after,.resizing-chat #chat-resizer::after{background:#9b87ff}body.resizing-chat,body.resizing-chat *{cursor:col-resize!important;user-select:none!important}
47
+ .chat-heading{display:flex;align-items:center;gap:8px;padding:16px;border-bottom:1px solid #2a3046}.chat-heading strong{flex:1}.chat-heading button{padding:5px 8px}
54
48
  #messages{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:16px;overscroll-behavior:contain}
55
- .chat-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.chat-message.user{background:#eef2ff;border-radius:8px;padding:10px 12px}.chat-message small{display:block;color:#697386;font-size:11px;margin-bottom:4px}.chat-intro{color:#737d8e;line-height:1.7}
56
- #chat-form{padding:14px;border-top:1px solid #e6e9ef}#chat-context{font-size:11px;color:#4967dd;margin-bottom:8px}#chat-input{font:inherit;resize:vertical;min-height:90px;max-height:200px;width:100%;padding:10px;border:1px solid #d4d9e2;border-radius:8px} .chat-actions{display:flex;gap:8px;margin-top:8px;align-items:center}.chat-actions select{min-width:0;flex:1}#chat-send{background:#293c73;color:white}#chat-status{padding:0 14px 12px;color:#667085;font-size:11px;white-space:pre-wrap;max-height:90px;overflow:auto}
49
+ .chat-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.chat-message.user{background:#302752;border-radius:8px;padding:10px 12px}.chat-message small{display:block;color:#a4afc6;font-size:11px;margin-bottom:4px}.chat-intro{color:#8995b0;line-height:1.7}
50
+ #chat-form{padding:14px;border-top:1px solid #2a3046}#chat-context{font-size:11px;color:#9b87ff;margin-bottom:8px}#chat-input{font:inherit;resize:vertical;min-height:90px;max-height:200px;width:100%;padding:10px;border:1px solid #30364d;border-radius:8px} .chat-actions{display:flex;gap:8px;margin-top:8px;align-items:center}.chat-actions select{min-width:0;flex:1}#chat-send{background:#7154e8;color:white}#chat-status{padding:0 14px 12px;color:#9ba5be;font-size:11px;white-space:pre-wrap;max-height:90px;overflow:auto}
57
51
  body.chat-hidden .workspace{grid-template-columns:190px minmax(0,1fr)}body.chat-hidden #chat{display:none}
58
52
  body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
59
53
  @media(max-width:1000px){.workspace{--filmstrip-width:120px;--chat-width:380px}.filmstrip{padding:12px 5px}#stage{padding:16px}}
60
- @media(max-width:700px){#chat-resizer{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(160px,1fr) minmax(200px,1fr)}.filmstrip{display:none}#chat{border-top:1px solid #d4d9e2}.chat-heading{padding:8px 12px}#chat-form{padding:8px 12px}#chat-input{min-height:50px}body.chat-hidden .workspace,.presenting .workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}.brand{font-size:14px}header{gap:6px}.download{font-size:11px}}
54
+ @media(max-width:700px){#chat-resizer{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(160px,1fr) minmax(200px,1fr)}.filmstrip{display:none}#chat{border-top:1px solid #30364d}.chat-heading{padding:8px 12px}#chat-form{padding:8px 12px}#chat-input{min-height:50px}body.chat-hidden .workspace,.presenting .workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}.brand{font-size:14px}header{gap:6px}.download{font-size:11px}}
61
55
  @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
62
56
 
63
57
  [hidden]{display:none!important}
64
58
  .chat-heading{padding:10px 14px}.chat-heading select{flex:1;min-width:0;font-weight:600;border:0;padding-left:0}
65
- #chat-context{margin:0;padding:8px 14px;border-bottom:1px solid #e6e9ef;color:#667085;font-size:11px}
59
+ #chat-context{margin:0;padding:8px 14px;border-bottom:1px solid #2a3046;color:#9ba5be;font-size:11px}
66
60
  #claude-panel,#codex-panel{display:flex;flex-direction:column;flex:1;min-height:0}
67
61
  #claude-panel{background:#171b24;color:#e4e8f0}
68
62
  .terminal-toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:11px;min-height:40px}.terminal-toolbar span{flex:1;overflow-wrap:anywhere}.terminal-toolbar button{font-size:11px;padding:5px 8px;background:#262d3b;border-color:#45516a;color:#e4e8f0}
69
63
  .terminal-toolbar button:hover:not(:disabled){background:#35415a}
70
- #terminal{flex:1;min-height:0;overflow:hidden;padding:4px 8px 8px}.xterm{height:100%}
64
+ #terminal{flex:1;min-height:0;overflow:hidden;padding:0}.xterm{height:100%}
71
65
  #chat-input{min-height:70px;resize:vertical}.chat-shortcut{flex:1;color:#9098a7;font-size:10px}
72
- .markdown{white-space:normal;line-height:1.65}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p{margin:0 0 .8em}.markdown pre{overflow:auto;padding:10px;background:#f3f5f8;border:1px solid #e6e9ef;border-radius:6px;white-space:pre}.markdown code{font-size:.9em;background:#f3f5f8;border-radius:3px;padding:1px 4px}.markdown pre code{padding:0}.markdown ul,.markdown ol{padding-left:22px}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid #d4d9e2;color:#667085}.markdown table{display:block;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:6px 8px;border:1px solid #d4d9e2}.markdown h1,.markdown h2,.markdown h3{font-size:1.1em}.markdown a{color:#3654c1;text-decoration:underline}
66
+ .markdown{white-space:normal;line-height:1.65}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p{margin:0 0 .8em}.markdown pre{overflow:auto;padding:10px;background:#22283b;border:1px solid #2a3046;border-radius:6px;white-space:pre}.markdown code{font-size:.9em;background:#22283b;border-radius:3px;padding:1px 4px}.markdown pre code{padding:0}.markdown ul,.markdown ol{padding-left:22px}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid #30364d;color:#9ba5be}.markdown table{display:block;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:6px 8px;border:1px solid #30364d}.markdown h1,.markdown h2,.markdown h3{font-size:1.1em}.markdown a{color:#b6a5ff;text-decoration:underline}
67
+
68
+ .brand{display:flex;align-items:center;gap:10px;letter-spacing:-.5px}.brand svg{width:32px;height:32px;flex:none}.brand em{font-style:normal;font-weight:400;color:#aca4ce}.badge{text-transform:uppercase;letter-spacing:.14em;font-size:9px;border:1px solid #61508866;border-radius:5px}#status{font-size:11px}#present,#chat-send{color:#fff;background:linear-gradient(120deg,#8061ed,#634de0);border-color:#a38aef55;box-shadow:0 3px 15px #7455e82a}#toggle-chat[aria-expanded="true"]{background:#35294f;border-color:#6f528b;color:#d6c5ff}.download{background:transparent;color:#bcc5dc}header button,header .download{font-size:12px}.filmstrip{background:linear-gradient(180deg,#161b2c,#111625)}.thumbnail{transition:background .15s}.thumbnail[aria-current="true"]{box-shadow:inset 0 0 0 1px #9980ef33}.thumbnail[aria-current="true"] img{box-shadow:0 0 16px #9567ed22}footer{background:#141929;color:#b9c2d8}footer .hint{font-size:10px}#count{font-size:11px;font-weight:600}#chat{background:#101422}.workspace-heading{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;color:#c7bee5;font-size:10px;font-weight:600;letter-spacing:.12em}.workspace-heading b{font-size:9px;color:#89cdbc;font-weight:500;letter-spacing:.08em}.workspace-heading b::before{content:"";display:inline-block;width:5px;height:5px;background:#86d5bb;border-radius:50%;margin-right:6px;box-shadow:0 0 8px #86d5bb66}.chat-heading{background:#191e30}.chat-heading select{background:transparent;color:#e4e5f3;font-size:12px}.chat-heading option{background:#191e30}#chat-context{background:#1a1e30;color:#a59abb;font-size:10px}.terminal-toolbar{gap:6px;padding:8px;font-size:10px}.terminal-toolbar button{white-space:nowrap}#terminal-start{background:#6c52c9;border-color:#9678e9}.terminal-frame{display:flex;flex:1;min-height:0;min-width:0;padding:4px 8px 8px;overflow:hidden}#terminal{width:100%;min-width:0}#chat-input{color:#e2e5f3;background:#131827;border-color:#3b3f58}#chat-input:focus{outline:none;border-color:#9b87ff;box-shadow:0 0 0 3px #9b87ff18}.chat-message.user{border:1px solid #67509655}.chat-intro{font-size:12px}.markdown pre{border-color:#3a4059}.markdown code{color:#d2c6fa}#error{background:#38242b;color:#ffb8b0}
69
+ @media(max-width:700px){.brand svg{width:26px;height:26px}.brand{gap:6px}.workspace-heading{padding:7px 12px}header .download{font-size:10px;padding:7px}.badge{display:none}}
70
+ `;
71
+ //#endregion
72
+ //#region src/page.ts
73
+ const page = `<!doctype html>
74
+ <html lang="en">
75
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width">
76
+ <title>Office Kit — PowerPoint preview</title>
77
+ <link rel="stylesheet" href="/terminal.css">
78
+ <style>
79
+ ${previewStyles}
80
+ #agent-workspace{position:relative;flex:1;min-height:0;min-width:0;display:flex;overflow:auto}.agent-pane{position:absolute;padding:3px 5px;display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;overflow:hidden}.agent-pane iframe{border:0;width:100%;flex:1;min-height:0;border-radius:0 0 9px 9px}.agent-tools{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:6px;background:#20253a;border:1px solid #34334e;border-radius:9px 9px 0 0}.agent-tools span{flex:1;font-size:10px;letter-spacing:.04em;color:#b3abc9}.agent-tools svg{width:14px;height:14px}.agent-pane:focus-within .agent-tools{border-color:#8b6ed577;background:#29243e}.agent-tools button{font-size:11px;padding:5px;background:transparent;border:0;display:grid;place-items:center}.agent-divider{position:absolute;z-index:2;background:#101422;cursor:col-resize;touch-action:none}.agent-divider.vertical{cursor:row-resize}.agent-divider:hover,.agent-divider:focus-visible{background:#9b87ff}.splitting-agents iframe,.resizing-chat iframe{pointer-events:none}
73
81
  </style>
74
- <header><span class="brand">Office Kit</span><span class="badge">Preview</span><span id="status" role="status">Building…</span><button id="toggle-chat" aria-expanded="true" aria-controls="chat">Chat</button><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
82
+ <header><span class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><defs><linearGradient id="brand-gradient" x2="1" y2="1"><stop stop-color="#c39aff"/><stop offset="1" stop-color="#7165f4"/></linearGradient></defs><rect x="2" y="2" width="32" height="32" rx="10" fill="url(#brand-gradient)"/><path d="M11 10h9l6 6v10H11z" fill="none" stroke="white" stroke-width="1.8" stroke-linejoin="round"/><path d="M20 10v7h6M15 21h7M15 25h4" fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round"/><path d="m28 3 1.2 3.8L33 8l-3.8 1.2L28 13l-1.2-3.8L23 8l3.8-1.2z" fill="#e7dbff"/></svg>Office <em>Kit</em></span><span class="badge">Studio</span><span id="status" role="status">Building…</span><button id="toggle-chat" aria-expanded="true" aria-controls="chat">✦ Agents</button><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
75
83
  <div class="workspace">
76
84
  <nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
77
85
  <main aria-label="Slide viewer"><pre id="error" role="alert" hidden></pre><div id="stage" tabindex="-1"><div id="empty">Waiting for slides…</div><div id="slide" hidden></div></div></main>
78
86
  <aside id="chat" aria-label="Slide chat">
79
87
  <div id="chat-resizer" role="separator" tabindex="0" aria-label="Chat width" aria-orientation="vertical" aria-controls="chat" title="Drag to resize · Double-click to reset"></div>
80
- <div class="chat-heading"><select id="chat-provider" aria-label="AI provider"><option value="claude">Claude Code</option><option value="codex">Codex</option></select><button id="chat-reset" hidden>New chat</button></div>
81
- <div id="chat-context">No slide selected</div>
82
- <section id="claude-panel" aria-label="Claude Code terminal"><div class="terminal-toolbar"><span id="terminal-status" role="status">Start Claude Code to edit your slides.</span><button id="terminal-start">Start</button><button id="terminal-stop" hidden>End session</button></div><div id="terminal"></div></section>
83
- <div id="codex-panel" hidden>
84
- <div id="messages" role="log" aria-label="Conversation"><p class="chat-intro">Describe what you want to change. The current slide is included automatically. You can also ask about other slides or the whole deck.<br><br>Uses your locally installed Codex. Sign in through its CLI first. Sending a message allows it to edit this project.</p></div>
85
- <form id="chat-form"><textarea id="chat-input" aria-label="Message" maxlength="16000" placeholder="Ask for a change…" required></textarea><div class="chat-actions"><span class="chat-shortcut">⌘ / Ctrl + Enter</span><button id="chat-stop" type="button" hidden>Stop</button><button id="chat-send" type="submit">Send</button></div></form>
86
- <div id="chat-status" role="status">Ready</div>
87
- </div>
88
- </aside>
88
+ <div class="workspace-heading"><span>✦ AI WORKSPACE</span><b>LOCAL</b></div><div id="agent-workspace"></div><div id="chat-context" hidden></div></aside>
89
89
  </div>
90
90
  <footer><span id="count" aria-live="polite">No slides</span><span class="hint">Changes appear automatically · Text can be selected and copied</span><button id="prev" aria-label="Previous slide" disabled>‹</button><button id="next" aria-label="Next slide" disabled>›</button><label for="zoom">Zoom</label><select id="zoom"><option value="fit">Fit</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option></select></footer>
91
91
  <div id="presentation-controls"><button id="present-prev" aria-label="Previous slide">‹</button><span id="present-count"></span><button id="present-next" aria-label="Next slide">›</button><button id="exit-present">Exit · Esc</button></div>
@@ -144,6 +144,7 @@ function selectSlide(next,focusThumbnail=false,reveal=true){
144
144
  const count=state.slides.length?'Slide '+(index+1)+' of '+state.slides.length:'No slides';
145
145
  byId('chat-context').textContent=state.slides.length?count:'Whole project';
146
146
  byId('chat-context').dataset.focus=JSON.stringify({slide:state.slides.length?index:null,revision:state.revision??0});
147
+ window.dispatchEvent(new Event('agent-focus'));
147
148
  byId('count').textContent=count;byId('present-count').textContent=count;
148
149
  for(const id of ['prev','present-prev'])byId(id).disabled=index===0;
149
150
  for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
@@ -234,6 +235,30 @@ async function refresh(){
234
235
  try{const response=await fetch('/state'+(state.revision===undefined?'':'?since='+state.revision));if(!response.ok)throw new Error('Preview unavailable');const updated=await response.json();if(id===refreshId){if(updated.changes){updated.slides=state.slides.slice(0,updated.count);updated.slides.length=updated.count;for(const [position,svg] of Object.entries(updated.changes))updated.slides[Number(position)]=svg;}update(updated);}}
235
236
  catch{if(id===refreshId)byId('status').textContent='Reconnecting…';}
236
237
  }
238
+ byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
239
+ const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='chat'){window.dispatchEvent(new Event('agent-chat'));return;}if(event.data==='ready'){delete state.revision;}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
240
+ <\/script><script type="module" src="/terminal.js"><\/script></html>`;
241
+ //#endregion
242
+ //#region src/agent-page.ts
243
+ const agentPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="/terminal.css"><style>${previewStyles}
244
+ body{display:flex;flex-direction:column;background:#191e30;height:100dvh;min-width:0;overflow:auto;overflow-x:hidden} .chat-heading{flex-wrap:wrap} .chat-heading select{min-width:100px}.chat-heading,#chat-context{flex-shrink:0}#claude-panel{min-height:100px}#codex-panel{min-height:240px}
245
+ </style><div class="chat-heading"><select id="chat-provider" aria-label="AI provider"><option value="claude">Claude Code</option><option value="codex">Codex</option></select><button id="chat-reset" hidden>New chat</button></div>
246
+ <div id="chat-context">No slide selected</div>
247
+ <section id="claude-panel" aria-label="Claude Code terminal"><div class="terminal-toolbar"><span id="terminal-status" role="status">Start Claude Code to edit your slides.</span><button id="terminal-start">Start</button><button id="terminal-stop" hidden>End session</button></div><div class="terminal-frame"><div id="terminal"></div></div></section>
248
+ <div id="codex-panel" hidden>
249
+ <div id="messages" role="log" aria-label="Conversation"><p class="chat-intro">Describe what you want to change. The current slide is included automatically. You can also ask about other slides or the whole deck.<br><br>Uses your locally installed Codex. Sign in through its CLI first. Sending a message allows it to edit this project.</p></div>
250
+ <form id="chat-form"><textarea id="chat-input" aria-label="Message" maxlength="16000" placeholder="Ask for a change…" required></textarea><div class="chat-actions"><span class="chat-shortcut">⌘ / Ctrl + Enter</span><button id="chat-stop" type="button" hidden>Stop</button><button id="chat-send" type="submit">Send</button></div></form>
251
+ <div id="chat-status" role="status">Ready</div>
252
+ </div>
253
+ <script>
254
+ const byId=id=>document.getElementById(id),base=location.pathname;
255
+ let focus={slide:null,revision:0};
256
+ window.addEventListener('message',event=>{
257
+ if(event.origin!==location.origin||event.source!==parent)return;
258
+ if(event.data?.type==='focus'){focus=event.data.focus;byId('chat-context').dataset.focus=JSON.stringify(focus);byId('chat-context').textContent=event.data.label;}
259
+ if(event.data?.type==='chat')void refreshChat();
260
+ });
261
+ parent.postMessage({type:'agent-ready'},location.origin);
237
262
  let chatRequest=0,chatBusy=false,lastMessages='';
238
263
  function renderChat(chat){
239
264
  chatBusy=chat.running;
@@ -259,12 +284,12 @@ function renderChat(chat){
259
284
  }
260
285
  async function refreshChat(){
261
286
  const id=++chatRequest;
262
- try{const response=await fetch('/chat');if(!response.ok)throw new Error('Chat unavailable');const chat=await response.json();if(id===chatRequest)renderChat(chat);}
287
+ try{const response=await fetch(base+'/chat');if(!response.ok)throw new Error('Chat unavailable');const chat=await response.json();if(id===chatRequest)renderChat(chat);}
263
288
  catch(error){if(id===chatRequest)byId('chat-status').textContent=error.message;}
264
289
  }
265
290
  async function chatAction(path,payload){
266
291
  ++chatRequest;
267
- const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
292
+ const response=await fetch(base+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
268
293
  const result=await response.json();if(!response.ok)throw new Error(result.error||'Chat request failed');
269
294
  await refreshChat();
270
295
  }
@@ -272,21 +297,27 @@ byId('chat-form').onsubmit=async event=>{
272
297
  event.preventDefault();if(chatBusy)return;
273
298
  const input=byId('chat-input'),message=input.value.trim();if(!message)return;
274
299
  chatBusy=true;byId('chat-send').disabled=true;
275
- try{await chatAction('/chat',{message,provider:byId('chat-provider').value,slide:state.slides.length?index:null,revision:state.revision??0});if(input.value.trim()===message)input.value='';}
300
+ try{await chatAction('/chat',{message,provider:byId('chat-provider').value,...focus});if(input.value.trim()===message)input.value='';}
276
301
  catch(error){byId('chat-status').textContent=error.message;chatBusy=false;byId('chat-send').disabled=false;}
277
302
  };
278
303
  byId('chat-input').onkeydown=event=>{if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)&&!event.isComposing){event.preventDefault();byId('chat-form').requestSubmit();}};
279
304
  for(const action of ['stop','reset'])byId('chat-'+action).onclick=()=>chatAction('/chat/'+action,{}).catch(error=>{byId('chat-status').textContent=error.message;});
280
- byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
281
- byId('chat-provider').onchange=()=>{const claude=byId('chat-provider').value==='claude';byId('claude-panel').hidden=!claude;byId('codex-panel').hidden=claude;byId('chat-reset').hidden=claude;};
305
+ byId('chat-provider').onchange=()=>{try{sessionStorage.setItem('office-kit-provider'+base,byId('chat-provider').value);}catch{}const claude=byId('chat-provider').value==='claude';byId('claude-panel').hidden=!claude;byId('codex-panel').hidden=claude;byId('chat-reset').hidden=claude;};
306
+ try{byId('chat-provider').value=sessionStorage.getItem('office-kit-provider'+base)||'claude';}catch{}
307
+ byId('chat-provider').onchange();
282
308
  void refreshChat();
283
- const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='chat'){void refreshChat();return;}if(event.data==='ready'){delete state.revision;void refreshChat();}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
284
309
  <\/script><script type="module" src="/terminal.js"><\/script></html>`;
285
310
  //#endregion
311
+ //#region src/authoring-guidance.ts
312
+ /** Shared guidance for agents editing a deck through the preview. */
313
+ const authoringGuidance = `For lists and tables of contents, use Text paragraphs/bullets or mapped Text rows. Bullets and TableOfContents are not built-in DSL components: inspect installed exports, import existing local helpers or define them. Do not use Raw for these layouts.
314
+ Choose foreground and actual background colors together. Explicitly set readable text colors on slides, cards, tables, chart labels and footnotes; check inherited theme styles. Aim for 4.5:1 contrast for normal text and 3:1 for large text (18pt or 14pt bold). Use a solid backing over images or gradients if needed. Inspect rendered slides; compilation alone cannot verify contrast.`;
315
+ //#endregion
286
316
  //#region src/terminal.ts
287
317
  const REQUEST_LIMIT = 128e3;
288
318
  /** A real interactive Claude session, shared across reloads of its owning tab. */
289
- function createTerminal(entry, busy = () => false) {
319
+ function createTerminal(entry, busy = () => false, base = "", verify = async () => null) {
320
+ let repairs = 0;
290
321
  let process;
291
322
  let owner = "";
292
323
  let output = "";
@@ -327,17 +358,48 @@ function createTerminal(entry, busy = () => false) {
327
358
  response.writeHead(code, { "Content-Type": "application/json" });
328
359
  response.end(JSON.stringify(value));
329
360
  };
330
- if (request.url === "/terminal/context") {
361
+ if (request.url === "/terminal/context" || request.url === "/terminal/verify") {
331
362
  if (request.method !== "POST" || request.headers.authorization !== `Bearer ${token}`) {
332
363
  json(403, { error: "Invalid hook credentials" });
333
364
  return;
334
365
  }
335
366
  request.resume();
367
+ if (request.url === "/terminal/verify") {
368
+ if (closing || stopping || !process) {
369
+ json(200, {});
370
+ return;
371
+ }
372
+ let error;
373
+ try {
374
+ error = await verify();
375
+ } catch (cause) {
376
+ error = cause instanceof Error ? cause.message : String(cause);
377
+ }
378
+ if (closing || stopping || !process) {
379
+ json(200, {});
380
+ return;
381
+ }
382
+ if (error && repairs < 3) {
383
+ repairs++;
384
+ status = `Repairing preview (${repairs}/3)…`;
385
+ json(200, {
386
+ decision: "block",
387
+ reason: `The actual preview build failed. Repair the source before finishing (attempt ${repairs}/3). Preserve unrelated edits, inspect the installed DSL exports and local components before changing imports. Do not hide the error or remove requested content. No Raw is needed for lists or an agenda: use Text paragraphs/bullets or mapped Text rows. Build error:\n${error.slice(0, 16e3)}`
388
+ });
389
+ } else {
390
+ status = error ? "Preview still has errors after 3 repair attempts." : "Preview build verified";
391
+ json(200, {});
392
+ }
393
+ emit("state", snapshot());
394
+ return;
395
+ }
396
+ repairs = 0;
336
397
  json(200, { hookSpecificOutput: {
337
398
  hookEventName: "UserPromptSubmit",
338
399
  additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
339
400
  Read the project's authoring instructions. Make focused source patches and preserve unrelated work.
340
- The preview rebuilds on save; do not start another dev server.
401
+ The preview rebuilds on save; do not start another dev server. A Stop hook checks the actual build and returns errors for repair.
402
+ ${authoringGuidance}
341
403
  The focused slide is context, NOT a restriction. For "this slide", edit only its relevant source. For other slides or deck-wide requests, locate the relevant source or shared theme.
342
404
  Slide numbers are 1-based. Verify source before editing; dependency paths are candidates, not an exact slide-to-file mapping.
343
405
  Preview context captured with the latest terminal input: ${JSON.stringify(context)}`
@@ -375,7 +437,7 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
375
437
  return;
376
438
  }
377
439
  const size = () => {
378
- if (!Number.isInteger(value.cols) || value.cols < 20 || value.cols > 500 || !Number.isInteger(value.rows) || value.rows < 5 || value.rows > 300) throw new Error("Invalid terminal dimensions");
440
+ if (!Number.isInteger(value.cols) || value.cols < 2 || value.cols > 500 || !Number.isInteger(value.rows) || value.rows < 1 || value.rows > 300) throw new Error("Invalid terminal dimensions");
379
441
  };
380
442
  const updateFocus = () => {
381
443
  if (!(value.slide === null || Number.isInteger(value.slide) && value.slide >= 0) || !Number.isInteger(value.revision)) throw new Error("Invalid preview context");
@@ -398,12 +460,20 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
398
460
  }
399
461
  const pty = await import("node-pty");
400
462
  if (process || closing) throw new Error("Terminal state changed. Try again.");
401
- const settings = { hooks: { UserPromptSubmit: [{ hooks: [{
402
- type: "http",
403
- url: `${origin}/terminal/context`,
404
- headers: { Authorization: `Bearer ${token}` },
405
- timeout: 5
406
- }] }] } };
463
+ const settings = { hooks: {
464
+ Stop: [{ hooks: [{
465
+ type: "http",
466
+ url: `${origin}${base}/terminal/verify`,
467
+ headers: { Authorization: `Bearer ${token}` },
468
+ timeout: 40
469
+ }] }],
470
+ UserPromptSubmit: [{ hooks: [{
471
+ type: "http",
472
+ url: `${origin}${base}/terminal/context`,
473
+ headers: { Authorization: `Bearer ${token}` },
474
+ timeout: 5
475
+ }] }]
476
+ } };
407
477
  const env = { ...globalThis.process.env };
408
478
  delete env.CLAUDECODE;
409
479
  process = pty.spawn("claude", ["--settings", JSON.stringify(settings)], {
@@ -470,7 +540,7 @@ const markdown = new MarkdownIt({
470
540
  });
471
541
  markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
472
542
  /** One shared conversation and one editing process per dev server. */
473
- function createChat(entry, notify, busy = () => false) {
543
+ function createChat(entry, notify, busy = () => false, verify = async () => null) {
474
544
  let messages = [];
475
545
  let provider = "codex";
476
546
  let child;
@@ -492,6 +562,10 @@ function createChat(entry, notify, busy = () => false) {
492
562
  stopping = true;
493
563
  status = "Stopping…";
494
564
  const processToStop = child;
565
+ if (processToStop.exitCode !== null || processToStop.signalCode !== null) {
566
+ notify();
567
+ return;
568
+ }
495
569
  const kill = (signal) => {
496
570
  try {
497
571
  if (process.platform !== "win32" && processToStop.pid) process.kill(-processToStop.pid, signal);
@@ -502,7 +576,7 @@ function createChat(entry, notify, busy = () => false) {
502
576
  killTimer = setTimeout(() => kill("SIGKILL"), 2e3);
503
577
  notify();
504
578
  }
505
- function start(text, selected, context) {
579
+ function start(text, selected, context, repairs = 0) {
506
580
  provider = selected;
507
581
  const user = {
508
582
  role: "user",
@@ -516,6 +590,7 @@ function createChat(entry, notify, busy = () => false) {
516
590
  const prompt = `You are editing a local PowerPoint TSX project in ${dirname(resolve(entry))}.
517
591
  Read the project's authoring instructions. Make focused patches to the TSX source, preserving unrelated work.
518
592
  The preview rebuilds automatically on save; do not start another dev server.
593
+ ${authoringGuidance}
519
594
  The attached focus is context, NOT a restriction: use it for references such as "this slide". For other slides or deck-wide requests, find the relevant source or shared theme instead. Do not rewrite the whole deck for a local edit.
520
595
  Slide numbers are 1-based and refer to the last successful preview at send time. Source may have changed since then; verify it before editing. Files are dependency candidates, not an exact slide-to-source mapping.
521
596
  Respond in the user's language, briefly describing edits or answering their question. If permissions prevent an action, explain that instead of claiming success.
@@ -591,10 +666,31 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
591
666
  running.on("error", (error) => {
592
667
  failure = `Could not run ${selected}. Install it and log in from your terminal first. ${error.message}`;
593
668
  });
594
- running.on("close", (code) => {
669
+ running.on("close", async (code) => {
595
670
  if (buffer.trim()) consume(buffer);
596
671
  clearTimeout(killTimer);
597
672
  status = stopping ? "Stopped. Saved edits remain in the preview." : failure || (code !== 0 ? stderr || `${selected} exited with code ${code}` : !sawResult ? "CLI exited without a completed result." : "Done");
673
+ if (status === "Done") {
674
+ status = "Verifying preview…";
675
+ notify();
676
+ let buildError;
677
+ try {
678
+ buildError = await verify();
679
+ } catch (cause) {
680
+ buildError = cause instanceof Error ? cause.message : String(cause);
681
+ }
682
+ if (!stopping && buildError && repairs < 3) {
683
+ child = void 0;
684
+ start(`The actual preview build failed. Repair the source and preserve unrelated work (attempt ${repairs + 1}/3). Do not remove requested content or hide the error.\n${buildError.slice(0, 16e3)}`, selected, {
685
+ ...context,
686
+ buildError
687
+ }, repairs + 1);
688
+ await completion;
689
+ done();
690
+ return;
691
+ }
692
+ status = stopping ? "Stopped. Saved edits remain in the preview." : buildError ? "Preview still has errors after 3 repair attempts.\n" + buildError.slice(0, 16e3) : "Done";
693
+ }
598
694
  if (status !== "Done") append(status);
599
695
  child = void 0;
600
696
  notify();
@@ -687,10 +783,23 @@ async function serveDeck(entry, port = 4173) {
687
783
  let pending = false;
688
784
  let closed = false;
689
785
  const clients = /* @__PURE__ */ new Set();
690
- const chat = createChat(entry, () => {
691
- for (const client of clients) client.write("data: chat\n\n");
692
- }, () => terminal.isRunning());
693
- const terminal = createTerminal(entry, () => chat.isRunning());
786
+ const sessions = /* @__PURE__ */ new Map();
787
+ function session(id) {
788
+ let result = sessions.get(id);
789
+ if (!result) {
790
+ const chat = createChat(entry, () => {
791
+ for (const client of clients) client.write("data: chat\n\n");
792
+ }, () => terminal.isRunning(), verify);
793
+ const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify);
794
+ result = {
795
+ chat,
796
+ terminal,
797
+ closing: false
798
+ };
799
+ sessions.set(id, result);
800
+ }
801
+ return result;
802
+ }
694
803
  const server = createServer((request, response) => {
695
804
  const host = request.headers.host;
696
805
  if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
@@ -698,20 +807,73 @@ async function serveDeck(entry, port = 4173) {
698
807
  return;
699
808
  }
700
809
  response.setHeader("Cache-Control", "no-store");
701
- if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) (request.url.startsWith("/terminal/") ? terminal : chat).handle(request, response, (index, viewedRevision) => {
702
- if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
703
- if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
704
- return {
705
- slide: index === null ? null : index + 1,
706
- count: latest?.slides.length ?? 0,
707
- revision,
708
- text: index === null ? "" : (latest?.slideTexts[index] ?? "").slice(0, 12e3),
709
- entry: resolve(entry),
710
- files: latest?.dependencies ?? [resolve(entry)],
711
- buildError: error
712
- };
713
- });
714
- else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
810
+ const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|resize|stop|events|context|verify)|close))?$/);
811
+ if (request.url?.startsWith("/agents/") && !match) {
812
+ response.writeHead(404).end();
813
+ return;
814
+ }
815
+ const id = match?.[1] ?? "";
816
+ if (match) {
817
+ if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== `http://${host}`) {
818
+ response.writeHead(403).end();
819
+ return;
820
+ }
821
+ if (sessions.get(id)?.closing) {
822
+ response.writeHead(409).end("{\"error\":\"Agent is closing\"}");
823
+ return;
824
+ }
825
+ if (match[2] === "/close") {
826
+ if (request.method !== "POST" || request.headers.origin !== `http://${host}` || request.headers["content-type"] !== "application/json") {
827
+ response.writeHead(403).end();
828
+ return;
829
+ }
830
+ request.resume();
831
+ const current = sessions.get(id);
832
+ (async () => {
833
+ if (current) {
834
+ current.closing = true;
835
+ await Promise.all([current.terminal.close(), current.chat.close()]);
836
+ sessions.delete(id);
837
+ }
838
+ response.writeHead(200).end("{}");
839
+ })().catch(() => response.writeHead(500).end());
840
+ return;
841
+ }
842
+ if (!sessions.has(id) && sessions.size >= 8) {
843
+ response.writeHead(429).end("Too many agent sessions. Close an existing pane.");
844
+ return;
845
+ }
846
+ if (!match[2]) {
847
+ if (request.method !== "GET") {
848
+ response.writeHead(405).end();
849
+ return;
850
+ }
851
+ session(id);
852
+ response.writeHead(200, { "Content-Type": "text/html" }).end(agentPage);
853
+ return;
854
+ }
855
+ if (!sessions.has(id)) {
856
+ response.writeHead(404).end();
857
+ return;
858
+ }
859
+ request.url = match[2];
860
+ }
861
+ if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) {
862
+ const current = session(id);
863
+ (request.url.startsWith("/terminal/") ? current.terminal : current.chat).handle(request, response, (index, viewedRevision) => {
864
+ if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
865
+ if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
866
+ return {
867
+ slide: index === null ? null : index + 1,
868
+ count: latest?.slides.length ?? 0,
869
+ revision,
870
+ text: index === null ? "" : (latest?.slideTexts[index] ?? "").slice(0, 12e3),
871
+ entry: resolve(entry),
872
+ files: latest?.dependencies ?? [resolve(entry)],
873
+ buildError: error
874
+ };
875
+ });
876
+ } else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
715
877
  const asset = request.url === "/terminal.js" ? "terminal-client.js" : "terminal-client.css";
716
878
  readFile(new URL(asset, import.meta.url)).then((bytes) => {
717
879
  response.writeHead(200, { "Content-Type": asset.endsWith(".css") ? "text/css" : "text/javascript" });
@@ -749,6 +911,15 @@ async function serveDeck(entry, port = 4173) {
749
911
  response.end(page);
750
912
  } else response.writeHead(404).end();
751
913
  });
914
+ async function verify() {
915
+ rebuild();
916
+ const deadline = Date.now() + 3e4;
917
+ while (!closed && (building || timer || pending)) {
918
+ if (Date.now() >= deadline) return "Preview verification timed out. Inspect the build before claiming success.";
919
+ await new Promise((done) => setTimeout(done, 50));
920
+ }
921
+ return closed ? "Preview server closed." : error;
922
+ }
752
923
  async function rebuild() {
753
924
  if (closed) return;
754
925
  if (building) {
@@ -822,8 +993,7 @@ async function serveDeck(entry, port = 4173) {
822
993
  closed = true;
823
994
  clearTimeout(timer);
824
995
  watcher.close();
825
- await terminal.close();
826
- await chat.close();
996
+ await Promise.all([...sessions.values()].flatMap(({ terminal, chat }) => [terminal.close(), chat.close()]));
827
997
  await builder.close();
828
998
  for (const client of clients) client.end();
829
999
  await new Promise((done, reject) => server.close((cause) => cause ? reject(cause) : done()));