aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/core/sl0ppij.ts
blob: e5db9e8aa1054f81bd013410f603ae7dec221bd5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
 * Thin wrapper around the sl0ppij binary.
 *
 * sl0ppi orchestrates, it does not reimplement: every piece of state comes back
 * out of `list-tabs`/`list-panes`, so there is nothing here to desync.
 *
 * Index conventions, both verified against a live session rather than inferred,
 * because getting either wrong closes or focuses the wrong tab:
 *
 *   list-tabs `position`     0-based
 *   close-tab --tab-id       0-based  (same numbering as `position`)
 *   go-to-tab <INDEX>        1-BASED  (matches the `GoToTab 1` keybinds)
 *
 * Closing also RENUMBERS the tabs after it, so a batch of closes must run in
 * descending position order.
 */

import { spawnSync } from "node:child_process";

export const DEFAULT_BIN = "sl0ppij";

export interface TabInfo {
  position: number;
  name: string;
  purpose?: string;
  active?: boolean;
}

export interface PaneInfo {
  id: number;
  tab_id: number;
  is_plugin: boolean;
  title: string;
  purpose?: string;
  exited?: boolean;
  is_held?: boolean;
  terminal_command?: string | null;
}

export interface RunResult {
  code: number;
  stdout: string;
  stderr: string;
}

export class Sl0ppijError extends Error {
  constructor(
    message: string,
    readonly result: RunResult,
  ) {
    super(message);
    this.name = "Sl0ppijError";
  }
}

/**
 * The variables that tell a sl0ppij process "you are inside a session".
 *
 * Both spellings, because the fork exports `SL0PPIJ*` and keeps `ZELLIJ*` for
 * ecosystem compatibility.
 */
export const SESSION_ENV_KEYS = [
  "SL0PPIJ",
  "SL0PPIJ_SESSION_NAME",
  "SL0PPIJ_PANE_ID",
  "ZELLIJ",
  "ZELLIJ_SESSION_NAME",
  "ZELLIJ_PANE_ID",
] as const;

/** A copy of `env` with the "inside a session" markers removed. */
export function withoutSessionEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
  const copy = { ...env };
  for (const key of SESSION_ENV_KEYS) delete copy[key];
  return copy;
}

export interface ClientOptions {
  bin?: string;
  session?: string;
}

export class Sl0ppij {
  readonly bin: string;
  readonly session?: string;

  constructor(options: ClientOptions = {}) {
    this.bin = options.bin ?? process.env.SL0PPI_BIN ?? DEFAULT_BIN;
    this.session = options.session;
  }

  /** Same client, pinned to a session. */
  forSession(session: string): Sl0ppij {
    return new Sl0ppij({ bin: this.bin, session });
  }

  run(
    args: string[],
    { session = true, detached = false }: { session?: boolean; detached?: boolean } = {},
  ): RunResult {
    const argv = session && this.session ? ["-s", this.session, ...args] : args;
    if (process.env.SL0PPI_DEBUG) {
      process.stderr.write(`sl0ppi: exec ${this.bin} ${argv.map((a) => JSON.stringify(a)).join(" ")}\n`);
    }
    const proc = spawnSync(this.bin, argv, {
      encoding: "utf8",
      env: detached ? withoutSessionEnv() : process.env,
    });
    if (proc.error) {
      throw new Sl0ppijError(`could not run ${this.bin}: ${proc.error.message}`, {
        code: -1,
        stdout: "",
        stderr: String(proc.error.message),
      });
    }
    return {
      code: proc.status ?? -1,
      stdout: proc.stdout ?? "",
      stderr: proc.stderr ?? "",
    };
  }

  private runOk(
    args: string[],
    what: string,
    opts?: { session?: boolean; detached?: boolean },
  ): RunResult {
    const result = this.run(args, opts);
    if (result.code !== 0) {
      throw new Sl0ppijError(`${what} failed: ${result.stderr.trim() || result.stdout.trim()}`, result);
    }
    return result;
  }

  listTabs(): TabInfo[] {
    return this.readList<TabInfo>(["action", "list-tabs", "--json"], "list-tabs");
  }

  listPanes(): PaneInfo[] {
    return this.readList<PaneInfo>(["action", "list-panes", "--json"], "list-panes");
  }

  /**
   * Read a `--json` listing, tolerating a flaky underlying CLI.
   *
   * Measured on a 2-vCPU Debian box against a live session: `list-tabs` and
   * `list-panes` each fail about 60% of the time when the session is detached,
   * and about 25% when a client is attached. The failure is either an empty
   * stdout with exit 0, or "There is no active session!" from the action client
   * -- which checks `get_active_session()` before it even looks at `-s`.
   *
   * That is a defect in the terminal, not something a workspace tool should
   * inherit: these are idempotent reads, so retry them. If the session really
   * is gone, say so instead of retrying into a wall.
   */
  private readList<T>(args: string[], what: string, attempts = 5): T[] {
    let lastProblem = "";
    for (let attempt = 0; attempt < attempts; attempt++) {
      const result = this.run(args);
      if (result.code === 0) {
        const text = result.stdout.trim();
        if (text.length > 0) return parseJsonList<T>(text, what);
        lastProblem = "empty output";
      } else {
        lastProblem = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? "";
      }
      if (process.env.SL0PPI_DEBUG) {
        process.stderr.write(`sl0ppi: ${what} attempt ${attempt + 1}: ${lastProblem}\n`);
      }
      pause(120 * (attempt + 1));
    }

    // Out of retries. Distinguish "the session is gone" (an answer) from "the
    // terminal would not talk to us" (a problem worth reporting).
    if (this.session !== undefined && !this.sessionExists(this.session)) return [];
    throw new Sl0ppijError(`${what} failed ${attempts}x (last: ${lastProblem})`, {
      code: -1,
      stdout: "",
      stderr: lastProblem,
    });
  }

