sideby 0.2.2 → 0.4.0

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 (116) hide show
  1. package/README.md +27 -10
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +8 -1
  4. package/dist/src/cli/commands.js +330 -9
  5. package/dist/src/cli/format.d.ts +3 -1
  6. package/dist/src/cli/format.js +25 -0
  7. package/dist/src/cli/json-schemas.js +113 -0
  8. package/dist/src/cli/main.d.ts +5 -0
  9. package/dist/src/cli/main.js +40 -8
  10. package/dist/src/core/config.d.ts +41 -0
  11. package/dist/src/core/config.js +86 -11
  12. package/dist/src/core/file-memo.d.ts +23 -0
  13. package/dist/src/core/file-memo.js +37 -0
  14. package/dist/src/core/handoff-brief.d.ts +17 -0
  15. package/dist/src/core/handoff-brief.js +75 -0
  16. package/dist/src/core/handoff-chain.d.ts +74 -0
  17. package/dist/src/core/handoff-chain.js +74 -0
  18. package/dist/src/core/handoff-doctor.d.ts +13 -0
  19. package/dist/src/core/handoff-doctor.js +35 -0
  20. package/dist/src/core/handoff-run.d.ts +31 -0
  21. package/dist/src/core/handoff-run.js +346 -0
  22. package/dist/src/core/handoff-select.d.ts +42 -0
  23. package/dist/src/core/handoff-select.js +88 -0
  24. package/dist/src/core/handoff-status.d.ts +66 -0
  25. package/dist/src/core/handoff-status.js +198 -0
  26. package/dist/src/core/launch.d.ts +22 -1
  27. package/dist/src/core/launch.js +54 -4
  28. package/dist/src/core/memo-store.d.ts +20 -0
  29. package/dist/src/core/memo-store.js +92 -0
  30. package/dist/src/core/resume.d.ts +7 -2
  31. package/dist/src/core/resume.js +22 -9
  32. package/dist/src/core/tuning-apply.d.ts +51 -0
  33. package/dist/src/core/tuning-apply.js +237 -0
  34. package/dist/src/core/tuning.d.ts +98 -0
  35. package/dist/src/core/tuning.js +176 -0
  36. package/dist/src/families/claude/brief.d.ts +5 -0
  37. package/dist/src/families/claude/brief.js +63 -0
  38. package/dist/src/families/claude/handoff-hook.d.ts +2 -0
  39. package/dist/src/families/claude/handoff-hook.js +31 -0
  40. package/dist/src/families/claude/handoff.d.ts +5 -0
  41. package/dist/src/families/claude/handoff.js +115 -0
  42. package/dist/src/families/claude/index.js +8 -1
  43. package/dist/src/families/claude/tuning.d.ts +22 -0
  44. package/dist/src/families/claude/tuning.js +187 -0
  45. package/dist/src/families/claude/usage.d.ts +1 -0
  46. package/dist/src/families/claude/usage.js +2 -2
  47. package/dist/src/families/codex/brief.d.ts +5 -0
  48. package/dist/src/families/codex/brief.js +49 -0
  49. package/dist/src/families/codex/handoff-hook.d.ts +2 -0
  50. package/dist/src/families/codex/handoff-hook.js +16 -0
  51. package/dist/src/families/codex/handoff.d.ts +5 -0
  52. package/dist/src/families/codex/handoff.js +121 -0
  53. package/dist/src/families/codex/index.js +6 -0
  54. package/dist/src/families/codex/rate-limits.d.ts +16 -0
  55. package/dist/src/families/codex/rate-limits.js +92 -0
  56. package/dist/src/families/codex/rollout.d.ts +1 -2
  57. package/dist/src/families/codex/rollout.js +4 -48
  58. package/dist/src/families/codex/tuning.d.ts +16 -0
  59. package/dist/src/families/codex/tuning.js +145 -0
  60. package/dist/src/families/grok/brief.d.ts +5 -0
  61. package/dist/src/families/grok/brief.js +74 -0
  62. package/dist/src/families/grok/handoff-hook.d.ts +4 -0
  63. package/dist/src/families/grok/handoff-hook.js +26 -0
  64. package/dist/src/families/grok/handoff.d.ts +47 -0
  65. package/dist/src/families/grok/handoff.js +219 -0
  66. package/dist/src/families/grok/index.js +2 -0
  67. package/dist/src/families/pi/handoff.d.ts +2 -0
  68. package/dist/src/families/pi/handoff.js +13 -0
  69. package/dist/src/families/pi/index.js +2 -0
  70. package/dist/src/families/shared/brief.d.ts +24 -0
  71. package/dist/src/families/shared/brief.js +42 -0
  72. package/dist/src/families/shared/handoff-args.d.ts +18 -0
  73. package/dist/src/families/shared/handoff-args.js +57 -0
  74. package/dist/src/families/shared/sessions.d.ts +4 -0
  75. package/dist/src/families/shared/sessions.js +19 -0
  76. package/dist/src/families/shared/toml.d.ts +5 -0
  77. package/dist/src/families/shared/toml.js +30 -0
  78. package/dist/src/handoff/hook.d.ts +16 -0
  79. package/dist/src/handoff/hook.js +166 -0
  80. package/dist/src/handoff/messages.d.ts +7 -0
  81. package/dist/src/handoff/messages.js +27 -0
  82. package/dist/src/handoff/reader.d.ts +11 -0
  83. package/dist/src/handoff/reader.js +9 -0
  84. package/dist/src/panel/handler.d.ts +6 -0
  85. package/dist/src/panel/handler.js +110 -0
  86. package/dist/src/panel/i18n.d.ts +96 -0
  87. package/dist/src/panel/i18n.js +192 -0
  88. package/dist/src/panel/page-logic.d.ts +49 -0
  89. package/dist/src/panel/page-logic.js +67 -0
  90. package/dist/src/panel/page-script.js +454 -4
  91. package/dist/src/panel/page-styles.js +28 -2
  92. package/dist/src/panel/page.js +4 -1
  93. package/dist/src/quota/claude-statusline.js +4 -7
  94. package/dist/src/runtime.d.ts +40 -3
  95. package/dist/src/runtime.js +226 -43
  96. package/dist/src/types.d.ts +101 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +1 -0
  99. package/package.json +16 -4
  100. package/schemas/config.json +77 -0
  101. package/schemas/families.json +5 -1
  102. package/schemas/handoff-disable.json +106 -0
  103. package/schemas/handoff-enable.json +106 -0
  104. package/schemas/handoff-ready.json +23 -0
  105. package/schemas/handoff-setup.json +60 -0
  106. package/schemas/handoff-status.json +137 -0
  107. package/schemas/handoff-teardown.json +30 -0
  108. package/schemas/list.json +5 -1
  109. package/schemas/tune-change.json +108 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +7 -4
  112. package/skills/sideby/references/badge.md +31 -0
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. package/skills/sideby/references/setup.md +3 -2
  116. package/README.zh-CN.md +0 -112
