@shardflux/cli 0.15.2 → 0.16.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +82 -12
  2. package/README.md +50 -3
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +8 -2
  5. package/dist/agent-commands.js +23 -11
  6. package/dist/agent-target-commands.d.ts +7 -2
  7. package/dist/agent-target-commands.js +12 -7
  8. package/dist/agent-targets.d.ts +3 -0
  9. package/dist/agent-targets.js +4 -1
  10. package/dist/agent-terminal.d.ts +8 -0
  11. package/dist/agent-terminal.js +35 -2
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +26 -3
  14. package/dist/codex-cloud.d.ts +2 -0
  15. package/dist/codex-cloud.js +2 -0
  16. package/dist/command-center-actions.js +9 -0
  17. package/dist/commands.d.ts +22 -0
  18. package/dist/commands.js +54 -14
  19. package/dist/conversation-commands.js +6 -0
  20. package/dist/conversation-terminal.js +10 -1
  21. package/dist/conversations.d.ts +13 -1
  22. package/dist/conversations.js +41 -5
  23. package/dist/device-helper.js +9 -2
  24. package/dist/errors.d.ts +5 -0
  25. package/dist/errors.js +11 -1
  26. package/dist/files-mirror.d.ts +164 -0
  27. package/dist/files-mirror.js +761 -0
  28. package/dist/handlers.js +3 -1
  29. package/dist/main.js +9 -2
  30. package/dist/registry.d.ts +2 -42
  31. package/dist/registry.js +6 -90
  32. package/dist/thread-on-computer.d.ts +35 -0
  33. package/dist/thread-on-computer.js +200 -0
  34. package/dist/wait-status.d.ts +22 -0
  35. package/dist/wait-status.js +59 -0
  36. package/dist/worktree-twin-engine.d.ts +95 -0
  37. package/dist/worktree-twin-engine.js +402 -0
  38. package/dist/worktree-twin-land.py +331 -0
  39. package/dist/worktree-twin.d.ts +296 -0
  40. package/dist/worktree-twin.js +718 -0
  41. package/package.json +3 -3
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The device helper's live twin loop (0.16.0+): watches the API (conversations and agent sessions, never a VM) for
3
+ * conversations whose turn ended with committed work and lands each into the local branch it started from, with no
4
+ * input. One landing at a time, in the order the turns finished; anything unsafe pauses and retries. A conversation
5
+ * that ended while this computer was away (its VM reclaimed) lands from the project hub. After being offline or asleep
6
+ * the loop retries at once (runTwinLoop). See worktree-twin.ts.
7
+ */
8
+ import { connect } from 'node:net';
9
+ import { networkInterfaces } from 'node:os';
10
+ import { existsSync } from 'node:fs';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+ import { out } from "./commands.js";
13
+ import { conversationClient } from "./conversation-commands.js";
14
+ import { conversationName, conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
15
+ import { UsageError } from "./errors.js";
16
+ import { projectIdentity } from "./project-identity.js";
17
+ import { TwinLedger, backupRoot, inspectWorktree, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ /** Its VM is gone: the conversation ended (archived after the retention window, or done). */
21
+ export const ended = (c) => !!c.ended_at || c.state === 'archived';
22
+ const revision = (c) => JSON.stringify([c.changes, c.summary]);
23
+ /** A turn ended with committed work only; the agent is not asking anything. */
24
+ export function finished(c, agents) {
25
+ if (ended(c))
26
+ return 'state';
27
+ const agent = agents.find(a => a.workspace_id === c.workspace_id && c.harness_session_id && a.id === `${c.workspace_id}:${c.harness_session_id}`);
28
+ if (agent)
29
+ return agent.state === 'working' ? null : agent;
30
+ return c.state === 'unresolved' || c.state === 'done' ? 'state' : null;
31
+ }
32
+ export function eligible(c, ledger, now) {
33
+ if (c.route === 'codex-cloud' || c.join_mode === 'reviewer' || c.state === 'needs_you')
34
+ return false;
35
+ if (!c.changes || c.changes.commits <= 0 || c.changes.uncommitted || c.landed?.how === 'discarded')
36
+ return false;
37
+ const st = ledger.conversation(c.id);
38
+ // An ended conversation is landed here unless someone landed it some other way before it ended.
39
+ if (ended(c) && c.landed && !st.landed_revision)
40
+ return false;
41
+ if ((st.retry_at ?? 0) > now)
42
+ return false;
43
+ if (st.landed_revision === revision(c) && (ended(c) || c.landed?.how === 'merged' || c.landed?.how === 'pr'))
44
+ return false;
45
+ return st.conflict_revision !== revision(c);
46
+ }
47
+ export function conflictMessage(into, files) {
48
+ const list = files.length > 8 ? `${files.slice(0, 8).join(', ')} and ${files.length - 8} more` : files.join(', ');
49
+ return `Shardflux could not land your commits into ${into} on the user's computer: ${list} also changed in the user's newer commits on ${into}. `
50
+ + `Merge their branch into yours and commit: git merge refs/shard/twin-in^1 (refs/shard/twin-in is the user's working state as of now; its first parent is the tip of their ${into}). `
51
+ + `Resolve each conflict without asking: where you and the user changed the same lines, keep the user's lines and keep your change only where it fits around them; then run the checks and commit. `
52
+ + `Your work lands on the user's computer automatically when you finish. Ask the user only if the result cannot work either way.`;
53
+ }
54
+ export class TwinEngine {
55
+ #deps;
56
+ #reported = new Map();
57
+ #posted = new Map();
58
+ constructor(deps) { this.#deps = deps; }
59
+ #report(key, line) { if (this.#reported.get(key) !== line) {
60
+ this.#reported.set(key, line);
61
+ this.#deps.log(line);
62
+ } }
63
+ /** One pass over every project with a local conversation; returns the suggested wait before the next. */
64
+ async tick(signal) {
65
+ const d = this.#deps, records = d.records().filter(r => !r.handed_off);
66
+ let wait = 1000;
67
+ for (const project of new Set(records.map(r => r.project_id))) {
68
+ signal?.throwIfAborted();
69
+ const mode = d.ledger.mode(project);
70
+ if (mode === 'off')
71
+ continue;
72
+ const mine = new Map(records.filter(r => r.project_id === project).map(r => [r.conversation.id, r]));
73
+ const candidates = (await d.conversations(project)).filter(c => mine.has(c.id) && eligible(c, d.ledger, d.now()));
74
+ if (!candidates.length)
75
+ continue;
76
+ const agents = candidates.some(c => !ended(c)) ? await d.agents(project) : [];
77
+ // In the order the turns finished (several may have finished while this computer was away); each lands against
78
+ // the checkout as it is when its turn comes.
79
+ const ready = candidates.map(c => ({ c, turn: finished(c, agents) })).filter((x) => x.turn !== null)
80
+ .map(x => ({ ...x, at: x.turn === 'state' ? Date.parse(x.c.last_activity_at ?? x.c.created_at) : Date.parse(x.turn.since) }))
81
+ .sort((a, b) => (a.at || 0) - (b.at || 0));
82
+ for (const { c, turn } of ready) {
83
+ const next = await this.landOne(c, mine.get(c.id), mode, turn === 'state' ? null : turn, signal);
84
+ if (next !== undefined)
85
+ wait = Math.min(wait, next);
86
+ }
87
+ }
88
+ return wait;
89
+ }
90
+ /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
91
+ resetBackoff() {
92
+ this.#deps.ledger.update(doc => { for (const st of Object.values(doc.conversations ?? {})) {
93
+ st.failures = 0;
94
+ st.retry_at = 0;
95
+ } });
96
+ this.#reported.clear();
97
+ }
98
+ async landOne(c, record, mode, agent, signal) {
99
+ const d = this.#deps, st = d.ledger.conversation(c.id), rev = revision(c);
100
+ const into = c.base_branch ?? record.base_branch ?? st.base_branch;
101
+ const name = c.name ?? conversationName(c);
102
+ const label = `${c.title || name} (${c.id})`;
103
+ if (!into || into.startsWith('shard/')) {
104
+ this.#report(c.id, `Twin: ${label} has no local base branch to land into.`);
105
+ return;
106
+ }
107
+ if (!existsSync(record.folder)) {
108
+ this.#report(c.id, `Twin: ${record.folder} is gone; ${label} stays in the cloud.`);
109
+ return;
110
+ }
111
+ // Landed at this revision already; only the API record is missing (a later report reset it on an older guest).
112
+ if (st.landed_revision === rev) {
113
+ if (ended(c))
114
+ return;
115
+ if ((this.#posted.get(c.id) ?? 0) + 30_000 < d.now()) {
116
+ this.#posted.set(c.id, d.now());
117
+ await d.land(c, st.pr_url ? 'pr' : 'merge', st.pr_url ?? into);
118
+ }
119
+ return;
120
+ }
121
+ const branch = `refs/heads/${into}`;
122
+ let worktree = null;
123
+ const target = (await listWorktrees(record.folder)).find(w => w.branch === branch);
124
+ if (target) {
125
+ worktree = await inspectWorktree(target.root);
126
+ if (pauseReason(worktree))
127
+ return 1000;
128
+ }
129
+ const started = d.now();
130
+ let vm, fromHub = false;
131
+ try {
132
+ vm = ended(c) ? null : await d.cell(c);
133
+ // The VM is gone (reclaimed while this computer was away): its branch is in the project hub.
134
+ if (!vm) {
135
+ vm = await d.hubCell(c.project_id);
136
+ fromHub = true;
137
+ }
138
+ }
139
+ catch (error) {
140
+ if (signal?.aborted)
141
+ throw error;
142
+ return this.#fail(c, label, error instanceof Error ? error.message : String(error));
143
+ }
144
+ let outcome, keep = false;
145
+ try {
146
+ for (let attempt = 0; attempt < 3; attempt++) {
147
+ if (target && attempt)
148
+ worktree = await inspectWorktree(target.root);
149
+ outcome = await landingRound(vm.cell, { conversation: { id: c.id, name, title: c.title, summary: c.summary, branch: c.branch, base_commit: c.base_commit, worktree_path: c.worktree_path },
150
+ into, mode: mode === 'pr' ? 'pr' : 'auto', device: d.device, repo: target?.root ?? record.folder, worktree, stateDir: d.stateDir, backupRoot: backupRoot(d.stateDir),
151
+ upto: st.upto, known: [st.tip, st.upto].filter((x) => !!x), hub: d.hub, vmTmp: d.vmTmp, quietMs: d.quietMs, signal, now: () => d.now(),
152
+ ...(fromHub ? { fromHub: { ...(c.repo_remote ? { repoRemote: c.repo_remote } : {}) } } : {}) });
153
+ if (outcome.state !== 'moved')
154
+ break;
155
+ }
156
+ keep = outcome?.state === 'landed';
157
+ }
158
+ catch (error) {
159
+ if (signal?.aborted)
160
+ throw error;
161
+ return this.#fail(c, label, error instanceof Error ? error.message : String(error));
162
+ }
163
+ finally {
164
+ if (!keep)
165
+ vm.close();
166
+ }
167
+ const post = async (action, ref) => {
168
+ // An ended conversation cannot record a landing any more (the API refuses); the ledger keeps it.
169
+ if (ended(c))
170
+ return;
171
+ this.#posted.set(c.id, d.now());
172
+ await d.land(c, action, ref);
173
+ };
174
+ const o = outcome;
175
+ switch (o.state) {
176
+ case 'landed': {
177
+ // The VM records the landing and copies it to the project hub on its own time; the connection closes after.
178
+ void o.confirmed.catch((error) => { this.#report(c.id, `Twin: ${label} landed; recording it in its VM failed: ${error instanceof Error ? error.message : String(error)}.`); })
179
+ .finally(() => vm.close());
180
+ const latency = agent ? o.applied.at - Date.parse(agent.since) : undefined;
181
+ d.ledger.patch(c.id, { upto: o.land.upto, tip: o.land.tip, landed_revision: rev, landed_at: new Date(o.applied.at).toISOString(), method: o.land.method,
182
+ failures: 0, retry_at: 0, last_error: undefined, ...(latency !== undefined ? { latency_ms: latency } : {}) });
183
+ await post('merge', into);
184
+ d.log(`Twin: landed ${label} into ${into} in ${target?.root ?? record.folder}${fromHub ? ' from the project hub' : ''} (${o.land.method}; ${o.applied.files.length} file${o.applied.files.length === 1 ? '' : 's'}`
185
+ + `${o.applied.raced.length ? `, kept your version of ${o.applied.raced.join(', ')}` : ''}${o.land.shadowed?.length ? `, your edits win in ${o.land.shadowed.join(', ')}` : ''}`
186
+ + `${latency !== undefined ? `; ${(latency / 1000).toFixed(1)} s after the turn ended, round started at ${((started - Date.parse(agent.since)) / 1000).toFixed(1)} s` : ''}`
187
+ + `; round ${Object.entries(o.timings).map(([k, v]) => `${k} ${v} ms`).join(', ')}; in the VM ${Object.entries(o.land.phases ?? {}).map(([k, v]) => `${k} ${v}`).join(', ')}).`);
188
+ return;
189
+ }
190
+ case 'noop':
191
+ d.ledger.patch(c.id, { upto: o.land.upto, landed_revision: rev, failures: 0, retry_at: 0 });
192
+ if (c.landed?.how !== 'merged')
193
+ await post('merge', into);
194
+ return;
195
+ case 'pr':
196
+ d.ledger.patch(c.id, { tip: o.land.tip, landed_revision: rev, pr_url: o.land.url, failures: 0, retry_at: 0 });
197
+ await post('pr', o.land.url);
198
+ d.log(`Twin: opened ${o.land.url} for ${label}.`);
199
+ return;
200
+ case 'conflict':
201
+ if (st.conflict_tip !== o.land.conversation_tip) {
202
+ // Its agent resolves it; an ended conversation is restored into a fresh VM first.
203
+ if (ended(c))
204
+ await d.resume(c);
205
+ await d.message(c, conflictMessage(into, o.land.conflicts ?? []));
206
+ }
207
+ d.ledger.patch(c.id, { conflict_tip: o.land.conversation_tip, conflict_revision: rev });
208
+ d.log(`Twin: ${label} conflicts with your commits on ${into} (${(o.land.conflicts ?? []).join(', ')}); its agent is resolving it.`);
209
+ return;
210
+ case 'busy':
211
+ d.ledger.patch(c.id, { retry_at: d.now() + (o.waitMs ?? 1000) });
212
+ return o.waitMs ?? 1000;
213
+ case 'paused': return 1000;
214
+ case 'moved':
215
+ d.ledger.patch(c.id, { retry_at: d.now() + 2000 });
216
+ return 2000;
217
+ case 'error': return this.#fail(c, label, `${o.land.reason}: ${o.land.message ?? ''}`);
218
+ }
219
+ }
220
+ #fail(c, label, message) {
221
+ const d = this.#deps, failures = (d.ledger.conversation(c.id).failures ?? 0) + 1, wait = Math.min(300_000, 5000 * 2 ** Math.min(failures - 1, 6));
222
+ d.ledger.patch(c.id, { failures, retry_at: d.now() + wait, last_error: message });
223
+ this.#report(c.id, `Twin: ${label} could not land yet: ${message} (retrying in ${Math.round(wait / 1000)} s).`);
224
+ return wait;
225
+ }
226
+ }
227
+ const PROBE_MS = 5000, JUMP_MS = 5000;
228
+ /**
229
+ * Runs the twin's passes. A failing pass (offline) backs off up to 5 minutes, but the wait ends at once when the
230
+ * computer wakes from sleep (the wall clock jumps past the monotonic one), when its network changes, or when the API
231
+ * host answers again (probed every 5 s while failing); every conversation's retry backoff is then cleared and a pass
232
+ * runs immediately. The first pass that succeeds after failures clears them too and runs again at once.
233
+ */
234
+ export async function runTwinLoop(deps, signal) {
235
+ let failures = 0, net = deps.network();
236
+ const reported = new Set();
237
+ while (!signal.aborted) {
238
+ let wait;
239
+ try {
240
+ wait = await deps.tick(signal);
241
+ if (failures) {
242
+ failures = 0;
243
+ deps.resetBackoff();
244
+ wait = 0;
245
+ }
246
+ }
247
+ catch (error) {
248
+ if (signal.aborted)
249
+ break;
250
+ wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ if (!conversationsUnavailable(error) && !reported.has(message)) {
253
+ reported.add(message);
254
+ deps.log(`Twin: ${message} (retrying in ${wait / 1000} s).`);
255
+ }
256
+ }
257
+ let left = wait, probed = deps.mono();
258
+ while (left > 0 && !signal.aborted) {
259
+ const slice = Math.min(1000, left), wall = deps.wall(), mono = deps.mono();
260
+ await deps.sleep(slice, signal);
261
+ left -= slice;
262
+ const woke = deps.wall() - wall - (deps.mono() - mono) > JUMP_MS;
263
+ const now = deps.network(), changed = now !== net;
264
+ net = now;
265
+ let back = false;
266
+ if (failures && deps.mono() - probed >= PROBE_MS) {
267
+ probed = deps.mono();
268
+ back = await deps.reachable().catch(() => false);
269
+ }
270
+ if (woke || changed || back) {
271
+ deps.resetBackoff();
272
+ left = 0;
273
+ }
274
+ }
275
+ }
276
+ }
277
+ function networkSignature() {
278
+ const all = [];
279
+ for (const [name, list] of Object.entries(networkInterfaces()))
280
+ for (const a of list ?? [])
281
+ if (!a.internal)
282
+ all.push(`${name} ${a.address}`);
283
+ return all.sort().join(',');
284
+ }
285
+ function reachable(url) {
286
+ const u = new URL(url), port = Number(u.port || (u.protocol === 'http:' ? 80 : 443));
287
+ return new Promise(resolve => {
288
+ const socket = connect({ host: u.hostname, port, timeout: 3000 });
289
+ const done = (ok) => { socket.destroy(); resolve(ok); };
290
+ socket.once('connect', () => done(true));
291
+ socket.once('timeout', () => done(false));
292
+ socket.once('error', () => done(false));
293
+ });
294
+ }
295
+ const gone = (error) => error.status === 404 || error.details?.reason === 'workspace_deleted';
296
+ /** The device helper's twin. Nothing here asks the user anything. */
297
+ export async function runWorktreeTwin(ctx, device, signal) {
298
+ const client = () => conversationClient(ctx);
299
+ const open = (ws) => {
300
+ const cell = ws.cell({ agentLabel: 'shard-twin' });
301
+ return { cell, close: () => cell.close() };
302
+ };
303
+ const engine = new TwinEngine({
304
+ records: () => Object.values(localConversations(ctx)),
305
+ conversations: async (projectId) => await listConversations(ctx, projectId),
306
+ agents: async (projectId) => {
307
+ // A signed-in session without a project key has no agent list here; the conversation state then decides.
308
+ const api = client(), all = [];
309
+ if (!('agents' in api))
310
+ return all;
311
+ let cursor;
312
+ do {
313
+ const page = await api.agents.list({ projectId, limit: 200, ...(cursor ? { cursor } : {}) });
314
+ all.push(...page.data);
315
+ cursor = page.nextCursor ?? undefined;
316
+ } while (cursor);
317
+ return all;
318
+ },
319
+ cell: async (c) => {
320
+ let ws;
321
+ try {
322
+ ws = await client().workspaces.get(c.workspace_id);
323
+ }
324
+ catch (error) {
325
+ if (gone(error))
326
+ return null;
327
+ throw error;
328
+ }
329
+ if (ws.data.deleted_at)
330
+ return null;
331
+ if (!ws.ready)
332
+ await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal });
333
+ return open(ws);
334
+ },
335
+ hubCell: async (projectId) => {
336
+ // An awake VM of the project reads the hub without waking anything; else the project's small space reader.
337
+ const all = (await listConversations(ctx, projectId)).filter(c => !ended(c))
338
+ .sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
339
+ for (const c of all) {
340
+ try {
341
+ const ws = await client().workspaces.get(c.workspace_id);
342
+ if (!ws.data.deleted_at && ws.ready)
343
+ return open(ws);
344
+ }
345
+ catch { /* another may be awake */ }
346
+ }
347
+ const reader = await client().conversations.spaceReader(projectId, { signal });
348
+ const ws = await client().workspaces.get(reader.workspace.id);
349
+ if (!ws.ready)
350
+ await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal });
351
+ return open(ws);
352
+ },
353
+ resume: async (c) => { await client().conversations.resume(c.id, { signal }); },
354
+ land: async (c, action, into) => { await client().conversations.land(c.id, action, { into, signal }); },
355
+ message: async (c, text) => { await client().conversations.send(c.id, text, { signal }); },
356
+ ledger: new TwinLedger(ctx.store.dir), stateDir: ctx.store.dir, device,
357
+ log: line => { ctx.io.stderr.write(line + '\n'); }, now: Date.now,
358
+ });
359
+ await runTwinLoop({
360
+ tick: s => engine.tick(s), resetBackoff: () => engine.resetBackoff(),
361
+ sleep: (ms, s) => delay(ms, undefined, { signal: s }).catch(() => undefined),
362
+ wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl),
363
+ log: line => { ctx.io.stderr.write(line + '\n'); },
364
+ }, signal);
365
+ }
366
+ const MODES = ['auto', 'pr', 'off'];
367
+ function projectOf(ctx, path) {
368
+ const folder = projectIdentity(path ?? process.cwd()).folder;
369
+ const local = Object.values(localConversations(ctx)).find(r => r.folder === folder);
370
+ const project = local?.project_id ?? ctx.store.profile(ctx.apiUrl).project_id;
371
+ if (!project)
372
+ throw new UsageError('No Shardflux project for this checkout: start a conversation here first, or run shard setup.');
373
+ return project;
374
+ }
375
+ export const TWIN_HANDLERS = {
376
+ /** shard conversations landing [auto|pr|off|restore <backup>|backups] */
377
+ async 'conversations landing'(ctx, values, [action, arg]) {
378
+ const ledger = new TwinLedger(ctx.store.dir), project = projectOf(ctx, typeof values.path === 'string' ? values.path : undefined), root = backupRoot(ctx.store.dir);
379
+ if (action === 'restore') {
380
+ if (!arg || !/^[\w.:-]+$/.test(arg))
381
+ throw new UsageError('Name a backup: shard conversations landing backups');
382
+ const r = await restoreBackup(join(root, arg), { backupRoot: root });
383
+ out(ctx, `Restored ${r.restored.length} file${r.restored.length === 1 ? '' : 's'}. The replaced versions are in backup ${r.backup.split('/').at(-1)}.\n`, r);
384
+ return 0;
385
+ }
386
+ if (action === 'backups') {
387
+ const list = (existsSync(root) ? readdirSync(root) : []).filter(n => existsSync(join(root, n, 'manifest.json'))).sort().reverse()
388
+ .map(id => { const m = JSON.parse(readFileSync(join(root, id, 'manifest.json'), 'utf8')); return { id, created_at: m.created_at, worktree: m.worktree, title: m.title, files: m.files.map(f => f.path) }; });
389
+ out(ctx, list.map(b => `${b.id}\t${b.title}\t${b.files.length} files\t${b.worktree}`).join('\n') + (list.length ? '\n' : 'No landing backups.\n'), { backups: list });
390
+ return 0;
391
+ }
392
+ if (action !== undefined) {
393
+ if (!MODES.includes(action))
394
+ throw new UsageError('Choose auto, pr or off (or restore <backup>, backups).');
395
+ ledger.setMode(project, action);
396
+ }
397
+ const mode = ledger.mode(project);
398
+ const text = mode === 'auto' ? 'Finished conversations land in your local branch automatically.' : mode === 'pr' ? 'Finished conversations open a pull request instead of landing locally.' : 'Automatic landing is off for this project.';
399
+ out(ctx, `${text}\n`, { project_id: project, landing: mode });
400
+ return 0;
401
+ },
402
+ };