codeply-cli 0.4.1 → 0.5.1

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/lib/cloud.mjs CHANGED
@@ -36,13 +36,14 @@ export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
36
36
  export const MIRROR_PREFIX = 'craft-workspace-';
37
37
  export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
38
38
  export const SESSIONS_BRANCH = 'craft-sessions';
39
- export const ENGINE = 'codeply-cli@0.4';
39
+ export const ENGINE = 'codeply-cli@0.5';
40
40
  export const taskBranch = (id) => `craft/task-${id}`;
41
41
  export const checkName = (id) => `craft ${id}`;
42
42
 
43
43
  const MAX_FILE_BYTES = 50 * 1024 * 1024;
44
44
  const MAX_SCAN_BYTES = 2 * 1024 * 1024;
45
45
  const MAX_PROMPT = 20000;
46
+ const MAX_BOT_PROMPT = 30000;
46
47
  const MAX_OUTPUT = 60000;
47
48
  const HISTORY_TURNS = 40;
48
49
 
@@ -100,6 +101,9 @@ jobs:
100
101
  CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
101
102
  CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
102
103
  CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
104
+ CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
105
+ CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
106
+ CRAFT_BASE: \${{ github.ref_name }}
103
107
  CRAFT_PROMPT: \${{ inputs.prompt }}
104
108
  CRAFT_MODE: \${{ inputs.mode }}
105
109
  CRAFT_TASK_ID: \${{ inputs.task_id }}
@@ -333,6 +337,8 @@ const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: s
333
337
  export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
334
338
  const p = getProject(cwd, home);
335
339
  if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
340
+ // A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
341
+ if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
336
342
  const snap = await snapshot({ cwd, home });
337
343
  if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
338
344
  const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
@@ -411,30 +417,113 @@ export async function checkToken(api) {
411
417
  return { ok: true, login: r.json.login };
412
418
  }
413
419
 
414
- /** One-click setup: mirror repo, first snapshot with the workflow, model key. */
415
- export async function setupCloud({ cwd, token, model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
420
+ // ─── Targets: the project's own GitHub repo, or a private mirror ─────────
421
+
422
+ /** owner/name of the project's GitHub `origin`, or null. */
423
+ export async function githubOrigin(cwd) {
424
+ try {
425
+ const url = await git(cwd, ['remote', 'get-url', 'origin']);
426
+ const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
427
+ return m ? `${m[1]}/${m[2]}` : null;
428
+ } catch { return null; }
429
+ }
430
+
431
+ /** The repos this login can push to (for the phone's and the app's repo picker). */
432
+ export async function listMyRepos(api) {
433
+ const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
434
+ return (r.json || []).filter((x) => x.permissions && x.permissions.push)
435
+ .map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
436
+ }
437
+
438
+ /** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
439
+ export async function installWorkflowInRepo({ api, repo, base }) {
440
+ const files = branchFiles(api, repo, base);
441
+ const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
442
+ const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
443
+ if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
444
+ const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
445
+ if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
446
+ return true;
447
+ }
448
+
449
+ /** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
450
+ export async function setProjectEnv({ api, repo, envText }) {
451
+ const text = String(envText || '').trim();
452
+ if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
453
+ const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
454
+ await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
455
+ return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
456
+ }
457
+
458
+ /**
459
+ * One-click setup. With `target: 'repo'` (default when the project has a GitHub
460
+ * origin) the cloud works in that repo: new branch per task, merged into the
461
+ * default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
462
+ */
463
+ export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
416
464
  const api = githubApi({ token, apiUrl, fetchImpl });
417
465
  const t = await checkToken(api);
418
466
  if (!t.ok) throw new Error(t.error);
419
467
  const cfg = cloudModelConfig(model);
420
468
  if (cfg.error) throw new Error(cfg.error);
421
- const { repo, created } = await ensureMirror({ api, cwd, home });
422
- const pushed = await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl });
469
+ const origin = target === 'mirror' ? null : await githubOrigin(cwd);
470
+ let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
471
+ if (origin) {
472
+ const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
473
+ if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
474
+ if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
475
+ repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
476
+ updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
477
+ await installWorkflowInRepo({ api, repo, base });
478
+ } else {
479
+ ({ repo, created } = await ensureMirror({ api, cwd, home }));
480
+ updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
481
+ skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
482
+ }
423
483
  await configureModel({ api, repo, model });
