kaizen-agent 1.6.9 → 1.7.1

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
@@ -301,6 +301,16 @@ serves, the board opens on every project; `?all=0` in the URL narrows it to the
301
301
  detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it. `kaizen web --shortcut` adds a launcher
302
302
  the OS can find — Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows.
303
303
 
304
+ **Dictate instead of type.** Every text field on the board — a new idea, its notes, an
305
+ edit, a reject reason, a note on a run — has a mic. Press it, talk, press it again; the
306
+ words land at the caret next to whatever you had typed. Recognition is Whisper running
307
+ inside the page, so audio never leaves your machine. The first press downloads the model
308
+ from huggingface.co (about 165 MB on a WebGPU browser, 80 MB on the CPU fallback) and
309
+ caches it in the browser. The transformers.js library itself is fetched from jsDelivr the
310
+ first time you press the mic in a session, so that first press needs the network; the
311
+ model does not download again. Chromium and Edge qualify (Firefox falls back to the
312
+ slower CPU path); a browser without a microphone API or WebAssembly shows no mic.
313
+
304
314
  ### Mouse
305
315
 
306
316
  Off by default. Turn it on in `kaizen settings` under `ui.mouse`, and clicking a row or
package/cli/install.ts CHANGED
@@ -221,6 +221,7 @@ if (wantWeb) {
221
221
  // The pid file only knows a --daemon; a board started from the shortcut or a
222
222
  // terminal has none, so also ask the OS what listens on the port.
223
223
  const recorded = existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : NaN;
224
+ const { pidOnPort } = await import("./state.ts");
224
225
  const listening = await pidOnPort(port ?? 7420);
225
226
  const pids = [...new Set([recorded, listening].filter((p): p is number => Number.isFinite(p) && p > 0))];
226
227
  if (!pids.length) { console.log(" nothing running on the kaizen web port"); process.exit(0); }
@@ -797,19 +798,6 @@ if (blocked) process.exit(1);
797
798
 
798
799
  // ---------------------------------------------------------------- helpers
799
800
 
800
- // Whoever holds the board's port, by the OS's own accounting.
801
- async function pidOnPort(port: number): Promise<number | null> {
802
- try {
803
- if (process.platform === "win32") {
804
- const out = await Bun.$`netstat -ano -p tcp`.text();
805
- const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
806
- return m ? Number(m[1]) : null;
807
- }
808
- const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
809
- return Number(out.trim().split("\n")[0]) || null;
810
- } catch { return null; }
811
- }
812
-
813
801
  function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
814
802
 
815
803
  // Runs the upgrade and reports what it actually did, version to version, so the
package/cli/state.ts CHANGED
@@ -689,3 +689,16 @@ export function launchRun(it: Item, from: string): Launch {
689
689
  return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
690
690
  }
691
691
  }
692
+
693
+ // Whoever holds the board's port, by the OS's own accounting.
694
+ export async function pidOnPort(port: number): Promise<number | null> {
695
+ try {
696
+ if (process.platform === "win32") {
697
+ const out = await Bun.$`netstat -ano -p tcp`.text();
698
+ const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
699
+ return m ? Number(m[1]) : null;
700
+ }
701
+ const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
702
+ return Number(out.trim().split("\n")[0]) || null;
703
+ } catch { return null; }
704
+ }
package/cli/web.ts CHANGED
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import {
6
6
  home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
7
  readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment,
8
+ appendNote, saveAttachment, pidOnPort,
9
9
  } from "./state.ts";
10
10
 
11
11
  const PAGE = join(dirname(import.meta.dir), "web", "board.html");
@@ -108,6 +108,13 @@ export async function web(repoDir: string, opts: Opts = {}) {
108
108
  const url = `http://127.0.0.1:${port}/`;
109
109
  const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
110
110
  if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
111
+ // A listener that does not answer is a board that hung; it is ours to stop.
112
+ const pid = await pidOnPort(port);
113
+ if (pid && pid !== process.pid) {
114
+ try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
115
+ await Bun.sleep(300);
116
+ try { server = serve(port); continue; } catch { /* still held */ }
117
+ }
111
118
  console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
112
119
  }
113
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.6.9",
3
+ "version": "1.7.1",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
package/web/board.html CHANGED
@@ -26,6 +26,8 @@ pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:
26
26
  .ib:hover{color:var(--ink);border-color:var(--line)}
