agent-relay 15.0.0-next.6 → 15.0.0-next.8

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.
@@ -68,8 +68,10 @@ export declare function withInitDefaults(overrides?: Partial<InitCommandDependen
68
68
  * machine by default, the hosted service, or your own server), then connect
69
69
  * your agent tools. By default the Agent Relay core injects messages into
70
70
  * their running sessions; `--delivery mcp` (or a machine the core cannot run
71
- * on) uses the Relay MCP server instead. In a terminal it asks; with `--yes`
72
- * or without a TTY it uses flags and defaults.
71
+ * on) uses the Relay MCP server instead. It asks nothing: flags, then
72
+ * defaults (this machine, every installed tool, global scope), and a summary
73
+ * of what it chose. `--interactive` asks each step in a terminal; `--yes` is
74
+ * accepted and changes nothing.
73
75
  *
74
76
  * @param program - The commander program to add the command to.
75
77
  * @param overrides - Dependencies to replace, typically in tests.
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/init.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AASzC,OAAO,EAkBL,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,OAAO,EACZ,KAAK,WAAW,EAIjB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAA4B,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAErF,OAAO,EAA2B,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACrF,OAAO,EAML,KAAK,YAAY,EACjB,KAAK,cAAc,EACpB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAiBL,KAAK,UAAU,EAKhB,MAAM,sBAAsB,CAAC;AAqB9B,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,WAAW,CAAC;AAC7C,eAAO,MAAM,oBAAoB,6BAA8C,CAAC;AAKhF,8DAA8D;AAC9D,MAAM,WAAW,uBAAwB,SAAQ,cAAc;IAC7D,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,wDAAwD;IACxD,WAAW,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,MAAM,CAAC;IACnF,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAClC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,iEAAiE;IACjE,aAAa,EAAE,MAAM,OAAO,CAAC;IAC7B,QAAQ,EAAE,MAAM,YAAY,CAAC;IAC7B,iFAAiF;IACjF,WAAW,EAAE,MAAM,WAAW,CAAC;IAC/B,0CAA0C;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,aAAa,EAAE,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,CAAC;IACpE;;;OAGG;IACH,UAAU,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC;IACxC,iFAAiF;IACjF,cAAc,EAAE,YAAY,CAAC;IAC7B,2EAA2E;IAC3E,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,6EAA6E;IAC7E,cAAc,EAAE,MAAM,cAAc,CAAC;IACrC,4CAA4C;IAC5C,UAAU,EAAE,MAAM,MAAM,CAAC;IACzB,oEAAoE;IACpE,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IACjE,gDAAgD;IAChD,SAAS,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,MAAM,CAAC;IAClF,sEAAsE;IACtE,aAAa,EAAE,YAAY,CAAC;IAC5B,4EAA4E;IAC5E,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CACtD;AAgJD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAAG,uBAAuB,CAwB1G;AAm9CD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,EAChB,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC/C,IAAI,CA4CN"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/init.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AASzC,OAAO,EAkBL,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,OAAO,EACZ,KAAK,WAAW,EAIjB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAA4B,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAErF,OAAO,EAA2B,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACrF,OAAO,EAML,KAAK,YAAY,EACjB,KAAK,cAAc,EACpB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAiBL,KAAK,UAAU,EAKhB,MAAM,sBAAsB,CAAC;AAyB9B,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,WAAW,CAAC;AAC7C,eAAO,MAAM,oBAAoB,6BAA8C,CAAC;AAKhF,8DAA8D;AAC9D,MAAM,WAAW,uBAAwB,SAAQ,cAAc;IAC7D,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,wDAAwD;IACxD,WAAW,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,MAAM,CAAC;IACnF,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAClC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,iEAAiE;IACjE,aAAa,EAAE,MAAM,OAAO,CAAC;IAC7B,QAAQ,EAAE,MAAM,YAAY,CAAC;IAC7B,iFAAiF;IACjF,WAAW,EAAE,MAAM,WAAW,CAAC;IAC/B,0CAA0C;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,aAAa,EAAE,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,KAAK,SAAS,CAAC;IACpE;;;OAGG;IACH,UAAU,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC;IACxC,iFAAiF;IACjF,cAAc,EAAE,YAAY,CAAC;IAC7B,2EAA2E;IAC3E,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,6EAA6E;IAC7E,cAAc,EAAE,MAAM,cAAc,CAAC;IACrC,4CAA4C;IAC5C,UAAU,EAAE,MAAM,MAAM,CAAC;IACzB,oEAAoE;IACpE,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IACjE,gDAAgD;IAChD,SAAS,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,MAAM,CAAC;IAClF,sEAAsE;IACtE,aAAa,EAAE,YAAY,CAAC;IAC5B,4EAA4E;IAC5E,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CACtD;AAgJD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAAG,uBAAuB,CAwB1G;AA6oDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,EAChB,SAAS,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC/C,IAAI,CAwDN"}
@@ -10,8 +10,8 @@ import { sessionBaseName } from '../lib/mcp-session-name.js';
10
10
  import { runSdk, withSdkDefaults } from '../lib/sdk-command.js';
