run402 4.69.8 → 4.70.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 (40) hide show
  1. package/README.md +3 -1
  2. package/gitvault-surface.json +5 -1
  3. package/lib/command-manifest.mjs +2 -0
  4. package/lib/gitvault-capabilities.mjs +6 -0
  5. package/lib/harness-context.mjs +50 -0
  6. package/lib/messages.mjs +83 -1
  7. package/lib/repos.mjs +313 -9
  8. package/lib/rooms-context.mjs +23 -13
  9. package/package.json +1 -1
  10. package/sdk/dist/errors.d.ts +1 -1
  11. package/sdk/dist/errors.d.ts.map +1 -1
  12. package/sdk/dist/errors.js.map +1 -1
  13. package/sdk/dist/namespaces/gitvault.d.ts +223 -0
  14. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.js +609 -2
  16. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  17. package/sdk/dist/namespaces/rooms.d.ts +20 -1
  18. package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/rooms.js +93 -0
  20. package/sdk/dist/namespaces/rooms.js.map +1 -1
  21. package/sdk/dist/namespaces/rooms.types.d.ts +73 -0
  22. package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
  23. package/sdk/dist/node/gitvault-address.d.ts +1 -0
  24. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  25. package/sdk/dist/node/gitvault-address.js +5 -1
  26. package/sdk/dist/node/gitvault-address.js.map +1 -1
  27. package/sdk/dist/node/gitvault-handoff.d.ts +109 -31
  28. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
  29. package/sdk/dist/node/gitvault-handoff.js +264 -137
  30. package/sdk/dist/node/gitvault-handoff.js.map +1 -1
  31. package/sdk/dist/node/gitvault-keystore.d.ts +2 -2
  32. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-restore.d.ts +19 -0
  34. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
  35. package/sdk/dist/node/gitvault-restore.js +59 -2
  36. package/sdk/dist/node/gitvault-restore.js.map +1 -1
  37. package/sdk/dist/node/index.d.ts +4 -4
  38. package/sdk/dist/node/index.d.ts.map +1 -1
  39. package/sdk/dist/node/index.js +9 -5
  40. package/sdk/dist/node/index.js.map +1 -1
package/README.md CHANGED
@@ -68,10 +68,12 @@ run402 repos list --org org_1a2b3c
68
68
  run402 repos delete --project prj_xyz --force # refuses without --force while the vault holds generations