27
27
  .ib.pri{color:var(--accent)}
28
28
  .ib.sm{width:26px;height:26px}.ib.sm svg{width:15px;height:15px}
29
+ /* dictation mic: accent while listening (pulsing) or still transcribing; digits while the model downloads */
30
+ .ib.mic.on{color:var(--accent);animation:pl 1.6s infinite}.ib.mic.busy{color:var(--accent)}.ib.mic i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums}.row .mic{margin-right:auto;align-self:center}
29
31
  .btn{border:1px solid var(--line);border-radius:var(--r);padding:5px 12px;color:var(--ink);background:var(--bg-2);min-height:30px;font-size:13px}
30
32
  .btn:hover{border-color:var(--dim)}
31
33
  .btn.pri{background:var(--accent);border-color:var(--accent);color:#fff}
@@ -154,7 +156,7 @@ aside .foot:empty{display:none}
154
156
  .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:20px 24px;line-height:2}
155
157
  .help kbd{display:inline-block;width:7ch;color:var(--accent)}
156
158
  @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.strip,.ftr{padding-inline:16px}section h2{position:sticky;top:0;background:var(--bg);z-index:1}.more{display:grid}.card:hover .age{display:inline}}
157
- @media(prefers-reduced-motion:reduce){aside,.toast,.mascot,.d-run .pulse{transition:none;animation:none}}
159
+ @media(prefers-reduced-motion:reduce){aside,.toast,.mascot,.d-run .pulse,.ib.mic.on{transition:none;animation:none}}
158
160
  </style>
159
161
  </head>
160
162
  <body>
@@ -163,6 +165,7 @@ aside .foot:empty{display:none}
163
165
  <symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
164
166
  <symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></symbol>
165
167
  <symbol id="i-play" viewBox="0 0 24 24"><path d="M6 4l14 8-14 8z"/></symbol>
168
+ <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6"/></symbol>
166
169
  <symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
167
170
  <symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
168
171
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
@@ -219,6 +222,65 @@ Optional. Everything here is also a button.<br>
219
222
  const $=id=>document.getElementById(id);
220
223
  const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[c]);
221
224
  const ico=n=>`<svg><use href="#i-${n}"/></svg>`;