@@ -0,0 +1,219 @@
1
+ // How Grok Build takes part in a Handoff (spec §3.17). It has no public Quota, so it starts one only after a turn
2
+ // failed at the limit. Interactive `grok` has no launch option for hooks: `sideby handoff setup grok` adds
3
+ // `hooks/sideby-handoff.json` to the Main Account and Doctor copies it to the others (verified 2026-10-09).
4
+ // The hook inherits Grok's sandbox, so the profile decides whether a run may start or receive.
5
+ import { mkdir, readFile, rm, rmdir } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { sha256, writeFileAtomic } from '../../core/fs-safe.js';
8
+ import { sidebyBinProblem } from '../../core/launch.js';
9
+ import { tildify } from '../../core/paths.js';
10
+ import { carryArgs, hasOption, optionValue, promptAfterDashes, } from '../shared/handoff-args.js';
11
+ import { tomlValue } from '../shared/toml.js';
12
+ import { grokBrief } from './brief.js';
13
+ /** From `grok --help`. */
14
+ export const GROK_ARGS = {
15
+ valued: [
16
+ '--agent',
17
+ '--agents',
18
+ '--allow',
19
+ '--cwd',
20
+ '--debug-file',
21
+ '--deny',
22
+ '--disallowed-tools',
23
+ '--json-schema',
24
+ '--leader-socket',
25
+ '-m',
26
+ '--model',
27
+ '--max-turns',
28
+ '--output-format',
29
+ '-p',
30
+ '--single',
31
+ '--permission-mode',
32
+ '--prompt-file',
33
+ '--prompt-json',
34
+ '--reasoning-effort',
35
+ '--rules',
36
+ '-s',
37
+ '--session-id',
38
+ '--sandbox',
39
+ '--system-prompt-override',
40
+ '--tools',
41
+ '--worktree-ref',
42
+ ],
43
+ optional: ['-r', '--resume', '-w', '--worktree'],
44
+ drop: ['-c', '--continue', '-r', '--resume', '-s', '--session-id', '--fork-session', '--restore-code'],
45
+ };
46
+ export const ETC_GROK = '/etc/grok';
47
+ export const HOOK_FILE = 'sideby-handoff.json';
48
+ const str = (v) => (typeof v === 'string' ? v : undefined);
49
+ const bool = (v) => (typeof v === 'boolean' ? v : undefined);
50
+ const first = (...vs) => vs.find((v) => v !== undefined);
51
+ /**
52
+ * The effective sandbox profile, leader setting and managed-hooks pin, in Grok's precedence (spec §3.17):
53
+ * requirements (`/etc` over `$GROK_HOME`, a pin) > command line > environment > `config.toml` > managed config
54
+ * (`$GROK_HOME` over `/etc`) > the default `off`.
55
+ */
56
+ export function grokPolicy(layers, args, env) {
57
+ const read = (key) => (t) => t === null ? undefined : tomlValue(t, ...key);
58
+ const profileIn = read(['sandbox', 'profile']);
59
+ const leaderIn = read(['cli', 'use_leader']);
60
+ const pinIn = read(['', 'allow_managed_hooks_only']);
61
+ const profile = first(str(profileIn(layers.etcRequirements)), str(profileIn(layers.homeRequirements)), optionValue(args, ['--sandbox']), env.GROK_SANDBOX || undefined, str(profileIn(layers.config)), str(profileIn(layers.homeManaged)), str(profileIn(layers.etcManaged))) ?? 'off';
62
+ const leaderFlag = hasOption(args, ['--no-leader'])
63
+ ? false
64
+ : hasOption(args, ['--leader'])
65
+ ? true
66
+ : undefined;
67
+ const leader = first(bool(leaderIn(layers.etcRequirements)), bool(leaderIn(layers.homeRequirements)), leaderFlag, bool(leaderIn(layers.config)), bool(leaderIn(layers.homeManaged)), bool(leaderIn(layers.etcManaged))) ?? false;
68
+ const managedHooksOnly = [
69
+ layers.etcRequirements,
70
+ layers.homeRequirements,
71
+ layers.homeManaged,
72
+ layers.etcManaged,
73
+ ].some((t) => bool(pinIn(t)) === true);
74
+ return { profile, leader, managedHooksOnly };
75
+ }
76
+ async function textOrNull(path) {
77
+ try {
78
+ return await readFile(path, 'utf8');
79
+ }
80
+ catch (err) {
81
+ if (err.code === 'ENOENT')
82
+ return null;
83
+ throw err;
84
+ }
85
+ }
86
+ async function readLayers(home) {
87
+ return {
88
+ etcRequirements: await textOrNull(join(ETC_GROK, 'requirements.toml')),
89
+ homeRequirements: await textOrNull(join(home, 'requirements.toml')),
90
+ config: await textOrNull(join(home, 'config.toml')),
91
+ homeManaged: await textOrNull(join(home, 'managed_config.toml')),
92
+ etcManaged: await textOrNull(join(ETC_GROK, 'managed_config.toml')),
93
+ };
94
+ }
95
+ const HEADLESS = ['-p', '--single', '--prompt-file', '--prompt-json'];
96
+ export function grokEligibility(policy, args, home) {
97
+ if (hasOption(args, HEADLESS))
98
+ return { start: false, receive: false, reason: 'a headless run (`-p`) has nobody to hand over to' };
99
+ const extra = policy.leader && !hasOption(args, ['--leader-socket'])
100
+ ? { args: ['--leader-socket', join(home, 'leader.sock')] }
101
+ : {};
102
+ const { profile } = policy;
103
+ if (profile === 'off' || profile === 'devbox') {
104
+ if (policy.managedHooksOnly)
105
+ return {
106
+ start: false,
107
+ receive: true,
108
+ reason: 'allow_managed_hooks_only keeps sideby’s hook from running',
109
+ ...extra,
110
+ };
111
+ return { start: true, receive: true, ...extra };
112
+ }
113
+ if (profile === 'workspace' || profile === 'read-only')
114
+ return {
115
+ start: false,
116
+ receive: true,
117
+ reason: `the \`${profile}\` sandbox cannot write sideby’s handoff files, so this run can only take over`,
118
+ ...extra,
119
+ };
120
+ return {
121
+ start: false,
122
+ receive: false,
123
+ reason: `the \`${profile}\` sandbox profile cannot read sideby’s handoff files`,
124
+ };
125
+ }
126
+ const hookCommand = (event) => `sideby handoff-hook grok ${event}`;
127
+ /** The whole file setup writes; teardown removes it only while it is still exactly this. */
128
+ export function grokHookFileText() {
129
+ const hooks = {
130
+ hooks: {
131
+ Stop: [{ hooks: [{ type: 'command', command: hookCommand('Stop') }] }],
132
+ // Observation hooks get 5 seconds by default; putting a Brief together may take longer.
133
+ StopFailure: [{ hooks: [{ type: 'command', command: hookCommand('StopFailure'), timeout: 60 }] }],
134
+ },
135
+ };
136
+ return `${JSON.stringify(hooks, null, 2)}\n`;
137
+ }
138
+ const hookFile = (ctx) => join(ctx.home, '.grok', 'hooks', HOOK_FILE);
139
+ async function plan(ctx) {
140
+ const file = hookFile(ctx);
141
+ const shown = tildify(file, ctx.home);
142
+ const want = grokHookFileText();
143
+ const have = await textOrNull(file);
144
+ if (have === want)
145
+ return { status: 'enabled', file, diff: '', message: `already set up in ${shown}` };
146
+ if (have !== null)
147
+ return {
148
+ status: 'blocked',
149
+ file,
150
+ diff: '',
151
+ message: `${shown} exists and is not sideby's; move it away and retry`,
152
+ };
153
+ const problem = await sidebyBinProblem(ctx.env, 'Grok runs `sideby handoff-hook` when a turn ends');
154
+ if (problem)
155
+ return { status: 'blocked', file, diff: '', message: problem };
156
+ return {
157
+ status: 'ready',
158
+ file,
159
+ diff: `--- /dev/null\n+++ ${shown}\n${want
160
+ .trimEnd()
161
+ .split('\n')
162
+ .map((l) => `+${l}`)
163
+ .join('\n')}\n`,
164
+ message: `adds ${shown} so Grok tells sideby when a turn hits the usage limit; then run \`sideby doctor grok --fix\` to copy it to every Grok account; undo with \`sideby handoff teardown grok\``,
165
+ };
166
+ }
167
+ export const grokHookSetup = {
168
+ summary: 'Adds one sideby hook file to ~/.grok/hooks so a Grok session that hits its usage limit can hand over.',
169
+ plan,
170
+ async apply(ctx) {
171
+ const p = await plan(ctx);
172
+ if (p.status !== 'ready')
173
+ return p;
174
+ await mkdir(join(ctx.home, '.grok', 'hooks'), { recursive: true });
175
+ await writeFileAtomic(p.file, grokHookFileText(), 0o644);
176
+ return { ...p, status: 'enabled' };
177
+ },
178
+ async teardown(ctx) {
179
+ const file = hookFile(ctx);
180
+ const shown = tildify(file, ctx.home);
181
+ const have = await textOrNull(file);
182
+ if (have === null)
183
+ return { ok: true, message: `${shown} is not there; nothing to undo` };
184
+ if (sha256(have) !== sha256(grokHookFileText()))
185
+ return {
186
+ ok: false,
187
+ message: `${shown} changed since setup; delete it yourself if you no longer want it`,
188
+ };
189
+ await rm(file);
190
+ // Setup may have made the directory; an empty one goes too, so the Main Account is back as it was.
191
+ await rmdir(join(ctx.home, '.grok', 'hooks')).catch(() => { });
192
+ return {
193
+ ok: true,
194
+ message: `removed ${shown}; run \`sideby doctor grok --fix\` to remove the copies in other Grok accounts`,
195
+ };
196
+ },
197
+ };
198
+ export const grokHandoff = {
199
+ starts: ['limit'],
200
+ promptArgs: promptAfterDashes,
201
+ continueArgs: (args) => carryArgs(args, GROK_ARGS),
202
+ resumeArgs: (id) => ['--resume', id],
203
+ async eligibility(args, account, env) {
204
+ let layers;
205
+ try {
206
+ layers = await readLayers(account.dir);
207
+ }
208
+ catch (err) {
209
+ return {
210
+ start: false,
211
+ receive: false,
212
+ reason: `cannot read Grok's config to tell its sandbox: ${err.message}`,
213
+ };
214
+ }
215
+ return grokEligibility(grokPolicy(layers, args, env), args, account.dir);
216
+ },
217
+ briefFromSession: grokBrief,
218
+ hookSetup: grokHookSetup,
219
+ };
@@ -4,6 +4,7 @@ import { identityFromJsonFile, identityOf } from '../../core/identity.js';
4
4
  import { BUILTIN_LOGOS } from '../logos.js';
