sideby 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +332 -9
- package/dist/src/cli/format.d.ts +3 -1
- package/dist/src/cli/format.js +25 -0
- package/dist/src/cli/json-schemas.js +114 -0
- package/dist/src/cli/main.d.ts +5 -0
- package/dist/src/cli/main.js +40 -8
- package/dist/src/core/config.d.ts +40 -0
- package/dist/src/core/config.js +83 -11
- package/dist/src/core/file-memo.d.ts +23 -0
- package/dist/src/core/file-memo.js +37 -0
- package/dist/src/core/handoff-brief.d.ts +17 -0
- package/dist/src/core/handoff-brief.js +75 -0
- package/dist/src/core/handoff-chain.d.ts +74 -0
- package/dist/src/core/handoff-chain.js +74 -0
- package/dist/src/core/handoff-doctor.d.ts +13 -0
- package/dist/src/core/handoff-doctor.js +35 -0
- package/dist/src/core/handoff-run.d.ts +31 -0
- package/dist/src/core/handoff-run.js +346 -0
- package/dist/src/core/handoff-select.d.ts +42 -0
- package/dist/src/core/handoff-select.js +88 -0
- package/dist/src/core/handoff-status.d.ts +66 -0
- package/dist/src/core/handoff-status.js +198 -0
- package/dist/src/core/launch.d.ts +15 -0
- package/dist/src/core/launch.js +25 -2
- package/dist/src/core/memo-store.d.ts +20 -0
- package/dist/src/core/memo-store.js +92 -0
- package/dist/src/core/resume.d.ts +7 -2
- package/dist/src/core/resume.js +22 -9
- package/dist/src/core/tuning-apply.d.ts +63 -0
- package/dist/src/core/tuning-apply.js +264 -0
- package/dist/src/core/tuning.d.ts +113 -0
- package/dist/src/core/tuning.js +192 -0
- package/dist/src/families/claude/brief.d.ts +5 -0
- package/dist/src/families/claude/brief.js +63 -0
- package/dist/src/families/claude/handoff-hook.d.ts +2 -0
- package/dist/src/families/claude/handoff-hook.js +31 -0
- package/dist/src/families/claude/handoff.d.ts +5 -0
- package/dist/src/families/claude/handoff.js +115 -0
- package/dist/src/families/claude/index.js +8 -1
- package/dist/src/families/claude/tuning.d.ts +22 -0
- package/dist/src/families/claude/tuning.js +203 -0
- package/dist/src/families/claude/usage.d.ts +1 -0
- package/dist/src/families/claude/usage.js +2 -2
- package/dist/src/families/codex/brief.d.ts +5 -0
- package/dist/src/families/codex/brief.js +49 -0
- package/dist/src/families/codex/handoff-hook.d.ts +2 -0
- package/dist/src/families/codex/handoff-hook.js +16 -0
- package/dist/src/families/codex/handoff.d.ts +5 -0
- package/dist/src/families/codex/handoff.js +121 -0
- package/dist/src/families/codex/index.js +4 -0
- package/dist/src/families/codex/rate-limits.d.ts +16 -0
- package/dist/src/families/codex/rate-limits.js +92 -0
- package/dist/src/families/codex/rollout.d.ts +1 -2
- package/dist/src/families/codex/rollout.js +4 -48
- package/dist/src/families/codex/tuning.d.ts +16 -0
- package/dist/src/families/codex/tuning.js +145 -0
- package/dist/src/families/grok/brief.d.ts +5 -0
- package/dist/src/families/grok/brief.js +74 -0
- package/dist/src/families/grok/handoff-hook.d.ts +4 -0
- package/dist/src/families/grok/handoff-hook.js +26 -0
- package/dist/src/families/grok/handoff.d.ts +47 -0
- package/dist/src/families/grok/handoff.js +219 -0
- package/dist/src/families/grok/index.js +2 -0
- package/dist/src/families/pi/handoff.d.ts +2 -0
- package/dist/src/families/pi/handoff.js +13 -0
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/families/shared/brief.d.ts +24 -0
- package/dist/src/families/shared/brief.js +42 -0
- package/dist/src/families/shared/handoff-args.d.ts +18 -0
- package/dist/src/families/shared/handoff-args.js +57 -0
- package/dist/src/families/shared/sessions.d.ts +4 -0
- package/dist/src/families/shared/sessions.js +19 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +30 -0
- package/dist/src/handoff/hook.d.ts +16 -0
- package/dist/src/handoff/hook.js +166 -0
- package/dist/src/handoff/messages.d.ts +7 -0
- package/dist/src/handoff/messages.js +27 -0
- package/dist/src/handoff/reader.d.ts +11 -0
- package/dist/src/handoff/reader.js +9 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +110 -0
- package/dist/src/panel/i18n.d.ts +98 -0
- package/dist/src/panel/i18n.js +196 -0
- package/dist/src/panel/page-logic.d.ts +49 -0
- package/dist/src/panel/page-logic.js +67 -0
- package/dist/src/panel/page-script.js +461 -4
- package/dist/src/panel/page-styles.js +28 -2
- package/dist/src/panel/page.js +4 -1
- package/dist/src/quota/claude-statusline.js +4 -7
- package/dist/src/runtime.d.ts +40 -3
- package/dist/src/runtime.js +212 -41
- package/dist/src/types.d.ts +93 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +1 -0
- package/package.json +16 -4
- package/schemas/config.json +73 -0
- package/schemas/families.json +5 -1
- package/schemas/handoff-disable.json +106 -0
- package/schemas/handoff-enable.json +106 -0
- package/schemas/handoff-ready.json +23 -0
- package/schemas/handoff-setup.json +60 -0
- package/schemas/handoff-status.json +137 -0
- package/schemas/handoff-teardown.json +30 -0
- package/schemas/list.json +5 -1
- package/schemas/tune-change.json +126 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/badge.md +1 -1
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/README.zh-CN.md +0 -112
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Environment sideby sets for a Host run that may start a Handoff. The hook does nothing without all three. */
|
|
2
|
+
export declare const HANDOFF_ENV: {
|
|
3
|
+
readonly run: 'SIDEBY_HANDOFF_RUN';
|
|
4
|
+
readonly family: 'SIDEBY_HANDOFF_FAMILY';
|
|
5
|
+
readonly dir: 'SIDEBY_HANDOFF_DIR';
|
|
6
|
+
};
|
|
7
|
+
export type HandoffTrigger = 'threshold' | 'limit' | 'manual';
|
|
8
|
+
/** What the parent decided the next Handoff would do, refreshed while the Host runs. */
|
|
9
|
+
export interface PlanFile {
|
|
10
|
+
/** The run this plan is for; a plan left by an earlier hop of the chain is ignored. */
|
|
11
|
+
runId: string;
|
|
12
|
+
/** Where this run's session writes its Handoff Brief. */
|
|
13
|
+
brief: string;
|
|
14
|
+
at: string;
|
|
15
|
+
decision: 'pick' | 'wait' | 'none';
|
|
16
|
+
pick?: string;
|
|
17
|
+
prepareAt: number;
|
|
18
|
+
threshold: number;
|
|
19
|
+
/** False when the session cannot write its Brief: no preparing step, and the threshold only ends the turn. */
|
|
20
|
+
agentBrief?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** What the hook already did in one run. */
|
|
23
|
+
export interface RunState {
|
|
24
|
+
prepareSentAt?: string;
|
|
25
|
+
thresholdSentAt?: string;
|
|
26
|
+
stopBlocked?: boolean;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
transcriptPath?: string;
|
|
29
|
+
}
|
|
30
|
+
/** The run is ready to hand off; written by the hook (`ready.json`), or asked for by `sideby handoff ready`. */
|
|
31
|
+
export interface ReadyFile {
|
|
32
|
+
runId: string;
|
|
33
|
+
at: string;
|
|
34
|
+
trigger: HandoffTrigger;
|
|
35
|
+
/** The Brief file, when the session or the user wrote one. */
|
|
36
|
+
brief?: string;
|
|
37
|
+
briefSource?: 'agent' | 'user';
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
transcriptPath?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ChainHop {
|
|
42
|
+
ref: string;
|
|
43
|
+
startedAt: string;
|
|
44
|
+
/** How this hop ended, when it handed off. */
|
|
45
|
+
trigger?: HandoffTrigger;
|
|
46
|
+
briefSource?: 'agent' | 'sideby' | 'user';
|
|
47
|
+
brief?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ChainFile {
|
|
50
|
+
hops: ChainHop[];
|
|
51
|
+
}
|
|
52
|
+
export declare const FILES: {
|
|
53
|
+
readonly plan: 'plan.json';
|
|
54
|
+
readonly ready: 'ready.json';
|
|
55
|
+
readonly request: 'request.json';
|
|
56
|
+
readonly chain: 'chain.json';
|
|
57
|
+
readonly run: (runId: string) => string;
|
|
58
|
+
};
|
|
59
|
+
/** A short random id for a chain or a run: safe in file names and environment values. */
|
|
60
|
+
export declare function newId(): string;
|
|
61
|
+
export declare function chainDir(stateDir: string, chain: string): string;
|
|
62
|
+
/** Creates the chain directory (and its parents) and makes sure it is mode 700. */
|
|
63
|
+
export declare function ensureChainDir(dir: string): Promise<void>;
|
|
64
|
+
/** `<dir>/<n>-<ref>.md`, with the ref's `:` made safe for file names (`claude:001` → `claude-001`). */
|
|
65
|
+
export declare function briefPath(dir: string, n: number, ref: string): string;
|
|
66
|
+
/** The parsed JSON object in `path`, or null when it is missing, unreadable or not a JSON object. */
|
|
67
|
+
export declare function readJsonFile<T>(path: string): Promise<T | null>;
|
|
68
|
+
export declare function writeJsonFile(path: string, value: unknown): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
|
|
71
|
+
* started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600).
|
|
72
|
+
* Returns the request, or null outside such a run.
|
|
73
|
+
*/
|
|
74
|
+
export declare function requestHandoff(env: Readonly<Record<string, string | undefined>>, briefFile?: string): Promise<ReadyFile | null>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The files one Handoff chain keeps under `<state>/handoffs/<chain>/` (spec §3.17). The handoff hook runs on every
|
|
2
|
+
// tool call, so this module imports only Node built-ins and leaf modules (never the Runtime or a Family index).
|
|
3
|
+
// The parent `sideby run` writes `plan.json` and `chain.json`; the hook writes `run-<id>.json` and `ready.json`;
|
|
4
|
+
// `sideby handoff ready` writes `request.json`. Every file is mode 600 in a mode 700 directory.
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { chmod, mkdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { writeFileAtomic } from './fs-safe.js';
|
|
9
|
+
/** Environment sideby sets for a Host run that may start a Handoff. The hook does nothing without all three. */
|
|
10
|
+
export const HANDOFF_ENV = {
|
|
11
|
+
run: 'SIDEBY_HANDOFF_RUN',
|
|
12
|
+
family: 'SIDEBY_HANDOFF_FAMILY',
|
|
13
|
+
dir: 'SIDEBY_HANDOFF_DIR',
|
|
14
|
+
};
|
|
15
|
+
export const FILES = {
|
|
16
|
+
plan: 'plan.json',
|
|
17
|
+
ready: 'ready.json',
|
|
18
|
+
request: 'request.json',
|
|
19
|
+
chain: 'chain.json',
|
|
20
|
+
run: (runId) => `run-${runId}.json`,
|
|
21
|
+
};
|
|
22
|
+
/** A short random id for a chain or a run: safe in file names and environment values. */
|
|
23
|
+
export function newId() {
|
|
24
|
+
return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
export function chainDir(stateDir, chain) {
|
|
27
|
+
return join(stateDir, 'handoffs', chain);
|
|
28
|
+
}
|
|
29
|
+
/** Creates the chain directory (and its parents) and makes sure it is mode 700. */
|
|
30
|
+
export async function ensureChainDir(dir) {
|
|
31
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
await chmod(dir, 0o700);
|
|
33
|
+
}
|
|
34
|
+
/** `<dir>/<n>-<ref>.md`, with the ref's `:` made safe for file names (`claude:001` → `claude-001`). */
|
|
35
|
+
export function briefPath(dir, n, ref) {
|
|
36
|
+
return join(dir, `${n}-${ref.replace(/[^A-Za-z0-9._-]/g, '-')}.md`);
|
|
37
|
+
}
|
|
38
|
+
/** The parsed JSON object in `path`, or null when it is missing, unreadable or not a JSON object. */
|
|
39
|
+
export async function readJsonFile(path) {
|
|
40
|
+
try {
|
|
41
|
+
const v = JSON.parse(await readFile(path, 'utf8'));
|
|
42
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function writeJsonFile(path, value) {
|
|
49
|
+
await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`, 0o600);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
|
|
53
|
+
* started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600).
|
|
54
|
+
* Returns the request, or null outside such a run.
|
|
55
|
+
*/
|
|
56
|
+
export async function requestHandoff(env, briefFile) {
|
|
57
|
+
const runId = env[HANDOFF_ENV.run];
|
|
58
|
+
const dir = env[HANDOFF_ENV.dir];
|
|
59
|
+
if (!runId || !dir)
|
|
60
|
+
return null;
|
|
61
|
+
let brief;
|
|
62
|
+
if (briefFile) {
|
|
63
|
+
brief = join(dir, `user-${runId}.md`);
|
|
64
|
+
await writeFileAtomic(brief, await readFile(briefFile), 0o600);
|
|
65
|
+
}
|
|
66
|
+
const request = {
|
|
67
|
+
runId,
|
|
68
|
+
at: new Date().toISOString(),
|
|
69
|
+
trigger: 'manual',
|
|
70
|
+
...(brief ? { brief, briefSource: 'user' } : {}),
|
|
71
|
+
};
|
|
72
|
+
await writeJsonFile(join(dir, FILES.request), request);
|
|
73
|
+
return request;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Account, Finding } from '../types.ts';
|
|
2
|
+
import type { HandoffSettings } from './config.ts';
|
|
3
|
+
export declare function handoffFindings(input: {
|
|
4
|
+
settings: HandoffSettings;
|
|
5
|
+
/** Family ids this Doctor run checks. */
|
|
6
|
+
families: readonly string[];
|
|
7
|
+
/** Every Account on disk, to resolve `order` entries in any Family. */
|
|
8
|
+
accounts: readonly Account[];
|
|
9
|
+
aliases: Readonly<Record<string, string>>;
|
|
10
|
+
/** Whether Claude's quota tap is on; undefined when Claude is not part of the run. */
|
|
11
|
+
claudeTap?: boolean;
|
|
12
|
+
home: string;
|
|
13
|
+
}): Promise<Finding[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Doctor warnings for Auto Handoff (spec §3.17): only when `handoff.auto` is on, never a failure, never fixed.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { statOrNull } from './fs-safe.js';
|
|
4
|
+
export async function handoffFindings(input) {
|
|
5
|
+
const { settings: s } = input;
|
|
6
|
+
if (!s.auto)
|
|
7
|
+
return [];
|
|
8
|
+
const out = [];
|
|
9
|
+
const warn = (account, code, message, hint) => ({
|
|
10
|
+
level: 'warn',
|
|
11
|
+
account,
|
|
12
|
+
item: 'handoff',
|
|
13
|
+
code,
|
|
14
|
+
message,
|
|
15
|
+
hint,
|
|
16
|
+
source: 'core',
|
|
17
|
+
fixable: false,
|
|
18
|
+
});
|
|
19
|
+
const has = (family) => input.families.includes(family) && input.accounts.some((a) => a.family === family);
|
|
20
|
+
if (has('claude') && input.claudeTap === false)
|
|
21
|
+
out.push(warn('claude:main', 'handoff.no-quota-tap', 'Auto Handoff is on, but Claude Code reports no quota to sideby, so Claude sessions never hand over', 'run `sideby quota setup claude` (it shows the change first)'));
|
|
22
|
+
if (has('grok') && !(await statOrNull(join(input.home, '.grok', 'hooks', 'sideby-handoff.json'))))
|
|
23
|
+
out.push(warn('grok:main', 'handoff.setup-missing', 'Auto Handoff is on, but Grok has no sideby hook file, so a Grok session that hits its limit cannot hand over', 'run `sideby handoff setup grok`, then `sideby doctor grok --fix` to copy it to every Grok account'));
|
|
24
|
+
const refs = new Set(input.accounts.map((a) => a.ref));
|
|
25
|
+
for (const [family, f] of Object.entries(s.families)) {
|
|
26
|
+
if (!input.families.includes(family))
|
|
27
|
+
continue;
|
|
28
|
+
if (f.policy === 'order' && f.order.length === 0)
|
|
29
|
+
out.push(warn(`${family}:main`, 'handoff.order-empty', `handoff.families.${family} has policy "order" but no order, so ${family} sessions never hand over`, `list the accounts in handoff.families.${family}.order, or drop the policy to rank by quota`));
|
|
30
|
+
const unknown = f.order.filter((name) => !refs.has(input.aliases[name] ?? name));
|
|
31
|
+
if (unknown.length)
|
|
32
|
+
out.push(warn(`${family}:main`, 'handoff.order-unknown', `handoff.families.${family}.order names ${unknown.join(', ')}, which sideby cannot find; they are skipped`, 'fix the names in the config, or create the account with `sideby new`'));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
import type { Env } from '../types.ts';
|
|
3
|
+
import type { HandoffDecision } from './handoff-select.ts';
|
|
4
|
+
export interface HandoffIo {
|
|
5
|
+
err(line: string): void;
|
|
6
|
+
stdin: NodeJS.ReadableStream & {
|
|
7
|
+
isTTY?: boolean;
|
|
8
|
+
setRawMode?: (on: boolean) => unknown;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** How long the Host gets to exit after SIGTERM (time for its SessionEnd hooks) before sideby gives up. */
|
|
12
|
+
export declare const EXIT_GRACE_MS = 10000;
|
|
13
|
+
export declare function explain(d: HandoffDecision): string;
|
|
14
|
+
/** `args` with `extra` before the first `--`, so a user's own `--` cannot turn sideby's options into prompt text. */
|
|
15
|
+
export declare function beforeDashes(args: readonly string[], extra: readonly string[]): string[];
|
|
16
|
+
/** Whether the `sideby` on PATH has the hook entry: an older install would fail every hook call. */
|
|
17
|
+
export declare function hookEntryWorks(env: Env): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Runs `ref` with Auto Handoff when the config, the Family, the run and the terminal allow it, and returns the exit
|
|
20
|
+
* code of the last Host; null when Auto Handoff does not apply and the caller should run the Host as usual.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runWithHandoff(rt: Runtime, ref: string, userArgs: readonly string[], io: HandoffIo,
|
|
23
|
+
/** For tests: how long the Host gets to exit after SIGTERM. */
|
|
24
|
+
opts?: {
|
|
25
|
+
exitGraceMs?: number;
|
|
26
|
+
}): Promise<number | null>;
|
|
27
|
+
/**
|
|
28
|
+
* For `sideby handoff ready`: where a Handoff from the session running under `env` would go now, using the chain's
|
|
29
|
+
* Accounts so far; null when `env` is not a run sideby started with Auto Handoff.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readyDecision(rt: Runtime, env: Env): Promise<HandoffDecision | null>;
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Auto Handoff (spec §3.17, ADR-0010): `sideby run` stays the Host's parent. The Host's hooks write `ready.json`
|
|
2
|
+
// when the session should hand over; then sideby ends the Host, finishes the Brief, counts down, and starts the next
|
|
3
|
+
// Account in the same terminal with a new session that reads the Brief. Nothing here reads a credential.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { watch } from 'node:fs';
|
|
6
|
+
import { rm } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { HOOK_FAMILIES } from '../handoff/hook.js';
|
|
9
|
+
import { firstPrompt } from '../handoff/messages.js';
|
|
10
|
+
import { aliasArgs } from './config.js';
|
|
11
|
+
import { finishBrief } from './handoff-brief.js';
|
|
12
|
+
import { briefPath, chainDir, ensureChainDir, FILES, HANDOFF_ENV, newId, readJsonFile, writeJsonFile, } from './handoff-chain.js';
|
|
13
|
+
import { runHost, sidebyBinProblem, spawnHost } from './launch.js';
|
|
14
|
+
import { quotaPressure } from './quota-levels.js';
|
|
15
|
+
/** How long the Host gets to exit after SIGTERM (time for its SessionEnd hooks) before sideby gives up. */
|
|
16
|
+
export const EXIT_GRACE_MS = 10_000;
|
|
17
|
+
const PLAN_REFRESH_MS = 60_000;
|
|
18
|
+
const READY_POLL_MS = 1000;
|
|
19
|
+
const hookCommand = (family) => (event) => `sideby handoff-hook ${family} ${event}`;
|
|
20
|
+
const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
21
|
+
export function explain(d) {
|
|
22
|
+
if (d.kind === 'wait')
|
|
23
|
+
return `the full window resets at ${clock(d.until)}, so waiting beats moving on`;
|
|
24
|
+
if (d.kind === 'none')
|
|
25
|
+
return d.earliestReset
|
|
26
|
+
? `${d.reason}; ${d.earliestReset.ref} is back first, at ${clock(d.earliestReset.at)}`
|
|
27
|
+
: d.reason;
|
|
28
|
+
return `next: ${d.ref}`;
|
|
29
|
+
}
|
|
30
|
+
/** Resolves with the ready file once the hook writes one for `runId`; `stop()` ends the watch. */
|
|
31
|
+
function watchReady(dir, runId) {
|
|
32
|
+
let stop = () => { };
|
|
33
|
+
const ready = new Promise((resolve) => {
|
|
34
|
+
const check = async () => {
|
|
35
|
+
const r = await readJsonFile(join(dir, FILES.ready));
|
|
36
|
+
if (r && r.runId === runId) {
|
|
37
|
+
stop();
|
|
38
|
+
resolve(r);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const timer = setInterval(() => void check(), READY_POLL_MS);
|
|
42
|
+
let watcher;
|
|
43
|
+
try {
|
|
44
|
+
watcher = watch(dir, () => void check());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// polling alone still works
|
|
48
|
+
}
|
|
49
|
+
stop = () => {
|
|
50
|
+
clearInterval(timer);
|
|
51
|
+
watcher?.close();
|
|
52
|
+
};
|
|
53
|
+
void check();
|
|
54
|
+
});
|
|
55
|
+
return { ready, stop: () => stop() };
|
|
56
|
+
}
|
|
57
|
+
const WHY = {
|
|
58
|
+
threshold: 'its quota reached the hand-over level',
|
|
59
|
+
limit: 'it hit its usage limit',
|
|
60
|
+
manual: 'you asked to hand over',
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Counts down; resolves true when the person pressed Enter to cancel. On a terminal the line is rewritten in place,
|
|
64
|
+
* elsewhere each second is a line of its own.
|
|
65
|
+
*/
|
|
66
|
+
function countdown(io, seconds, to, from) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let left = seconds;
|
|
69
|
+
const raw = typeof io.stdin.setRawMode === 'function';
|
|
70
|
+
const inPlace = process.stderr.isTTY === true;
|
|
71
|
+
const done = (cancelled) => {
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
io.stdin.off('data', onData);
|
|
74
|
+
if (raw)
|
|
75
|
+
io.stdin.setRawMode(false);
|
|
76
|
+
io.stdin.pause();
|
|
77
|
+
if (inPlace)
|
|
78
|
+
process.stderr.write('\n');
|
|
79
|
+
resolve(cancelled);
|
|
80
|
+
};
|
|
81
|
+
const onData = (chunk) => {
|
|
82
|
+
const s = chunk.toString();
|
|
83
|
+
if (s.includes('\r') || s.includes('\n'))
|
|
84
|
+
done(true);
|
|
85
|
+
// Ctrl-C while counting down cancels as well.
|
|
86
|
+
else if (s.includes('\u0003'))
|
|
87
|
+
done(true);
|
|
88
|
+
};
|
|
89
|
+
const tick = () => {
|
|
90
|
+
const line = `sideby: starting ${to} in ${left}s; press Enter to stay in ${from}`;
|
|
91
|
+
if (inPlace)
|
|
92
|
+
process.stderr.write(`\r${line}\x1b[K`);
|
|
93
|
+
else
|
|
94
|
+
io.err(line);
|
|
95
|
+
if (left-- <= 0)
|
|
96
|
+
done(false);
|
|
97
|
+
};
|
|
98
|
+
if (raw)
|
|
99
|
+
io.stdin.setRawMode(true);
|
|
100
|
+
io.stdin.resume();
|
|
101
|
+
io.stdin.on('data', onData);
|
|
102
|
+
const timer = setInterval(tick, 1000);
|
|
103
|
+
tick();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Codex runs a hook only after the person trusted it in `/hooks`, once per Account; say so the first time. */
|
|
107
|
+
async function codexTrustNotice(rt, account, io) {
|
|
108
|
+
const file = join(rt.paths.stateDir, 'handoff', 'codex-trust-notices.json');
|
|
109
|
+
const seen = (await readJsonFile(file))?.refs ?? [];
|
|
110
|
+
if (seen.includes(account.ref))
|
|
111
|
+
return;
|
|
112
|
+
io.err(`sideby: Codex runs sideby's handoff hooks only after you trust them: in ${account.ref}, type /hooks and trust \`sideby handoff-hook\`. Until then this account will not hand over.`);
|
|
113
|
+
await ensureChainDir(join(rt.paths.stateDir, 'handoff'));
|
|
114
|
+
await writeJsonFile(file, { refs: [...seen, account.ref] });
|
|
115
|
+
}
|
|
116
|
+
/** `args` with `extra` before the first `--`, so a user's own `--` cannot turn sideby's options into prompt text. */
|
|
117
|
+
export function beforeDashes(args, extra) {
|
|
118
|
+
const at = args.indexOf('--');
|
|
119
|
+
return at === -1 ? [...args, ...extra] : [...args.slice(0, at), ...extra, ...args.slice(at)];
|
|
120
|
+
}
|
|
121
|
+
/** Whether the `sideby` on PATH has the hook entry: an older install would fail every hook call. */
|
|
122
|
+
export function hookEntryWorks(env) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
execFile('sideby', ['handoff-hook', '--check'], { env: env, timeout: 5000 }, (err, out) => resolve(!err && String(out).trim() === 'ok'));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** The decision, or `none` with the reason when sideby cannot tell (an account removed mid-chain, say). */
|
|
128
|
+
async function decide(rt, account, used) {
|
|
129
|
+
try {
|
|
130
|
+
return await rt.handoffDecision(account, used);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { kind: 'none', reason: err.message };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Runs `ref` with Auto Handoff when the config, the Family, the run and the terminal allow it, and returns the exit
|
|
138
|
+
* code of the last Host; null when Auto Handoff does not apply and the caller should run the Host as usual.
|
|
139
|
+
*/
|
|
140
|
+
export async function runWithHandoff(rt, ref, userArgs, io,
|
|
141
|
+
/** For tests: how long the Host gets to exit after SIGTERM. */
|
|
142
|
+
opts = {}) {
|
|
143
|
+
const grace = opts.exitGraceMs ?? EXIT_GRACE_MS;
|
|
144
|
+
const settings = rt.handoffSettings();
|
|
145
|
+
if (!settings.auto || !io.stdin.isTTY)
|
|
146
|
+
return null;
|
|
147
|
+
const firstLaunch = await rt.prepareLaunch(ref, userArgs, 'run');
|
|
148
|
+
const firstHandoff = firstLaunch.family.handoff;
|
|
149
|
+
// Only built-in Families have a hook reader (spec §3.17); a Plugin Family can only take over.
|
|
150
|
+
if (!firstHandoff?.starts.length || !HOOK_FAMILIES.includes(firstLaunch.family.id))
|
|
151
|
+
return null;
|
|
152
|
+
const firstElig = await firstHandoff.eligibility(firstLaunch.args, firstLaunch.account, firstLaunch.env);
|
|
153
|
+
if (!firstElig.start) {
|
|
154
|
+
// A headless run is simply not part of it; an interactive one that cannot start says why.
|
|
155
|
+
if (firstElig.receive && firstElig.reason)
|
|
156
|
+
io.err(`sideby: ${firstLaunch.account.ref} will not hand over: ${firstElig.reason}`);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const binProblem = (await sidebyBinProblem(rt.env, 'the Host runs `sideby handoff-hook` for Auto Handoff')) ??
|
|
160
|
+
((await hookEntryWorks(rt.env))
|
|
161
|
+
? null
|
|
162
|
+
: 'the sideby on PATH is older than this one and has no `handoff-hook`; upgrade it with `npm i -g sideby`');
|
|
163
|
+
if (binProblem) {
|
|
164
|
+
io.err(`sideby: Auto Handoff is off for this run: ${binProblem}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const chain = newId();
|
|
168
|
+
const dir = chainDir(rt.paths.stateDir, chain);
|
|
169
|
+
await ensureChainDir(dir);
|
|
170
|
+
const hops = { hops: [] };
|
|
171
|
+
const used = [];
|
|
172
|
+
const own = rt.config.aliases?.[ref];
|
|
173
|
+
let hop = { ref, args: [...aliasArgs(own), ...userArgs] };
|
|
174
|
+
let n = 1;
|
|
175
|
+
for (;;) {
|
|
176
|
+
const launch = n === 1 ? firstLaunch : await rt.prepareLaunch(hop.ref, hop.args, 'run');
|
|
177
|
+
const account = launch.account;
|
|
178
|
+
const h = launch.family.handoff;
|
|
179
|
+
if (!h)
|
|
180
|
+
return runHost(launch);
|
|
181
|
+
const elig = n === 1 ? firstElig : await h.eligibility(launch.args, account, launch.env);
|
|
182
|
+
const canStart = h.starts.length > 0 && elig.start && HOOK_FAMILIES.includes(account.family);
|
|
183
|
+
if (!canStart && elig.reason)
|
|
184
|
+
io.err(`sideby: ${account.ref} will not hand over: ${elig.reason}`);
|
|
185
|
+
const runId = newId();
|
|
186
|
+
const brief = briefPath(dir, n, account.ref);
|
|
187
|
+
const extra = [...(elig.args ?? [])];
|
|
188
|
+
const env = { ...launch.env };
|
|
189
|
+
if (canStart) {
|
|
190
|
+
extra.push(...((await h.hookArgs?.(hookCommand(account.family), rt.paths.stateDir)) ?? []));
|
|
191
|
+
env[HANDOFF_ENV.run] = runId;
|
|
192
|
+
env[HANDOFF_ENV.family] = account.family;
|
|
193
|
+
env[HANDOFF_ENV.dir] = dir;
|
|
194
|
+
if (account.family === 'codex')
|
|
195
|
+
await codexTrustNotice(rt, account, io);
|
|
196
|
+
}
|
|
197
|
+
extra.push(...(h.dirArgs?.(dir) ?? []));
|
|
198
|
+
const args = beforeDashes(launch.args, extra);
|
|
199
|
+
// Later hops carry no `--` of their own (continueArgs drops it), so the prompt goes after a fresh one.
|
|
200
|
+
if (hop.prompt)
|
|
201
|
+
args.push(...h.promptArgs(hop.prompt));
|
|
202
|
+
used.push(account.ref);
|
|
203
|
+
hops.hops.push({ ref: account.ref, startedAt: new Date().toISOString() });
|
|
204
|
+
await writeJsonFile(join(dir, FILES.chain), hops);
|
|
205
|
+
if (launch.notice)
|
|
206
|
+
io.err(launch.notice);
|
|
207
|
+
if (!canStart)
|
|
208
|
+
return runHost({ ...launch, args, env });
|
|
209
|
+
// Plan writes run one after another, and none lands after this hop is over: a late one would carry this
|
|
210
|
+
// run's id into the next hop's plan.json and silence its hook.
|
|
211
|
+
let stopped = false;
|
|
212
|
+
let pending = Promise.resolve();
|
|
213
|
+
const writePlan = () => {
|
|
214
|
+
pending = pending.then(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const d = await rt.handoffDecision(account, used);
|
|
217
|
+
if (stopped)
|
|
218
|
+
return;
|
|
219
|
+
const plan = {
|
|
220
|
+
runId,
|
|
221
|
+
brief,
|
|
222
|
+
at: new Date().toISOString(),
|
|
223
|
+
decision: d.kind,
|
|
224
|
+
...(d.kind === 'pick' ? { pick: d.ref } : {}),
|
|
225
|
+
prepareAt: settings.prepareAt,
|
|
226
|
+
threshold: settings.threshold,
|
|
227
|
+
...(elig.agentBrief === false ? { agentBrief: false } : {}),
|
|
228
|
+
};
|
|
229
|
+
await writeJsonFile(join(dir, FILES.plan), plan);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// the hook treats a missing plan as "do not interrupt"
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return pending;
|
|
236
|
+
};
|
|
237
|
+
await writePlan();
|
|
238
|
+
const refresh = setInterval(() => void writePlan(), PLAN_REFRESH_MS);
|
|
239
|
+
const host = spawnHost({ ...launch, args, env });
|
|
240
|
+
const watcher = watchReady(dir, runId);
|
|
241
|
+
const outcome = await Promise.race([
|
|
242
|
+
host.exited.then((code) => ({ kind: 'exit', code })),
|
|
243
|
+
watcher.ready.then((ready) => ({ kind: 'ready', ready })),
|
|
244
|
+
]);
|
|
245
|
+
clearInterval(refresh);
|
|
246
|
+
watcher.stop();
|
|
247
|
+
stopped = true;
|
|
248
|
+
await pending;
|
|
249
|
+
if (outcome.kind === 'exit') {
|
|
250
|
+
await noteUnhandedHighQuota(rt, account, used, settings.threshold, io);
|
|
251
|
+
return outcome.code;
|
|
252
|
+
}
|
|
253
|
+
// Nothing to move on to (or better to wait): leave the session alone and say why once it ends.
|
|
254
|
+
const decision = await decide(rt, account, used);
|
|
255
|
+
if (decision.kind !== 'pick') {
|
|
256
|
+
await rm(join(dir, FILES.ready), { force: true });
|
|
257
|
+
const code = await host.exited;
|
|
258
|
+
io.err(`sideby: ${account.ref} did not hand over: ${explain(decision)}`);
|
|
259
|
+
return code;
|
|
260
|
+
}
|
|
261
|
+
// The session is ready: end the Host, give it time to save, never kill it.
|
|
262
|
+
host.child.kill('SIGTERM');
|
|
263
|
+
const ended = await Promise.race([
|
|
264
|
+
host.exited.then((code) => ({ code })),
|
|
265
|
+
new Promise((r) => setTimeout(() => r(null), grace).unref()),
|
|
266
|
+
]);
|
|
267
|
+
if (!ended) {
|
|
268
|
+
io.err(`sideby: ${account.ref} did not exit within ${grace / 1000}s, so nothing was handed over; exit it yourself, then start the next account with \`sideby run ${decision.ref}\``);
|
|
269
|
+
return host.exited;
|
|
270
|
+
}
|
|
271
|
+
const ready = outcome.ready;
|
|
272
|
+
const assembled = await h
|
|
273
|
+
.briefFromSession?.(account, {
|
|
274
|
+
...(ready.sessionId ? { id: ready.sessionId } : {}),
|
|
275
|
+
...(ready.transcriptPath ? { transcriptPath: ready.transcriptPath } : {}),
|
|
276
|
+
})
|
|
277
|
+
.catch(() => undefined);
|
|
278
|
+
const done = await finishBrief({
|
|
279
|
+
target: brief,
|
|
280
|
+
ready,
|
|
281
|
+
...(assembled ? { assembled } : {}),
|
|
282
|
+
cwd: process.cwd(),
|
|
283
|
+
});
|
|
284
|
+
const last = hops.hops[hops.hops.length - 1];
|
|
285
|
+
Object.assign(last, { trigger: ready.trigger, briefSource: done.source, brief: done.path });
|
|
286
|
+
await writeJsonFile(join(dir, FILES.chain), hops);
|
|
287
|
+
await rm(join(dir, FILES.ready), { force: true });
|
|
288
|
+
await rm(join(dir, FILES.request), { force: true });
|
|
289
|
+
io.err(`sideby: ${account.ref} saved its session and is handing over because ${WHY[ready.trigger]}; next is ${decision.ref}. Brief: ${done.path}`);
|
|
290
|
+
if (settings.countdownSeconds > 0 &&
|
|
291
|
+
(await countdown(io, settings.countdownSeconds, decision.ref, account.ref))) {
|
|
292
|
+
io.err(`sideby: staying in ${account.ref}`);
|
|
293
|
+
if (!ready.sessionId) {
|
|
294
|
+
io.err(`sideby: the session id is unknown, so start ${account.ref} yourself; the Brief is at ${done.path}`);
|
|
295
|
+
return ended.code;
|
|
296
|
+
}
|
|
297
|
+
const backArgs = [...h.continueArgs(hop.args), ...h.resumeArgs(ready.sessionId)];
|
|
298
|
+
const back = await rt.prepareLaunch(account.ref, backArgs, 'run');
|
|
299
|
+
// The same per-Account arguments as before (Grok's leader socket), without sideby's hooks.
|
|
300
|
+
const backElig = await h.eligibility(back.args, account, back.env);
|
|
301
|
+
return runHost({ ...back, args: beforeDashes(back.args, backElig.args ?? []) });
|
|
302
|
+
}
|
|
303
|
+
let next;
|
|
304
|
+
try {
|
|
305
|
+
next = await rt.resolve(decision.ref);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
io.err(`sideby: cannot start ${decision.ref}: ${err.message}; the Brief is at ${done.path}`);
|
|
309
|
+
return ended.code;
|
|
310
|
+
}
|
|
311
|
+
io.err(`sideby: handing over from ${account.ref} to ${next.ref}`);
|
|
312
|
+
hop = {
|
|
313
|
+
ref: next.ref,
|
|
314
|
+
args: next.family === account.family ? h.continueArgs(hop.args) : [],
|
|
315
|
+
prompt: firstPrompt(account.ref, done.path),
|
|
316
|
+
};
|
|
317
|
+
n++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** After a session ended on its own: when its Quota passed the threshold but nothing could take over, say why. */
|
|
321
|
+
async function noteUnhandedHighQuota(rt, account, used, threshold, io) {
|
|
322
|
+
try {
|
|
323
|
+
const pressure = quotaPressure(await rt.quota(account), new Date());
|
|
324
|
+
if (pressure === null || pressure < threshold)
|
|
325
|
+
return;
|
|
326
|
+
const d = await rt.handoffDecision(account, used);
|
|
327
|
+
if (d.kind !== 'pick')
|
|
328
|
+
io.err(`sideby: ${account.ref} is at ${Math.round(pressure)}%; no Auto Handoff: ${explain(d)}`);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// a note only
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* For `sideby handoff ready`: where a Handoff from the session running under `env` would go now, using the chain's
|
|
336
|
+
* Accounts so far; null when `env` is not a run sideby started with Auto Handoff.
|
|
337
|
+
*/
|
|
338
|
+
export async function readyDecision(rt, env) {
|
|
339
|
+
const dir = env[HANDOFF_ENV.dir];
|
|
340
|
+
const ref = env.SIDEBY_ACCOUNT;
|
|
341
|
+
if (!env[HANDOFF_ENV.run] || !dir || !ref)
|
|
342
|
+
return null;
|
|
343
|
+
const account = await rt.resolve(ref);
|
|
344
|
+
const chain = await readJsonFile(join(dir, FILES.chain));
|
|
345
|
+
return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref]);
|
|
346
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Account, AccountIdentity, LoginState, QuotaResult } from '../types.ts';
|
|
2
|
+
import type { HandoffSettings } from './config.ts';
|
|
3
|
+
export interface SelectCandidate {
|
|
4
|
+
ref: string;
|
|
5
|
+
family: string;
|
|
6
|
+
name: string;
|
|
7
|
+
isMain: boolean;
|
|
8
|
+
kind: Account['kind'];
|
|
9
|
+
login: LoginState | 'not-needed';
|
|
10
|
+
quota: QuotaResult;
|
|
11
|
+
identity?: AccountIdentity;
|
|
12
|
+
/** Whether the Account's Host is on PATH. */
|
|
13
|
+
installed: boolean;
|
|
14
|
+
/** Whether its Family can start as the next Account (has `handoff`, and the run would be eligible). */
|
|
15
|
+
receive: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type HandoffDecision = {
|
|
18
|
+
kind: 'pick';
|
|
19
|
+
ref: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'wait';
|
|
22
|
+
until: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'none';
|
|
25
|
+
reason: string;
|
|
26
|
+
earliestReset?: {
|
|
27
|
+
ref: string;
|
|
28
|
+
at: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/** Organizations when both have one, else email domains when both have one, else the same. */
|
|
32
|
+
export declare function sameOrganization(a?: AccountIdentity, b?: AccountIdentity): boolean;
|
|
33
|
+
export declare function selectNext(input: {
|
|
34
|
+
origin: SelectCandidate;
|
|
35
|
+
candidates: readonly SelectCandidate[];
|
|
36
|
+
settings: HandoffSettings;
|
|
37
|
+
/** Short command → Account ref, so lists may name either. */
|
|
38
|
+
aliases: Readonly<Record<string, string>>;
|
|
39
|
+
/** Accounts this chain already used, the origin included. */
|
|
40
|
+
used: readonly string[];
|
|
41
|
+
now: Date;
|
|
42
|
+
}): HandoffDecision;
|