225
+
226
+ // ---- dictation: Whisper in the page. Audio never leaves the browser; the model is
227
+ // fetched once from huggingface.co and cached. Library and model are pinned here.
228
+ const TFJS="https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",ASR_MODEL="onnx-community/whisper-base";
229
+ // Inference lives in a module Worker so a slow chunk never freezes the board.
230
+ const WORKER_SRC=`import{pipeline}from"${TFJS}";let asr=null;
231
+ onmessage=async e=>{const m=e.data;try{
232
+ if(m.type==="load"){asr=await pipeline("automatic-speech-recognition","${ASR_MODEL}",{device:m.webgpu?"webgpu":"wasm",dtype:m.webgpu?{encoder_model:"fp16",decoder_model_merged:"q4"}:"q8",progress_callback:p=>{if(p.status==="progress")postMessage({type:"progress",file:p.file,pct:p.progress||0})}});postMessage({type:"ready"})}
233
+ if(m.type==="run"){const r=await asr(m.audio,{chunk_length_s:30,return_timestamps:false});postMessage({type:"text",sid:m.sid,seq:m.seq,text:(r.text||"").trim()})}
234
+ }catch(err){postMessage({type:"error",sid:m.sid,seq:m.seq,error:String(err&&err.message||err)})}}`;
235
+ // The worklet only forwards 128-frame blocks; chunking and silence detection stay on the main thread.
236
+ const WORKLET_SRC=`class T extends AudioWorkletProcessor{process(i){const c=i[0]&&i[0][0];if(c)this.port.postMessage(c.slice(0));return true}}registerProcessor("kz-tap",T)`;
237
+ const HAS_MIC=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.AudioWorkletNode&&window.WebAssembly&&window.Worker);
238
+ // `active` is the one session listening; `sessions` also holds stopped ones still waiting on a chunk.
239
+ let asrWorker=null,asrReady=null,asrProgress=()=>{},active=null,starting=false;const sessions=new Set();
240
+ // One worker for the page; loading is awaited by whoever pressed first, later presses join.
241
+ function loadAsr(){if(asrReady)return asrReady;asrReady=new Promise((res,rej)=>{
242
+ asrWorker=new Worker(URL.createObjectURL(new Blob([WORKER_SRC],{type:"text/javascript"})),{type:"module"});
243
+ const files={};asrWorker.onmessage=e=>{const m=e.data;if(m.type==="progress"){files[m.file]=m.pct;const v=Object.values(files);asrProgress(Math.round(v.reduce((a,b)=>a+b,0)/v.length))}else if(m.type==="ready")res();else if(m.type==="error"&&m.seq==null)rej(new Error(m.error));for(const s of sessions)s.onmsg(m)};
244
+ asrWorker.onerror=e=>rej(new Error(e.message||"worker failed"));
245
+ const webgpu=!!navigator.gpu;if(!webgpu)toast("No WebGPU: dictation will run on the CPU and be slower");asrWorker.postMessage({type:"load",webgpu})});
246
+ asrReady.catch(()=>{asrReady=null;try{asrWorker.terminate()}catch(e){}asrWorker=null});return asrReady}
247
+ // After a re-render only a listener whose field is gone is stopped; closing the panel stops any.
248
+ function stopDictation(all){if(active&&(all||!active.field.isConnected))active.stop()}
249
+ // mic(fields): a button that dictates into the last-focused of `fields` at its caret.
250
+ function mic(fields){
251
+ if(!HAS_MIC)return null;
252
+ const b=document.createElement("button");b.className="ib sm mic";b.title="Dictate";b.setAttribute("aria-label","Dictate");b.type="button";
253
+ const idle=()=>{b.innerHTML=ico("mic");b.classList.remove("on","busy")};idle();
254
+ let target=fields[0];fields.forEach(f=>f.addEventListener("focus",()=>target=f));
255
+ b.onclick=async e=>{e.preventDefault();e.stopPropagation();
256
+ if(active){const mine=active.btn===b;active.stop();if(mine)return}
257
+ // One press at a time: the model load and the permission prompt are both awaits a second press could slip through.
258
+ if(starting)return;starting=true;
259
+ b.classList.add("busy");asrProgress=pct=>{b.innerHTML=`<i>${pct}%</i>`};
260
+ try{await loadAsr()}catch(err){idle();asrProgress=()=>{};starting=false;toast("Dictation model could not load: "+err.message);return}
261
+ asrProgress=()=>{};b.innerHTML=ico("mic");if(!b.isConnected){idle();starting=false;return}
262
+ let stream;try{stream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,noiseSuppression:true,echoCancellation:true}})}catch(err){idle();starting=false;toast("Microphone not available: "+(err.name==="NotAllowedError"?"permission denied":err.message));return}
263
+ if(active)active.stop();
264
+ const ctx=new AudioContext({sampleRate:16000});await ctx.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET_SRC],{type:"text/javascript"})));
265
+ const src=ctx.createMediaStreamSource(stream),tap=new AudioWorkletNode(ctx,"kz-tap");src.connect(tap);
266
+ // Text lands where the caret is when each chunk arrives (or after the last insertion when the
267
+ // field is not focused), so typing while the mic is on is kept.
268
+ const f=target;let at=f.selectionStart??f.value.length;
269
+ let buf=[],len=0,voiced=0,quiet=0,seq=0,done=0,pending=new Map(),stopping=false;
270
+ const SR=16000,MAX=6*SR,SIL=0.8*SR,MIN=1*SR;
271
+ const insert=t=>{if(!t||!f.isConnected)return;const v=f.value;if(document.activeElement===f)at=f.selectionStart;at=Math.min(at,v.length);const head=v.slice(0,at),sep=head&&!/\s$/.test(head)?" ":"";f.value=head+sep+t+v.slice(at);at=head.length+sep.length+t.length;f.selectionStart=f.selectionEnd=at;f.dispatchEvent(new Event("input",{bubbles:true}))};
272
+ const flush=()=>{if(!len||voiced<MIN){buf=[];len=0;voiced=0;quiet=0;return}const a=new Float32Array(len);let o=0;for(const c of buf){a.set(c,o);o+=c.length}buf=[];len=0;voiced=0;quiet=0;pending.set(seq,null);asrWorker.postMessage({type:"run",sid,seq,audio:a},[a.buffer]);seq++;b.classList.add("busy")};
273
+ const drain=()=>{while(pending.has(done)&&pending.get(done)!==null){insert(pending.get(done));pending.delete(done);done++}if(!pending.size){b.classList.remove("busy");if(stopping){idle();sessions.delete(sess)}}};
274
+ tap.port.onmessage=e=>{const c=e.data;buf.push(c);len+=c.length;let s=0;for(let i=0;i<c.length;i++)s+=c[i]*c[i];const rms=Math.sqrt(s/c.length);
275
+ if(rms>0.01){voiced+=c.length;quiet=0}else quiet+=c.length;
276
+ if(len>=MAX||(voiced>=MIN&&quiet>=SIL))flush()};
277
+ // Chunk seqs are per session, so a result is matched by session id too.
278
+ const sid=Math.random().toString(36).slice(2);
279
+ const sess={btn:b,field:f,onmsg:m=>{if(m.sid!==sid)return;if(m.type==="text"){pending.set(m.seq,m.text);drain()}else if(m.type==="error"&&m.seq!=null){pending.set(m.seq,"");toast("Dictation failed: "+m.error);drain()}},
280
+ stop:()=>{if(stopping)return;stopping=true;b.classList.remove("on");try{tap.port.onmessage=null;src.disconnect();tap.disconnect();stream.getTracks().forEach(t=>t.stop());ctx.close()}catch(e){}active=null;flush();drain()}};
281
+ sessions.add(sess);active=sess;starting=false;
282
+ b.classList.remove("busy");b.classList.add("on");f.focus()};
283
+ return b}
222
284
  const m=60e3,h=3600e3,d=86400e3;
