Offer the foreign sessions this project already has

b4cefc3a43ab · AtlantisPleb · · parent 09f64a78d77c

Offer the foreign sessions this project already has

The scanner half of foreign session resume shipped as the
`foreign-sessions` WASM plugin: given read-only mounts over ~/.claude
and ~/.codex it reports session metadata and nothing else. Nothing
surfaced it.

The picker half does: it builds the bounded, filtered scan request,
keeps the sessions whose working directory is this project, renders
them newest first as a numbered list, and prints what a person needs
to act — source, session id, directory, age, record count, and the
exact command to resume in the foreign tool.

The resume commands are the real ones, checked against `claude --help`
and `codex --help` on this machine rather than assumed: `claude
--resume <id>` and `codex resume <id>`.

The posture the issue is strict about is inherited rather than
restated: the scan is metadata-only and read-only through the plugin's
confined mounts, it never writes to the foreign tool's state, every
bound stays where the plugin set it, and a missing directory or
unreadable file degrades to a shorter list rather than an error. A
scan that hit a bound says so, so a short list is never mistaken for
an empty one.

The module takes an invoke seam, so the tests drive a fake plugin and
the real path loads and invokes the pinned artifact.

Built by a Devin child through the openagents coder's delegate tool;
one test asserted a `codex continue` subcommand that does not exist
and was corrected in review. 796 CLI tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-foreign-resume.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-foreign-resume.test.ts

Diff

8 files changed, +692 -4

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2467,
7
    "filesScanned": 2468,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:f11a893e45986831504d003dfa23967abb728700567e6ec3469c41b7dae7aa0f",
4
  "sourceDigest": "sha256:359b6533e42f95b7605e98f21e6ed7a4f837f917a73ad1723aab8cee17ec0752",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (63 tracked test files)"
1879
          "ref": "packages/openagents-cli (64 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +59 -1

@@ -90,14 +90,18 @@ import { ThreadTranscriptWriter } from "./coder-transcript.js";

90 90
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
91 91
import {
92 92
  describeLoad,
93
  invokePlugin,
93 94
  isRefusal,
94 95
  loadPluginFromManifest,
95 96
  pluginIdentity,
96 97
  pluginTool,
97 98
  type LoadedPlugin,
98 99
} from "./coder-plugins.js";
100
import { runForeignResume } from "./coder-foreign-resume.js";
101
import { existsSync } from "node:fs";
99 102
import { spawnSync } from "node:child_process";
100
import { resolve as resolvePath } from "node:path";
103
import { dirname, join, resolve as resolvePath } from "node:path";
104
import { fileURLToPath } from "node:url";
101 105
102 106
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
103 107
import { loadSkillSelection, standingContext } from "./coder-skills.js";

@@ -2374,6 +2378,58 @@ const coderCommand = Command.make(

2374 2378
        return described;
2375 2379
      };
2376 2380
2381
      const locateForeignSessionsManifest = (): string | undefined => {
2382
        let here = dirname(fileURLToPath(import.meta.url));
2383
        while (true) {
2384
          const candidate = join(here, "plugins/foreign-sessions/manifest.json");
2385
          if (existsSync(candidate)) {
2386
            return candidate;
2387
          }
2388
          const parent = dirname(here);
2389
          if (parent === here) {
2390
            return undefined;
2391
          }
2392
          here = parent;
2393
        }
2394
      };
2395
2396
      const foreignSessionsManifest = locateForeignSessionsManifest();
