acdev 1.0.15 → 1.1.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.0.15",
3
+ "version": "1.1.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
@@ -240,6 +240,7 @@ const els = {
240
240
  overviewTicketSource: document.getElementById('overview-ticket-source'),
241
241
  enqueueFeedback: document.getElementById('enqueue-feedback'),
242
242
  preferredBranch: document.getElementById('preferred-branch'),
243
+ enqueueUserPrompt: document.getElementById('enqueue-user-prompt'),
243
244
  addBtn: document.getElementById('add-btn'),
244
245
  statsGrid: document.getElementById('stats-grid'),
245
246
  overviewRunning: document.getElementById('overview-running'),
@@ -301,6 +302,8 @@ const els = {
301
302
  settingsModelValue: document.getElementById('settings-model-value'),
302
303
  settingsModelPanel: document.getElementById('settings-model-panel'),
303
304
  settingsModelList: document.getElementById('settings-model-list'),
305
+ settingsModelSearch: document.getElementById('settings-model-search'),
306
+ settingsModelHint: document.getElementById('settings-model-hint'),
304
307
  reviewPrLink: document.getElementById('review-pr-link'),
305
308
  reviewPrStatusLine: document.getElementById('review-pr-status-line'),
306
309
  reviewPrStatusNotes: document.getElementById('review-pr-status-notes'),
@@ -308,6 +311,10 @@ const els = {
308
311
  reviewTerminalMessage: document.getElementById('review-terminal-message'),
309
312
  reviewRetryBtn: document.getElementById('review-retry-btn'),
310
313
  settingsTools: document.getElementById('settings-tools'),
314
+ settingsMaxTurns: document.getElementById('settings-max-turns'),
315
+ settingsTimeout: document.getElementById('settings-timeout'),
316
+ settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
317
+ settingsTestCommand: document.getElementById('settings-test-command'),
311
318
  settingsFeedback: document.getElementById('settings-feedback'),
312
319
  settingsSaveBtn: document.getElementById('settings-save-btn'),
313
320
  settingsTicketSource: document.getElementById('settings-ticket-source'),
@@ -1051,6 +1058,148 @@ function sortedJobs() {
1051
1058
  );
1052
1059
  }
1053
1060
 
1061
+ /**
1062
+ * @param {string} raw
1063
+ * @returns {string}
1064
+ */
1065
+ function escapeHtml(raw) {
1066
+ return String(raw)
1067
+ .replaceAll('&', '&')
1068
+ .replaceAll('<', '&lt;')
1069
+ .replaceAll('>', '&gt;')
1070
+ .replaceAll('"', '&quot;')
1071
+ .replaceAll("'", '&#39;');
1072
+ }
1073
+
1074
+ /**
1075
+ * @param {string} raw
1076
+ * @returns {string | null}
1077
+ */
1078
+ function safeMarkdownHref(raw) {
1079
+ const href = String(raw || '').trim();
1080
+ if (!href) return null;
1081
+ if (href.startsWith('#') || href.startsWith('/')) return href;
1082
+ try {
1083
+ const url = new URL(href, window.location.href);
1084
+ return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * @param {string} raw
1092
+ * @returns {string}
1093
+ */
1094
+ function renderInlineMarkdown(raw) {
1095
+ const stash = [];
1096
+ const hold = (html) => `\u0000${stash.push(html) - 1}\u0000`;
1097
+ let out = escapeHtml(raw);
1098
+ out = out.replace(/`([^`]+)`/g, (_, code) => hold(`<code>${code}</code>`));
1099
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, body) => hold(`<strong>${body}</strong>`));
1100
+ out = out.replace(/__([^_]+)__/g, (_, body) => hold(`<strong>${body}</strong>`));
1101
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
1102
+ const safe = safeMarkdownHref(href);
1103
+ if (!safe) return label;
1104
+ return hold(
1105
+ `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer">${label}</a>`
1106
+ );
1107
+ });
1108
+ return out.replace(/\u0000(\d+)\u0000/g, (_, idx) => stash[Number(idx)] || '');
1109
+ }
1110
+
1111
+ /**
1112
+ * @param {HTMLElement | null} container
1113
+ * @param {string} raw
1114
+ */
1115
+ function renderMarkdownBlock(container, raw) {
1116
+ if (!container) return;
1117
+ container.innerHTML = '';
1118
+ const text = String(raw || '').replace(/\r\n/g, '\n').trim();
1119
+ if (!text) return;
1120
+
1121
+ const lines = text.split('\n');
1122
+ let paragraph = [];
1123
+ let listEl = null;
1124
+ let listType = '';
1125
+
1126
+ const flushParagraph = () => {
1127
+ if (!paragraph.length) return;
1128
+ const p = document.createElement('p');
1129
+ p.innerHTML = renderInlineMarkdown(paragraph.join(' '));
1130
+ container.appendChild(p);
1131
+ paragraph = [];
1132
+ };
1133
+
1134
+ const flushList = () => {
1135
+ if (!listEl) return;
1136
+ container.appendChild(listEl);
1137
+ listEl = null;
1138
+ listType = '';
1139
+ };
1140
+
1141
+ for (let i = 0; i < lines.length; i += 1) {
1142
+ const line = lines[i];
1143
+ if (!line.trim()) {
1144
+ flushParagraph();
1145
+ flushList();
1146
+ continue;
1147
+ }
1148
+
1149
+ const fence = line.match(/^```([\w-]+)?\s*$/);
1150
+ if (fence) {
1151
+ flushParagraph();
1152
+ flushList();
1153
+ const codeLines = [];
1154
+ for (i += 1; i < lines.length; i += 1) {
1155
+ if (/^```\s*$/.test(lines[i])) break;
1156
+ codeLines.push(lines[i]);
1157
+ }
1158
+ const pre = document.createElement('pre');
1159
+ pre.className = 'markdown-code';
1160
+ const code = document.createElement('code');
1161
+ if (fence[1]) code.dataset.language = fence[1];
1162
+ code.textContent = codeLines.join('\n');
1163
+ pre.appendChild(code);
1164
+ container.appendChild(pre);
1165
+ continue;
1166
+ }
1167
+
1168
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
1169
+ if (heading) {
1170
+ flushParagraph();
1171
+ flushList();
1172
+ const tag = `h${heading[1].length}`;
1173
+ const el = document.createElement(tag);
1174
+ el.className = `markdown-heading markdown-heading-${heading[1].length}`;
1175
+ el.innerHTML = renderInlineMarkdown(heading[2]);
1176
+ container.appendChild(el);
1177
+ continue;
1178
+ }
1179
+
1180
+ const bullet = line.match(/^\s*([-*+])\s+(.*)$/);
1181
+ const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
1182
+ if (bullet || ordered) {
1183
+ flushParagraph();
1184
+ const type = bullet ? 'ul' : 'ol';
1185
+ if (!listEl || listType !== type) {
1186
+ flushList();
1187
+ listEl = document.createElement(type);
1188
+ listType = type;
1189
+ }
1190
+ const li = document.createElement('li');
1191
+ li.innerHTML = renderInlineMarkdown((bullet || ordered)[2] || (bullet || ordered)[1] || '');
1192
+ listEl.appendChild(li);
1193
+ continue;
1194
+ }
1195
+
1196
+ paragraph.push(line.trim());
1197
+ }
1198
+
1199
+ flushParagraph();
1200
+ flushList();
1201
+ }
1202
+
1054
1203
  /**
1055
1204
  * @param {HTMLElement | null} container
1056
1205
  * @param {object | null | undefined} pr
@@ -1089,8 +1238,8 @@ function renderPrStatusNotes(container, pr) {
1089
1238
 
1090
1239
  if (review.body) {
1091
1240
  const body = document.createElement('div');
1092
- body.className = 'pr-status-note-body';
1093
- body.textContent = review.body;
1241
+ body.className = 'pr-status-note-body markdown-view';
1242
+ renderMarkdownBlock(body, review.body);
1094
1243
  item.appendChild(body);
1095
1244
  }
1096
1245
 
@@ -1107,6 +1256,10 @@ function renderPrStatusNotes(container, pr) {
1107
1256
  container.appendChild(requested);
1108
1257
  }
1109
1258
  }
1259
+ if (typeof window !== 'undefined') {
1260
+ window.__acdevMarkdownRender = renderMarkdownBlock;
1261
+ window.__acdevMarkdownInline = renderInlineMarkdown;
1262
+ }
1110
1263
  function formatTime(ts) {
1111
1264
  try {
1112
1265
  return new Date(ts).toLocaleTimeString([], {
@@ -1771,8 +1924,8 @@ function renderDiff(diff, opts = {}) {
1771
1924
  const chip = document.createElement('div');
1772
1925
  chip.className = 'diff-pending-chip';
1773
1926
  const body = document.createElement('div');
1774
- body.className = 'diff-pending-chip-body';
1775
- body.textContent = c.body;
1927
+ body.className = 'diff-pending-chip-body markdown-view';
1928
+ renderMarkdownBlock(body, c.body);
1776
1929
  const actions = document.createElement('div');
1777
1930
  actions.className = 'diff-pending-chip-actions';
1778
1931
  const edit = document.createElement('button');
@@ -1922,8 +2075,7 @@ function renderDiff(diff, opts = {}) {
1922
2075
  }
1923
2076
  continue;
1924
2077
  }
1925
-
1926
- if (line.startsWith('@@')) {
2078
+ if (line.startsWith('@@ ')) {
1927
2079
  const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1928
2080
  if (m) {
1929
2081
  leftNo = Number(m[1]);
@@ -2065,8 +2217,8 @@ function renderPendingCommentList(jobId) {
2065
2217
  }
2066
2218
 
2067
2219
  const body = document.createElement('div');
2068
- body.className = 'review-pending-item-body';
2069
- body.textContent = c.body;
2220
+ body.className = 'review-pending-item-body markdown-view';
2221
+ renderMarkdownBlock(body, c.body);
2070
2222
  main.appendChild(body);
2071
2223
  const actions = document.createElement('div');
2072
2224
  actions.className = 'review-pending-item-actions';
@@ -4998,6 +5150,7 @@ els.addBtn.addEventListener('click', async () => {
4998
5150
 
4999
5151
  const urls = splitIssueUrls(text);
5000
5152
  const branchName = els.preferredBranch?.value?.trim() || '';
5153
+ const userPrompt = els.enqueueUserPrompt?.value?.trim() || '';
5001
5154
 
5002
5155
  try {
5003
5156
  const res = await fetch('/api/issues', {
@@ -5007,6 +5160,7 @@ els.addBtn.addEventListener('click', async () => {
5007
5160
  urls,
5008
5161
  ticketSource,
5009
5162
  ...(branchName ? { branchName } : {}),
5163
+ ...(userPrompt ? { userPrompt } : {}),
5010
5164
  }),
5011
5165
  });
5012
5166
  const data = await res.json();
@@ -5019,6 +5173,7 @@ els.addBtn.addEventListener('click', async () => {
5019
5173
  }
5020
5174
  els.issueUrls.value = '';
5021
5175
  if (els.preferredBranch) els.preferredBranch.value = '';
5176
+ if (els.enqueueUserPrompt) els.enqueueUserPrompt.value = '';
5022
5177
  const parts = [];
5023
5178
  if (data.jobs?.length) parts.push(`Added ${data.jobs.length}`);
5024
5179
  if (data.skipped?.length) {
package/public/index.html CHANGED
@@ -156,6 +156,22 @@
156
156
  </p>
157
157
  </div>
158
158
 
159
+ <div class="enqueue-branch-field">
160
+ <div class="enqueue-branch-label-row">
161
+ <label class="field-label" for="enqueue-user-prompt">Additional instructions</label>
162
+ <span class="enqueue-branch-optional">Optional</span>
163
+ </div>
164
+ <textarea
165
+ id="enqueue-user-prompt"
166
+ class="input textarea enqueue-textarea"
167
+ rows="3"
168
+ placeholder="Anything specific you want the agent to know or do…"
169
+ ></textarea>
170
+ <p class="enqueue-input-hint">
171
+ Appended to the agent's instructions for these tickets. Leave empty to use the issue content only.
172
+ </p>
173
+ </div>
174
+
159
175
  <div class="enqueue-info-banner" id="enqueue-info-banner" role="note">
160
176
  <svg class="enqueue-info-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
161
177
  <circle cx="12" cy="12" r="10"/>
package/public/styles.css CHANGED
@@ -1620,15 +1620,67 @@ a { color: var(--primary); text-underline-offset: 3px; }
1620
1620
  background: var(--surface-2);
1621
1621
  }
1622
1622
 
1623
- .pr-status-note-meta {
1623
+ .markdown-view {
1624
+ line-height: 1.45;
1625
+ word-break: break-word;
1626
+ }
1627
+
1628
+ .markdown-view > :first-child {
1629
+ margin-top: 0;
1630
+ }
1631
+
1632
+ .markdown-view > :last-child {
1633
+ margin-bottom: 0;
1634
+ }
1635
+
1636
+ .markdown-view p,
1637
+ .markdown-view ul,
1638
+ .markdown-view ol,
1639
+ .markdown-view pre,
1640
+ .markdown-view blockquote,
1641
+ .markdown-view h1,
1642
+ .markdown-view h2,
1643
+ .markdown-view h3,
1644
+ .markdown-view h4,
1645
+ .markdown-view h5,
1646
+ .markdown-view h6 {
1647
+ margin: 0 0 8px;
1648
+ }
1649
+
1650
+ .markdown-view ul,
1651
+ .markdown-view ol {
1652
+ padding-left: 20px;
1653
+ }
1654
+
1655
+ .markdown-view code {
1656
+ font-family: "JetBrains Mono", ui-monospace, monospace;
1624
1657
  font-size: 12px;
1625
- color: var(--text-muted);
1626
- margin-bottom: 4px;
1658
+ padding: 1px 4px;
1659
+ background: var(--bg);
1660
+ border-radius: 4px;
1661
+ }
1662
+
1663
+ .markdown-view pre {
1664
+ margin: 0 0 8px;
1665
+ padding: 8px 10px;
1666
+ background: var(--bg);
1667
+ border: 1px solid var(--border-soft);
1668
+ border-radius: 8px;
1669
+ overflow-x: auto;
1670
+ }
1671
+
1672
+ .markdown-view pre code {
1673
+ padding: 0;
1674
+ background: transparent;
1675
+ }
1676
+
1677
+ .markdown-view a {
1678
+ color: var(--primary);
1679
+ text-decoration: underline;
1627
1680
  }
1628
1681
 
1629
1682
  .pr-status-note-body {
1630
- white-space: pre-wrap;
1631
- line-height: 1.45;
1683
+ font-size: 13px;
1632
1684
  }
1633
1685
 
1634
1686
  /* —— Review file selection —— */
