@bill10/agent-007 0.6.2
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/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
// Job board — a pinned tab in the terminal panel. Renders three columns
|
|
2
|
+
// (To do / In progress / Review), an archive of finished jobs behind a toolbar
|
|
3
|
+
// toggle, and drives the dispatcher settings.
|
|
4
|
+
//
|
|
5
|
+
// Two kinds of card share those columns. A one-time job crosses them once and
|
|
6
|
+
// stops in Review, its agent kept until the card is done. A schedule stays in
|
|
7
|
+
// To do and posts a one-time run card each time it comes due; the runs are
|
|
8
|
+
// what cross the board.
|
|
9
|
+
//
|
|
10
|
+
// Card *state* comes from the server (durable, persisted). The "needs you"
|
|
11
|
+
// badge is computed here from the live agent map, because it describes a PTY
|
|
12
|
+
// as it is right now — a value the server persisted would be stale the moment
|
|
13
|
+
// it was written.
|
|
14
|
+
|
|
15
|
+
import { agents, jobs, boardSettings, setBoardSettings, boardActive, setBoardActive, activeSessionId, repos } from './state.js';
|
|
16
|
+
import { send } from './ws.js';
|
|
17
|
+
import { escapeHtml, getToken } from './auth.js';
|
|
18
|
+
import { switchToSession } from './terminal.js';
|
|
19
|
+
|
|
20
|
+
// The columns, in board order. `done` deliberately has none: a job whose PR
|
|
21
|
+
// merged is finished work, and a Review column that accumulates it stops
|
|
22
|
+
// meaning "needs your review". Those jobs are kept and reachable through the
|
|
23
|
+
// Finished jobs view below.
|
|
24
|
+
export const COLUMNS = [
|
|
25
|
+
{ state: 'todo', label: 'To do' },
|
|
26
|
+
{ state: 'in-progress', label: 'In progress' },
|
|
27
|
+
{ state: 'review', label: 'Review' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// The one mode that leaves a dispatched agent unreviewed. Named here rather
|
|
31
|
+
// than compared inline in three places, and marked wherever it is shown so it
|
|
32
|
+
// never reads as just another entry in a list of six. Kept in step with
|
|
33
|
+
// PERMISSION_MODES in lib/jobs.js by a test — public/ cannot import it.
|
|
34
|
+
const DANGEROUS_MODE = 'bypassPermissions';
|
|
35
|
+
|
|
36
|
+
function markDangerousMode(el) {
|
|
37
|
+
if (!el) return;
|
|
38
|
+
// An empty value is "whatever the board decides" (the toolbar's .env entry,
|
|
39
|
+
// a card's Board default): dangerous when that decision is bypass for
|
|
40
|
+
// either CLI — a picked board mode, or with none picked, a .env default.
|
|
41
|
+
const board = boardSettings.permissionModeChosen
|
|
42
|
+
? [boardSettings.permissionMode]
|
|
43
|
+
: [boardSettings.permissionMode, ...Object.values(boardSettings.envModes || {})];
|
|
44
|
+
el.classList.toggle('job-mode-danger', el.value === DANGEROUS_MODE || (el.value === '' && board.includes(DANGEROUS_MODE)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mirrors STALLED_AFTER_MS in lib/jobs.js.
|
|
48
|
+
const STALLED_AFTER_MS = 3 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
let editingJobId = null;
|
|
51
|
+
// Files waiting in the form: { name } for one the card already has, { name,
|
|
52
|
+
// data } (base64) for a new one, `reading` while the FileReader is still busy.
|
|
53
|
+
let pendingAttachments = [];
|
|
54
|
+
// Which of the two views the board is showing. The columns and the finished
|
|
55
|
+
// list share the same space rather than stacking, so the finished archive can
|
|
56
|
+
// never push the live work off the screen.
|
|
57
|
+
let showingFinished = false;
|
|
58
|
+
|
|
59
|
+
// --- Helpers ---
|
|
60
|
+
|
|
61
|
+
function relativeTime(iso) {
|
|
62
|
+
if (!iso) return '';
|
|
63
|
+
const then = new Date(iso).getTime();
|
|
64
|
+
if (Number.isNaN(then)) return '';
|
|
65
|
+
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
66
|
+
if (secs < 60) return `${secs}s ago`;
|
|
67
|
+
const mins = Math.round(secs / 60);
|
|
68
|
+
if (mins < 60) return `${mins}m ago`;
|
|
69
|
+
const hours = Math.round(mins / 60);
|
|
70
|
+
if (hours < 24) return `${hours}h ago`;
|
|
71
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// relativeTime only looks backwards. A scheduled card's whole point is the run
|
|
75
|
+
// that has not happened yet, so it needs the other direction too.
|
|
76
|
+
function untilTime(iso) {
|
|
77
|
+
if (!iso) return '';
|
|
78
|
+
const then = new Date(iso).getTime();
|
|
79
|
+
if (Number.isNaN(then)) return '';
|
|
80
|
+
const secs = Math.round((then - Date.now()) / 1000);
|
|
81
|
+
// Due, but the board only acts on a scan — saying "0s" would imply a
|
|
82
|
+
// precision the dispatcher does not have.
|
|
83
|
+
if (secs <= 0) return 'due';
|
|
84
|
+
if (secs < 60) return 'in <1m';
|
|
85
|
+
const mins = Math.round(secs / 60);
|
|
86
|
+
if (mins < 60) return `in ${mins}m`;
|
|
87
|
+
const hours = Math.round(mins / 60);
|
|
88
|
+
if (hours < 24) return `in ${hours}h`;
|
|
89
|
+
return `in ${Math.round(hours / 24)}d`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The absolute time as well, in the browser's locale, because "in 14h" alone
|
|
93
|
+
// does not tell you whether that lands at a sensible hour.
|
|
94
|
+
function clockTime(iso) {
|
|
95
|
+
const then = new Date(iso).getTime();
|
|
96
|
+
if (Number.isNaN(then)) return '';
|
|
97
|
+
// Past ~6 days the weekday alone no longer says which date is meant (a
|
|
98
|
+
// monthly cron's "next Sat" could be either of two Saturdays), so add it.
|
|
99
|
+
const far = then - Date.now() > 6 * 24 * 60 * 60 * 1000;
|
|
100
|
+
return new Date(then).toLocaleString([], {
|
|
101
|
+
weekday: 'short', ...(far ? { month: 'short', day: 'numeric' } : {}),
|
|
102
|
+
hour: '2-digit', minute: '2-digit',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isScheduled(job) {
|
|
107
|
+
return job.type === 'scheduled';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function repoSlug(repoPath) {
|
|
111
|
+
const repo = repos.get(repoPath);
|
|
112
|
+
if (repo) return repo.slug;
|
|
113
|
+
return String(repoPath || '').split('/').filter(Boolean).pop() || repoPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Live status for an in-progress card. Deliberately mirrors deriveJobStatus in
|
|
117
|
+
// lib/jobs.js, but reads the client's agent map so the badge reacts the instant
|
|
118
|
+
// a state-change arrives rather than on the next board broadcast.
|
|
119
|
+
function liveStatus(job) {
|
|
120
|
+
if (job.state !== 'in-progress') return null;
|
|
121
|
+
const agent = job.agentSessionId ? agents.get(job.agentSessionId) : null;
|
|
122
|
+
if (!agent || agent.state === 'DISCONNECTED') return 'gone';
|
|
123
|
+
if (agent.state === 'MESSAGE') return 'needs-input';
|
|
124
|
+
if (agent.state === 'WAITING' && (Date.now() - (agent.lastOutputAt || 0)) > STALLED_AFTER_MS) return 'stalled';
|
|
125
|
+
return 'running';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const STATUS_TEXT = {
|
|
129
|
+
'running': 'running',
|
|
130
|
+
'needs-input': 'needs you',
|
|
131
|
+
'stalled': 'quiet — may need you',
|
|
132
|
+
'gone': 'agent gone',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
// --- Rendering ---
|
|
137
|
+
|
|
138
|
+
export function renderBoard() {
|
|
139
|
+
const container = document.getElementById('job-columns');
|
|
140
|
+
if (!container || !boardActive) return;
|
|
141
|
+
|
|
142
|
+
const finished = [...jobs.values()].filter(j => j.state === 'done');
|
|
143
|
+
const panel = document.getElementById('job-finished');
|
|
144
|
+
// A page served from cache after an upgrade can have the old markup and the
|
|
145
|
+
// new module — nothing busts the cache on app.js. Falling back to the columns
|
|
146
|
+
// beats hiding them to show an archive whose container does not exist.
|
|
147
|
+
if (!panel) showingFinished = false;
|
|
148
|
+
renderFinishedToggle(finished.length);
|
|
149
|
+
if (panel) panel.style.display = showingFinished ? 'flex' : 'none';
|
|
150
|
+
container.style.display = showingFinished ? 'none' : '';
|
|
151
|
+
if (showingFinished) return renderFinishedList(finished);
|
|
152
|
+
|
|
153
|
+
const byState = new Map(COLUMNS.map(c => [c.state, []]));
|
|
154
|
+
for (const job of jobs.values()) {
|
|
155
|
+
if (byState.has(job.state)) byState.get(job.state).push(job);
|
|
156
|
+
}
|
|
157
|
+
// Newest first within To do would bury the next thing to be dispatched, so
|
|
158
|
+
// To do stays oldest-first to match the dispatcher's own ordering. The other
|
|
159
|
+
// columns show most-recent activity first.
|
|
160
|
+
byState.get('todo').sort((a, b) => String(a.postedAt).localeCompare(String(b.postedAt)));
|
|
161
|
+
byState.get('in-progress').sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
|
162
|
+
byState.get('review').sort((a, b) => String(b.reviewAt || '').localeCompare(String(a.reviewAt || '')));
|
|
163
|
+
|
|
164
|
+
container.innerHTML = '';
|
|
165
|
+
for (const col of COLUMNS) {
|
|
166
|
+
const list = byState.get(col.state);
|
|
167
|
+
const colEl = document.createElement('div');
|
|
168
|
+
colEl.className = 'job-column';
|
|
169
|
+
colEl.dataset.state = col.state;
|
|
170
|
+
|
|
171
|
+
const header = document.createElement('div');
|
|
172
|
+
header.className = 'job-column-header';
|
|
173
|
+
header.innerHTML = `<span class="job-column-label">${escapeHtml(col.label)}</span><span class="job-column-count">${list.length}</span>`;
|
|
174
|
+
colEl.appendChild(header);
|
|
175
|
+
|
|
176
|
+
const cards = document.createElement('div');
|
|
177
|
+
cards.className = 'job-cards';
|
|
178
|
+
if (list.length === 0) {
|
|
179
|
+
const empty = document.createElement('div');
|
|
180
|
+
empty.className = 'job-column-empty';
|
|
181
|
+
empty.textContent = col.state === 'todo' ? 'No jobs queued' : '—';
|
|
182
|
+
cards.appendChild(empty);
|
|
183
|
+
}
|
|
184
|
+
for (const job of list) cards.appendChild(renderCard(job));
|
|
185
|
+
colEl.appendChild(cards);
|
|
186
|
+
container.appendChild(colEl);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Newest first: the thing that just merged is the one you are most likely to be
|
|
191
|
+
// looking for.
|
|
192
|
+
function renderFinishedList(finished) {
|
|
193
|
+
const cards = document.getElementById('job-finished-cards');
|
|
194
|
+
const count = document.getElementById('job-finished-count');
|
|
195
|
+
if (!cards) return;
|
|
196
|
+
const sorted = [...finished].sort((a, b) =>
|
|
197
|
+
String(b.doneAt || b.prMergedAt || '').localeCompare(String(a.doneAt || a.prMergedAt || '')));
|
|
198
|
+
if (count) count.textContent = String(sorted.length);
|
|
199
|
+
cards.innerHTML = '';
|
|
200
|
+
if (sorted.length === 0) {
|
|
201
|
+
const empty = document.createElement('div');
|
|
202
|
+
empty.className = 'job-column-empty';
|
|
203
|
+
empty.textContent = 'No finished jobs yet — a card lands here once its pull request merges or is closed, or you mark it done.';
|
|
204
|
+
cards.appendChild(empty);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const job of sorted) cards.appendChild(renderCard(job));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The title and aria-pressed move with the label: a tooltip still describing the
|
|
211
|
+
// view you just left is worse than no tooltip, and the accent border is the only
|
|
212
|
+
// other thing saying which view you are in — which reaches nobody using a screen
|
|
213
|
+
// reader.
|
|
214
|
+
function renderFinishedToggle(count) {
|
|
215
|
+
const btn = document.getElementById('btn-finished-jobs');
|
|
216
|
+
if (!btn) return;
|
|
217
|
+
btn.textContent = showingFinished
|
|
218
|
+
? '\u2190 Back to board'
|
|
219
|
+
: `View finished jobs${count ? ` (${count})` : ''}`;
|
|
220
|
+
btn.title = showingFinished
|
|
221
|
+
? 'Back to the To do / In progress / Review columns'
|
|
222
|
+
: 'Jobs whose pull request merged or was closed, or that were marked done. They leave the board but are kept here.';
|
|
223
|
+
btn.setAttribute('aria-pressed', String(showingFinished));
|
|
224
|
+
btn.classList.toggle('showing', showingFinished);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderCard(job) {
|
|
228
|
+
const card = document.createElement('div');
|
|
229
|
+
const status = liveStatus(job);
|
|
230
|
+
card.className = 'job-card' + (status ? ` status-${status}` : '');
|
|
231
|
+
card.dataset.jobId = job.id;
|
|
232
|
+
|
|
233
|
+
// A card whose agent is still around is a jump target — In progress, and
|
|
234
|
+
// Review, where the agent is kept until the card is done. The badge below
|
|
235
|
+
// stays clickable, but the whole card is a far bigger hit area than a
|
|
236
|
+
// one-line pill, and "click the job to see the agent" is what the columns
|
|
237
|
+
// already imply.
|
|
238
|
+
const liveAgentId = (job.state === 'in-progress' || job.state === 'review') && job.agentSessionId && agents.has(job.agentSessionId)
|
|
239
|
+
? job.agentSessionId
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
const title = document.createElement('div');
|
|
243
|
+
title.className = 'job-card-title';
|
|
244
|
+
title.textContent = job.title;
|
|
245
|
+
if (isScheduled(job)) {
|
|
246
|
+
// A chip rather than a whole second column: a scheduled card is still an
|
|
247
|
+
// ordinary card in the same queue, and splitting the board would hide it
|
|
248
|
+
// from the glance that the three columns exist to give.
|
|
249
|
+
const chip = document.createElement('span');
|
|
250
|
+
chip.className = 'job-card-type';
|
|
251
|
+
chip.textContent = 'scheduled';
|
|
252
|
+
chip.title = 'Posts a run card each time it comes due; the runs are the cards that move';
|
|
253
|
+
title.appendChild(chip);
|
|
254
|
+
}
|
|
255
|
+
if (job.scheduleId) {
|
|
256
|
+
const chip = document.createElement('span');
|
|
257
|
+
chip.className = 'job-card-type';
|
|
258
|
+
chip.textContent = 'run';
|
|
259
|
+
chip.title = 'Posted by its schedule';
|
|
260
|
+
title.appendChild(chip);
|
|
261
|
+
}
|
|
262
|
+
if (job.agent === 'codex') {
|
|
263
|
+
const chip = document.createElement('span');
|
|
264
|
+
chip.className = 'job-card-type';
|
|
265
|
+
chip.textContent = 'codex';
|
|
266
|
+
chip.title = 'Runs on Codex instead of Claude Code';
|
|
267
|
+
title.appendChild(chip);
|
|
268
|
+
}
|
|
269
|
+
// A chip marks the setting that differs from its type's default: no PR on a
|
|
270
|
+
// one-time card, PR runs on a schedule.
|
|
271
|
+
if (isScheduled(job) && job.requiresPr === true) {
|
|
272
|
+
const chip = document.createElement('span');
|
|
273
|
+
chip.className = 'job-card-type';
|
|
274
|
+
chip.textContent = 'PR runs';
|
|
275
|
+
chip.title = 'Each run opens a pull request';
|
|
276
|
+
title.appendChild(chip);
|
|
277
|
+
}
|
|
278
|
+
if (!isScheduled(job) && job.requiresPr === false) {
|
|
279
|
+
const chip = document.createElement('span');
|
|
280
|
+
chip.className = 'job-card-type';
|
|
281
|
+
chip.textContent = 'no PR';
|
|
282
|
+
chip.title = 'Finishes with a summary from its agent instead of a pull request';
|
|
283
|
+
title.appendChild(chip);
|
|
284
|
+
}
|
|
285
|
+
if (job.permissionMode) {
|
|
286
|
+
// Only when the card overrides the board. The mode decides how much its
|
|
287
|
+
// agent may do unasked, so a card carrying its own must say so on its face
|
|
288
|
+
// rather than only inside the edit form.
|
|
289
|
+
const chip = document.createElement('span');
|
|
290
|
+
// bypassPermissions gets the danger colour rather than the routine gold:
|
|
291
|
+
// it is the one mode with nothing reviewing the agent, and a card already
|
|
292
|
+
// on the board is exactly where that has to be readable at a glance.
|
|
293
|
+
chip.className = `job-card-type${job.permissionMode === DANGEROUS_MODE ? ' job-mode-danger' : ''}`;
|
|
294
|
+
chip.textContent = job.permissionMode;
|
|
295
|
+
chip.title = job.agent === 'codex'
|
|
296
|
+
? `This job runs on Codex in ${job.permissionMode}, mapped onto Codex's sandbox and approval flags, instead of the board's setting`
|
|
297
|
+
: `This job runs with --permission-mode ${job.permissionMode} instead of the board's setting`;
|
|
298
|
+
title.appendChild(chip);
|
|
299
|
+
}
|
|
300
|
+
card.appendChild(title);
|
|
301
|
+
|
|
302
|
+
const meta = document.createElement('div');
|
|
303
|
+
meta.className = 'job-card-meta';
|
|
304
|
+
// "Who posted it" is up to three facts: the person it belongs to, the agent
|
|
305
|
+
// that typed it (when one did — see the board's MCP tool), and when. Any of
|
|
306
|
+
// the first two can be absent, so filter rather than branch.
|
|
307
|
+
const posted = [
|
|
308
|
+
job.postedByName ? escapeHtml(job.postedByName) : null,
|
|
309
|
+
job.postedByAgent ? `<span class="job-card-via">via ${escapeHtml(job.postedByAgent)}</span>` : null,
|
|
310
|
+
relativeTime(job.postedAt),
|
|
311
|
+
// An agent can rewrite a To do card through the board's edit_job tool, and
|
|
312
|
+
// that text becomes the next agent's prompt. The card would otherwise still
|
|
313
|
+
// read as the work of whoever queued it, so the rewrite says so on the card
|
|
314
|
+
// and not only in the toast that has already scrolled away.
|
|
315
|
+
job.editedByAgent
|
|
316
|
+
? `<span class="job-card-via">edited by ${escapeHtml(job.editedByAgent)} ${relativeTime(job.editedAt)}</span>`
|
|
317
|
+
: null,
|
|
318
|
+
].filter(Boolean).join(' · ');
|
|
319
|
+
meta.innerHTML = `<span class="job-card-repo">${escapeHtml(repoSlug(job.repoPath))}</span><span class="job-card-posted">${posted}</span>`;
|
|
320
|
+
card.appendChild(meta);
|
|
321
|
+
|
|
322
|
+
if (isScheduled(job)) {
|
|
323
|
+
const sched = document.createElement('div');
|
|
324
|
+
sched.className = 'job-card-schedule';
|
|
325
|
+
const bits = [`<span class="job-card-cron">${escapeHtml(job.schedule || '')}</span>`];
|
|
326
|
+
if (job.paused) {
|
|
327
|
+
// The stored nextRunAt is not shown: it is the due time the pause is
|
|
328
|
+
// holding, and resuming re-arms from that moment instead of running it.
|
|
329
|
+
bits.push('<span class="job-card-next job-card-paused">paused</span>');
|
|
330
|
+
} else if (job.nextRunAt) {
|
|
331
|
+
bits.push(`<span class="job-card-next">next ${escapeHtml(clockTime(job.nextRunAt))} · ${escapeHtml(untilTime(job.nextRunAt))}</span>`);
|
|
332
|
+
} else {
|
|
333
|
+
// nextCronIso returned nothing: a valid expression that matches no date
|
|
334
|
+
// that will ever come round, such as 30 February.
|
|
335
|
+
bits.push('<span class="job-card-next">never fires again</span>');
|
|
336
|
+
}
|
|
337
|
+
// Coerced, not trusted: this lands in innerHTML, and job records come from
|
|
338
|
+
// config.json, which a person can edit by hand.
|
|
339
|
+
const runs = Number(job.runCount) || 0;
|
|
340
|
+
if (runs) bits.push(`<span class="job-card-runs">· ran ${runs}\u00d7${job.lastRunAt ? `, last ${escapeHtml(relativeTime(job.lastRunAt))}` : ''}</span>`);
|
|
341
|
+
// Where its latest run is, so the schedule points at the card to look at.
|
|
342
|
+
const latest = job.lastRunJobId ? jobs.get(job.lastRunJobId) : null;
|
|
343
|
+
const column = latest && (COLUMNS.find(c => c.state === latest.state)?.label || (latest.state === 'done' ? 'Finished' : null));
|
|
344
|
+
sched.innerHTML = bits.join(' ');
|
|
345
|
+
card.appendChild(sched);
|
|
346
|
+
// The way to the card to look at: its agent when it has a live one,
|
|
347
|
+
// otherwise the card itself on the board.
|
|
348
|
+
if (column) {
|
|
349
|
+
const link = document.createElement('button');
|
|
350
|
+
link.className = 'job-card-lastrun';
|
|
351
|
+
link.textContent = `latest run: ${column}`;
|
|
352
|
+
link.title = latest.agentSessionId && agents.has(latest.agentSessionId) ? 'Open its agent' : 'Show the run';
|
|
353
|
+
link.onclick = (e) => {
|
|
354
|
+
e.stopPropagation();
|
|
355
|
+
if (latest.agentSessionId && agents.has(latest.agentSessionId)) return switchToSession(latest.agentSessionId);
|
|
356
|
+
if (latest.state === 'done' && !showingFinished) { showingFinished = true; renderBoard(); }
|
|
357
|
+
document.querySelector(`[data-job-id="${CSS.escape(latest.id)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
358
|
+
};
|
|
359
|
+
card.appendChild(link);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A firing it held off (see scheduleHold), said out loud: a schedule that
|
|
363
|
+
// quietly stops posting runs is the failure this is here to prevent.
|
|
364
|
+
if (job.lastSkipReason && !job.paused) {
|
|
365
|
+
const skip = document.createElement('div');
|
|
366
|
+
skip.className = 'job-card-held';
|
|
367
|
+
skip.textContent = `held off ${relativeTime(job.lastSkipAt)}: ${job.lastSkipReason}`;
|
|
368
|
+
card.appendChild(skip);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (job.detail) {
|
|
373
|
+
const detail = document.createElement('div');
|
|
374
|
+
detail.className = 'job-card-detail';
|
|
375
|
+
detail.textContent = job.detail;
|
|
376
|
+
card.appendChild(detail);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (Array.isArray(job.attachments) && job.attachments.length) {
|
|
380
|
+
const files = document.createElement('div');
|
|
381
|
+
files.className = 'job-card-attachments';
|
|
382
|
+
for (const a of job.attachments) {
|
|
383
|
+
const link = document.createElement('a');
|
|
384
|
+
link.className = 'job-card-attachment';
|
|
385
|
+
link.href = attachmentUrl(job.id, a.name);
|
|
386
|
+
link.target = '_blank';
|
|
387
|
+
link.rel = 'noopener noreferrer';
|
|
388
|
+
link.textContent = a.name;
|
|
389
|
+
files.appendChild(link);
|
|
390
|
+
}
|
|
391
|
+
card.appendChild(files);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Who worked on it, and where the work is (requirement 3). Rendered whenever
|
|
395
|
+
// EITHER is known: the branch is a fact about the job, not about the agent,
|
|
396
|
+
// and gating it on the name left a finished card showing nothing at all once
|
|
397
|
+
// the name was lost.
|
|
398
|
+
if (job.agentName || job.branchName || job.startedAt) {
|
|
399
|
+
const agentEl = document.createElement('div');
|
|
400
|
+
agentEl.className = 'job-card-agent';
|
|
401
|
+
const parts = [];
|
|
402
|
+
if (job.agentName) parts.push(`<span class="job-card-agent-name">${escapeHtml(job.agentName)}</span>`);
|
|
403
|
+
if (job.branchName) parts.push(`<span class="job-card-branch">${escapeHtml(job.branchName)}</span>`);
|
|
404
|
+
if (job.startedAt) parts.push(`<span class="job-card-since">· started ${relativeTime(job.startedAt)}</span>`);
|
|
405
|
+
agentEl.innerHTML = parts.join(' ');
|
|
406
|
+
card.appendChild(agentEl);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (status) {
|
|
410
|
+
const badge = document.createElement('button');
|
|
411
|
+
badge.className = `job-card-status job-status-${status}`;
|
|
412
|
+
badge.innerHTML = `<span class="job-status-dot"></span>${escapeHtml((STATUS_TEXT[status] || status))}`;
|
|
413
|
+
const canJump = !!liveAgentId;
|
|
414
|
+
badge.title = canJump
|
|
415
|
+
? `Open ${job.agentName}'s terminal`
|
|
416
|
+
: 'This agent is no longer running';
|
|
417
|
+
badge.onclick = (e) => {
|
|
418
|
+
e.stopPropagation();
|
|
419
|
+
if (canJump) switchToSession(liveAgentId);
|
|
420
|
+
};
|
|
421
|
+
if (!canJump) badge.disabled = true;
|
|
422
|
+
card.appendChild(badge);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// What the agent reported through finish_job. On a card that opens no PR
|
|
426
|
+
// this is the result itself, so it is shown in full rather than clipped.
|
|
427
|
+
if (job.resultSummary) {
|
|
428
|
+
const result = document.createElement('div');
|
|
429
|
+
result.className = 'job-card-result';
|
|
430
|
+
result.textContent = job.resultSummary;
|
|
431
|
+
// It scrolls, so it must take focus for keyboard users to reach the rest.
|
|
432
|
+
result.tabIndex = 0;
|
|
433
|
+
result.setAttribute('role', 'region');
|
|
434
|
+
result.setAttribute('aria-label', 'Agent summary');
|
|
435
|
+
result.title = ''; // the card's "open terminal" tooltip does not apply here
|
|
436
|
+
card.appendChild(result);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// How many runs of the same schedule this one replaced in Review, and on an
|
|
440
|
+
// archived run, that it was replaced — so the archive does not read as if
|
|
441
|
+
// nobody ever looked at a result that was simply overtaken.
|
|
442
|
+
if (job.supersededRuns || job.supersededBy) {
|
|
443
|
+
const note = document.createElement('div');
|
|
444
|
+
note.className = 'job-card-retired';
|
|
445
|
+
note.textContent = job.supersededBy
|
|
446
|
+
? 'superseded by a newer run'
|
|
447
|
+
: `filed ${job.supersededRuns} earlier run${job.supersededRuns === 1 ? '' : 's'} to Finished`;
|
|
448
|
+
card.appendChild(note);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// A Review card keeps its agent until it is done, so a missing one went with
|
|
452
|
+
// a restart or was closed by hand — say so rather than leaving a bare name.
|
|
453
|
+
if (job.state === 'review' && job.agentName && !agents.has(job.agentSessionId)) {
|
|
454
|
+
const note = document.createElement('div');
|
|
455
|
+
note.className = 'job-card-retired';
|
|
456
|
+
note.textContent = 'agent closed';
|
|
457
|
+
card.appendChild(note);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (job.state === 'done') {
|
|
461
|
+
const fin = document.createElement('div');
|
|
462
|
+
fin.className = 'job-card-finished';
|
|
463
|
+
fin.textContent = job.prMergedAt
|
|
464
|
+
? `merged ${relativeTime(job.prMergedAt)}`
|
|
465
|
+
: job.prClosedAt
|
|
466
|
+
? `PR closed without merging ${relativeTime(job.prClosedAt)}`
|
|
467
|
+
: `finished ${relativeTime(job.doneAt)}`;
|
|
468
|
+
card.appendChild(fin);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The URL comes from `gh pr list`, but it still ends up in an href, and a
|
|
472
|
+
// non-http scheme there (javascript:, data:) executes on click. Cheap guard.
|
|
473
|
+
const prHref = /^https?:\/\//i.test(String(job.prUrl || '')) ? job.prUrl : null;
|
|
474
|
+
if (prHref) {
|
|
475
|
+
const pr = document.createElement('a');
|
|
476
|
+
pr.className = 'job-card-pr';
|
|
477
|
+
pr.href = prHref;
|
|
478
|
+
pr.target = '_blank';
|
|
479
|
+
pr.rel = 'noopener noreferrer';
|
|
480
|
+
pr.textContent = job.prNumber ? `PR #${job.prNumber}` : 'View PR';
|
|
481
|
+
card.appendChild(pr);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (job.lastError) {
|
|
485
|
+
const err = document.createElement('div');
|
|
486
|
+
err.className = 'job-card-error';
|
|
487
|
+
err.textContent = job.lastError;
|
|
488
|
+
card.appendChild(err);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Shown alongside lastError, never instead of it: "the agent is gone" and
|
|
492
|
+
// "the board cannot see this repo's pull requests" are both worth knowing,
|
|
493
|
+
// and together they explain why a card is stuck.
|
|
494
|
+
if (job.prCheckError) {
|
|
495
|
+
const err = document.createElement('div');
|
|
496
|
+
err.className = 'job-card-error';
|
|
497
|
+
err.textContent = job.prCheckError;
|
|
498
|
+
card.appendChild(err);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
card.appendChild(renderCardActions(job));
|
|
502
|
+
|
|
503
|
+
if (liveAgentId) {
|
|
504
|
+
// Pointer affordance only. The card holds buttons and a link, so giving it
|
|
505
|
+
// role="button" would flatten them out of the accessibility tree, and a
|
|
506
|
+
// second tab stop would just duplicate the status badge — which is already
|
|
507
|
+
// a real button that jumps to the same place.
|
|
508
|
+
card.classList.add('job-card-live');
|
|
509
|
+
card.title = `Open ${job.agentName || 'this agent'}'s terminal`;
|
|
510
|
+
card.onclick = (e) => {
|
|
511
|
+
// The buttons and the PR link own their own clicks, and a click that ends
|
|
512
|
+
// a selection inside this card is someone reading it, not asking to leave.
|
|
513
|
+
// So is a click in the agent's summary, which scrolls.
|
|
514
|
+
if (e.target.closest && e.target.closest('button, a, .job-card-result')) return;
|
|
515
|
+
const sel = window.getSelection?.();
|
|
516
|
+
if (sel && !sel.isCollapsed && card.contains(sel.anchorNode)) return;
|
|
517
|
+
switchToSession(liveAgentId);
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return card;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderCardActions(job) {
|
|
525
|
+
const actions = document.createElement('div');
|
|
526
|
+
actions.className = 'job-card-actions';
|
|
527
|
+
|
|
528
|
+
const mk = (label, title, onClick, cls = '') => {
|
|
529
|
+
const b = document.createElement('button');
|
|
530
|
+
b.className = `job-card-btn ${cls}`.trim();
|
|
531
|
+
b.textContent = label;
|
|
532
|
+
b.title = title;
|
|
533
|
+
b.onclick = (e) => { e.stopPropagation(); onClick(); };
|
|
534
|
+
return b;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// Manual moves in both directions: the automatic transitions are best-effort
|
|
538
|
+
// (gh may be unavailable, an agent may finish without a PR), so the user
|
|
539
|
+
// always keeps the final say over where a card sits.
|
|
540
|
+
if (job.state === 'todo') {
|
|
541
|
+
actions.appendChild(mk('Edit', 'Edit this job', () => openForm(job.id)));
|
|
542
|
+
}
|
|
543
|
+
// Pause holds the schedule's next firing; a run already posted is its own
|
|
544
|
+
// card and is left alone.
|
|
545
|
+
if (isScheduled(job)) {
|
|
546
|
+
actions.appendChild(job.paused
|
|
547
|
+
? mk('Resume', 'Resume this schedule. The next run is set from now, so a firing missed while paused is not replayed.',
|
|
548
|
+
() => send({ type: 'job-pause', jobId: job.id, paused: false }))
|
|
549
|
+
: mk('Pause', 'Hold this schedule. The card stays in To do and no further runs go out until you resume; a run already under way is left alone.',
|
|
550
|
+
() => send({ type: 'job-pause', jobId: job.id, paused: true })));
|
|
551
|
+
}
|
|
552
|
+
// A schedule never moves (the server refuses it too); its runs do.
|
|
553
|
+
if (job.state === 'in-progress' && !isScheduled(job)) {
|
|
554
|
+
actions.appendChild(mk('→ Review', 'Mark as ready for review (use when the agent finished without calling finish_job). The agent is kept.', () => send({ type: 'job-move', jobId: job.id, state: 'review' })));
|
|
555
|
+
actions.appendChild(mk('← To do', 'Requeue this job and close its agent. Uncommitted or unpushed work is kept as an orphan.', () => {
|
|
556
|
+
if (confirm(`Return "${job.title}" to To do?\n\n${job.agentName || 'The agent'} is closed and its worktree released, then the board dispatches a fresh agent for this job. Any uncommitted or unpushed work is kept as an orphan.`)) {
|
|
557
|
+
send({ type: 'job-move', jobId: job.id, state: 'todo' });
|
|
558
|
+
}
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
if (job.state === 'review' && !isScheduled(job)) {
|
|
562
|
+
actions.appendChild(mk('← In progress', 'Send back to In progress', () => send({ type: 'job-move', jobId: job.id, state: 'in-progress' })));
|
|
563
|
+
// The board files a card away by itself once its PR merges; this is the
|
|
564
|
+
// same move by hand, for a PR the board cannot see or work that landed
|
|
565
|
+
// some other way. It is one-way — done is the end of a card's life on the
|
|
566
|
+
// board — so it asks first, the way Delete does.
|
|
567
|
+
actions.appendChild(mk('✓ Done', 'File this job away as finished. This is final: the card leaves the board for Finished jobs and cannot be brought back.', () => {
|
|
568
|
+
// Names the agent for the same reason Delete does: this move retires it,
|
|
569
|
+
// and a Review agent is one someone may still be talking to.
|
|
570
|
+
const agentNote = job.agentSessionId
|
|
571
|
+
? `${job.agentName || 'Its agent'} is closed and its worktree released. `
|
|
572
|
+
: '';
|
|
573
|
+
if (confirm(`File "${job.title}" away as finished?\n\n${agentNote}The card leaves the board for Finished jobs and cannot be moved back — follow-up work needs a new job.`)) {
|
|
574
|
+
send({ type: 'job-move', jobId: job.id, state: 'done' });
|
|
575
|
+
}
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
actions.appendChild(mk('Delete', 'Delete this job from the board', () => {
|
|
579
|
+
if (confirm(`Delete "${job.title}"?\n\n${job.agentSessionId ? 'Its agent is closed and the worktree released. Uncommitted or unpushed work is kept as an orphan.' : 'This removes the card.'}`)) {
|
|
580
|
+
send({ type: 'job-delete', jobId: job.id });
|
|
581
|
+
}
|
|
582
|
+
}, 'danger'));
|
|
583
|
+
|
|
584
|
+
return actions;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// --- Toolbar ---
|
|
588
|
+
|
|
589
|
+
function renderToolbar() {
|
|
590
|
+
const toggle = document.getElementById('btn-dispatcher-toggle');
|
|
591
|
+
const status = document.getElementById('job-dispatcher-status');
|
|
592
|
+
const cap = document.getElementById('job-max-per-repo');
|
|
593
|
+
const perm = document.getElementById('job-permission-mode');
|
|
594
|
+
if (!toggle) return;
|
|
595
|
+
|
|
596
|
+
const running = boardSettings.running;
|
|
597
|
+
toggle.textContent = running ? 'Stop' : 'Start';
|
|
598
|
+
toggle.classList.toggle('running', running);
|
|
599
|
+
const mins = Math.round((boardSettings.intervalMs || 300000) / 60000);
|
|
600
|
+
status.textContent = running
|
|
601
|
+
? `scanning every ${mins}m`
|
|
602
|
+
: 'dispatcher stopped';
|
|
603
|
+
status.classList.toggle('running', running);
|
|
604
|
+
if (document.activeElement !== cap) cap.value = boardSettings.maxPerRepo;
|
|
605
|
+
// The mode a card falls back to when it does not name one of its own. With
|
|
606
|
+
// none picked here and a .env default in play, that default is what workers
|
|
607
|
+
// start in, so the dropdown says so (an entry of its own, so that picking
|
|
608
|
+
// any mode, the built-in one included, still counts as picking it).
|
|
609
|
+
if (perm && document.activeElement !== perm) {
|
|
610
|
+
const env = boardSettings.envModes || {};
|
|
611
|
+
const fromEnv = !boardSettings.permissionModeChosen && (env.claude || env.codex);
|
|
612
|
+
let entry = perm.querySelector('option[value=""]');
|
|
613
|
+
if (fromEnv) {
|
|
614
|
+
if (!entry) { entry = document.createElement('option'); entry.value = ''; perm.prepend(entry); }
|
|
615
|
+
const base = boardSettings.permissionMode || 'auto';
|
|
616
|
+
entry.textContent = `.env: claude ${env.claude || base}, codex ${env.codex || base}`;
|
|
617
|
+
} else if (entry) {
|
|
618
|
+
entry.remove();
|
|
619
|
+
}
|
|
620
|
+
perm.value = fromEnv ? '' : (boardSettings.permissionMode || 'auto');
|
|
621
|
+
}
|
|
622
|
+
markDangerousMode(perm);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// --- Attachments ---
|
|
626
|
+
|
|
627
|
+
// Mirrors the server's limits in server/jobs.js (public/ cannot import it).
|
|
628
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
629
|
+
const MAX_ATTACHMENTS = 20;
|
|
630
|
+
const MAX_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
631
|
+
// Pasted screenshots carry a MIME type, not a name.
|
|
632
|
+
const IMAGE_EXT = { 'image/jpeg': 'jpg', 'image/svg+xml': 'svg' };
|
|
633
|
+
|
|
634
|
+
// A plain link cannot send a bearer header, so the token rides in the query,
|
|
635
|
+
// which the server accepts for exactly this case.
|
|
636
|
+
function attachmentUrl(jobId, name) {
|
|
637
|
+
const token = getToken();
|
|
638
|
+
return `/api/jobs/${encodeURIComponent(jobId)}/attachments/${encodeURIComponent(name)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function renderAttachments() {
|
|
642
|
+
const box = document.getElementById('job-attachments');
|
|
643
|
+
if (!box) return;
|
|
644
|
+
box.innerHTML = '';
|
|
645
|
+
for (const a of pendingAttachments) {
|
|
646
|
+
const chip = document.createElement('span');
|
|
647
|
+
chip.className = 'job-attachment';
|
|
648
|
+
chip.textContent = a.name;
|
|
649
|
+
const rm = document.createElement('button');
|
|
650
|
+
rm.type = 'button';
|
|
651
|
+
rm.className = 'job-attachment-remove';
|
|
652
|
+
rm.textContent = '\u00d7';
|
|
653
|
+
rm.title = `Remove ${a.name}`;
|
|
654
|
+
rm.onclick = () => { pendingAttachments = pendingAttachments.filter(x => x !== a); renderAttachments(); };
|
|
655
|
+
chip.appendChild(rm);
|
|
656
|
+
box.appendChild(chip);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function addAttachments(files, fallbackName) {
|
|
661
|
+
for (const file of files) {
|
|
662
|
+
if (file.size > MAX_ATTACHMENT_BYTES) { showFormError(`${file.name} is too large (max 10MB)`); continue; }
|
|
663
|
+
// A pasted screenshot arrives as "image.png" every time; a timestamp keeps
|
|
664
|
+
// the second one from replacing the first.
|
|
665
|
+
const name = fallbackName && /^image\.\w+$/i.test(file.name || 'image.png')
|
|
666
|
+
? `${fallbackName}-${Date.now()}.${IMAGE_EXT[file.type] || file.type.split('/')[1] || 'png'}`
|
|
667
|
+
: file.name;
|
|
668
|
+
const others = pendingAttachments.filter(a => a.name !== name);
|
|
669
|
+
// The same limits the server enforces, caught here while the file picker
|
|
670
|
+
// is still open rather than as a toast after the form has closed.
|
|
671
|
+
if (others.length >= MAX_ATTACHMENTS) { showFormError(`At most ${MAX_ATTACHMENTS} files per job`); continue; }
|
|
672
|
+
if (others.reduce((n, a) => n + (a.size || 0), 0) + file.size > MAX_ATTACHMENT_TOTAL_BYTES) { showFormError('Attachments add up to more than 50MB'); continue; }
|
|
673
|
+
const entry = { name, size: file.size, reading: true };
|
|
674
|
+
pendingAttachments = [...others, entry];
|
|
675
|
+
const reader = new FileReader();
|
|
676
|
+
reader.onload = () => { entry.data = String(reader.result).split(',')[1]; delete entry.reading; };
|
|
677
|
+
// A file that vanished or cannot be read must not leave a chip that
|
|
678
|
+
// blocks every save with "still reading".
|
|
679
|
+
reader.onerror = reader.onabort = () => {
|
|
680
|
+
pendingAttachments = pendingAttachments.filter(a => a !== entry);
|
|
681
|
+
showFormError(`Could not read ${name}`);
|
|
682
|
+
renderAttachments();
|
|
683
|
+
};
|
|
684
|
+
reader.readAsDataURL(file);
|
|
685
|
+
}
|
|
686
|
+
renderAttachments();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// --- Form ---
|
|
690
|
+
|
|
691
|
+
// The cron box only means anything for a scheduled job, so it is hidden rather
|
|
692
|
+
// than left sitting there inert next to a one-time card.
|
|
693
|
+
// The pull-request choice applies to both: on a schedule it is what its runs
|
|
694
|
+
// get. Its default follows the type (see jobRequiresPr in lib/jobs.js), so a
|
|
695
|
+
// new card switched to scheduled flips to Not required unless picked by hand.
|
|
696
|
+
function syncScheduleField() {
|
|
697
|
+
const scheduled = document.getElementById('job-type').value === 'scheduled';
|
|
698
|
+
document.getElementById('job-schedule-field').style.display = scheduled ? 'flex' : 'none';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
let prPicked = false; // the user touched the PR select on this form
|
|
702
|
+
function syncPrDefault() {
|
|
703
|
+
if (prPicked) return;
|
|
704
|
+
document.getElementById('job-requires-pr').value = document.getElementById('job-type').value === 'scheduled' ? 'no' : 'yes';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A Codex card is offered "board default", auto and bypassPermissions, so the
|
|
708
|
+
// Claude-only modes are hidden for it — except one the card already holds.
|
|
709
|
+
// The server maps every mode onto a Codex flag, so a stored plan or manual is
|
|
710
|
+
// a real, stricter setting; resetting it to board default on open would let
|
|
711
|
+
// an unrelated edit (a typo fixed in the title) quietly widen the card.
|
|
712
|
+
function syncAgentField() {
|
|
713
|
+
const codex = document.getElementById('job-agent')?.value === 'codex';
|
|
714
|
+
const permEl = document.getElementById('job-permission-mode-field');
|
|
715
|
+
if (!permEl) return;
|
|
716
|
+
// Both flags: Safari does not hide a hidden <option> in a native select,
|
|
717
|
+
// so it is disabled as well and refuses the pick there instead.
|
|
718
|
+
for (const opt of permEl.querySelectorAll('option[data-claude-only]')) {
|
|
719
|
+
const off = codex && !opt.selected;
|
|
720
|
+
opt.hidden = off;
|
|
721
|
+
opt.disabled = off;
|
|
722
|
+
}
|
|
723
|
+
// The two modes both CLIs share mean different things under each, and the
|
|
724
|
+
// visible gloss has to say which — a title tooltip never reaches keyboard
|
|
725
|
+
// or touch users.
|
|
726
|
+
for (const opt of permEl.querySelectorAll('option[data-codex-label]')) {
|
|
727
|
+
if (!opt.dataset.claudeLabel) opt.dataset.claudeLabel = opt.textContent;
|
|
728
|
+
opt.textContent = codex ? opt.dataset.codexLabel : opt.dataset.claudeLabel;
|
|
729
|
+
}
|
|
730
|
+
markDangerousMode(permEl);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// A shape check only — five whitespace-separated fields, or a known @shorthand.
|
|
734
|
+
// The real parser is lib/cron.js on the server, and it stays the authority; this
|
|
735
|
+
// exists so the commonest typo (too few fields) is caught while the form is
|
|
736
|
+
// still open and the user's text is still in it, rather than coming back as a
|
|
737
|
+
// toast after the form has closed. Keep the @-list in sync with CRON_MACROS in
|
|
738
|
+
// lib/cron.js — public/ cannot import server modules, so this is a copy.
|
|
739
|
+
const CRON_MACROS = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'];
|
|
740
|
+
|
|
741
|
+
function looksLikeCron(text) {
|
|
742
|
+
const raw = text.trim();
|
|
743
|
+
if (raw.startsWith('@')) return CRON_MACROS.includes(raw.toLowerCase());
|
|
744
|
+
return raw.split(/\s+/).length === 5;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function openForm(jobId) {
|
|
748
|
+
// The agent spawn form is an overlay anchored below the 36px header strip,
|
|
749
|
+
// and it sizes to its own content rather than covering the viewport, so the
|
|
750
|
+
// board's toolbar and cards stay clickable underneath it. Every door into
|
|
751
|
+
// this form goes through here, so this is the one place that has to dismiss
|
|
752
|
+
// it — otherwise the form opens behind the overlay and focuses a title input
|
|
753
|
+
// the user cannot see, swallowing whatever they type next.
|
|
754
|
+
const spawn = document.getElementById('spawn-form');
|
|
755
|
+
if (spawn) spawn.style.display = 'none';
|
|
756
|
+
editingJobId = jobId || null;
|
|
757
|
+
// A new job lands in To do, which the archive is covering. Posting one from
|
|
758
|
+
// there would look like nothing happened, so the form takes you back to the
|
|
759
|
+
// columns first — the same move showJobBoard makes. Repainted immediately
|
|
760
|
+
// rather than waiting for the server's next broadcast, so the form never
|
|
761
|
+
// opens over a view its result will not appear in.
|
|
762
|
+
if (showingFinished) {
|
|
763
|
+
showingFinished = false;
|
|
764
|
+
renderBoard();
|
|
765
|
+
}
|
|
766
|
+
const form = document.getElementById('job-form');
|
|
767
|
+
const titleEl = document.getElementById('job-title');
|
|
768
|
+
const detailEl = document.getElementById('job-detail');
|
|
769
|
+
const repoEl = document.getElementById('job-repo');
|
|
770
|
+
const typeEl = document.getElementById('job-type');
|
|
771
|
+
const scheduleEl = document.getElementById('job-schedule');
|
|
772
|
+
const permEl = document.getElementById('job-permission-mode-field');
|
|
773
|
+
const agentEl = document.getElementById('job-agent');
|
|
774
|
+
const prEl = document.getElementById('job-requires-pr');
|
|
775
|
+
const saveBtn = document.getElementById('btn-job-save');
|
|
776
|
+
|
|
777
|
+
repoEl.innerHTML = '';
|
|
778
|
+
for (const [path, repo] of repos) {
|
|
779
|
+
const opt = document.createElement('option');
|
|
780
|
+
opt.value = path;
|
|
781
|
+
opt.textContent = repo.slug;
|
|
782
|
+
repoEl.appendChild(opt);
|
|
783
|
+
}
|
|
784
|
+
if (repos.size === 0) {
|
|
785
|
+
const opt = document.createElement('option');
|
|
786
|
+
opt.value = '';
|
|
787
|
+
opt.textContent = 'No repositories — add one in the explorer first';
|
|
788
|
+
repoEl.appendChild(opt);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const job = jobId ? jobs.get(jobId) : null;
|
|
792
|
+
titleEl.value = job ? job.title : '';
|
|
793
|
+
detailEl.value = job ? job.detail : '';
|
|
794
|
+
if (job) repoEl.value = job.repoPath;
|
|
795
|
+
typeEl.value = job && isScheduled(job) ? 'scheduled' : 'one-time';
|
|
796
|
+
scheduleEl.value = job && job.schedule ? job.schedule : '';
|
|
797
|
+
// '' is the "Board default" option, and it is what a card with no mode of
|
|
798
|
+
// its own goes back to — never pre-filled with the board's current value,
|
|
799
|
+
// which would silently freeze the card onto today's setting.
|
|
800
|
+
if (permEl) permEl.value = job && job.permissionMode ? job.permissionMode : '';
|
|
801
|
+
if (agentEl) agentEl.value = job && job.agent === 'codex' ? 'codex' : 'claude';
|
|
802
|
+
prPicked = false;
|
|
803
|
+
prEl.value = job ? (job.requiresPr === false || (isScheduled(job) && job.requiresPr !== true) ? 'no' : 'yes') : 'yes';
|
|
804
|
+
syncAgentField(); // also marks the danger colour on the permission select
|
|
805
|
+
pendingAttachments = job && Array.isArray(job.attachments) ? job.attachments.map(a => ({ name: a.name })) : [];
|
|
806
|
+
renderAttachments();
|
|
807
|
+
syncScheduleField();
|
|
808
|
+
saveBtn.textContent = job ? 'Save' : 'Post job';
|
|
809
|
+
clearFormError();
|
|
810
|
+
form.style.display = 'flex';
|
|
811
|
+
titleEl.focus();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function closeForm() {
|
|
815
|
+
editingJobId = null;
|
|
816
|
+
document.getElementById('job-form').style.display = 'none';
|
|
817
|
+
clearFormError();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function showFormError(text) {
|
|
821
|
+
const el = document.getElementById('job-form-error');
|
|
822
|
+
el.textContent = text;
|
|
823
|
+
el.style.display = 'block';
|
|
824
|
+
}
|
|
825
|
+
function clearFormError() {
|
|
826
|
+
const el = document.getElementById('job-form-error');
|
|
827
|
+
el.textContent = '';
|
|
828
|
+
el.style.display = 'none';
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function saveForm() {
|
|
832
|
+
const title = document.getElementById('job-title').value.trim();
|
|
833
|
+
const detail = document.getElementById('job-detail').value;
|
|
834
|
+
const repoPath = document.getElementById('job-repo').value;
|
|
835
|
+
const jobType = document.getElementById('job-type').value;
|
|
836
|
+
const schedule = document.getElementById('job-schedule').value.trim();
|
|
837
|
+
const permissionMode = document.getElementById('job-permission-mode-field')?.value || '';
|
|
838
|
+
const agent = document.getElementById('job-agent')?.value || 'claude';
|
|
839
|
+
const requiresPr = document.getElementById('job-requires-pr').value !== 'no';
|
|
840
|
+
if (!title) return showFormError('Give the job a title.');
|
|
841
|
+
if (!repoPath) return showFormError('Add a repository in the explorer first — a job needs one to run in.');
|
|
842
|
+
if (jobType === 'scheduled' && !schedule) return showFormError('A scheduled job needs a cron schedule, for example "0 9 * * 1-5".');
|
|
843
|
+
if (jobType === 'scheduled' && !looksLikeCron(schedule)) {
|
|
844
|
+
return showFormError('That does not look like a cron schedule — five fields (minute hour day month weekday), or @daily / @hourly / @weekly.');
|
|
845
|
+
}
|
|
846
|
+
if (pendingAttachments.some(a => a.reading)) return showFormError('Still reading an attached file — try again in a moment.');
|
|
847
|
+
// The form always holds the complete list; an empty one on an edit means
|
|
848
|
+
// "none left".
|
|
849
|
+
const attachments = pendingAttachments.map(a => ({ name: a.name, data: a.data }));
|
|
850
|
+
const fields = { title, detail, repoPath, jobType, schedule, permissionMode, agent, requiresPr, attachments };
|
|
851
|
+
if (editingJobId) send({ type: 'job-update', jobId: editingJobId, ...fields });
|
|
852
|
+
else send({ type: 'job-create', ...fields });
|
|
853
|
+
closeForm();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Opening the form from outside the board — the top bar's "+ Job" button. The
|
|
857
|
+
// board may not be the visible tab, so bring it up first; otherwise the form
|
|
858
|
+
// opens where nobody can see it. The tab strip repaints itself: showJobBoard()
|
|
859
|
+
// fires the board-visibility hook, which app.js wires to updateTabs(), and
|
|
860
|
+
// openForm() clears the spawn overlay. app.js closes this form on the way in
|
|
861
|
+
// the other direction.
|
|
862
|
+
export function openJobForm() {
|
|
863
|
+
showJobBoard();
|
|
864
|
+
openForm(null);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export function closeJobForm() {
|
|
868
|
+
closeForm();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// --- Show / hide ---
|
|
872
|
+
|
|
873
|
+
export function showJobBoard() {
|
|
874
|
+
// Always open on the columns. Opening the board is the user asking for the
|
|
875
|
+
// live work; the archive is somewhere you go on purpose, not somewhere the
|
|
876
|
+
// Jobs tab can strand you.
|
|
877
|
+
showingFinished = false;
|
|
878
|
+
const agent = activeSessionId ? agents.get(activeSessionId) : null;
|
|
879
|
+
if (agent) agent.termEl.style.display = 'none';
|
|
880
|
+
document.getElementById('terminal-empty').style.display = 'none';
|
|
881
|
+
document.getElementById('job-board').style.display = 'flex';
|
|
882
|
+
setBoardActive(true);
|
|
883
|
+
renderToolbar();
|
|
884
|
+
renderBoard();
|
|
885
|
+
if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export function hideJobBoard() {
|
|
889
|
+
document.getElementById('job-board').style.display = 'none';
|
|
890
|
+
setBoardActive(false);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// --- Messages ---
|
|
894
|
+
|
|
895
|
+
export function handleJobsList(msg) {
|
|
896
|
+
jobs.clear();
|
|
897
|
+
for (const job of msg.jobs || []) jobs.set(job.id, job);
|
|
898
|
+
if (msg.settings) setBoardSettings(msg.settings);
|
|
899
|
+
renderToolbar();
|
|
900
|
+
renderBoard();
|
|
901
|
+
if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// --- Setup ---
|
|
905
|
+
|
|
906
|
+
export function setupJobBoard() {
|
|
907
|
+
document.getElementById('btn-new-job').onclick = () => openForm(null);
|
|
908
|
+
document.getElementById('btn-new-job-shortcut').onclick = openJobForm;
|
|
909
|
+
document.getElementById('btn-job-cancel').onclick = closeForm;
|
|
910
|
+
document.getElementById('btn-job-save').onclick = saveForm;
|
|
911
|
+
document.getElementById('job-title').addEventListener('keydown', (e) => {
|
|
912
|
+
if (e.key === 'Enter') { e.preventDefault(); saveForm(); }
|
|
913
|
+
if (e.key === 'Escape') closeForm();
|
|
914
|
+
});
|
|
915
|
+
document.getElementById('job-type').onchange = () => { syncScheduleField(); syncPrDefault(); };
|
|
916
|
+
const prSelect = document.getElementById('job-requires-pr');
|
|
917
|
+
if (prSelect) prSelect.onchange = () => { prPicked = true; };
|
|
918
|
+
document.getElementById('job-schedule').addEventListener('keydown', (e) => {
|
|
919
|
+
if (e.key === 'Enter') { e.preventDefault(); saveForm(); }
|
|
920
|
+
if (e.key === 'Escape') closeForm();
|
|
921
|
+
});
|
|
922
|
+
document.getElementById('job-detail').addEventListener('keydown', (e) => {
|
|
923
|
+
// Cmd/Ctrl+Enter submits; a bare Enter has to stay available for newlines.
|
|
924
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveForm(); }
|
|
925
|
+
if (e.key === 'Escape') closeForm();
|
|
926
|
+
});
|
|
927
|
+
document.getElementById('job-detail').addEventListener('paste', (e) => {
|
|
928
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
929
|
+
if (!files.length) return;
|
|
930
|
+
e.preventDefault();
|
|
931
|
+
addAttachments(files, 'screenshot');
|
|
932
|
+
});
|
|
933
|
+
const attachBtn = document.getElementById('btn-job-attach');
|
|
934
|
+
const attachInput = document.getElementById('job-attach-input');
|
|
935
|
+
if (attachBtn && attachInput) {
|
|
936
|
+
attachBtn.onclick = () => attachInput.click();
|
|
937
|
+
attachInput.onchange = () => { addAttachments(attachInput.files); attachInput.value = ''; };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
document.getElementById('btn-dispatcher-toggle').onclick = () => {
|
|
941
|
+
send({ type: 'job-settings', running: !boardSettings.running });
|
|
942
|
+
};
|
|
943
|
+
document.getElementById('btn-dispatch-now').onclick = () => {
|
|
944
|
+
send({ type: 'job-dispatch-now' });
|
|
945
|
+
};
|
|
946
|
+
const finishedBtn = document.getElementById('btn-finished-jobs');
|
|
947
|
+
if (finishedBtn) finishedBtn.onclick = () => { showingFinished = !showingFinished; renderBoard(); };
|
|
948
|
+
document.getElementById('job-max-per-repo').onchange = (e) => {
|
|
949
|
+
const value = parseInt(e.target.value, 10);
|
|
950
|
+
if (Number.isFinite(value)) send({ type: 'job-settings', maxPerRepo: value });
|
|
951
|
+
};
|
|
952
|
+
const permEl = document.getElementById('job-permission-mode');
|
|
953
|
+
// Sending this is what records the mode as CHOSEN on the server, which is
|
|
954
|
+
// how a stored value is told apart from one a default wrote (see
|
|
955
|
+
// boardSettings in server/jobs.js) — so it must only fire on a real change.
|
|
956
|
+
if (permEl) permEl.onchange = (e) => {
|
|
957
|
+
markDangerousMode(e.target);
|
|
958
|
+
send({ type: 'job-settings', permissionMode: e.target.value });
|
|
959
|
+
};
|
|
960
|
+
// The form's own select is marked as it changes too, not only when the form
|
|
961
|
+
// opens — the warning has to be on screen while the choice is being made.
|
|
962
|
+
const formPermEl = document.getElementById('job-permission-mode-field');
|
|
963
|
+
if (formPermEl) formPermEl.onchange = () => markDangerousMode(formPermEl);
|
|
964
|
+
const agentEl = document.getElementById('job-agent');
|
|
965
|
+
if (agentEl) agentEl.onchange = syncAgentField;
|
|
966
|
+
|
|
967
|
+
// Relative timestamps and the "quiet" threshold both drift with the clock, so
|
|
968
|
+
// the board re-renders on a slow tick while it is visible. Cheap: it only
|
|
969
|
+
// touches the DOM when the board is the active tab.
|
|
970
|
+
setInterval(() => { if (boardActive) renderBoard(); }, 30_000);
|
|
971
|
+
}
|