2397
2398
      const runForeignSessionResume = async (
2399
        selection: number | undefined,
2400
      ): Promise<string> => {
2401
        if (foreignSessionsManifest === undefined) {
2402
          return (
2403
            "The foreign session scanner is not available from this installation. " +
2404
            "It lives at plugins/foreign-sessions/manifest.json in the repository."
2405
          );
2406
        }
2407
        const loaded = loadPluginFromManifest(foreignSessionsManifest);
2408
        if (isRefusal(loaded)) {
2409
          return `Could not load the foreign session scanner (${loaded.code}): ${loaded.reason}`;
2410
        }
2411
        const invoke = async (input: Record<string, unknown>): Promise<unknown> => {
2412
          const packet = new TextEncoder().encode(JSON.stringify(input));
2413
          const outcome = await invokePlugin(loaded, packet);
2414
          if (isRefusal(outcome)) {
2415
            return { refusal: { code: outcome.code, reason: outcome.reason } };
2416
          }
2417
          try {
2418
            return JSON.parse(new TextDecoder().decode(outcome));
2419
          } catch (cause) {
2420
            throw new Error(
2421
              `the scanner output is not valid JSON: ${
2422
                cause instanceof Error ? cause.message : String(cause)
2423
              }`,
2424
            );
2425
          }
2426
        };
2427
        return runForeignResume(
2428
          { now_ms: Date.now(), cwd: process.cwd(), selection },
2429
          invoke,
2430
        );
2431
      };
2432
2377 2433
      // Only when there is really no way to run a child. A refused child
2378 2434
      // thread is not that: `buildDelegation` prefers a free harness model over
2379 2435
      // the account's grant anyway, so the grant lane failing is the normal

@@ -2411,6 +2467,7 @@ const coderCommand = Command.make(

2411 2467
                stdout: process.stdout,
2412 2468
                skills,
2413 2469
                onSkillsChanged: declareTools,
2470
                resume: runForeignSessionResume,
2414 2471
                loadPlugin,
2415 2472
              })
