← back to Ticket System
Add model picker to Run Now
444a832f17791a6a278dc80b933e4754006aa57c · 2026-08-31 10:40:23 -0700 · Steve Abrams
Files touched
M board.htmlM run-ticket.shM server.jsA test/run-ticket-profiles.test.js
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 →