[object Object]

← back to Ticket System

Add model picker to Run Now

444a832f17791a6a278dc80b933e4754006aa57c · 2026-08-31 10:40:23 -0700 · Steve Abrams

Files touched

Diff

commit 444a832f17791a6a278dc80b933e4754006aa57c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 10:40:23 2026 -0700

    Add model picker to Run Now
---
 board.html                       | 12 +++++++++---
 run-ticket.sh                    | 21 ++++++++++++++++++---
 server.js                        | 10 +++++++---
 test/run-ticket-profiles.test.js | 20 ++++++++++++++++++++
 4 files changed, 54 insertions(+), 9 deletions(-)

diff --git a/board.html b/board.html
index 4cb6c7ff..71fb54b6 100644
--- a/board.html
+++ b/board.html
@@ -105,7 +105,10 @@
 </header>
 <div id="actionbar">
   <span><span class="n" id="seln">0</span> selected</span>
-  <button class="btn run" id="runBtn" title="Open a new iTerm2 window with Claude loaded, one per selected ticket">▶ Run Now</button>
+  <select class="gsel" id="runProfile" title="Model used for Run Now">
+    <option value="claude-sonnet">Claude Sonnet</option><option value="claude-opus">Claude Opus</option><option value="claude-haiku">Claude Haiku</option><option value="codex">Codex (default)</option>
+  </select>
+  <button class="btn run" id="runBtn" title="Open a new iTerm2 window with the selected model, one per selected ticket">▶ Run Now</button>
   <button class="btn dtd" id="dtdBtn" title="Batched /dtd — clear yes/no 'run now' verdict per recent ticket">🧠 Analyze w/ DTD</button>
   <button class="btn" id="yesBtn" title="Add every ticket DTD said YES to the selection">✅ Select all YES</button>
   <button class="btn stop" id="stopBtn" title="TicketStopped — stop forever (reversible)">⛔ Stop Forever</button>
@@ -190,7 +193,9 @@ let PILL_VIS=Object.assign(Object.fromEntries(PILLS.map(p=>[p.k,1])),LS('tkPills
 const DET=[{k:'comment',l:'comments'},{k:'note',l:'notes'},{k:'action',l:'actions'},{k:'win',l:'wins'},{k:'challenge',l:'challenges'},{k:'cody',l:'cody dissents'}];
 let DET_VIS=LS('tkDet',null)||Object.fromEntries(DET.map(d=>[d.k,1]));
 let DM_ON=LS('tkDM',1), LASTMSG=[];
+let RUN_PROFILE=LS('tkRunProfile','claude-sonnet');
 document.documentElement.style.setProperty('--rowpad',DENS+'px'); $('#dens').value=DENS; $('#grp').value=GROUP; $('#view').value=VIEW;
+if(![...$('#runProfile').options].some(o=>o.value===RUN_PROFILE))RUN_PROFILE='claude-sonnet';$('#runProfile').value=RUN_PROFILE;
 const ordered=()=>ORDER.map(k=>COLS.find(c=>c.k===k)).filter(c=>c&&VIS[c.k]);
 
 function renderHead(){
@@ -274,8 +279,9 @@ function syncBar(){const n=SEL.size;$('#seln').textContent=n;$('#actionbar').cla
 function selIds(){return [...SEL];}
 async function post(url,body){const r=await fetch(location.origin+url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});return r.json();}
 $('#clearBtn').onclick=()=>{SEL.clear();render();syncBar();};
-$('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Open ${ids.length} new iTerm2 window(s), each running Claude on a ticket?`))return;
-  $('#runBtn').disabled=true;try{const r=await post('/api/run',{ids});alert(`Launched ${((r.launched||[]).length)} session(s)`+((r.skipped&&r.skipped.length)?`\nSkipped: ${r.skipped.map(s=>s.id+'('+s.why+')').join(', ')}`:''));}catch(e){alert('run failed: '+e.message);}finally{$('#runBtn').disabled=false;SEL.clear();load();}};
+$('#runProfile').onchange=e=>{RUN_PROFILE=e.target.value;save('tkRunProfile',RUN_PROFILE);};
+$('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;const label=$('#runProfile').selectedOptions[0].textContent;if(!confirm(`Open ${ids.length} new iTerm2 window(s), each running ${label} on a ticket?`))return;
+  $('#runBtn').disabled=true;try{const r=await post('/api/run',{ids,profile:RUN_PROFILE});if(r.error)throw new Error(r.error);alert(`Launched ${((r.launched||[]).length)} ${label} session(s)`+((r.skipped&&r.skipped.length)?`\nSkipped: ${r.skipped.map(s=>s.id+'('+s.why+')').join(', ')}`:''));}catch(e){alert('run failed: '+e.message);}finally{$('#runBtn').disabled=false;SEL.clear();load();}};
 $('#stopBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Stop ${ids.length} ticket(s) FOREVER (TicketStopped)? Reversible via Reopen.`))return;
   try{await post('/api/stop',{ids});}catch(e){alert('stop failed: '+e.message);}SEL.clear();load();};
 $('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){alert('reopen failed: '+e.message);}SEL.clear();load();};
diff --git a/run-ticket.sh b/run-ticket.sh
index b8204140..5fde3c79 100755
--- a/run-ticket.sh
+++ b/run-ticket.sh
@@ -5,7 +5,7 @@
 # authored ticket title ever reaches a shell). Steve, TK-10527 (2026-08-13):
 # "run ticket like the approvals opens a new iterm2 terminal with claude loaded first."
 #
-# Usage: run-ticket.sh <TK-id> [start-dir]
+# Usage: run-ticket.sh <TK-id> [start-dir] [claude-sonnet|claude-opus|claude-haiku|codex]
 set -euo pipefail
 
 ID="${1:?usage: run-ticket.sh <TK-id> [start-dir]}"
@@ -22,11 +22,20 @@ case "$CWD" in
   *) CWD="$HOME" ;;
 esac
 
+PROFILE="${3:-claude-sonnet}"
+case "$PROFILE" in
+  claude-sonnet) RUNNER=(claude --model sonnet); AGENT_PREFIX=claude-run ;;
+  claude-opus)   RUNNER=(claude --model opus);   AGENT_PREFIX=claude-run ;;
+  claude-haiku)  RUNNER=(claude --model haiku);  AGENT_PREFIX=claude-run ;;
+  codex)         RUNNER=(/Users/macstudio3/.local/bin/codex); AGENT_PREFIX=codex-run ;;
+  *) echo "run-ticket: refusing unknown profile '$PROFILE'" >&2; exit 2 ;;
+esac
+
 # The prompt references ONLY the safe id — the session pulls full context itself
 # via `tk show`. Encodes Steve's standing operating loop (tickets-first, copious
 # notes, gated actions draft to pending-approval).
 read -r -d '' PROMPT <<PROMPT_EOF || true