11
11
  import { currentEnginePlatform, ensureReleaseBinary, ensureStandaloneEngine, fetchDownload, readEngineVersion, } from '../lib/relaycast-engine-install.js';
12
12
  import { AUTO_ACTIVATE, CORE_START_TIMEOUT_MS, PROBE_DELIVERY, PROBE_VERSION, connectCore, coreSocketPath, deliveryKeys, disableDelivery, enableDelivery, probeAssetName, probeAssetUrl, readCoreState, readDeliveryRecord, readProbeVersion, startCoreProcess, writePrivateJson, } from '../lib/probe-core.js';
13
- import { shellQuote } from '../lib/agent-registration.js';
14
- import { claudeFileStates, openCodeConfigDir, openCodeState, tidyClaudeSettings, tidyOpenCodePlugin, } from '../lib/init-leftovers.js';
13
+ import { shellQuote, shellWord } from '../lib/agent-registration.js';
14
+ import { claudeFileStates, openCodeConfigDir, openCodeState, piAgentDir, piState, tidyClaudeSettings, tidyOpenCodePlugin, tidyPiExtension, } from '../lib/init-leftovers.js';
15
15
  import { persistWorkspaceSession } from '../lib/workspace-session.js';
16
16
  import { readWorkspaceStore } from '../lib/workspace-store.js';
17
17
  import { getProjectPaths } from '@agent-relay/config';
@@ -286,22 +286,22 @@ async function verifyWorkspace(deps, origin, key) {
286
286
  }
287
287
  if (!response.ok)
288
288
  throw new Error(`${origin} answered ${response.status} when checking the workspace.`);
289
- return workspaceIdIn(response);
289
+ return workspaceInfoIn(response);
290
290
  }