  /** Adds a tab from a KDL layout string. Returns its new position. */
  newTab(layoutKdl: string): number {
    const { stdout } = this.runOk(
      ["action", "new-tab", "--layout-string", layoutKdl],
      "new-tab",
    );
    return Number.parseInt(stdout.trim(), 10);
  }

  /** `position` is the 0-based `position` from list-tabs. */
  closeTab(position: number): void {
    this.runOk(["action", "close-tab", "--tab-id", String(position)], "close-tab");
  }

  /** Takes a 0-based position and converts to go-to-tab's 1-based index. */
  focusTab(position: number): void {
    this.runOk(["action", "go-to-tab", String(position + 1)], "go-to-tab");
  }

  /**
   * Every session sl0ppij knows about, live or dead-but-resurrectable.
   *
   * `--short` lists EXITED sessions too with no way to tell them apart, so we
   * parse the long form. (The same trap is documented in the pane-finder
   * installer.) The dead ones matter enormously: see `deadSessions`.
   */
  sessions(): { name: string; dead: boolean }[] {
    const result = this.run(["list-sessions", "--no-formatting"], { session: false });
    if (result.code !== 0) return []; // "no sessions" exits non-zero, not a failure
    return result.stdout
      .split("\n")
      .filter((line) => line.trim().length > 0)
      .map((line) => ({
        name: line.trim().split(/\s+/)[0] as string,
        dead: line.includes("EXITED"),
      }))
      .filter((session) => session.name.length > 0);
  }

  liveSessions(): string[] {
    return this.sessions().filter((s) => !s.dead).map((s) => s.name);
  }

  /**
   * Sessions that are gone but still resurrectable.
   *
   * These are not cosmetic. `attach --create-background NAME` on one of these
   * RESURRECTS it from the serialised copy and silently ignores `--layout`, so
   * "create a session from this layout" quietly becomes "restore whatever was
   * there before" -- which is how a root full of projects came back as plain
   * shells with no agents.
   */
  deadSessions(): string[] {
    return this.sessions().filter((s) => s.dead).map((s) => s.name);
  }

  sessionExists(name: string): boolean {
    return this.liveSessions().includes(name);
  }

  /** Discards a session AND its serialised copy, so the name is reusable. */
  deleteSession(name: string): void {
    this.run(["delete-session", name], { session: false, detached: true });
  }

  /**
   * Block until a session is actually answering, or give up.
   *
   * Creating (or resurrecting) a session returns before the server is
   * registered, so querying it straight away fails with "There is no active
   * session!". Poll for readiness rather than sleeping a guessed interval:
   * usually it is ready on the first check, and a slow box still works.
   */
  waitForSession(name: string, timeoutMs = 10_000): boolean {
    const deadline = Date.now() + timeoutMs;
    for (;;) {
      if (this.sessionExists(name)) return true;
      if (Date.now() >= deadline) return false;
      pause(150);
    }
  }

  /**
   * Creates a detached session from a layout file, without attaching.
   *
   * Runs with the session markers stripped: `attach --create-background` fails
   * with "There is no active session!" when the caller is itself inside a
   * session, and sl0ppi is nearly always invoked from inside one. We are
   * creating a new detached session, not nesting, so dropping the markers is
   * exactly right.
   */
  createBackground(name: string, layoutPath: string): void {
    this.runOk(["--layout", layoutPath, "attach", "--create-background", name], "create session", {
      session: false,
      detached: true,
    });
  }
}

/**
 * Parse a `--json` listing.
 *
 * Empty stdout with a zero exit is a real state, not corruption: it is what
 * `action list-panes` returns once the session has died underneath us. Treat it
 * as "nothing", and keep the raw text in the error for anything genuinely
 * malformed -- a bare "JSON Parse error: Unexpected EOF" tells a user nothing.
 */
export function parseJsonList<T>(stdout: string, what: string): T[] {
  const text = stdout.trim();
  if (text.length === 0) return [];
  try {
    const parsed = JSON.parse(text);
    return Array.isArray(parsed) ? (parsed as T[]) : [];
  } catch (error) {
    throw new Sl0ppijError(
      `${what} returned something that is not JSON: ${text.slice(0, 200)}`,
      { code: 0, stdout, stderr: "" },
    );
  }
}

/** Synchronous pause. These commands are one-shot and strictly sequential. */
function pause(ms: number): void {
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

/** True when we are running inside a sl0ppij/zellij pane already. */
export function insideSession(env: NodeJS.ProcessEnv = process.env): string | null {
  return env.SL0PPIJ_SESSION_NAME ?? env.ZELLIJ_SESSION_NAME ?? null;
}