223
285
  const age=(t,now)=>{const x=now-t;return x<h?Math.round(x/m)+"m":x<d?Math.round(x/h)+"h":Math.round(x/d)+"d"};
224
286
  const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor"};
@@ -282,6 +344,8 @@ function inlineForm(c){
282
344
  if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
283
345
  if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal with the request typed in.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite")}
284
346
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
347
+ // Edit and reject take dictation; the run prompt has no field.
348
+ if(mode!=="run"){const m=mic([...li.querySelectorAll("input,textarea")]);if(m)li.querySelector(".row").prepend(m)}
285
349
  const inp=li.querySelector("input");if(inp){inp.onkeydown=e=>{if(e.key==="Enter")li.querySelector('[data-a=s]').click();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
286
350
  return li;}
287
351
 
@@ -325,17 +389,20 @@ function render(){
325
389
  const ul=document.createElement("ul");
326
390
  if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`<input id="newidea" placeholder="One line, like a commit subject"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add to inbox</button></div>`;ul.appendChild(f);
327
391
  const inp=f.querySelector("input");const save=async()=>{const text=inp.value.trim();if(!text)return;const notes=f.querySelector("textarea").value.trim();mode=null;const r=await post("/inbox",{op:"add",text,notes});if(r.ok)toast("Added to inbox.md");load()};
328
- f.querySelector("#ns").onclick=save;f.querySelector("#nc").onclick=()=>{mode=null;render()};inp.onkeydown=e=>{if(e.key==="Enter")save();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
392
+ f.querySelector("#ns").onclick=save;f.querySelector("#nc").onclick=()=>{mode=null;render()};{const m=mic([inp,f.querySelector("textarea")]);if(m)f.querySelector(".row").prepend(m)}inp.onkeydown=e=>{if(e.key==="Enter")save();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
329
393
  const shown=unfolded.has(ci)||list.length<=FOLD+1?list:list.slice(0,FOLD);
330
394
  shown.forEach(c=>{if(modeKey===key(c)&&mode){ul.appendChild(inlineForm(c));return}const li=document.createElement("li");li.appendChild(card(c));ul.appendChild(li)});
331
395
  if(shown.length<list.length){const li=document.createElement("li");li.innerHTML=`<button class="more-btn">Show ${list.length-shown.length} more</button>`;li.firstChild.onclick=()=>{unfolded.add(ci);render()};ul.appendChild(li)}
332
396
  else if(unfolded.has(ci)&&list.length>FOLD+1){const li=document.createElement("li");li.innerHTML=`<button class="more-btn">Show less</button>`;li.firstChild.onclick=()=>{unfolded.delete(ci);render()};ul.appendChild(li)}
333
397
  s.appendChild(ul);b.appendChild(s)});
334
398
  const seen=new Map();cs.forEach(c=>seen.set(c.status,1));
335
- $("legend").innerHTML=[...seen.keys()].map(s=>`<span class="st d-${ICON[s]}" style="display:inline-grid;vertical-align:-2px"><svg><use href="#s-${ICON[s]}"/></svg></span> ${WORD[s]||s}`).join(" ");}
399
+ $("legend").innerHTML=[...seen.keys()].map(s=>`<span class="st d-${ICON[s]}" style="display:inline-grid;vertical-align:-2px"><svg><use href="#s-${ICON[s]}"/></svg></span> ${WORD[s]||s}`).join(" ");
400
+ stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
401
+ }
336
402
 
337
403
  // ---- detail panel
338
404
  function open(c){
405
+ stopDictation(true); // the panel body is about to be replaced
339
406
  openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
340
407
  if(c.kind==="idea"){runDoc=null;$("psub").textContent="idea · inbox.md · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
341
408
  const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn pri" data-a="run">Run</button><button class="btn" data-a="edit">Edit</button><button class="btn" data-a="reject">Reject</button><button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost danger" data-a="delete">Delete</button>`:`<span style="color:var(--dim);font-size:12px">Starting: a run was launched for this idea.</span>`;
@@ -352,11 +419,12 @@ function chat(c,src,ro){
352
419
  $("pbody").innerHTML=h;
353
420
  if(ro)return;
354
421
  const ta=$("pbody").querySelector("textarea"),files=[];
422
+ {const m=mic([ta]);if(m)$("npick").before(m)}
355
423
  const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
356
424
  const add=f=>{files.push(f);show()};
357
425
  $("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
358
426
  ta.onpaste=e=>{for(const it of e.clipboardData.items)if(it.kind==="file"){const f=it.getAsFile();add(new File([f],f.name==="image.png"?`paste-${Date.now()}.png`:f.name,{type:f.type}));e.preventDefault()}};
359
- const send=async()=>{const text=ta.value.trim();if(!text&&!files.length)return;const enc=await Promise.all(files.map(f=>new Promise(r=>{const rd=new FileReader();rd.onload=()=>r({name:f.name,data:rd.result});rd.readAsDataURL(f)})));
427
+ const send=async()=>{stopDictation(true);const text=ta.value.trim();if(!text&&!files.length)return;const enc=await Promise.all(files.map(f=>new Promise(r=>{const rd=new FileReader();rd.onload=()=>r({name:f.name,data:rd.result});rd.readAsDataURL(f)})));
360
428
  const body=c.kind==="run"?{dir:c.state,id:c.id,text,files:enc}:{dir:c.state,for:c.text,text,files:enc};const r=await post("/note",body);if(r.ok){files.length=0;ta.value="";toast("Noted");load()}};
361
429
  $("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
362
430
  $("pbody").scrollTop=0}
@@ -383,7 +451,7 @@ async function openRun(c,refresh){
383
451
  else foot.innerHTML=`${arch}<button class="btn ghost" data-a="ab">Abandon run</button>`;
384
452
  foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}close();mode="reject";modeKey=key(c);render()})}
385
453
  async function toggleArchive(c,archived){const r=await post("/archive",c.kind==="idea"?{dir:c.state,text:c.text,archived}:{dir:c.state,id:c.id,archived});if(r.ok)toast(archived?"Archived":"Restored");close();load()}
386
- function close(){openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render()}
454
+ function close(){stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render()}
387
455
  $("pclose").onclick=close;$("scrim").onclick=close;
388
456
 
389
457
  // Enough markdown for kaizen's own files: headings, lists, fences, tables,
@@ -404,6 +472,7 @@ function md(src){
404
472
 
405
473
  // ---- settings, toast, notifications, keys
406
474
  async function openSettings(){
475
+ stopDictation(true);
407
476
  openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
408
477
  const cur=document.documentElement.dataset.theme||"system";
409
478
  const opts=(id,vals,on)=>`<div class="opts" id="${id}">${vals.map(v=>`<button class="${v===on?"on":""}" data-v="${v}" ${v===""?'title="empty"':""}>${v===""?"—":v}</button>`).join("")}</div>`;