5
5
  import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
6
6
  import { topLevelTomlString } from '../shared/toml.js';
7
+ import { grokHandoff } from './handoff.js';
7
8
  // Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
8
9
  const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
9
10
  const REAL_FILE_MESSAGE = 'Grok hooks-paths registry has wrong type (expected real file)';
@@ -71,6 +72,7 @@ export const grokFamily = {
71
72
  model: readModel,
72
73
  identity: grokIdentity,
73
74
  resumedSession: resumeFlagSession,
75
+ handoff: grokHandoff,
74
76
  // One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
75
77
  sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'sessions'), id, 'dir'),
76
78
  };
@@ -0,0 +1,2 @@
1
+ import type { FamilyHandoff } from '../../types.ts';
2
+ export declare const piHandoff: FamilyHandoff;
@@ -0,0 +1,13 @@
1
+ import { hasOption, promptAfterDashes } from '../shared/handoff-args.js';
2
+ export const piHandoff = {
3
+ starts: [],
4
+ promptArgs: promptAfterDashes,
5
+ // pi never starts a Handoff, so it never continues one of its own sessions with the user's arguments.
6
+ continueArgs: () => [],
7
+ resumeArgs: (id) => ['--session', id],
8
+ async eligibility(args) {
9
+ if (hasOption(args, ['-p', '--print']))
10
+ return { start: false, receive: false, reason: 'a headless run (`-p`) has nobody to hand over to' };
11
+ return { start: false, receive: true };
12
+ },
13
+ };
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { lstatOrNull } from '../../core/fs-safe.js';
6
6
  import { BUILTIN_LOGOS } from '../logos.js';