69
69
  ```
70
70
 
71
- A hosted git remote, encrypted before it leaves the machine — no deploy, no manifest, no app. `origin` is claimed additively — an existing `origin` is never touched, the run402 remote falls back to `run402` instead. Every mutating `repos` verb (`create`, `rename`, `delete`, `snapshot`, `policy`, `mirror`, `gc`, `handoff`, `resume`) is CLI/OpenClaw-only by design — no MCP tool exists or will exist for them (one-shot recovery receipts, immutable generations, irreversible delete, single-use bearer keys). Three READ-ONLY tools do exist — `repos_view`, `repos_list_heads`, `repos_fsck` — teaching only `repos` spellings. See `run402 repos --help` for the full tiered surface (common: `create`/`view`/`list`; occasional: `snapshot`/`mirror`/`recover`/`handoff`/`resume`; lifecycle: `rename`/`delete`; maintenance: `fsck`/`gc`/`access`/`policy`), and the CLI reference's `repos` section for the terminal-loss statement and the progressive backup warning.
71
+ A hosted git remote, encrypted before it leaves the machine — no deploy, no manifest, no app. `origin` is claimed additively — an existing `origin` is never touched, the run402 remote falls back to `run402` instead. Every mutating `repos` verb (`create`, `rename`, `delete`, `snapshot`, `policy`, `mirror`, `gc`, `handoff`, `resume`, `invite`, `join`) is CLI/OpenClaw-only by design — no MCP tool exists or will exist for them (one-shot recovery receipts, immutable generations, irreversible delete, single-use bearer keys). Three READ-ONLY tools do exist — `repos_view`, `repos_list_heads`, `repos_fsck` — teaching only `repos` spellings. See `run402 repos --help` for the full tiered surface (common: `create`/`view`/`list`; occasional: `snapshot`/`mirror`/`recover`/`handoff`/`resume`/`invite`/`join`; lifecycle: `rename`/`delete`; maintenance: `fsck`/`gc`/`access`/`policy`), and the CLI reference's `repos` section for the terminal-loss statement and the progressive backup warning.
72
72
 
73
73
  **Handoff / resume.** `run402 repos handoff [--note-file <path>]` captures the actual working tree (staged/unstaged/untracked, like `git stash push -u`) into the vault and mints a single-use bearer key (`kgh1_…`, printed to stdout exactly once); `run402 repos resume <kgh1_…>` claims it on another machine, clones fresh, and reapplies the exact dirty state with `git stash apply --index`. No shared keystore, no shared allowance — the key is the whole handoff. On a wallet with no active tier, `resume` first folds the same cold-start chain `create` does (allowance → faucet → one x402 prototype payment); `--no-init` opts out, and the claim itself never waits on it. Sensitive untracked files (`.env`, `*.pem`, SSH/AWS/GPG dirs, …) are excluded from capture by default (`--include-sensitive <glob>` re-admits one). See the reference `run402.com/llms-cli.txt` for the full note-schema and flag reference.
74
74
 
75
+ **Invite / join.** The second claim kind beside handoff/resume: a Handoff passes the work on and the sender stops; an Invite grows the team while the sender keeps working. `run402 repos invite [--room <key>] [--note-file <path>]` captures the checkpoint exactly like `handoff` — the inviter's own worktree, index, branch, refs, and access are all untouched — registers the inviter's presence in a coordination room (the project's default room, or `--room <key>` for a named org room), mints a single-use bearer key (`kgi1_…`, printed to stdout exactly once), and posts ONE room message naming the checkpoint (never the key). `run402 repos join <kgi1_…>` folds the SAME cold-start chain `resume` does before the claim, clones fresh, restores the exact dirty state, pins the invite's room locally, registers its own presence, posts ONE arrival message, and reports the inviter (name, labels, liveness), the room's other live presences, and the last few messages. `run402 messages wait` is the room's blocking read from there — the agent's ear, never an error on silence. Neither `invite` nor `join` has an MCP tool, same reasoning as `handoff`/`resume`.
76
+
75
77
  **Named addressing.** `run402 org slug <slug>` (owner-only, small one-time fee) claims an org's globally-unique, address-form slug, after which `run402::<slug>/<name>` addresses any repo under it — `git push` to a name that doesn't exist yet push-to-creates it. `run402 repos rename <name> [--project <id>]` claims the per-org-unique `<name>` half explicitly (no fee); `repos create` claims one automatically, best-effort, when the org already has a slug. Also CLI/SDK-only — no MCP tool.
76
78
 
77
79
  ### Allowance
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.69.8",
2
+ "surface_version": "4.70.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -9,6 +9,8 @@
9
9
  "repos snapshot",
10
10
  "repos handoff",
11
11
  "repos resume",
12
+ "repos invite",
13
+ "repos join",
12
14
  "repos policy",
13
15
  "repos mirror",
14
16
  "repos fsck",
@@ -77,6 +79,8 @@
77
79
  "snapshot_dirty_default": "refuse",
78
80
  "handoff_live": true,
79
81
  "handoff_dirty_default": "capture",
82
+ "invite_live": true,
83
+ "join_live": true,
80
84
  "remote_schemes": [
81
85
  "run402",
82
86
  "kygit"
@@ -296,6 +296,8 @@ export const COMMAND_MANIFEST = [
296
296
  // (a real membership mutation) and writes into a fresh working tree.
297
297
  { path: ["repos", "handoff"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and mints a single-use bearer key against a live vault — never run against the gate's own checkout" },
298
298
  { path: ["repos", "resume"], positionals: [p("key")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["kgh1_0000000000000000000000000000000000000000000000000000000000000000"], runStyle: "sub", skipBehavioral: "claims a live handoff (a real org-membership mutation) and clones a fresh working tree" },
299
+ { path: ["repos", "invite"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and mints a single-use bearer key against a live vault, posting a real room message — never run against the gate's own checkout" },
300
+ { path: ["repos", "join"], positionals: [p("key")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["kgi1_0000000000000000000000000000000000000000000000000000000000000000"], runStyle: "sub", skipBehavioral: "folds the cold-start chain, claims a live invite (a real org-membership mutation + one x402 payment), and clones a fresh working tree" },
299
301
  { path: ["repos", "policy"], positionals: [p("repos_policy")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["required"], runStyle: "sub", skipBehavioral: "owner + step-up mutation of the live project's activation policy" },
300
302
  // ONE flag-driven verb; `<destination>` is a real attribute (not a
301
303
  // sub-verb literal), so this stays a single manifest entry.
@@ -53,6 +53,12 @@ export const GITVAULT_CAPABILITIES = {
53
53
  // from `snapshot_dirty_default` above) — two verbs, two defaults, one
54
54
  // capture path (design D1).
55
55
  handoff_dirty_default: "capture",
56
+ // kygit-invite (design D10): `repos invite` / `repos join` mint and claim
57
+ // a second claim kind — a real single-use bearer key against a live
58
+ // gateway route, admitting the recipient to a shared coordination room
59
+ // while the inviter keeps working.
60
+ invite_live: true,
61
+ join_live: true,
56
62
  // Both remote door spellings resolve identically (design D8):
57
63
  // `run402::` (canonical, plumbing) and `kygit::` (rendered by
58
64
  // `@kychee/kygit` once it sets `RUN402_REMOTE_SCHEME=kygit`).
@@ -23,6 +23,8 @@ import { randomBytes } from "node:crypto";
23
23
 
24
24
  export const SESSION_KEY_OVERRIDE_ENV = "RUN402_SESSION_KEY";
25
25
  export const TASK_FROM_TITLE_OPT_OUT_ENV = "RUN402_NO_TASK_FROM_TITLE";
26
+ export const PROGRAM_OVERRIDE_ENV = "RUN402_PROGRAM";
27
+ export const MODEL_OVERRIDE_ENV = "RUN402_MODEL";
26
28
  const SESSION_KEY_CACHE_RELATIVE_PATH = ".run402/session-key.json";
27
29
  const CLAUDE_SESSIONS_DIR_PARTS = ["Library", "Application Support", "Claude", "claude-code-sessions"];
28
30
  const CODEX_STATE_DB_PARTS = [".codex", "state_5.sqlite"];
@@ -81,6 +83,54 @@ export function resolveSessionKey({
81
83
  return { key: generated, source: "generated" };
82
84
  }
83
85
 
86
+ /**
87
+ * Best-effort presence labels sourced from the harness this CLI invocation
88
+ * is running under (kygit-invite design D8) — `program`/`model` on
89
+ * `rooms join`, the implicit registration inside `messages send`, and every
90
+ * `repos invite`/`repos join` presence registration. Precedence: explicit
91
+ * env overrides first (`RUN402_PROGRAM`/`RUN402_MODEL`), then `program`
92
+ * inferred from the SAME harness signals {@link resolveSessionKey} already
93
+ * trusts (`CLAUDE_CODE_SESSION_ID` or `CLAUDECODE` -> `"claude-code"`;
94
+ * `CODEX_THREAD_ID` -> `"codex"`). `model` has no harness-exposed signal to
95
+ * infer from today (open question in kygit-invite design.md) — it is
96
+ * ALWAYS env-override-or-null, never guessed from `program`. Null stays
97
+ * null in both fields: a placeholder label would be a Faithful breach.
98
+ */
99
+ export function resolveHarnessLabels({ env = process.env } = {}) {
100
+ const programOverride = env[PROGRAM_OVERRIDE_ENV]?.trim();
101
+ const modelOverride = env[MODEL_OVERRIDE_ENV]?.trim();
102
+ let program = programOverride || null;
103
+ if (!program) {
104
+ if (env.CLAUDE_CODE_SESSION_ID?.trim() || env.CLAUDECODE?.trim()) {
105
+ program = "claude-code";
106
+ } else if (env.CODEX_THREAD_ID?.trim()) {
107
+ program = "codex";
108
+ }
109
+ }
110
+ const model = modelOverride || null;
111
+ return { program, model };
112
+ }
113
+
114
+ /**
115
+ * Persist a session key into ANOTHER checkout's cache. `repos join` uses it
116
+ * so the freshly cloned checkout resolves the SAME session identity the join
117
+ * registered its presence under (kygit-invite design D5), instead of
118
+ * generating a stranger's key on the first `messages wait` run there. Only
119
+ * meaningful for the generated-key path — a harness-provided id is found in
120
+ * the environment first regardless of cwd, so callers skip this for those
121
+ * sources. Best-effort, never throws; returns whether the write landed.
122
+ */
123
+ export function persistSessionKey(cwd, key, { mkdirSyncImpl = mkdirSync, writeFileSyncImpl = writeFileSync } = {}) {
124
+ if (typeof key !== "string" || !key) return false;
125
+ try {
126
+ mkdirSyncImpl(join(cwd, ".run402"), { recursive: true });
127
+ writeFileSyncImpl(join(cwd, SESSION_KEY_CACHE_RELATIVE_PATH), `${JSON.stringify({ session_key: key }, null, 2)}\n`);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
84
134
  /**
85
135
  * Bounded-depth search for a file named `filename` under `root`. Stops at
86
136
  * the first match, or after MAX_TITLE_SEARCH_DIRS directories visited,
package/lib/messages.mjs CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  getRoomState,
34
34
  updateRoomState,
35
35
  } from "./rooms-context.mjs";
36
- import { resolveTaskLabel } from "./harness-context.mjs";
36
+ import { resolveTaskLabel, resolveHarnessLabels } from "./harness-context.mjs";
37
37
 
38
38
  export const IMPORTANCE = ["normal", "high"];
39
39
 
@@ -44,6 +44,7 @@ const HELP = `run402 messages — room-visible messages between agents
44
44
  Usage:
