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.
- package/README.md +24 -16
- package/bin/agentbox.mjs +18 -9
- package/briefs/message-rules.md +15 -4
- package/main/account-tooling.mjs +2 -1
- package/main/agent-updates.mjs +3 -1
- package/main/approvals.mjs +14 -5
- package/main/claude-bin.mjs +1 -0
- package/main/claude-plan.mjs +14 -2
- package/main/codex-bin.mjs +1 -0
- package/main/codex-launch-env.mjs +31 -0
- package/main/config.mjs +14 -9
- package/main/demo.mjs +17 -14
- package/main/engine-setup.mjs +23 -12
- package/main/fresh-user.mjs +1 -1
- package/main/git-change.mjs +8 -2
- package/main/instruction-settings.mjs +47 -11
- package/main/ipc.mjs +19 -0
- package/main/machine.mjs +1 -1
- package/main/main.mjs +20 -1
- package/main/memory-gate.mjs +1 -1
- package/main/notify.mjs +1 -1
- package/main/refresh-agent-models.mjs +2 -1
- package/main/row-label.mjs +2 -1
- package/main/runs-on.mjs +93 -0
- package/main/serve.mjs +11 -1
- package/main/settings.mjs +23 -1
- package/main/ship-queue.mjs +1 -1
- package/main/store/home.mjs +37 -5
- package/main/store/project.mjs +15 -19
- package/main/store.mjs +10 -4
- package/main/supervisor.mjs +205 -26
- package/main/task-folders.mjs +20 -3
- package/main/task-terminals.mjs +12 -1
- package/main/team/index.mjs +2 -2
- package/main/team/projects.mjs +1 -1
- package/mcp/core/page.mjs +1 -1
- package/mcp/core/work.mjs +34 -3
- package/mcp/tools.mjs +2 -1
- package/package.json +1 -1
- package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
- package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +7 -2
- package/shared/agent-import.mjs +1 -1
- package/shared/bridge-map.mjs +1 -0
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +4 -4
- package/shared/codex-modes.mjs +1 -1
- package/shared/demo-world.mjs +1 -1
- package/shared/fresh-user-home.mjs +12 -1
- package/shared/instruction-defaults.json +1 -1
- package/shared/project-folder-check.mjs +3 -3
- package/shared/runs-on.d.mts +20 -0
- package/shared/runs-on.mjs +79 -0
- package/shared/shortcuts.mjs +1 -1
- package/shared/spawn-trouble.mjs +1 -1
- package/shared/work-items.mjs +3 -1
- package/renderer/dist/assets/index-B-2Az952.css +0 -32
- package/renderer/dist/assets/index-D6qncgai.js +0 -1168
package/main/supervisor.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
/**
|
|
2674
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ?? {})) {
|
package/main/task-folders.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 */ }
|
package/main/task-terminals.mjs
CHANGED
|
@@ -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=
|
|
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
|
package/main/team/index.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/main/team/projects.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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)`),
|