484
+ if (envText != null) await setProjectEnv({ api, repo, envText });
424
485
  updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: model.name || model.model, enabled: true }));
425
- return { repo, created, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped: pushed.skipped };
486
+ return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
426
487
  }
427
488
 
428
489
  // ─── Runs ────────────────────────────────────────────────────────────────
429
490
 
430
491
  const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
431
492
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
493
+ const LIVE_FRESH_MS = 60000;
494
+
495
+ /** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
496
+ export async function liveRunner(api, repo, sessionId) {
497
+ if (!sessionId) return null;
498
+ const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
499
+ const live = f && f.json;
500
+ if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
501
+ if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
502
+ return live;
503
+ }
504
+
505
+ async function dispatch(api, repo, base, task, retryMs) {
506
+ let lastErr = null;
507
+ // Right after the workflow file first lands, GitHub can take a few seconds to know it.
508
+ for (let i = 0; i < 10; i++) {
509
+ try {
510
+ await api('POST', `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: base || 'main', inputs: { prompt: task.prompt, mode: task.mode, task_id: task.id, session_id: task.sessionId } });
511
+ return;
512
+ } catch (e) {
513
+ lastErr = e;
514
+ if (e.status !== 404 && e.status !== 422) break;
515
+ await sleep(retryMs);
516
+ }
517
+ }
518
+ throw lastErr;
519
+ }
432
520
 
433
521
  /**
434
- * Push the latest snapshot and start a cloud run.
435
- * @returns {Promise<object>} the task record (also saved in ~/.codeply/cloud.json)
522
+ * Start a cloud task. A runner already up for this chat gets it through the
523
+ * queue at once; otherwise a new runner is dispatched. Mirror projects push
524
+ * the latest snapshot first.
436
525
  */
437
- export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
526
+ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
438
527
  const p = getProject(cwd, home);
439
528
  if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
440
529
  prompt = String(prompt || '').trim();
@@ -442,7 +531,6 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
442
531
  if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
443
532
  mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
444
533
  const api = githubApi({ token, apiUrl, fetchImpl });
445
- // A different model (or key) than last time goes to the mirror first.
446
534
  if (model) {
447
535
  const cfg = cloudModelConfig(model);
448
536
  if (cfg.error) throw new Error(cfg.error);
@@ -451,68 +539,155 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
451
539
  updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: model.name || model.model }));
452
540
  }
453
541
  }