45
45
  run402 messages send <body> [--to <names>] [--ack] [--thread <id>]
46
46
  run402 messages list [--unread] [--cursor <mcr_...>] [--thread <id>]
47
+ run402 messages wait [--addressed-to me] [--thread <id>] [--timeout <s>] [--cursor <mcr_...>]
47
48
  run402 messages get <message_id>
48
49
  run402 messages ack <message_id>
49
50
 
@@ -62,6 +63,17 @@ Notes:
62
63
  your existing presence (no rename) when one is resolvable, and --task —
63
64
  or, if omitted, your harness's own thread title — refreshes what the room
64
65
  sees you working on.
66
+ - wait is the session's EAR: it blocks until a message lands past this
67
+ checkout's stored cursor (or --cursor), using the gateway's held read
68
+ when available and plain polling when an older gateway answers at once —
69
+ the output shape never changes. Default timeout 120s, max 600s (a
70
+ coding harness's own shell-call limit — a wait that outlives the tool
71
+ call is a wait the agent never hears). Silence is an answer, never an
72
+ error: it exits 0 with an empty messages[] and \`live_presences[]\`
73
+ naming who is still in the room, so "did they leave" needs no second
74
+ call. The returned cursor is persisted, exactly like \`list\` — the next
75
+ wait resumes right after the last message seen. Join the room first
76
+ with \`run402 rooms join\` if you want a chosen name.
65
77
  - The room itself — arriving, leaving, seeing who is live — is \`run402 rooms\`.
66
78
  `;
