humanish 0.94.0 → 0.96.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 (95) hide show
  1. package/README.md +10 -0
  2. package/dist/comms-agentmail.d.ts +25 -0
  3. package/dist/comms-agentmail.js +418 -0
  4. package/dist/comms-agentmail.js.map +1 -0
  5. package/dist/comms-connections.d.ts +52 -0
  6. package/dist/comms-connections.js +123 -0
  7. package/dist/comms-connections.js.map +1 -0
  8. package/dist/comms-lease-store.d.ts +90 -0
  9. package/dist/comms-lease-store.js +489 -0
  10. package/dist/comms-lease-store.js.map +1 -0
  11. package/dist/comms-receiving-evidence.d.ts +46 -0
  12. package/dist/comms-receiving-evidence.js +35 -0
  13. package/dist/comms-receiving-evidence.js.map +1 -0
  14. package/dist/comms-receiving-inbox.d.ts +16 -0
  15. package/dist/comms-receiving-inbox.js +410 -0
  16. package/dist/comms-receiving-inbox.js.map +1 -0
  17. package/dist/comms-receiving-runtime.d.ts +25 -0
  18. package/dist/comms-receiving-runtime.js +45 -0
  19. package/dist/comms-receiving-runtime.js.map +1 -0
  20. package/dist/comms-receiving-types.d.ts +98 -0
  21. package/dist/comms-receiving-types.js +2 -0
  22. package/dist/comms-receiving-types.js.map +1 -0
  23. package/dist/comms-receiving.d.ts +58 -0
  24. package/dist/comms-receiving.js +554 -0
  25. package/dist/comms-receiving.js.map +1 -0
  26. package/dist/comms-setup.d.ts +40 -0
  27. package/dist/comms-setup.js +121 -0
  28. package/dist/comms-setup.js.map +1 -0
  29. package/dist/concurrent-shared-world-lab.js +662 -624
  30. package/dist/concurrent-shared-world-lab.js.map +1 -1
  31. package/dist/cua-actor-lab.d.ts +4 -1
  32. package/dist/cua-actor-lab.js +98 -17
  33. package/dist/cua-actor-lab.js.map +1 -1
  34. package/dist/doctor-lab.d.ts +7 -0
  35. package/dist/doctor-lab.js +26 -7
  36. package/dist/doctor-lab.js.map +1 -1
  37. package/dist/e2b-terminal-lab.js +3 -0
  38. package/dist/e2b-terminal-lab.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/key-resolution.d.ts +1 -1
  43. package/dist/key-resolution.js +6 -4
  44. package/dist/key-resolution.js.map +1 -1
  45. package/dist/lab-config.d.ts +20 -3
  46. package/dist/lab-config.js +65 -3
  47. package/dist/lab-config.js.map +1 -1
  48. package/dist/lab-engine.js +8 -3
  49. package/dist/lab-engine.js.map +1 -1
  50. package/dist/lab-summary.d.ts +3 -2
  51. package/dist/lab-summary.js +17 -7
  52. package/dist/lab-summary.js.map +1 -1
  53. package/dist/observer-app.html +1 -1
  54. package/dist/observer-data.js +14 -1
  55. package/dist/observer-data.js.map +1 -1
  56. package/dist/oss-lab.d.ts +1 -1
  57. package/dist/oss-lab.js.map +1 -1
  58. package/dist/oss-meta-lab.d.ts +1 -1
  59. package/dist/oss-meta-lab.js.map +1 -1
  60. package/dist/program.d.ts +4 -0
  61. package/dist/program.js +231 -74
  62. package/dist/program.js.map +1 -1
  63. package/dist/run-narration-secrets.d.ts +5 -0
  64. package/dist/run-narration-secrets.js +68 -0
  65. package/dist/run-narration-secrets.js.map +1 -0
  66. package/dist/run.d.ts +7 -2
  67. package/dist/run.js +25 -3
  68. package/dist/run.js.map +1 -1
  69. package/dist/scripted-browser-lab.js +7 -0
  70. package/dist/scripted-browser-lab.js.map +1 -1
  71. package/dist/secret-prompt.d.ts +2 -0
  72. package/dist/secret-prompt.js +36 -0
  73. package/dist/secret-prompt.js.map +1 -0
  74. package/dist/shared-world-lab.js +4 -0
  75. package/dist/shared-world-lab.js.map +1 -1
  76. package/dist/study-analysis-engine.js +30 -1
  77. package/dist/study-analysis-engine.js.map +1 -1
  78. package/dist/study-analysis-evidence.js +9 -1
  79. package/dist/study-analysis-evidence.js.map +1 -1
  80. package/dist/tui-app.js +135 -135
  81. package/dist/tui-contract.d.ts +26 -1
  82. package/dist/tui-contract.js.map +1 -1
  83. package/dist/tui-launch.d.ts +2 -0
  84. package/dist/tui-launch.js +9 -1
  85. package/dist/tui-launch.js.map +1 -1
  86. package/docs/architecture/comms-inbox.md +4 -0
  87. package/docs/architecture/real-email-receiving.md +135 -0
  88. package/docs/contracts/run-bundle.md +9 -1
  89. package/docs/contracts/schemas.md +50 -5
  90. package/docs/goals/current.md +5 -5
  91. package/docs/ramp/README.md +13 -3
  92. package/docs/release/0.95.0-connections-setup.md +38 -0
  93. package/docs/release/0.96.0-real-email-receiving.md +47 -0
  94. package/package.json +4 -2
  95. package/skills/humanish/SKILL.md +55 -14
