@clipboard-health/groundcrew 4.0.2 → 4.1.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 (62) hide show
  1. package/README.md +32 -13
  2. package/crew.config.example.ts +5 -18
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +64 -10
  5. package/dist/commands/interruptWorkspace.d.ts.map +1 -1
  6. package/dist/commands/interruptWorkspace.js +3 -3
  7. package/dist/commands/resumeWorkspace.d.ts.map +1 -1
  8. package/dist/commands/resumeWorkspace.js +1 -2
  9. package/dist/commands/setupRepos.d.ts.map +1 -1
  10. package/dist/commands/setupRepos.js +2 -13
  11. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  12. package/dist/commands/setupWorkspace.js +1 -7
  13. package/dist/lib/agentLaunch.d.ts +0 -6
  14. package/dist/lib/agentLaunch.d.ts.map +1 -1
  15. package/dist/lib/agentLaunch.js +1 -12
  16. package/dist/lib/cmuxAdapter.d.ts +8 -0
  17. package/dist/lib/cmuxAdapter.d.ts.map +1 -0
  18. package/dist/lib/cmuxAdapter.js +163 -0
  19. package/dist/lib/config.d.ts +2 -76
  20. package/dist/lib/config.d.ts.map +1 -1
  21. package/dist/lib/config.js +29 -102
  22. package/dist/lib/launchCommand.d.ts +3 -3
  23. package/dist/lib/sandboxName.d.ts +9 -0
  24. package/dist/lib/sandboxName.d.ts.map +1 -0
  25. package/dist/lib/sandboxName.js +12 -0
  26. package/dist/lib/tmuxAdapter.d.ts +9 -0
  27. package/dist/lib/tmuxAdapter.d.ts.map +1 -0
  28. package/dist/lib/tmuxAdapter.js +156 -0
  29. package/dist/lib/util.d.ts +11 -0
  30. package/dist/lib/util.d.ts.map +1 -1
  31. package/dist/lib/util.js +21 -0
  32. package/dist/lib/workspaceAdapter.d.ts +79 -0
  33. package/dist/lib/workspaceAdapter.d.ts.map +1 -0
  34. package/dist/lib/workspaceAdapter.js +17 -0
  35. package/dist/lib/workspaces.d.ts +7 -55
  36. package/dist/lib/workspaces.d.ts.map +1 -1
  37. package/dist/lib/workspaces.js +8 -404
  38. package/package.json +1 -2
  39. package/dist/commands/sandbox/auth.d.ts +0 -3
  40. package/dist/commands/sandbox/auth.d.ts.map +0 -1
  41. package/dist/commands/sandbox/auth.js +0 -227
  42. package/dist/commands/sandbox/index.d.ts +0 -2
  43. package/dist/commands/sandbox/index.d.ts.map +0 -1
  44. package/dist/commands/sandbox/index.js +0 -47
  45. package/dist/commands/sandbox/inspect.d.ts +0 -2
  46. package/dist/commands/sandbox/inspect.d.ts.map +0 -1
  47. package/dist/commands/sandbox/inspect.js +0 -18
  48. package/dist/commands/sandbox/lifecycle.d.ts +0 -7
  49. package/dist/commands/sandbox/lifecycle.d.ts.map +0 -1
  50. package/dist/commands/sandbox/lifecycle.js +0 -68
  51. package/dist/commands/sandbox/model.d.ts +0 -10
  52. package/dist/commands/sandbox/model.d.ts.map +0 -1
  53. package/dist/commands/sandbox/model.js +0 -37
  54. package/dist/commands/sandbox/picker.d.ts +0 -20
  55. package/dist/commands/sandbox/picker.d.ts.map +0 -1
  56. package/dist/commands/sandbox/picker.js +0 -23
  57. package/dist/lib/dockerSandbox.d.ts +0 -43
  58. package/dist/lib/dockerSandbox.d.ts.map +0 -1
  59. package/dist/lib/dockerSandbox.js +0 -69
  60. package/dist/lib/sandboxGitDefaults.d.ts +0 -10
  61. package/dist/lib/sandboxGitDefaults.d.ts.map +0 -1
  62. package/dist/lib/sandboxGitDefaults.js +0 -31
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspaceAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/workspaceAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,CAAC;IAC7D,0DAA0D;IAC1D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC3D;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAE7D"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared contract for Workspace backends. A Workspace is the host-side
3
+ * terminal session that runs an agent for one ticket; `Workspace.name` is
4
+ * the ticket id callers key on. The cmux and tmux adapters implement this
5
+ * interface in their own files (`cmuxAdapter.ts`, `tmuxAdapter.ts`);
6
+ * `workspaces.ts` resolves and fronts them. This is internal cleanup, not a
7
+ * plugin contract — nothing here is a published extension point.
8
+ */
9
+ import { runCommandAsync } from "./commandRunner.js";
10
+ export async function runWorkspaceCommand(command, arguments_, signal) {
11
+ return signal === undefined
12
+ ? await runCommandAsync(command, arguments_)
13
+ : await runCommandAsync(command, arguments_, { signal });
14
+ }
15
+ export function isSignalAborted(signal) {
16
+ return signal?.aborted === true;
17
+ }
@@ -1,62 +1,15 @@
1
1
  /**
2
- * Workspace adapter — opens/lists/closes the host-side terminal session
2
+ * Workspace facade — opens/lists/closes the host-side terminal session
3
3
  * that runs an agent for one ticket. `Workspace.name` is the ticket id;
4
- * callers key on it. Adapters do their own internal lookup when their
5
- * backend uses opaque refs.
4
+ * callers key on it. The cmux and tmux backends live in their own files
5
+ * (`cmuxAdapter.ts`, `tmuxAdapter.ts`) behind the shared `Adapter`
6
+ * interface in `workspaceAdapter.ts`; this module resolves which one to
7
+ * use, caches it per config, and exposes the `workspaces` API.
6
8
  */