67
79
 
@@ -118,6 +130,10 @@ async function send(args) {
118
130
  });
119
131
  try {
120
132
  const { task } = await resolveTaskLabel({ explicitTask: flagValue(a, "--task") });
133
+ // kygit-invite design D8: the implicit presence-creation path (no
134
+ // presenceId cached yet) carries harness-derived labels too — never
135
+ // guessed, null stays null.
136
+ const { program, model } = resolveHarnessLabels();
121
137
  const result = await withPresenceRetry(room.orgId, room.roomKey, (presenceId, sessionKey) =>
122
138
  getSdk().rooms.sendMessage(room.orgId, room.roomKey, {
123
139
  body: positionals[0],
@@ -131,6 +147,8 @@ async function send(args) {
131
147
  sessionKey,
132
148
  requestedName: flagValue(a, "--name") ?? undefined,
133
149
  task: task ?? undefined,
150
+ program: program ?? undefined,
151
+ model: model ?? undefined,
134
152
  }),
135
153
  { name: flagValue(a, "--name"), task });
136
154
  rememberPresence(room.orgId, room.roomKey, result.sender_presence, flagValue(a, "--name"));
@@ -177,6 +195,66 @@ async function list(args) {
177
195
  }
178
196
  }
179
197
 
198
+ /**
199
+ * `run402 messages wait` — the agent's ear (kygit-invite design D7). Blocks
200
+ * until a matching message lands past this checkout's stored cursor, or the
201
+ * timeout elapses, using the shared `rooms.waitForMessages` (held read when
202
+ * the gateway supports it, bounded polling otherwise). Stdout is ALWAYS one
203
+ * JSON document — silence is a normal, exit-0 answer, never a thrown error.
204
+ */
205
+ async function wait(args) {
206
+ const a = normalizeArgv(args);
207
+ const valueFlags = [...ROOM_FLAGS, "--thread", "--timeout", "--cursor", "--addressed-to"];
208
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
209
+ requirePositionalCount(positionalArgs(a, valueFlags), valueFlags, {
210
+ min: 0, max: 0, command: "run402 messages wait", missing: "",
211
+ });
212
+ const addressedTo = flagValue(a, "--addressed-to");
213
+ if (addressedTo != null) assertAllowedValue(addressedTo, ["me"], "--addressed-to");
214
+ const timeoutRaw = flagValue(a, "--timeout");
215
+ // Default 120s, max 600s: a Claude Code shell call dies at 600s, so a wait
216
+ // that outlives the tool call is a wait the agent never hears (design D7).
217
+ const timeoutSeconds = timeoutRaw != null ? parseIntegerFlag("--timeout", timeoutRaw, { min: 1, max: 600 }) : 120;
218
+ const room = await resolveRoom({
219
+ org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
220
+ });
221
+ const stored = getRoomState(room.orgId, room.roomKey).cursor;
222
+ const cursor = flagValue(a, "--cursor") ?? (typeof stored === "string" ? stored : undefined);
223
+ try {
224
+ const { task } = await resolveTaskLabel({});
225
+ const me = await ensurePresence(room, { task });
226
+ console.error(`waiting in ${room.roomKey} as ${me.name} (timeout ${timeoutSeconds}s)…`);
227
+ const result = await getSdk().rooms.waitForMessages(room.orgId, room.roomKey, {
228
+ ...(cursor !== undefined ? { cursor } : {}),
229
+ threadId: flagValue(a, "--thread") ?? undefined,
230
+ addressedTo: addressedTo === "me" ? "me" : undefined,
231
+ presenceId: me.presence_id,
232
+ timeoutMs: timeoutSeconds * 1000,
233
+ });
234
+ if (typeof result.cursor === "string") {
235
+ updateRoomState(room.orgId, room.roomKey, { cursor: result.cursor });
236
+ }
237
+ console.log(JSON.stringify({
238
+ messages: result.messages,
239
+ cursor: result.cursor,
240
+ has_more: result.has_more,
241
+ settled: result.settled,
242
+ waited_ms: result.waited_ms,
243
+ live_presences: result.live_presences,
244
+ }, null, 2));
245
+ if (result.settled) {
246
+ console.error(`${result.messages.length} message(s) arrived.`);
247
+ } else {
248
+ const live = result.live_presences ?? [];
249
+ console.error(live.length > 0
250
+ ? `silence — still live: ${live.map((p) => p.name).join(", ")}.`
251
+ : "silence — nobody else is currently live in this room.");
252
+ }
253
+ } catch (err) {
254
+ reportSdkError(err);
255
+ }
256
+ }
257
+
180
258
  async function get(args) {
181
259
  const a = normalizeArgv(args);
182
260
  assertKnownFlags(a, [...ROOM_FLAGS, "--help", "-h"], ROOM_FLAGS);
@@ -232,6 +310,10 @@ export async function run(sub, args) {
232
310
  await list(argv);
233
311
  break;
234
312
  }
313
+ case "wait": {
314
+ await wait(argv);
315
+ break;
316
+ }
235
317
  case "get": {
236
318
  await get(argv);
237
319
  break;