package/dist/program.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type AutomaticAnalysisHooks } from "./automatic-analysis-completion.js";
2
2
  import { Command } from "commander";
3
3
  import { discoverProviderKeys } from "./key-resolution.js";
4
+ import { checkCommsConnection } from "./comms-setup.js";
5
+ import { promptSecret } from "./secret-prompt.js";
4
6
  import { type TelemetryProperties } from "./telemetry.js";
5
7
  import type { CuaActorLabResult } from "./cua-actor-lab.js";
6
8
  import type { ObserverResult, ObserverServer } from "./observer.js";
@@ -39,6 +41,8 @@ export declare function createProgram(io?: Partial<CliIo> & {
39
41
  * TTY, an old Node, or a missing bundle appear.
40
42
  */
41
43
  export interface TuiRuntime {
44
+ checkComms: typeof checkCommsConnection;
45
+ promptSecret: typeof promptSecret;
42
46
  stdin: NodeJS.ReadStream;
43
47
  stdout: NodeJS.WriteStream;
44
48
  nodeVersion: string;
package/dist/program.js CHANGED
@@ -10,6 +10,11 @@ import { Command, Option } from "commander";
10
10
  import { startCodexAppServerUi } from "./codex-app-server-ui.js";
11
11
  import { loadEnvFile } from "./env-file.js";
12
12
  import { discoverProviderKeys, listUserKeys, resolveKeyName, setUserKey, unsetUserKey, userKeyStorePath } from "./key-resolution.js";
13
+ import { COMMS_PROVIDERS, readCommsSetup, saveCommsConnection } from "./comms-connections.js";
14
+ import { checkCommsConnection, configureCommsLab } from "./comms-setup.js";
15
+ import { inspectCommsRecovery, recoverCommsReceiving } from "./comms-receiving.js";
16
+ import { resolveReceivingConnection } from "./comms-receiving-runtime.js";
17
+ import { promptSecret } from "./secret-prompt.js";
13
18
  import { redactText } from "./redaction.js";
14
19
  import { draftFeedback, listFeedback, renderIssueMarkdown, renderIssueUrl, verifyFeedback } from "./feedback.js";
15
20
  import { runInit } from "./init.js";
@@ -461,6 +466,8 @@ function registerDoctorCommand(parent, io) {
461
466
  });
462
467
  }
463
468
  const defaultTuiRuntime = {
469
+ checkComms: checkCommsConnection,
470
+ promptSecret,
464
471
  stdin: process.stdin,
465
472
  stdout: process.stdout,
466
473
  env: process.env,
@@ -560,10 +567,14 @@ function registerTuiCommand(parent, io) {
560
567
  .description("Open the interactive terminal surface for browsing labs and runs (humans only).")
561
568
  .summary("Human terminal for labs and runs; refuses detected agent sessions and non-TTY input/output. Agents: humanish lab list --json, humanish lab inspect <lab> --json, humanish runs --json.")
562
569
  .option("--cwd <path>", "Target project directory.", ".")
570
+ .option("--env-file <path>", "Load a local env file for this terminal session and its runs without printing values.")
563
571
  .option("--force", "Open it anyway in a session that looks like an agent's.")
564
572
  .option("--json", JSON_OPTION_DESCRIPTION)
565
573
  .action(async (options, command) => {
566
574
  const { stdin, stdout } = tuiRuntime;
575
+ // Production uses process.env, including SDKs used by existing cleanup actions. Tests inject
576
+ // an isolated host context. Values stay behind the capability closures, never in view data.
577
+ const sessionEnv = tuiRuntime.env;
567
578
  // An agent runner, even with a real terminal. `codex exec` allocates a PTY for the commands
568
579
  // it runs, so the TTY check below passes and the surface used to open: a study watched an
569
580
  // agent navigate the labs list and start a run it did not mean to start
@@ -623,43 +634,110 @@ function registerTuiCommand(parent, io) {
623
634
  });
624
635
  return;
625
636
  }
637
+ const discoveredKeys = new Set();
638
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io, env: sessionEnv,
639
+ onDiscovered: names => names.forEach(name => discoveredKeys.add(name)) }))
640
+ return;
641
+ // Probe stored credentials afresh. Discovery fills must not become permanent env overrides
642
+ // when a person replaces a stored key during this terminal session.
643
+ const connectionEnv = () => {
644
+ const env = { ...sessionEnv };
645
+ for (const name of discoveredKeys)
646
+ delete env[name];
647
+ return env;
648
+ };
626
649
  const observerSession = createTuiObserverSession(resolve(options.cwd));