-export TK_AGENT=claude-run-${IDNUM}. You are driving ticket ${ID} to completion. First run: tk inbox (act on any DMs), then tk show ${ID} for full context. You now OWN this ticket: tk take ${ID}. Work it to done per CLAUDE.md's operating loop — tk log EVERY action, tk comment reasoning, dm peers with tk dm if you need help. Any customer-facing / destructive / spend / DNS / publish / send-to-list / remote-push / canonical dw_unified or Shopify write is GATED: draft it to ~/.claude/yolo-queue/pending-approval/ and STOP — never auto-fire. When the work is genuinely complete AND verified: tk done ${ID}.
+export TK_AGENT=${AGENT_PREFIX}-${IDNUM}. You are driving ticket ${ID} to completion. First run: tk inbox (act on any DMs), then tk show ${ID} for full context. You now OWN this ticket: tk take ${ID}. Work it to done per AGENTS.md/CLAUDE.md's operating loop — tk log EVERY action, tk comment reasoning, dm peers with tk dm if you need help. Any customer-facing / destructive / spend / DNS / publish / send-to-list / remote-push / canonical dw_unified or Shopify write is GATED: draft it to ~/.claude/yolo-queue/pending-approval/ and STOP — never auto-fire. When the work is genuinely complete AND verified: tk done ${ID}.
 PROMPT_EOF
 
 # Build the inner shell payload, then escape for an AppleScript double-quoted literal
@@ -36,12 +45,18 @@ PROMPT_EOF
 # re-assert fires ~5s in (after zsh's shell-integration precmd hook has run, which would
 # otherwise overwrite the launch-time name) and again on a short loop for the first minute.
 retitle="( for i in 1 2 3 4; do sleep 5; \"$HOME/bin/tk-iterm-title\" TK-${IDNUM}; done ) >/dev/null 2>&1 &"
-payload="cd $(printf %q "$CWD") && ${retitle} claude $(printf %q "$PROMPT")"
+printf -v runner_cmd '%q ' "${RUNNER[@]}"
+payload="cd $(printf %q "$CWD") && ${retitle} ${runner_cmd}$(printf %q "$PROMPT")"
 osa_title="TK-${IDNUM}"
 esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
 osa_payload="$(esc "$payload")"
 osa_title="$(esc "$osa_title")"
 