@@ -1943,7 +1995,7 @@ body.diff-fs-open {
1943
1995
 
1944
1996
  .diff-pending-chip-body {
1945
1997
  color: var(--text);
1946
- white-space: pre-wrap;
1998
+ font-size: 12px;
1947
1999
  }
1948
2000
 
1949
2001
  .diff-pending-chip-actions {
@@ -1953,6 +2005,10 @@ body.diff-fs-open {
1953
2005
  flex-wrap: wrap;
1954
2006
  }
1955
2007
 
2008
+ .review-pending-item-body {
2009
+ font-size: 13px;
2010
+ }
2011
+
1956
2012
  .review-pending-list {
1957
2013
  display: flex;
1958
2014
  flex-direction: column;
package/src/agent.js CHANGED
@@ -89,6 +89,7 @@ export function stripAiAttribution(text) {
89
89
  * comments?: Array<{ author: string, body: string }>,
90
90
  * },
91
91
  * jiraPrLinkPhrase?: string,
92
+ * userPrompt?: string,
92
93
  * }} [context]
93
94
  */
94
95
  export function buildPrompt(issueUrl, config, context = {}) {
@@ -163,6 +164,14 @@ export function buildPrompt(issueUrl, config, context = {}) {
163
164
  );
164
165
  }
165
166
 
167
+ if (context.userPrompt && context.userPrompt.trim()) {
168
+ parts.push(
169
+ '',
170
+ 'Additional instructions from the user (optional — follow them unless they conflict with the rules above):',
171
+ context.userPrompt.trim()
172
+ );
173
+ }
174
+
166
175
  // Drop empty strings from optional browse URL line
167
176
  const cleaned = parts.filter((p) => p !== '');
168
177
 
@@ -579,6 +588,7 @@ function stubAgentResult(onEvent, title, body) {
579
588
  * ticketSource?: 'github' | 'jira',
580
589
  * jiraKey?: string,
581
590
  * jiraIssue?: object,
591
+ * userPrompt?: string,
582
592
  * queryFn?: typeof query,
583
593
  * callModelFn?: (args: object) => object,
584
594
  * }} params