454
- const snap = await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl });
455
- const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha: snap.sha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
456
- // Right after the workflow file first lands, GitHub can take a few seconds to know it.
457
- let lastErr = null;
458
- for (let i = 0; i < 10; i++) {
459
- try {
460
- await api('POST', `/repos/${p.repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: 'main', inputs: { prompt, mode, task_id: task.id, session_id: task.sessionId } });
461
- lastErr = null;
462
- break;
463
- } catch (e) {
464
- lastErr = e;
465
- if (e.status !== 404 && e.status !== 422) break;
466
- await sleep(retryMs);
542
+ const base = p.base || 'main';
543
+ let baseSha;
544
+ if (p.kind === 'repo') {
545
+ const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
546
+ baseSha = ref.status === 200 ? ref.json.object.sha : null;
547
+ } else {
548
+ baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
549
+ }
550
+ const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
551
+ const b = cleanBot(bot);
552
+ const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
553
+ if (b) task.bot = { name: b.name };
554
+ if (extra.kind) task.kind = 'task';
555
+ const files = branchFiles(api, p.repo);
556
+ const live = await liveRunner(api, p.repo, task.sessionId);
557
+ if (live) {
558
+ await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
559
+ Object.assign(task, { queued: true, enqueuedAt: Date.now() });
560
+ } else {
561
+ // A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
562
+ if (Object.keys(extra).length) {
563
+ if (baseSha) await files.ensure(baseSha).catch(() => {});
564
+ if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
467
565
  }
566
+ await dispatch(api, p.repo, base, task, retryMs);
468
567
  }
469
- if (lastErr) throw lastErr;
470
568
  saveTask(cwd, home, task);
471
569
  return task;
472
570
  }
473
571
 
474
- /** Where a cloud run is, from the workflow run and the runner's check run. */
475
- export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl }) {
572
+ /** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
573
+ export async function readTaskDoc(api, repo, taskId) {
574
+ const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
575
+ return f && f.json ? f.json : null;
576
+ }
577
+
578
+ /** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
579
+ export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
476
580
  const task = getTask(cwd, taskId, home);
477
581
  if (!task) throw new Error('Unknown cloud task.');
478
582
  if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
479
583
  const api = githubApi({ token, apiUrl, fetchImpl });
584
+ const next = { ...task };
585
+ const doc = await readTaskDoc(api, task.repo, task.id);
586
+ if (doc) {
587
+ const { events = [], status, ...rest } = doc;
588
+ if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
589
+ if (status === 'done' || status === 'failed') {
590
+ const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
591
+ Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
592
+ } else next.status = 'running';
593
+ saveTask(cwd, home, next);
594
+ return { ...next, events };
595
+ }
596
+ if (task.queued) {
597
+ // Handed to a runner that went away before taking it: start a new one.
598
+ if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
599
+ const p = getProject(cwd, home) || {};
600
+ await dispatch(api, task.repo, p.base || 'main', task, retryMs);
601
+ Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
602
+ } else next.status = 'queued';
603
+ saveTask(cwd, home, next);
604
+ return { ...next, events: [] };
605
+ }
480
606
  let run = null;
481
607
  if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
482
608
  else {
483
609
  const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
484
610
  run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
485
611
  }
486
- const next = { ...task };
487
612
  if (!run) {
488
613
  // GitHub drops a dispatch it can't start; don't wait forever.
489
- if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the mirror repo.' });
614
+ if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the repo.' });
490
615
  saveTask(cwd, home, next);
491
- return next;
616
+ return { ...next, events: [] };
492
617
  }
493
618
  next.runId = run.id;
494
619
  next.runUrl = run.html_url;
495
- const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
496
- const check = (checks.check_runs || [])[0];
497
- if (check && check.output) next.progress = check.output.summary || '';
498
- // The step-by-step record rides along to the caller but isn't kept in cloud.json.
499
- let events = [];
500
- let data = {};
501
- try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
502
- if (Array.isArray(data.events)) events = data.events;
503
- if (check && check.status === 'completed') {
504
- const { events: _drop, ...result } = data;
505
- Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
506
- } else if (run.status === 'completed') {
620
+ if (run.status === 'completed') {
621
+ // Finished without a status file: it died before Craft could report (or an older engine ran).
622
+ const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
623
+ const check = (checks.check_runs || [])[0];
624
+ let data = {};
625
+ try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
626
+ if (check && check.status === 'completed' && data.mode) {
627
+ const { events = [], ...result } = data;
628
+ Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
629
+ saveTask(cwd, home, next);
630
+ return { ...next, events };
631
+ }
507
632
  const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
508
633
  Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
509
634
  } else {
510
635
  next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
511
636
  }
512
637
  saveTask(cwd, home, next);
513
- return { ...next, events };
638
+ return { ...next, events: [] };
639
+ }
640
+
641
+ /** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
642
+ export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
643
+ const api = githubApi({ token, apiUrl, fetchImpl });
644
+ const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
645
+ if (r.status !== 200 || !r.json || !r.json.content) return null;
646
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
647
+ fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
648
+ return dest;
514
649
  }
515
650
 
651
+ /**
652
+ * Before coding: is the local branch behind GitHub (a cloud run merged work
653
+ * into it)? Only for projects whose cloud target is their own repo.
654
+ */
655
+ export async function checkBehind(cwd) {
656
+ try {
657
+ const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
658
+ const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
659
+ if (!upstream) return { ok: false, reason: 'no upstream' };
660
+ await git(cwd, ['fetch', '-q', '--no-tags', upstream.split('/')[0]]);
661
+ const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
662
+ const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
663
+ const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
664
+ return { ok: true, branch, upstream, behind, ahead, dirty, latest };
665
+ } catch (e) { return { ok: false, reason: e.message }; }
666
+ }
667
+
668
+ /** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
669
+ export async function pullLatest(cwd) {
670
+ const st = await checkBehind(cwd);
671
+ if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
672
+ if (!st.behind) return { pulled: 0 };
673
+ let stashed = false;
674
+ if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
675
+ try {
676
+ await git(cwd, ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit']);
677
+ } catch (e) {
678
+ // A conflicting pull is undone, so the project is never left half-merged.
679
+ const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
680
+ await git(cwd, ['merge', '--abort']).catch(() => {});
681
+ throw new Error(conflicted.length
682
+ ? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
683
+ : `Could not pull: ${e.message}`);
684
+ } finally {
685
+ if (stashed) await git(cwd, ['stash', 'pop', '-q']).catch(() => { throw new Error('Pulled, but your own edits clashed with them. They are saved in git stash.'); });
686
+ }
687
+ return { pulled: st.behind };
688
+ }
689
+
690
+
516
691
  /**
517
692
  * Runs started somewhere else (the phone, while this PC was off) become local
518
693
  * tasks, so their changes can be pulled here. Only finished runs are imported.
@@ -662,83 +837,240 @@ export function packEvents(events, budget = EVENT_BUDGET) {
662
837
  return list;
663
838
  }
664
839
 
665
- async function loadHistory(api, repo, sessionId) {
666
- if (!sessionId) return { messages: [], sha: null };
667
- const r = await api('GET', `/repos/${repo}/contents/sessions/${encodeURIComponent(sessionId)}.json?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
668
- if (r.status !== 200) return { messages: [], sha: null };
669
- try {
670
- const data = JSON.parse(Buffer.from(r.json.content, 'base64').toString('utf8'));
671
- return { messages: Array.isArray(data.messages) ? data.messages : [], sha: r.json.sha };
672
- } catch { return { messages: [], sha: r.json.sha }; }
840
+ // ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
841
+
842
+ export const SESSION_PATHS = {
843
+ history: (sid) => `sessions/${sid}.json`,
844
+ task: (id) => `tasks/${id}.json`,
845
+ live: (sid) => `live/${sid}.json`,
846
+ queue: (sid) => `queue/${sid}`,
847
+ shot: (id, n) => `shots/${id}/${n}.png`,
848
+ // What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
849
+ request: (id) => `requests/${id}.json`,
850
+ };
851
+
852
+ /**
853
+ * A task's optional bot: { name, prompt }, the full prompt the PC built
854
+ * (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
855
+ */
856
+ export function cleanBot(bot) {
857
+ if (!bot || typeof bot !== 'object') return null;
858
+ const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
859
+ if (!prompt) return null;
860
+ return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
861
+ }
862
+ /** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
863
+ const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
864
+
865
+ /** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
866
+ export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
867
+ const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
868
+ const read = async (p) => {
869
+ const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
870
+ if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
871
+ let json = null;
872
+ try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
873
+ return { json, sha: r.json.sha, size: r.json.size };
874
+ };
875
+ const list = async (p) => {
876
+ const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
877
+ return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
878
+ };
879
+ const put = async (p, content, message) => {
880
+ for (let i = 0; i < 4; i++) {
881
+ const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
882
+ const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
883
+ const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
884
+ if (r.status < 300) return true;
885
+ await sleep(300 + i * 400);
886
+ }
887
+ return false;
888
+ };
889
+ const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
890
+ const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
891
+ const remove = async (p, sha) => {
892
+ for (let i = 0; i < 3; i++) {
893
+ const s = sha || (await read(p) || {}).sha;
894
+ if (!s) return;
895
+ const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
896
+ if (r.status < 300 || r.status === 404) return;
897
+ sha = null;
898
+ await sleep(300);
899
+ }
900
+ };
901
+ const ensure = async (baseSha) => {
902
+ const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
903
+ if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
904
+ };
905
+ return { read, list, write, writeBytes, remove, ensure };
906
+ }
907
+
908
+ async function loadHistory(files, sessionId) {
909
+ if (!sessionId) return { messages: [] };
910
+ const f = await files.read(SESSION_PATHS.history(sessionId));
911
+ return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
673
912
  }
674
913
 
675
- async function saveHistory(api, repo, sessionId, messages, baseSha) {
914
+ async function saveHistory(files, sessionId, messages) {
676
915
  if (!sessionId) return;
677
- const ref = await api('GET', `/repos/${repo}/git/ref/heads/${SESSIONS_BRANCH}`, null, { allow: [404] });
678
- if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${SESSIONS_BRANCH}`, sha: baseSha }, { allow: [422] });
679
- const file = `sessions/${encodeURIComponent(sessionId)}.json`;
680
- const body = (sha) => ({ message: `Chat ${sessionId}`, branch: SESSIONS_BRANCH, content: Buffer.from(JSON.stringify({ sessionId, updatedAt: new Date().toISOString(), messages }, null, 1)).toString('base64'), ...(sha ? { sha } : {}) });
681
- for (let i = 0; i < 3; i++) {
682
- const cur = await api('GET', `/repos/${repo}/contents/${file}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
683
- const r = await api('PUT', `/repos/${repo}/contents/${file}`, body(cur.status === 200 ? cur.json.sha : null), { allow: [409, 422] });
684
- if (r.status < 300) return;
685
- }
916
+ await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
686
917
  }
687
918
 
919
+ const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
920
+ export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
921
+
688
922
  /**
689
- * `codeply cloud runner`: one cloud task, end to end, inside GitHub Actions.
690
- * @returns {Promise<{status:'done'|'failed', result:object}>}
923
+ * `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
924
+ * (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
925
+ * message in queue/<session>, so a follow-up starts at once instead of waiting
926
+ * for a new runner. Each task:
927
+ * - runs like a local run, with a real browser (screenshots) when Chrome is there
928
+ * - publishes its steps live to tasks/<id>.json (and the first one's check run)
929
+ * - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
930
+ * into the base branch (CRAFT_MERGE=false keeps it as a branch only)
931
+ * @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
691
932
  */
692
- export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 5000 }) {
933
+ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 4000, idleMs, pollMs = 3000, browserImpl }) {
693
934
  const repo = env.GITHUB_REPOSITORY;
694
- const taskId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
695
- const mode = ['Build', 'Plan', 'Ask'].includes(env.CRAFT_MODE) ? env.CRAFT_MODE : 'Build';
696
- const prompt = String(env.CRAFT_PROMPT || '').slice(0, MAX_PROMPT);
935
+ const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
697
936
  const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
698
937
  const headSha = env.GITHUB_SHA;
699
- if (!repo || !token || !taskId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' } };
938
+ if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
700
939
  const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
701
940
  const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
702
941
  const api = githubApi({ token, apiUrl, fetchImpl });
942
+ const files = branchFiles(api, repo);
943
+ const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
944
+ const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
945
+ const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
946
+ await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
947
+
948
+ // The project's environment for running and testing: never committed.
949
+ const envFile = path.join(cwd, '.env');
950
+ if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
951
+ try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
952
+ }
953
+ const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
954
+
955
+ const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
956
+
957
+ let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
958
+ // A bot (and the task kind) comes next to the dispatch, on craft-sessions.
959
+ const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
960
+ if (req && req.json) {
961
+ task = { ...task, bot: req.json.bot, kind: req.json.kind };
962
+ await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
963
+ }
964
+ const results = [];
965
+ try {
966
+ while (task) {
967
+ if (sessionId) await heartbeat(true, Date.now() + 60 * 60 * 1000);
968
+ results.push(await runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat: sessionId ? () => heartbeat(true, Date.now() + 60 * 60 * 1000) : null }));
969
+ if (!sessionId || idle <= 0 || setupError) break;
970
+ task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
971
+ }
972
+ } finally {
973
+ if (sessionId) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
974
+ if (browser && browser.close) await browser.close().catch(() => {});
975
+ }
976
+ const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
977
+ return { ...last, results };
978
+ }
703
979
 
