@ctrl-spc/cs 0.7.13 → 0.7.14

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.
@@ -14,7 +14,7 @@
14
14
  * two owners of it is how two answers are born.
15
15
  */
16
16
  import { execFileSync } from 'node:child_process';
17
- import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { configDir, readCodebasePaths } from '../config.js';
20
20
  function isDirectory(path) {
@@ -413,6 +413,8 @@ export function commitCardWork(folder,
413
413
  // Name the codebase and branch in failures, without exposing a local path.
414
414
  describe) {
415
415
  try {
416
+ if (gitOperationInProgress(folder))
417
+ throw pathFree('A Git operation is still in progress on this card. Use recover_landing to finish the approved recovery before landing.');
416
418
  git(folder, ['add', '-A']);
417
419
  if (git(folder, ['status', '--porcelain']).length > 0) {
418
420
  git(folder, ['commit', '--no-verify', '-m', 'Work from this card']);
@@ -447,28 +449,118 @@ export function mergeIntoBase(source, branch, base, codebaseName) {
447
449
  The worktree holding the base may be the person's own checkout, and the
448
450
  step below would abort on failure. So a conflicted merge already sitting
449
451
  there stops this before anything is attempted. */
450
- if (existsSync(join(holder, '.git', 'MERGE_HEAD')) || existsSync(join(holder, 'MERGE_HEAD'))) {
451
- throw pathFree(`A merge is already in progress in the copy of ${codebaseName} holding ${base} on this `
452
+ let inProgress;
453
+ try {
454
+ inProgress = gitOperationInProgress(holder);
455
+ }
456
+ catch {
457
+ throw pathFree(`Could not inspect ${codebaseName}'s ${base} on this machine. Nothing was merged; check that working copy before retrying.`);
458
+ }
459
+ if (inProgress) {
460
+ throw pathFree(`A Git operation is already in progress in the copy of ${codebaseName} holding ${base} on this `
452
461
  + `machine. Nothing was done, and this card's work is still on ${branch}.`);
453
462
  }
454
463
  try {
455
464
  git(holder, ['merge', '--no-edit', branch]);
456
465
  }
457
466
  catch {
467
+ const conflicts = git(holder, ['diff', '--name-only', '--diff-filter=U']).length > 0;
458
468
  /* ITS OWN TRY, SO A FAILED ABORT CANNOT REPLACE THE COMPOSED FAILURE with
459
469
  git's own text, which names a folder. */
470
+ if (gitOk(holder, ['rev-parse', '--verify', 'MERGE_HEAD'])) {
471
+ try {
472
+ git(holder, ['merge', '--abort']);
473
+ }
474
+ catch {
475
+ throw pathFree(`The merge in ${codebaseName} could not be rolled back. ${base} needs recovery before retrying; this card's branch is preserved.`);
476
+ }
477
+ }
478
+ throw Object.assign(pathFree(`This card's work will not go back onto ${base} in ${codebaseName} cleanly. Nothing was `
479
+ + `merged, ${base} is where it was, and the work is still on ${branch}.`), { mergeConflicts: conflicts });
480
+ }
481
+ }
482
+ function gitOperationInProgress(folder) {
483
+ return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply', 'sequencer']
484
+ .some(name => existsSync(git(folder, ['rev-parse', '--path-format=absolute', '--git-path', name])));
485
+ }
486
+ const reconciliationMessage = (card) => `CTRL+SPC: reconcile ${card.branch} with ${card.base}`;
487
+ /** Git owns the durable recovery state. Its merge message identifies our merge;
488
+ * a user's in-progress operation is never continued or aborted by recovery. */
489
+ export function ownsReconciliation(card) {
490
+ if (!gitOk(card.folder, ['rev-parse', '--verify', 'MERGE_HEAD']))
491
+ return false;
492
+ try {
493
+ const message = git(card.folder, ['rev-parse', '--path-format=absolute', '--git-path', 'MERGE_MSG']);
494
+ return readFileSync(message, 'utf8').split('\n')[0] === reconciliationMessage(card);
495
+ }
496
+ catch {
497
+ throw pathFree('Could not read this card’s merge recovery state. Its working copy needs recovery on this machine.');
498
+ }
499
+ }
500
+ function checkCardBranch(card) {
501
+ if (git(card.folder, ['branch', '--show-current']) !== card.branch || card.branch === card.base) {
502
+ throw pathFree('The card is no longer on its assigned branch. Restore its working copy before retrying the approved merge.');
503
+ }
504
+ }
505
+ /** Prepare only the card's working copy. The sandboxed agent resolves ordinary
506
+ * files; the product keeps ownership of the index, merge state, and base branch. */
507
+ export function prepareCardReconciliation(card) {
508
+ try {
509
+ checkCardBranch(card);
510
+ if (gitOperationInProgress(card.folder)) {
511
+ if (!ownsReconciliation(card))
512
+ throw pathFree('Another Git operation is already in progress on this card. It was left untouched.');
513
+ }
514
+ else {
515
+ if (git(card.folder, ['status', '--porcelain']).length > 0) {
516
+ throw pathFree('The card has uncommitted changes. Preserve and verify them before retrying the approved merge with recover_landing(action="finish").');
517
+ }
518
+ try {
519
+ git(card.folder, ['merge', '--no-commit', '--no-ff', '-m', reconciliationMessage(card), `refs/heads/${card.base}`]);
520
+ }
521
+ catch (error) {
522
+ if (!ownsReconciliation(card))
523
+ throw error;
524
+ }
525
+ }
526
+ const conflicts = git(card.folder, ['diff', '--name-only', '--diff-filter=U']);
527
+ return { conflicts: conflicts ? conflicts.split('\n').length : 0 };
528
+ }
529
+ catch (error) {
530
+ if (error?.pathFree)
531
+ throw error;
532
+ throw pathFree(`Could not prepare merge recovery on ${card.branch}. The work remains on its branch; retry recovery on this machine.`);
533
+ }
534
+ }
535
+ /** Called only after the owner reports verification under the active token. */
536
+ export function finishCardReconciliation(card) {
537
+ try {
538
+ checkCardBranch(card);
539
+ if (!gitOperationInProgress(card.folder))
540
+ return;
541
+ if (!ownsReconciliation(card))
542
+ throw pathFree('Another Git operation is already in progress on this card. It was left untouched.');
543
+ // Stage resolved edits, including deletions. Refuse remaining conflict
544
+ // markers before committing, even though staging clears Git's U entries.
545
+ git(card.folder, ['add', '-A']);
460
546
  try {
461
- git(holder, ['merge', '--abort']);
547
+ git(card.folder, ['diff', '--cached', '--check']);
548
+ }
549
+ catch {
550
+ throw pathFree('The resolution still contains conflict markers or whitespace errors. Fix the files, verify the result, and retry recover_landing(action="finish"). Nothing was merged onto the base.');
462
551
  }
463
- catch { /* nothing to abort */ }
464
- throw pathFree(`This card's work will not go back onto ${base} in ${codebaseName} cleanly. Nothing was `
465
- + `merged, ${base} is where it was, and the work is still on ${branch}.`);
552
+ git(card.folder, ['commit', '--no-edit']);
553
+ }
554
+ catch (error) {
555
+ if (error?.pathFree)
556
+ throw error;
557
+ throw pathFree(`Could not finish merge recovery on ${card.branch}. The resolution is preserved; retry recovery on this machine.`);
466
558
  }
467
559
  }
468
560
  export function settleCardWorktree(folder) {
469
561
  // Completion is not permission to commit. Preserve unfinished Git work,
470
562
  // including staged and untracked files, for the person to review or resume.
471
- if (git(folder, ['status', '--porcelain']).length > 0)
563
+ if (gitOperationInProgress(folder) || git(folder, ['status', '--porcelain']).length > 0)
472
564
  return false;
473
565
  // Only ignored build/dependency files can remain in a clean copy.
474
566
  removeWorktree(folder);
@@ -380,6 +380,9 @@ const LANDING_LINES = {
380
380
  'The product writes the question and both answers, and it performs the merge itself if they',
381
381
  'choose to put the work back. Landing is the product\'s job and never yours: never merge and',
382
382
  'never push.',
383
+ 'If that approved merge needs recovery, call recover_landing(action="prepare"). Resolve and verify',
384
+ 'the files, write_report(work_complete=true), then recover_landing(action="finish"). Approval persists;',
385
+ 'do not ask again or request broader Git permissions. The product handles the Git updates.',
383
386
  ],
384
387
  branch: [
385
388
  'Finished work in this codebase stays on its branch. Nothing is pushed and nothing is merged.',
@@ -862,7 +865,18 @@ export const landingOutcomeContext = (landing) => {
862
865
  ...head,
863
866
  `They chose to put this work back, and the product has merged ${landing.branch} onto `
864
867
  + `${landing.base} on this machine. It is done and it is not yours to do.`,
865
- 'Say so plainly in your reply, name the branch it went onto, and finish the card.',
868
+ 'Record completion with write_report(work_complete=true), then say so plainly in your reply, name the branch it went onto, and finish the card.',
869
+ ];
870
+ }
871
+ if (landing.outcome === 'reconciling') {
872
+ return [
873
+ ...head,
874
+ `The merge is already approved. Nothing has landed onto ${landing.base} yet.`,
875
+ `The product brought ${landing.base} into this card's branch ${landing.branch} for recovery; ${landing.conflicts} files are marked as conflicted.`,
876
+ 'Resolve the files in your current working copy, preserve both the assignment and current base changes, and verify the combined result.',
877
+ 'Use read-only Git commands to inspect it. The product owns Git metadata: do not stage, commit, merge or rebase yourself, and do not ask for wider filesystem access.',
878
+ 'After verification, call write_report(work_complete=true), then recover_landing(action="finish"). If the base advances and new conflicts appear, resolve and verify again.',
879
+ 'After a restart, recover_landing(action="prepare") resumes the same recovery without discarding your edits. Do not ask for the same merge approval again.',
866
880
  ];
867
881
  }
868
882
  if (landing.outcome === 'leave') {
@@ -883,13 +897,7 @@ export const landingOutcomeContext = (landing) => {
883
897
  'offer leaves it with them.',
884
898
  ];
885
899
  }
886
- /* ═══ IT DOES NOT SAY "DO NOT FINISH THE CARD", BECAUSE THE AGENT CANNOT
887
- OBEY THAT. ═══ The card reads done the moment the owner replies, by
888
- `panel3_answer`, and nothing an agent writes changes it. Telling it
889
- otherwise would be a rule that can only be broken. What it can do is say
890
- plainly that the work did not go back and where it still is, which is what
891
- the person needs; C3 replaces this relay with a question, and a question is
892
- what keeps the card open. */
900
+ // A non-conflict refusal still needs a truthful outcome and a concrete next action.
893
901
  return [
894
902
  ...head,
895
903
  'They chose to put this work back and the product could not do it:',
@@ -138,10 +138,10 @@ import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, load
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
139
  import { sayListening, sayPollingProblem, stopListening } from './presence.js';
140
140
  import { selectedHarness } from './coordinator.js';
141
- import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
141
+ import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, ownsReconciliation, prepareCardReconciliation, finishCardReconciliation, worktreesOnThisMachine, } from './checkout.js';
142
142
  import { harness, startAgent } from './spawn.js';
143
143
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
144
- import { startToolsServer, PANEL3_IMAGES_BUCKET } from './tools.js';
144
+ import { startToolsServer, processActivationIsCurrent, PANEL3_IMAGES_BUCKET } from './tools.js';
145
145
  import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
146
146
  import { getMachineIdentity, scratchDir } from '../config.js';
147
147
  import { listCodebases } from '../codebases.js';
@@ -242,19 +242,9 @@ export async function landingOffer(client, askId) {
242
242
  return 'unclear';
243
243
  return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
244
244
  }
245
- /**
246
- * ═══ THE PRODUCT PERFORMS THE LANDING, HERE, BEFORE THE AGENT THAT WILL SPEAK
247
- * ABOUT IT STARTS. ═══
248
- *
249
- * Not a tool, because a tool means an agent decides whether a yes takes effect,
250
- * and an agent that finishes the card instead leaves a person who clicked yes
251
- * with nothing. Not the poll's sweep, because the sweep cannot tell the agent
252
- * what it did.
253
- *
254
- * ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
255
- * tell the person, so what happened is returned as the sentence it will be
256
- * handed rather than written to `failed_because`.
257
- */
245
+ /** The initial approved landing is automatic. Conflict recovery returns here
246
+ * after the owner resolves and verifies the files through recover_landing.
247
+ * Failures are reported to the agent so the card can continue truthfully. */
258
248
  export function landCardWork(where, outcome) {
259
249
  const card = where.card;
260
250
  if (outcome !== 'land')
@@ -270,22 +260,80 @@ export function landCardWork(where, outcome) {
270
260
  };
271
261
  }
272
262
  try {
273
- /* THE COMMIT FIRST, AND ITS ABSENCE IS NOT A FAILURE. Between the answer and
274
- this re-arm every run on the card has ended, so the sweep may already have
275
- committed the copy and taken it away; `workingDirectory` has just rebuilt
276
- it from the branch, and the only difference is that there is nothing left
277
- to commit. */
263
+ if (ownsReconciliation(card)) {
264
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
265
+ }
266
+ // A repeated activation may recreate an already-committed working copy;
267
+ // in that case the commit helper has nothing left to do.
278
268
  commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
279
269
  mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
280
270
  return { outcome: 'landed', branch: card.branch, base: card.base };
281
271
  }
282
272
  catch (error) {
273
+ if (error?.mergeConflicts) {
274
+ try {
275
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
276
+ }
277
+ catch (recoveryError) {
278
+ error = recoveryError;
279
+ }
280
+ }
283
281
  return {
284
282
  outcome: 'refused',
285
283
  because: error instanceof Error ? error.message : String(error),
286
284
  };
287
285
  }
288
286
  }
287
+ /** Recovery uses the current owner's recorded approval, never model-supplied
288
+ * paths or refs. Recheck it before resolving a working copy or mutating Git. */
289
+ export async function recoverLanding(client, machineId, runId, processToken, action) {
290
+ if (!await processActivationIsCurrent(client, runId, processToken)) {
291
+ throw new Error('This activation no longer owns the card. Nothing was merged.');
292
+ }
293
+ const runs = await returned(client.from('panel3_runs').select('card_id, machine_id, level, completion_requested_token')
294
+ .eq('id', runId).eq('process_token', processToken), 'read', 'the merge recovery owner');
295
+ const run = runs[0];
296
+ if (!run || run.level !== 2 || run.machine_id !== machineId) {
297
+ throw new Error('Merge recovery must run with this card’s conversation owner on its assigned machine. Nothing was merged.');
298
+ }
299
+ const offers = await returned(client.from('panel3_asks').select('id, run_id').eq('card_id', run.card_id)
300
+ .eq('offers_landing', true).order('created_at', { ascending: false }).limit(1), 'read', 'the latest merge approval');
301
+ const offer = offers[0];
302
+ if (!offer || offer.run_id !== runId || await landingOffer(client, offer.id) !== 'land') {
303
+ throw new Error('The latest ending choice does not approve a merge. Nothing was merged; use offer_ending after the work is verified.');
304
+ }
305
+ if (action === 'finish' && run.completion_requested_token !== processToken) {
306
+ throw new Error('Resolve and verify the work, then call write_report(work_complete=true) before finishing merge recovery.');
307
+ }
308
+ const activeWork = await returned(client.from('panel3_runs').select('id').eq('card_id', run.card_id).neq('id', runId)
309
+ .or('ended_at.is.null,pid.not.is.null'), 'read', 'other processes on this card');
310
+ if (activeWork.length > 0)
311
+ throw new Error('Wait for the other agents on this card to exit before recovering its merge. Nothing was merged.');
312
+ // Preparation or a failed retry is more work, not a completed assignment.
313
+ // Consume the completion marker so every new resolution must be verified.
314
+ let clearCompletion = client.from('panel3_runs')
315
+ .update({ completion_requested_token: null, completion_summary: null })
316
+ .eq('id', runId).eq('process_token', processToken).eq('state', 'running').is('ended_at', null);
317
+ if (action === 'finish')
318
+ clearCompletion = clearCompletion.eq('completion_requested_token', processToken);
319
+ const cleared = await returned(clearCompletion.select('id'), 'update', 'the merge recovery activation');
320
+ if (cleared.length !== 1 || !await processActivationIsCurrent(client, runId, processToken)) {
321
+ throw new Error('The card’s activation or verified work changed. Refresh the card, settle its work, and report verification again before retrying. Nothing was merged.');
322
+ }
323
+ const where = await workingDirectory(client, runId, 2, true);
324
+ const card = where.card;
325
+ if (!card || card.landing !== 'main') {
326
+ throw new Error('This card is not configured to merge onto its base branch. Nothing was merged.');
327
+ }
328
+ if (!await processActivationIsCurrent(client, runId, processToken)) {
329
+ throw new Error('This activation no longer owns the card. Nothing was merged.');
330
+ }
331
+ if (action === 'prepare') {
332
+ return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
333
+ }
334
+ finishCardReconciliation(card);
335
+ return landCardWork(where, 'land');
336
+ }
289
337
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
290
338
  /**
291
339
  * ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
@@ -3040,7 +3088,7 @@ export async function run(args, injected, signal, lifecycle) {
3040
3088
  const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
3041
3089
  hold(child.runId, child.settled);
3042
3090
  return { runId: child.runId };
3043
- });
3091
+ }, (runId, processToken, action) => recoverLanding(current(), machineId, runId, processToken, action));
3044
3092
  out(`daemon machine ${machineId}`);
3045
3093
  out(`tools ${tools.urlFor('<run-id>')}`);
3046
3094
  out(once ? 'mode one poll' : `mode polling every ${POLL_INTERVAL_MS / 1000}s, Ctrl-C to stop`);
@@ -126,7 +126,7 @@ import { z } from 'zod';
126
126
  import { returned } from './client.js';
127
127
  import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
128
128
  import { rememberSecret, redactArgs } from './secrets.js';
129
- import { workBrief } from './prompt.js';
129
+ import { workBrief, landingOutcomeContext } from './prompt.js';
130
130
  import { listCodexModels } from './codex-models.js';
131
131
  /* The harness this daemon spawns with, which is the process this server runs
132
132
  in. It is what `panel3_runs.harness` is written from at spawn, so reading it
@@ -2030,6 +2030,26 @@ const TOOLS = [
2030
2030
  + 'Nobody will be asked again.';
2031
2031
  },
2032
2032
  },
2033
+ {
2034
+ name: 'recover_landing',
2035
+ levels: [2],
2036
+ description: 'Recover a merge the person already approved. The product manages Git metadata; you resolve '
2037
+ + 'and verify ordinary files in this card’s working copy. Use prepare to bring the current base '
2038
+ + 'into the card branch, including after a restart or a Git permission failure. Resolve conflicts '
2039
+ + 'and run checks, record write_report(work_complete=true), then use finish to retry landing. '
2040
+ + 'No new approval is needed. Never change the sandbox or ask the person to reopen with Git access. '
2041
+ + 'After landing succeeds, record completion again and reply. If recovery fails, report the exact remaining action.',
2042
+ input: { action: z.enum(['prepare', 'finish']) },
2043
+ handler: async (caller, args) => {
2044
+ if (!caller.isOwner || caller.level !== 2 || !caller.processToken) {
2045
+ throw new Error('Only the current conversation owner can recover its approved merge.');
2046
+ }
2047
+ if (!caller.recoverLanding)
2048
+ throw new Error('Merge recovery is unavailable on this companion. Update and restart the companion on this card’s machine, then retry in this card.');
2049
+ const { action } = args;
2050
+ return landingOutcomeContext(await caller.recoverLanding(caller.runId, caller.processToken, action)).join('\n');
2051
+ },
2052
+ },
2033
2053
  /**
2034
2054
  * ═══ THE AGENT ASKS FOR THE OFFER; THE PRODUCT WRITES IT. ═══
2035
2055
  *
@@ -2051,7 +2071,8 @@ const TOOLS = [
2051
2071
  levels: [2],
2052
2072
  description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
2053
2073
  + 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
2054
- + 'the main branch. First record verified completion with write_report(work_complete=true). '
2074
+ + 'the main branch. If the person already approved a merge that needs recovery, use recover_landing instead of asking again. '
2075
+ + 'First record verified completion with write_report(work_complete=true). '
2055
2076
  + 'Use say for progress; this tool cannot announce work you intend to do. You do not write the question or the answers and you never merge anything: '
2056
2077
  + 'the product composes both, and it performs the merge itself if they choose to put the work '
2057
2078
  + 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
@@ -2824,7 +2845,7 @@ const TOOLS = [
2824
2845
  await whileRunning(client.rpc('panel3_request_completion', {
2825
2846
  p_run_id: runId, p_process_token: processToken, p_summary: report,
2826
2847
  }), runId, 'declare completion of');
2827
- return 'Completion recorded. If the finished work needs an ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
2848
+ return 'Completion recorded. If an already-approved merge is awaiting recovery, call recover_landing(action="finish"); if the finished work needs its first ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
2828
2849
  }
2829
2850
  await whileRunning(processToken === undefined
2830
2851
  ? client.from('panel3_runs').update({ report }).eq('id', runId)
@@ -3341,6 +3362,8 @@ export function toolShape(name) {
3341
3362
  function toolAvailable(tool, level, isOwner) {
3342
3363
  if (isOwner && tool.name === 'escalate')
3343
3364
  return false;
3365
+ if (tool.name === 'recover_landing' && !isOwner)
3366
+ return false;
3344
3367
  return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
3345
3368
  }
3346
3369
  function toolNamed(name) {
@@ -3431,7 +3454,7 @@ export async function processActivationIsCurrent(client, runId, processToken) {
3431
3454
  * below goes through the signed-in user's own token, so guessing a run id would
3432
3455
  * still only ever reach that user's own record.
3433
3456
  */
3434
- export async function startToolsServer(client, dispatch) {
3457
+ export async function startToolsServer(client, dispatch, recoverLanding) {
3435
3458
  const { data, error } = await client.auth.getUser();
3436
3459
  if (error)
3437
3460
  throw new Error(`could not start the tools server: ${error.message}`);
@@ -3550,7 +3573,7 @@ export async function startToolsServer(client, dispatch) {
3550
3573
  };
3551
3574
  const server = buildServer({
3552
3575
  client, userId, runId, cardId: run.card_id, level: run.level,
3553
- processToken, isOwner, dispatch,
3576
+ processToken, isOwner, dispatch, recoverLanding,
3554
3577
  });
3555
3578
  await server.connect(transport);
3556
3579
  await transport.handleRequest(req, res, body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"