run402 4.70.9 → 4.72.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 (44) hide show
  1. package/gitvault-surface.json +1 -1
  2. package/lib/cold-start.mjs +33 -7
  3. package/lib/command-manifest.mjs +9 -2
  4. package/lib/feedback.mjs +58 -10
  5. package/lib/org-context.mjs +8 -2
  6. package/lib/rooms-context.mjs +14 -3
  7. package/lib/rooms.mjs +299 -2
  8. package/lib/sdk-errors.mjs +25 -0
  9. package/package.json +1 -1
  10. package/sdk/dist/namespaces/admin.d.ts +17 -2
  11. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  12. package/sdk/dist/namespaces/admin.js +13 -4
  13. package/sdk/dist/namespaces/admin.js.map +1 -1
  14. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  15. package/sdk/dist/namespaces/deploy.js +6 -0
  16. package/sdk/dist/namespaces/deploy.js.map +1 -1
  17. package/sdk/dist/namespaces/deploy.types.d.ts +14 -5
  18. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/rooms.d.ts +53 -1
  20. package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/rooms.js +130 -0
  22. package/sdk/dist/namespaces/rooms.js.map +1 -1
  23. package/sdk/dist/namespaces/rooms.types.d.ts +94 -0
  24. package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
  25. package/sdk/dist/node/bearer-claim-key.d.ts +126 -0
  26. package/sdk/dist/node/bearer-claim-key.d.ts.map +1 -0
  27. package/sdk/dist/node/bearer-claim-key.js +209 -0
  28. package/sdk/dist/node/bearer-claim-key.js.map +1 -0
  29. package/sdk/dist/node/gitvault-address.d.ts +19 -0
  30. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  31. package/sdk/dist/node/gitvault-address.js +20 -0
  32. package/sdk/dist/node/gitvault-address.js.map +1 -1
  33. package/sdk/dist/node/gitvault-handoff.d.ts +3 -95
  34. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
  35. package/sdk/dist/node/gitvault-handoff.js +9 -122
  36. package/sdk/dist/node/gitvault-handoff.js.map +1 -1
  37. package/sdk/dist/node/index.d.ts +3 -1
  38. package/sdk/dist/node/index.d.ts.map +1 -1
  39. package/sdk/dist/node/index.js +8 -1
  40. package/sdk/dist/node/index.js.map +1 -1
  41. package/sdk/dist/node/paid-fetch.d.ts +8 -0
  42. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  43. package/sdk/dist/node/paid-fetch.js +41 -1
  44. package/sdk/dist/node/paid-fetch.js.map +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.70.9",
2
+ "surface_version": "4.72.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -14,6 +14,14 @@
14
14
  * untouched; this module does not replace it, only the one path `repos
15
15
  * create` needs when it discovers there is no tier and nothing else has
16
16
  * set one up yet.
17
+ *
18
+ * add-room-invite design D9 splits the funded-wallet half (allowance →
19
+ * faucet-if-empty → brief settlement poll) out as {@link ensureFundedWallet}
20
+ * — `rooms join <key>` needs exactly that half, with NO tier purchase and NO
21
+ * project creation (the claim itself is the one x402 payment that funds the
22
+ * onboarding). {@link foldColdStartChain} is now their composition —
23
+ * `ensureFundedWallet` followed by the tier step — so every EXISTING caller
24
+ * (`repos create`/`resume`/`join`) is unchanged.
17
25
  */
18
26
  import { readAllowance, saveAllowance } from "./config.mjs";
19
27
  import { getSdk } from "./sdk.mjs";
@@ -22,13 +30,16 @@ const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view"
22
30
  const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
23
31
 
24
32
  /**
33
+ * Allowance → faucet-if-empty → brief settlement poll. NO tier purchase, no
34
+ * project creation — the caller decides what (if anything) to pay for next.
35
+ *
25
36
  * @param {(line: string) => void} announce Called once per step, so the
26
37
  * caller can print each one it took (client-surface spec: "announcing
27
38
  * each step").
28
- * @returns {Promise<{allowance_created: boolean, faucet_requested: boolean, tier: object|null}>}
39
+ * @returns {Promise<{allowance_created: boolean, faucet_requested: boolean, address: string}>}
29
40
  */