627
- let exitCode;
650
+ let exitCode = 0;
651
+ let connectionNotice;
652
+ let connectionCheck;
628
653
  try {
629
- exitCode = await loaded.startTui({
630
- cwd: resolve(options.cwd),
631
- version: { cli: CLI_VERSION },
632
- capabilities: {
633
- // One cache for the life of the surface: it refreshes on a cadence, and re-walking every
634
- // run tree each tick is the cost this index exists to avoid.
635
- readRunIndex: (target, readOptions) => readRunIndex(target, { ...readOptions, cache: runIndexCache }),
636
- listLabs: listLabManifests,
637
- startRun: launchRun,
638
- readLaunchLog: readLaunchLogTail,
639
- readRunDetail,
640
- readLabSummary,
641
- readProjectState,
642
- openObserver: (target, observerPath) => observerSession.open(target, observerPath),
643
- reclaimRun: (target, runId) => reclaimRunSandboxes(target, runId),
644
- stopRun,
645
- initProject: async (target) => {
646
- const result = await runInit({ cwd: target, yes: true });
647
- return result.ok
648
- ? {
649
- schema: TUI_ACTION_SCHEMA,
650
- ok: true,
651
- message: `set up humanish here — ${result.changes.filter((change) => change.action !== "skip").length} files written`
654
+ for (;;) {
655
+ const outcome = await loaded.startTui({
656
+ ...(connectionNotice === undefined ? {} : { initialScreen: "connections", connectionNotice }),
657
+ cwd: resolve(options.cwd),
658
+ version: { cli: CLI_VERSION },
659
+ capabilities: {
660
+ comms: {
661
+ read: async () => ({ ...await readCommsSetup(resolve(options.cwd), connectionEnv()), ...(connectionCheck ? { authentication: connectionCheck } : {}) }),
662
+ save: () => saveCommsConnection(resolve(options.cwd)),
663
+ check: async () => { connectionCheck = await tuiRuntime.checkComms({ cwd: resolve(options.cwd), env: connectionEnv(), online: true }); return connectionCheck; },
664
+ labs: async () => (await listLabManifests(resolve(options.cwd))).labs.map(lab => ({ title: lab.title ?? lab.id, path: lab.path })),
665
+ configure: (lab, apply, planToken) => configureCommsLab({ cwd: resolve(options.cwd), lab, connection: "agentmail", apply, ...(planToken ? { planToken } : {}) }),
666
+ recovery: () => inspectCommsRecovery({ cwd: resolve(options.cwd) }),
667
+ recover: async (runId, connectionName) => {
668
+ try {
669
+ const { connection, adapter } = await resolveReceivingConnection(resolve(options.cwd), connectionName, connectionEnv());
670
+ return await recoverCommsReceiving({ cwd: resolve(options.cwd), runId, connectionName, apiKeyEnv: connection.apiKeyEnv, adapter });
671
+ }
672
+ catch {
673
+ return { ok: false, message: "Could not recover email resources. Check the connection and retry." };
674
+ }
652
675
  }
653
- : {
654
- schema: TUI_ACTION_SCHEMA,
655
- ok: false,
656
- message: result.error?.message ?? "humanish init could not set this directory up"
657
- };
658
- }
659
- },
660
- stdin,
661
- stdout
662
- });
676
+ },
677
+ // One cache for the life of the surface: it refreshes on a cadence, and re-walking every
678
+ // run tree each tick is the cost this index exists to avoid.
679
+ readRunIndex: (target, readOptions) => readRunIndex(target, { ...readOptions, cache: runIndexCache }),
680
+ listLabs: listLabManifests,
681
+ startRun: launchOptions => launchRun({ ...launchOptions, env: sessionEnv }),
682
+ readLaunchLog: readLaunchLogTail,
683
+ readRunDetail,
684
+ readLabSummary: (target, lab, readOptions) => readLabSummary(target, lab, { ...readOptions, env: sessionEnv }),
685
+ readProjectState,
686
+ openObserver: (target, observerPath) => observerSession.open(target, observerPath),
687
+ reclaimRun: (target, runId) => reclaimRunSandboxes(target, runId),
688
+ stopRun,
689
+ initProject: async (target) => {
690
+ const result = await runInit({ cwd: target, yes: true });
691
+ return result.ok
692
+ ? {
693
+ schema: TUI_ACTION_SCHEMA,
694
+ ok: true,
695
+ message: `set up humanish here — ${result.changes.filter((change) => change.action !== "skip").length} files written`
696
+ }
697
+ : {
698
+ schema: TUI_ACTION_SCHEMA,
699
+ ok: false,
700
+ message: result.error?.message ?? "humanish init could not set this directory up"
701
+ };
702
+ }
703
+ },
704
+ stdin,
705
+ stdout
706
+ });
707
+ if (typeof outcome === "number") {
708
+ exitCode = outcome;
709
+ break;
710
+ }
711
+ if (outcome.action !== "agentmail-key") {
712
+ exitCode = 1;
713
+ break;
714
+ }
715
+ // startTui has unmounted: only the host reads the credential, then remounts the view.
716
+ const value = await tuiRuntime.promptSecret("AgentMail API key", stdin, stdout);
717
+ if (value === null) {
718
+ connectionNotice = "Key entry cancelled. Nothing was changed.";
719
+ continue;
720
+ }
721
+ try {
722
+ setUserKey("AGENTMAIL_API_KEY", value, sessionEnv);
723
+ // Refresh only a value filled implicitly by discovery; explicit env/file wins.
724
+ if (discoveredKeys.has("AGENTMAIL_API_KEY"))
725
+ delete sessionEnv.AGENTMAIL_API_KEY;
726
+ const saved = await saveCommsConnection(resolve(options.cwd));
727
+ connectionNotice = saved.ok ? "Key stored. Project connection saved."
728
+ : `Key stored for your user. ${saved.message}`;
729
+ stdout.write("Checking AgentMail authentication…\n");
730
+ connectionCheck = await tuiRuntime.checkComms({ cwd: resolve(options.cwd), env: connectionEnv(), online: true });
731
+ connectionNotice = saved.ok
732
+ ? connectionCheck.authenticated === true ? "Key stored. Authentication passed."
733
+ : connectionCheck.authenticated === false ? "Key stored. Authentication rejected; test it for details."
734
+ : "Key stored. Authentication unknown; test it to retry."
735
+ : `${connectionNotice} ${connectionCheck.message}`;
736
+ }
737
+ catch {
738
+ connectionNotice = "Could not store the key. Use a single non-empty line and check key-store permissions.";
739
+ }
740
+ }
663
741
  }
664
742
  finally {
665
743
  await observerSession.close();
@@ -681,7 +759,7 @@ function formatKeysHuman(result) {
681
759
  return `${lines.join("\n")}\n`;
682
760
  }
683
761
  /** Read one secret line: from a piped stdin when --stdin, else a hidden TTY prompt. */
684
- async function readSecretValue(useStdin, promptLabel, io) {
762
+ async function readSecretValue(useStdin, promptLabel) {
685
763
  if (useStdin || !process.stdin.isTTY) {
686
764
  const chunks = [];
687
765
  for await (const chunk of process.stdin)
@@ -689,38 +767,7 @@ async function readSecretValue(useStdin, promptLabel, io) {
689
767
  const text = Buffer.concat(chunks).toString("utf8").trim();
690
768
  return text.length > 0 ? text : null;
691
769
  }
692
- io.writeErr(`${promptLabel} (input hidden): `);
693
- return await new Promise((resolve) => {
694
- const stdin = process.stdin;
695
- let value = "";
696
- stdin.setRawMode?.(true);
697
- stdin.resume();
698
- stdin.setEncoding("utf8");
699
- const onData = (key) => {
700
- if (key === "\u0003") {
701
- cleanup();
702
- resolve(null);
703
- return;
704
- }
705
- if (key === "\r" || key === "\n") {
706
- cleanup();
707
- io.writeErr("\n");
708
- resolve(value.trim().length > 0 ? value.trim() : null);
709
- return;
710
- }
711
- if (key === "\u007f" || key === "\b") {
712
- value = value.slice(0, -1);
713
- return;
714
- }
715
- value += key;
716
- };
717
- const cleanup = () => {
718
- stdin.off("data", onData);
719
- stdin.setRawMode?.(false);
720
- stdin.pause();
721
- };
722
- stdin.on("data", onData);
723
- });
770
+ return promptSecret(promptLabel, process.stdin, process.stderr);
724
771
  }
725
772
  function registerKeysCommand(parent, io) {
726
773
  const keys = parent
@@ -729,7 +776,7 @@ function registerKeysCommand(parent, io) {
729
776
  .summary("Manage the user-level provider key store.");
730
777
  keys
731
778
  .command("set")
732
- .argument("<vendor-or-name>", "A vendor alias (openai, e2b, anthropic, github) or a raw ENV_NAME.")
779
+ .argument("<vendor-or-name>", "A vendor alias (openai, e2b, anthropic, github, agentmail) or a raw ENV_NAME.")
733
780
  .description("Store one provider key in the user store (0600), prompted with hidden input.")
734
781
  .option("--stdin", "Read the value from stdin instead of prompting (for agents/pipes).")
735
782
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -739,13 +786,13 @@ function registerKeysCommand(parent, io) {
739
786
  if (name === null) {
740
787
  const result = {
741
788
  schema: KEYS_RESULT_SCHEMA, ok: false, action: "set", store: storePath, names: [],
742
- message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: openai, e2b, anthropic, github.`
789
+ message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: openai, e2b, anthropic, github, agentmail.`
743
790
  };
744
791
  writeResult(command, io, result, formatKeysHuman);
745
792
  io.setExitCode(2);
746
793
  return;
747
794
  }
748
- const value = await readSecretValue(options.stdin === true, `Value for ${name}`, io);
795
+ const value = await readSecretValue(options.stdin === true, `Value for ${name}`);
749
796
  if (value === null) {
750
797
  const result = {
751
798
  schema: KEYS_RESULT_SCHEMA, ok: false, action: "set", store: storePath, names: [name],
@@ -1140,8 +1187,113 @@ function registerRunsCommand(parent, io) {
1140
1187
  function registerCommsCommands(parent, io) {
1141
1188
  const comms = parent
1142
1189
  .command("comms")
1143
- .description("Off-app comms surfaces (email/SMS the app under test sends).")
1190
+ .description("Local email capture, real receiving connections, checks and cleanup recovery.")
1144
1191
  .summary("Off-app comms surfaces.");
1192
+ comms.command("providers")
1193
+ .description("List installed communication provider capabilities. No network requests.")
1194
+ .option("--json", JSON_OPTION_DESCRIPTION)
1195
+ .action((_options, command) => {
1196
+ const result = { schema: "humanish.comms-providers.v1", ok: true, providers: COMMS_PROVIDERS };
1197
+ writeResult(command, io, result, () => COMMS_PROVIDERS.map(provider => `${provider.label}: ${provider.limitation}\nKey: ${provider.keyEnv}\nSetup: ${provider.setupUrl}\n`).join("\n"));
1198
+ });
1199
+ const connections = comms.command("connections").description("Manage project-local non-secret connection profiles. A lab explicitly selects its receiving connection.");
1200
+ connections.command("list")
1201
+ .description("Show saved connections and local credential status; does not authenticate with a provider.")
1202
+ .option("--cwd <path>", "Target project directory.", ".")
1203
+ .option("--env-file <path>", "Load credentials for local status without printing values.")
1204
+ .option("--json", JSON_OPTION_DESCRIPTION)
1205
+ .action(async (options, command) => {
1206
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io }))
1207
+ return;
1208
+ const result = await readCommsSetup(resolve(options.cwd), process.env);
1209
+ writeResult(command, io, result, value => `${value.message}\nAgentMail key: ${value.credential.present ? "present" : "missing"}\n${value.connections.map(connection => `${connection.name}: ${connection.provider} (${connection.apiKeyEnv})\n`).join("")}`);
1210
+ io.setExitCode(result.ok ? 0 : 2);
1211
+ });
1212
+ connections.command("add")
1213
+ .argument("[name]", "Project connection name.", "agentmail")
1214
+ .description("Save an AgentMail connection profile. Does not write a key, alter a lab or contact the provider.")
1215
+ .option("--cwd <path>", "Target project directory.", ".")
1216
+ .option("--provider <id>", "Installed provider id.", "agentmail")
1217
+ .option("--api-key-env <name>", "Environment variable NAME, never its value.", "AGENTMAIL_API_KEY")
1218
+ .option("--json", JSON_OPTION_DESCRIPTION)
1219
+ .action(async (name, options, command) => {
1220
+ const result = { schema: "humanish.comms-connection-result.v1", ...(options.provider === "agentmail"
1221
+ ? await saveCommsConnection(resolve(options.cwd), name, options.apiKeyEnv)
1222
+ : { ok: false, message: "Only AgentMail connection setup is currently available." }) };
1223
+ writeResult(command, io, result, value => `${value.message}\n`);
1224
+ io.setExitCode(result.ok ? 0 : 2);
1225
+ });
1226
+ comms.command("check")
1227
+ .description("Check connection and credential presence; --online authenticates without creating inboxes.")
1228
+ .option("--cwd <path>", "Target project directory.", ".")
1229
+ .option("--connection <name>", "Saved connection name.", "agentmail")
1230
+ .option("--lab <path>", "Check the connection selected by this exact lab.")
1231
+ .option("--online", "Make a read-only provider authentication request.")
1232
+ .option("--env-file <path>", "Load credentials without printing values.")
1233
+ .option("--json", JSON_OPTION_DESCRIPTION)
1234
+ .action(async (options, command) => {
1235
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io }))
1236
+ return;
1237
+ let connection = options.connection;
1238
+ if (options.lab) {
1239
+ const lab = await resolveLabManifest(options.cwd, options.lab);
1240
+ if (!lab.ok || lab.config.comms?.email?.kind !== "real") {
1241
+ const result = { ok: false, message: "This lab does not select a real email connection." };
1242
+ writeResult(command, io, result, value => `${value.message}\n`);
1243
+ io.setExitCode(2);
1244
+ return;
1245
+ }
1246
+ connection = lab.config.comms.email.connection;
1247
+ }
1248
+ const result = await checkCommsConnection({ cwd: resolve(options.cwd), connection, env: process.env, online: options.online === true });
1249
+ writeResult(command, io, result, value => `${value.message}\n`);
1250
+ io.setExitCode(result.ok ? 0 : 2);
1251
+ });
1252
+ comms.command("configure")
1253
+ .description("Preview or save a local receiving-enabled copy of a supported lab. No provider requests.")
1254
+ .requiredOption("--lab <path>", "Exact source lab path or handle.")
1255
+ .option("--cwd <path>", "Target project directory.", ".")
1256
+ .option("--connection <name>", "Saved connection name.", "agentmail")
1257
+ .option("--apply", "Save the local copy; original lab remains unchanged.")
1258
+ .option("--plan-token <digest>", "Require the source and destination to match a previous preview.")
1259
+ .option("--json", JSON_OPTION_DESCRIPTION)
1260
+ .action(async (options, command) => {
1261
+ const result = await configureCommsLab({ ...options, cwd: resolve(options.cwd) });
1262
+ writeResult(command, io, result, value => `${value.message}\n`);
1263
+ io.setExitCode(result.ok ? 0 : 2);
1264
+ });
1265
+ comms.command("recover")
1266
+ .description("Inspect interrupted email leases; --apply deletes only privately recorded resources owned by this project and account.")
1267
+ .option("--cwd <path>", "Target project directory.", ".")
1268
+ .option("--run <id>", "One run to inspect or recover.")
1269
+ .option("--apply", "Recover the selected inactive run and verify mailbox deletion.")
1270
+ .option("--env-file <path>", "Load credentials without printing values.")
1271
+ .option("--json", JSON_OPTION_DESCRIPTION)
1272
+ .action(async (options, command) => {
1273
+ if (!await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io }))
1274
+ return;
1275
+ const cwd = resolve(options.cwd);
1276
+ try {
1277
+ const entries = (await inspectCommsRecovery({ cwd })).filter(entry => !options.run || entry.runId === options.run);
1278
+ if (!options.apply) {
1279
+ const result = { schema: "humanish.comms-recovery.v1", ok: true, entries };
1280
+ writeResult(command, io, result, value => value.entries.length ? value.entries.map(entry => `${entry.runId}: ${entry.unresolvedCount} unresolved; ${entry.activeOwner === null ? "unknown owner" : entry.activeOwner ? "active owner" : "inactive"}\n`).join("") : "No recoverable email leases in this project.\n");
1281
+ return;
1282
+ }
1283
+ if (!options.run || entries.length !== 1)
1284
+ throw new Error("selection");
1285
+ const entry = entries[0];
1286
+ const { connection, adapter } = await resolveReceivingConnection(cwd, entry.connectionName, process.env);
1287
+ const result = { schema: "humanish.comms-recovery-result.v1", ...await recoverCommsReceiving({ cwd, runId: options.run, connectionName: entry.connectionName, apiKeyEnv: connection.apiKeyEnv, adapter }) };
1288
+ writeResult(command, io, result, value => `${value.message}\n`);
1289
+ io.setExitCode(result.ok ? 0 : 2);
1290
+ }
1291
+ catch {
1292
+ const result = { schema: "humanish.comms-recovery-result.v1", ok: false, message: "Recovery could not complete. Select one recorded run with --run, check its connection, and retry. No unrecorded resources are eligible." };
1293
+ writeResult(command, io, result, value => `${value.message}\n`);
1294
+ io.setExitCode(2);
1295
+ }
1296
+ });
1145
1297
  comms
1146
1298
  .command("catch")
1147
1299
  .description("Run the email catch on THIS host so humanish can study an app it does not provision (#328). Your app posts its email sends here; the persona opens /inbox; humanish drains GET /deliveries and writes digest-only evidence. Point your lab's comms.email.external.catchBaseUrl at this server.")
@@ -3232,24 +3384,29 @@ async function renderAndMaybeFollowObserver(args) {
3232
3384
  }
3233
3385
  }
3234
3386
  async function applyEnvFileOption(args) {
3387
+ const env = args.env ?? process.env;
3235
3388
  if (args.envFile) {
3236
- const result = await loadEnvFile(args.cwd, args.envFile);
3389
+ const stagedEnv = { ...env };
3390
+ const result = await loadEnvFile(args.cwd, args.envFile, stagedEnv);
3237
3391
  if (!result.ok) {
3238
3392
  writeResult(args.command, args.io, result, formatEnvFileHuman);
3239
3393
  args.io.setExitCode(2);
3240
3394
  return false;
3241
3395
  }
3396
+ for (const name of result.loaded)
3397
+ env[name] = stagedEnv[name];
3242
3398
  }
3243
3399
  // Provider-key discovery (#436): fill still-missing keys from the documented project
3244
3400
  // overlay, the owning vendors' native stores, and the humanish user store — fill-only
3245
3401
  // (an explicit --env-file or process env always wins), each fill announced by name and
3246
3402
  // source on stderr, never by value. HUMANISH_STRICT_KEYS=1 restores env-only behavior.
3247
3403
  try {
3248
- await keyDiscoveryFn({
3404
+ const discovered = await keyDiscoveryFn({
3249
3405
  cwd: args.cwd,
3250
- env: process.env,
3406
+ env,
3251
3407
  announce: (line) => args.io.writeErr(`${line}\n`)
3252
3408
  });
3409
+ args.onDiscovered?.(discovered.map(fill => fill.name));
3253
3410
  }
3254
3411
  catch {
3255
3412
  // Discovery must never break a command; a rung that fails to read is a miss, not an error.