agent-yes 1.197.0 → 1.198.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/ts/ws.ts ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * `ay ws` — workspace management over the codehost/provision standard layout
3
+ * (`<wsRoot>/<owner>/<repo>/tree/<branch>`, independent clones except forked
4
+ * linked worktrees).
5
+ *
6
+ * v1 surface (read/list/new/fork only — deletion/gc/doctor are deliberately
7
+ * deferred until workspace lifecycle data exists to design them around):
8
+ *
9
+ * ay ws ls [--status] [--json]
10
+ * ay ws status [<spec-or-path>] [--path|--spec] [--json]
11
+ * ay ws new <source> [--create]
12
+ * ay ws fork <branch> [--from <path>] [--wip]
13
+ *
14
+ * `--json` schema (stable, versioned via `schema`):
15
+ * ls: { schema:"ay-ws/v1", wsRoot, workspaces: WsEntry[] }
16
+ * status: { schema:"ay-ws/v1", workspace: WsEntry }
17
+ * WsEntry = { owner, repo, branch, path, agents: { live: number },
18
+ * git?: GitStatus | null, gitError?: string }
19
+ *
20
+ * codehost/provision is a peer package (bun-linked in dev). Like serve.ts's
21
+ * fork path, it is imported lazily so `ay ls` etc. never pay for it and a
22
+ * missing install produces a clear actionable error instead of a module crash.
23
+ */
24
+
25
+ import path from "path";
26
+ import { opendir, lstat } from "fs/promises";
27
+ import { existsSync } from "fs";
28
+ import { getProvisionRoot } from "./workspaceConfig.ts";
29
+ import { listRecords } from "./subcommands.ts";
30
+ import type { GlobalPidRecord } from "./globalPidIndex.ts";
31
+
32
+ // Everything we consume from codehost/provision, typed locally so this module
33
+ // compiles without the package present (it is resolved at runtime).
34
+ interface RepoSpec {
35
+ owner: string;
36
+ repo: string;
37
+ branch: string;
38
+ }
39
+ interface GitStatus {
40
+ branch: string;
41
+ head: string;
42
+ ahead: number;
43
+ behind: number;
44
+ dirty: boolean;
45
+ hasUpstream: boolean;
46
+ }
47
+ interface ProvisionResult {
48
+ ok: boolean;
49
+ spec: RepoSpec;
50
+ folder: string;
51
+ existed: boolean;
52
+ action: string;
53
+ git?: GitStatus;
54
+ error?: string;
55
+ reason?: "branch-not-found" | "repo-not-found" | "other";
56
+ }
57
+ interface Provision {
58
+ resolveWsRoot(wsRoot?: string): string;
59
+ parseSource(input: string): RepoSpec | null;
60
+ folderFor(spec: RepoSpec, wsRoot?: string): string;
61
+ readStatus(dir: string): Promise<GitStatus>;
62
+ provision(spec: RepoSpec, opts?: { wsRoot?: string }): Promise<ProvisionResult>;
63
+ createBranch(spec: RepoSpec, opts?: { wsRoot?: string }): Promise<ProvisionResult>;
64
+ forkWorktree(opts: {
65
+ fromCwd: string;
66
+ branch: string;
67
+ wsRoot?: string;
68
+ wip?: boolean;
69
+ }): Promise<ProvisionResult>;
70
+ }
71
+
72
+ async function loadProvision(): Promise<Provision> {
73
+ try {
74
+ return (await import("codehost/provision")) as unknown as Provision;
75
+ } catch (e) {
76
+ throw new Error(
77
+ `'ay ws' needs the 'codehost' package (codehost/provision) — install it ` +
78
+ `(npm i -g codehost) or 'bun link' it for local dev: ${(e as Error).message}`,
79
+ );
80
+ }
81
+ }
82
+
83
+ export const WS_JSON_SCHEMA = "ay-ws/v1";
84
+
85
+ // A workspace found under the standard layout.
86
+ export interface WsEntry {
87
+ owner: string;
88
+ repo: string;
89
+ branch: string;
90
+ path: string;
91
+ agents: { live: number };
92
+ git?: GitStatus | null;
93
+ gitError?: string;
94
+ }
95
+
96
+ // readStatus can block up to git's 120s timeout per workspace, so status joins
97
+ // run through a small worker pool instead of one unbounded Promise.all.
98
+ const STATUS_CONCURRENCY = 8;
99
+ // Branch names may contain `/`, so the walk below `tree/` is recursive; this
100
+ // bounds a pathological/looping layout, not a legitimate branch depth.
101
+ const MAX_BRANCH_DEPTH = 8;
102
+
103
+ /**
104
+ * Path containment that matches how the OS actually compares paths: segment
105
+ * boundaries via path.relative (never a raw startsWith), case-insensitive on
106
+ * Windows (and macOS's default case-insensitive FS is fine with this too —
107
+ * false positives there require two dirs differing only by case).
108
+ */
109
+ export function isPathInside(parent: string, child: string): boolean {
110
+ let p = path.resolve(parent);
111
+ let c = path.resolve(child);
112
+ if (process.platform === "win32") {
113
+ p = p.toLowerCase();
114
+ c = c.toLowerCase();
115
+ }
116
+ const rel = path.relative(p, c);
117
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
118
+ }
119
+
120
+ /** True when `dir` is a git checkout root (`.git` file = linked worktree, dir = clone). */
121
+ function isCheckoutRoot(dir: string): boolean {
122
+ return existsSync(path.join(dir, ".git"));
123
+ }
124
+
125
+ // List real subdirectories (no symlinks, no dotdirs), tolerating vanished dirs.
126
+ async function subdirs(dir: string): Promise<string[]> {
127
+ const out: string[] = [];
128
+ let d: Awaited<ReturnType<typeof opendir>>;
129
+ try {
130
+ d = await opendir(dir);
131
+ } catch {
132
+ return out;
133
+ }
134
+ for await (const ent of d) {
135
+ if (ent.name.startsWith(".")) continue;
136
+ if (ent.isDirectory()) {
137
+ out.push(ent.name);
138
+ } else if (ent.isSymbolicLink()) {
139
+ // never follow symlinks — a link cycle under tree/ must not loop the walk
140
+ continue;
141
+ }
142
+ }
143
+ return out.sort();
144
+ }
145
+
146
+ /**
147
+ * Walk `<wsRoot>/<owner>/<repo>/tree/**` collecting checkout roots. The branch
148
+ * may contain `/`, so below `tree/` we descend until a `.git` marker is found
149
+ * (a checkout root is never nested inside another checkout in this layout),
150
+ * bounded by MAX_BRANCH_DEPTH.
151
+ */
152
+ export async function walkWorkspaces(wsRoot: string): Promise<Omit<WsEntry, "agents">[]> {
153
+ const found: Omit<WsEntry, "agents">[] = [];
154
+
155
+ async function walkBranches(dir: string, owner: string, repo: string, segs: string[]) {
156
+ if (segs.length > MAX_BRANCH_DEPTH) return;
157
+ for (const name of await subdirs(dir)) {
158
+ const p = path.join(dir, name);
159
+ const branchSegs = [...segs, name];
160
+ if (isCheckoutRoot(p)) {
161
+ found.push({ owner, repo, branch: branchSegs.join("/"), path: p });
162
+ } else {
163
+ await walkBranches(p, owner, repo, branchSegs);
164
+ }
165
+ }
166
+ }
167
+
168
+ for (const owner of await subdirs(wsRoot)) {
169
+ for (const repo of await subdirs(path.join(wsRoot, owner))) {
170
+ const tree = path.join(wsRoot, owner, repo, "tree");
171
+ try {
172
+ if (!(await lstat(tree)).isDirectory()) continue;
173
+ } catch {
174
+ continue;
175
+ }
176
+ await walkBranches(tree, owner, repo, []);
177
+ }
178
+ }
179
+ return found;
180
+ }
181
+
182
+ /** Live (non-exited) agents whose cwd sits inside `wsPath`. */
183
+ function liveAgentsIn(records: GlobalPidRecord[], wsPath: string): GlobalPidRecord[] {
184
+ return records.filter((r) => r.cwd && isPathInside(wsPath, r.cwd));
185
+ }
186
+
187
+ // Run `fn` over items with at most `limit` in flight (order preserved).
188
+ async function mapBounded<T, R>(
189
+ items: T[],
190
+ limit: number,
191
+ fn: (item: T) => Promise<R>,
192
+ ): Promise<R[]> {
193
+ const out: R[] = new Array(items.length);
194
+ let next = 0;
195
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
196
+ while (next < items.length) {
197
+ const i = next++;
198
+ out[i] = await fn(items[i]!);
199
+ }
200
+ });
201
+ await Promise.all(workers);
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Resolve a status/inspect operand deterministically:
207
+ * 1. `--path` forces path, `--spec` forces source-spec parsing
208
+ * 2. an operand that exists on disk is a path
209
+ * 3. otherwise it must parse as a source (owner/repo[@branch|/tree/branch] or URL)
210
+ * Prints the normalized resolution so the user always sees what was addressed.
211
+ */
212
+ export async function resolveOperand(
213
+ prov: Provision,
214
+ operand: string,
215
+ mode: "auto" | "path" | "spec",
216
+ wsRoot: string | undefined,
217
+ ): Promise<{ dir: string; spec: RepoSpec | null }> {
218
+ const asPath = () => {
219
+ const dir = path.resolve(operand);
220
+ if (!existsSync(dir)) throw new Error(`path does not exist: ${dir}`);
221
+ if (!isCheckoutRoot(dir)) throw new Error(`not a git checkout root (no .git): ${dir}`);
222
+ return { dir, spec: null };
223
+ };
224
+ const asSpec = () => {
225
+ const spec = prov.parseSource(operand);
226
+ if (!spec) {
227
+ throw new Error(
228
+ `cannot parse "${operand}" as a source — expected <owner>/<repo>, ` +
229
+ `<owner>/<repo>@<branch>, <owner>/<repo>/tree/<branch>, or a github URL` +
230
+ (existsSync(operand) ? "" : " (and it is not an existing path)"),
231
+ );
232
+ }
233
+ const dir = prov.folderFor(spec, wsRoot);
234
+ if (!existsSync(dir)) throw new Error(`workspace not provisioned: ${dir} (ay ws new ${operand})`);
235
+ return { dir, spec };
236
+ };
237
+ if (mode === "path") return asPath();
238
+ if (mode === "spec") return asSpec();
239
+ // auto: an existing local path wins; only then try the spec grammar.
240
+ if (existsSync(path.resolve(operand))) return asPath();
241
+ return asSpec();
242
+ }
243
+
244
+ /**
245
+ * Default source for `ws fork`: the calling agent's registered cwd when run
246
+ * inside an agent (AGENT_YES_PID is the wrapper pid of the enclosing agent),
247
+ * else the current directory.
248
+ */
249
+ async function defaultForkFrom(): Promise<string> {
250
+ const envPid = Number(process.env.AGENT_YES_PID);
251
+ if (envPid > 0) {
252
+ const recs = await listRecords(undefined, {
253
+ all: true,
254
+ active: false,
255
+ json: false,
256
+ latest: false,
257
+ cwdScope: null,
258
+ });
259
+ const self = recs.find((r) => r.wrapper_pid === envPid) ?? recs.find((r) => r.pid === envPid);
260
+ if (self?.cwd) return self.cwd;
261
+ }
262
+ return process.cwd();
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // arg parsing (tiny, flag-only — no positional grammar beyond one operand)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ function parseFlags(
270
+ args: string[],
271
+ known: Record<string, "bool" | "value">,
272
+ ): { flags: Record<string, string | boolean>; positional: string[] } {
273
+ const flags: Record<string, string | boolean> = {};
274
+ const positional: string[] = [];
275
+ for (let i = 0; i < args.length; i++) {
276
+ const a = args[i]!;
277
+ if (!a.startsWith("--")) {
278
+ positional.push(a);
279
+ continue;
280
+ }
281
+ const eq = a.indexOf("=");
282
+ const name = eq === -1 ? a.slice(2) : a.slice(2, eq);
283
+ const kind = known[name];
284
+ if (!kind) throw new Error(`unknown flag --${name}`);
285
+ if (kind === "bool") {
286
+ if (eq !== -1) throw new Error(`--${name} takes no value`);
287
+ flags[name] = true;
288
+ } else {
289
+ const v = eq !== -1 ? a.slice(eq + 1) : args[++i];
290
+ if (v === undefined) throw new Error(`--${name} requires a value`);
291
+ flags[name] = v;
292
+ }
293
+ }
294
+ return { flags, positional };
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // subcommands
299
+ // ---------------------------------------------------------------------------
300
+
301
+ async function cmdWsLs(args: string[]): Promise<number> {
302
+ const { flags, positional } = parseFlags(args, { status: "bool", json: "bool" });
303
+ if (positional.length > 0) throw new Error(`ws ls takes no positional args`);
304
+ const prov = await loadProvision();
305
+ const wsRoot = prov.resolveWsRoot(getProvisionRoot());
306
+
307
+ const [bare, records] = await Promise.all([
308
+ walkWorkspaces(wsRoot),
309
+ listRecords(undefined, { all: false, active: false, json: false, latest: false, cwdScope: null }),
310
+ ]);
311
+
312
+ let entries: WsEntry[] = bare.map((w) => ({
313
+ ...w,
314
+ agents: { live: liveAgentsIn(records, w.path).length },
315
+ }));
316
+
317
+ if (flags.status) {
318
+ entries = await mapBounded(entries, STATUS_CONCURRENCY, async (e) => {
319
+ try {
320
+ return { ...e, git: await prov.readStatus(e.path) };
321
+ } catch (err) {
322
+ return { ...e, git: null, gitError: (err as Error).message.slice(0, 200) };
323
+ }
324
+ });
325
+ }
326
+
327
+ if (flags.json) {
328
+ process.stdout.write(
329
+ JSON.stringify({ schema: WS_JSON_SCHEMA, wsRoot, workspaces: entries }, null, 2) + "\n",
330
+ );
331
+ return 0;
332
+ }
333
+
334
+ if (entries.length === 0) {
335
+ process.stderr.write(`no workspaces under ${wsRoot} (layout <owner>/<repo>/tree/<branch>)\n`);
336
+ return 0;
337
+ }
338
+
339
+ const specOf = (e: WsEntry) => `${e.owner}/${e.repo}@${e.branch}`;
340
+ const specW = Math.max(9, ...entries.map((e) => specOf(e).length));
341
+ const agentsW = 6;
342
+ const header = flags.status
343
+ ? `${"WORKSPACE".padEnd(specW)} ${"AGENTS".padEnd(agentsW)} GIT\n`
344
+ : `${"WORKSPACE".padEnd(specW)} ${"AGENTS".padEnd(agentsW)} PATH\n`;
345
+ process.stdout.write(header);
346
+ for (const e of entries) {
347
+ const agents = e.agents.live > 0 ? String(e.agents.live) : "-";
348
+ const tail = flags.status ? gitSummary(e) : e.path;
349
+ process.stdout.write(`${specOf(e).padEnd(specW)} ${agents.padEnd(agentsW)} ${tail}\n`);
350
+ }
351
+ if (entries.some((e) => e.agents.live > 0)) {
352
+ process.stderr.write(`\n ay ls --cwd <path> # the agents inside a workspace\n`);
353
+ }
354
+ return 0;
355
+ }
356
+
357
+ function gitSummary(e: WsEntry): string {
358
+ if (e.gitError) return `error: ${e.gitError}`;
359
+ const g = e.git;
360
+ if (!g) return "?";
361
+ const parts: string[] = [];
362
+ if (g.dirty) parts.push("dirty");
363
+ if (g.ahead > 0) parts.push(`ahead ${g.ahead}`);
364
+ if (g.behind > 0) parts.push(`behind ${g.behind}`);
365
+ if (!g.hasUpstream) parts.push("no-upstream");
366
+ return parts.length ? parts.join(", ") : "clean";
367
+ }
368
+
369
+ async function cmdWsStatus(args: string[]): Promise<number> {
370
+ const { flags, positional } = parseFlags(args, { json: "bool", path: "bool", spec: "bool" });
371
+ if (flags.path && flags.spec) throw new Error("--path and --spec are mutually exclusive");
372
+ if (positional.length > 1) throw new Error("ws status takes at most one target");
373
+ const prov = await loadProvision();
374
+ const wsRoot = getProvisionRoot();
375
+ const operand = positional[0] ?? ".";
376
+ const mode = flags.path ? "path" : flags.spec ? "spec" : "auto";
377
+ const { dir, spec } = await resolveOperand(prov, operand, mode, wsRoot);
378
+ const git = await prov.readStatus(dir);
379
+
380
+ // Fill owner/repo/branch from the layout when addressed by path, best-effort.
381
+ const layoutSpec =
382
+ spec ??
383
+ (() => {
384
+ const root = prov.resolveWsRoot(wsRoot);
385
+ if (!isPathInside(root, dir)) return null;
386
+ const segs = path.relative(root, dir).split(path.sep);
387
+ return segs.length >= 4 && segs[2] === "tree"
388
+ ? { owner: segs[0]!, repo: segs[1]!, branch: segs.slice(3).join("/") }
389
+ : null;
390
+ })();
391
+
392
+ const entry: WsEntry = {
393
+ owner: layoutSpec?.owner ?? "",
394
+ repo: layoutSpec?.repo ?? "",
395
+ branch: layoutSpec?.branch ?? git.branch,
396
+ path: dir,
397
+ agents: {
398
+ live: liveAgentsIn(
399
+ await listRecords(undefined, {
400
+ all: false,
401
+ active: false,
402
+ json: false,
403
+ latest: false,
404
+ cwdScope: null,
405
+ }),
406
+ dir,
407
+ ).length,
408
+ },
409
+ git,
410
+ };
411
+
412
+ if (flags.json) {
413
+ process.stdout.write(JSON.stringify({ schema: WS_JSON_SCHEMA, workspace: entry }, null, 2) + "\n");
414
+ return 0;
415
+ }
416
+ process.stdout.write(
417
+ `${dir}\n` +
418
+ (layoutSpec ? ` spec: ${layoutSpec.owner}/${layoutSpec.repo}@${layoutSpec.branch}\n` : "") +
419
+ ` branch: ${git.branch} @ ${git.head}\n` +
420
+ ` state: ${gitSummary(entry)}\n` +
421
+ ` agents: ${entry.agents.live} live\n`,
422
+ );
423
+ if (entry.agents.live > 0) process.stderr.write(`\n ay ls --cwd ${dir}\n`);
424
+ return 0;
425
+ }
426
+
427
+ async function cmdWsNew(args: string[]): Promise<number> {
428
+ const { flags, positional } = parseFlags(args, { create: "bool" });
429
+ const source = positional[0];
430
+ if (!source || positional.length > 1)
431
+ throw new Error("usage: ay ws new <owner>/<repo>[@branch] [--create]");
432
+ const prov = await loadProvision();
433
+ const wsRoot = getProvisionRoot();
434
+ const spec = prov.parseSource(source);
435
+ if (!spec) throw new Error(`cannot parse "${source}" as a source (owner/repo[@branch] or URL)`);
436
+
437
+ // Echo the normalized target before mutating anything.
438
+ process.stderr.write(`provisioning ${spec.owner}/${spec.repo}@${spec.branch} …\n`);
439
+ let res = await prov.provision(spec, { wsRoot });
440
+ if (!res.ok && res.reason === "branch-not-found" && flags.create) {
441
+ process.stderr.write(`branch not found on remote — creating it locally (--create)\n`);
442
+ res = await prov.createBranch(spec, { wsRoot });
443
+ }
444
+ if (!res.ok) {
445
+ process.stderr.write(
446
+ `provision failed (${res.reason ?? "error"}): ${res.error ?? "unknown"}\n` +
447
+ (res.reason === "branch-not-found" && !flags.create
448
+ ? ` (branch missing on the remote — re-run with --create to branch off the default)\n`
449
+ : ""),
450
+ );
451
+ return 1;
452
+ }
453
+ process.stdout.write(`${res.action} ${res.folder}\n`);
454
+ return 0;
455
+ }
456
+
457
+ async function cmdWsFork(args: string[]): Promise<number> {
458
+ const { flags, positional } = parseFlags(args, { from: "value", wip: "bool" });
459
+ const branch = positional[0];
460
+ if (!branch || positional.length > 1)
461
+ throw new Error("usage: ay ws fork <new-branch> [--from <path>] [--wip]");
462
+ const prov = await loadProvision();
463
+ const fromCwd = path.resolve(
464
+ typeof flags.from === "string" ? flags.from : await defaultForkFrom(),
465
+ );
466
+
467
+ process.stderr.write(`forking ${fromCwd} → branch ${branch}${flags.wip ? " (with WIP)" : ""} …\n`);
468
+ const res = await prov.forkWorktree({
469
+ fromCwd,
470
+ branch,
471
+ wsRoot: getProvisionRoot(),
472
+ wip: !!flags.wip,
473
+ });
474
+ if (!res.ok) {
475
+ process.stderr.write(`fork failed: ${res.error ?? "unknown"}\n`);
476
+ return 1;
477
+ }
478
+ process.stdout.write(`${res.action} ${res.folder}\n`);
479
+ process.stderr.write(`\n ay claude --cwd ${res.folder} -- "<prompt>" # work there\n`);
480
+ return 0;
481
+ }
482
+
483
+ function wsHelp(): number {
484
+ process.stdout.write(
485
+ `ay ws - workspaces under <wsRoot>/<owner>/<repo>/tree/<branch>\n` +
486
+ `\n` +
487
+ ` ay ws ls [--status] [--json] list workspaces (+git state, +live agent count)\n` +
488
+ ` ay ws status [<spec|path>] [--json] one workspace's git state (default: cwd)\n` +
489
+ ` ay ws new <owner>/<repo>[@branch] clone/refresh a workspace (--create: new branch)\n` +
490
+ ` ay ws fork <branch> [--from <path>] sibling worktree off HEAD (--wip: carry changes)\n` +
491
+ `\n` +
492
+ ` targets: owner/repo, owner/repo@branch, owner/repo/tree/branch, github URL, or a path\n` +
493
+ ` wsRoot: CODEHOST_WS_ROOT > config provisionRoot > ~/ws\n`,
494
+ );
495
+ return 0;
496
+ }
497
+
498
+ /** `ay ws <sub> …` dispatcher (called from runSubcommand). */
499
+ export async function cmdWs(args: string[]): Promise<number> {
500
+ const sub = args[0];
501
+ const rest = args.slice(1);
502
+ switch (sub) {
503
+ case "ls":
504
+ case "list":
505
+ return cmdWsLs(rest);
506
+ case "status":
507
+ return cmdWsStatus(rest);
508
+ case "new":
509
+ return cmdWsNew(rest);
510
+ case "fork":
511
+ return cmdWsFork(rest);
512
+ case undefined:
513
+ case "help":
514
+ case "--help":
515
+ case "-h":
516
+ return wsHelp();
517
+ default:
518
+ process.stderr.write(`ay ws: unknown subcommand "${sub}"\n\n`);
519
+ wsHelp();
520
+ return 1;
521
+ }
522
+ }
@@ -1,9 +0,0 @@
1
- import "./ts-B64pNQlI.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-DKiSuTSp.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BY83eM58.js";
8
-
9
- export { SUPPORTED_CLIS };