704
- const check = (await api('POST', `/repos/${repo}/check-runs`, {
980
+ /** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
981
+ async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
982
+ const deadline = Date.now() + idle;
983
+ let beat = 0;
984
+ while (Date.now() < deadline) {
985
+ if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
986
+ const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
987
+ for (const it of items) {
988
+ const f = await files.read(it.path).catch(() => null);
989
+ await files.remove(it.path, f && f.sha).catch(() => {});
990
+ const t = f && f.json;
991
+ if (!t || !t.id) continue;
992
+ // Picked up by another runner already (it was re-dispatched): skip.
993
+ if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
994
+ return { ...t, sessionId, dispatched: false };
995
+ }
996
+ await sleep(pollMs);
997
+ }
998
+ return null;
999
+ }
1000
+
1001
+ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat }) {
1002
+ const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
1003
+ const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
1004
+ const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
1005
+ const sessionId = task.sessionId || '';
1006
+ const bot = cleanBot(task.bot);
1007
+ const kind = cleanKind(task.kind);
1008
+ const startedAt = Date.now();
1009
+ const events = [];
1010
+ const lines = [];
1011
+
1012
+ // The dispatched task also gets a check run: it is how a failed start is diagnosed.
1013
+ const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
705
1014
  name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
706
1015
  output: { title: 'Starting', summary: 'Starting...' },
707
- })).json;
708
- const events = [];
1016
+ }).catch(() => ({ json: null }))).json : null;
1017
+
1018
+ const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
709
1019
  const finish = async (ok, title, summary, result) => {
710
- // The result's fields stay at the top level (older apps read them there); events share the space left.
711
- const base = JSON.stringify(result);
712
- const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - base.length - 200));
713
- await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
714
- status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
715
- output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
716
- }).catch((e) => log(`Could not finish the check run: ${e.message}`));
1020
+ const baseLen = JSON.stringify(result).length;
1021
+ const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
1022
+ await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
1023
+ if (check) {
1024
+ await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
1025
+ status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
1026
+ output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
1027
+ }).catch((e) => log(`Could not finish the check run: ${e.message}`));
1028
+ }
717
1029
  return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
718
1030
  };
719
1031
 
720
1032
  if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
721
1033
  if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
722
1034
 
723
- const lines = [];
724
1035
  let lastPush = 0; let pending = null;
725
1036
  const pushProgress = async (note, force = false) => {
726
1037
  if (!force && Date.now() - lastPush < updateMs) return;
727
1038
  lastPush = Date.now();
728
- pending = api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
729
- output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packEvents(events) }) },
730
- }).catch(() => {});
1039
+ const packed = packEvents(events);
1040
+ pending = Promise.all([
1041
+ files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
1042
+ check ? api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packed }) } }).catch(() => {}) : null,
1043
+ heartbeat ? heartbeat() : null,
1044
+ ]);
731
1045
  await pending;
732
1046
  };
733
1047
 
1048
+ // Screenshots go next to the status, where the PC and the phone read them.
1049
+ let shots = 0; let pendingShot = null;
1050
+ const agentBrowser = browser ? async (url, opts) => {
1051
+ const r = await browser(url, opts);
1052
+ if (r && r.ok && r.screenshotPath) {
1053
+ try {
1054
+ const rel = SESSION_PATHS.shot(taskId, ++shots);
1055
+ if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
1056
+ } catch {}
1057
+ }
1058
+ return r;
1059
+ } : undefined;
1060
+
734
1061
  try {
735
- const hist = await loadHistory(api, repo, sessionId);
1062
+ // Where this task starts (a later message in the same runner starts after the earlier merge).
1063
+ const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
1064
+ const hist = await loadHistory(files, sessionId);
736
1065
  await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
737
1066
  const approve = ciApprove(cwd);
738
1067
  let answer = ''; let steps = 0; let failure = '';
739
- const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud, on a copy of the project. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Do not commit or push; Craft does that when you finish.)`;
740
- for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, signal: new AbortController().signal, route, maxSteps })) {
1068
+ const userMessage = kind === 'task'
1069
+ ? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
1070
+ : `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
1071
+ for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}) })) {
741
1072
  const rec = recordEvent(ev);
1073
+ if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
742
1074
  // The final answer arrives as the reply; everything else is part of the chat's steps.
743
1075
  if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
744
1076
  if (ev.type === 'text' && !ev.interim) answer = ev.text;
@@ -750,37 +1082,53 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
750
1082
  if (pending) await pending;
751
1083
  if (failure && !answer) throw new Error(failure);
752
1084
 
1085
+ // The bot's own last words, before a plan file replaces the answer: what the phone speaks.
1086
+ const reply = answer;
753
1087
  if (mode === 'Plan') {
754
1088
  const dir = path.join(cwd, '.codeply', 'plans');
755
- const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
756
- if (files.length) answer = fs.readFileSync(path.join(dir, files[0].f), 'utf8');
1089
+ const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
1090
+ if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
757
1091
  }
758
1092
 
759
- let branch = null; let sha = null; let files = []; let stats = [];
1093
+ let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
760
1094
  if (mode === 'Build') {
761
- await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`]);
1095
+ await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
762
1096
  const staged = await git(cwd, ['diff', '--cached', '--name-only']);
763
1097
  if (staged) {
764
- files = staged.split('\n').filter(Boolean);
1098
+ changed = staged.split('\n').filter(Boolean);
765
1099
  // Per-file + and - lines, for the chat's record of what was pushed.
766
1100
  stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
767
1101
  const [add, del, ...name] = l.split('\t');
768
1102
  return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
769
1103
  });
770
- branch = taskBranch(taskId);
771
- await git(cwd, [...BOT, 'commit', '-q', '-m', `${prompt.split('\n')[0].slice(0, 60)}\n\nCraft cloud task ${taskId}.`]);
1104
+ branch = cloudBranchName(prompt, taskId);
1105
+ const subject = prompt.split('\n')[0].slice(0, 60);
1106
+ await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
772
1107
  sha = await git(cwd, ['rev-parse', 'HEAD']);
773
1108
  await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
1109
+ // Into the base branch too, so the next run (and the PC's pull) starts from it.
1110
+ if (merge) {
1111
+ const m = await api('POST', `/repos/${repo}/merges`, { base, head: branch, commit_message: `Merge ${branch}: ${subject}` }, { allow: [204, 404, 409] }).catch((e) => ({ status: 0, error: e.message }));
1112
+ merged = m.status === 201 || m.status === 204
1113
+ ? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
1114
+ : { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
1115
+ if (merged.ok) {
1116
+ // Keep working from the merged base for the next message in this chat.
1117
+ await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
1118
+ await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
1119
+ }
1120
+ }
774
1121
  const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
775
1122
  const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
776
- events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus });
1123
+ events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
777
1124
  }
778
1125
  }
779
1126
 
780
- await saveHistory(api, repo, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2), headSha)
1127
+ await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
781
1128
  .catch((e) => log(`Could not save the chat: ${e.message}`));
782
- const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files, stats: stats.slice(0, 100), steps, baseSha: headSha };
783
- const title = files.length ? `Changed ${files.length} file${files.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
1129
+ const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase,
1130
+ ...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
1131
+ const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
784
1132
  return finish(true, title, answer || title, result);
785
1133
  } catch (e) {
786
1134
  return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });