agentbox-app 0.1.11 → 0.1.12

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 (60) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/agent-updates.mjs +3 -1
  6. package/main/approvals.mjs +14 -5
  7. package/main/claude-bin.mjs +1 -0
  8. package/main/claude-plan.mjs +14 -2
  9. package/main/codex-bin.mjs +1 -0
  10. package/main/codex-launch-env.mjs +31 -0
  11. package/main/config.mjs +14 -9
  12. package/main/demo.mjs +17 -14
  13. package/main/engine-setup.mjs +23 -12
  14. package/main/fresh-user.mjs +1 -1
  15. package/main/git-change.mjs +8 -2
  16. package/main/instruction-settings.mjs +47 -11
  17. package/main/ipc.mjs +19 -0
  18. package/main/machine.mjs +1 -1
  19. package/main/main.mjs +20 -1
  20. package/main/memory-gate.mjs +1 -1
  21. package/main/notify.mjs +1 -1
  22. package/main/refresh-agent-models.mjs +2 -1
  23. package/main/row-label.mjs +2 -1
  24. package/main/runs-on.mjs +93 -0
  25. package/main/serve.mjs +11 -1
  26. package/main/settings.mjs +23 -1
  27. package/main/ship-queue.mjs +1 -1
  28. package/main/store/home.mjs +37 -5
  29. package/main/store/project.mjs +15 -19
  30. package/main/store.mjs +10 -4
  31. package/main/supervisor.mjs +205 -26
  32. package/main/task-folders.mjs +20 -3
  33. package/main/task-terminals.mjs +12 -1
  34. package/main/team/index.mjs +2 -2
  35. package/main/team/projects.mjs +1 -1
  36. package/mcp/core/page.mjs +1 -1
  37. package/mcp/core/work.mjs +34 -3
  38. package/mcp/tools.mjs +2 -1
  39. package/package.json +1 -1
  40. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  41. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  42. package/renderer/dist/index.html +2 -2
  43. package/scripts/after-install.mjs +14 -0
  44. package/scripts/approval-server.sh +7 -2
  45. package/shared/agent-import.mjs +1 -1
  46. package/shared/bridge-map.mjs +1 -0
  47. package/shared/claude-commands.generated.mjs +2 -2
  48. package/shared/claude-models.generated.mjs +4 -4
  49. package/shared/codex-modes.mjs +1 -1
  50. package/shared/demo-world.mjs +1 -1
  51. package/shared/fresh-user-home.mjs +12 -1
  52. package/shared/instruction-defaults.json +1 -1
  53. package/shared/project-folder-check.mjs +3 -3
  54. package/shared/runs-on.d.mts +20 -0
  55. package/shared/runs-on.mjs +79 -0
  56. package/shared/shortcuts.mjs +1 -1
  57. package/shared/spawn-trouble.mjs +1 -1
  58. package/shared/work-items.mjs +3 -1
  59. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  60. package/renderer/dist/assets/index-D6qncgai.js +0 -1168
@@ -10,7 +10,7 @@ import { hookFailure } from '../shared/hook-failure.mjs';
10
10
  import { taskCommand } from './task-commands.mjs';
11
11
  import { providerCommand, reviewTarget, nativeCommandNames } from '../shared/provider-commands.mjs';
12
12
  import { compactCodexThread } from './codex-compaction.mjs';
13
- import {readSystemTemplate} from './instruction-settings.mjs';
13
+ import {agentInstructionsUnified, readSystemTemplate, writeInstruction} from './instruction-settings.mjs';
14
14
  import {fillName} from '../shared/product-name.mjs';
15
15
  // The supervisor: deterministic, boring, and the only part of the app that talks
16
16
  // to the brain. It spawns headless claude sessions on the founder's plan, one
@@ -96,6 +96,7 @@ import {
96
96
  summarizeCodexEvent, traceCodexEvent, SAYING_CAP,
97
97
  } from './codex.mjs';
98
98
  import { createCodexAppServer } from './codex-app-server.mjs';
99
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
99
100
  import { CodexUsage } from './codex-usage.mjs';
100
101
  import { createCodexWorker, codexTranscriptFile, mcpServerNames, workerThreadParams } from './codex-session.mjs';
101
102
  import { CODEX_DEFAULT_MODE, isCodexMode } from '../shared/codex-modes.mjs';
@@ -106,6 +107,7 @@ import { nameRow, wantsName } from './row-label.mjs';
106
107
  import { LEVELS, latestMessage, sortMessage, wantsPriority } from './message-priority.mjs';
107
108
  import { NAME, Name, envName, nameSlug, isOurSlug } from '../shared/product-name.mjs';
108
109
  import { signInFiles, signInStamp } from './sign-in-files.mjs';
110
+ import { runsOn } from './runs-on.mjs';
109
111
  import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath } from './memory-gate-server.mjs';
110
112
  import { autoSlots, DEFAULTS as MEMORY_GATE } from './memory-gate.mjs';
111
113
  import { LeftoverCleaner } from './leftovers.mjs';
@@ -762,10 +764,19 @@ export class Supervisor {
762
764
  const out = { resumed: 0, queued: 0, working: 0, missing: 0 };
763
765
  if (!only.size) return out;
764
766
  this.liftBrakeForHer();
767
+ // RESUMING IS STILL ASKING TO RUN ON THIS MAC, so it obeys the same team
768
+ // rule the tick does (w-7fc38861be). It did not: on 2026-10-05 "Resume
769
+ // Agents" over a handful of ticked rows reached two conversations with
770
+ // teammates, and an agent turned up in the middle of each, reading the
771
+ // last thread reply as an instruction. Asking by name outranks a delivery
772
+ // mark and the attempt cap below; it does not make a row this Mac's to run.
773
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
774
+ const me = process.env.AGENTBOX_PERSON_ID || null;
765
775
 
766
776
  for (const item of this.store.listItems(Date.now())) {
767
777
  if (!only.has(item.id)) continue;
768
778
  only.delete(item.id);
779
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
769
780
  // A row with a worker on it is already resumed. Saying so is the honest
770
781
  // answer; killing the session to restart it would throw away the work in
771
782
  // flight, which is never what "resume" meant.
@@ -1325,7 +1336,7 @@ export class Supervisor {
1325
1336
  const word = engineLabel(engine);
1326
1337
  try {
1327
1338
  this.store.recordSessionResult(item.product, item.id, {
1328
- result: `This task was being worked on in ${word}, which is not installed on this Mac, so ${NAME} has not put a session back on it. It is not lost: install ${word} and the same session picks up where it stopped.`,
1339
+ result: `This task was being worked on in ${word}, which is not installed on this computer, so ${NAME} has not put a session back on it. It is not lost: install ${word} and the same session picks up where it stopped.`,
1329
1340
  status: 'open',
1330
1341
  });
1331
1342
  } catch (e) { console.warn('zero: could not say the harness is missing:', e.message); }
@@ -1893,6 +1904,20 @@ export class Supervisor {
1893
1904
  // throw away every chat she is in the middle of.
1894
1905
  const folders = product ? this.workFolders(item, product) : [];
1895
1906
  if (folders.length && rec.cwd && !folders.includes(rec.cwd)) return null;
1907
+ // A CHAT ON THE WRONG LOGIN IS NOT RESUMED IN A TIED PROJECT, and this is
1908
+ // the one question asked ahead of the health of the account. A chat started
1909
+ // before the project was tied lives on whatever account the rotation gave
1910
+ // it, and resuming it there would send the next reply -- and everything the
1911
+ // transcript already holds -- to exactly the login the tie exists to keep it
1912
+ // off. Dropped here, the row is briefed fresh on the right account.
1913
+ //
1914
+ // AND A TIED PROJECT KEEPS ITS CHAT WHILE THE PINNED ACCOUNT IS ILL, which
1915
+ // is why the test below is not simply "also check `_profileCannotHoldAChat`".
1916
+ // That guard moves a chat to a HEALTHIER account; a tied project has none to
1917
+ // move to, so firing it would throw the thread away and buy nothing.
1918
+ if (this._projectProfile(item.product, rec.engine)) {
1919
+ return this._profileFitsProject(rec.profile, item.product, rec.engine) ? rec : null;
1920
+ }
1896
1921
  // The account this chat lives on cannot run anything until somebody acts.
1897
1922
  // Null here is the fresh brief, which is exactly what this row needs: it
1898
1923
  // goes to a working account carrying its own thread.
@@ -2630,7 +2655,7 @@ export class Supervisor {
2630
2655
  .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
2631
2656
  if (!next) return undefined;
2632
2657
  this._naming = true;
2633
- return this._askSmall((opts) => nameRow(next, opts))
2658
+ return this._askSmall((opts) => nameRow(next, opts), { product: next.product })
2634
2659
  .then((label) => {
2635
2660
  if (!label) { this._nameMisses.set(key(next), now); return; }
2636
2661
  this._nameMisses.delete(key(next));
@@ -2656,13 +2681,26 @@ export class Supervisor {
2656
2681
  * one call per account and then waits for the next pass. The environment is
2657
2682
  * a worker's (`_workerEnv`), so no API key from the app's own shell reaches
2658
2683
  * the call, and the account rides it the way it does on a worker's spawn.
2684
+ *
2685
+ * A PROJECT TIED TO ONE ACCOUNT IS NOT WALKED, AND THAT IS THE WHOLE POINT OF
2686
+ * THE TIE. The prompt here carries the row's own text -- its title, the first
2687
+ * lines of its body, a teammate's message -- so a walk would put an
2688
+ * employer's row in front of a personal subscription, which is the one thing
2689
+ * the setting promises will not happen. So a tied project gets ONE call on
2690
+ * ONE account: the pinned login, resting or not, and no fallback to the other
2691
+ * engine either, because Codex is a different vendor's account and a walk
2692
+ * onto it breaks the same promise the walk across logins does. The cost of
2693
+ * getting nothing is the row keeping its own title, which is what every row
2694
+ * had before the namer existed.
2659
2695
  */
2660
- async _askSmall(ask) {
2696
+ async _askSmall(ask, { product = null } = {}) {
2661
2697
  const home = this._homeEngine();
2662
2698
  const other = home === 'codex' ? DEFAULT_ENGINE : 'codex';
2663
- const engines = [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2699
+ const engines = this._projectProfile(product)
2700
+ ? [DEFAULT_ENGINE]
2701
+ : [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2664
2702
  for (const engine of engines) {
2665
- for (const env of this._smallModelEnvs(engine)) {
2703
+ for (const env of this._smallModelEnvs(engine, product)) {
2666
2704
  const answer = await ask({ claudeBin: this.config?.claudeBin, codexBin: this.config?.codexBin, engine, env });
2667
2705
  if (answer) return answer;
2668
2706
  }
@@ -2670,15 +2708,19 @@ export class Supervisor {
2670
2708
  return '';
2671
2709
  }
2672
2710
 
2673
- /** One environment per account on an engine, the accounts not resting first. */
2674
- _smallModelEnvs(engine) {
2711
+ /**
2712
+ * One environment per account on an engine, the accounts not resting first --
2713
+ * or the one account a tied project named, which is a list of one.
2714
+ */
2715
+ _smallModelEnvs(engine, product = null) {
2675
2716
  const env = this._workerEnv(engine);
2676
2717
  const now = Date.now();
2677
2718
  const resting = (p) => {
2678
2719
  const key = this._accountKey(engine, p);
2679
2720
  return (this._profileCooldown?.[key] ?? 0) >= now || !!this._profileTrouble?.[key];
2680
2721
  };
2681
- const all = this._profilesFor(engine);
2722
+ const pinned = this._projectProfile(product, engine);
2723
+ const all = pinned ? [pinned] : this._profilesFor(engine);
2682
2724
  return [...all.filter((p) => !resting(p)), ...all.filter(resting)].map((p) => {
2683
2725
  if (engine === 'codex') return { ...env, CODEX_HOME: this._codexProfileHome(p) };
2684
2726
  return p === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: p };
@@ -2704,7 +2746,7 @@ export class Supervisor {
2704
2746
  if (!next) return undefined;
2705
2747
  this._sortingMessages = true;
2706
2748
  this._sortedMessages.add(`${next.item.product}:${next.item.id}:${next.latest.ts}`);
2707
- return this._askPriority(next.latest)
2749
+ return this._askPriority(next.latest, next.item.product)
2708
2750
  .then((level) => {
2709
2751
  if (level) this.store.prioritizeItem(next.item.product, next.item.id, LEVELS[level]);
2710
2752
  })
@@ -2712,8 +2754,8 @@ export class Supervisor {
2712
2754
  .finally(() => { this._sortingMessages = false; });
2713
2755
  }
2714
2756
 
2715
- _askPriority(latest) {
2716
- return this._askSmall((opts) => sortMessage(latest, opts));
2757
+ _askPriority(latest, product = null) {
2758
+ return this._askSmall((opts) => sortMessage(latest, opts), { product });
2717
2759
  }
2718
2760
 
2719
2761
  sayItOnEveryStrandedRow(items, now = Date.now()) {
@@ -3025,6 +3067,50 @@ export class Supervisor {
3025
3067
  return all.includes(chosen) ? [chosen] : all;
3026
3068
  }
3027
3069
 
3070
+ /**
3071
+ * THE ONE CLAUDE ACCOUNT A PROJECT IS TIED TO, when it is tied to one.
3072
+ *
3073
+ * `activeAccount` narrows the WHOLE app to one login, which is no use to
3074
+ * somebody who genuinely uses two: with a personal subscription and an
3075
+ * employer's on one Mac, round-robin runs the employer's code on the personal
3076
+ * plan about half the time. `projectAccounts` answers per project instead, and
3077
+ * a tied project runs there and waits rather than borrowing another login.
3078
+ *
3079
+ * CLAUDE ONLY, said here once. A Codex login is a different subscription on a
3080
+ * different binary and this setting says nothing about it, so a Codex run
3081
+ * reads null and rotates exactly as it did.
3082
+ *
3083
+ * IT IS IGNORED THE MOMENT IT STOPS NAMING A LOGIN THAT IS ON THIS MAC,
3084
+ * for the reason `_narrowToChosen` gives about `activeAccount` a few lines
3085
+ * down: a pin left over from an account since removed would otherwise hand
3086
+ * every worker a CLAUDE_CONFIG_DIR pointing at a folder with no login in it,
3087
+ * and the project would fail in silence while the app looked fine. A pin
3088
+ * nobody can honour is no pin, and the project rotates as it did before
3089
+ * anybody pinned it.
3090
+ *
3091
+ * AND A PIN BEATS `activeAccount`, DELIBERATELY. The two can disagree -- pick
3092
+ * one account for the app, tie a project to the other -- and one of them has
3093
+ * to win. It is the pin, because it is the narrower statement and the one made
3094
+ * about THIS project on its own page, and because the other way round is the
3095
+ * silent stop this file minds most: `activeAccount` would empty the tied
3096
+ * project's pool of one and nothing would ever start in it, with every screen
3097
+ * reading normal. So the check below is against the logins on the Mac
3098
+ * (`_profiles`) and not the pool `activeAccount` has narrowed.
3099
+ */
3100
+ _projectProfile(product, engine = DEFAULT_ENGINE) {
3101
+ if (engineOf(engine) !== DEFAULT_ENGINE) return null;
3102
+ const slug = typeof product === 'string' ? product : product?.slug;
3103
+ const pinned = slug ? this.config.projectAccounts?.[slug] : null;
3104
+ if (typeof pinned !== 'string' || !pinned) return null;
3105
+ return this._profiles().includes(pinned) ? pinned : null;
3106
+ }
3107
+
3108
+ /** Whether a session on `profile` is allowed to carry this project's work. */
3109
+ _profileFitsProject(profile, product, engine = DEFAULT_ENGINE) {
3110
+ const pinned = this._projectProfile(product, engine);
3111
+ return !pinned || (profile || 'default') === pinned;
3112
+ }
3113
+
3028
3114
  // THE ACCOUNTS ON ONE ENGINE THAT ARE NOT RESTING. Keyed through
3029
3115
  // `_accountKey`, which is the whole point: both engines call their primary
3030
3116
  // login 'default', and reading one raw name for both would let a quarantined
@@ -3335,6 +3421,33 @@ export class Supervisor {
3335
3421
  return n;
3336
3422
  }
3337
3423
 
3424
+ /**
3425
+ * HOW MANY SESSIONS ONE ACCOUNT IS ALREADY CARRYING. `_loadFor` above is the
3426
+ * same count for a whole engine, and a tied project needs the narrower one:
3427
+ * the engine's cap is the per-account number times the live accounts, so with
3428
+ * two logins a project tied to one of them would be let through at twice its
3429
+ * account's share and spend one subscription's rate limit on both halves.
3430
+ *
3431
+ * A ROW STILL BEING PREPARED COUNTS, the way it does in `_loadFor`, but only
3432
+ * when its own project is tied to this same account: that is the one case
3433
+ * where the account is already decided before the spawn. An untied row's
3434
+ * account is picked at the spawn and counting it here would charge it to a
3435
+ * login it may never reach.
3436
+ */
3437
+ _loadOnProfile(profile, engine = DEFAULT_ENGINE) {
3438
+ const key = this._accountKey(engine, profile);
3439
+ let n = 0;
3440
+ for (const s of this.sessions.values()) {
3441
+ if (s.command || s.remoteIdle) continue;
3442
+ if (this._accountKey(s.engine, s.profile || 'default') === key) n += 1;
3443
+ }
3444
+ for (const p of this._preparing?.values() ?? []) {
3445
+ const pin = this._projectProfile(p.item?.product, p.engine);
3446
+ if (pin && this._accountKey(p.engine, pin) === key) n += 1;
3447
+ }
3448
+ return n;
3449
+ }
3450
+
3338
3451
  // IS THERE ROOM FOR ONE MORE SESSION ON THIS ENGINE?
3339
3452
  //
3340
3453
  // THREE QUESTIONS, AND THE SECOND ONE WAS DELETED AS UNREACHABLE WHEN IT IS
@@ -4624,7 +4737,14 @@ export class Supervisor {
4624
4737
  // round robin lands on is an id that home has never heard of, the CLI
4625
4738
  // prints "No conversation found" and dies in a second. So this drops both,
4626
4739
  // and the thread is briefed fresh instead.
4627
- const strandedThread = !!walkedHome && this._profileCannotHoldAChat(walkedHome, engine);
4740
+ //
4741
+ // IN A TIED PROJECT THE QUESTION IS NOT HEALTH BUT WHOSE ACCOUNT IT IS, the
4742
+ // same swap `rowSessionFor` makes above and for the same reason: a thread
4743
+ // found on the wrong login is stranded whatever that login's health, and a
4744
+ // thread on the right one is kept however poorly it is faring.
4745
+ const strandedThread = !!walkedHome && (this._projectProfile(product?.slug ?? item.product, engine)
4746
+ ? !this._profileFitsProject(walkedHome, product?.slug ?? item.product, engine)
4747
+ : this._profileCannotHoldAChat(walkedHome, engine));
4628
4748
  const resumeId = strandedThread ? null : resumeIdOnDisk;
4629
4749
  // The fork's account is the source conversation's account, for the reason
4630
4750
  // every line above gives: a resume is only a resume on the login whose home
@@ -5060,7 +5180,7 @@ export class Supervisor {
5060
5180
  // never ours.
5061
5181
  messageRules() {
5062
5182
  let theirs = '';
5063
- if (this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5183
+ if (!agentInstructionsUnified(this.userDir) && this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5064
5184
  try { theirs = fs.readFileSync(this.messageRulesFile(), 'utf8').trim(); } catch {}
5065
5185
  }
5066
5186
  const ours = this.shippedMessageRules();
@@ -5116,6 +5236,7 @@ export class Supervisor {
5116
5236
  // What the settings box shows: the user's own words and nothing of ours
5117
5237
  // (w-3ec9f07978), so it opens empty until they write something.
5118
5238
  readMessageRules() {
5239
+ if (agentInstructionsUnified(this.userDir)) return this.readStanding();
5119
5240
  try {
5120
5241
  return fs.readFileSync(this.messageRulesFile(), 'utf8');
5121
5242
  } catch (err) {
@@ -5130,6 +5251,7 @@ export class Supervisor {
5130
5251
  // a state on disk that nothing later mistakes for a fresh install and
5131
5252
  // re-seeds.
5132
5253
  writeMessageRules(text) {
5254
+ if (agentInstructionsUnified(this.userDir)) return this.writeStanding(text);
5133
5255
  const file = this.messageRulesFile();
5134
5256
  const tmp = `${file}.tmp-${process.pid}`;
5135
5257
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -5173,11 +5295,7 @@ export class Supervisor {
5173
5295
  // Written whole, through a rename, because the fleet reads this file on
5174
5296
  // every spawn: a half-written save is a session briefed with half a rule.
5175
5297
  writeStanding(text) {
5176
- const file = this.standingFile();
5177
- const tmp = `${file}.tmp-${process.pid}`;
5178
- fs.mkdirSync(path.dirname(file), { recursive: true });
5179
- fs.writeFileSync(tmp, typeof text === 'string' ? text : '', 'utf8');
5180
- fs.renameSync(tmp, file);
5298
+ return writeInstruction(this.userDir, 'rules', typeof text === 'string' ? text : '');
5181
5299
  }
5182
5300
 
5183
5301
  /* ---------------------------- the second engine ------------------------- */
@@ -5373,6 +5491,34 @@ export class Supervisor {
5373
5491
  return { choices, workspace, byItem };
5374
5492
  }
5375
5493
 
5494
+ /**
5495
+ * WHICH SUBSCRIPTION THE FLEET IS SPENDING, for the one line that says so
5496
+ * (w-e217e577e5, 2026-10-07). Null on a Mac where nothing readable is signed
5497
+ * in, and then nothing is drawn anywhere.
5498
+ *
5499
+ * IT IS ANSWERED HERE FOR THE REASON `engineFacts` IS. Which engine a row runs
5500
+ * on needs the capability gate, and which ACCOUNT it runs on needs
5501
+ * `_narrowToChosen`, the rule that one account picked in Settings is the only
5502
+ * one the fleet uses. A corner that worked either out for itself could name
5503
+ * the subscription this app is deliberately not spending, which is the one
5504
+ * mistake this line must never make: somebody reads it to find out whose money
5505
+ * is going.
5506
+ *
5507
+ * THE FIRST OF THE POOL, which is the account the next spawn goes to on a Mac
5508
+ * with nothing picked (`_pickProfile` round-robins from there). Two accounts
5509
+ * of the same plan read the same either way; two different plans and a chosen
5510
+ * account make this exact, which is the case somebody would ask about.
5511
+ */
5512
+ runsOnAccount(engine = this._engineFor(null)) {
5513
+ const home = this.config.home;
5514
+ if (engine === 'codex') {
5515
+ const pool = this._narrowToChosen('codex', this._codexProfiles());
5516
+ return runsOn({ engine, home, codexHome: this._codexProfileHome(pool[0] ?? 'default') });
5517
+ }
5518
+ const pool = this._narrowToChosen('claude', this._profiles());
5519
+ return runsOn({ engine: 'claude', home, claudeProfile: pool[0] ?? 'default' });
5520
+ }
5521
+
5376
5522
  /**
5377
5523
  * THE ENGINE ON A TASK SHE IS COMPOSING, OR NULL, and the door writes nothing
5378
5524
  * else (`zero:compose` in main/ipc.mjs).
@@ -5834,7 +5980,7 @@ export class Supervisor {
5834
5980
  const levels = codexModelLevels(slug, { home });
5835
5981
  if (!levels || levels.includes(level)) return null;
5836
5982
  const offers = levels.length ? `It offers ${levels.join(', ')}.` : 'It advertises no levels at all.';
5837
- return `this row asks "${slug}" to think at "${level}", which is not a level that model offers on this Mac. ${offers} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work at a level she did not choose`;
5983
+ return `this row asks "${slug}" to think at "${level}", which is not a level that model offers on this computer. ${offers} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work at a level she did not choose`;
5838
5984
  }
5839
5985
 
5840
5986
  /**
@@ -5914,7 +6060,7 @@ export class Supervisor {
5914
6060
  const asked = from === 'workspace'
5915
6061
  ? `every Codex agent here is set to run on "${word}"`
5916
6062
  : `this row asks to run on "${word}"`;
5917
- return `${asked}, which is not a model the Codex on this Mac knows.${instead} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work on a model she did not choose`;
6063
+ return `${asked}, which is not a model the Codex on this computer knows.${instead} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work on a model she did not choose`;
5918
6064
  }
5919
6065
 
5920
6066
  /**
@@ -5998,7 +6144,7 @@ export class Supervisor {
5998
6144
  // The socket its threads' commands ask on, and no row: see
5999
6145
  // `codexMemoryGateEnv`. One app-server is every Codex thread of this
6000
6146
  // login, so a row named here would be the wrong row for all but one.
6001
- env: { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() },
6147
+ env: codexLaunchEnv(this.config.codexBin, { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() }),
6002
6148
  stdio: ['pipe', 'pipe', 'pipe'],
6003
6149
  }),
6004
6150
  // WHAT IS LEFT OF THIS LOGIN'S LIMIT, ARRIVING UNBIDDEN. `onNotification`
@@ -6250,11 +6396,37 @@ export class Supervisor {
6250
6396
  return;
6251
6397
  }
6252
6398
  if (!this._hasSlotFor(engine) && !continuation) return;
6399
+ // A TIED PROJECT WAITS RATHER THAN BORROWING A LOGIN, and this is where
6400
+ // "tied" stops being a word on a settings page. The engine's own slot check
6401
+ // above has already passed, and it is a check about the whole engine: with
6402
+ // two logins it says yes while the ONE account this project may use is full.
6403
+ // So the pinned account answers for itself, and when it cannot take the work
6404
+ // nothing starts. Waiting is the promise; running it on the other
6405
+ // subscription is the bug the setting exists to stop.
6406
+ //
6407
+ // A REPLY IS HANDED BACK RATHER THAN DROPPED. A continuation skips the slot
6408
+ // check by design, so without the `redeliverAnswer` here the queue's
6409
+ // delivery mark would stand on a reply no worker ever carried, which records
6410
+ // it as handed over for good.
6411
+ //
6412
+ // AND A FORCED PROFILE LOSES. `forcedProfile` is the wake sweep saying which
6413
+ // account it interrupted; if that is not the pinned one, the session being
6414
+ // resumed lives on a login this project may no longer use, so the id goes
6415
+ // with it and the row is briefed fresh on the right account.
6416
+ const pinned = this._projectProfile(item.product, engine);
6417
+ if (pinned) {
6418
+ if (forcedProfile && forcedProfile !== pinned) { forcedProfile = null; resumeSessionId = null; }
6419
+ const full = !continuation && this._loadOnProfile(pinned, engine) >= this._slotsPerAccount(engine);
6420
+ if (full || this._profileResting(this._accountKey(engine, pinned))) {
6421
+ if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
6422
+ return;
6423
+ }
6424
+ }
6253
6425
  // HER REPLY WAITS FOR AN ACCOUNT THAT CAN CARRY IT. A continuation skips the
6254
6426
  // slot check by design, so with every account on this engine sitting out it
6255
6427
  // would go to one anyway, die in two seconds, and go again next tick. Held
6256
6428
  // here and handed back, it goes out the tick the account returns.
6257
- if (continuation && !this._liveProfilesFor(engine).length) {
6429
+ if (continuation && !pinned && !this._liveProfilesFor(engine).length) {
6258
6430
  if (item.answer) this.redeliverAnswer(item, item.answer);
6259
6431
  return;
6260
6432
  }
@@ -6305,7 +6477,12 @@ export class Supervisor {
6305
6477
  // the one path that resumes on every reply the user writes, a personal
6306
6478
  // continuation, fell through to the pick and lost the account half of the
6307
6479
  // time. plan.resumeProfile is that same answer for that path.
6308
- const profile = forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
6480
+ //
6481
+ // A TIE OUTRANKS ALL THREE, and it can because of the two lines above: a
6482
+ // `forcedProfile` or a `resumeProfile` that disagreed with the tie has
6483
+ // already had its session id taken off it, so this is not a resume pointed
6484
+ // at the wrong home, it is a fresh brief on the account the project named.
6485
+ const profile = pinned ?? forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
6309
6486
  // A SECOND ACCOUNT ARRIVES EMPTY, AND THIS IS WHERE IT STOPS BEING EMPTY.
6310
6487
  // Our own Accounts page tells a person to log in with a brand new folder,
6311
6488
  // and Claude Code reads a session's skills, commands and subagents out of
@@ -7546,7 +7723,7 @@ export class Supervisor {
7546
7723
  // EVERY RUN, not only a fresh one: a conversation resumed after the switch
7547
7724
  // went on, and a chat, never heard it otherwise (Codex's review).
7548
7725
  if (cleaner.enabled) {
7549
- parts.push('Anything you leave running after your turn (a dev server, a preview, a background job) is stopped two hours after you finish, sooner if this Mac runs short of memory. '
7726
+ parts.push('Anything you leave running after your turn (a dev server, a preview, a background job) is stopped two hours after you finish, sooner if this computer runs short of memory. '
7550
7727
  + 'If the person needs something to keep running, start it with AGENTBOX_KEEP=1 in its environment and say so in your answer. Stop anything else you started before you finish.');
7551
7728
  }
7552
7729
  const stopping = cleaner.stoppingNote(item.id);
@@ -7644,7 +7821,9 @@ export function unpacked(p) {
7644
7821
  // row, and it used to count as a word: a persona test switched a finished
7645
7822
  // thread to Private and the agent ran again on its own, unasked and paid for.
7646
7823
  // Those fields describe the thread; they never ask it anything.
7647
- const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks']);
7824
+ // Moving an answer out of the inbox is not asking the agent to answer again.
7825
+ // runAt also covers snoozes written before the inbox reminder had its own field.
7826
+ const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks', 'runAt', 'snoozedUntil']);
7648
7827
  function lastFounderWrite(item) {
7649
7828
  let latest = 0;
7650
7829
  for (const [field, w] of Object.entries(item?.wrote ?? {})) {
@@ -194,7 +194,11 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
194
194
  const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
195
195
  const from = branchExists ? branch : baseRef(root);
196
196
  if (!cloneCheckout(root, staging, branch, from, branchExists)) {
197
- const add = branchExists
197
+ // The fast path may have created the branch and then failed the copy.
198
+ // The branch stays: this file never deletes one. The slow path uses it
199
+ // when it is already there, which is what `worktree add -b` would refuse.
200
+ const existsNow = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
201
+ const add = existsNow
198
202
  ? tryGit(root, ['worktree', 'add', staging, branch])
199
203
  : tryGit(root, ['worktree', 'add', '-b', branch, staging, baseRef(root)]);
200
204
  if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
@@ -244,7 +248,20 @@ function clearUnfinished(root, staging) {
244
248
  * commit, down to an empty `git status`, the folder is thrown away and the
245
249
  * ordinary checkout runs. A fast folder that is subtly not the code would be
246
250
  * far worse than a slow one.
251
+ *
252
+ * THE COPY IS THE PLATFORM'S OWN CLONE. macOS `cp -c` is APFS clonefile.
253
+ * GNU `cp -a --reflink=auto` shares blocks where the filesystem can and
254
+ * copies where it cannot. `-c` is not an option there, and a copy that
255
+ * failed used to leave the branch the fast path had just created, so the
256
+ * ordinary checkout then died with "a branch named … already exists".
247
257
  */
258
+ function copyTree(from, to) {
259
+ const args = process.platform === 'darwin'
260
+ ? ['-c', '-R', from, to]
261
+ : ['-a', '--reflink=auto', from, to];
262
+ execFileSync('cp', args, { stdio: ['ignore', 'ignore', 'pipe'] });
263
+ }
264
+
248
265
  function cloneCheckout(root, folder, branch, from, branchExists) {
249
266
  // TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
250
267
  // carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
@@ -266,7 +283,7 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
266
283
  try {
267
284
  for (const entry of fs.readdirSync(root)) {
268
285
  if (entry === '.git' || entry === WORKTREES[0]) continue;
269
- execFileSync('cp', ['-c', '-R', path.join(root, entry), folder], { stdio: ['ignore', 'ignore', 'pipe'] });
286
+ copyTree(path.join(root, entry), folder);
270
287
  }
271
288
  if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
272
289
  const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
@@ -315,7 +332,7 @@ function cloneDependencies(root, folder) {
315
332
  const to = path.join(folder, 'node_modules');
316
333
  if (!fs.existsSync(from) || fs.existsSync(to)) return false;
317
334
  try {
318
- execFileSync('cp', ['-c', '-R', from, to], { stdio: ['ignore', 'ignore', 'pipe'] });
335
+ copyTree(from, to);
319
336
  return true;
320
337
  } catch {
321
338
  try { fs.rmSync(to, { recursive: true, force: true }); } catch { /* nothing to undo */ }
@@ -32,6 +32,17 @@ export function terminalPlace({store,supervisor,product,id,agent}){
32
32
  return {cwd,notice};
33
33
  }
34
34
  export function terminalFolder(args){return terminalPlace(args).cwd;}
35
+ // The shell a person actually has. `$SHELL` when that file exists, then
36
+ // zsh, bash, and sh. A machine with no zsh used to spawn `/bin/zsh` and
37
+ // the terminal never came up.
38
+ export function loginShell(env=process.env,exists=fs.existsSync){
39
+ const preferred=env.SHELL;
40
+ if(preferred&&exists(preferred))return preferred;
41
+ for(const candidate of ['/bin/zsh','/bin/bash','/bin/sh']){
42
+ if(exists(candidate))return candidate;
43
+ }
44
+ return preferred||'/bin/sh';
45
+ }
35
46
  // Enumerate only descendants of our own shell, including background process
36
47
  // groups. Kill children before shell; closing a pane never calls this.
37
48
  export function disposeTerminalProcess(pty){
@@ -69,7 +80,7 @@ const IDLE_MS=600000;
69
80
  const SWEEP_MS=60000;
70
81
  const MAX_TERMINALS=20;
71
82
  export class TaskTerminals {
72
- constructor({spawn,resolve,disposeProcess=disposeTerminalProcess,env=terminalEnv(),shell=process.env.SHELL||'/bin/zsh',idleMs=IDLE_MS,sweepMs=SWEEP_MS,now=()=>Date.now()}={}){
83
+ constructor({spawn,resolve,disposeProcess=disposeTerminalProcess,env=terminalEnv(),shell=loginShell(),idleMs=IDLE_MS,sweepMs=SWEEP_MS,now=()=>Date.now()}={}){
73
84
  this.spawn=spawn??((...args)=>require('node-pty').spawn(...args));this.resolve=resolve;this.disposeProcess=disposeProcess;this.env=env;this.shell=shell;this.sessions=new Map();
74
85
  this.idleMs=idleMs;this.now=now;
75
86
  // Keys whose shell this class ended on its own, and WHY. The next open on
@@ -133,7 +133,7 @@ export function createTeamService({
133
133
  : [[], [], state.me];
134
134
  set({ lastSyncAt: Date.now(), error: null, cards, people, me: me ?? state.me });
135
135
  if (report.joined.length || report.pulled) log(`team: joined ${report.joined.length}, pulled ${report.pulled}, pushed ${report.pushed}`);
136
- if (report.notJoined) log(`team: left ${report.notJoined} shared project(s) unjoined, this Mac already holds the most it takes on by itself`);
136
+ if (report.notJoined) log(`team: left ${report.notJoined} shared project(s) unjoined, this computer already holds the most it takes on by itself`);
137
137
  return report;
138
138
  } catch (err) {
139
139
  set({ error: String(err?.message ?? err) });
@@ -320,7 +320,7 @@ export function createTeamService({
320
320
  // already in a team does not move: that is a choice nobody offers here.
321
321
  async acceptInvite(teamId) {
322
322
  if (!backend || !state.me) throw new Error('sign in first');
323
- if (state.team) throw new Error(`this Mac is already in ${state.team.name}`);
323
+ if (state.team) throw new Error(`this computer is already in ${state.team.name}`);
324
324
  if (!state.invites.some((i) => i.teamId === teamId)) throw new Error('that invite is no longer waiting');
325
325
  await backend.acceptInvite(teamId);
326
326
  kept().setTeam(state.me.id, teamId);
@@ -100,7 +100,7 @@ export function markShared(dir, { teamId, visibility = 'team', people = [], shar
100
100
  if (!project) throw new Error(`no project.json in ${dir}`);
101
101
  // Sharing it would put every line of it in the cloud, which is the one thing
102
102
  // this project exists to make hard. A thread in it is shared one at a time.
103
- if (project[PERSONAL_FLAG] === true) throw new Error(`${PERSONAL_NAME} stays on this Mac. Share a thread in it instead.`);
103
+ if (project[PERSONAL_FLAG] === true) throw new Error(`${PERSONAL_NAME} stays on this computer. Share a thread in it instead.`);
104
104
  const projectId = project.team?.projectId || crypto.randomUUID();
105
105
  project.team = {
106
106
  projectId, teamId, visibility: visibility === 'people' ? 'people' : 'team', people: [...new Set(people)],
package/mcp/core/page.mjs CHANGED
@@ -87,7 +87,7 @@ export async function lookAtPage({ product, url, width = 1280, height = 900, cap
87
87
  * stored in the browser, and it is thrown away with the profile directory.
88
88
  */
89
89
  export async function captureWithChrome({ url, width, height, chrome = findChrome(), timeoutMs = 20_000 }) {
90
- if (!chrome) return { error: 'No Chrome, Chromium or Edge on this Mac, so there is nothing to open the page with.' };
90
+ if (!chrome) return { error: 'No Chrome, Chromium or Edge on this computer, so there is nothing to open the page with.' };
91
91
  const profile = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'look-at-page-'));
92
92
  const child = spawn(chrome, [
93
93
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`,
package/mcp/core/work.mjs CHANGED
@@ -167,6 +167,22 @@ export function saidToClose(item, words) {
167
167
  return [item?.answer, item?.body, item?.title].some((t) => plainly(t).includes(quote));
168
168
  }
169
169
 
170
+ /*
171
+ * A PUT-OFF THE USER ASKED FOR IS THEIRS (w-d6ea290028).
172
+ *
173
+ * A runAt an agent writes brakes workers and never hides the row
174
+ * (`parkedByAgent`, list-rules.ts), so an agent cannot take its own question
175
+ * out of the inbox. But "remind me in 4 days" asks for exactly that, and the
176
+ * row sat in Needs you wearing Scheduled until Sunday, with the user unsure
177
+ * whether closing it would lose the reminder.
178
+ *
179
+ * So a worker that quotes the user's own words asking for the put-off, in
180
+ * `deferBecause`, checked exactly as `closeBecause` is, has the moment written
181
+ * as the user's. That is what hides a row until it comes due, puts it in
182
+ * Scheduled, and lets the user's L move it again.
183
+ */
184
+ const DEFERRED_AS_AGENT = 'The put-off was saved as YOURS, not theirs, so the row stays in their inbox until then: the words in deferBecause are not ones they wrote on this row. If they did ask for it to be put off, send runAt again with deferBecause set to their exact words.';
185
+
170
186
  /*
171
187
  * A SUMMARY LINE IS SHORT ENOUGH TO READ (w-b51b2e1c86). An agent's problem,
172
188
  * progress or solution over SUMMARY_WORDS words is taken out of the write and
@@ -356,7 +372,7 @@ export function createClaimRegistry({ heartbeatMs = HEARTBEAT_MS, holder } = {})
356
372
  // fence below still catches the case where somebody else took over.
357
373
  }
358
374
 
359
- const { closeBecause, ...asked } = patch;
375
+ const { closeBecause, deferBecause, ...asked } = patch;
360
376
  let leftOpen = false;
361
377
  if (asked.status === 'done') {
362
378
  const before = readWorkItem(holding.dir, id);
@@ -365,6 +381,17 @@ export function createClaimRegistry({ heartbeatMs = HEARTBEAT_MS, holder } = {})
365
381
  leftOpen = true;
366
382
  }
367
383
  }
384
+ let theirMoment = 0;
385
+ let deferredAsAgent = null;
386
+ if (deferBecause !== undefined && Number.isFinite(asked.runAt) && asked.runAt > 0) {
387
+ const before = readWorkItem(holding.dir, id);
388
+ if (herRow(before) && saidToClose(before, deferBecause)) {
389
+ theirMoment = asked.runAt;
390
+ delete asked.runAt;
391
+ } else {
392
+ deferredAsAgent = { deferredAsAgent: DEFERRED_AS_AGENT };
393
+ }
394
+ }
368
395
  const summaryTooLong = takeOverLong(asked);
369
396
  patch = asked;
370
397
  let item = Object.keys(patch).length
@@ -389,6 +416,10 @@ export function createClaimRegistry({ heartbeatMs = HEARTBEAT_MS, holder } = {})
389
416
  holding.pending = false;
390
417
  }
391
418
 
419
+ // Only now, past the fence, so a session that lost the row cannot still
420
+ // put it away in the user's name.
421
+ if (theirMoment) item = updateWorkItem(holding.dir, id, { runAt: theirMoment }, { source: 'founder' });
422
+
392
423
  // FINISHING A ROW GIVES THE CLAIM BACK NOW, not in five minutes. It used
393
424
  // to drop the row from this map and leave the claim line standing, so the
394
425
  // ledger went on reading "held by <this session>" for a full lease after
@@ -411,9 +442,9 @@ export function createClaimRegistry({ heartbeatMs = HEARTBEAT_MS, holder } = {})
411
442
  holding.live = false;
412
443
  stopTimer();
413
444
  }
414
- return { ...item, leftOpen: LEFT_OPEN, ...summaryTooLong };
445
+ return { ...item, leftOpen: LEFT_OPEN, ...deferredAsAgent, ...summaryTooLong };
415
446
  }
416
- return { ...item, ...summaryTooLong };
447
+ return { ...item, ...deferredAsAgent, ...summaryTooLong };
417
448
  },
418
449
 
419
450
  release(id) {
package/mcp/tools.mjs CHANGED
@@ -191,7 +191,7 @@ export function buildTools({ holder } = {}) {
191
191
  },
192
192
  {
193
193
  name: 'update_work_item',
194
- description: 'Report progress on an item: set status to done when it is finished, blocked when it cannot proceed until something outside it changes, and leave a note saying where things stand. Keep the summary current with problem (shown as Context) and progress (shown as the Done list), which is all a teammate sees of the thread. A worker the app started for a row ends with its answer as its last message, and the app writes that message onto the row as the result, so such a worker does not pass result for its own row: the same answer written twice is what they then read twice. A row the founder wrote (labelled founder) is closed only when they asked for it: answering them is not finishing it, so the row stays open for them. To close one, pass closeBecause with their exact words asking you to; without them the rest of the write lands and the row is left open. Writing to a row this session is not holding takes the row first, so there is no need to claim before you speak; the write is only refused when another session is genuinely on it, and that refusal is kept rather than thrown away, parked where the error says and handed to whoever takes the row next. Finishing a row hands the claim straight back, and you can still answer on it afterwards.',
194
+ description: 'Report progress on an item: set status to done when it is finished, blocked when it cannot proceed until something outside it changes, and leave a note saying where things stand. Keep the summary current with problem (shown as Context) and progress (shown as the Done list), which is all a teammate sees of the thread. A worker the app started for a row ends with its answer as its last message, and the app writes that message onto the row as the result, so such a worker does not pass result for its own row: the same answer written twice is what they then read twice. A row the founder wrote (labelled founder) is closed only when they asked for it: answering them is not finishing it, so the row stays open for them. To close one, pass closeBecause with their exact words asking you to; without them the rest of the write lands and the row is left open. A runAt you set holds workers off but keeps the row in their inbox; when they asked for it to be put off ("remind me Friday"), pass deferBecause with their exact words and it leaves their inbox until then. Writing to a row this session is not holding takes the row first, so there is no need to claim before you speak; the write is only refused when another session is genuinely on it, and that refusal is kept rather than thrown away, parked where the error says and handed to whoever takes the row next. Finishing a row hands the claim straight back, and you can still answer on it afterwards.',
195
195
  schema: {
196
196
  id: z.string(),
197
197
  status: z.enum(WORK_ITEM_STATUSES).optional(),
@@ -201,6 +201,7 @@ export function buildTools({ holder } = {}) {
201
201
  labels: z.array(z.string()).optional(),
202
202
  runAt: z.number().int().optional().describe('epoch ms to defer this item until; 0 clears a schedule and makes it available now'),
203
203
  closeBecause: z.string().optional().describe('only with status done on a row the founder wrote: their own words, quoted exactly from the row, asking you to close it'),
204
+ deferBecause: z.string().optional().describe('only with runAt on a row the founder wrote: their own words, quoted exactly from the row, asking for it to be put off or to be reminded later. Without them the row stays in their inbox'),
204
205
  problem: z.string().optional().describe(`the summary's CONTEXT: a few plain sentences, ${CONTEXT_WORDS} words at most, that bring the thread back to someone who has forgotten it: what was asked, what was found, the facts needed to decide`),
205
206
  progress: z.string().optional().describe(`the summary's DONE list: the steps taken so far, one per line, oldest first and newest last, ${DONE_STEPS} lines at most of ${DONE_STEP_WORDS} words each. Send the whole list each time; it replaces the last one`),
206
207
  solution: z.string().optional().describe(`no longer shown; leave it out (older threads may still carry one, ${SUMMARY_WORDS} words at most)`),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentbox-app",
3
3
  "productName": "Agentbox",
4
- "version": "0.1.11",
4
+ "version": "0.1.12",
5
5
  "description": "An inbox for managing dozens of Claude Code and Codex agents.",
6
6
  "author": "Savannah Feder",
7
7
  "repository": {