acdev 1.1.1 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -22,6 +22,7 @@ const RUN_STATUSES = new Set([
22
22
  'preparing_worktree',
23
23
  'running',
24
24
  'applying_feedback',
25
+ 'awaiting_permission',
25
26
  'awaiting_review',
26
27
  'pr_opened',
27
28
  'failed',
@@ -49,6 +50,7 @@ const STATUS_LABELS = {
49
50
  preparing_worktree: 'Preparing worktree…',
50
51
  running: 'Agent running…',
51
52
  applying_feedback: 'Applying review feedback…',
53
+ awaiting_permission: 'Needs permission',
52
54
  awaiting_review: 'Awaiting review',
53
55
  pr_opened: 'PR opened',
54
56
  discarded: 'Discarded',
@@ -56,6 +58,25 @@ const STATUS_LABELS = {
56
58
  'retry queued': 'Retry queued',
57
59
  };
58
60
 
61
+ /**
62
+ * Agent permission modes, mirroring Claude Code's own permission modes.
63
+ * @type {Record<'manual' | 'auto_edit' | 'full_auto', { label: string, hint: string }>}
64
+ */
65
+ const PERMISSION_MODE_META = {
66
+ manual: {
67
+ label: 'Manual review',
68
+ hint: 'Every tool call (read, edit, or command) pauses and waits for your Allow/Deny decision.',
69
+ },
70
+ auto_edit: {
71
+ label: 'Auto-accept edits',
72
+ hint: 'File reads and edits proceed automatically; shell commands still pause for your decision.',
73
+ },
74
+ full_auto: {
75
+ label: 'Full auto',
76
+ hint: 'The agent never asks — reads, edits, and shell commands all proceed automatically.',
77
+ },
78
+ };
79
+
59
80
  const THEME_KEY = 'acdev-theme';
60
81
  const DISMISS_KEY = 'acdev-dismissed-alerts';
61
82
  const REVIEW_FILTER_KEY = 'acdev-review-filter';
@@ -162,6 +183,8 @@ let availableModels = [];
162
183
  let ticketSource = 'github';
163
184
  /** @type {'claude' | 'openrouter'} */
164
185
  let llmProvider = 'claude';
186
+ /** @type {'manual' | 'auto_edit' | 'full_auto'} */
187
+ let permissionMode = 'full_auto';
165
188
  /** @type {Array<{ name: string, column?: string, board?: string }>} */
166
189
  let jiraStatusOptions = [];
167
190
  /** @type {Array<{ name: string, projectTitle?: string }>} */
@@ -249,6 +272,8 @@ const els = {
249
272
  overviewReviewEmpty: document.getElementById('overview-review-empty'),
250
273
  overviewQueue: document.getElementById('overview-queue'),
251
274
  overviewQueueEmpty: document.getElementById('overview-queue-empty'),
275
+ overviewPermission: document.getElementById('overview-permission'),
276
+ overviewPermissionEmpty: document.getElementById('overview-permission-empty'),
252
277
  runsList: document.getElementById('runs-list'),
253
278
  runsEmpty: document.getElementById('runs-empty'),
254
279
  reviewEmpty: document.getElementById('review-empty'),
@@ -359,6 +384,11 @@ const els = {
359
384
  settingsLlmProvider: document.getElementById('settings-llm-provider'),
360
385
  settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
361
386
  overviewLlmProvider: document.getElementById('overview-llm-provider'),
387
+ settingsPermissionMode: document.getElementById('settings-permission-mode'),
388
+ settingsPermissionModeHint: document.getElementById('settings-permission-mode-hint'),
389
+ overviewPermissionMode: document.getElementById('overview-permission-mode'),
390
+ overviewPermissionModeHint: document.getElementById('overview-permission-mode-hint'),
391
+ overviewPermissionModeFeedback: document.getElementById('overview-permission-mode-feedback'),
362
392
  sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
363
393
  settingsTabs: document.getElementById('settings-tabs'),
364
394
  };
@@ -1414,7 +1444,7 @@ function pipelineStates(job) {
1414
1444
  if (job.status === 'preparing_worktree') {
1415
1445
  return ['done', 'done', 'active', 'pending', 'pending'];
1416
1446
  }
1417
- if (job.status === 'running') {
1447
+ if (job.status === 'running' || job.status === 'awaiting_permission') {
1418
1448
  return ['done', 'done', 'active', 'pending', 'pending'];
1419
1449
  }
1420
1450
  if (job.status === 'failed') {
@@ -1432,8 +1462,12 @@ function pipelineStates(job) {
1432
1462
  }
1433
1463
 
1434
1464
  function currentStepLabel(job) {
1435
- if (job.status === 'failed') return job.error || 'failed';
1465
+ if (job.status === 'failed') return truncate(String(job.error || 'failed').replace(/\s+/g, ' '), 160);
1436
1466
  if (job.status === 'awaiting_review') return 'awaiting review';
1467
+ if (job.status === 'awaiting_permission') {
1468
+ const tool = job.pendingPermission?.tool;
1469
+ return tool ? `waiting on your permission decision — ${tool}` : 'waiting on your permission decision';
1470
+ }
1437
1471
  if (job.status === 'pr_opened') return 'PR opened';
1438
1472
  const logs = job.logs || [];
1439
1473
  for (let i = logs.length - 1; i >= 0; i--) {
@@ -2352,6 +2386,19 @@ function collectAlerts(jobs) {
2352
2386
  actions: ['retry', 'clear'],
2353
2387
  });
2354
2388
  }
2389
+ if (job.status === 'awaiting_permission' && job.pendingPermission) {
2390
+ const req = job.pendingPermission;
2391
+ alerts.push({
2392
+ id: `permission:${job.id}:${req.id}`,
2393
+ jobId: job.id,
2394
+ title: `${jobTitle(job)} needs permission`,
2395
+ text: `${req.tool} · ${truncate(summarizeToolInput(req.input), 140)}`,
2396
+ repo: meta.repo,
2397
+ number: String(meta.number),
2398
+ time: formatTime(req.requestedAt),
2399
+ actions: ['decide-permission'],
2400
+ });
2401
+ }
2355
2402
  for (const log of job.logs || []) {
2356
2403
  if (log.type !== 'error' && log.type !== 'warn') continue;
2357
2404
  const payload = String(log.payload || '');
@@ -2447,6 +2494,7 @@ function statusPill(job) {
2447
2494
 
2448
2495
  function renderOverview(jobs) {
2449
2496
  const running = jobs.filter((j) => RUNNING_STATUSES.has(j.status));
2497
+ const permission = jobs.filter((j) => j.status === 'awaiting_permission');
2450
2498
  const review = jobs.filter((j) => j.status === 'awaiting_review');
2451
2499
  const queued = jobs.filter((j) => j.status === 'queued');
2452
2500
 
@@ -2487,6 +2535,54 @@ function renderOverview(jobs) {
2487
2535
  els.overviewRunning.appendChild(row);
2488
2536
  }
2489
2537
 
2538
+ els.overviewPermission.innerHTML = '';
2539
+ els.overviewPermissionEmpty.classList.toggle('hidden', permission.length > 0);
2540
+ for (const job of permission) {
2541
+ const row = document.createElement('div');
2542
+ row.className = 'job-row';
2543
+ row.innerHTML = `
2544
+ ${stepDotsHtml(pipelineStates(job))}
2545
+ <div class="job-main">
2546
+ <div class="job-title"></div>
2547
+ <div class="job-sub mono"></div>
2548
+ </div>
2549
+ `;
2550
+ row.querySelector('.job-title').textContent = jobTitle(job);
2551
+ row.querySelector('.job-sub').textContent = jobSubLine(job);
2552
+ const badge = typeBadgeEl(job);
2553
+ if (badge) row.appendChild(badge);
2554
+ if (job.pendingPermission) {
2555
+ const toolChip = document.createElement('span');
2556
+ toolChip.className = 'usage-chip mono';
2557
+ toolChip.textContent = `${job.pendingPermission.tool} · ${summarizeToolInput(job.pendingPermission.input) || '—'}`;
2558
+ row.appendChild(toolChip);
2559
+ }
2560
+ const allowBtn = document.createElement('button');
2561
+ allowBtn.type = 'button';
2562
+ allowBtn.className = 'btn btn-primary btn-sm';
2563
+ allowBtn.textContent = 'Allow';
2564
+ allowBtn.addEventListener('click', (e) => {
2565
+ e.stopPropagation();
2566
+ if (job.pendingPermission) decidePermission(job.id, job.pendingPermission.id, 'allow');
2567
+ });
2568
+ row.appendChild(allowBtn);
2569
+ const denyBtn = document.createElement('button');
2570
+ denyBtn.type = 'button';
2571
+ denyBtn.className = 'btn btn-danger-text btn-sm';
2572
+ denyBtn.textContent = 'Deny';
2573
+ denyBtn.addEventListener('click', (e) => {
2574
+ e.stopPropagation();
2575
+ if (job.pendingPermission) decidePermission(job.id, job.pendingPermission.id, 'deny');
2576
+ });
2577
+ row.appendChild(denyBtn);
2578
+ row.style.cursor = 'pointer';
2579
+ row.addEventListener('click', () => {
2580
+ setView('runs');
2581
+ selectRun(job.id);
2582
+ });
2583
+ els.overviewPermission.appendChild(row);
2584
+ }
2585
+
2490
2586
  els.overviewReview.innerHTML = '';
2491
2587
  els.overviewReviewEmpty.classList.toggle('hidden', review.length > 0);
2492
2588
  for (const job of review) {
@@ -2566,6 +2662,43 @@ function renderOverview(jobs) {
2566
2662
  });
2567
2663
  }
2568
2664
 
2665
+ /**
2666
+ * Build the Allow/Deny card shown in a run's detail pane while it is
2667
+ * paused waiting on a permission decision.
2668
+ * @param {object} job
2669
+ */
2670
+ function buildPermissionRequestCard(job) {
2671
+ const req = job.pendingPermission;
2672
+ const card = document.createElement('div');
2673
+ card.className = 'permission-card';
2674
+ card.innerHTML = `
2675
+ <div class="permission-card-header">
2676
+ <span class="permission-card-icon" aria-hidden="true">🔒</span>
2677
+ <div>
2678
+ <div class="permission-card-title"></div>
2679
+ <div class="permission-card-sub">Nothing else runs until you decide.</div>
2680
+ </div>
2681
+ </div>
2682
+ <pre class="permission-card-input"></pre>
2683
+ <div class="actions">
2684
+ <button type="button" class="btn btn-primary btn-sm" data-decision="allow">Allow</button>
2685
+ <button type="button" class="btn btn-danger-text btn-sm" data-decision="deny">Deny</button>
2686
+ </div>
2687
+ `;
2688
+ card.querySelector('.permission-card-title').textContent = `Agent wants to run ${req.tool}`;
2689
+ card.querySelector('.permission-card-input').textContent = formatRawPayload(req.input);
2690
+ card.querySelectorAll('[data-decision]').forEach((btn) => {
2691
+ btn.addEventListener('click', (e) => {
2692
+ e.stopPropagation();
2693
+ card.querySelectorAll('button').forEach((b) => {
2694
+ b.disabled = true;
2695
+ });
2696
+ decidePermission(job.id, req.id, btn.dataset.decision);
2697
+ });
2698
+ });
2699
+ return card;
2700
+ }
2701
+
2569
2702
  function renderRuns(jobs) {
2570
2703
  const runs = jobs.filter((j) => RUN_STATUSES.has(j.status)).slice(0, 40);
2571
2704
  els.runsList.innerHTML = '';
@@ -2619,8 +2752,10 @@ function renderRuns(jobs) {
2619
2752
  preview.className = 'run-log-preview';
2620
2753
  for (const ev of previewLogs) {
2621
2754
  const line = document.createElement('div');
2755
+ line.className = 'run-log-preview-line';
2622
2756
  const f = formatLogEvent(ev);
2623
- line.textContent = `[${formatTime(ev.ts)}] ${f.text}`;
2757
+ const text = truncate(String(f.text || '').replace(/\s+/g, ' '), 140);
2758
+ line.textContent = `[${formatTime(ev.ts)}] ${text}`;
2624
2759
  preview.appendChild(line);
2625
2760
  }
2626
2761
  card.appendChild(preview);
@@ -2663,6 +2798,10 @@ function renderRuns(jobs) {
2663
2798
  detail.appendChild(banner);
2664
2799
  }
2665
2800
 
2801
+ if (job.status === 'awaiting_permission' && job.pendingPermission) {
2802
+ detail.appendChild(buildPermissionRequestCard(job));
2803
+ }
2804
+
2666
2805
  if (job.status === 'failed') {
2667
2806
  const err = document.createElement('div');
2668
2807
  err.className = 'error-banner';
@@ -3202,6 +3341,18 @@ function renderAlerts(jobs) {
3202
3341
  actions.appendChild(retry);
3203
3342
  }
3204
3343
 
3344
+ if (alert.actions.includes('decide-permission')) {
3345
+ const decide = document.createElement('button');
3346
+ decide.type = 'button';
3347
+ decide.className = 'btn btn-primary btn-sm';
3348
+ decide.textContent = 'Decide';
3349
+ decide.addEventListener('click', () => {
3350
+ setView('runs');
3351
+ selectRun(alert.jobId);
3352
+ });
3353
+ actions.appendChild(decide);
3354
+ }
3355
+
3205
3356
  if (alert.actions.includes('clear')) {
3206
3357
  const clear = document.createElement('button');
3207
3358
  clear.type = 'button';
@@ -3350,6 +3501,30 @@ async function clearJob(jobId) {
3350
3501
  }
3351
3502
  }
3352
3503
 
3504
+ /**
3505
+ * Resolve a pending tool-approval request for a job awaiting permission.
3506
+ * @param {string} jobId
3507
+ * @param {string} requestId
3508
+ * @param {'allow' | 'deny'} decision
3509
+ */
3510
+ async function decidePermission(jobId, requestId, decision) {
3511
+ try {
3512
+ const res = await fetch(`/api/jobs/${jobId}/permission`, {
3513
+ method: 'POST',
3514
+ headers: { 'Content-Type': 'application/json' },
3515
+ body: JSON.stringify({ requestId, decision }),
3516
+ });
3517
+ const data = await readJson(res);
3518
+ if (!res.ok) {
3519
+ alert(data.error || `Could not record decision (HTTP ${res.status})`);
3520
+ return;
3521
+ }
3522
+ } catch (err) {
3523
+ alert(`Could not record decision: ${err.message}`);
3524
+ }
3525
+ await fetchJobs();
3526
+ }
3527
+
3353
3528
  async function fetchJobs() {
3354
3529
  try {
3355
3530
  const res = await fetch('/api/jobs');
@@ -4755,6 +4930,58 @@ async function saveLlmProvider(next) {
4755
4930
  }
4756
4931
  }
4757
4932
 
4933
+ /**
4934
+ * @param {'manual' | 'auto_edit' | 'full_auto'} mode
4935
+ */
4936
+ function updatePermissionModeUI(mode) {
4937
+ permissionMode = PERMISSION_MODE_META[mode] ? mode : 'full_auto';
4938
+ const meta = PERMISSION_MODE_META[permissionMode];
4939
+ for (const toggle of [els.settingsPermissionMode, els.overviewPermissionMode]) {
4940
+ if (!toggle) continue;
4941
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
4942
+ const active = btn.dataset.mode === permissionMode;
4943
+ btn.classList.toggle('active', active);
4944
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4945
+ });
4946
+ }
4947
+ if (els.settingsPermissionModeHint) els.settingsPermissionModeHint.textContent = meta.hint;
4948
+ if (els.overviewPermissionModeHint) els.overviewPermissionModeHint.textContent = meta.hint;
4949
+ }
4950
+
4951
+ /** @param {'manual' | 'auto_edit' | 'full_auto'} next */
4952
+ async function savePermissionMode(next) {
4953
+ const mode = PERMISSION_MODE_META[next] ? next : 'full_auto';
4954
+ const prev = permissionMode;
4955
+ updatePermissionModeUI(mode);
4956
+ try {
4957
+ const res = await fetch('/api/config', {
4958
+ method: 'PATCH',
4959
+ headers: { 'Content-Type': 'application/json' },
4960
+ body: JSON.stringify({ permissionMode: mode }),
4961
+ });
4962
+ const data = await readJson(res);
4963
+ if (!res.ok) {
4964
+ updatePermissionModeUI(prev);
4965
+ const msg = data.error || `Update failed (HTTP ${res.status})`;
4966
+ if (els.overviewPermissionModeFeedback) {
4967
+ els.overviewPermissionModeFeedback.textContent = msg;
4968
+ els.overviewPermissionModeFeedback.className = 'feedback error';
4969
+ els.overviewPermissionModeFeedback.classList.remove('hidden');
4970
+ }
4971
+ setSettingsFeedback(msg, 'error');
4972
+ return;
4973
+ }
4974
+ appConfig = { ...appConfig, ...data };
4975
+ } catch (err) {
4976
+ updatePermissionModeUI(prev);
4977
+ if (els.overviewPermissionModeFeedback) {
4978
+ els.overviewPermissionModeFeedback.textContent = err.message || 'Update failed';
4979
+ els.overviewPermissionModeFeedback.className = 'feedback error';
4980
+ els.overviewPermissionModeFeedback.classList.remove('hidden');
4981
+ }
4982
+ }
4983
+ }
4984
+
4758
4985
  function updateTimeoutMsHint(minutes) {
4759
4986
  if (!els.settingsTimeoutMs) return;
4760
4987
  const m = Number(minutes);
@@ -4834,6 +5061,7 @@ function applyConfigSnapshot(data) {
4834
5061
  data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude',
4835
5062
  data || appConfig
4836
5063
  );
5064
+ updatePermissionModeUI(data?.permissionMode || 'full_auto');
4837
5065
  if (els.repoName) {
4838
5066
  els.repoName.textContent = data?.repoName || 'local repo';
4839
5067
  }
@@ -5245,6 +5473,18 @@ function handleLlmProviderToggleClick(e) {
5245
5473
  els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
5246
5474
  els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
5247
5475
 
5476
+ function handlePermissionModeToggleClick(e) {
5477
+ const btn = e.target.closest('.source-btn');
5478
+ if (!btn?.dataset.mode) return;
5479
+ const next = PERMISSION_MODE_META[btn.dataset.mode] ? btn.dataset.mode : 'full_auto';
5480
+ if (next === permissionMode) return;
5481
+ e.stopPropagation();
5482
+ void savePermissionMode(next);
5483
+ }
5484
+
5485
+ els.settingsPermissionMode?.addEventListener('click', handlePermissionModeToggleClick);
5486
+ els.overviewPermissionMode?.addEventListener('click', handlePermissionModeToggleClick);
5487
+
5248
5488
  els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
5249
5489
  e.preventDefault();
5250
5490
  setView('settings');
package/public/index.html CHANGED
@@ -238,6 +238,26 @@
238
238
  </div>
239
239
  </section>
240
240
 
241
+ <section>
242
+ <div class="card permission-mode-card">
243
+ <div class="permission-mode-card-header">
244
+ <div class="field-label">Agent permissions</div>
245
+ <div
246
+ class="source-toggle source-toggle--compact"
247
+ id="overview-permission-mode"
248
+ role="group"
249
+ aria-label="Agent permission mode"
250
+ >
251
+ <button type="button" class="source-btn" data-mode="manual">Manual review</button>
252
+ <button type="button" class="source-btn" data-mode="auto_edit">Auto-accept edits</button>
253
+ <button type="button" class="source-btn active" data-mode="full_auto">Full auto</button>
254
+ </div>
255
+ </div>
256
+ <p class="field-hint" id="overview-permission-mode-hint"></p>
257
+ <p id="overview-permission-mode-feedback" class="feedback hidden" role="status"></p>
258
+ </div>
259
+ </section>
260
+
241
261
  <section class="stats-grid" id="stats-grid"></section>
242
262
 
243
263
  <section>
@@ -246,6 +266,12 @@
246
266
  <p id="overview-running-empty" class="empty-inline hidden">Nothing running.</p>
247
267
  </section>
248
268
 
269
+ <section>
270
+ <div class="section-label">Needs your permission — waiting on you</div>
271
+ <div id="overview-permission" class="item-stack"></div>
272
+ <p id="overview-permission-empty" class="empty-inline hidden">No pending permission requests.</p>
273
+ </section>
274
+
249
275
  <section>
250
276
  <div class="section-label">Ready for review — waiting on you</div>
251
277
  <div id="overview-review" class="item-stack"></div>
@@ -694,6 +720,15 @@
694
720
  </div>
695
721
  <p class="field-hint" id="settings-llm-provider-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.</p>
696
722
  </div>
723
+ <div class="settings-field settings-field-full">
724
+ <div class="field-label">Permission mode</div>
725
+ <div class="source-toggle" id="settings-permission-mode" role="group" aria-label="Agent permission mode">
726
+ <button type="button" class="source-btn" data-mode="manual">Manual review</button>
727
+ <button type="button" class="source-btn" data-mode="auto_edit">Auto-accept edits</button>
728
+ <button type="button" class="source-btn active" data-mode="full_auto">Full auto</button>
729
+ </div>
730
+ <p class="field-hint" id="settings-permission-mode-hint"></p>
731
+ </div>
697
732
  <div class="settings-grid">
698
733
  <div class="settings-field">
699
734
  <label class="field-label" for="settings-base-branch">Base branch</label>
package/public/styles.css CHANGED
@@ -1021,6 +1021,11 @@ a { color: var(--primary); text-underline-offset: 3px; }
1021
1021
  color: var(--primary);
1022
1022
  }
1023
1023
 
1024
+ .status-pill.awaiting_permission {
1025
+ background: var(--amber-100);
1026
+ color: var(--amber-800);
1027
+ }
1028
+
1024
1029
  .status-pill.pr_opened {
1025
1030
  background: var(--green-100);
1026
1031
  color: var(--green-800);
@@ -1307,9 +1312,17 @@ a { color: var(--primary); text-underline-offset: 3px; }
1307
1312
  display: flex;
1308
1313
  align-items: center;
1309
1314
  gap: 8px;
1315
+ min-width: 0;
1310
1316
  }
1311
1317
 
1312
- .run-now-label { color: var(--text-muted); }
1318
+ .run-now-label { color: var(--text-muted); flex: none; }
1319
+
1320
+ .run-now .mono {
1321
+ min-width: 0;
1322
+ overflow: hidden;
1323
+ text-overflow: ellipsis;
1324
+ white-space: nowrap;
1325
+ }
1313
1326
 
1314
1327
  .run-log-preview {
1315
1328
  background: var(--surface-2);
@@ -1323,6 +1336,12 @@ a { color: var(--primary); text-underline-offset: 3px; }
1323
1336
  overflow: auto;
1324
1337
  }
1325
1338
 
1339
+ .run-log-preview-line {
1340
+ overflow: hidden;
1341
+ text-overflow: ellipsis;
1342
+ white-space: nowrap;
1343
+ }
1344
+
1326
1345
  .run-detail {
1327
1346
  display: flex;
1328
1347
  flex-direction: column;
@@ -2182,6 +2201,52 @@ body.diff-fs-open {
2182
2201
  font-size: 13px;
2183
2202
  }
2184
2203
 
2204
+ .permission-card {
2205
+ display: flex;
2206
+ flex-direction: column;
2207
+ gap: 10px;
2208
+ padding: 12px 14px;
2209
+ background: var(--amber-100);
2210
+ color: var(--amber-800);
2211
+ border-radius: 8px;
2212
+ font-size: 13px;
2213
+ }
2214
+
2215
+ .permission-card-header {
2216
+ display: flex;
2217
+ align-items: flex-start;
2218
+ gap: 10px;
2219
+ }
2220
+
2221
+ .permission-card-icon {
2222
+ flex: none;
2223
+ font-size: 16px;
2224
+ line-height: 1.4;
2225
+ }
2226
+
2227
+ .permission-card-title { font-weight: 600; }
2228
+
2229
+ .permission-card-sub {
2230
+ font-size: 12px;
2231
+ color: color-mix(in srgb, var(--amber-800) 80%, var(--text-muted));
2232
+ margin-top: 2px;
2233
+ }
2234
+
2235
+ .permission-card-input {
2236
+ margin: 0;
2237
+ padding: 8px 10px;
2238
+ background: var(--bg);
2239
+ color: var(--text);
2240
+ border: 1px solid var(--border-soft);
2241
+ border-radius: 6px;
2242
+ font-family: "JetBrains Mono", ui-monospace, monospace;
2243
+ font-size: 12px;
2244
+ white-space: pre-wrap;
2245
+ word-break: break-word;
2246
+ max-height: 160px;
2247
+ overflow: auto;
2248
+ }
2249
+
2185
2250
  /* —— Scrollbars —— */
2186
2251
  .amcp-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
2187
2252
  .amcp-scroll::-webkit-scrollbar-thumb {
@@ -2565,7 +2630,7 @@ body.diff-fs-open {
2565
2630
  color: var(--text-muted);
2566
2631
  cursor: pointer;
2567
2632
  white-space: nowrap;
2568
- transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
2633
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
2569
2634
  }
2570
2635
 
2571
2636
  .source-toggle--compact .source-btn {
@@ -2587,6 +2652,20 @@ body.diff-fs-open {
2587
2652
  box-shadow: var(--shadow-sm);
2588
2653
  }
2589
2654
 
2655
+ /* Agent permission mode: selected button uses the theme's primary color. */
2656
+ #settings-permission-mode .source-btn.active,
2657
+ #overview-permission-mode .source-btn.active {
2658
+ background: var(--primary);
2659
+ color: var(--btn-on-primary);
2660
+ border-color: var(--primary);
2661
+ }
2662
+
2663
+ #settings-permission-mode .source-btn.active:hover:not(:disabled),
2664
+ #overview-permission-mode .source-btn.active:hover:not(:disabled) {
2665
+ background: var(--primary-hover);
2666
+ border-color: var(--primary-hover);
2667
+ }
2668
+
2590
2669
  .source-btn:disabled {
2591
2670
  opacity: 0.45;
2592
2671
  cursor: not-allowed;
package/src/agent.js CHANGED
@@ -429,9 +429,17 @@ export function mergeAgentResult(current, incoming) {
429
429
  * config: object,
430
430
  * onEvent: (message: unknown) => void,
431
431
  * queryFn?: typeof query,
432
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
432
433
  * }} params
433
434
  */
434
- async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn = query }) {
435
+ async function runAgentQuery({
436
+ prompt,
437
+ worktreePath,
438
+ config,
439
+ onEvent,
440
+ queryFn = query,
441
+ requestApproval,
442
+ }) {
435
443
  const timeoutMs = config.agentTimeoutMs ?? 900_000;
436
444
  let lastMessage = null;
437
445
  let resultMessage = null;
@@ -441,6 +449,18 @@ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn =
441
449
  // instructions unless attribution is cleared via Claude settings.
442
450
  ensureNoAiAttributionSettings(worktreePath);
443
451
 
452
+ // Only override the SDK's own permission handling when a gate is wired up
453
+ // (permissionMode !== 'full_auto'). Otherwise keep the exact prior
454
+ // behavior: 'acceptEdits' with no canUseTool callback.
455
+ const canUseTool = requestApproval
456
+ ? async (toolName, input) => {
457
+ const decision = await requestApproval(toolName, input);
458
+ return decision.allowed
459
+ ? { behavior: 'allow', updatedInput: input }
460
+ : { behavior: 'deny', message: decision.message || `${toolName} was denied.` };
461
+ }
462
+ : undefined;
463
+
444
464
  const runLoop = async () => {
445
465
  try {
446
466
  for await (const message of queryFn({
@@ -450,13 +470,14 @@ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn =
450
470
  cwd: worktreePath,
451
471
  allowedTools: config.allowedTools,
452
472
  disallowedTools: DISALLOWED_AGENT_TOOLS,
453
- permissionMode: 'acceptEdits',
473
+ permissionMode: canUseTool ? 'default' : 'acceptEdits',
454
474
  maxTurns: config.maxAgentTurns,
455
475
  model: config.model || 'claude-sonnet-5',
456
476
  // Load only local settings so our empty attribution wins without
457
477
  // pulling in unrelated user settings.
458
478
  settingSources: ['local'],
459
479
  abortController,
480
+ ...(canUseTool ? { canUseTool } : {}),
460
481
  },
461
482
  })) {
462
483
  onEvent(message);
@@ -513,6 +534,7 @@ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn =
513
534
  * onEvent: (message: unknown) => void,
514
535
  * queryFn?: typeof query,
515
536
  * callModelFn?: (args: object) => object,
537
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
516
538
  * }} params
517
539
  */
518
540
  async function runConfiguredQuery({
@@ -522,6 +544,7 @@ async function runConfiguredQuery({
522
544
  onEvent,
523
545
  queryFn,
524
546
  callModelFn,
547
+ requestApproval,
525
548
  }) {
526
549
  if (config.llmProvider === 'openrouter') {
527
550
  const out = await runOpenRouterQuery({
@@ -530,6 +553,7 @@ async function runConfiguredQuery({
530
553
  config,
531
554
  onEvent,
532
555
  callModelFn,
556
+ requestApproval,
533
557
  });
534
558
  return {
535
559
  resultText: out.resultText,
@@ -537,7 +561,7 @@ async function runConfiguredQuery({
537
561
  usage: out.usage,
538
562
  };
539
563
  }
540
- return runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn });
564
+ return runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn, requestApproval });
541
565
  }
542
566
 
543
567
  function stubAgentResult(onEvent, title, body) {
@@ -591,6 +615,7 @@ function stubAgentResult(onEvent, title, body) {
591
615
  * userPrompt?: string,
592
616
  * queryFn?: typeof query,
593
617
  * callModelFn?: (args: object) => object,
618
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
594
619
  * }} params
595
620
  */
596
621
  export async function runAgentOnIssue({
@@ -607,6 +632,7 @@ export async function runAgentOnIssue({
607
632
  userPrompt,
608
633
  queryFn = query,
609
634
  callModelFn,
635
+ requestApproval,
610
636
  }) {
611
637
  if (stub) {
612
638
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -634,6 +660,7 @@ export async function runAgentOnIssue({
634
660
  onEvent,
635
661
  queryFn,
636
662
  callModelFn,
663
+ requestApproval,
637
664
  });
638
665
 
639
666
  if (meta) {
@@ -667,6 +694,7 @@ export async function runAgentOnIssue({
667
694
  * jiraKey?: string,
668
695
  * queryFn?: typeof query,
669
696
  * callModelFn?: (args: object) => object,
697
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
670
698
  * }} params
671
699
  */
672
700
  export async function runAgentOnReviewFeedback({
@@ -683,6 +711,7 @@ export async function runAgentOnReviewFeedback({
683
711
  jiraKey,
684
712
  queryFn = query,
685
713
  callModelFn,
714
+ requestApproval,
686
715
  }) {
687
716
  if (stub) {
688
717
  await new Promise((resolve) => setTimeout(resolve, 500));
@@ -712,6 +741,7 @@ export async function runAgentOnReviewFeedback({
712
741
  onEvent,
713
742
  queryFn,
714
743
  callModelFn,
744
+ requestApproval,
715
745
  });
716
746
 
717
747
  if (meta) {
package/src/config.js CHANGED
@@ -17,6 +17,9 @@ import {
17
17
  OPENROUTER_MODEL_OPTIONS,
18
18
  } from './models.js';
19
19
  import { dataDir } from './paths.js';
20
+ import { PERMISSION_MODES, DEFAULT_PERMISSION_MODE, normalizePermissionMode } from './permissions.js';
21
+
22
+ export { PERMISSION_MODES, normalizePermissionMode };
20
23
 
21
24
  export { DEFAULT_MODEL, CLAUDE_MODEL_OPTIONS, MODEL_OPTIONS, NO_MODEL } from './models.js';
22
25
 
@@ -48,6 +51,7 @@ const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
48
51
  const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
49
52
  const ALLOWED_LLM_PROVIDERS = new Set(LLM_PROVIDERS);
50
53
  const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
54
+ const ALLOWED_PERMISSION_MODES = new Set(PERMISSION_MODES);
51
55
 
52
56
  /** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
53
57
 
@@ -81,6 +85,7 @@ const DEFAULTS = {
81
85
  jiraPrLinkPhrase: 'Relates to',
82
86
  jiraRules: structuredClone(DEFAULT_JIRA_RULES),
83
87
  githubRules: structuredClone(DEFAULT_GITHUB_RULES),
88
+ permissionMode: DEFAULT_PERMISSION_MODE,
84
89
  };
85
90
 
86
91
  /**
@@ -342,6 +347,7 @@ function persistable(config) {
342
347
  jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
343
348
  jiraRules: normalizeJiraRules(config.jiraRules),
344
349
  githubRules: normalizeGithubRules(config.githubRules),
350
+ permissionMode: normalizePermissionMode(config.permissionMode),
345
351
  };
346
352
  }
347
353
 
@@ -445,6 +451,10 @@ export function loadConfig(repoRoot) {
445
451
  config.jiraPrLinkPhrase = 'Relates to';
446
452
  }
447
453
 
454
+ if (!ALLOWED_PERMISSION_MODES.has(config.permissionMode)) {
455
+ config.permissionMode = DEFAULT_PERMISSION_MODE;
456
+ }
457
+
448
458
  if (needsSave) {
449
459
  saveConfig(repoRoot, persistable(config));
450
460
  }
@@ -566,6 +576,15 @@ export function updateConfig(repoRoot, config, patch) {
566
576
  config.jiraPrLinkPhrase = patch.jiraPrLinkPhrase.trim();
567
577
  }
568
578
 
579
+ if (patch.permissionMode !== undefined) {
580
+ if (!ALLOWED_PERMISSION_MODES.has(patch.permissionMode)) {
581
+ throw new Error(
582
+ `Invalid permissionMode "${patch.permissionMode}". Allowed: ${PERMISSION_MODES.join(', ')}`
583
+ );
584
+ }
585
+ config.permissionMode = patch.permissionMode;
586
+ }
587
+
569
588
  if (patch.jiraRules !== undefined) {
570
589
  config.jiraRules = normalizeJiraRules(patch.jiraRules, { throwOnInvalid: true });
571
590
  }
@@ -653,6 +672,8 @@ export function publicConfig(config, opts = {}) {
653
672
  jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
654
673
  jiraRules: normalizeJiraRules(config.jiraRules),
655
674
  githubRules: normalizeGithubRules(config.githubRules),
675
+ permissionMode: normalizePermissionMode(config.permissionMode),
676
+ permissionModes: PERMISSION_MODES,
656
677
  jiraEmail: jiraEmail || null,
657
678
  jiraApiTokenSet: tokenMask.set,
658
679
  jiraApiTokenMasked: tokenMask.masked,
@@ -31,6 +31,7 @@ function toolUseEvent(call) {
31
31
  * config: object,
32
32
  * onEvent: (message: unknown) => void,
33
33
  * callModelFn?: (args: object) => object,
34
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
34
35
  * }} params
35
36
  */
36
37
  export async function runOpenRouterQuery({
@@ -39,6 +40,7 @@ export async function runOpenRouterQuery({
39
40
  config,
40
41
  onEvent,
41
42
  callModelFn,
43
+ requestApproval,
42
44
  }) {
43
45
  const auth = checkOpenRouterAuth();
44
46
  if (!auth.ok && !callModelFn) {
@@ -54,7 +56,9 @@ export async function runOpenRouterQuery({
54
56
  throw new Error('No OpenRouter model selected');
55
57
  }
56
58
 
57
- const tools = buildOpenRouterCodingTools(worktreePath, config.allowedTools || []);
59
+ const tools = buildOpenRouterCodingTools(worktreePath, config.allowedTools || [], {
60
+ requestApproval,
61
+ });
58
62
  const abortController = new AbortController();
59
63
  const started = Date.now();
60
64
 
@@ -98,13 +98,33 @@ function relToRoot(root, abs) {
98
98
  /**
99
99
  * @param {string} worktreePath
100
100
  * @param {string[]} allowedTools
101
+ * @param {{
102
+ * requestApproval?: (toolName: string, input: unknown) => Promise<{ allowed: boolean, message?: string }>,
103
+ * }} [opts]
101
104
  */
102
- export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
105
+ export function buildOpenRouterCodingTools(worktreePath, allowedTools, opts = {}) {
106
+ const { requestApproval } = opts;
103
107
  const allowed = new Set(allowedTools || []);
104
108
  const root = path.resolve(worktreePath);
105
109
  /** @type {ReturnType<typeof tool>[]} */
106
110
  const tools = [];
107
111
 
112
+ /**
113
+ * Gate a tool's execute function behind the configured permission mode.
114
+ * @param {string} name
115
+ * @param {(input: any) => Promise<string>} executeFn
116
+ */
117
+ const guard = (name, executeFn) => {
118
+ if (!requestApproval) return executeFn;
119
+ return async (input) => {
120
+ const decision = await requestApproval(name, input);
121
+ if (!decision.allowed) {
122
+ throw new Error(decision.message || `${name} was denied by user review.`);
123
+ }
124
+ return executeFn(input);
125
+ };
126
+ };
127
+
108
128
  if (allowed.has('Read')) {
109
129
  tools.push(
110
130
  tool({
@@ -115,7 +135,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
115
135
  offset: z.number().int().positive().optional(),
116
136
  limit: z.number().int().positive().optional(),
117
137
  }),
118
- execute: async ({ path: rel, offset, limit }) => {
138
+ execute: guard('Read', async ({ path: rel, offset, limit }) => {
119
139
  const full = resolveInWorktree(root, rel);
120
140
  const text = fs.readFileSync(full, 'utf8');
121
141
  const lines = text.split('\n');
@@ -123,7 +143,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
123
143
  const slice = limit ? lines.slice(start, start + limit) : lines.slice(start);
124
144
  const numbered = slice.map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`);
125
145
  return numbered.join('\n') || '(empty file)';
126
- },
146
+ }),
127
147
  })
128
148
  );
129
149
  }
@@ -137,12 +157,12 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
137
157
  path: z.string().describe('Path relative to the worktree root'),
138
158
  content: z.string().describe('Full file contents'),
139
159
  }),
140
- execute: async ({ path: rel, content }) => {
160
+ execute: guard('Write', async ({ path: rel, content }) => {
141
161
  const full = resolveInWorktree(root, rel);
142
162
  fs.mkdirSync(path.dirname(full), { recursive: true });
143
163
  fs.writeFileSync(full, content, 'utf8');
144
164
  return `Wrote ${relToRoot(root, full)} (${Buffer.byteLength(content, 'utf8')} bytes)`;
145
- },
165
+ }),
146
166
  })
147
167
  );
148
168
  }
@@ -159,7 +179,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
159
179
  new_string: z.string(),
160
180
  replace_all: z.boolean().optional(),
161
181
  }),
162
- execute: async ({ path: rel, old_string, new_string, replace_all }) => {
182
+ execute: guard('Edit', async ({ path: rel, old_string, new_string, replace_all }) => {
163
183
  const full = resolveInWorktree(root, rel);
164
184
  const before = fs.readFileSync(full, 'utf8');
165
185
  const count = before.split(old_string).length - 1;
@@ -176,7 +196,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
176
196
  : before.replace(old_string, new_string);
177
197
  fs.writeFileSync(full, after, 'utf8');
178
198
  return `Edited ${relToRoot(root, full)} (${count} replacement${count === 1 ? '' : 's'})`;
179
- },
199
+ }),
180
200
  })
181
201
  );
182
202
  }
@@ -190,14 +210,14 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
190
210
  pattern: z.string(),
191
211
  path: z.string().optional().describe('Subdirectory to search from'),
192
212
  }),
193
- execute: async ({ pattern, path: sub }) => {
213
+ execute: guard('Glob', async ({ pattern, path: sub }) => {
194
214
  const re = globToRegExp(pattern);
195
215
  const files = listWorktreeFiles(root, sub)
196
216
  .map((abs) => relToRoot(root, abs))
197
217
  .filter((rel) => re.test(rel) || re.test(rel.split('/').pop() || rel));
198
218
  if (files.length === 0) return '(no matches)';
199
219
  return files.sort().join('\n');
200
- },
220
+ }),
201
221
  })
202
222
  );
203
223
  }
@@ -212,7 +232,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
212
232
  path: z.string().optional(),
213
233
  glob: z.string().optional(),
214
234
  }),
215
- execute: async ({ pattern, path: sub, glob }) => {
235
+ execute: guard('Grep', async ({ pattern, path: sub, glob }) => {
216
236
  let re;
217
237
  try {
218
238
  re = new RegExp(pattern);
@@ -243,7 +263,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
243
263
  }
244
264
  }
245
265
  return hits.length ? hits.join('\n') : '(no matches)';
246
- },
266
+ }),
247
267
  })
248
268
  );
249
269
  }
@@ -256,7 +276,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
256
276
  inputSchema: z.object({
257
277
  command: z.string(),
258
278
  }),
259
- execute: async ({ command }) => {
279
+ execute: guard('Bash', async ({ command }) => {
260
280
  const cmd = String(command || '').trim();
261
281
  if (!cmd) throw new Error('command is required');
262
282
  const shell = process.env.SHELL || '/bin/bash';
@@ -282,7 +302,7 @@ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
282
302
  .filter(Boolean);
283
303
  throw new Error(bits.join('\n') || 'Command failed');
284
304
  }
285
- },
305
+ }),
286
306
  })
287
307
  );
288
308
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Permission modes controlling how much autonomy the agent has over tool
3
+ * calls (Read/Glob/Grep/Edit/Write/Bash), mirroring Claude Code's own
4
+ * permission modes:
5
+ * - `full_auto` — never asks (Claude Code's "bypass permissions").
6
+ * - `auto_edit` — file reads/edits proceed automatically; anything else
7
+ * (Bash, and any tool outside the known read/edit set)
8
+ * pauses for a human decision (Claude Code's "accept edits").
9
+ * - `manual` — every tool call pauses for a human decision
10
+ * (Claude Code's default interactive mode).
11
+ */
12
+ export const PERMISSION_MODES = /** @type {const} */ (['manual', 'auto_edit', 'full_auto']);
13
+ export const DEFAULT_PERMISSION_MODE = 'full_auto';
14
+
15
+ const ALLOWED_PERMISSION_MODES = new Set(PERMISSION_MODES);
16
+
17
+ /** Tools that only read state — safe to auto-approve outside `manual`. */
18
+ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep']);
19
+ /** Tools that edit files in the worktree — auto-approved under `auto_edit`. */
20
+ const FILE_EDIT_TOOLS = new Set(['Edit', 'Write']);
21
+
22
+ /**
23
+ * @param {unknown} value
24
+ * @returns {'manual' | 'auto_edit' | 'full_auto'}
25
+ */
26
+ export function normalizePermissionMode(value) {
27
+ return ALLOWED_PERMISSION_MODES.has(value) ? value : DEFAULT_PERMISSION_MODE;
28
+ }
29
+
30
+ /**
31
+ * True when a tool call must pause for human approval under `permissionMode`.
32
+ * @param {string} toolName
33
+ * @param {string} permissionMode
34
+ */
35
+ export function toolNeedsApproval(toolName, permissionMode) {
36
+ const mode = normalizePermissionMode(permissionMode);
37
+ if (mode === 'full_auto') return false;
38
+ if (mode === 'manual') return true;
39
+ return !(READ_ONLY_TOOLS.has(toolName) || FILE_EDIT_TOOLS.has(toolName));
40
+ }
41
+
42
+ /**
43
+ * Bridges agent tool calls to human approval via the job store. Only one job
44
+ * runs at a time (see `processQueue` in server.js), so a single
45
+ * pending-request-per-job map is sufficient.
46
+ */
47
+ export class PermissionController {
48
+ /**
49
+ * @param {{
50
+ * store: import('./store.js').Store,
51
+ * onEvent: (jobId: string, event: object) => void,
52
+ * }} deps
53
+ */
54
+ constructor({ store, onEvent }) {
55
+ this.store = store;
56
+ this.onEvent = onEvent;
57
+ /** @type {Map<string, { requestId: string, resolve: (r: { allowed: boolean, message?: string }) => void }>} */
58
+ this.pending = new Map();
59
+ }
60
+
61
+ /** @param {string} jobId */
62
+ hasPending(jobId) {
63
+ return this.pending.has(jobId);
64
+ }
65
+
66
+ /**
67
+ * Request approval for a tool call. Resolves immediately when the
68
+ * configured mode auto-allows the tool; otherwise pauses the job (status →
69
+ * `awaiting_permission`) until `resolve()` is called from the API, or
70
+ * `cancel()` is called because the job ended some other way.
71
+ * @param {{ jobId: string, toolName: string, input: unknown, permissionMode: string }} args
72
+ * @returns {Promise<{ allowed: boolean, message?: string }>}
73
+ */
74
+ requestApproval({ jobId, toolName, input, permissionMode }) {
75
+ if (!toolNeedsApproval(toolName, permissionMode)) {
76
+ return Promise.resolve({ allowed: true });
77
+ }
78
+
79
+ const job = this.store.getJob(jobId);
80
+ if (!job) return Promise.resolve({ allowed: true });
81
+
82
+ const requestId = `perm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
83
+ const request = {
84
+ id: requestId,
85
+ tool: toolName,
86
+ input,
87
+ requestedAt: new Date().toISOString(),
88
+ };
89
+ const event = { ts: request.requestedAt, type: 'permission_request', payload: request };
90
+
91
+ const updated = this.store.updateJob(jobId, {
92
+ status: 'awaiting_permission',
93
+ pendingPermission: request,
94
+ permissionPrevStatus: job.status,
95
+ logs: [...(job.logs || []), event],
96
+ });
97
+ this.onEvent(jobId, event);
98
+ void updated;
99
+
100
+ return new Promise((resolve) => {
101
+ this.pending.set(jobId, { requestId, resolve });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Resolve a pending request from the API.
107
+ * @param {string} jobId
108
+ * @param {string} requestId
109
+ * @param {'allow' | 'deny'} decision
110
+ * @returns {{ ok: true, job: object } | { ok: false, error: string }}
111
+ */
112
+ resolve(jobId, requestId, decision) {
113
+ const pending = this.pending.get(jobId);
114
+ if (!pending || pending.requestId !== requestId) {
115
+ return { ok: false, error: 'No matching pending permission request for this job.' };
116
+ }
117
+ this.pending.delete(jobId);
118
+
119
+ const job = this.store.getJob(jobId);
120
+ if (!job) {
121
+ return { ok: false, error: 'Job not found.' };
122
+ }
123
+ const prevStatus = job.permissionPrevStatus || 'running';
124
+ const tool = job.pendingPermission?.tool;
125
+ const event = {
126
+ ts: new Date().toISOString(),
127
+ type: 'permission_decision',
128
+ payload: { tool, decision },
129
+ };
130
+ this.store.updateJob(jobId, {
131
+ status: prevStatus,
132
+ pendingPermission: undefined,
133
+ permissionPrevStatus: undefined,
134
+ logs: [...(job.logs || []), event],
135
+ });
136
+ this.onEvent(jobId, event);
137
+
138
+ pending.resolve({
139
+ allowed: decision === 'allow',
140
+ message: decision === 'allow' ? undefined : `${tool || 'Tool call'} was denied by user.`,
141
+ });
142
+
143
+ return { ok: true, job: this.store.getJob(jobId) };
144
+ }
145
+
146
+ /**
147
+ * Drop a stale pending request without waiting (job ended some other way,
148
+ * e.g. it timed out while paused). No-op if nothing is pending.
149
+ * @param {string} jobId
150
+ * @param {string} [message]
151
+ */
152
+ cancel(jobId, message = 'Job ended before the permission request was answered.') {
153
+ const pending = this.pending.get(jobId);
154
+ if (!pending) return;
155
+ this.pending.delete(jobId);
156
+ pending.resolve({ allowed: false, message });
157
+ }
158
+ }
package/src/server.js CHANGED
@@ -48,6 +48,7 @@ import { checkGhAuth, originRemoteInfo } from './gh-auth.js';
48
48
  import { checkClaudeAuth } from './claude-auth.js';
49
49
  import { checkOpenRouterAuth } from './openrouter-auth.js';
50
50
  import { isValidModelId, isNoModel, NO_MODEL, isModelIdForProvider } from './models.js';
51
+ import { PermissionController, normalizePermissionMode } from './permissions.js';
51
52
 
52
53
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
53
54
 
@@ -57,6 +58,7 @@ const IN_FLIGHT_STATUSES = new Set([
57
58
  'syncing',
58
59
  'preparing_worktree',
59
60
  'applying_feedback',
61
+ 'awaiting_permission',
60
62
  ]);
61
63
 
62
64
  /** Statuses allowed for DELETE /api/jobs/:id */
@@ -329,6 +331,27 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
329
331
  }
330
332
  }
331
333
 
334
+ const permissionController = new PermissionController({ store, onEvent: emitEvent });
335
+
336
+ /**
337
+ * Build a per-job tool-approval bridge for the agent SDKs. Returns
338
+ * `undefined` in `full_auto` mode so behavior is byte-for-byte identical
339
+ * to before this feature existed.
340
+ * @param {string} jobId
341
+ */
342
+ function requestApprovalForJob(jobId) {
343
+ if (normalizePermissionMode(config.permissionMode) === 'full_auto') {
344
+ return undefined;
345
+ }
346
+ return (toolName, input) =>
347
+ permissionController.requestApproval({
348
+ jobId,
349
+ toolName,
350
+ input,
351
+ permissionMode: config.permissionMode,
352
+ });
353
+ }
354
+
332
355
  function appendLog(job, type, payload) {
333
356
  const event = {
334
357
  ts: new Date().toISOString(),
@@ -475,6 +498,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
475
498
  jiraKey: job.jiraKey,
476
499
  jiraIssue,
477
500
  userPrompt: job.userPrompt,
501
+ requestApproval: requestApprovalForJob(jobId),
478
502
  });
479
503
 
480
504
  if (!store.getJob(jobId)) return;
@@ -493,6 +517,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
493
517
  if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
494
518
  setStatus(jobId, 'awaiting_review', patch);
495
519
  } catch (err) {
520
+ permissionController.cancel(jobId);
496
521
  const message = formatAgentJobError(err);
497
522
  const current = store.getJob(jobId);
498
523
  if (!current) return;
@@ -579,6 +604,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
579
604
  issueNumber: job.issueNumber,
580
605
  ticketSource: job.ticketSource === 'jira' || job.jiraKey ? 'jira' : 'github',
581
606
  jiraKey: job.jiraKey,
607
+ requestApproval: requestApprovalForJob(jobId),
582
608
  });
583
609
 
584
610
  if (!store.getJob(jobId)) return;
@@ -597,6 +623,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
597
623
  if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
598
624
  setStatus(jobId, 'awaiting_review', patch);
599
625
  } catch (err) {
626
+ permissionController.cancel(jobId);
600
627
  const message = formatAgentJobError(err);
601
628
  const current = store.getJob(jobId);
602
629
  if (!current) return;
@@ -1306,6 +1333,37 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1306
1333
  }
1307
1334
  });
1308
1335
 
1336
+ app.post('/api/jobs/:id/permission', (req, res) => {
1337
+ try {
1338
+ const job = store.getJob(req.params.id);
1339
+ if (!job) {
1340
+ return res.status(404).json({ error: 'Job not found' });
1341
+ }
1342
+ if (job.status !== 'awaiting_permission') {
1343
+ return res.status(400).json({
1344
+ error: `Job is not awaiting a permission decision (status: ${job.status})`,
1345
+ });
1346
+ }
1347
+
1348
+ const requestId = String(req.body?.requestId || '');
1349
+ const decision = req.body?.decision;
1350
+ if (!requestId) {
1351
+ return res.status(400).json({ error: 'requestId is required' });
1352
+ }
1353
+ if (decision !== 'allow' && decision !== 'deny') {
1354
+ return res.status(400).json({ error: 'decision must be "allow" or "deny"' });
1355
+ }
1356
+
1357
+ const result = permissionController.resolve(job.id, requestId, decision);
1358
+ if (!result.ok) {
1359
+ return res.status(409).json({ error: result.error });
1360
+ }
1361
+ res.json(result.job);
1362
+ } catch (err) {
1363
+ res.status(500).json({ error: err.message });
1364
+ }
1365
+ });
1366
+
1309
1367
  // Unknown /api/* must return JSON — never Express HTML or the SPA shell.
1310
1368
  app.use('/api', (req, res) => {
1311
1369
  res.status(404).json({ error: `Cannot ${req.method} ${req.originalUrl}` });
package/src/store.js CHANGED
@@ -8,6 +8,7 @@ const IN_FLIGHT_STATUSES = new Set([
8
8
  'preparing_worktree',
9
9
  'running',
10
10
  'applying_feedback',
11
+ 'awaiting_permission',
11
12
  ]);
12
13
 
13
14
  export class Store {