7
9
  import type { ResolvedConfig, WorkspaceKindSetting } from "./config.ts";
8
10
  import { type HostCapabilities } from "./host.ts";
9
- export type WorkspaceKind = "cmux" | "tmux";
10
- export interface Workspace {
11
- /** Ticket id; the join key callers use. */
12
- name: string;
13
- }
14
- export interface WorkspaceStatus {
15
- text: string;
16
- color?: string;
17
- icon?: string;
18
- }
19
- export interface WorkspaceAccessHint {
20
- kind: "attachCommand";
21
- command: string;
22
- }
23
- export interface OpenSpec {
24
- /** Ticket id; becomes the workspace's name. */
25
- name: string;
26
- /** Working directory the workspace runs in. */
27
- cwd: string;
28
- /** Shell string the workspace executes (host setup + agent exec). */
29
- command: string;
30
- /** Optional status painting. Adapters that can't paint silently drop it. */
31
- status?: WorkspaceStatus;
32
- }
33
- /**
34
- * `unavailable` is "we don't know" — never treat it as "empty," or callers
35
- * would close every live workspace by deduction.
36
- */
37
- export type WorkspaceProbe = {
38
- kind: "ok";
39
- names: Set<string>;
40
- } | {
41
- kind: "unavailable";
42
- error?: unknown;
43
- };
44
- export type WorkspaceInterruptResult = {
45
- kind: "interrupted";
46
- } | {
47
- kind: "missing";
48
- } | {
49
- kind: "unavailable";
50
- error?: unknown;
51
- };
52
- export type WorkspaceCloseResult = {
53
- kind: "closed";
54
- } | {
55
- kind: "missing";
56
- } | {
57
- kind: "unavailable";
58
- error?: unknown;
59
- };
11
+ import { type OpenSpec, type WorkspaceAccessHint, type WorkspaceCloseResult, type WorkspaceInterruptResult, type WorkspaceKind, type WorkspaceProbe } from "./workspaceAdapter.ts";
12
+ export type { OpenSpec, Workspace, WorkspaceAccessHint, WorkspaceCloseResult, WorkspaceInterruptResult, WorkspaceKind, WorkspaceProbe, WorkspaceStatus, } from "./workspaceAdapter.ts";
60
13
  export interface WorkspaceResolution {
61
14
  requested: WorkspaceKindSetting;
62
15
  resolved: WorkspaceKind;
@@ -77,5 +30,4 @@ export declare const workspaces: {
77
30
  interrupt: typeof interruptWorkspace;
78
31
  accessHint(config: ResolvedConfig, name: string, signal?: AbortSignal): Promise<WorkspaceAccessHint | undefined>;
79
32
  };
80
- export {};
81
33
  //# sourceMappingURL=workspaces.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAI1E,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAwU7C,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAgOD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACH,UAAU,SACN,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;CAI5C,CAAC"}
1
+ {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE1E,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAsDD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACH,UAAU,SACN,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;CAI5C,CAAC"}
@@ -1,263 +1,15 @@
1
1
  /**
2
- * Workspace adapter — opens/lists/closes the host-side terminal session
2
+ * Workspace facade — opens/lists/closes the host-side terminal session
3
3
  * that runs an agent for one ticket. `Workspace.name` is the ticket id;
4
- * callers key on it. Adapters do their own internal lookup when their
5
- * backend uses opaque refs.
4
+ * callers key on it. The cmux and tmux backends live in their own files
5
+ * (`cmuxAdapter.ts`, `tmuxAdapter.ts`) behind the shared `Adapter`
6
+ * interface in `workspaceAdapter.ts`; this module resolves which one to
7
+ * use, caches it per config, and exposes the `workspaces` API.
6
8
  */
7
- import { runCommandAsync } from "./commandRunner.js";
9
+ import { cmuxAdapter } from "./cmuxAdapter.js";
8
10
  import { detectHostCapabilities } from "./host.js";
9
- import { shellSingleQuote } from "./shell.js";
10
- import { errorMessage, log, readEnvironmentVariable } from "./util.js";
11
- async function runWorkspaceCommand(command, arguments_, signal) {
12
- return signal === undefined
13
- ? await runCommandAsync(command, arguments_)
14
- : await runCommandAsync(command, arguments_, { signal });
15
- }
16
- function isSignalAborted(signal) {
17
- return signal?.aborted === true;
18
- }
19
- function parseCmuxList(output) {
20
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json list-workspaces always emits this shape
21
- const parsed = JSON.parse(output);
22
- const items = [];
23
- /* v8 ignore next @preserve -- cmux always emits a workspaces field; default keeps the loop safe */
24
- for (const ws of parsed.workspaces ?? []) {
25
- if (typeof ws.title !== "string" || ws.title.length === 0) {
26
- continue;
27
- }
28
- const id = pickCmuxId(ws);
29
- if (id === undefined) {
30
- log(`cmux list-workspaces returned workspace "${ws.title}" without a usable id or ref; skipping`);
31
- continue;
32
- }
33
- items.push({ title: ws.title, id });
34
- }
35
- return items;
36
- }
37
- /**
38
- * The stable workspace handle cmux v2 expects in JSON-RPC params. Prefer
39
- * the UUID; fall back to the legacy `workspace:N` short ref when older
40
- * cmux builds don't surface it. Returns `undefined` when neither is
41
- * available — cmux v2 `workspace.close` rejects titles, so we must never
42
- * forward `title` as a workspace handle.
43
- */
44
- function pickCmuxId(ws) {
45
- if (typeof ws.id === "string" && ws.id.length > 0) {
46
- return ws.id;
47
- }
48
- if (typeof ws.ref === "string" && ws.ref.length > 0) {
49
- return ws.ref;
50
- }
51
- return undefined;
52
- }
53
- async function listCmuxRaw(signal) {
54
- try {
55
- return parseCmuxList(await runWorkspaceCommand("cmux", ["--json", "list-workspaces"], signal));
56
- }
57
- catch (error) {
58
- if (isSignalAborted(signal)) {
59
- throw error;
60
- }
61
- log(`cmux list-workspaces failed: ${errorMessage(error)}`);
62
- return undefined;
63
- }
64
- }
65
- function extractCmuxOpenId(output) {
66
- try {
67
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace_id/ref object
68
- const parsed = JSON.parse(output);
69
- const uuid = parsed.workspace_id ?? parsed.id ?? "";
70
- if (uuid.length > 0) {
71
- return uuid;
72
- }
73
- const ref = parsed.workspace_ref ?? parsed.ref ?? "";
74
- if (ref.length > 0) {
75
- return ref;
76
- }
77
- }
78
- catch {
79
- /* not JSON; fall through to regex */
80
- }
81
- const match = /workspace:\d+/.exec(output);
82
- return match ? match[0] : undefined;
83
- }
84
- /**
85
- * Inspect `cmux current-workspace`. When groundcrew is itself launched
86
- * inside a cmux SSH workspace, `workspace.create` lands the new workspace
87
- * on the local (macOS) cmux app rather than the remote where the agent's
88
- * worktree lives. We can't replicate cmux's full SSH bootstrap
89
- * (relay_port, daemon, etc.) from the remote side, so we instead wrap the
90
- * agent launch command in a plain `ssh` to the same destination. Returns
91
- * `undefined` when there is nothing to inherit, leaving callers free to
92
- * launch locally as usual.
93
- */
94
- async function probeCurrentCmuxRemote(signal) {
95
- if (readEnvironmentVariable("CMUX_WORKSPACE_ID") === undefined) {
96
- return undefined;
97
- }
98
- let output;
99
- try {
100
- output = await runWorkspaceCommand("cmux", ["--json", "current-workspace"], signal);
101
- }
102
- catch (error) {
103
- if (isSignalAborted(signal)) {
104
- throw error;
105
- }
106
- // CMUX_WORKSPACE_ID is set, so we are inside a cmux workspace and a
107
- // probe failure means we cannot tell whether this is an SSH context.
108
- // Silently degrading to the local path would point cmux at a working
109
- // directory that lives on a remote host; surface the failure instead
110
- // so the caller can roll the worktree back rather than launch into
111
- // the void.
112
- throw new Error(`cmux current-workspace probe failed while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
113
- }
114
- try {
115
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json current-workspace shape per v2 API
116
- const parsed = JSON.parse(output);
117
- const remote = parsed.workspace?.remote;
118
- if (remote === undefined ||
119
- remote.connected !== true ||
120
- remote.transport !== "ssh" ||
121
- typeof remote.destination !== "string" ||
122
- remote.destination.length === 0) {
123
- return undefined;
124
- }
125
- const inherited = { destination: remote.destination };
126
- if (typeof remote.port === "number") {
127
- inherited.port = remote.port;
128
- }
129
- if (typeof remote.identity_file === "string" && remote.identity_file.length > 0) {
130
- inherited.identity_file = remote.identity_file;
131
- }
132
- if (Array.isArray(remote.ssh_options) && remote.ssh_options.length > 0) {
133
- inherited.ssh_options = remote.ssh_options;
134
- }
135
- return inherited;
136
- }
137
- catch (error) {
138
- // Same reasoning as the command-failure branch above: with
139
- // CMUX_WORKSPACE_ID set, malformed JSON means we cannot decide
140
- // between local and SSH context, so refuse rather than silently
141
- // launching at the wrong working directory.
142
- throw new Error(`cmux current-workspace returned malformed output while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
143
- }
144
- }
145
- /**
146
- * Compose an `ssh -t <destination> -- <cd && cmd>` invocation that lands
147
- * a new cmux workspace's terminal on the same SSH remote where
148
- * groundcrew is running. Path-bearing fields (`cwd`, the launch script
149
- * inside `command`) stay valid because the remote shell evaluates them.
150
- * The outermost return value is a single shell string suitable for
151
- * `cmux new-workspace --command`.
152
- */
153
- function buildSshWrappedCommand(spec, remote) {
154
- const remoteShell = `cd ${shellSingleQuote(spec.cwd)} && ${spec.command}`;
155
- const sshTokens = ["ssh", "-t"];
156
- if (remote.port !== undefined) {
157
- sshTokens.push("-p", String(remote.port));
158
- }
159
- if (remote.identity_file !== undefined) {
160
- sshTokens.push("-i", shellSingleQuote(remote.identity_file));
161
- }
162
- for (const option of remote.ssh_options ?? []) {
163
- sshTokens.push("-o", shellSingleQuote(option));
164
- }
165
- sshTokens.push(shellSingleQuote(remote.destination), "--", shellSingleQuote(remoteShell));
166
- return sshTokens.join(" ");
167
- }
168
- async function applyCmuxStatus(workspaceId, status, signal) {
169
- const arguments_ = ["set-status", "model", status.text];
170
- if (status.icon !== undefined) {
171
- arguments_.push("--icon", status.icon);
172
- }
173
- if (status.color !== undefined) {
174
- arguments_.push("--color", status.color);
175
- }
176
- arguments_.push("--workspace", workspaceId);
177
- await runWorkspaceCommand("cmux", arguments_, signal);
178
- }
179
- async function closeCmuxWorkspace(workspaceId, signal) {
180
- await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", workspaceId], signal);
181
- }
182
- function isCmuxSetStatusUnsupported(error) {
183
- return errorMessage(error).includes('unknown command "set-status"');
184
- }
185
- const cmuxAdapter = {
186
- async open(spec, signal) {
187
- const inheritedRemote = await probeCurrentCmuxRemote(signal);
188
- const newWorkspaceArguments = ["--json", "new-workspace", "--name", spec.name];
189
- if (inheritedRemote === undefined) {
190
- newWorkspaceArguments.push("--cwd", spec.cwd, "--command", spec.command);
191
- }
192
- else {
193
- // Skip --cwd: the path is on the SSH remote and would fall back to
194
- // $HOME (macOS) when cmux tries to chdir locally. The wrapped ssh
195
- // command does its own `cd` on the remote side.
196
- newWorkspaceArguments.push("--command", buildSshWrappedCommand(spec, inheritedRemote));
197
- }
198
- const output = await runWorkspaceCommand("cmux", newWorkspaceArguments, signal);
199
- const workspaceId = extractCmuxOpenId(output);
200
- if (workspaceId === undefined) {
201
- log(`cmux new-workspace returned unrecognized output for ${spec.name}; if a workspace was created, run \`cmux close-workspace\` manually.`);
202
- throw new Error(`Unexpected cmux output: ${output}`);
203
- }
204
- if (spec.status !== undefined) {
205
- try {
206
- await applyCmuxStatus(workspaceId, spec.status, signal);
207
- }
208
- catch (error) {
209
- // Status pills are best-effort. cmux v2+ dropped `set-status` entirely,
210
- // so swallow that specific gap silently; surface anything else so a real
211
- // regression doesn't hide behind the same swallow.
212
- if (!isCmuxSetStatusUnsupported(error)) {
213
- log(`cmux set-status failed for ${spec.name} (continuing): ${errorMessage(error)}`);
214
- }
215
- }
216
- }
217
- },
218
- async list(signal) {
219
- const raw = await listCmuxRaw(signal);
220
- return raw?.map((ws) => ({ name: ws.title }));
221
- },
222
- async close(name, signal) {
223
- const raw = await listCmuxRaw(signal);
224
- if (raw === undefined) {
225
- // cmux v2 `workspace.close` rejects titles, so forwarding `name`
226
- // would always fail. The list failure has already been logged by
227
- // `listCmuxRaw`; bail rather than guarantee a downstream error.
228
- log(`cmux close-workspace skipped for ${name}: list-workspaces failed, no usable id`);
229
- return { kind: "unavailable" };
230
- }
231
- const match = raw.find((ws) => ws.title === name);
232
- if (match === undefined) {
233
- return { kind: "missing" };
234
- }
235
- try {
236
- await closeCmuxWorkspace(match.id, signal);
237
- return { kind: "closed" };
238
- }
239
- catch (error) {
240
- if (isSignalAborted(signal)) {
241
- throw error;
242
- }
243
- const remaining = await listCmuxRaw(signal);
244
- if (remaining === undefined) {
245
- return { kind: "unavailable", error };
246
- }
247
- const isStillPresent = remaining.some((ws) => ws.title === name);
248
- if (!isStillPresent) {
249
- return { kind: "closed" };
250
- }
251
- throw error;
252
- }
253
- },
254
- accessHint(_name) {
255
- // cmux is a TUI; users surface workspaces by launching the cmux app,
256
- // not a shell command. No useful hint to emit.
257
- // oxlint-disable-next-line unicorn/no-useless-undefined -- explicit signal that the backend has no hint
258
- return undefined;
259
- },
260
- };
11
+ import { tmuxAdapter } from "./tmuxAdapter.js";
12
+ import { isSignalAborted, } from "./workspaceAdapter.js";
261
13
  export function resolveWorkspaceKind(arguments_) {
262
14
  const { config, host } = arguments_;
263
15
  const requested = config.workspaceKind;
@@ -290,154 +42,6 @@ function failIfBinaryUnavailable(kind, host) {
290
42
  throw new Error(`workspaceKind '${kind}' is set but the ${kind} binary is not on PATH. Install ${kind} or change the setting.`);
291
43
  }
292
44
  }
293
- const TMUX_SESSION = "groundcrew";
294
- // `tmux new-session -d -s …` always creates one initial window. Without
295
- // `-n`, that window is named after the running shell (e.g. "0" / "zsh") and
296
- // would surface from `list()` as a phantom workspace. We name it with this
297
- // sentinel and filter it out — it stays around as a placeholder so the
298
- // session doesn't collapse when the last ticket window closes.
299
- const TMUX_IDLE_WINDOW = "_groundcrew_idle";
300
- function tmuxTarget(name) {
301
- return `${TMUX_SESSION}:${name}`;
302
- }
303
- function isTmuxNotFoundError(error) {
304
- // runCommand surfaces the child's stderr in error.message, so the "no
305
- // server" / "missing session" / "can't find window" signatures are visible
306
- // without a separate stderr probe.
307
- const message = errorMessage(error);
308
- return (message.includes("no server running") ||
309
- message.includes("can't find session") ||
310
- message.includes("can't find window"));
311
- }
312
- async function probeTmuxList(format, signal) {
313
- try {
314
- return {
315
- status: "ok",
316
- output: await runWorkspaceCommand("tmux", ["list-windows", "-t", TMUX_SESSION, "-F", format], signal),
317
- };
318
- }
319
- catch (error) {
320
- if (isSignalAborted(signal)) {
321
- throw error;
322
- }
323
- if (isTmuxNotFoundError(error)) {
324
- return { status: "missing" };
325
- }
326
- return { status: "failed", reason: errorMessage(error) };
327
- }
328
- }
329
- async function ensureTmuxSession(signal) {
330
- try {
331
- await runWorkspaceCommand("tmux", ["has-session", "-t", TMUX_SESSION], signal);
332
- return;
333
- }
334
- catch (error) {
335
- if (isSignalAborted(signal)) {
336
- throw error;
337
- }
338
- /* session missing or server down; create it */
339
- }
340
- try {
341
- await runWorkspaceCommand("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", TMUX_IDLE_WINDOW], signal);
342
- }
343
- catch (error) {
344
- if (isSignalAborted(signal)) {
345
- throw error;
346
- }
347
- try {
348
- await runWorkspaceCommand("tmux", ["has-session", "-t", TMUX_SESSION], signal);
349
- }
350
- catch {
351
- throw error;
352
- }
353
- }
354
- }
355
- function parseTmuxWindows(output) {
356
- const items = [];
357
- for (const line of output.split("\n")) {
358
- if (line.length === 0) {
359
- continue;
360
- }
361
- const [name, deadFlag] = line.split("\t");
362
- /* v8 ignore next 3 @preserve -- split on a non-empty string always yields a non-empty first element */
363
- if (name === undefined || name.length === 0) {
364
- continue;
365
- }
366
- if (name === TMUX_IDLE_WINDOW) {
367
- continue;
368
- }
369
- // pane_dead != 0 means the command exited and the window is a zombie
370
- // (only happens when remain-on-exit is on; defense in depth in case a
371
- // user-globally-set value beats our per-window override).
372
- if (deadFlag !== undefined && deadFlag !== "0") {
373
- continue;
374
- }
375
- items.push({ name });
376
- }
377
- return items;
378
- }
379
- const tmuxAdapter = {
380
- async open(spec, signal) {
381
- await ensureTmuxSession(signal);
382
- const target = tmuxTarget(spec.name);
383
- const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
384
- const keepDeadWindows = keepDeadWindowsEnv !== undefined && keepDeadWindowsEnv.length > 0;
385
- await runWorkspaceCommand("tmux", [
386
- "new-window",
387
- "-d",
388
- "-t",
389
- TMUX_SESSION,
390
- "-n",
391
- spec.name,
392
- "-c",
393
- spec.cwd,
394
- spec.command,
395
- ";",
396
- "set-window-option",
397
- "-t",
398
- target,
399
- "remain-on-exit",
400
- keepDeadWindows ? "on" : "off",
401
- ";",
402
- "set-window-option",
403
- "-t",
404
- target,
405
- "allow-rename",
406
- "off",
407
- ], signal);
408
- // tmux can't paint status pills; spec.status is silently dropped.
409
- },
410
- async list(signal) {
411
- const probe = await probeTmuxList("#{window_name}\t#{pane_dead}", signal);
412
- if (probe.status === "missing") {
413
- return [];
414
- }
415
- if (probe.status === "failed") {
416
- log(`tmux list-windows failed: ${probe.reason}`);
417
- // oxlint-disable-next-line unicorn/no-useless-undefined -- undefined marks the workspace backend as unavailable.
418
- return undefined;
419
- }
420
- return parseTmuxWindows(probe.output);
421
- },
422
- async close(name, signal) {
423
- try {
424
- await runWorkspaceCommand("tmux", ["kill-window", "-t", tmuxTarget(name)], signal);
425
- return { kind: "closed" };
426
- }
427
- catch (error) {
428
- if (isSignalAborted(signal)) {
429
- throw error;
430
- }
431
- if (isTmuxNotFoundError(error)) {
432
- return { kind: "missing" };
433
- }
434
- throw error;
435
- }
436
- },
437
- accessHint(name) {
438
- return { kind: "attachCommand", command: `tmux attach -t ${tmuxTarget(name)}` };
439
- },
440
- };
441
45
  // Per-config cache: production resolves the adapter once at first use
442
46
  // (loadConfig returns a frozen, cached instance); each test uses a fresh
443
47
  // config object so the cache invalidates naturally between tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",
@@ -68,7 +68,6 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@clipboard-health/clearance": "1.0.8",
71
- "@inquirer/checkbox": "5.1.5",
72
71
  "@linear/sdk": "86.0.0",
73
72
  "cosmiconfig": "9.0.1",
74
73
  "tslib": "2.8.1",
@@ -1,3 +0,0 @@
1
- import type { ResolvedConfig } from "../../lib/config.ts";
2
- export declare function runAuth(config: ResolvedConfig, argv: string[]): Promise<void>;
3
- //# sourceMappingURL=auth.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/auth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAc,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAuNtE,wBAAsB,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAcnF"}