+if [[ "${RUN_TICKET_DRY_RUN:-0}" == "1" ]]; then
+  printf 'profile=%s\nagent=%s-%s\npayload=%s\n' "$PROFILE" "$AGENT_PREFIX" "$IDNUM" "$payload"
+  exit 0
+fi
+
 /usr/bin/osascript <<OSA
 tell application "iTerm"
   set newWin to (create window with default profile)
diff --git a/server.js b/server.js
index f5bc4347..4aa28404 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,8 @@ const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json');       // last batched
 const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
 const RUN_SH = path.join(__dirname, 'run-ticket.sh');           // opens an iTerm2 Claude session
 const DTD_RUN = path.join(__dirname, 'dtd-run.js');             // batched panel.sh sweep
+const RUN_PROFILES = new Set(['claude-sonnet', 'claude-opus', 'claude-haiku', 'codex']);
+const DEFAULT_RUN_PROFILE = 'claude-sonnet';
 // IDRE / REFRE / resolveList now live in ./lib.js (co-located with resolveId).
 const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
 function readJson(req, cb) { let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { cb(JSON.parse(b || '{}')); } catch { cb(null); } }); }
@@ -307,6 +309,8 @@ http.createServer((req, res) => {
     if (execBlockedForRemote(req, res)) return;
     return readJson(req, body => {
       if (!body) return json(res, 400, { error: 'bad json' });
+      const profile = String(body.profile || DEFAULT_RUN_PROFILE);
+      if (!RUN_PROFILES.has(profile)) return json(res, 400, { error: 'invalid run profile' });
       const map = tickets(); const ids = resolveList(body.ids, map);
       const launched = [], skipped = [];
       ids.forEach((id, i) => {
@@ -316,16 +320,16 @@ http.createServer((req, res) => {
         let cwd = os.homedir();
         const proj = t && t.project;
         if (proj && /^[a-z0-9._-]+$/i.test(proj)) { const p = path.join(os.homedir(), 'Projects', proj); if (fs.existsSync(p)) cwd = p; }
-        setTimeout(() => execFile('bash', [RUN_SH, id, cwd], { timeout: 25000 }, (err) => {
+        setTimeout(() => execFile('bash', [RUN_SH, id, cwd, profile], { timeout: 25000 }, (err) => {
           // Log the REAL outcome from the launch callback — never an optimistic "launched" before the window opens.
           // If osascript/iTerm fails (app quit, Automation permission denied), surface it on the board instead of a false success.
           withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
             text: err ? ('⚠ RUN NOW failed to launch iTerm2 session — ' + String(err.message || err).split('\n')[0])
-                      : '▶ RUN NOW — launched iTerm2 Claude session from the board' }));
+                      : `▶ RUN NOW — launched iTerm2 session from the board · profile=${profile}` }));
         }), i * 1300); // stagger ~1.3s
         launched.push(id);
       });
-      json(res, 200, { launched, skipped });
+      json(res, 200, { launched, skipped, profile });
     });
   }
   // Stop Forever — mark selected tickets TicketStopped (status 'stopped'); reversible via /api/reopen.
diff --git a/test/run-ticket-profiles.test.js b/test/run-ticket-profiles.test.js
new file mode 100644
index 00000000..d6a610d7
--- /dev/null
+++ b/test/run-ticket-profiles.test.js
@@ -0,0 +1,20 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+const { execFileSync, spawnSync } = require('child_process');
+const script = path.join(__dirname, '..', 'run-ticket.sh');
+
+const run = profile => execFileSync('bash', [script, 'TK-11027-safe-test', process.cwd(), profile], { encoding:'utf8', env:{...process.env,RUN_TICKET_DRY_RUN:'1'} });
+
+test('allowlisted launcher profiles map to fixed commands', () => {
+  assert.match(run('claude-sonnet'), /profile=claude-sonnet[\s\S]*claude --model sonnet/);
+  assert.match(run('claude-opus'), /profile=claude-opus[\s\S]*claude --model opus/);
+  assert.match(run('claude-haiku'), /profile=claude-haiku[\s\S]*claude --model haiku/);
+  assert.match(run('codex'), /profile=codex[\s\S]*\/Users\/macstudio3\/\.local\/bin\/codex/);
+});
+
+test('unknown launcher profile is rejected before AppleScript', () => {
+  const r=spawnSync('bash',[script,'TK-11027-safe-test',process.cwd(),'codex;touch-pwned'],{encoding:'utf8',env:{...process.env,RUN_TICKET_DRY_RUN:'1'}});
+  assert.equal(r.status,2); assert.match(r.stderr,/refusing unknown profile/);
+});

← 219bfbe0 Record blocker lanes E2E proof  ·  back to Ticket System  ·  Record Run Now model picker proof c9aa7c59 →