pi-rlm 0.1.0 → 0.1.3

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/index.ts CHANGED
@@ -205,7 +205,8 @@ function resolveStartInput(params: RlmToolParams, cwd: string): StartRunInput {
205
205
  maxNodes: params.maxNodes ?? 24,
206
206
  maxBranching: params.maxBranching ?? 3,
207
207
  concurrency: params.concurrency ?? 2,
208
- timeoutMs: params.timeoutMs ?? defaultNodeTimeoutMs
208
+ timeoutMs: params.timeoutMs ?? defaultNodeTimeoutMs,
209
+ tmuxUseCurrentSession: params.tmuxUseCurrentSession ?? false
209
210
  };
210
211
  }
211
212
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "pi-rlm",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Recursive Language Model (RLM) extension for Pi coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "index.ts",
8
+ "bin": {
9
+ "pi-rlm": "./bin/pi-rlm.mjs"
10
+ },
8
11
  "keywords": [
9
12
  "pi-package",
10
13
  "pi-extension",
@@ -22,6 +25,7 @@
22
25
  "files": [
23
26
  "index.ts",
24
27
  "src",
28
+ "bin",
25
29
  "README.md"
26
30
  ],
27
31
  "publishConfig": {
@@ -30,6 +34,9 @@
30
34
  },
31
35
  "scripts": {
32
36
  "typecheck": "tsc --noEmit",
37
+ "build:cli": "tsc -p tsconfig.cli.json && node ./scripts/build-cli.mjs",
38
+ "prepack": "npm run build:cli",
39
+ "cli": "npm run build:cli --silent && node ./bin/pi-rlm.mjs",
33
40
  "pack:check": "npm pack --dry-run",
34
41
  "check": "npm run typecheck && npm run pack:check"
35
42
  },
package/src/backends.ts CHANGED
@@ -21,6 +21,11 @@ export interface CompletionRequest {
21
21
  toolsProfile: RlmToolsProfile;
22
22
  timeoutMs: number;
23
23
  signal?: AbortSignal;
24
+ runId?: string;
25
+ nodeId?: string;
26
+ depth?: number;
27
+ stage?: string;
28
+ tmuxUseCurrentSession?: boolean;
24
29
  }
25
30
 
26
31
  interface ProcessResult {
@@ -38,6 +43,8 @@ const defaultCliFlags = [
38
43
  "--no-themes"
39
44
  ];
40
45
 
46
+ const tmuxWindowLocks = new Map<string, Promise<void>>();
47
+
41
48
  export async function completeWithBackend(
42
49
  request: CompletionRequest,
43
50
  ctx: ExtensionContext
@@ -166,16 +173,124 @@ async function completeWithTmux(request: CompletionRequest): Promise<string> {
166
173
  const stamp = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
167
174
  const promptPath = join(tmpdir(), `pi-rlm-${stamp}.prompt.txt`);
168
175
  const outputPath = join(tmpdir(), `pi-rlm-${stamp}.output.log`);
169
- const sessionName = `pi-rlm-${stamp}`;
170
176
  const tools = profileToTools(request.toolsProfile);
171
177
 
172
178
  await fs.writeFile(promptPath, request.prompt, "utf8");
173
179
 
174
180
  const modelPart = request.model ? ` --model ${shellQuote(request.model)}` : "";
175
- const command = [
181
+ const shellScript = [
176
182
  `PROMPT_CONTENT=$(cat ${shellQuote(promptPath)})`,
177
- `PI_OFFLINE=1 pi ${defaultCliFlags.join(" ")} --tools ${shellQuote(tools)}${modelPart} \"$PROMPT_CONTENT\" > ${shellQuote(outputPath)} 2>&1`
183
+ "set -o pipefail",
184
+ `PI_OFFLINE=1 pi ${defaultCliFlags.join(" ")} --tools ${shellQuote(tools)}${modelPart} \"$PROMPT_CONTENT\" 2>&1 | tee ${shellQuote(outputPath)}`
178
185
  ].join("; ");
186
+ const command = `bash -lc ${shellQuote(shellScript)}`;
187
+
188
+ try {
189
+ if (request.runId && typeof request.depth === "number") {
190
+ const paneId = await startStructuredTmuxCall(request, command);
191
+ await waitForTmuxPane(paneId, request.timeoutMs, request.signal);
192
+
193
+ if (shouldCleanupCurrentSessionWindows(request)) {
194
+ const currentSessionName = await getCurrentTmuxSessionName();
195
+ if (currentSessionName) {
196
+ await cleanupCurrentSessionDepthWindows(currentSessionName);
197
+ }
198
+ }
199
+ } else {
200
+ await runEphemeralTmuxCall(request, command, stamp);
201
+ }
202
+
203
+ const output = await fs.readFile(outputPath, "utf8").catch(() => "");
204
+ return output.trim() || "(no response)";
205
+ } finally {
206
+ await safeUnlink(promptPath);
207
+ await safeUnlink(outputPath);
208
+ }
209
+ }
210
+
211
+ async function startStructuredTmuxCall(
212
+ request: CompletionRequest,
213
+ command: string
214
+ ): Promise<string> {
215
+ const currentSessionName = request.tmuxUseCurrentSession
216
+ ? await getCurrentTmuxSessionName()
217
+ : undefined;
218
+ const useCurrentSession = Boolean(currentSessionName);
219
+
220
+ const sessionName = currentSessionName ?? toTmuxRunSessionName(request.runId!);
221
+ const windowName = toTmuxDepthWindowName(request.depth!, useCurrentSession);
222
+
223
+ if (useCurrentSession) {
224
+ const { paneId, windowTarget } = await startPaneInDepthWindow(
225
+ sessionName,
226
+ windowName,
227
+ request.cwd,
228
+ command,
229
+ request.signal
230
+ );
231
+
232
+ await setTmuxPaneTitle(paneId, toTmuxPaneTitle(request));
233
+ await setTmuxWindowTiled(windowTarget);
234
+ return paneId;
235
+ }
236
+
237
+ const createResult = await runProcess(
238
+ "tmux",
239
+ [
240
+ "new-session",
241
+ "-d",
242
+ "-s",
243
+ sessionName,
244
+ "-n",
245
+ windowName,
246
+ "-c",
247
+ request.cwd,
248
+ "-P",
249
+ "-F",
250
+ "#{pane_id}",
251
+ command
252
+ ],
253
+ {
254
+ cwd: request.cwd,
255
+ timeoutMs: 10000,
256
+ signal: request.signal
257
+ }
258
+ );
259
+
260
+ if (createResult.code === 0) {
261
+ await configureStructuredTmuxSession(sessionName);
262
+ const paneId = createResult.stdout.trim();
263
+ const windowTarget = (await getTmuxWindowIdForPane(paneId)) ?? `${sessionName}:${windowName}`;
264
+ await setTmuxPaneTitle(paneId, toTmuxPaneTitle(request));
265
+ await setTmuxWindowTiled(windowTarget);
266
+ return paneId;
267
+ }
268
+
269
+ const createOutput = `${createResult.stderr}\n${createResult.stdout}`;
270
+ if (!isTmuxDuplicateSessionError(createOutput)) {
271
+ throw new Error(`tmux backend failed to start run session: ${createResult.stderr || createResult.stdout}`);
272
+ }
273
+
274
+ await configureStructuredTmuxSession(sessionName);
275
+ const { paneId, windowTarget } = await startPaneInDepthWindow(
276
+ sessionName,
277
+ windowName,
278
+ request.cwd,
279
+ command,
280
+ request.signal
281
+ );
282
+
283
+ await setTmuxPaneTitle(paneId, toTmuxPaneTitle(request));
284
+ await setTmuxWindowTiled(windowTarget);
285
+ return paneId;
286
+ }
287
+
288
+ async function runEphemeralTmuxCall(
289
+ request: CompletionRequest,
290
+ command: string,
291
+ stamp: string
292
+ ): Promise<void> {
293
+ const sessionName = `pi-rlm-${stamp}`;
179
294
 
180
295
  const startResult = await runProcess(
181
296
  "tmux",
@@ -188,35 +303,374 @@ async function completeWithTmux(request: CompletionRequest): Promise<string> {
188
303
  );
189
304
 
190
305
  if (startResult.code !== 0) {
191
- await safeUnlink(promptPath);
192
306
  throw new Error(`tmux backend failed to start: ${startResult.stderr || startResult.stdout}`);
193
307
  }
194
308
 
195
309
  const deadline = Date.now() + request.timeoutMs;
196
310
 
311
+ while (Date.now() < deadline) {
312
+ if (request.signal?.aborted) {
313
+ await killTmuxSession(sessionName);
314
+ throw new Error("RLM request aborted");
315
+ }
316
+
317
+ const alive = await hasTmuxSession(sessionName);
318
+ if (!alive) return;
319
+ await sleep(250);
320
+ }
321
+
322
+ if (await hasTmuxSession(sessionName)) {
323
+ await killTmuxSession(sessionName);
324
+ throw new Error(`tmux backend timed out after ${request.timeoutMs}ms`);
325
+ }
326
+ }
327
+
328
+ async function configureStructuredTmuxSession(sessionName: string): Promise<void> {
329
+ await runProcess(
330
+ "tmux",
331
+ ["set-window-option", "-g", "-t", sessionName, "remain-on-exit", "on"],
332
+ {
333
+ timeoutMs: 5000
334
+ }
335
+ ).catch(() => undefined);
336
+ }
337
+
338
+ async function withTmuxWindowLock<T>(
339
+ sessionName: string,
340
+ windowName: string,
341
+ worker: () => Promise<T>
342
+ ): Promise<T> {
343
+ const key = `${sessionName}:${windowName}`;
344
+ const previous = tmuxWindowLocks.get(key) ?? Promise.resolve();
345
+
346
+ let release: (() => void) | undefined;
347
+ const current = new Promise<void>((resolve) => {
348
+ release = resolve;
349
+ });
350
+
351
+ const queued = previous
352
+ .then(() => current)
353
+ .catch(() => current);
354
+ tmuxWindowLocks.set(key, queued);
355
+
356
+ await previous;
357
+
197
358
  try {
198
- while (Date.now() < deadline) {
199
- if (request.signal?.aborted) {
200
- await killTmuxSession(sessionName);
201
- throw new Error("RLM request aborted");
359
+ return await worker();
360
+ } finally {
361
+ release?.();
362
+ if (tmuxWindowLocks.get(key) === queued) {
363
+ tmuxWindowLocks.delete(key);
364
+ }
365
+ }
366
+ }
367
+
368
+ async function startPaneInDepthWindow(
369
+ sessionName: string,
370
+ windowName: string,
371
+ cwd: string,
372
+ command: string,
373
+ signal?: AbortSignal
374
+ ): Promise<{ paneId: string; windowTarget: string }> {
375
+ return withTmuxWindowLock(sessionName, windowName, async () => {
376
+ const existingWindowId = await getTmuxWindowIdByName(sessionName, windowName);
377
+
378
+ if (existingWindowId) {
379
+ const splitResult = await runProcess(
380
+ "tmux",
381
+ [
382
+ "split-window",
383
+ "-d",
384
+ "-t",
385
+ existingWindowId,
386
+ "-c",
387
+ cwd,
388
+ "-P",
389
+ "-F",
390
+ "#{pane_id}",
391
+ command
392
+ ],
393
+ {
394
+ cwd,
395
+ timeoutMs: 10000,
396
+ signal
397
+ }
398
+ );
399
+
400
+ if (splitResult.code !== 0) {
401
+ throw new Error(`tmux backend failed to create pane: ${splitResult.stderr || splitResult.stdout}`);
202
402
  }
203
403
 
204
- const alive = await hasTmuxSession(sessionName);
205
- if (!alive) break;
206
- await sleep(250);
404
+ return {
405
+ paneId: splitResult.stdout.trim(),
406
+ windowTarget: existingWindowId
407
+ };
207
408
  }
208
409
 
209
- if (await hasTmuxSession(sessionName)) {
210
- await killTmuxSession(sessionName);
211
- throw new Error(`tmux backend timed out after ${request.timeoutMs}ms`);
410
+ const createResult = await runProcess(
411
+ "tmux",
412
+ [
413
+ "new-window",
414
+ "-d",
415
+ "-t",
416
+ sessionName,
417
+ "-n",
418
+ windowName,
419
+ "-c",
420
+ cwd,
421
+ "-P",
422
+ "-F",
423
+ "#{pane_id}",
424
+ command
425
+ ],
426
+ {
427
+ cwd,
428
+ timeoutMs: 10000,
429
+ signal
430
+ }
431
+ );
432
+
433
+ if (createResult.code === 0) {
434
+ const paneId = createResult.stdout.trim();
435
+ const windowTarget = (await getTmuxWindowIdForPane(paneId)) ?? `${sessionName}:${windowName}`;
436
+ return { paneId, windowTarget };
212
437
  }
213
438
 
214
- const output = await fs.readFile(outputPath, "utf8").catch(() => "");
215
- return output.trim() || "(no response)";
216
- } finally {
217
- await safeUnlink(promptPath);
218
- await safeUnlink(outputPath);
439
+ const createOutput = `${createResult.stderr}\n${createResult.stdout}`;
440
+ if (isTmuxDuplicateWindowError(createOutput)) {
441
+ const recoveredWindowId = await getTmuxWindowIdByName(sessionName, windowName);
442
+ if (recoveredWindowId) {
443
+ const recoveredSplit = await runProcess(
444
+ "tmux",
445
+ [
446
+ "split-window",
447
+ "-d",
448
+ "-t",
449
+ recoveredWindowId,
450
+ "-c",
451
+ cwd,
452
+ "-P",
453
+ "-F",
454
+ "#{pane_id}",
455
+ command
456
+ ],
457
+ {
458
+ cwd,
459
+ timeoutMs: 10000,
460
+ signal
461
+ }
462
+ );
463
+
464
+ if (recoveredSplit.code === 0) {
465
+ return {
466
+ paneId: recoveredSplit.stdout.trim(),
467
+ windowTarget: recoveredWindowId
468
+ };
469
+ }
470
+ }
471
+ }
472
+
473
+ throw new Error(`tmux backend failed to create depth window: ${createResult.stderr || createResult.stdout}`);
474
+ });
475
+ }
476
+
477
+ async function getCurrentTmuxSessionName(): Promise<string | undefined> {
478
+ const result = await runProcess("tmux", ["display-message", "-p", "#S"], {
479
+ timeoutMs: 5000,
480
+ env: process.env
481
+ }).catch(() => ({ code: null, stdout: "", stderr: "" }));
482
+
483
+ if (!result || result.code !== 0) {
484
+ return undefined;
219
485
  }
486
+
487
+ const sessionName = result.stdout.trim();
488
+ return sessionName || undefined;
489
+ }
490
+
491
+ function shouldCleanupCurrentSessionWindows(request: CompletionRequest): boolean {
492
+ if (!request.tmuxUseCurrentSession) return false;
493
+ if (request.depth !== 0) return false;
494
+ return request.stage === "solver" || request.stage === "synthesizer";
495
+ }
496
+
497
+ async function cleanupCurrentSessionDepthWindows(sessionName: string): Promise<void> {
498
+ const result = await runProcess(
499
+ "tmux",
500
+ ["list-windows", "-t", sessionName, "-F", "#{window_id}\t#{window_name}"],
501
+ {
502
+ timeoutMs: 5000
503
+ }
504
+ );
505
+
506
+ if (result.code !== 0) {
507
+ return;
508
+ }
509
+
510
+ const rlmDepthWindowIds: string[] = [];
511
+
512
+ for (const entry of result.stdout.split("\n")) {
513
+ const line = entry.trim();
514
+ if (!line) continue;
515
+
516
+ const [windowId, ...nameParts] = line.split("\t");
517
+ if (!windowId) continue;
518
+
519
+ const windowName = nameParts.join("\t").trim();
520
+ if (!windowName.startsWith("rlm-depth-")) continue;
521
+
522
+ rlmDepthWindowIds.push(windowId.trim());
523
+ }
524
+
525
+ for (const windowId of rlmDepthWindowIds) {
526
+ await killTmuxWindow(windowId);
527
+ }
528
+ }
529
+
530
+ async function waitForTmuxPane(
531
+ paneId: string,
532
+ timeoutMs: number,
533
+ signal?: AbortSignal
534
+ ): Promise<"missing" | "dead"> {
535
+ const deadline = Date.now() + timeoutMs;
536
+
537
+ while (Date.now() < deadline) {
538
+ if (signal?.aborted) {
539
+ await killTmuxPane(paneId);
540
+ throw new Error("RLM request aborted");
541
+ }
542
+
543
+ const paneState = await getTmuxPaneState(paneId);
544
+ if (paneState === "missing" || paneState === "dead") {
545
+ return paneState;
546
+ }
547
+
548
+ await sleep(250);
549
+ }
550
+
551
+ await killTmuxPane(paneId);
552
+ throw new Error(`tmux backend timed out after ${timeoutMs}ms`);
553
+ }
554
+
555
+ async function getTmuxPaneState(paneId: string): Promise<"alive" | "dead" | "missing"> {
556
+ const result = await runProcess("tmux", ["list-panes", "-a", "-F", "#{pane_id} #{pane_dead}"], {
557
+ timeoutMs: 5000
558
+ });
559
+
560
+ if (result.code !== 0) {
561
+ return "missing";
562
+ }
563
+
564
+ const line = result.stdout
565
+ .split("\n")
566
+ .map((entry) => entry.trim())
567
+ .find((entry) => entry.startsWith(`${paneId} `));
568
+
569
+ if (!line) {
570
+ return "missing";
571
+ }
572
+
573
+ const deadFlag = line.slice(paneId.length + 1).trim();
574
+ return deadFlag === "1" ? "dead" : "alive";
575
+ }
576
+
577
+ async function getTmuxWindowIdByName(
578
+ sessionName: string,
579
+ windowName: string
580
+ ): Promise<string | undefined> {
581
+ const result = await runProcess(
582
+ "tmux",
583
+ ["list-windows", "-t", sessionName, "-F", "#{window_id}\t#{window_name}"],
584
+ {
585
+ timeoutMs: 5000
586
+ }
587
+ );
588
+
589
+ if (result.code !== 0) {
590
+ return undefined;
591
+ }
592
+
593
+ for (const entry of result.stdout.split("\n")) {
594
+ const line = entry.trim();
595
+ if (!line) continue;
596
+
597
+ const [windowId, ...nameParts] = line.split("\t");
598
+ if (!windowId) continue;
599
+
600
+ const currentName = nameParts.join("\t").trim();
601
+ if (currentName === windowName) {
602
+ return windowId;
603
+ }
604
+ }
605
+
606
+ return undefined;
607
+ }
608
+
609
+ async function getTmuxWindowIdForPane(paneId: string): Promise<string | undefined> {
610
+ if (!paneId) return undefined;
611
+
612
+ const result = await runProcess("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
613
+ timeoutMs: 5000
614
+ });
615
+
616
+ if (result.code !== 0) {
617
+ return undefined;
618
+ }
619
+
620
+ const windowId = result.stdout.trim();
621
+ return windowId || undefined;
622
+ }
623
+
624
+ async function setTmuxPaneTitle(paneId: string, paneTitle: string): Promise<void> {
625
+ if (!paneId) return;
626
+
627
+ await runProcess("tmux", ["select-pane", "-t", paneId, "-T", paneTitle], {
628
+ timeoutMs: 5000
629
+ }).catch(() => undefined);
630
+ }
631
+
632
+ async function setTmuxWindowTiled(windowTarget: string): Promise<void> {
633
+ await runProcess("tmux", ["select-layout", "-t", windowTarget, "tiled"], {
634
+ timeoutMs: 5000
635
+ }).catch(() => undefined);
636
+ }
637
+
638
+ async function killTmuxPane(paneId: string): Promise<void> {
639
+ await runProcess("tmux", ["kill-pane", "-t", paneId], {
640
+ timeoutMs: 5000
641
+ }).catch(() => undefined);
642
+ }
643
+
644
+ async function killTmuxWindow(windowTarget: string): Promise<void> {
645
+ await runProcess("tmux", ["kill-window", "-t", windowTarget], {
646
+ timeoutMs: 5000
647
+ }).catch(() => undefined);
648
+ }
649
+
650
+ function toTmuxRunSessionName(runId: string): string {
651
+ const sanitized = runId.replace(/[^a-zA-Z0-9_-]/g, "-");
652
+ return `pi-rlm-${sanitized}`.slice(0, 48);
653
+ }
654
+
655
+ function toTmuxDepthWindowName(depth: number, useCurrentSession: boolean): string {
656
+ return useCurrentSession ? `rlm-depth-${depth}` : `depth-${depth}`;
657
+ }
658
+
659
+ function toTmuxPaneTitle(request: CompletionRequest): string {
660
+ const node = request.nodeId ?? "root";
661
+ const stage = request.stage ?? "call";
662
+ const depth = typeof request.depth === "number" ? `d${request.depth}` : "dx";
663
+ return `${depth}:${node}:${stage}`.slice(0, 64);
664
+ }
665
+
666
+ function isTmuxDuplicateSessionError(output: string): boolean {
667
+ const normalized = output.toLowerCase();
668
+ return normalized.includes("duplicate session") || normalized.includes("session already exists");
669
+ }
670
+
671
+ function isTmuxDuplicateWindowError(output: string): boolean {
672
+ const normalized = output.toLowerCase();
673
+ return normalized.includes("duplicate window") || normalized.includes("window already exists");
220
674
  }
221
675
 
222
676
  function extractLastAssistantText(messages: unknown[]): string {