flowviant 0.6.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/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/cli.mjs +78 -0
- package/bin/lib/claude.mjs +151 -0
- package/bin/lib/config.mjs +55 -0
- package/bin/lib/fleet.mjs +363 -0
- package/bin/lib/git.mjs +44 -0
- package/bin/lib/live.mjs +483 -0
- package/bin/lib/login.mjs +84 -0
- package/bin/lib/preflight.mjs +49 -0
- package/bin/lib/preview.mjs +169 -0
- package/bin/lib/single.mjs +66 -0
- package/bin/lib/ui.mjs +26 -0
- package/package.json +37 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet daemon. Install ONCE with a fleet credential; manage everything from
|
|
3
|
+
* Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one persistent
|
|
4
|
+
* git worktree + worker loop per roster agent, rotates each worker's short-lived
|
|
5
|
+
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
VERSION,
|
|
14
|
+
FLEET_URL,
|
|
15
|
+
FLEET_TOKEN,
|
|
16
|
+
USER_AGENT,
|
|
17
|
+
MCP_URL,
|
|
18
|
+
SAFE,
|
|
19
|
+
POLL_SECONDS,
|
|
20
|
+
IDLE_SECONDS,
|
|
21
|
+
RECONCILE_SECONDS,
|
|
22
|
+
REFRESH_BEFORE_SECONDS,
|
|
23
|
+
LIVE,
|
|
24
|
+
} from './config.mjs';
|
|
25
|
+
import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
|
|
26
|
+
import {
|
|
27
|
+
sleep,
|
|
28
|
+
mcpConfigFor,
|
|
29
|
+
runTurn,
|
|
30
|
+
sawSentinel,
|
|
31
|
+
blockedId,
|
|
32
|
+
SYSTEM_SINGLE,
|
|
33
|
+
SINGLE_KICKOFF,
|
|
34
|
+
SINGLE_RESUME,
|
|
35
|
+
} from './claude.mjs';
|
|
36
|
+
import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
|
|
37
|
+
import { runLiveWorker } from './live.mjs';
|
|
38
|
+
import { preflight } from './preflight.mjs';
|
|
39
|
+
|
|
40
|
+
async function fetchRoster(haveIds) {
|
|
41
|
+
const url = new URL(FLEET_URL);
|
|
42
|
+
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
43
|
+
// An explicit User-Agent is required: Node's default ("node"/empty) trips
|
|
44
|
+
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
45
|
+
const res = await fetch(url, {
|
|
46
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
47
|
+
});
|
|
48
|
+
if (res.status === 401 || res.status === 403) {
|
|
49
|
+
// Fleet credential revoked/expired — retrying can't recover; signal exit.
|
|
50
|
+
const e = new Error(`fleet credential rejected (${res.status})`);
|
|
51
|
+
e.auth = true;
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
55
|
+
const body = await res.json();
|
|
56
|
+
return body.data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
60
|
+
// base between tasks (fresh conversation), resume in place while on a blocker.
|
|
61
|
+
async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
|
|
62
|
+
let resuming = false;
|
|
63
|
+
let needsReset = true; // reset to base before a FRESH task, not on idle polls
|
|
64
|
+
let phase = ''; // '', 'idle', 'blocked' — log each transition once, not per poll
|
|
65
|
+
const enter = (p, fn, msg) => {
|
|
66
|
+
if (phase !== p) {
|
|
67
|
+
phase = p;
|
|
68
|
+
fn(`${label} ${msg}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
while (isAlive()) {
|
|
72
|
+
const token = getToken(agentId);
|
|
73
|
+
if (!token) {
|
|
74
|
+
await sleep(IDLE_SECONDS);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Idle = no claimable work (the server tells us via the roster poll). Don't
|
|
78
|
+
// spawn Claude just to find nothing — that's a wasted API call. A blocked
|
|
79
|
+
// task (resuming) still polls, so its resolution gets picked up.
|
|
80
|
+
if (!resuming && !getHasWork(agentId)) {
|
|
81
|
+
enter('idle', info, 'idle — no work assigned');
|
|
82
|
+
await sleep(IDLE_SECONDS);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!resuming && needsReset) {
|
|
86
|
+
resetWorktree(cwd, baseRef); // clean slate for a new task
|
|
87
|
+
needsReset = false;
|
|
88
|
+
}
|
|
89
|
+
const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
|
|
90
|
+
let out = '';
|
|
91
|
+
try {
|
|
92
|
+
out = await runTurn({
|
|
93
|
+
prompt: resuming ? SINGLE_RESUME : SINGLE_KICKOFF,
|
|
94
|
+
resume: resuming,
|
|
95
|
+
system: SYSTEM_SINGLE,
|
|
96
|
+
cwd,
|
|
97
|
+
mcpConfig,
|
|
98
|
+
label,
|
|
99
|
+
onSpawn: (ch) => onChild?.(ch),
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
onChild?.(null);
|
|
104
|
+
}
|
|
105
|
+
if (!isAlive()) break;
|
|
106
|
+
if (blockedId(out)) {
|
|
107
|
+
enter('blocked', warn, `${c.yellow('paused')}${c.dim(' — waiting on your review/answer in Flowviant')}`);
|
|
108
|
+
resuming = true;
|
|
109
|
+
await sleep(POLL_SECONDS);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (sawSentinel(out, 'NOTHING')) {
|
|
113
|
+
enter('idle', info, 'idle — no work assigned');
|
|
114
|
+
resuming = false;
|
|
115
|
+
await sleep(IDLE_SECONDS);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (sawSentinel(out, 'DONE')) {
|
|
119
|
+
ok(`${label} ${c.dim('finished a task — PR opened for your review')}`);
|
|
120
|
+
phase = '';
|
|
121
|
+
resuming = false;
|
|
122
|
+
needsReset = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// No sentinel — the turn didn't complete the protocol. Almost always the
|
|
126
|
+
// flowviant MCP failed to surface its tools (usually a stale worker token).
|
|
127
|
+
// Drop the cached token so the next poll re-mints a fresh one, then retry —
|
|
128
|
+
// don't fake a blocker or a completion.
|
|
129
|
+
enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
|
|
130
|
+
onTokenSuspect?.(agentId);
|
|
131
|
+
resuming = false;
|
|
132
|
+
needsReset = true;
|
|
133
|
+
await sleep(IDLE_SECONDS);
|
|
134
|
+
}
|
|
135
|
+
info(`${label} stopped`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runFleetDaemon() {
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`fleet daemon · v${VERSION}`)}`);
|
|
141
|
+
console.log(` ${c.dim('──────────────────────────────────────────────')}`);
|
|
142
|
+
const repoRoot = repoRootOrDie();
|
|
143
|
+
const baseRef = detectBaseRef(repoRoot);
|
|
144
|
+
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
145
|
+
info(`repo · ${repoRoot}`);
|
|
146
|
+
info(`base · ${baseRef}`);
|
|
147
|
+
info(`server · ${FLEET_URL}`);
|
|
148
|
+
console.log('');
|
|
149
|
+
preflight({ needGit: true });
|
|
150
|
+
|
|
151
|
+
const baseDir = mkdtempSync(join(tmpdir(), 'flowviant-fleet-'));
|
|
152
|
+
const tokenByAgent = new Map(); // agentId -> latest worker token
|
|
153
|
+
const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
|
|
154
|
+
const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
|
|
155
|
+
let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
|
|
156
|
+
let mcpUrl = MCP_URL;
|
|
157
|
+
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
158
|
+
|
|
159
|
+
const teardown = () => {
|
|
160
|
+
for (const [, w] of workers) {
|
|
161
|
+
w.state.alive = false;
|
|
162
|
+
try {
|
|
163
|
+
w.state.child?.kill('SIGKILL');
|
|
164
|
+
} catch {
|
|
165
|
+
/* best-effort */
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
169
|
+
} catch {
|
|
170
|
+
/* best-effort */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
175
|
+
} catch {
|
|
176
|
+
/* best-effort */
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
process.on('SIGINT', () => {
|
|
180
|
+
console.log('');
|
|
181
|
+
note('shutting down — stopping workers and freeing worktrees…');
|
|
182
|
+
teardown();
|
|
183
|
+
process.exit(130);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
|
|
187
|
+
// user's own gh. `merging` guards against re-processing a job mid-flight.
|
|
188
|
+
const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
|
|
189
|
+
const merging = new Set();
|
|
190
|
+
const reportMerged = async (intentId) => {
|
|
191
|
+
try {
|
|
192
|
+
await fetch(MERGE_DONE_URL, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
196
|
+
'User-Agent': USER_AGENT,
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify({ intentId }),
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
/* best-effort — the job reappears next poll if this failed */
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const processMergeJobs = (jobs) => {
|
|
206
|
+
for (const job of jobs ?? []) {
|
|
207
|
+
if (merging.has(job.id)) continue;
|
|
208
|
+
merging.add(job.id);
|
|
209
|
+
(async () => {
|
|
210
|
+
try {
|
|
211
|
+
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
212
|
+
let merged = false;
|
|
213
|
+
try {
|
|
214
|
+
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
215
|
+
cwd: repoRoot,
|
|
216
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
merged = true;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
221
|
+
if (/already merged|not open|closed/i.test(err)) merged = true;
|
|
222
|
+
else if (/conflict|not mergeable|CONFLICTING/i.test(err))
|
|
223
|
+
warn(`"${job.title}" has a merge conflict — rebase the branch, then it'll merge.`);
|
|
224
|
+
else warn(`merge failed for "${job.title}": ${err.split('\n')[0]} — will retry`);
|
|
225
|
+
}
|
|
226
|
+
if (merged) {
|
|
227
|
+
await reportMerged(job.id);
|
|
228
|
+
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
merging.delete(job.id);
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let connected = false; // log the first successful poll once
|
|
238
|
+
let rosterSig = null; // last roster membership, to log changes only
|
|
239
|
+
let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
|
|
240
|
+
let joinCount = 0; // for stable per-agent label colours
|
|
241
|
+
|
|
242
|
+
// Which agents to tell the server we already hold a good token for. We keep
|
|
243
|
+
// our token (omit a re-mint) UNLESS it's near expiry AND the worker is idle
|
|
244
|
+
// (no child mid-turn) — then we drop it from `have` to force a fresh token,
|
|
245
|
+
// safely between turns so we never swap a credential out from under a run.
|
|
246
|
+
const buildHave = () =>
|
|
247
|
+
[...tokenByAgent.keys()].filter((id) => {
|
|
248
|
+
const ageS = (Date.now() - (mintedAt.get(id) ?? 0)) / 1000;
|
|
249
|
+
const nearExpiry = ageS > leaseTtlSeconds - REFRESH_BEFORE_SECONDS;
|
|
250
|
+
const midTurn = workers.get(id)?.state.child != null;
|
|
251
|
+
return !nearExpiry || midTurn;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Reconcile loop: poll roster, start new workers, stop removed ones.
|
|
255
|
+
for (;;) {
|
|
256
|
+
let roster;
|
|
257
|
+
try {
|
|
258
|
+
roster = await fetchRoster(buildHave());
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (e.auth) {
|
|
261
|
+
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
262
|
+
teardown();
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
|
|
266
|
+
await sleep(RECONCILE_SECONDS);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!connected) {
|
|
270
|
+
connected = true;
|
|
271
|
+
ok('Connected to Flowviant — watching your roster.');
|
|
272
|
+
}
|
|
273
|
+
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
274
|
+
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
275
|
+
processMergeJobs(roster.mergeJobs);
|
|
276
|
+
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
277
|
+
|
|
278
|
+
// Announce roster size only when it changes (not every poll).
|
|
279
|
+
const sig = [...rosterIds].sort().join(',');
|
|
280
|
+
if (sig !== rosterSig) {
|
|
281
|
+
rosterSig = sig;
|
|
282
|
+
if (rosterIds.size === 0) {
|
|
283
|
+
warn('No agents on your roster yet.');
|
|
284
|
+
info('Add agents in Flowviant → Cockpit → Fleet; they spin up here automatically.');
|
|
285
|
+
} else {
|
|
286
|
+
note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Heartbeat so a quiet/empty daemon visibly stays alive.
|
|
290
|
+
if (rosterIds.size === 0 && Date.now() - idleBeatAt > 60_000) {
|
|
291
|
+
idleBeatAt = Date.now();
|
|
292
|
+
info('idle — waiting for agents…');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const a of roster.agents) {
|
|
296
|
+
if (a.token) {
|
|
297
|
+
tokenByAgent.set(a.agentId, a.token);
|
|
298
|
+
mintedAt.set(a.agentId, Date.now());
|
|
299
|
+
}
|
|
300
|
+
hasWorkByAgent.set(a.agentId, !!a.hasWork);
|
|
301
|
+
if (!workers.has(a.agentId)) {
|
|
302
|
+
const wt = join(baseDir, `agent-${a.agentId}`);
|
|
303
|
+
try {
|
|
304
|
+
if (!existsSync(wt)) git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
|
|
310
|
+
const label = colorFn(`[${a.name}]`);
|
|
311
|
+
const state = { alive: true, child: null };
|
|
312
|
+
ok(`${label} ${c.dim(`online — worktree ready${LIVE ? ' · live session' : ''}`)}`);
|
|
313
|
+
const workerFn = LIVE ? runLiveWorker : runFleetWorker;
|
|
314
|
+
const promise = workerFn({
|
|
315
|
+
agentId: a.agentId,
|
|
316
|
+
label,
|
|
317
|
+
cwd: wt,
|
|
318
|
+
baseRef,
|
|
319
|
+
getToken: (id) => tokenByAgent.get(id),
|
|
320
|
+
getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
|
|
321
|
+
getMcpUrl: () => mcpUrl,
|
|
322
|
+
isAlive: () => state.alive,
|
|
323
|
+
onChild: (ch) => {
|
|
324
|
+
state.child = ch;
|
|
325
|
+
},
|
|
326
|
+
// A turn that couldn't reach the MCP server: forget the cached token so
|
|
327
|
+
// the next reconcile poll re-mints a fresh one (self-heals a token that
|
|
328
|
+
// was rotated/expired out from under a running session).
|
|
329
|
+
onTokenSuspect: (id) => {
|
|
330
|
+
tokenByAgent.delete(id);
|
|
331
|
+
mintedAt.delete(id);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
workers.set(a.agentId, { state, promise, wt, label });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Stop workers whose agent left the roster (removed in the app).
|
|
339
|
+
for (const [id, w] of [...workers]) {
|
|
340
|
+
if (!rosterIds.has(id)) {
|
|
341
|
+
warn(`${w.label} removed — stopping it now, freeing its worktree.`);
|
|
342
|
+
w.state.alive = false;
|
|
343
|
+
// Immediate teardown (Q6=B): kill the in-flight Claude process now; its
|
|
344
|
+
// task was already requeued server-side on removal.
|
|
345
|
+
try {
|
|
346
|
+
w.state.child?.kill('SIGKILL');
|
|
347
|
+
} catch {
|
|
348
|
+
/* best-effort */
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
352
|
+
} catch {
|
|
353
|
+
/* best-effort */
|
|
354
|
+
}
|
|
355
|
+
workers.delete(id);
|
|
356
|
+
tokenByAgent.delete(id);
|
|
357
|
+
hasWorkByAgent.delete(id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await sleep(RECONCILE_SECONDS);
|
|
362
|
+
}
|
|
363
|
+
}
|
package/bin/lib/git.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export function git(args, cwd) {
|
|
6
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function repoRootOrDie() {
|
|
10
|
+
try {
|
|
11
|
+
return git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
12
|
+
} catch {
|
|
13
|
+
console.error('error: fleet mode must run inside a git repo.');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function detectBaseRef(repoRoot) {
|
|
19
|
+
try {
|
|
20
|
+
return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
|
|
21
|
+
} catch {
|
|
22
|
+
/* origin/HEAD not set */
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`;
|
|
26
|
+
} catch {
|
|
27
|
+
return 'HEAD';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resetWorktree(wt, baseRef) {
|
|
32
|
+
try {
|
|
33
|
+
git(['fetch', 'origin', '--quiet'], wt);
|
|
34
|
+
} catch {
|
|
35
|
+
/* offline / no remote — reset to whatever we have */
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
git(['checkout', '--detach', baseRef], wt);
|
|
39
|
+
git(['reset', '--hard', baseRef], wt);
|
|
40
|
+
git(['clean', '-fd'], wt);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error(` (worktree reset to ${baseRef} failed: ${e.message})`);
|
|
43
|
+
}
|
|
44
|
+
}
|