@@ -594,6 +604,7 @@ export async function runAgentOnIssue({
594
604
  ticketSource,
595
605
  jiraKey,
596
606
  jiraIssue,
607
+ userPrompt,
597
608
  queryFn = query,
598
609
  callModelFn,
599
610
  }) {
@@ -613,6 +624,7 @@ export async function runAgentOnIssue({
613
624
  jiraKey,
614
625
  jiraIssue,
615
626
  jiraPrLinkPhrase: config.jiraPrLinkPhrase,
627
+ userPrompt,
616
628
  });
617
629
 
618
630
  const { resultText, meta, usage } = await runConfiguredQuery({
package/src/server.js CHANGED
@@ -68,6 +68,9 @@ const CLEARABLE_STATUSES = new Set([
68
68
  'failed',
69
69
  ]);
70
70
 
71
+ /** Max length for optional user-supplied prompt appended to the agent instructions. */
72
+ const USER_PROMPT_MAX_LEN = 4000;
73
+
71
74
  /**
72
75
  * Worktree directory id: Jira key or GitHub issue number.
73
76
  * @param {{ jiraKey?: string, issueNumber?: number }} job
@@ -471,6 +474,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
471
474
  ticketSource: isJira ? 'jira' : 'github',
472
475
  jiraKey: job.jiraKey,
473
476
  jiraIssue,
477
+ userPrompt: job.userPrompt,
474
478
  });
475
479
 
476
480
  if (!store.getJob(jobId)) return;
@@ -833,6 +837,17 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
833
837
  }
834
838
  const preferredBranchName = preferredParsed.value;
835
839
 
840
+ const rawUserPrompt = req.body?.userPrompt;
841
+ if (rawUserPrompt != null && typeof rawUserPrompt !== 'string') {
842
+ return res.status(400).json({ error: 'userPrompt must be a string' });
843
+ }
844
+ const userPrompt = typeof rawUserPrompt === 'string' ? rawUserPrompt.trim() : '';
845
+ if (userPrompt.length > USER_PROMPT_MAX_LEN) {
846
+ return res.status(400).json({
847
+ error: `userPrompt must be ${USER_PROMPT_MAX_LEN} characters or fewer.`,
848
+ });
849
+ }
850
+
836
851
  const ticketSource =
837
852
  req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
838
853
  ? req.body.ticketSource
@@ -929,6 +944,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
929
944
  ticketSource: item.ticketSource,
930
945
  jiraKey: item.jiraKey,
931
946
  ...(preferredBranchName ? { preferredBranchName } : {}),
947
+ ...(userPrompt ? { userPrompt } : {}),
932
948
  llmProvider: llm.llmProvider,
933
949
  model: llm.model,
934
950
  });
package/src/store.js CHANGED
@@ -53,19 +53,21 @@ export class Store {
53
53
  * @param {{
54
54
  * issueUrl: string,
55
55
  * issueNumber?: number,
56
- * ticketSource?: 'github' | 'jira',
57
- * jiraKey?: string,
58
- * preferredBranchName?: string,
59
- * llmProvider?: 'claude' | 'openrouter',
60
- * model?: string,
61
- * }} data
62
- * @returns {Job}
63
- */
56
+ * ticketSource?: 'github' | 'jira',
57
+ * jiraKey?: string,
58
+ * preferredBranchName?: string,
59
+ * llmProvider?: 'claude' | 'openrouter',
60
+ * model?: string,
61
+ * userPrompt?: string,
62
+ * }} data
63
+ * @returns {Job}
64
+ */
64
65
  addJob(data) {
65
66
  const now = new Date().toISOString();
66
67
  const ticketSource = data.ticketSource === 'jira' ? 'jira' : 'github';
67
68
  const llmProvider = data.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
68
69
  const model = typeof data.model === 'string' ? data.model.trim() : '';
70
+ const userPrompt = typeof data.userPrompt === 'string' ? data.userPrompt.trim() : '';
69
71
  /** @type {Job} */
70
72
  const job = {
71
73
  id: randomUUID(),
@@ -76,6 +78,7 @@ export class Store {
76
78
  ...(model ? { model } : {}),
77
79
  ...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
78
80
  ...(data.preferredBranchName ? { preferredBranchName: data.preferredBranchName } : {}),
81
+ ...(userPrompt ? { userPrompt } : {}),
79
82
  status: 'queued',
80
83
  createdAt: now,
81
84
  updatedAt: now,