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.
- 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 +330 -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 +113 -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 +41 -0
- package/dist/src/core/config.js +86 -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 +22 -1
- package/dist/src/core/launch.js +54 -4
- 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 +51 -0
- package/dist/src/core/tuning-apply.js +237 -0
- package/dist/src/core/tuning.d.ts +98 -0
- package/dist/src/core/tuning.js +176 -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 +187 -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 +6 -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 +96 -0
- package/dist/src/panel/i18n.js +192 -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 +454 -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 +226 -43
- package/dist/src/types.d.ts +101 -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 +77 -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 +108 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +7 -4
- package/skills/sideby/references/badge.md +31 -0
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/skills/sideby/references/setup.md +3 -2
- package/README.zh-CN.md +0 -112
|
@@ -41,6 +41,7 @@ const FamilyInfo = Type.Object({
|
|
|
41
41
|
installUrl: Str,
|
|
42
42
|
installed: Type.Boolean(),
|
|
43
43
|
plugin: Str,
|
|
44
|
+
tuning: Type.Boolean(),
|
|
44
45
|
});
|
|
45
46
|
const ShareModeSchema = Type.Union(['link', 'copy', 'link-or-copy', 'link-or-local', 'local', 'local-if-api', 'info', 'json-key'].map((m) => Type.Literal(m)));
|
|
46
47
|
const FamilyDetail = Type.Intersect([
|
|
@@ -100,6 +101,50 @@ const Quota = Type.Union([
|
|
|
100
101
|
}),
|
|
101
102
|
Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
|
|
102
103
|
]);
|
|
104
|
+
const TuningModel = Type.Object({
|
|
105
|
+
model: Str,
|
|
106
|
+
calls: Type.Number(),
|
|
107
|
+
contextWindow: Type.Number(),
|
|
108
|
+
configured: Type.Union([Type.Number(), Type.Null()], {
|
|
109
|
+
description: 'Configured threshold in tokens; null is the Host default.',
|
|
110
|
+
}),
|
|
111
|
+
effective: Type.Union([Type.Number(), Type.Null()]),
|
|
112
|
+
compactions: Type.Object({
|
|
113
|
+
total: Type.Number({
|
|
114
|
+
description: 'All compactions, including those whose trigger the Host does not record.',
|
|
115
|
+
}),
|
|
116
|
+
auto: Type.Number(),
|
|
117
|
+
manual: Type.Number(),
|
|
118
|
+
preTokensP50: Type.Union([Type.Number(), Type.Null()]),
|
|
119
|
+
preTokensMax: Type.Union([Type.Number(), Type.Null()]),
|
|
120
|
+
}),
|
|
121
|
+
suggestion: Type.Union([
|
|
122
|
+
Type.Object({
|
|
123
|
+
threshold: Type.Number(),
|
|
124
|
+
savedPercent: Type.Union([Type.Number(), Type.Null()]),
|
|
125
|
+
savedWeighted: Type.Union([Type.Number(), Type.Null()]),
|
|
126
|
+
}),
|
|
127
|
+
Type.Null(),
|
|
128
|
+
], { description: 'The estimated cheapest threshold; null when `reason` says why there is none.' }),
|
|
129
|
+
reason: Type.Union([
|
|
130
|
+
Type.Literal('suggested'),
|
|
131
|
+
Type.Literal('already-best'),
|
|
132
|
+
Type.Literal('insufficient-calls'),
|
|
133
|
+
Type.Literal('no-compactions'),
|
|
134
|
+
Type.Literal('no-optimum'),
|
|
135
|
+
Type.Literal('unknown-model'),
|
|
136
|
+
Type.Literal('observe-only'),
|
|
137
|
+
]),
|
|
138
|
+
});
|
|
139
|
+
const Tuning = Type.Union([
|
|
140
|
+
Type.Object({
|
|
141
|
+
status: Type.Literal('ok'),
|
|
142
|
+
days: Type.Number(),
|
|
143
|
+
estimate: Type.Literal(true),
|
|
144
|
+
models: Type.Array(TuningModel),
|
|
145
|
+
}),
|
|
146
|
+
Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
|
|
147
|
+
]);
|
|
103
148
|
const Usage = Type.Union([
|
|
104
149
|
Type.Object({
|
|
105
150
|
status: Type.Literal('ok'),
|
|
@@ -153,6 +198,27 @@ export const OUTPUT_SCHEMAS = {
|
|
|
153
198
|
general: Type.Array(Finding),
|
|
154
199
|
fixes: Type.Array(Type.Object({ account: Str, item: Str, code: Str, ok: Type.Boolean(), message: Str })),
|
|
155
200
|
}, { title: 'sideby doctor --json' }),
|
|
201
|
+
'tune-change': Type.Object({
|
|
202
|
+
schemaVersion: Version,
|
|
203
|
+
account: Str,
|
|
204
|
+
action: Type.Union([Type.Literal('apply'), Type.Literal('undo')]),
|
|
205
|
+
applied: Type.Boolean({ description: 'True once the change was written (or undone).' }),
|
|
206
|
+
plan: Type.Optional(Type.Object({
|
|
207
|
+
status: Type.Union([Type.Literal('ready'), Type.Literal('unchanged'), Type.Literal('blocked')]),
|
|
208
|
+
file: Str,
|
|
209
|
+
link: Type.Boolean(),
|
|
210
|
+
affected: Type.Array(Str),
|
|
211
|
+
current: Type.Union([Type.Number(), Type.Null()]),
|
|
212
|
+
next: Type.Union([Type.Number(), Type.Null()]),
|
|
213
|
+
creates: Type.Boolean(),
|
|
214
|
+
reason: Type.Optional(Str),
|
|
215
|
+
})),
|
|
216
|
+
message: Type.Optional(Str),
|
|
217
|
+
}, { title: 'sideby tune apply|undo --json' }),
|
|
218
|
+
tune: Type.Object({
|
|
219
|
+
schemaVersion: Version,
|
|
220
|
+
accounts: Type.Array(Type.Object({ ref: Str, family: Str, tuning: Tuning })),
|
|
221
|
+
}, { title: 'sideby tune --json' }),
|
|
156
222
|
quota: Type.Object({
|
|
157
223
|
schemaVersion: Version,
|
|
158
224
|
accounts: Type.Array(Type.Object({ ref: Str, kind: Kind, quota: Quota, usage: Usage })),
|
|
@@ -189,6 +255,53 @@ export const OUTPUT_SCHEMAS = {
|
|
|
189
255
|
})),
|
|
190
256
|
}, { title: 'sideby next --json' }),
|
|
191
257
|
'quota-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby quota setup --json' }),
|
|
258
|
+
'handoff-status': Type.Object({
|
|
259
|
+
schemaVersion: Version,
|
|
260
|
+
auto: Type.Boolean(),
|
|
261
|
+
sameFamily: Type.Boolean(),
|
|
262
|
+
prepareAt: Type.Number(),
|
|
263
|
+
threshold: Type.Number(),
|
|
264
|
+
sideby: Type.Object({ ok: Type.Boolean(), problem: Type.Optional(Str) }),
|
|
265
|
+
families: Type.Array(Type.Object({
|
|
266
|
+
family: Str,
|
|
267
|
+
starts: Type.Array(Type.Union([Type.Literal('quota'), Type.Literal('limit')])),
|
|
268
|
+
ready: Type.Boolean(),
|
|
269
|
+
problems: Type.Array(Str),
|
|
270
|
+
notes: Type.Array(Str),
|
|
271
|
+
order: Type.Object({
|
|
272
|
+
policy: Type.Union([Type.Literal('pressure'), Type.Literal('order')]),
|
|
273
|
+
order: Type.Array(Str),
|
|
274
|
+
unknown: Type.Array(Str),
|
|
275
|
+
}),
|
|
276
|
+
})),
|
|
277
|
+
nextSteps: Type.Array(Str),
|
|
278
|
+
}, { title: 'sideby handoff status --json' }),
|
|
279
|
+
...Object.fromEntries(['enable', 'disable'].map((sub) => [
|
|
280
|
+
`handoff-${sub}`,
|
|
281
|
+
Type.Object({
|
|
282
|
+
schemaVersion: Version,
|
|
283
|
+
applied: Type.Boolean(),
|
|
284
|
+
file: Str,
|
|
285
|
+
diff: Str,
|
|
286
|
+
settings: Type.Object({
|
|
287
|
+
auto: Type.Boolean(),
|
|
288
|
+
sameFamily: Type.Boolean(),
|
|
289
|
+
prepareAt: Type.Number(),
|
|
290
|
+
threshold: Type.Number(),
|
|
291
|
+
waitIfResetWithinMinutes: Type.Number(),
|
|
292
|
+
countdownSeconds: Type.Number(),
|
|
293
|
+
crossOrganization: Type.Array(Str),
|
|
294
|
+
families: Type.Record(Str, Type.Object({
|
|
295
|
+
policy: Type.Union([Type.Literal('pressure'), Type.Literal('order')]),
|
|
296
|
+
order: Type.Array(Str),
|
|
297
|
+
})),
|
|
298
|
+
}),
|
|
299
|
+
nextSteps: Type.Array(Str),
|
|
300
|
+
}, { title: `sideby handoff ${sub} --json` }),
|
|
301
|
+
])),
|
|
302
|
+
'handoff-ready': Type.Object({ schemaVersion: Version, requested: Type.Literal(true), brief: Type.Optional(Str) }, { title: 'sideby handoff ready --json' }),
|
|
303
|
+
'handoff-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby handoff setup --json' }),
|
|
304
|
+
'handoff-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby handoff teardown --json' }),
|
|
192
305
|
'quota-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby quota teardown --json' }),
|
|
193
306
|
new: Type.Object({
|
|
194
307
|
schemaVersion: Version,
|
package/dist/src/cli/main.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* True below Node 22.18. npm's `engines` field only warns (`EBADENGINE`); this is the check that exits.
|
|
4
|
+
* `version` defaults to the running Node so tests can pass a string.
|
|
5
|
+
*/
|
|
6
|
+
export declare function nodeTooOld(version?: string): boolean;
|
|
2
7
|
export declare function main(argv: string[]): Promise<number>;
|
package/dist/src/cli/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Entry point. Keeps imports lazy so `statusline-tap
|
|
2
|
+
// Entry point. Keeps imports lazy so `statusline-tap` (run on every status line refresh) and `handoff-hook` (run on
|
|
3
|
+
// every tool call while Auto Handoff is on) start fast.
|
|
3
4
|
import { realpathSync } from 'node:fs';
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
6
|
const MIN_NODE = [22, 18];
|
|
@@ -29,6 +30,16 @@ Usage:
|
|
|
29
30
|
sideby quota [account] quota and 7-day token usage
|
|
30
31
|
sideby quota setup claude [--yes] turn on Claude quota (shows the change first)
|
|
31
32
|
sideby quota teardown claude undo it, restoring the original file
|
|
33
|
+
sideby tune [account|family] estimate the cheapest auto-compact threshold per model
|
|
34
|
+
from your last 7 days of sessions (read-only)
|
|
35
|
+
sideby handoff status what Auto Handoff still needs on this machine
|
|
36
|
+
sideby handoff enable [--yes] turn it on (shows the config change first); --same-family,
|
|
37
|
+
--order claude=codex001,cc002, --threshold n, --prepare-at n
|
|
38
|
+
sideby handoff disable [--yes] turn it off, keeping the other settings
|
|
39
|
+
sideby handoff ready [--brief <f>] inside a session sideby started with Auto Handoff: hand over to
|
|
40
|
+
the next account when this turn ends (config \`handoff\`)
|
|
41
|
+
sideby handoff setup grok [--yes] add sideby's hook file so Grok can hand over at its limit
|
|
42
|
+
sideby handoff teardown grok remove it
|
|
32
43
|
sideby ui [--port n] [--no-open] open the local panel
|
|
33
44
|
sideby ui --background same, without a terminal: keeps running after this command exits
|
|
34
45
|
sideby ui --stop stop the background panel
|
|
@@ -40,7 +51,7 @@ Usage:
|
|
|
40
51
|
sideby plugins list loaded plugins and load errors
|
|
41
52
|
|
|
42
53
|
Options:
|
|
43
|
-
--json machine-readable output (list, new, next, alias, doctor, quota, families, plugins, app)
|
|
54
|
+
--json machine-readable output (list, new, next, alias, doctor, quota, tune, handoff, families, plugins, app)
|
|
44
55
|
-h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families
|
|
45
56
|
-v, --version show the version
|
|
46
57
|
|
|
@@ -124,8 +135,12 @@ Examples:
|
|
|
124
135
|
sideby families claude --json
|
|
125
136
|
Exit: 0; 1 unknown family; 2 malformed command line.`,
|
|
126
137
|
};
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
/**
|
|
139
|
+
* True below Node 22.18. npm's `engines` field only warns (`EBADENGINE`); this is the check that exits.
|
|
140
|
+
* `version` defaults to the running Node so tests can pass a string.
|
|
141
|
+
*/
|
|
142
|
+
export function nodeTooOld(version = process.versions.node) {
|
|
143
|
+
const [maj = 0, min = 0] = version.split('.').map(Number);
|
|
129
144
|
return maj < MIN_NODE[0] || (maj === MIN_NODE[0] && min < MIN_NODE[1]);
|
|
130
145
|
}
|
|
131
146
|
const COMMANDS = {
|
|
@@ -138,6 +153,11 @@ const COMMANDS = {
|
|
|
138
153
|
alias: { flags: ['json'] },
|
|
139
154
|
doctor: { flags: ['fix', 'force', 'json'] },
|
|
140
155
|
quota: { flags: ['json', 'yes'] },
|
|
156
|
+
tune: { flags: ['json', 'yes'] },
|
|
157
|
+
handoff: {
|
|
158
|
+
flags: ['json', 'yes', 'same-family', 'no-same-family'],
|
|
159
|
+
valued: ['brief', 'order', 'prepare-at', 'threshold', 'wait-minutes', 'countdown'],
|
|
160
|
+
},
|
|
141
161
|
ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
|
|
142
162
|
app: { flags: ['json'], valued: ['url'] },
|
|
143
163
|
'shell-init': { flags: ['write'] },
|
|
@@ -146,10 +166,22 @@ const COMMANDS = {
|
|
|
146
166
|
};
|
|
147
167
|
export async function main(argv) {
|
|
148
168
|
const [first, ...rest] = argv;
|
|
169
|
+
// Claude Code runs this on every status-line refresh. It must still launch the original command when Node is
|
|
170
|
+
// older than 22.18, so the check below does not apply here (spec §3.1).
|
|
149
171
|
if (first === 'statusline-tap') {
|
|
150
172
|
const { runStatuslineTap } = await import('../quota/statusline-tap.js');
|
|
151
173
|
return runStatuslineTap(rest, process.env, process.stdin, process.stdout);
|
|
152
174
|
}
|
|
175
|
+
// The handoff hook runs on every tool call while Auto Handoff is on; like the tap it comes before the Node check,
|
|
176
|
+
// so a Host never gets a version error from its own hook (spec §3.17).
|
|
177
|
+
if (first === 'handoff-hook') {
|
|
178
|
+
const { runHandoffHook } = await import('../handoff/hook.js');
|
|
179
|
+
return runHandoffHook(rest, process.env, process.stdin, process.stdout);
|
|
180
|
+
}
|
|
181
|
+
if (nodeTooOld()) {
|
|
182
|
+
console.error(`sideby needs Node ${MIN_NODE.join('.')} or newer (you have ${process.versions.node}). Upgrade: https://nodejs.org`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
153
185
|
if (first === '-v' || first === '--version' || first === 'version') {
|
|
154
186
|
const { packageVersion } = await import('../core/version.js');
|
|
155
187
|
console.log(packageVersion());
|
|
@@ -159,10 +191,6 @@ export async function main(argv) {
|
|
|
159
191
|
console.log((rest[0] !== undefined && first === 'help' && COMMAND_HELP[rest[0]]) || HELP);
|
|
160
192
|
return 0;
|
|
161
193
|
}
|
|
162
|
-
if (nodeTooOld()) {
|
|
163
|
-
console.error(`sideby needs Node ${MIN_NODE.join('.')} or newer (you have ${process.versions.node}). Upgrade: https://nodejs.org`);
|
|
164
|
-
return 1;
|
|
165
|
-
}
|
|
166
194
|
const cmd = first === undefined || first.startsWith('--') ? 'list' : first;
|
|
167
195
|
const args = first === undefined || first.startsWith('--') ? argv : rest;
|
|
168
196
|
const spec = COMMANDS[cmd];
|
|
@@ -225,6 +253,10 @@ export async function main(argv) {
|
|
|
225
253
|
return await cmds.cmdDoctor(parsed, io);
|
|
226
254
|
case 'quota':
|
|
227
255
|
return await cmds.cmdQuota(parsed, io);
|
|
256
|
+
case 'tune':
|
|
257
|
+
return await cmds.cmdTune(parsed, io);
|
|
258
|
+
case 'handoff':
|
|
259
|
+
return await cmds.cmdHandoff(parsed, io);
|
|
228
260
|
case 'ui':
|
|
229
261
|
return await cmds.cmdUi(parsed, io);
|
|
230
262
|
case 'app':
|
|
@@ -16,6 +16,19 @@ export declare const aliasAccount: (value: AliasValue) => string;
|
|
|
16
16
|
export declare const aliasArgs: (value: AliasValue | undefined) => string[];
|
|
17
17
|
/** `aliases` as alias → Account ref, for lookups that ignore the arguments. */
|
|
18
18
|
export declare function aliasRefs(aliases: Readonly<Record<string, AliasValue>> | undefined): Record<string, string>;
|
|
19
|
+
export interface HandoffSettings {
|
|
20
|
+
auto: boolean;
|
|
21
|
+
sameFamily: boolean;
|
|
22
|
+
prepareAt: number;
|
|
23
|
+
threshold: number;
|
|
24
|
+
waitIfResetWithinMinutes: number;
|
|
25
|
+
countdownSeconds: number;
|
|
26
|
+
crossOrganization: string[];
|
|
27
|
+
families: Record<string, {
|
|
28
|
+
policy: 'pressure' | 'order';
|
|
29
|
+
order: string[];
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
19
32
|
export declare const ConfigSchema: Type.TObject<{
|
|
20
33
|
$schema: Type.TOptional<Type.TString>;
|
|
21
34
|
accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TObject<{
|
|
@@ -34,16 +47,44 @@ export declare const ConfigSchema: Type.TObject<{
|
|
|
34
47
|
noSymlink: Type.TOptional<Type.TBoolean>;
|
|
35
48
|
credential: Type.TOptional<Type.TBoolean>;
|
|
36
49
|
}>>>>;
|
|
50
|
+
accountTitle: Type.TOptional<Type.TBoolean>;
|
|
37
51
|
resumeRouting: Type.TOptional<Type.TBoolean>;
|
|
52
|
+
handoff: Type.TOptional<Type.TObject<{
|
|
53
|
+
auto: Type.TOptional<Type.TBoolean>;
|
|
54
|
+
sameFamily: Type.TOptional<Type.TBoolean>;
|
|
55
|
+
prepareAt: Type.TOptional<Type.TInteger>;
|
|
56
|
+
threshold: Type.TOptional<Type.TInteger>;
|
|
57
|
+
waitIfResetWithinMinutes: Type.TOptional<Type.TInteger>;
|
|
58
|
+
countdownSeconds: Type.TOptional<Type.TInteger>;
|
|
59
|
+
crossOrganization: Type.TOptional<Type.TArray<Type.TString>>;
|
|
60
|
+
families: Type.TOptional<Type.TRecord<"^.*$", Type.TObject<{
|
|
61
|
+
policy: Type.TOptional<Type.TUnion<[Type.TLiteral<"pressure">, Type.TLiteral<"order">]>>;
|
|
62
|
+
order: Type.TOptional<Type.TArray<Type.TString>>;
|
|
63
|
+
}>>>;
|
|
64
|
+
}>>;
|
|
38
65
|
shellInitFile: Type.TOptional<Type.TObject<{
|
|
39
66
|
zsh: Type.TOptional<Type.TString>;
|
|
40
67
|
bash: Type.TOptional<Type.TString>;
|
|
41
68
|
}>>;
|
|
42
69
|
}>;
|
|
43
70
|
export type Config = Static<typeof ConfigSchema>;
|
|
71
|
+
/** The `handoff` settings with every default filled in. */
|
|
72
|
+
export declare function handoffSettings(config: Config): HandoffSettings;
|
|
44
73
|
export declare class ConfigError extends UserError {
|
|
45
74
|
}
|
|
46
75
|
export declare function loadConfig(file: string): Promise<Config>;
|
|
76
|
+
export type HandoffConfig = NonNullable<Config['handoff']>;
|
|
77
|
+
/**
|
|
78
|
+
* `sideby handoff enable|disable` (spec §3.17): changes the config `handoff` key with `edit` (see `editConfigKey`
|
|
79
|
+
* for how the file is written). With `dryRun`, shows the change without writing.
|
|
80
|
+
*/
|
|
81
|
+
export declare function editConfigHandoff(file: string, edit: (current: HandoffConfig) => HandoffConfig, opts?: {
|
|
82
|
+
dryRun?: boolean;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
changed: boolean;
|
|
85
|
+
before: HandoffConfig | undefined;
|
|
86
|
+
after: HandoffConfig | undefined;
|
|
87
|
+
}>;
|
|
47
88
|
/**
|
|
48
89
|
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
49
90
|
* `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
|
package/dist/src/core/config.js
CHANGED
|
@@ -45,6 +45,35 @@ const describeAlias = (v) => {
|
|
|
45
45
|
const args = aliasArgs(v);
|
|
46
46
|
return args.length ? `${aliasAccount(v)} with ${args.join(' ')}` : aliasAccount(v);
|
|
47
47
|
};
|
|
48
|
+
const Percent = (description) => Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description }));
|
|
49
|
+
/** Auto Handoff (spec §3.17, ADR-0010). Every key is optional; `handoffSettings` fills the defaults. */
|
|
50
|
+
const HandoffSchema = Type.Object({
|
|
51
|
+
auto: Type.Optional(Type.Boolean({
|
|
52
|
+
description: 'Hand a running session to the next account when its Quota runs low: only to other families and API accounts unless `sameFamily` is on. Off by default.',
|
|
53
|
+
})),
|
|
54
|
+
sameFamily: Type.Optional(Type.Boolean({
|
|
55
|
+
description: 'Also hand off to an account of the same family (cc001 → cc002). The kind vendors are most likely to act on. Off by default.',
|
|
56
|
+
})),
|
|
57
|
+
prepareAt: Percent('Quota Pressure at which the session starts keeping a Handoff Brief (default 80).'),
|
|
58
|
+
threshold: Percent('Quota Pressure at which the Handoff begins (default 95).'),
|
|
59
|
+
waitIfResetWithinMinutes: Type.Optional(Type.Integer({
|
|
60
|
+
minimum: 0,
|
|
61
|
+
description: 'Stay and wait when the full window resets within this many minutes (default 30; 0 never waits).',
|
|
62
|
+
})),
|
|
63
|
+
countdownSeconds: Type.Optional(Type.Integer({
|
|
64
|
+
minimum: 0,
|
|
65
|
+
description: 'Countdown before the next account starts; Enter cancels (default 10; 0 skips it).',
|
|
66
|
+
})),
|
|
67
|
+
crossOrganization: Type.Optional(Type.Array(Type.String(), {
|
|
68
|
+
description: 'Accounts (ref or short command) that may take over from an account of another organization.',
|
|
69
|
+
})),
|
|
70
|
+
families: Type.Optional(Type.Record(Type.String(), Type.Object({
|
|
71
|
+
policy: Type.Optional(Type.Union([Type.Literal('pressure'), Type.Literal('order')])),
|
|
72
|
+
order: Type.Optional(Type.Array(Type.String())),
|
|
73
|
+
}, { additionalProperties: false }), {
|
|
74
|
+
description: 'Handoff Order per family the session starts in: `pressure` (default) or an `order` list.',
|
|
75
|
+
})),
|
|
76
|
+
}, { additionalProperties: false });
|
|
48
77
|
export const ConfigSchema = Type.Object({
|
|
49
78
|
$schema: Type.Optional(Type.String()),
|
|
50
79
|
accounts: Type.Optional(Type.Record(Type.String(), Type.Object({ args: Type.Optional(Type.Array(Type.String())) }, { additionalProperties: false }), { description: 'Per-Account settings keyed by `<family>:<name>`.' })),
|
|
@@ -57,9 +86,13 @@ export const ConfigSchema = Type.Object({
|
|
|
57
86
|
description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
|
|
58
87
|
})),
|
|
59
88
|
extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema))),
|
|
89
|
+
accountTitle: Type.Optional(Type.Boolean({
|
|
90
|
+
description: 'Set the terminal title to `[<short command or account>]` when sideby starts an Account, and keep Hosts that allow it (Codex) from replacing it. Off by default.',
|
|
91
|
+
})),
|
|
60
92
|
resumeRouting: Type.Optional(Type.Boolean({
|
|
61
93
|
description: 'Make `sideby shell-init` also define Host-named functions (`claude`, `codex`, `grok`) that resume a session by id in the Account that holds it. Off by default.',
|
|
62
94
|
})),
|
|
95
|
+
handoff: Type.Optional(HandoffSchema),
|
|
63
96
|
shellInitFile: Type.Optional(Type.Object({
|
|
64
97
|
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
65
98
|
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
@@ -68,6 +101,23 @@ export const ConfigSchema = Type.Object({
|
|
|
68
101
|
description: 'Absolute or `~/` paths of files sideby rewrites with the `sideby shell-init <shell>` output whenever it adds an Account or an alias; source the file from your shell rc.',
|
|
69
102
|
})),
|
|
70
103
|
}, { additionalProperties: false, title: 'sideby config' });
|
|
104
|
+
/** The `handoff` settings with every default filled in. */
|
|
105
|
+
export function handoffSettings(config) {
|
|
106
|
+
const h = config.handoff ?? {};
|
|
107
|
+
return {
|
|
108
|
+
auto: h.auto ?? false,
|
|
109
|
+
sameFamily: h.sameFamily ?? false,
|
|
110
|
+
prepareAt: h.prepareAt ?? 80,
|
|
111
|
+
threshold: h.threshold ?? 95,
|
|
112
|
+
waitIfResetWithinMinutes: h.waitIfResetWithinMinutes ?? 30,
|
|
113
|
+
countdownSeconds: h.countdownSeconds ?? 10,
|
|
114
|
+
crossOrganization: [...(h.crossOrganization ?? [])],
|
|
115
|
+
families: Object.fromEntries(Object.entries(h.families ?? {}).map(([f, v]) => [
|
|
116
|
+
f,
|
|
117
|
+
{ policy: v.policy ?? 'pressure', order: [...(v.order ?? [])] },
|
|
118
|
+
])),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
71
121
|
export class ConfigError extends UserError {
|
|
72
122
|
}
|
|
73
123
|
export async function loadConfig(file) {
|
|
@@ -92,6 +142,9 @@ export async function loadConfig(file) {
|
|
|
92
142
|
const where = first?.instancePath || '/';
|
|
93
143
|
throw new ConfigError(`${file}: ${where} ${first?.message ?? 'is invalid'}; see schemas/config.json`);
|
|
94
144
|
}
|
|
145
|
+
const handoff = handoffSettings(data);
|
|
146
|
+
if (handoff.prepareAt >= handoff.threshold)
|
|
147
|
+
throw new ConfigError(`${file}: handoff.prepareAt (${handoff.prepareAt}) must be lower than handoff.threshold (${handoff.threshold}); lower prepareAt or raise threshold`);
|
|
95
148
|
// TypeBox does not enforce key patterns on records; alias names become shell function names.
|
|
96
149
|
for (const alias of Object.keys(data.aliases ?? {})) {
|
|
97
150
|
const problem = aliasProblem(alias);
|
|
@@ -105,12 +158,14 @@ async function configTarget(file) {
|
|
|
105
158
|
return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
|
|
106
159
|
}
|
|
107
160
|
/**
|
|
108
|
-
* Re-reads the config file, lets `edit` change
|
|
109
|
-
* order kept (with `$schema`), 2-space JSON
|
|
110
|
-
* a
|
|
111
|
-
*
|
|
161
|
+
* Re-reads the config file, lets `edit` change one top-level `key` (the only two sideby writes are `aliases` and
|
|
162
|
+
* `handoff`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
|
|
163
|
+
* with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
|
|
164
|
+
* file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
|
|
165
|
+
* while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
|
|
166
|
+
* `retry` names what to do again after a fix.
|
|
112
167
|
*/
|
|
113
|
-
async function
|
|
168
|
+
async function editConfigKey(file, key, retry, edit, opts = {}) {
|
|
114
169
|
const target = await configTarget(file);
|
|
115
170
|
let raw = null;
|
|
116
171
|
try {
|
|
@@ -135,19 +190,39 @@ async function editConfigAliases(file, retry, edit) {
|
|
|
135
190
|
throw new ConfigError(`${file} does not match schemas/config.json; nothing was changed. Run \`sideby list\` to see why`);
|
|
136
191
|
data = parsed;
|
|
137
192
|
}
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
return
|
|
193
|
+
const before = data[key];
|
|
194
|
+
const after = edit(before);
|
|
195
|
+
if (after === null || JSON.stringify(after) === JSON.stringify(before))
|
|
196
|
+
return { changed: false, before, after: before };
|
|
142
197
|
// A new file gets the schema link editors use for completion; an existing file keeps its keys in order.
|
|
143
|
-
const next = raw === null
|
|
198
|
+
const next = raw === null
|
|
199
|
+
? { $schema: 'https://unpkg.com/sideby/schemas/config.json', [key]: after }
|
|
200
|
+
: { ...data, [key]: after };
|
|
201
|
+
if (!Value.Check(ConfigSchema, next))
|
|
202
|
+
throw new ConfigError(`the new ${key} would not match schemas/config.json`);
|
|
203
|
+
const h = handoffSettings(next);
|
|
204
|
+
if (h.prepareAt >= h.threshold)
|
|
205
|
+
throw new ConfigError(`handoff.prepareAt must be lower than handoff.threshold; fix them in ${file} and ${retry} again`);
|
|
206
|
+
if (opts.dryRun)
|
|
207
|
+
return { changed: true, before, after };
|
|
144
208
|
const st = raw === null ? null : await lstatOrNull(target);
|
|
145
209
|
if (raw === null)
|
|
146
210
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
147
211
|
const written = await writeIfUnchanged(target, raw === null ? null : sha256(raw), `${JSON.stringify(next, null, 2)}\n`, st ? st.mode & 0o777 : 0o600);
|
|
148
212
|
if (!written)
|
|
149
213
|
throw new ConfigError(`${file} changed while sideby was writing it; nothing was changed. Try again`);
|
|
150
|
-
return
|
|
214
|
+
return { changed: true, before, after };
|
|
215
|
+
}
|
|
216
|
+
async function editConfigAliases(file, retry, edit) {
|
|
217
|
+
const r = await editConfigKey(file, 'aliases', retry, (current) => edit(current ?? {}));
|
|
218
|
+
return r.after ?? {};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `sideby handoff enable|disable` (spec §3.17): changes the config `handoff` key with `edit` (see `editConfigKey`
|
|
222
|
+
* for how the file is written). With `dryRun`, shows the change without writing.
|
|
223
|
+
*/
|
|
224
|
+
export function editConfigHandoff(file, edit, opts = {}) {
|
|
225
|
+
return editConfigKey(file, 'handoff', 'run the command', (current) => edit(current ?? {}), opts);
|
|
151
226
|
}
|
|
152
227
|
/**
|
|
153
228
|
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
@@ -9,6 +9,18 @@ export interface MemoPass<T> {
|
|
|
9
9
|
/** Ends the pass: the scope now holds only the files this pass asked for. */
|
|
10
10
|
done(): void;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* How a memo is kept on disk. `name` is its key in the cache file; bump `version` whenever the parsed value's shape
|
|
14
|
+
* or meaning changes, so a cache written by an older sideby is dropped instead of misread.
|
|
15
|
+
*/
|
|
16
|
+
export interface MemoPersist {
|
|
17
|
+
name: string;
|
|
18
|
+
version: number;
|
|
19
|
+
}
|
|
20
|
+
/** A memo as stored on disk: per scope, per file, the signature and the parsed value. */
|
|
21
|
+
export type MemoDump = Record<string, Record<string, [sig: string, value: unknown]>>;
|
|
22
|
+
/** Every memo created with `persist`, by name. */
|
|
23
|
+
export declare const persistedMemos: Map<string, FileMemo<unknown>>;
|
|
12
24
|
export declare class FileMemo<T> {
|
|
13
25
|
#private;
|
|
14
26
|
/** Counters for tests and timing. */
|
|
@@ -16,8 +28,19 @@ export declare class FileMemo<T> {
|
|
|
16
28
|
hits: number;
|
|
17
29
|
misses: number;
|
|
18
30
|
};
|
|
31
|
+
/** True when a pass changed what is remembered since the last `dump`. */
|
|
32
|
+
dirty: boolean;
|
|
33
|
+
readonly persist?: MemoPersist;
|
|
34
|
+
constructor(persist?: MemoPersist);
|
|
19
35
|
/** Starts a pass over `scope`, such as one Account's directory. */
|
|
20
36
|
pass(scope: string): MemoPass<T>;
|
|
37
|
+
/** What is remembered, for the cache file; clears `dirty`. */
|
|
38
|
+
dump(): MemoDump;
|
|
39
|
+
/**
|
|
40
|
+
* Takes remembered parses from the cache file for scopes this process has not read yet. The values are trusted
|
|
41
|
+
* to have the shape `persist.version` stands for; a stale one never matches, because its signature differs.
|
|
42
|
+
*/
|
|
43
|
+
restore(data: MemoDump): void;
|
|
21
44
|
/** Files remembered for a scope (for tests). */
|
|
22
45
|
size(scope: string): number;
|
|
23
46
|
clear(): void;
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
export function fileSignature(st) {
|
|
2
2
|
return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
|
|
3
3
|
}
|
|
4
|
+
/** Every memo created with `persist`, by name. */
|
|
5
|
+
export const persistedMemos = new Map();
|
|
4
6
|
export class FileMemo {
|
|
5
7
|
#scopes = new Map();
|
|
6
8
|
/** Counters for tests and timing. */
|
|
7
9
|
stats = { hits: 0, misses: 0 };
|
|
10
|
+
/** True when a pass changed what is remembered since the last `dump`. */
|
|
11
|
+
dirty = false;
|
|
12
|
+
persist;
|
|
13
|
+
constructor(persist) {
|
|
14
|
+
if (!persist)
|
|
15
|
+
return;
|
|
16
|
+
this.persist = persist;
|
|
17
|
+
persistedMemos.set(persist.name, this);
|
|
18
|
+
}
|
|
8
19
|
/** Starts a pass over `scope`, such as one Account's directory. */
|
|
9
20
|
pass(scope) {
|
|
10
21
|
const before = this.#scopes.get(scope);
|
|
@@ -25,16 +36,42 @@ export class FileMemo {
|
|
|
25
36
|
return value;
|
|
26
37
|
},
|
|
27
38
|
done: () => {
|
|
39
|
+
if (!before || before.size !== seen.size || [...seen].some(([k, v]) => before.get(k) !== v))
|
|
40
|
+
this.dirty = true;
|
|
28
41
|
this.#scopes.set(scope, seen);
|
|
29
42
|
},
|
|
30
43
|
};
|
|
31
44
|
}
|
|
45
|
+
/** What is remembered, for the cache file; clears `dirty`. */
|
|
46
|
+
dump() {
|
|
47
|
+
this.dirty = false;
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [scope, files] of this.#scopes)
|
|
50
|
+
out[scope] = Object.fromEntries([...files].map(([path, e]) => [path, [e.sig, e.value]]));
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Takes remembered parses from the cache file for scopes this process has not read yet. The values are trusted
|
|
55
|
+
* to have the shape `persist.version` stands for; a stale one never matches, because its signature differs.
|
|
56
|
+
*/
|
|
57
|
+
restore(data) {
|
|
58
|
+
for (const [scope, files] of Object.entries(data)) {
|
|
59
|
+
if (this.#scopes.has(scope) || !files || typeof files !== 'object')
|
|
60
|
+
continue;
|
|
61
|
+
const map = new Map();
|
|
62
|
+
for (const [path, entry] of Object.entries(files))
|
|
63
|
+
if (Array.isArray(entry) && typeof entry[0] === 'string')
|
|
64
|
+
map.set(path, { sig: entry[0], value: entry[1] });
|
|
65
|
+
this.#scopes.set(scope, map);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
32
68
|
/** Files remembered for a scope (for tests). */
|
|
33
69
|
size(scope) {
|
|
34
70
|
return this.#scopes.get(scope)?.size ?? 0;
|
|
35
71
|
}
|
|
36
72
|
clear() {
|
|
37
73
|
this.#scopes.clear();
|
|
74
|
+
this.dirty = false;
|
|
38
75
|
this.stats.hits = 0;
|
|
39
76
|
this.stats.misses = 0;
|
|
40
77
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReadyFile } from './handoff-chain.ts';
|
|
2
|
+
/** Branch, uncommitted files and `git diff --stat`, as Markdown; '' outside a git work tree. Never commits or stashes. */
|
|
3
|
+
export declare function gitSection(cwd: string): Promise<string>;
|
|
4
|
+
export declare const ASSEMBLED_NOTE = "> Put together by sideby from the previous session\u2019s records, without a model; it may be incomplete. Check the repository before continuing.";
|
|
5
|
+
/**
|
|
6
|
+
* Writes the final Brief to `target` (mode 600) and says where it came from: the user's or the session's Brief
|
|
7
|
+
* named in `ready`, the session's Brief already at `target`, else `assembled`, plus the repository state.
|
|
8
|
+
*/
|
|
9
|
+
export declare function finishBrief(opts: {
|
|
10
|
+
target: string;
|
|
11
|
+
ready: Pick<ReadyFile, 'brief' | 'briefSource'>;
|
|
12
|
+
assembled?: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
path: string;
|
|
16
|
+
source: 'agent' | 'sideby' | 'user';
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Finishing a Handoff Brief before the next Account starts (spec §3.17 "Handoff Brief"): keep what the session or
|
|
2
|
+
// the user wrote, else what sideby put together from the session's records, and add the repository state. No model.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { writeFileAtomic } from './fs-safe.js';
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
9
|
+
const MAX_STATUS_LINES = 50;
|
|
10
|
+
async function git(cwd, args) {
|
|
11
|
+
const { stdout } = await run('git', args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1 << 20 });
|
|
12
|
+
return stdout.trimEnd();
|
|
13
|
+
}
|
|
14
|
+
/** Branch, uncommitted files and `git diff --stat`, as Markdown; '' outside a git work tree. Never commits or stashes. */
|
|
15
|
+
export async function gitSection(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
if ((await git(cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true')
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const out = ['## Repository state (added by sideby)', '', `Working directory: ${cwd}`];
|
|
24
|
+
try {
|
|
25
|
+
out.push(`Branch: ${(await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])) || '(detached)'}`);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// a repository without commits
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const status = (await git(cwd, ['status', '--porcelain'])).split('\n').filter(Boolean);
|
|
32
|
+
out.push('', 'Uncommitted files:', '');
|
|
33
|
+
out.push(...(status.length ? status.slice(0, MAX_STATUS_LINES).map((l) => ` ${l}`) : [' (none)']));
|
|
34
|
+
if (status.length > MAX_STATUS_LINES)
|
|
35
|
+
out.push(` … and ${status.length - MAX_STATUS_LINES} more`);
|
|
36
|
+
const stat = await git(cwd, ['diff', '--stat']);
|
|
37
|
+
if (stat)
|
|
38
|
+
out.push('', 'Diff stat:', '', ...stat.split('\n').map((l) => ` ${l}`));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// git refused; the branch line is still useful
|
|
42
|
+
}
|
|
43
|
+
return `${out.join('\n')}\n`;
|
|
44
|
+
}
|
|
45
|
+
async function nonEmpty(path) {
|
|
46
|
+
if (!path)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const text = await readFile(path, 'utf8');
|
|
50
|
+
return text.trim() ? text : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export const ASSEMBLED_NOTE = '> Put together by sideby from the previous session’s records, without a model; it may be incomplete. Check the repository before continuing.';
|
|
57
|
+
/**
|
|
58
|
+
* Writes the final Brief to `target` (mode 600) and says where it came from: the user's or the session's Brief
|
|
59
|
+
* named in `ready`, the session's Brief already at `target`, else `assembled`, plus the repository state.
|
|
60
|
+
*/
|
|
61
|
+
export async function finishBrief(opts) {
|
|
62
|
+
let body = await nonEmpty(opts.ready.brief);
|
|
63
|
+
let source = opts.ready.briefSource ?? 'agent';
|
|
64
|
+
if (!body) {
|
|
65
|
+
body = await nonEmpty(opts.target);
|
|
66
|
+
source = 'agent';
|
|
67
|
+
}
|
|
68
|
+
if (!body) {
|
|
69
|
+
source = 'sideby';
|
|
70
|
+
body = `${ASSEMBLED_NOTE}\n\n${opts.assembled ?? 'sideby could not read the previous session’s records.\n'}`;
|
|
71
|
+
}
|
|
72
|
+
const repo = await gitSection(opts.cwd);
|
|
73
|
+
await writeFileAtomic(opts.target, `${body.trimEnd()}\n${repo ? `\n${repo}` : ''}`, 0o600);
|
|
74
|
+
return { path: opts.target, source };
|
|
75
|
+
}
|