291
- /** The `id` of a `GET /v1/workspace` answer (`{ok, data: {id}}`), read leniently. */
292
- async function workspaceIdIn(response) {
291
+ /** The `id` and `name` of a `GET /v1/workspace` answer (`{ok, data: {id, name}}`), read leniently. */
292
+ async function workspaceInfoIn(response) {
293
+ const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
293
294
  try {
294
295
  const body = (await response.json());
295
- const id = body?.data?.id ?? body?.id;
296
- return typeof id === 'string' && id.trim() ? id.trim() : undefined;
296
+ return { id: text(body?.data?.id ?? body?.id), name: text(body?.data?.name ?? body?.name) };
297
297
  }
298
298
  catch {
299
- return undefined;
299
+ return {};
300
300
  }
301
301
  }
302
302
  /** The workspace id a key belongs to, when its server can say; never throws. */
303
303
  async function workspaceIdOf(deps, origin, key) {
304
- return verifyWorkspace(deps, origin, key).catch(() => undefined);
304
+ return verifyWorkspace(deps, origin, key).then((workspace) => workspace.id, () => undefined);
305
305
  }
306
306
  function parsePort(value) {
307
307
  const port = Number.parseInt(value, 10);
@@ -322,14 +322,45 @@ function parseTools(value) {
322
322
  throw new InvalidArgumentError(error.message);
323
323
  }
324
324
  }
325
- /** A word as the shell reads it: quoted unless it is plainly safe to paste. */
326
- function shellWord(value) {
327
- return /^[A-Za-z0-9._@%+=:,/-]+$/.test(value) ? value : shellQuote(value);
328
- }
329
325
  /** The command that makes `name` the machine-wide active workspace. */
330
326
  function switchCommand(name) {
331
327
  return `relay workspace switch ${shellWord(name)}`;
332
328
  }
329
+ /**
330
+ * The npm package spec a teammate's `npx` runs: the `next` dist-tag for a
331
+ * `-next` prerelease (or a build that is not a published version), else
332
+ * this exact version.
333
+ */
334
+ function teammatePackage(version) {
335
+ const published = version?.trim();
336
+ if (!published || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(published) || published.includes('-next')) {
337
+ return 'agent-relay@next';
338
+ }
339
+ return `agent-relay@${published}`;
340
+ }
341
+ /**
342
+ * The one command a teammate runs in their project to join a hosted
343
+ * workspace, Relay installed or not: it saves the workspace (named as the
344
+ * server names it), starts the Agent Relay core and turns on message
345
+ * injection for their agent tools. `relay workspace join` only saves it.
346
+ */
347
+ function joinCommand(key, version) {
348
+ return `npx -y ${teammatePackage(version)} init --key ${shellQuote(key)}`;
349
+ }
350
+ /**
351
+ * A free name for a workspace joined by key: `name`, unless a saved
352
+ * workspace under it holds another key, then `name-2` and on.
353
+ */
354
+ function freeWorkspaceName(name, key) {
355
+ const saved = readWorkspaceStore().workspaces;
356
+ const free = (candidate) => !saved[candidate] || saved[candidate]?.key === key;
357
+ if (free(name))
358
+ return name;
359
+ for (let n = 2;; n += 1) {
360
+ if (free(`${name}-${n}`))
361
+ return `${name}-${n}`;
362
+ }
363
+ }
333
364
  /**
334
365
  * Save the workspace and bind it to this project. It becomes the machine-wide
335
366
  * active workspace only when no other one is: a machine already serving a
@@ -362,22 +393,79 @@ function globalScopeNote(setup, scope, installed) {
362
393
  return (`In projects without their own workspace, globally set up agent tools use the active workspace "${setup.keptActive}". ` +
363
394
  `To make "${setup.workspace}" the machine-wide fallback, run: ${switchCommand(setup.workspace)}`);
364
395
  }
396
+ /** A workspace name part: lowercase letters, digits and single dashes. */
397
+ function nameSlug(value) {
398
+ return value
399
+ .toLowerCase()
400
+ .replace(/[^a-z0-9]+/g, '-')
401
+ .replace(/^-+|-+$/g, '');
402
+ }
403
+ /**
404
+ * The name `relay init --hosted` gives a new workspace without `--name`:
405
+ * the person and the project folder (`alice-api`), with a numeric suffix
406
+ * when a saved workspace already has that name, so a second hosted
407
+ * workspace never replaces the first in the store.
408
+ */
409
+ function defaultHostedName(projectRoot) {
410
+ let user = '';
411
+ try {
412
+ user = os.userInfo().username;
413
+ }
414
+ catch {
415
+ // No passwd entry (some containers): the folder alone.
416
+ }
417
+ const base = [nameSlug(user), nameSlug(path.basename(projectRoot))].filter(Boolean).join('-').slice(0, 40) || 'relay';
418
+ const taken = new Set(Object.keys(readWorkspaceStore().workspaces));
419
+ if (!taken.has(base))
420
+ return base;
421
+ for (let n = 2;; n += 1) {
422
+ if (!taken.has(`${base}-${n}`))
423
+ return `${base}-${n}`;
424
+ }
425
+ }
365
426
  /**
366
427
  * Connect to (or start) the server and save its workspace for this project.
367
428
  * This is the original `relay init`; the agent-tool setup comes after it.
368
429
  */
369
430
  async function setupServer(deps, choice) {
370
431
  if (choice.mode === 'own') {
371
- const { url, key } = choice;
372
- if (!url || !key)
373
- throw new Error('Pass --url and --key together for your own server.');
432
+ const { key } = choice;
433
+ if (!key)
434
+ throw new Error('Pass --key with --url for your own server.');
435
+ // `--key` alone joins the hosted service. A key already bound to a
436
+ // self-hosted server is never sent there: only an explicit --url moves it.
437
+ const url = choice.url ?? HOSTED_ORIGIN;
438
+ const bound = choice.url ? undefined : boundOrigin(key);
439
+ if (bound && new URL(bound).origin !== HOSTED_ORIGIN) {
440
+ throw new Error(`This workspace key belongs to ${new URL(bound).origin}, so relay init will not send it to the hosted ` +
441
+ `service. To use it there, run: relay init --url ${shellWord(new URL(bound).origin)} --key <key>`);
442
+ }
374
443
  const origin = configuredOrigin(url, '--url');
375
444
  if (!origin)
376
445
  throw new Error('--url is empty.');
377
- const workspaceId = await verifyWorkspace(deps, origin, key);
446
+ const workspace = await verifyWorkspace(deps, origin, key);
447
+ const workspaceId = workspace.id;
378
448
  guardCredentialOrigin(key, url);
379
- const name = choice.name ?? new URL(origin).hostname;
449
+ const hosted = origin === HOSTED_ORIGIN;
450
+ // Named as the person says, else as the server names the workspace.
451
+ const name = choice.name ??
452
+ freeWorkspaceName(workspace.name ??
453
+ (hosted ? defaultHostedName(deps.toolContext().projectRoot) : new URL(origin).hostname), key);
380
454
  const { lines, ...saved } = saveWorkspace(name, key);
455
+ // A teammate joining a hosted workspace by key: the hosted service, not
456
+ // a server of their own. It stays an `own` setup: init did not create
457
+ // the workspace, so its name never stands in for a workspace id when
458
+ // checking which workspace the core serves (`sameWorkspace`).
459
+ if (hosted) {
460
+ return {
461
+ ...saved,
462
+ workspaceId,
463
+ mode: 'own',
464
+ origin,
465
+ headline: `Joined hosted workspace "${name}" (${origin}).`,
466
+ details: ['Your key is only ever sent to the hosted service.', ...lines],
467
+ };
468
+ }
381
469
  return {
382
470
  ...saved,
383
471
  workspaceId,
@@ -388,7 +476,7 @@ async function setupServer(deps, choice) {
388
476
  };
389
477
  }
390
478
  if (choice.mode === 'hosted') {
391
- const name = choice.name ?? `relay-${os.hostname().split('.')[0]}`;
479
+ const name = choice.name ?? defaultHostedName(deps.toolContext().projectRoot);
392
480
  const relay = await deps.createWorkspace(name, HOSTED_ORIGIN);
393
481
  if (!relay.workspaceKey)
394
482
  throw new Error('The hosted service did not return a workspace key.');
@@ -400,9 +488,11 @@ async function setupServer(deps, choice) {
400
488
  origin: HOSTED_ORIGIN,
401
489
  headline: `Created hosted workspace "${name}".`,
402
490
  details: [
491
+ ...(choice.name ? [] : [`Named after you and this folder; pass --name to choose another.`]),
403
492
  ...lines,
404
- 'On another machine, join it with (treat this key like a password):',
405
- ` relay workspace join ${name} ${relay.workspaceKey}`,
493
+ 'On another machine, run this in the project to join it and connect its agent tools ' +
494
+ '(treat this key like a password):',
495
+ ` ${joinCommand(relay.workspaceKey, deps.cliVersion)}`,
406
496
  ],
407
497
  };
408
498
  }
@@ -416,7 +506,7 @@ async function setupServer(deps, choice) {
416
506
  // Only a key the server refuses is replaced. A timeout or server
417
507
  // error is reported instead, so a hiccup never swaps the workspace
418
508
  // (and its agents and messages) for a new one.
419
- workspaceId = await verifyWorkspace(deps, origin, workspaceKey).catch((error) => {
509
+ workspaceId = await verifyWorkspace(deps, origin, workspaceKey).then((workspace) => workspace.id, (error) => {
420
510
  if (!(error instanceof WorkspaceKeyRejectedError))
421
511
  throw error;
422
512
  workspaceKey = undefined;
@@ -726,10 +816,12 @@ async function setupInjection(deps, tools, setup) {
726
816
  // included, instead of the hold the core writes on each login.
727
817
  const before = tools.includes('claude') ? claudeInboundValues(ctx) : {};
728
818
  // The files the core is about to edit, as they are now: what --uninstall
729
- // tidies back to (a settings.json or plugins folder init created).
819
+ // tidies back to (a settings.json, plugins or extensions folder, or off
820
+ // marker init created).
730
821
  const files = {
731
822
  claudeFiles: tools.includes('claude') ? claudeFileStates(claudeLoginSettings(ctx)) : undefined,
732
823
  openCode: tools.includes('opencode') ? openCodeState(openCodeConfigDir(ctx.env, ctx.home)) : undefined,
824
+ pi: tools.includes('pi') ? piState(piAgentDir(ctx.env, ctx.home), ctx.home) : undefined,
733
825
  };
734
826
  const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn);
735
827
  // Applied to the record as it is now, not as it was read: another
@@ -756,6 +848,7 @@ async function setupInjection(deps, tools, setup) {
756
848
  openCode: touched.includes('opencode_plugin')
757
849
  ? (current.openCode ?? files.openCode)
758
850
  : current.openCode,
851
+ pi: touched.includes('pi_extension') ? (current.pi ?? files.pi) : current.pi,
759
852
  });
760
853
  });
761
854
  try {
@@ -787,6 +880,31 @@ async function setupInjection(deps, tools, setup) {
787
880
  session.client.close();
788
881
  }
789
882
  }
883
+ /** The bytes of the regular file `file` is or links to; undefined when there is none. */
884
+ function readIfFile(file) {
885
+ let fd;
886
+ try {
887
+ // One descriptor for the check and the read, so they see the same file.
888
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
889
+ return fs.fstatSync(fd).isFile() ? fs.readFileSync(fd) : undefined;
890
+ }
891
+ catch {
892
+ return undefined;
893
+ }
894
+ finally {
895
+ if (fd !== undefined)
896
+ fs.closeSync(fd);
897
+ }
898
+ }
899
+ /** Puts `bytes` back in `file`; a failure leaves the core's marker, which means the same. */
900
+ function restoreBytes(file, bytes) {
901
+ try {
902
+ fs.writeFileSync(file, bytes);
903
+ }
904
+ catch {
905
+ // The core's own marker stays: Pi is still off.
906
+ }
907
+ }
790
908
  /** Settings init turned on that are neither recorded nor turned off: setup stops. */
791
909
  class InjectionStuckError extends Error {
792
910
  }
@@ -809,6 +927,9 @@ async function undoUnrecorded(client, turnedOn, before, files, cause) {
809
927
  if (turnedOn.includes('opencode_plugin') && !failed('opencode_plugin') && files.openCode) {
810
928
  tidyOpenCodePlugin(files.openCode);
811
929
  }
930
+ if (turnedOn.includes('pi_extension') && !failed('pi_extension') && files.pi) {
931
+ tidyPiExtension(files.pi);
932
+ }
812
933
  if (failures.length > 0) {
813
934
  throw new InjectionStuckError(`Could not save ${deliveryRecordPath()} (${cause.message}), and these settings relay init turned on ` +
814
935
  `could not be turned off again: ${failures.join('; ')}. Turn them off with ` +
@@ -1000,13 +1121,14 @@ const CLAUDE_FILE_KEYS = ['direct_delivery', 'claude_session_hook'];
1000
1121
  /**
1001
1122
  * Once nothing init turned on edits a tool's files any more, tidies them
1002
1123
  * back to how init found them (a Claude settings.json init created, the
1003
- * OpenCode plugin's off marker) and drops their recorded state. Run after
1124
+ * OpenCode plugin's and Pi extension's off markers) and drops their
1125
+ * recorded state. Run after
1004
1126
  * the core turned the settings off and the original crossSessionInbound is
1005
1127
  * back.
1006
1128
  * @returns The record without the states it used
1007
1129
  */
1008
1130
  function tidyLeftovers(record) {
1009
- let { claudeFiles, openCode } = record;
1131
+ let { claudeFiles, openCode, pi } = record;
1010
1132
  if (claudeFiles && !record.turnedOn.some((key) => CLAUDE_FILE_KEYS.includes(key))) {
1011
1133
  tidyClaudeSettings(claudeFiles);
1012
1134
  claudeFiles = undefined;
@@ -1015,11 +1137,16 @@ function tidyLeftovers(record) {
1015
1137
  tidyOpenCodePlugin(openCode);
1016
1138
  openCode = undefined;
1017
1139
  }
1140
+ if (pi && !record.turnedOn.includes('pi_extension')) {
1141
+ tidyPiExtension(pi);
1142
+ pi = undefined;
1143
+ }
1018
1144
  return {
1019
1145
  turnedOn: record.turnedOn,
1020
1146
  claudeInbound: record.claudeInbound,
1021
1147
  ...(claudeFiles ? { claudeFiles } : {}),
1022
1148
  ...(openCode ? { openCode } : {}),
1149
+ ...(pi ? { pi } : {}),
1023
1150
  };
1024
1151
  }
1025
1152
  /**
@@ -1040,6 +1167,7 @@ function writeDeliveryRecord(record) {
1040
1167
  ...(Object.keys(record.claudeInbound).length > 0 ? { claudeInbound: record.claudeInbound } : {}),
1041
1168
  ...(record.claudeFiles ? { claudeFiles: record.claudeFiles } : {}),
1042
1169
  ...(record.openCode ? { openCode: record.openCode } : {}),
1170
+ ...(record.pi ? { pi: record.pi } : {}),
1043
1171
  });
1044
1172
  }
1045
1173
  /**
@@ -1070,7 +1198,17 @@ async function turnOffInjection(deps, keys) {
1070
1198
  throw new Error(`Could not reach the Agent Relay core to turn off message injection: ${session.reason}`);
1071
1199
  }
1072
1200
  try {
1201
+ // A Pi off marker already there was the person's own opt-out after init
1202
+ // (the desktop app's switch), not this run's: it is kept, byte for byte,
1203
+ // though turning pi_extension off rewrites it.
1204
+ const pi = readDeliveryRecord(deliveryRecordPath()).pi;
1205
+ const piOptedOut = keys.includes('pi_extension') &&
1206
+ pi !== undefined &&
1207
+ fs.lstatSync(pi.marker, { throwIfNoEntry: false }) !== undefined;
1208
+ const piMarker = piOptedOut && pi ? readIfFile(pi.marker) : undefined;
1073
1209
  const failures = await disableDelivery(session.client, keys);
1210
+ if (pi && piMarker)
1211
+ restoreBytes(pi.marker, piMarker);
1074
1212
  const done = keys.filter((key) => !failures.some((failure) => failure.startsWith(`${key}:`)));
1075
1213
  const ctx = deps.toolContext();
1076
1214
  updateDeliveryRecord((current) => {
@@ -1078,6 +1216,8 @@ async function turnOffInjection(deps, keys) {
1078
1216
  restoreClaudeInbound(recordedClaudeFiles(current, ctx));
1079
1217
  return tidyLeftovers({
1080
1218
  ...current,
1219
+ // Kept as if it had been there before init; an extensions folder init created still goes.
1220
+ ...(piOptedOut && current.pi ? { pi: { ...current.pi, off: true } } : {}),
1081
1221
  turnedOn: current.turnedOn.filter((key) => !done.includes(key)),
1082
1222
  claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound,
1083
1223
  });
@@ -1132,11 +1272,12 @@ async function stopCore(deps, quiet = false) {
1132
1272
  fs.rmSync(coreStatePath(), { force: true });
1133
1273
  }
1134
1274
  function serverChoiceFromFlags(o) {
1135
- if (Boolean(o.url) !== Boolean(o.key))
1136
- throw new Error('Pass --url and --key together for your own server.');
1275
+ if (o.url && !o.key)
1276
+ throw new Error('Pass --key with --url for your own server.');
1137
1277
  if (o.url && o.hosted)
1138
1278
  throw new Error('Choose either --hosted or --url/--key.');
1139
- if (o.url)
1279
+ // A key joins its workspace: on --url's server, else on the hosted service.
1280
+ if (o.key)
1140
1281
  return { mode: 'own', url: o.url, key: o.key, name: o.name, port: o.port };
1141
1282
  if (o.hosted)
1142
1283
  return { mode: 'hosted', name: o.name, port: o.port };
@@ -1231,7 +1372,7 @@ async function runPlain(deps, o) {
1231
1372
  deps.log(line);
1232
1373
  const ctx = deps.toolContext();
1233
1374
  const tools = o.tools ?? detectTools(ctx).map((tool) => tool.id);
1234
- const scope = o.scope ?? 'project';
1375
+ const scope = o.scope ?? 'global';
1235
1376
  const plan = await planDelivery(deps, tools, o.delivery ?? 'inject', setup, ctx);
1236
1377
  if (plan.injection) {
1237
1378
  deps.log('');
@@ -1279,6 +1420,28 @@ async function runPlain(deps, o) {
1279
1420
  deps.log(` ${line}`);
1280
1421
  for (const line of CLI_FALLBACK)
1281
1422
  deps.log(` ${line}`);
1423
+ deps.log('');
1424
+ for (const line of choicesSummary(o, setup, tools, scope))
1425
+ deps.log(line);
1426
+ }
1427
+ /**
1428
+ * What a `relay init` that asked nothing chose, each with the flag that
1429
+ * changes it, so the defaults are never a surprise.
1430
+ */
1431
+ function choicesSummary(o, setup, tools, scope) {
1432
+ const where = setup.mode === 'local'
1433
+ ? 'this machine (--hosted for agents on other machines, or --url and --key for your own server)'
1434
+ : setup.mode === 'hosted' && !o.url
1435
+ ? `hosted workspace "${setup.workspace}"${o.name ? '' : ' (--name to name it)'}`
1436
+ : `workspace "${setup.workspace}" on ${setup.origin}`;
1437
+ const toolList = tools.length > 0 ? tools.join(', ') : 'none';
1438
+ return [
1439
+ 'Chosen without asking:',
1440
+ ` Server: ${where}`,
1441
+ ` Tools: ${toolList}${o.tools ? '' : ' (the ones installed here; --tools to choose)'}`,
1442
+ ` Scope: ${scope}${o.scope ? '' : ' (--scope project for this project only)'}`,
1443
+ 'To choose each step yourself, run: relay init --interactive',
1444
+ ];
1282
1445
  }
1283
1446
  /**
1284
1447
  * Codex reads a project's .codex/config.toml only once that project is
@@ -1425,11 +1588,10 @@ async function runGuided(deps, o) {
1425
1588
  p.note(summary.join('\n'), 'Installation Summary');
1426
1589
  if (delivery === 'mcp' && launch.warning)
1427
1590
  p.warn(launch.warning);
1428
- if (!o.yes) {
1429
- const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true });
1430
- if (proceed === CANCELLED || !proceed)
1431
- return cancelled();
1432
- }
1591
+ // `--interactive` asks to the end; `--yes` changes nothing here either.
1592
+ const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true });
1593
+ if (proceed === CANCELLED || !proceed)
1594
+ return cancelled();
1433
1595
  const injecting = p.spinner();
1434
1596
  if (delivery === 'inject')
1435
1597
  injecting.start('Setting up message injection');
@@ -1483,8 +1645,10 @@ function parseDelivery(value) {
1483
1645
  * machine by default, the hosted service, or your own server), then connect
1484
1646
  * your agent tools. By default the Agent Relay core injects messages into
1485
1647
  * their running sessions; `--delivery mcp` (or a machine the core cannot run
1486
- * on) uses the Relay MCP server instead. In a terminal it asks; with `--yes`
1487
- * or without a TTY it uses flags and defaults.
1648
+ * on) uses the Relay MCP server instead. It asks nothing: flags, then
1649
+ * defaults (this machine, every installed tool, global scope), and a summary
1650
+ * of what it chose. `--interactive` asks each step in a terminal; `--yes` is
1651
+ * accepted and changes nothing.
1488
1652
  *
1489
1653
  * @param program - The commander program to add the command to.
1490
1654
  * @param overrides - Dependencies to replace, typically in tests.
@@ -1493,19 +1657,21 @@ export function registerInitCommand(program, overrides = {}) {
1493
1657
  const deps = withInitDefaults(overrides);
1494
1658
  program
1495
1659
  .command('init')
1496
- .description('Set up Relay and connect your agent tools: a private server on this machine (default), the hosted ' +
1497
- 'service (--hosted), or your own server (--url and --key); then the Agent Relay core injects messages ' +
1498
- 'into running Claude Code, Codex, OpenCode, Grok, Pi and Devin sessions')
1660
+ .description('Set up Relay and connect your agent tools without asking: a private server on this machine (default), ' +
1661
+ 'the hosted service (--hosted), or your own server (--url and --key), every installed agent tool, and ' +
1662
+ 'the Agent Relay core injecting messages into running Claude Code, Codex, OpenCode, Grok, Pi and Devin ' +
1663
+ 'sessions. --interactive asks each step instead')
1499
1664
  .option('--hosted', 'Use the hosted service so agents on other machines can join')
1500
- .option('--url <url>', 'Your own Relaycast server')
1501
- .option('--key <key>', 'Workspace key for your own server')
1502
- .option('--name <name>', 'Workspace name to save this as')
1665
+ .option('--url <url>', 'Your own Relaycast server (with --key)')
1666
+ .option('--key <key>', 'Join the workspace this key belongs to: hosted, or on --url')
1667
+ .option('--name <name>', 'Workspace name to save this as (default: the one its server names)')
1503
1668
  .option('--port <port>', 'Port for the local server', parsePort, DEFAULT_LOCAL_PORT)
1504
1669
  .option('--tools <tools>', `Agent tools to connect: ${TOOL_IDS.join(',')}, all, or none (default: the ones installed here)`, parseTools)
1505
1670
  .option('--delivery <delivery>', 'inject (default): the Agent Relay core types messages into running sessions; mcp: the Relay MCP ' +
1506
1671
  'server, whose inbox agents check', parseDelivery)
1507
- .option('--scope <scope>', 'Where the Relay MCP server goes: project or global (injection is always per user)', parseScope)
1508
- .option('-y, --yes', 'Do not ask; use flags and defaults')
1672
+ .option('--scope <scope>', 'Where the Relay MCP server goes: global (default) or project (injection is always per user)', parseScope)
1673
+ .option('-i, --interactive', 'Ask where agents talk, which agent tools to connect and where the MCP server goes')
1674
+ .option('-y, --yes', 'Accepted for older scripts: relay init asks nothing unless --interactive')
1509
1675
  .option('--uninstall', 'Remove the Relay MCP entries relay init added (both scopes unless --scope) and turn off the injection it turned on')
1510
1676
  .option('--stop', 'Stop the local server and the Agent Relay core started by relay init')
1511
1677
  .action(async (o) => {
@@ -1514,8 +1680,12 @@ export function registerInitCommand(program, overrides = {}) {
1514
1680
  return stopLocalServer(deps);
1515
1681
  if (o.uninstall)
1516
1682
  return uninstall(deps, o);
1517
- if (!o.yes && deps.isInteractive())
1683
+ if (o.interactive) {
1684
+ if (!deps.isInteractive()) {
1685
+ throw new Error('relay init --interactive needs a terminal; run relay init without it to use the defaults.');
1686
+ }
1518
1687
  return runGuided(deps, o);
1688
+ }
1519
1689
  return runPlain(deps, o);
1520
1690
  });
1521
1691
  });