2416 2473
            : await runCoderPlain(session, {

@@ -2418,6 +2475,7 @@ const coderCommand = Command.make(

2418 2475
                stdout: process.stdout,
2419 2476
                prompt: oneShot,
2420 2477
                skills,
2478
                resume: runForeignSessionResume,
2421 2479
                loadPlugin,
2422 2480
              });
2423 2481
        } finally {
packages/openagents-cli/src/coder-foreign-resume.ts added +308

@@ -0,0 +1,308 @@

1
/**
2
 * Foreign session resume picker for `openagents coder`.
3
 *
4
 * The scanner half of OpenAgentsInc/openagents.com#198 is a packet-v0 WASM
5
 * plugin under `plugins/foreign-sessions`. This module is the CLI picker
6
 * half: it builds the bounded, filtered scan request, interprets the
7
 * metadata-only result, renders a numbered list, and prints a resume command.
8
 *
9
 * It takes an `invoke` seam so tests can stand in a fake plugin and so the
10
 * real call loads the plugin and invokes it outside this module.
11
 */
12
13
const DAY_MS = 86_400_000;
14
const HOUR_MS = 3_600_000;
15
16
export const DEFAULT_MAX_AGE_DAYS = 30;
17
export const DEFAULT_PICKER_LIMIT = 10;
18
19
export interface ForeignSession {
20
  readonly source: "claude" | "codex";
21
  readonly session_id: string;
22
  readonly path: string;
23
  readonly cwd: string | undefined;
24
  readonly project_dir: string | undefined;
25
  readonly mtime_ms: number;
26
  readonly size_bytes: number;
27
  readonly record_count: number | undefined;
28
  readonly metadata_truncated: boolean;
29
}
30
31
export interface ForeignScanOutput {
32
  readonly sessions: ReadonlyArray<ForeignSession>;
33
  readonly scanned_dirs: number;
34
  readonly scanned_files: number;
35
  readonly skipped: {
36
    readonly malformed: number;
37
    readonly unreadable: number;
38
    readonly symlinked: number;
39
  };
40
  readonly oversized: number;
41
  readonly missing_sources: ReadonlyArray<string>;
42
  readonly scan_truncated: boolean;
43
  readonly read_budget_exhausted: boolean;
44
}
45
46
export interface ForeignScanRefusal {
47
  readonly code: string;
48
  readonly reason: string;
49
}
50
51
export interface ForeignResumeDeps {
52
  readonly now_ms: number;
53
  readonly cwd: string;
54
  readonly selection: number | undefined;
55
}
56
57
export type ForeignResumeInvoke = (
58
  input: Record<string, unknown>,
59
) => Promise<unknown>;
60
61
export interface ForeignResumeOptions {
62
  readonly max_age_days?: number | undefined;
63
  readonly limit?: number | undefined;
64
}
65
66
const asRecord = (value: unknown): Record<string, unknown> =>
67
  typeof value === "object" && value !== null && !Array.isArray(value)
68
    ? (value as Record<string, unknown>)
69
    : {};
70
71
const asString = (value: unknown): string =>
72
  typeof value === "string" ? value : "";
73
74
const asNumber = (value: unknown): number =>
75
  typeof value === "number" && Number.isFinite(value) ? value : 0;
76
77
const asBool = (value: unknown): boolean => value === true;
78
79
const asArray = (value: unknown): ReadonlyArray<unknown> =>
80
  Array.isArray(value) ? value : [];
81
82
function parseSession(value: unknown): ForeignSession | undefined {
83
  const record = asRecord(value);
84
  const source = record["source"];
85
  if (source !== "claude" && source !== "codex") return undefined;
86
  return {
87
    source,
88
    session_id: asString(record["session_id"]),
89
    path: asString(record["path"]),
90
    cwd: record["cwd"] === undefined ? undefined : asString(record["cwd"]),
91
    project_dir:
92
      record["project_dir"] === undefined
93
        ? undefined
94
        : asString(record["project_dir"]),
95
    mtime_ms: asNumber(record["mtime_ms"]),
96
    size_bytes: asNumber(record["size_bytes"]),
97
    record_count:
98
      record["record_count"] === undefined
99
        ? undefined
100
        : asNumber(record["record_count"]),
101
    metadata_truncated: asBool(record["metadata_truncated"]),
102
  };
103
}
104
105
function parseScanOutput(value: unknown): ForeignScanOutput {
106
  const record = asRecord(value);
107
  const rawSessions = asArray(record["sessions"]);
108
  const sessions = rawSessions
109
    .map(parseSession)
110
    .filter((s): s is ForeignSession => s !== undefined);
111
  const rawSkipped = asRecord(record["skipped"]);
112
  const rawMissing = asArray(record["missing_sources"]);
113
114
  return {
115
    sessions,
116
    scanned_dirs: asNumber(record["scanned_dirs"]),
117
    scanned_files: asNumber(record["scanned_files"]),
118
    skipped: {
119
      malformed: asNumber(rawSkipped["malformed"]),
120
      unreadable: asNumber(rawSkipped["unreadable"]),
121
      symlinked: asNumber(rawSkipped["symlinked"]),
122
    },
123
    oversized: asNumber(record["oversized"]),
124
    missing_sources: rawMissing.filter((m): m is string => typeof m === "string"),
125
    scan_truncated: asBool(record["scan_truncated"]),
126
    read_budget_exhausted: asBool(record["read_budget_exhausted"]),
127
  };
128
}
129
130
type ScanResult =
131
  | { readonly kind: "ok"; readonly output: ForeignScanOutput }
132
  | { readonly kind: "refusal"; readonly refusal: ForeignScanRefusal }
133
  | { readonly kind: "error"; readonly message: string };
134
135
function normalizeScanResult(value: unknown): ScanResult {
136
  const record = asRecord(value);
137
138
  if (record["refusal"] !== undefined) {
139
    const refusal = asRecord(record["refusal"]);
140
    const code = asString(refusal["code"]);
141
    const reason = asString(refusal["reason"]);
142
    if (code.length > 0 && reason.length > 0) {
143
      return { kind: "refusal", refusal: { code, reason } };
144
    }
145
    return { kind: "error", message: "The scanner returned a malformed refusal." };
146
  }
147
148
  if (record["ok"] !== undefined) {
149
    return { kind: "ok", output: parseScanOutput(record["ok"]) };
150
  }
151
152
  return { kind: "error", message: "The scanner returned an unrecognised packet." };
153
}
154
155
function buildPacket(
156
  deps: ForeignResumeDeps,
157
  options: ForeignResumeOptions,
158
): Record<string, unknown> {
159
  return {
160
    now_ms: deps.now_ms,
161
    cwd_filter: deps.cwd,
162
    max_age_days: options.max_age_days ?? DEFAULT_MAX_AGE_DAYS,
163
    limit: options.limit ?? DEFAULT_PICKER_LIMIT,
164
  };
165
}
166
167
export function formatAge(mtime_ms: number, now_ms: number): string {
168
  const diff = Math.max(0, now_ms - mtime_ms);
169
  const days = Math.floor(diff / DAY_MS);
170
  if (days >= 1) {
171
    return `${String(days)} day${days === 1 ? "" : "s"} ago`;
172
  }
173
  const hours = Math.floor(diff / HOUR_MS);
174
  if (hours >= 1) {
175
    return `${String(hours)} hour${hours === 1 ? "" : "s"} ago`;
176
  }
177
  return "just now";
178
}
179
180
function resumeCommand(session: ForeignSession): string {
181
  const id = session.session_id;
182
  if (session.source === "claude") {
183
    if (session.cwd !== undefined && session.cwd.length > 0) {
184
      return `cd "${session.cwd}" && claude --resume ${id}`;
185
    }
186
    return `claude --resume ${id}`;
187
  }
188
  if (session.cwd !== undefined && session.cwd.length > 0) {
189
    return `cd "${session.cwd}" && codex resume ${id}`;
190
  }
191
  return `codex resume ${id}`;
192
}
193
194
function describeSession(session: ForeignSession, now_ms: number): string {
195
  const age = formatAge(session.mtime_ms, now_ms);
196
  const records =
197
    session.record_count === undefined ? "metadata only" : `${String(session.record_count)} records`;
198
  const truncated = session.metadata_truncated ? " · truncated" : "";
199
  const cwd = session.cwd ?? "(cwd unknown)";
200
  return `${session.source.padEnd(6)}  ${session.session_id}  ${cwd}  ${age}  ${records}${truncated}`;
201
}
202
203
function describeList(
204
  output: ForeignScanOutput,
205
  now_ms: number,
206
  cwd: string,
207
): string {
208
  const sessions = output.sessions;
209
  const header = `Recent foreign sessions for this directory (${cwd}):`;
210
211
  if (sessions.length === 0) {
212
    const reasons: string[] = [];
213
    if (output.missing_sources.length > 0) {
214
      reasons.push(
215
        `the scanner could not read the ${output.missing_sources.join(" or ")} state store`,
216
      );
217
    }
218
    if (output.scan_truncated) {
219
      reasons.push("the scan was truncated");
220
    }
221
    if (output.read_budget_exhausted) {
222
      reasons.push("the file-read budget was exhausted");
223
    }
224
    const reason = reasons.length > 0 ? ` (${reasons.join("; ")})` : "";
225
    return `${header}\n\nNo recent foreign sessions were found${reason}.`;
226
  }
227
228
  const lines = sessions
229
    .map((session, index) => `  ${String(index + 1).padStart(2)}. ${describeSession(session, now_ms)}`)
230
    .join("\n");
231
232
  const notes: string[] = [];
233
  if (output.scan_truncated) {
234
    notes.push("The scan hit a bound and may be partial.");
235
  }
236
  if (output.read_budget_exhausted) {
237
    notes.push("The file-read budget was exhausted; some sessions may be metadata-only.");
238
  }
239
  const note = notes.length > 0 ? `\n\n${notes.join(" ")}` : "";
240
241
  return `${header}\n\n${lines}\n\nRun /resume <number> to see the resume command for that session.${note}`;
242
}
243
244
function describeSelection(session: ForeignSession, now_ms: number): string {
245
  const age = formatAge(session.mtime_ms, now_ms);
246
  return [
247
    "Resume context:",
248
    `  source:      ${session.source}`,
249
    `  session id:  ${session.session_id}`,
250
    `  cwd:         ${session.cwd ?? "(unknown)"}`,
251
    `  age:         ${age}`,
252
    `  records:     ${session.record_count === undefined ? "(unknown)" : String(session.record_count)}`,
253
    session.metadata_truncated ? "  metadata:    truncated" : undefined,
254
    "",
255
    "Run this to resume in the foreign tool:",
256
    `  ${resumeCommand(session)}`,
257
  ]
258
    .filter((line): line is string => line !== undefined)
259
    .join("\n");
260
}
261
262
/**
263
 * Run one `/resume` turn: ask the scanner, then either list sessions or
264
 * describe the selected one. Returns a single notice string.
265
 *
266
 * The `invoke` seam is given the packet the real plugin expects:
267
 * `now_ms`, `cwd_filter`, `max_age_days`, `limit`. Sources default to both.
268
 */
269
export async function runForeignResume(
270
  deps: ForeignResumeDeps,
271
  invoke: ForeignResumeInvoke,
272
  options?: ForeignResumeOptions,
273
): Promise<string> {
274
  const packet = buildPacket(deps, options ?? {});
275
276
  let raw: unknown;
277
  try {
278
    raw = await invoke(packet);
279
  } catch (cause) {
280
    return `The scanner could not run: ${cause instanceof Error ? cause.message : String(cause)}`;
281
  }
282
283
  const result = normalizeScanResult(raw);
284
285
  if (result.kind === "error") {
286
    return result.message;
287
  }
288
289
  if (result.kind === "refusal") {
290
    return `The scanner refused (${result.refusal.code}): ${result.refusal.reason}`;
291
  }
292
293
  const output = result.output;
294
  const sessions = output.sessions;
295
296
  if (deps.selection !== undefined) {
297
    if (deps.selection < 1 || deps.selection > sessions.length) {
298
      return `There is no session at ${String(deps.selection)}.${
299
        sessions.length === 0
300
          ? ""
301
          : ` Choose a number from 1 to ${String(sessions.length)}.`
302
      }\n\n${describeList(output, deps.now_ms, deps.cwd)}`;
303
    }
304
    return describeSelection(sessions[deps.selection - 1]!, deps.now_ms);
305
  }
306
307
  return describeList(output, deps.now_ms, deps.cwd);
308
}
packages/openagents-cli/src/coder-plain.ts modified +18

@@ -29,6 +29,13 @@ export interface CoderPlainOptions {

29 29
  readonly prompt?: string | undefined;
30 30
  /** The workspace's skills, so `/skills` can report them. */
31 31
  readonly skills?: SkillSelection | undefined;
32
  /**
33
   * Pick and describe a foreign coding-agent session to resume.
34
   *
35
   * For `/resume` and `/resume <number>`. The caller loads the foreign-sessions
36
   * plugin and invokes it; this mode only asks for the result and writes it.
37
   */
38
  readonly resume?: ((selection: number | undefined) => Promise<string>) | undefined;
32 39
  /**
33 40
   * Load a WASM plugin from a manifest path and say what happened.
34 41
   *

@@ -131,6 +138,17 @@ export async function runCoderPlain(

131 138
      return;
132 139
    }
133 140
141
    const resumeMatch = /^\/resume(?:\s+(\d+))?\s*$/.exec(line.trim());
142
    if (resumeMatch !== null) {
143
      const selection = resumeMatch[1] === undefined ? undefined : Number(resumeMatch[1]);
144
      const text =
145
        options.resume === undefined
146
          ? "This session cannot resume foreign sessions."
147
          : await options.resume(selection).catch(() => "The foreign session scan failed unexpectedly.");
148
      stdout.write(`\n${text}\n`);
149
      return;
150
    }
151
134 152
    written = 0;
135 153
    stdout.write(`\ncoder> `);
136 154
    await session.submit(line);
packages/openagents-cli/src/coder-session.ts modified +2

@@ -775,6 +775,7 @@ export class CoderSession {

775 775
          "  /system                     what the model is told: tools, skills, and its",
776 776
          "                              standing context",
777 777
          "  /skills                     choose which skills the model is offered",
778
          "  /resume [<n>]               list or select a recent foreign coding session",
778 779
          "  /export                     write this conversation as an ATIF trajectory",
779 780
          "  /reload                     rebuild and restart on the current source",
780 781
          "  /delegate [<n>x] <prompt>   run child agents on a prompt",

@@ -831,6 +832,7 @@ export class CoderSession {

831 832
          "  /help     this list",
832 833
          "  /system   what the model is told, including tools and skills",
833 834
          "  /skills   choose which skills the model is offered",
835
          "  /resume   list or select a recent foreign coding session",
834 836
          "  /export   write this conversation as an ATIF trajectory",
835 837
          "  /reload   rebuild and restart on the current source",
836 838
          "  /delegate [<n>x] <prompt>   run child agents on a prompt",
packages/openagents-cli/src/coder-ui.ts modified +30

@@ -176,6 +176,14 @@ export interface CoderUiOptions {

176 176
  readonly skills?: SkillSelection | undefined;
177 177
  /** Re-declare the tools after a skill is switched. */
178 178
  readonly onSkillsChanged?: (() => void) | undefined;
179
  /**
180
   * Pick and describe a foreign coding-agent session to resume.
181
   *
182
   * For `/resume` and `/resume <number>`. The caller loads the foreign-sessions
183
   * plugin and invokes it; this interface only asks for the result and shows it
184
   * as a notice.
185
   */
186
  readonly resume?: ((selection: number | undefined) => Promise<string>) | undefined;
179 187
  /**
180 188
   * Load a WASM plugin from a manifest path and say what happened.
181 189
   *

@@ -1111,6 +1119,28 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1111 1119
        return;
1112 1120
      }
1113 1121
1122
      // `/resume` lists recent foreign coding-agent sessions or describes one.
1123
      const resumeMatch = /^\/resume(?:\s+(\d+))?\s*$/.exec(prompt.trim());
1124
      if (resumeMatch !== null) {
1125
        const selection = resumeMatch[1] === undefined ? undefined : Number(resumeMatch[1]);
1126
        if (options.resume === undefined) {
1127
          session.notice("This session cannot resume foreign sessions.");
1128
        } else {
1129
          void options
1130
            .resume(selection)
1131
            .then((text) => {
1132
              session.notice(text);
1133
              render();
1134
            })
1135
            .catch(() => {
1136
              session.notice("The foreign session scan failed unexpectedly.");
1137
              render();
1138
            });
1139
        }
1140
        render();
1141
        return;
1142
      }
1143
1114 1144
      if (prompt.trimStart().startsWith("/delegate")) {
1115 1145
        void session.submit(prompt);
1116 1146
        render();
packages/openagents-cli/test/coder-foreign-resume.test.ts added +272

@@ -0,0 +1,272 @@

1
import { describe, expect, it } from "vitest";
2
3
import {
4
  DEFAULT_MAX_AGE_DAYS,
5
  DEFAULT_PICKER_LIMIT,
6
  formatAge,
7
  runForeignResume,
8
  type ForeignResumeInvoke,
9
  type ForeignSession,
10
} from "../src/coder-foreign-resume.js";
11
12
const DAY_MS = 86_400_000;
13
const HOUR_MS = 3_600_000;
14
const NOW_MS = 1_000_000_000_000;
15
16
const session = (overrides: Partial<ForeignSession>): ForeignSession => ({
17
  source: "claude",
18
  session_id: "abc-123",
19
  path: "projects/abc.jsonl",
20
  cwd: undefined,
21
  project_dir: undefined,
22
  mtime_ms: NOW_MS,
23
  size_bytes: 100,
24
  record_count: 1,
25
  metadata_truncated: false,
26
  ...overrides,
27
});
28
29
const makeInvoke =
30
  (output: unknown): ForeignResumeInvoke =>
31
  async (input) => {
32
    void input;
33
    return output;
34
  };
35
36
const lastInput = async (output: unknown): Promise<{ input: Record<string, unknown>; result: string }> => {
37
  let captured: Record<string, unknown> = {};
38
  const invoke: ForeignResumeInvoke = async (input) => {
39
    captured = input;
40
    return output;
41
  };
42
  const result = await runForeignResume(
43
    { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
44
    invoke,
45
  );
46
  return { input: captured, result };
47
};
48
49
describe("runForeignResume packet", () => {
50
  it("sends the cwd filter, now, sensible age and limit bounds", async () => {
51
    const { input } = await lastInput({ ok: { sessions: [] } });
52
53
    expect(input["cwd_filter"]).toBe("/test/cwd");
54
    expect(input["now_ms"]).toBe(NOW_MS);
55
    expect(input["max_age_days"]).toBe(DEFAULT_MAX_AGE_DAYS);
56
    expect(input["limit"]).toBe(DEFAULT_PICKER_LIMIT);
57
    expect(input["sources"]).toBeUndefined();
58
  });
59
});
60
61
describe("runForeignResume listing", () => {
62
  it("renders a numbered, newest-first list and the /resume instruction", async () => {
63
    const result = await runForeignResume(
64
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
65
      makeInvoke({
66
        ok: {
67
          sessions: [
68
            session({
69
              source: "codex",
70
              session_id: "codex-newest",
71
              cwd: "/Users/ada/gamma",
72
              mtime_ms: NOW_MS - DAY_MS,
73
              record_count: 3,
74
            }),
75
            session({
76
              source: "claude",
77
              session_id: "claude-older",
78
              cwd: "/Users/ada/alpha",
79
              mtime_ms: NOW_MS - 3 * DAY_MS,
80
              record_count: 7,
81
            }),
82
          ],
83
        },
84
      }),
85
    );
86
87
    expect(result).toContain("Recent foreign sessions for this directory (/test/cwd):");
88
    expect(result).toContain("1.");
89
    expect(result).toContain("2.");
90
    // Newest first.
91
    expect(result.indexOf("codex-newest")).toBeLessThan(result.indexOf("claude-older"));
92
    expect(result).toContain("/Users/ada/gamma");
93
    expect(result).toContain("/Users/ada/alpha");
94
    expect(result).toContain("Run /resume <number>");
95
  });
96
97
  it("reports an empty list with missing sources", async () => {
98
    const result = await runForeignResume(
99
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
100
      makeInvoke({
101
        ok: { sessions: [], missing_sources: ["claude", "codex"], scan_truncated: false },
102
      }),
103
    );
104
105
    expect(result).toContain("No recent foreign sessions were found");
106
    expect(result).toContain("claude or codex state store");
107
  });
108
109
  it("notes a truncated scan", async () => {
110
    const result = await runForeignResume(
111
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
112
      makeInvoke({
113
        ok: {
114
          sessions: [session({ session_id: "one" })],
115
          scan_truncated: true,
116
          read_budget_exhausted: false,
117
        },
118
      }),
119
    );
120
121
    expect(result).toContain("The scan hit a bound and may be partial");
122
  });
123
124
  it("flags metadata-only sessions", async () => {
125
    const result = await runForeignResume(
126
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
127
      makeInvoke({
128
        ok: {
129
          sessions: [
130
            session({
131
              session_id: "huge",
132
              record_count: undefined,
133
              metadata_truncated: true,
134
            }),
135
          ],
136
        },
137
      }),
138
    );
139
140
    expect(result).toContain("huge");
141
    expect(result).toContain("metadata only");
142
    expect(result).toContain("truncated");
143
  });
144
});
145
146
describe("runForeignResume selection", () => {
147
  const sessions = [
148
    session({
149
      source: "claude",
150
      session_id: "claude-1",
151
      cwd: "/Users/ada/alpha",
152
      mtime_ms: NOW_MS - 2 * DAY_MS,
153
      record_count: 5,
154
    }),
155
    session({
156
      source: "codex",
157
      session_id: "codex-2",
158
      cwd: "/Users/ada/gamma",
159
      mtime_ms: NOW_MS - DAY_MS,
160
      record_count: 3,
161
    }),
162
  ];
163
164
  it("prints the resume context and exact command for a Claude session", async () => {
165
    const result = await runForeignResume(
166
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: 1 },
167
      makeInvoke({ ok: { sessions } }),
168
    );
169
170
    expect(result).toContain("source:      claude");
171
    expect(result).toContain("session id:  claude-1");
172
    expect(result).toContain("cwd:         /Users/ada/alpha");
173
    expect(result).toContain('cd "/Users/ada/alpha" && claude --resume claude-1');
174
  });
175
176
  it("prints the resume command for a Codex session", async () => {
177
    const result = await runForeignResume(
178
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: 2 },
179
      makeInvoke({ ok: { sessions } }),
180
    );
181
182
    expect(result).toContain("source:      codex");
183
    expect(result).toContain("session id:  codex-2");
184
    expect(result).toContain('cd "/Users/ada/gamma" && codex resume codex-2');
185
  });
186
187
  it("rejects an out-of-range selection and re-lists", async () => {
188
    const result = await runForeignResume(
189
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: 9 },
190
      makeInvoke({ ok: { sessions } }),
191
    );
192
193
    expect(result).toContain("There is no session at 9");
194
    expect(result).toContain("1 to 2");
195
    expect(result).toContain("Recent foreign sessions");
196
  });
197
198
  it("works when the session cwd is unknown", async () => {
199
    const result = await runForeignResume(
200
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: 1 },
201
      makeInvoke({
202
        ok: {
203
          sessions: [session({ session_id: "unknown-cwd", cwd: undefined, record_count: undefined })],
204
        },
205
      }),
206
    );
207
208
    expect(result).toContain("cwd:         (unknown)");
209
    expect(result).toContain("claude --resume unknown-cwd");
210
    expect(result).not.toContain("cd ");
211
  });
212
});
213
214
describe("runForeignResume soft failures", () => {
215
  it("handles a typed plugin refusal", async () => {
216
    const result = await runForeignResume(
217
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
218
      makeInvoke({ refusal: { code: "unsupported", reason: "unknown source" } }),
219
    );
220
221
    expect(result).toContain("The scanner refused (unsupported)");
222
    expect(result).toContain("unknown source");
223
  });
224
225
  it("handles malformed scanner output", async () => {
226
    const result = await runForeignResume(
227
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
228
      makeInvoke({ unexpected: true }),
229
    );
230
231
    expect(result).toContain("unrecognised packet");
232
  });
233
234
  it("handles an invoke error without crashing", async () => {
235
    const result = await runForeignResume(
236
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
237
      async () => {
238
        throw new Error("worker trap");
239
      },
240
    );
241
242
    expect(result).toContain("The scanner could not run");
243
    expect(result).toContain("worker trap");
244
  });
245
246
  it("handles a malformed refusal", async () => {
247
    const result = await runForeignResume(
248
      { now_ms: NOW_MS, cwd: "/test/cwd", selection: undefined },
249
      makeInvoke({ refusal: { code: 123 } }),
250
    );
251
252
    expect(result).toContain("malformed refusal");
253
  });
254
});
255
256
describe("formatAge", () => {
257
  it("uses days for old sessions", () => {
258
    expect(formatAge(NOW_MS - 5 * DAY_MS, NOW_MS)).toBe("5 days ago");
259
  });
260
261
  it("uses hours for same-day sessions", () => {
262
    expect(formatAge(NOW_MS - 3 * HOUR_MS, NOW_MS)).toBe("3 hours ago");
263
  });
264
265
  it("uses one day singular", () => {
266
    expect(formatAge(NOW_MS - DAY_MS, NOW_MS)).toBe("1 day ago");
267
  });
268
269
  it("uses just now for very recent sessions", () => {
270
    expect(formatAge(NOW_MS - 1000, NOW_MS)).toBe("just now");
271
  });
272
});

This page updates live while a promote is in flight · changelog