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 +1 -1
- package/public/app.js +243 -3
- package/public/index.html +35 -0
- package/public/styles.css +81 -2
- package/src/agent.js +33 -3
- package/src/config.js +21 -0
- package/src/openrouter-agent.js +5 -1
- package/src/openrouter-tools.js +33 -13
- package/src/permissions.js +158 -0
- package/src/server.js +58 -0
- package/src/store.js +1 -0
package/package.json
CHANGED
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
|
-
|
|
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.
|
|
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({
|
|
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,
|
package/src/openrouter-agent.js
CHANGED
|
@@ -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
|
|
package/src/openrouter-tools.js
CHANGED
|
@@ -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}` });
|