30
- export async function foldColdStartChain(announce = () => {}) {
31
- const out = { allowance_created: false, faucet_requested: false, tier: null };
41
+ export async function ensureFundedWallet(announce = () => {}) {
42
+ const out = { allowance_created: false, faucet_requested: false, address: "" };
32
43
 
33
44
  let allowance = readAllowance();
34
45
  if (!allowance) {
@@ -40,6 +51,7 @@ export async function foldColdStartChain(announce = () => {}) {
40
51
  out.allowance_created = true;
41
52
  announce(`allowance created: ${allowance.address}`);
42
53
  }
54
+ out.address = allowance.address;
43
55
 
44
56
  const { createPublicClient, http } = await import("viem");
45
57
  const { baseSepolia } = await import("viem/chains");
@@ -48,7 +60,7 @@ export async function foldColdStartChain(announce = () => {}) {
48
60
  try {
49
61
  balance = Number(await client.readContract({ address: USDC_SEPOLIA, abi: USDC_ABI, functionName: "balanceOf", args: [allowance.address] }));
50
62
  } catch {
51
- /* an RPC hiccup here is not fatal — the tier purchase below will surface a real payment failure if the balance really is zero */
63
+ /* an RPC hiccup here is not fatal — the payment below will surface a real failure if the balance really is zero */
52
64
  }
53
65
  if (balance === 0) {
54
66
  announce("balance is 0 — requesting the testnet faucet");
@@ -70,8 +82,22 @@ export async function foldColdStartChain(announce = () => {}) {
70
82
  saveAllowance({ ...allowance, funded: true, lastFaucet: new Date().toISOString() });
71
83
  }
72
84
 
73
- announce("subscribing to the prototype tier (one x402 testnet payment, perpetual)");
74
- out.tier = await getSdk().tier.set("prototype");
75
- announce(`prototype tier active${out.tier?.status === "already_active" ? " (already active)" : ""}`);
76
85
  return out;
77
86
  }
87
+
88
+ /**
89
+ * `ensureFundedWallet` + the tier step. Unchanged from before the split —
90
+ * every existing caller (`repos create`/`resume`/`join`) keeps working with
91
+ * no edits.
92
+ *
93
+ * @param {(line: string) => void} announce Called once per step.
94
+ * @returns {Promise<{allowance_created: boolean, faucet_requested: boolean, tier: object|null}>}
95
+ */
96
+ export async function foldColdStartChain(announce = () => {}) {
97
+ const funded = await ensureFundedWallet(announce);
98
+
99
+ announce("subscribing to the prototype tier (one x402 testnet payment, perpetual)");
100
+ const tier = await getSdk().tier.set("prototype");
101
+ announce(`prototype tier active${tier?.status === "already_active" ? " (already active)" : ""}`);
102
+ return { allowance_created: funded.allowance_created, faucet_requested: funded.faucet_requested, tier };
103
+ }
@@ -257,7 +257,14 @@ export const COMMAND_MANIFEST = [
257
257
  { path: ["subscriptions", "add"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
258
258
  { path: ["subscriptions", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
259
259
  { path: ["subscriptions", "rm"], positionals: [p("subscription_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["r_1"], runStyle: "sub" },
260
- { path: ["rooms", "join"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
260
+ // add-room-invite design D9: `rooms join` is one verb, two forms no
261
+ // positional registers a presence (the behaviorally-tested path, unchanged
262
+ // by the optional positional below since minimalArgs stays empty); a
263
+ // `kri1_…` positional claims a room seat first (a real x402 payment + an
264
+ // org-membership mutation), so that form is never behaviorally exercised
265
+ // here — the same reasoning `repos join`'s own row states.
266
+ { path: ["rooms", "join"], positionals: [p("key", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
267
+ { path: ["rooms", "invite"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "mints a single-use bearer key from a live room, registering the inviter's presence and posting a real room message — never run against the gate's own checkout" },
261
268
  { path: ["rooms", "leave"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
262
269
  { path: ["messages", "send"], positionals: [p("body")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["hello"], runStyle: "sub" },
263
270
  { path: ["messages", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
@@ -415,7 +422,7 @@ export const COMMAND_MANIFEST = [
415
422
  { path: ["email", "webhooks", "redrive"], positionals: [p("delivery_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["dlv_gate1"] },
416
423
 
417
424
  // ── message / agent / operator ───────────────────────────────────────────
418
- { path: ["feedback", "send"], positionals: [p("words", { variadic: true })], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["hello", "from", "the", "gate"] },
425
+ { path: ["feedback", "send"], positionals: [p("words", { variadic: true })], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["hello", "from", "the", "gate"] },
419
426
  { path: ["agent", "contact"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--name", "gate-agent"] },
420
427
  { path: ["agent", "status"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
421
428
  { path: ["agent", "verify-email"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
package/lib/feedback.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { allowanceAuthHeaders } from "./config.mjs";
2
2
  import { getSdk } from "./sdk.mjs";
3
3
  import { reportSdkError, fail } from "./sdk-errors.mjs";
4
- import { assertKnownFlags, normalizeArgv, failUnknownSubcommand } from "./argparse.mjs";
4
+ import { assertKnownFlags, normalizeArgv, failUnknownSubcommand, flagValue, positionalArgs } from "./argparse.mjs";
5
5
 
6
6
  const HELP = `run402 feedback — Send feedback to the Run402 developers
7
7
 
8
8
  Usage:
9
- run402 feedback send <text>
9
+ run402 feedback send <text> [--project <project_id>] [--handle <handle>]
10
10
 
11
11
  Notes:
12
12
  - WRITE-ONLY: there is no inbox to read and no reply path. If you need an
@@ -18,8 +18,24 @@ Notes:
18
18
  inbox useful and prevent payload-dump misuse. Trim or summarize long
19
19
  content (e.g. stack traces) before sending.
20
20
 
21
+ Finishing a deploy — the promotion consent flow:
22
+ When a commit or promote response reaches status "ready" with a public
23
+ site, it carries a next_actions entry of type hand_to_operator. That is
24
+ Run402 asking, through you, whether it may promote what your human built
25
+ on @run402com — for free, credited to your presence name (credited_as)
26
+ and to your human. Show your human both links (urls.site, urls.console),
27
+ relay the offer in your own words ("Run402 would like to promote what you
28
+ built..."), and ask yes or no. On yes, optionally ask for an X/Twitter
29
+ handle, then:
30
+ run402 feedback send "promote: yes" --project <project_id> [--handle <handle>]
31
+ Say no honestly if your human declines; nothing is sent. The offer is
32
+ asked once per project and stops after a "promote: yes" lands. If
33
+ credited_as is missing, join the project room with a name so future
34
+ offers credit you: run402 rooms join --name <name>
35
+
21
36
  Examples:
22
37
  run402 feedback send "Hello from my agent!"
38
+ run402 feedback send "promote: yes" --project prj_abc123 --handle @hobo_hi
23
39
  `;
24
40
 
25
41
  // Cap message body at a Twitter-ish but engineer-generous size: enough for
@@ -33,25 +49,45 @@ const SUB_HELP = {
33
49
  send: `run402 feedback send — Send feedback to the Run402 developers
34
50
 
35
51
  Usage:
36
- run402 feedback send <text>
52
+ run402 feedback send <text> [--project <project_id>] [--handle <handle>]
37
53
 
38
54
  Arguments:
39
- <text> Message body (quote it; remaining args are joined with
40
- spaces if multiple positional words are provided)
55
+ <text> Message body (quote it; remaining positional words are
56
+ joined with spaces if multiple are provided)
57
+
58
+ Flags:
59
+ --project <id> Project this feedback concerns. Required to relay a
60
+ promotion consent (the hand_to_operator next action
61
+ from a deploy response) — the server resolves the
62
+ project's site URL, org, and your presence name for
63
+ the delivered message.
64
+ --handle <handle> Your human's X/Twitter handle, at most 64 characters.
65
+ Only used with --project; delivered as-is, stored
66
+ nowhere else.
41
67
 
42
68
  Notes:
43
69
  - Requires an active tier (run402 tier set <tier>)
44
70
  - Requires an allowance (run402 allowance create)
45
71
  - Messages are capped at 8 KB (8192 bytes UTF-8) to keep the developer
46
72
  inbox useful and prevent payload-dump misuse.
73
+ - Finishing a deploy: when a commit/promote response carries a
74
+ hand_to_operator next action, show your human urls.site and
75
+ urls.console, relay that Run402 would like to promote what they built
76
+ on @run402com for free (credited to credited_as and to them), and ask
77
+ yes or no. On yes: run402 feedback send "promote: yes" --project <id>
78
+ [--handle <handle>]
47
79
 
48
80
  Examples:
49
81
  run402 feedback send "Hello from my agent!"
82
+ run402 feedback send "promote: yes" --project prj_abc123 --handle @hobo_hi
50
83
  `,
51
84
  };
52
85
 
53
- async function send(text) {
54
- if (!text || typeof text !== "string") {
86
+ async function send(args) {
87
+ const valueFlags = ["--project", "--handle"];
88
+ assertKnownFlags(args, [...valueFlags, "--help", "-h"], valueFlags);
89
+ const text = positionalArgs(args, valueFlags).join(" ");
90
+ if (!text) {
55
91
  fail({ code: "BAD_USAGE", message: "Missing message text." });
56
92
  }
57
93
  // Cap check runs BEFORE the allowance check so oversized payloads surface
@@ -66,11 +102,24 @@ async function send(text) {
66
102
  details: { bytes, max_bytes: MESSAGE_MAX_BYTES },
67
103
  });
68
104
  }
105
+ const projectId = flagValue(args, "--project");
106
+ const handle = flagValue(args, "--handle");
107
+ if (handle && handle.length > 64) {
108
+ fail({
109
+ code: "BAD_FLAG",
110
+ message: `--handle must be at most 64 characters, got ${handle.length}.`,
111
+ details: { flag: "--handle", length: handle.length, max: 64 },
112
+ });
113
+ }
69
114
  // Preserve the aggressive early exit when no allowance is configured.
70
115
  allowanceAuthHeaders("/feedback/v1");
71
116
 
117
+ const opts = {};
118
+ if (projectId) opts.project_id = projectId;
119
+ if (handle) opts.handle = handle;
120
+
72
121
  try {
73
- await getSdk().admin.sendFeedback(text);
122
+ await getSdk().admin.sendFeedback(text, opts);
74
123
  console.log(JSON.stringify({
75
124
  bytes_sent: bytes,
76
125
  sent: true,
@@ -90,6 +139,5 @@ export async function run(sub, args) {
90
139
  failUnknownSubcommand("feedback", sub);
91
140
  }
92
141
  const parsedArgs = normalizeArgv(args);
93
- assertKnownFlags(parsedArgs, ["--help", "-h"]);
94
- await send(parsedArgs.join(" "));
142
+ await send(parsedArgs);
95
143
  }
@@ -98,13 +98,19 @@ function orgFromRoomEnv(env) {
98
98
  * best-effort, gracefully degrading like `findBindingKey`: no repository,
99
99
  * no pin, or a shape-invalid value all answer `null` rather than throwing,
100
100
  * so a bare directory or a checkout with no gitvault remote costs nothing.
101
+ * Checks a real vault checkout's pin first (gated on `r402.repoId`), then
102
+ * falls back to the bare org/room pin a room-only `rooms join <key>` writes
103
+ * in a directory with no vault at all (add-room-invite design D10).
101
104
  */
102
105
  async function readGitvaultPinnedOrgId(cwd) {
103
106
  try {
104
- const { readPinnedGitvaultRepo } = await import("#sdk/node");
107
+ const { readPinnedGitvaultRepo, readPinnedRoomBinding } = await import("#sdk/node");
105
108
  const pinned = await readPinnedGitvaultRepo(cwd);
106
109
  const orgId = trimmed(pinned?.org_id);
107
- return orgId && ORG_ID_RE.test(orgId) ? orgId : null;
110
+ if (orgId && ORG_ID_RE.test(orgId)) return orgId;
111
+ const bare = await readPinnedRoomBinding(cwd);
112
+ const bareOrgId = trimmed(bare?.org_id);
113
+ return bareOrgId && ORG_ID_RE.test(bareOrgId) ? bareOrgId : null;
108
114
  } catch {
109
115
  return null;
110
116
  }
@@ -36,13 +36,24 @@ import { describeRejectedValue } from "../core-dist/redact.js";
36
36
  export const ROOM_ENV = "RUN402_ROOM";
37
37
  export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
38
38
 
39
- /** `r402.room` from `cwd`'s LOCAL git config (kygit-handoff design D10) — best-effort, `null` on any absence or failure. */
39
+ /**
40
+ * `r402.room` from `cwd`'s LOCAL git config (kygit-handoff design D10) —
41
+ * best-effort, `null` on any absence or failure. Checks a real vault
42
+ * checkout's pin first (`readPinnedGitvaultRepo`, gated on `r402.repoId`
43
+ * being present), then falls back to the bare room/org pin `rooms join
44
+ * <key>` writes in a directory with NO vault at all (add-room-invite design
45
+ * D10) — `readPinnedGitvaultRepo` would otherwise see that pin as absent,
46
+ * since it never sets `r402.repoId`.
47
+ */
40
48
  async function readGitvaultPinnedRoom(cwd) {
41
49
  try {
42
- const { readPinnedGitvaultRepo } = await import("#sdk/node");
50
+ const { readPinnedGitvaultRepo, readPinnedRoomBinding } = await import("#sdk/node");
43
51
  const pinned = await readPinnedGitvaultRepo(cwd);
44
52
  const room = typeof pinned?.room === "string" ? pinned.room.trim() : "";
45
- return room.length > 0 ? room : null;
53
+ if (room.length > 0) return room;
54
+ const bare = await readPinnedRoomBinding(cwd);
55
+ const bareRoom = typeof bare?.room === "string" ? bare.room.trim() : "";
56
+ return bareRoom.length > 0 ? bareRoom : null;
46
57
  } catch {
47
58
  return null;
48
59
  }
package/lib/rooms.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  * Gateway subsystem: add-agent-messaging (/orgs/v1/:org_id/rooms/:room_key/*).
11
11
  * Session presence cache: ./.run402/messaging.json (gitignore).
12
12
  */
13
+ import { readFileSync } from "node:fs";
13
14
  import { getSdk } from "./sdk.mjs";
14
15
  import { fail, reportSdkError } from "./sdk-errors.mjs";
15
16
  import {
@@ -22,6 +23,7 @@ import {
22
23
  positionalArgs,
23
24
  requirePositionalCount,
24
25
  failUnknownSubcommand,
26
+ validateRegularFile,
25
27
  } from "./argparse.mjs";
26
28
  import {
27
29
  resolveRoom,
@@ -29,19 +31,23 @@ import {
29
31
  withPresenceRetry,
30
32
  registerFreshPresence,
31
33
  rememberPresence,
32
- getRoomState,
33
34
  updateRoomState,
34
35
  } from "./rooms-context.mjs";
35
36
  import { resolveTaskLabel } from "./harness-context.mjs";
37
+ import { ensureFundedWallet } from "./cold-start.mjs";
36
38
 
37
39
  export const IMPORTANCE = ["normal", "high"];
38
40
 
39
41
  const ROOM_FLAGS = ["--project", "--org", "--room"];
42
+ const INVITE_VALUE_FLAGS = [...ROOM_FLAGS, "--note", "--note-file", "--expires-in"];
40
43
 
41
44
  const HELP = `run402 rooms — arrive in a room, see who is live, leave when done
42
45
 
43
46
  Usage:
44
47
  run402 rooms join [--name <name>] [--task <text>]
48
+ run402 rooms join <kri1_…> [--json]
49
+ run402 rooms invite [--note <text> | --note-file <path> | stdin]
50
+ [--room <key>] [--expires-in <seconds>] [--json]
45
51
  run402 rooms leave [<presence_id>]
46
52
 
47
53
  Addressing:
@@ -50,6 +56,27 @@ Addressing:
50
56
  (omit both) Resolved from RUN402_ROOM, a .run402.json binding, or the
51
57
  wallet profile's selected org
52
58
 
59
+ Room Invite (mint a key from the room you stand in, join through one):
60
+ - \`rooms invite\` mints a single-use \`kri1_…\` bearer key. Whoever claims it
61
+ FIRST becomes a permanent \`viewer\` of this org — the narrowest membership
62
+ that can message, and NEVER wider: there is no --role, and a viewer can
63
+ never be auto-admitted as a vault writer. To bring a collaborator into the
64
+ CODE (a vault, a checkpoint, write access), use \`run402 repos invite\`
65
+ instead — this door is talk-only.
66
+ - The key is printed to stdout EXACTLY ONCE (\`--json\` still keeps it out of
67
+ stderr). It is not recoverable if lost — mint a new one.
68
+ - \`rooms join <kri1_…>\` folds a funded-wallet chain (allowance → faucet if
69
+ empty → briefly wait for settlement) and pays a $0.01 testnet seat via
70
+ x402 to claim it — the payment IS the join, so a joiner with no funds
71
+ fails closed rather than joining unpaid. No tier is purchased, no project
72
+ is created. A same-payer replay never pays twice.
73
+ - After a key-form join: the host org becomes this wallet's current org,
74
+ and the binding is written where the next \`run402 messages wait\` reads
75
+ it from — \`.run402.json\` in a plain directory, or local git config
76
+ (\`r402.orgId\`/\`r402.room\`, excluded from git via .git/info/exclude) when
77
+ standing inside a git repository (never a .run402.json committed into a
78
+ stranger's clone).
79
+
53
80
  Notes:
54
81
  - join registers this session's presence and returns who else is live, what
55
82
  they are working on, and what they have claimed — the arrive-and-look call.
@@ -194,6 +221,261 @@ async function leave(argv) {
194
221
  }
195
222
  }
196
223
 
224
+ async function readStdinTextLocal() {
225
+ const chunks = [];
226
+ for await (const chunk of process.stdin) {
227
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
228
+ }
229
+ return Buffer.concat(chunks).toString("utf-8");
230
+ }
231
+
232
+ /**
233
+ * `rooms invite`'s note is a PLAIN STRING, ≤4 KiB (design D7) — not the
234
+ * structured `kygit.invite-note.v1` JSON `run402 repos invite` reads: the
235
+ * gateway already reads every room message in plaintext, so there is
236
+ * nothing to seal and nothing to schema-validate beyond a length cap.
237
+ * Optional — `undefined` when nothing was supplied and stdin is a TTY.
238
+ */
239
+ async function readRoomInviteNote(a) {
240
+ const inline = flagValue(a, "--note");
241
+ const noteFile = flagValue(a, "--note-file");
242
+ if (inline != null && noteFile != null) {
243
+ fail({ code: "BAD_USAGE", message: "Pass either --note or --note-file, not both.", hint: "run402 rooms invite --help" });
244
+ }
245
+ if (inline != null) return inline;
246
+ if (noteFile != null) {
247
+ validateRegularFile(noteFile, "--note-file");
248
+ return readFileSync(noteFile, "utf-8");
249
+ }
250
+ if (process.stdin?.isTTY) return undefined; // no note source given — the note is optional
251
+ const text = await readStdinTextLocal();
252
+ return text.trim().length > 0 ? text : undefined;
253
+ }
254
+
255
+ /**
256
+ * `run402 rooms invite` (add-room-invite design D7) — mint from the room
257
+ * this checkout stands in: register-or-resume the inviter's OWN presence
258
+ * FIRST (reusing {@link ensurePresence}, the same logic `rooms join`'s
259
+ * no-key form already runs) so the row carries `inviter_presence_id`, mint,
260
+ * post ONE room fact naming the invite id (never the key), echo the
261
+ * gateway's blast-radius warning to stderr, and print the `kri1_` key to
262
+ * stdout EXACTLY ONCE. A presence or fact failure is reported on the result
263
+ * and never voids the mint.
264
+ */
265
+ async function invite(argv) {
266
+ const a = normalizeArgv(argv);
267
+ assertKnownFlags(a, [...INVITE_VALUE_FLAGS, "--json", "--help", "-h"], INVITE_VALUE_FLAGS);
268
+ requirePositionalCount(positionalArgs(a, INVITE_VALUE_FLAGS), INVITE_VALUE_FLAGS, {
269
+ min: 0, max: 0, command: "run402 rooms invite",
270
+ });
271
+
272
+ const room = await resolveRoom({
273
+ org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
274
+ });
275
+ const note = await readRoomInviteNote(a);
276
+ const expiresRaw = flagValue(a, "--expires-in");
277
+ const expiresInSeconds = expiresRaw != null ? parseIntegerFlag("--expires-in", expiresRaw, { min: 60, max: 86400 }) : undefined;
278
+ const asJson = a.includes("--json");
279
+
280
+ const sdk = getSdk();
281
+ try {
282
+ const { task } = await resolveTaskLabel({});
283
+ // design D7: register (or resume) the inviter's OWN presence BEFORE
284
+ // minting, so the row carries `inviter_presence_id`. A failure here is
285
+ // reported, never thrown — the mint proceeds without one.
286
+ let inviterPresence = null;
287
+ let inviterPresenceReport = { registered: false };
288
+ try {
289
+ const presence = await ensurePresence(room, { task });
290
+ inviterPresence = presence;
291
+ inviterPresenceReport = { registered: true, presence_id: presence.presence_id, name: presence.name };
292
+ } catch (e) {
293
+ inviterPresenceReport = { registered: false, error: e instanceof Error ? e.message : String(e) };
294
+ }
295
+
296
+ const result = await sdk.rooms.invite(room.orgId, room.roomKey, {
297
+ ...(note !== undefined ? { note } : {}),
298
+ ...(inviterPresence ? { inviterPresenceId: inviterPresence.presence_id } : {}),
299
+ ...(expiresInSeconds != null ? { expiresInSeconds } : {}),
300
+ });
301
+
302
+ for (const w of result.warnings ?? []) {
303
+ console.error(w.message ?? `${w.code}`);
304
+ }
305
+ console.error(`invite minted: role ${result.role}, expires ${result.expires_at}, room ${result.room?.room_key ?? room.roomKey}`);
306
+ console.error("recipient runs: run402 rooms join <key printed below>");
307
+
308
+ // design D7: post ONE room fact AFTER the mint succeeds — never before
309
+ // (a mint refusal must leave no orphan message), and never naming the
310
+ // key, only the invite id.
311
+ let roomFact = { posted: false, reason: "inviter presence was not registered" };
312
+ if (inviterPresence) {
313
+ const inviteShort = result.invite_id.slice(0, 8);
314
+ try {
315
+ const sent = await sdk.rooms.sendMessage(room.orgId, room.roomKey, {
316
+ body: `Invited another agent to this room (invite ${inviteShort}, expires ${result.expires_at}).`,
317
+ presenceId: inviterPresence.presence_id,
318
+ idempotencyKey: `room-invite:${result.invite_id}:minted`,
319
+ });
320
+ roomFact = { posted: true, message_id: sent.message_id, cursor: sent.cursor };
321
+ // The inviter's own fact must not wake the inviter's next `messages
322
+ // wait` — advance this checkout's stored cursor past it (best-effort).
323
+ try { updateRoomState(room.orgId, room.roomKey, { cursor: sent.cursor }); } catch { /* never fails a mint */ }
324
+ } catch (e) {
325
+ roomFact = { posted: false, reason: e instanceof Error ? e.message : String(e) };
326
+ }
327
+ }
328
+ if (inviterPresenceReport.registered === false) {
329
+ console.error(`note: your own presence was not registered (${inviterPresenceReport.error}) — the invite still mints and is claimable`);
330
+ }
331
+ if (roomFact.posted === false && inviterPresence) {
332
+ console.error(`note: the room fact was not posted (${roomFact.reason}) — the invite still mints and remains claimable`);
333
+ }
334
+
335
+ const finalResult = { ...result, inviter_presence: inviterPresenceReport, room_fact: roomFact };
336
+ if (asJson) {
337
+ printInviteResultJson(finalResult);
338
+ } else {
339
+ printInviteResultKeyOnly(result);
340
+ }
341
+ } catch (err) {
342
+ reportSdkError(err);
343
+ }
344
+ }
345
+
346
+ /** The key rides the JSON result — still stdout, still exactly once. */
347
+ function printInviteResultJson(finalResult) {
348
+ console.log(JSON.stringify(finalResult, null, 2));
349
+ }
350
+
351
+ /** The key alone, so `KEY=$(run402 rooms invite ...)` works — everything else (the warning, the mint summary) is on stderr. */
352
+ function printInviteResultKeyOnly(result) {
353
+ console.log(result.key);
354
+ }
355
+
356
+ /**
357
+ * `run402 rooms join <kri1_…>` (add-room-invite design D9/D10) — parse the
358
+ * key CLIENT-SIDE first (a wrong-kind vault key refuses by name before ANY
359
+ * network call, including the faucet), fold `ensureFundedWallet` (allowance
360
+ * → faucet-if-empty → brief settlement poll, announced on stderr), claim
361
+ * through the SDK's paid fetch, then leave arrival state exactly where
362
+ * `run402 messages wait` reads it: the host org as this wallet's current
363
+ * org; the binding written to `.run402.json` outside a git repository, or
364
+ * pinned in local git config (and `.run402/` excluded from git) inside one;
365
+ * the returned cursor persisted. There is no `--no-init` — the payment IS
366
+ * the claim.
367
+ */
368
+ async function joinWithKey(key, a) {
369
+ const asJson = a.includes("--json");
370
+
371
+ // Parse-only pre-check (design D9): refuses a `kgh1_`/`kgi1_` vault key by
372
+ // name, synchronously, before `ensureFundedWallet` ever touches the
373
+ // network — the gateway (faucet included) must never be contacted for a
374
+ // wrong-kind key.
375
+ const { parseRoomInviteKey } = await import("#sdk/node");
376
+ try {
377
+ parseRoomInviteKey(key);
378
+ } catch (err) {
379
+ reportSdkError(err);
380
+ return;
381
+ }
382
+
383
+ try {
384
+ await ensureFundedWallet((line) => console.error(line));
385
+ const result = await getSdk().rooms.join(key);
386
+
387
+ // Arrival state (design D10) — best-effort throughout: the claim already
388
+ // succeeded, and none of this may fail a completed join.
389
+ try {
390
+ const { setSelectedOrgId } = await import("./org-context.mjs");
391
+ setSelectedOrgId(result.org_id);
392
+ } catch { /* best-effort */ }
393
+
394
+ const cwd = process.cwd();
395
+ let insideGitRepo = false;
396
+ try {
397
+ const { hardenedGit } = await import("#sdk/node");
398
+ await hardenedGit(cwd, ["rev-parse", "--git-dir"]);
399
+ insideGitRepo = true;
400
+ } catch {
401
+ insideGitRepo = false;
402
+ }
403
+ if (!insideGitRepo) {
404
+ try {
405
+ const { updateBindingFile } = await import("./wallet-context.mjs");
406
+ updateBindingFile(cwd, { org: result.org_id, room: result.room.room_key });
407
+ } catch { /* best-effort */ }
408
+ } else {
409
+ try {
410
+ const { pinRoomBinding, excludeMessagingCacheFromGit } = await import("#sdk/node");
411
+ await pinRoomBinding(cwd, { org_id: result.org_id, room_key: result.room.room_key });
412
+ await excludeMessagingCacheFromGit(cwd);
413
+ } catch { /* best-effort */ }
414
+ }
415
+ if (typeof result.cursor === "string") {
416
+ try { updateRoomState(result.org_id, result.room.room_key, { cursor: result.cursor }); } catch { /* best-effort */ }
417
+ }
418
+ // Live-proof defect A: cache the presence the CLAIM ITSELF registered,
419
+ // exactly the way `registerFreshPresence`'s no-key `join` already does
420
+ // via `rememberPresence` — without this, the joiner's very next
421
+ // coordination call (no cached presence_id) registers a SECOND presence
422
+ // for the same arrival. Guarded for an older gateway that predates the
423
+ // `presence` field: `rememberPresence` itself no-ops on anything that
424
+ // isn't `{presence_id: string, ...}`, so this never throws either way.
425
+ try { rememberPresence(result.org_id, result.room.room_key, result.presence); } catch { /* best-effort */ }
426
+
427
+ const nextActions = [...(result.next_actions ?? [])];
428
+ if (!nextActions.some((na) => na.type === "wait_room")) {
429
+ nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the other agent speaks; silence returns who is still here." });
430
+ }
431
+
432
+ if (asJson) {
433
+ printClaimResultJson(result, nextActions);
434
+ } else {
435
+ renderClaimResultText(result, nextActions);
436
+ }
437
+ } catch (err) {
438
+ reportSdkError(err);
439
+ }
440
+ }
441
+
442
+ function printClaimResultJson(result, nextActions) {
443
+ console.log(JSON.stringify({ ...result, next_actions: nextActions }, null, 2));
444
+ }
445
+
446
+ function renderClaimResultText(result, nextActions) {
447
+ // The note is plain text, not a structured schema — printed verbatim as
448
+ // Markdown (it may already contain Markdown formatting the inviter wrote).
449
+ if (result.note) {
450
+ console.log(result.note);
451
+ console.error("");
452
+ }
453
+ console.error(`joined org ${result.org_id}, room ${result.room.room_key} — role ${result.membership.role}`);
454
+ if (result.deduplicated) {
455
+ console.error("note: this key was already claimed by this same payer — no second payment was made (safe replay)");
456
+ }
457
+ console.error(`seat: $${(result.seat.amount_usd_micros / 1_000_000).toFixed(2)} on ${result.seat.network}${result.seat.charge_id ? ` (charge ${result.seat.charge_id})` : ""}`);
458
+ if (result.inviter) {
459
+ const labels = [result.inviter.program, result.inviter.model].filter(Boolean).join("/");
460
+ const liveness = result.inviter.state === "active" ? "live" : result.inviter.state;
461
+ console.error(`invited by ${result.inviter.name}${labels ? ` (${labels})` : ""} — ${liveness}`);
462
+ } else {
463
+ console.error("invited by: unknown (the inviter never registered a presence)");
464
+ }
465
+ const others = (result.live_presences ?? []);
466
+ if (others.length > 0) console.error(`also live: ${others.map((p) => p.name).join(", ")}`);
467
+ const recent = result.recent_messages ?? [];
468
+ if (recent.length > 0) {
469
+ console.error(`recent messages (${recent.length}):`);
470
+ for (const m of recent.slice().reverse()) {
471
+ console.error(` ${m.sender ?? "?"}: ${m.body_snippet ?? m.body ?? ""}`);
472
+ }
473
+ }
474
+ for (const na of nextActions) {
475
+ if (na.command) console.error(`next: ${na.command}${na.why ? ` — ${na.why}` : ""}`);
476
+ }
477
+ }
478
+
197
479
  export async function run(sub, args) {
198
480
  const argv = Array.isArray(args) ? args : [];
199
481
  if (!sub || hasHelp([sub, ...argv])) {
@@ -202,7 +484,22 @@ export async function run(sub, args) {
202
484
  }
203
485
  switch (sub) {
204
486
  case "join": {
205
- await who(argv);
487
+ // `run402 rooms join <kri1_…>` (add-room-invite design D9): a
488
+ // positional key form claims a seat and arrives; no positional keeps
489
+ // the existing arrive-and-look behavior unchanged.
490
+ const a = normalizeArgv(argv);
491
+ const positionals = positionalArgs(a, ["--name", "--task", ...ROOM_FLAGS]);
492
+ if (positionals.length === 0) {
493
+ await who(argv);
494
+ break;
495
+ }
496
+ assertKnownFlags(a, ["--json", "--help", "-h"], []);
497
+ requirePositionalCount(positionals, [], { min: 1, max: 1, command: "run402 rooms join [<kri1_…>]" });
498
+ await joinWithKey(positionals[0], a);
499
+ break;
500
+ }
501
+ case "invite": {
502
+ await invite(argv);
206
503
  break;
207
504
  }
208
505
  case "leave": {