7
+ import { piHandoff } from './handoff.js';
7
8
  /** `auth.json` exists after `/login`; its contents are never read. */
8
9
  async function loginState(account) {
9
10
  return (await lstatOrNull(join(account.dir, 'auth.json'))) ? 'logged-in' : 'logged-out';
@@ -45,6 +46,7 @@ export const piFamily = {
45
46
  login: { args: null, hint: 'pi signs in inside the app: start it and type /login' },
46
47
  loginState,
47
48
  model,
49
+ handoff: piHandoff,
48
50
  };
49
51
  export const piPlugin = {
50
52
  name: 'pi',
@@ -0,0 +1,24 @@
1
+ export interface SessionDigest {
2
+ /** The first thing the user asked. */
3
+ firstUser?: string;
4
+ /** The last text messages, oldest first. */
5
+ recent: {
6
+ role: 'user' | 'assistant';
7
+ text: string;
8
+ }[];
9
+ /** Files the session edited or wrote, in first-touched order. */
10
+ files: string[];
11
+ }
12
+ export declare const MAX_PIECE = 2000;
13
+ export declare const RECENT = 6;
14
+ export declare const clip: (s: string, max?: number) => string;
15
+ /** Keeps the last `RECENT` messages and each edited path once. */
16
+ export declare class DigestBuilder {
17
+ private d;
18
+ user(text: string): void;
19
+ assistant(text: string): void;
20
+ file(path: unknown): void;
21
+ private push;
22
+ result(): SessionDigest | undefined;
23
+ }
24
+ export declare function formatDigest(d: SessionDigest): string;
@@ -0,0 +1,42 @@
1
+ // A Handoff Brief put together from a session's local records, without a model (spec §3.17 "sideby 拼的 Brief").
2
+ // Only conversation text and edited paths are taken, never tool output, each piece clipped.
3
+ export const MAX_PIECE = 2000;
4
+ export const RECENT = 6;
5
+ export const clip = (s, max = MAX_PIECE) => (s.length > max ? `${s.slice(0, max)}…` : s);
6
+ /** Keeps the last `RECENT` messages and each edited path once. */
7
+ export class DigestBuilder {
8
+ d = { recent: [], files: [] };
9
+ user(text) {
10
+ const t = text.trim();
11
+ if (!t)
12
+ return;
13
+ this.d.firstUser ??= clip(t);
14
+ this.push('user', t);
15
+ }
16
+ assistant(text) {
17
+ const t = text.trim();
18
+ if (t)
19
+ this.push('assistant', t);
20
+ }
21
+ file(path) {
22
+ if (typeof path === 'string' && path && !this.d.files.includes(path))
23
+ this.d.files.push(path);
24
+ }
25
+ push(role, text) {
26
+ this.d.recent.push({ role, text: clip(text) });
27
+ if (this.d.recent.length > RECENT)
28
+ this.d.recent.shift();
29
+ }
30
+ result() {
31
+ return this.d.firstUser || this.d.recent.length || this.d.files.length ? this.d : undefined;
32
+ }
33
+ }
34
+ export function formatDigest(d) {
35
+ const out = ['## Task (first request)', '', d.firstUser ?? '(not found)', ''];
36
+ out.push('## Last messages', '');
37
+ for (const m of d.recent)
38
+ out.push(`**${m.role}:** ${m.text}`, '');
39
+ out.push('## Files edited', '');
40
+ out.push(...(d.files.length ? d.files.map((f) => `- ${f}`) : ['(none found)']), '');
41
+ return out.join('\n');
42
+ }
@@ -0,0 +1,18 @@
1
+ export interface ArgRules {
2
+ /** Options whose value is the next argument. */
3
+ valued: readonly string[];
4
+ /** Options taking one or more values, up to the next option (Claude's `--add-dir <directories...>`). */
5
+ variadic?: readonly string[];
6
+ /** Options whose value may be left out: the next argument is theirs unless it starts with `-`. */
7
+ optional?: readonly string[];
8
+ /** Options left out of the next session, with their values. */
9
+ drop: readonly string[];
10
+ }
11
+ /** The options to carry over; every positional (a subcommand, a session id, the old prompt) and `--` onwards go. */
12
+ export declare function carryArgs(args: readonly string[], rules: ArgRules): string[];
13
+ /** True when `args` (before `--`) contain any of `names`, alone or as `name=value`. */
14
+ export declare function hasOption(args: readonly string[], names: readonly string[]): boolean;
15
+ /** The value of the first of `names` in `args` (`--x v` or `--x=v`), before `--`. */
16
+ export declare function optionValue(args: readonly string[], names: readonly string[]): string | undefined;
17
+ /** First prompt after `--`, so an option that takes several values cannot swallow it. */
18
+ export declare const promptAfterDashes: (prompt: string) => string[];
@@ -0,0 +1,57 @@
1
+ // Host arguments for the next session of a Handoff (spec §3.17 "启动参数"): keep the user's options, drop the ones
2
+ // that resume or continue a session, and drop the old first prompt. Each Family lists which of its options take a
3
+ // value, from its `--help`, so a value is never mistaken for the prompt.
4
+ /** The options to carry over; every positional (a subcommand, a session id, the old prompt) and `--` onwards go. */
5
+ export function carryArgs(args, rules) {
6
+ const kept = [];
7
+ for (let i = 0; i < args.length; i++) {
8
+ const a = args[i];
9
+ if (a === '--')
10
+ break;
11
+ if (!a.startsWith('-') || a === '-')
12
+ continue;
13
+ const name = a.includes('=') ? a.slice(0, a.indexOf('=')) : a;
14
+ const inline = name !== a;
15
+ const drop = rules.drop.includes(name);
16
+ const take = [a];
17
+ if (!inline) {
18
+ if (rules.valued.includes(name) && i + 1 < args.length)
19
+ take.push(args[++i]);
20
+ else if (rules.variadic?.includes(name))
21
+ while (i + 1 < args.length && !args[i + 1].startsWith('-'))
22
+ take.push(args[++i]);
23
+ else if (rules.optional?.includes(name) && i + 1 < args.length && !args[i + 1].startsWith('-'))
24
+ take.push(args[++i]);
25
+ }
26
+ if (!drop)
27
+ kept.push(...take);
28
+ }
29
+ return kept;
30
+ }
31
+ /** True when `args` (before `--`) contain any of `names`, alone or as `name=value`. */
32
+ export function hasOption(args, names) {
33
+ for (const a of args) {
34
+ if (a === '--')
35
+ return false;
36
+ const name = a.includes('=') ? a.slice(0, a.indexOf('=')) : a;
37
+ if (names.includes(name))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ /** The value of the first of `names` in `args` (`--x v` or `--x=v`), before `--`. */
43
+ export function optionValue(args, names) {
44
+ for (let i = 0; i < args.length; i++) {
45
+ const a = args[i];
46
+ if (a === '--')
47
+ return undefined;
48
+ const eq = a.indexOf('=');
49
+ if (eq > 0 && names.includes(a.slice(0, eq)))
50
+ return a.slice(eq + 1);
51
+ if (names.includes(a))
52
+ return args[i + 1];
53
+ }
54
+ return undefined;
55
+ }
56
+ /** First prompt after `--`, so an option that takes several values cannot swallow it. */
57
+ export const promptAfterDashes = (prompt) => ['--', prompt];
@@ -1,8 +1,12 @@
1
1
  export declare const isSessionId: (s: string | undefined) => s is string;
2
2
  /** The id after `--resume <id>`, `--resume=<id>` or `-r <id>`; Claude Code and Grok Build share this form. */
3
3
  export declare function resumeFlagSession(args: readonly string[]): string | undefined;
4
+ /** The arguments without the by-id resume `resumeFlagSession` reads; the rest keep their order (ADR-0009). */
5
+ export declare function withoutResumeFlag(args: readonly string[]): string[];
4
6
  /** The first UUID after the `resume` subcommand (`codex resume [options] <id>`). */
5
7
  export declare function resumeSubcommandSession(args: readonly string[]): string | undefined;
8
+ /** Modification time in ms of `path` when it is a directory (Claude `session-env/<id>`, ADR-0009). */
9
+ export declare const dirWrittenAt: (path: string) => Promise<number | undefined>;
6
10
  /** `<dir>/*` + `/<entry>`: one level of grouping directories (Claude projects, Grok working directories). */
7
11
  export declare function groupedEntryWrittenAt(dir: string, entry: string, type: 'file' | 'dir'): Promise<number | undefined>;
8
12
  /** A file anywhere under `dir` whose name ends with `suffix` (Codex `sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`). */
@@ -23,6 +23,23 @@ export function resumeFlagSession(args) {
23
23
  }
24
24
  return undefined;
25
25
  }
26
+ /** The arguments without the by-id resume `resumeFlagSession` reads; the rest keep their order (ADR-0009). */
27
+ export function withoutResumeFlag(args) {
28
+ const end = args.indexOf('--');
29
+ const opts = end === -1 ? args : args.slice(0, end);
30
+ const kept = [];
31
+ for (let i = 0; i < opts.length; i++) {
32
+ const a = opts[i];
33
+ if (a.startsWith('--resume=') && isSessionId(a.slice('--resume='.length)))
34
+ continue;
35
+ if ((a === '--resume' || a === '-r') && isSessionId(opts[i + 1])) {
36
+ i++;
37
+ continue;
38
+ }
39
+ kept.push(a);
40
+ }
41
+ return end === -1 ? kept : [...kept, ...args.slice(end)];
42
+ }
26
43
  /** The first UUID after the `resume` subcommand (`codex resume [options] <id>`). */
27
44
  export function resumeSubcommandSession(args) {
28
45
  const opts = options(args);
@@ -43,6 +60,8 @@ async function firstWrittenAt(paths, type) {
43
60
  }
44
61
  return undefined;
45
62
  }
63
+ /** Modification time in ms of `path` when it is a directory (Claude `session-env/<id>`, ADR-0009). */
64
+ export const dirWrittenAt = (path) => firstWrittenAt([path], 'dir');
46
65
  async function subdirs(dir) {
47
66
  try {
48
67
  return (await readdir(dir, { withFileTypes: true }))
@@ -3,3 +3,8 @@
3
3
  * Only a whole-line header ends the top level, so a multi-line array line such as ` ["a"],` does not.
4
4
  */
5
5
  export declare function topLevelTomlString(text: string, key: string): string | undefined;
6
+ /**
7
+ * A string or boolean `key` in `[table]`, or as `table.key = …` at the top level; `table` '' means the top level.
8
+ * Enough for the few policy keys sideby reads (Grok's sandbox profile); not a TOML parser.
9
+ */
10
+ export declare function tomlValue(text: string, table: string, key: string): string | boolean | undefined;
@@ -14,3 +14,33 @@ export function topLevelTomlString(text, key) {
14
14
  }
15
15
  return undefined;
16
16
  }
17
+ /**
18
+ * A string or boolean `key` in `[table]`, or as `table.key = …` at the top level; `table` '' means the top level.
19
+ * Enough for the few policy keys sideby reads (Grok's sandbox profile); not a TOML parser.
20
+ */
21
+ export function tomlValue(text, table, key) {
22
+ const value = /^\s*(?:"([^"]*)"|'([^']*)'|(true|false))\s*(#.*)?$/;
23
+ let current = '';
24
+ for (const line of text.split(/\r?\n/)) {
25
+ const header = /^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(#.*)?$/.exec(line);
26
+ if (header) {
27
+ current = header[1];
28
+ continue;
29
+ }
30
+ if (TABLE_HEADER.test(line)) {
31
+ current = '\0';
32
+ continue;
33
+ }
34
+ const kv = /^\s*([A-Za-z0-9_.-]+)\s*=(.*)$/.exec(line);
35
+ if (!kv)
36
+ continue;
37
+ const full = current ? `${current}.${kv[1]}` : kv[1];
38
+ if (full !== (table ? `${table}.${key}` : key))
39
+ continue;
40
+ const m = value.exec(kv[2]);
41
+ if (!m)
42
+ return undefined;
43
+ return m[3] ? m[3] === 'true' : (m[1] ?? m[2]);
44
+ }
45
+ return undefined;
46
+ }
@@ -0,0 +1,16 @@
1
+ import type { Env, HandoffHookEvent } from '../types.ts';
2
+ import { type HookInput } from './reader.ts';
3
+ export declare const HOOK_COMMAND = "sideby handoff-hook";
4
+ /** Families whose hooks this entry understands; only they can start a Handoff. */
5
+ export declare const HOOK_FAMILIES: readonly string[];
6
+ /** What one hook call decided: output for the Host, and files to write. Separate from I/O for the tests. */
7
+ export declare function decide(opts: {
8
+ family: string;
9
+ event: HandoffHookEvent;
10
+ input: HookInput;
11
+ env: Env;
12
+ now: Date;
13
+ }): Promise<{
14
+ output?: unknown;
15
+ }>;
16
+ export declare function runHandoffHook(argv: readonly string[], env: Env, stdin: AsyncIterable<Uint8Array | string>, stdout: NodeJS.WritableStream): Promise<number>;