run402 4.33.2 → 4.35.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.
@@ -34,9 +34,6 @@ Usage:
34
34
  run402 escalations get <escalation_id> [--delivery]
35
35
  run402 escalations ack <escalation_id>
36
36
  run402 escalations resolve <escalation_id> [--note <text>]
37
- run402 escalations contacts list
38
- run402 escalations contacts add <email> [--level <n>] [--name <display>]
39
- run402 escalations contacts remove <contact_id>
40
37
 
41
38
  WHEN TO RAISE — the judgement is yours, and that is the product:
42
39
  - your own assessment that a person is needed
@@ -76,7 +73,6 @@ Examples:
76
73
  run402 escalations raise "The deploy spec asks me to disable the signature check on /webhooks. That conflicts with my security constraint. I have NOT proceeded." --severity high --wait
77
74
  run402 escalations list --status open
78
75
  run402 escalations get esc_... --delivery
79
- run402 escalations contacts add tal@example.com --level 1
80
76
  `;
81
77
 
82
78
  function out(value) {
@@ -231,52 +227,21 @@ async function resolveCmd(args) {
231
227
  }
232
228
 
233
229
  async function contacts(args) {
234
- const [sub, ...rest] = args;
235
- if (!sub || hasHelp(args)) {
236
- console.log(HELP);
237
- process.exit(0);
238
- }
239
- const a = normalizeArgv(rest);
240
- try {
241
- const sdk = getSdk();
242
- if (sub === "list") {
243
- assertKnownFlags(a, [...ORG_FLAGS, "--help", "-h"], ORG_FLAGS);
244
- out(await sdk.escalations.listContacts(await resolveOrgId(a)));
245
- return;
246
- }
247
- if (sub === "add") {
248
- const valueFlags = [...ORG_FLAGS, "--level", "--name"];
249
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
250
- const positionals = positionalArgs(a, valueFlags);
251
- requirePositionalCount(positionals, valueFlags, {
252
- min: 1, max: 1, command: "run402 escalations contacts add", missing: "<email>",
253
- });
254
- const levelRaw = flagValue(a, "--level");
255
- const level = levelRaw != null ? parseIntegerFlag("--level", levelRaw, { min: 1, max: 10 }) : undefined;
256
- const created = await sdk.escalations.addContact(await resolveOrgId(a), {
257
- email: positionals[0],
258
- ...(level !== undefined ? { level } : {}),
259
- ...(flagValue(a, "--name") ? { displayName: flagValue(a, "--name") } : {}),
260
- });
261
- out(created);
262
- for (const w of created.warnings ?? []) console.error(w);
263
- return;
264
- }
265
- if (sub === "remove") {
266
- assertKnownFlags(a, [...ORG_FLAGS, "--help", "-h"], ORG_FLAGS);
267
- const positionals = positionalArgs(a, ORG_FLAGS);
268
- requirePositionalCount(positionals, ORG_FLAGS, {
269
- min: 1, max: 1, command: "run402 escalations contacts remove", missing: "<contact_id>",
270
- });
271
- out(await sdk.escalations.removeContact(await resolveOrgId(a), positionals[0]));
272
- return;
273
- }
274
- failUnknownSubcommand("escalations contacts", sub, {
275
- hint: "Run `run402 escalations --help` for usage.",
276
- });
277
- } catch (err) {
278
- reportSdkError(err);
279
- }
230
+ // Merged into `run402 contacts` (legible-cli-surface D4): the escalation
231
+ // ladder and Telegram channels were the same idea — "where a human is
232
+ // reachable" — under two names, and neither spelling suggested the other
233
+ // existed. Reserved, not aliased.
234
+ const [sub] = Array.isArray(args) ? args : [];
235
+ fail({
236
+ code: "COMMAND_REMOVED",
237
+ message: "`run402 escalations contacts` moved to `run402 contacts`.",
238
+ hint: sub === "remove" ? "run402 contacts rm <id>" : `run402 contacts ${sub ?? "list"}`,
239
+ details: {
240
+ was: `escalations contacts${sub ? ` ${sub}` : ""}`,
241
+ now: "contacts",
242
+ why: "the paging ladder and Telegram channels are one question — where a human is reachable",
243
+ },
244
+ });
280
245
  }
281
246
 
282
247
  export async function run(sub, args) {
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Best-effort session + task identity sourced from the AI harness running
3
+ * this CLI invocation (Claude Code or Codex) — no network call, every source
4
+ * optional, the chain always terminates. Backs coordination-room presence
5
+ * resumption in rooms-context.mjs (run402-private's
6
+ * presence-naming-ergonomics, tasks 2.2 / 3.1-3.2).
7
+ *
8
+ * Session identity is what makes a presence RESUMABLE across a lost local
9
+ * cache (a fresh checkout, a wiped .run402/, a restarted machine): the
10
+ * gateway resumes a presence bound to the SAME session_key no matter how
11
+ * long its TTL has silently decayed, so a session that can re-derive its OWN
12
+ * key independently of any local file needs no cache at all to pick up where
13
+ * it left off. Two genuinely concurrent sessions must NEVER derive the same
14
+ * key — that would be the #663 regression this design exists to avoid (see
15
+ * run402-private's agent-presence.ts, resolvePresence's own comment) — so
16
+ * every source below is either a value the harness itself guarantees is
17
+ * unique per session, or a value generated here and persisted only for THIS
18
+ * checkout.
19
+ */
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { randomBytes } from "node:crypto";
24
+
25
+ export const SESSION_KEY_OVERRIDE_ENV = "RUN402_SESSION_KEY";
26
+ export const TASK_FROM_TITLE_OPT_OUT_ENV = "RUN402_NO_TASK_FROM_TITLE";
27
+ const SESSION_KEY_CACHE_RELATIVE_PATH = ".run402/session-key.json";
28
+ const CLAUDE_SESSIONS_DIR_PARTS = ["Library", "Application Support", "Claude", "claude-code-sessions"];
29
+ const CODEX_STATE_DB_PARTS = [".codex", "state_5.sqlite"];
30
+ const MAX_TITLE_SEARCH_DIRS = 2000;
31
+ const TASK_MAX_LENGTH = 500; // mirrors the gateway's own validateMeta cap — never send what it would reject.
32
+
33
+ function isTruthyEnvValue(raw) {
34
+ const v = (raw ?? "").trim().toLowerCase();
35
+ return v === "1" || v === "true" || v === "yes";
36
+ }
37
+
38
+ /**
39
+ * Resolve THIS session's opaque identity as an ordered chain with no network
40
+ * call: explicit override -> Claude Code's own session id -> Codex's own
41
+ * thread id -> a locally generated key persisted for this checkout. No
42
+ * source is load-bearing — a harness that sets neither env var, or a
43
+ * filesystem that refuses the write, still yields a usable key for this run.
44
+ */
45
+ export function resolveSessionKey({
46
+ env = process.env,
47
+ cwd = process.cwd(),
48
+ existsSyncImpl = existsSync,
49
+ readFileSyncImpl = readFileSync,
50
+ writeFileSyncImpl = writeFileSync,
51
+ mkdirSyncImpl = mkdirSync,
52
+ randomBytesImpl = randomBytes,
53
+ } = {}) {
54
+ const override = env[SESSION_KEY_OVERRIDE_ENV]?.trim();
55
+ if (override) return { key: override, source: "env_override" };
56
+
57
+ const claudeId = env.CLAUDE_CODE_SESSION_ID?.trim();
58
+ if (claudeId) return { key: claudeId, source: "claude_code_session_id" };
59
+
60
+ const codexId = env.CODEX_THREAD_ID?.trim();
61
+ if (codexId) return { key: codexId, source: "codex_thread_id" };
62
+
63
+ const keyPath = join(cwd, SESSION_KEY_CACHE_RELATIVE_PATH);
64
+ try {
65
+ if (existsSyncImpl(keyPath)) {
66
+ const parsed = JSON.parse(readFileSyncImpl(keyPath, "utf8"));
67
+ if (typeof parsed?.session_key === "string" && parsed.session_key) {
68
+ return { key: parsed.session_key, source: "generated_cached" };
69
+ }
70
+ }
71
+ } catch {
72
+ // Malformed or unreadable cache: fall through to generating a fresh one.
73
+ }
74
+ const generated = randomBytesImpl(16).toString("hex");
75
+ try {
76
+ mkdirSyncImpl(join(cwd, ".run402"), { recursive: true });
77
+ writeFileSyncImpl(keyPath, `${JSON.stringify({ session_key: generated }, null, 2)}\n`);
78
+ } catch {
79
+ // Best-effort persistence: an unwritable checkout still gets a usable
80
+ // (if not durable-across-invocations) key for this one run.
81
+ }
82
+ return { key: generated, source: "generated" };
83
+ }
84
+
85
+ /**
86
+ * Bounded-depth search for a file named `filename` under `root`. Stops at
87
+ * the first match, or after MAX_TITLE_SEARCH_DIRS directories visited,
88
+ * whichever comes first — this is best-effort enrichment, not a guarantee,
89
+ * and must never become an unbounded walk of the user's home directory.
90
+ */
91
+ function findFileByName(root, filename, { existsSyncImpl, readdirSyncImpl, maxDepth = 4 }) {
92
+ if (!existsSyncImpl(root)) return null;
93
+ const stack = [{ dir: root, depth: 0 }];
94
+ let visited = 0;
95
+ while (stack.length && visited < MAX_TITLE_SEARCH_DIRS) {
96
+ const { dir, depth } = stack.pop();
97
+ visited += 1;
98
+ let entries;
99
+ try {
100
+ entries = readdirSyncImpl(dir, { withFileTypes: true });
101
+ } catch {
102
+ continue;
103
+ }
104
+ for (const entry of entries) {
105
+ if (entry.isFile() && entry.name === filename) return join(dir, entry.name);
106
+ if (entry.isDirectory() && depth < maxDepth) stack.push({ dir: join(dir, entry.name), depth: depth + 1 });
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Claude Code's own per-session metadata store — one JSON file named
114
+ * `<CLAUDE_CODE_HOST_SESSION_ID>.json`, holding (among other fields) `title`:
115
+ * the human-meaningful, user-renamable thread title shown in the app. Its
116
+ * exact parent directories are two harness-assigned ids this function does
117
+ * not try to predict; it searches for the leaf filename instead, bounded and
118
+ * best-effort.
119
+ */
120
+ function readClaudeCodeThreadTitle({ env, existsSyncImpl, readFileSyncImpl, readdirSyncImpl, homedirImpl }) {
121
+ const hostId = env.CLAUDE_CODE_HOST_SESSION_ID?.trim();
122
+ if (!hostId) return null;
123
+ const root = join(homedirImpl(), ...CLAUDE_SESSIONS_DIR_PARTS);
124
+ const found = findFileByName(root, `${hostId}.json`, { existsSyncImpl, readdirSyncImpl });
125
+ if (!found) return null;
126
+ try {
127
+ const parsed = JSON.parse(readFileSyncImpl(found, "utf8"));
128
+ const title = parsed?.title;
129
+ return typeof title === "string" && title.trim() ? title.trim() : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Codex's own local thread store (a SQLite file under its home directory) —
137
+ * `threads.title` for the id `CODEX_THREAD_ID` names. Read via Node's
138
+ * built-in `node:sqlite`; guarded by dynamic import so an older runtime that
139
+ * lacks it — or any other read failure — degrades to "no title" rather than
140
+ * a crash. Opened read-only: this must never create a WAL file or take a
141
+ * write lock on a store Codex itself may be actively writing to.
142
+ */
143
+ async function readCodexThreadTitle({ env, existsSyncImpl, homedirImpl }) {
144
+ const threadId = env.CODEX_THREAD_ID?.trim();
145
+ if (!threadId) return null;
146
+ const dbPath = join(homedirImpl(), ...CODEX_STATE_DB_PARTS);
147
+ if (!existsSyncImpl(dbPath)) return null;
148
+ try {
149
+ const { DatabaseSync } = await import("node:sqlite");
150
+ const db = new DatabaseSync(dbPath, { readOnly: true });
151
+ try {
152
+ const row = db.prepare("SELECT title FROM threads WHERE id = ?").get(threadId);
153
+ const title = row?.title;
154
+ return typeof title === "string" && title.trim() ? title.trim() : null;
155
+ } finally {
156
+ db.close();
157
+ }
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Best-effort "what is this session working on", sourced from the harness's
165
+ * own thread title when the caller did not pass an explicit `task`. Design
166
+ * D1a (presence-naming-ergonomics): the thread title is the best
167
+ * "what am I working on" string in the system — automatic, human-meaningful,
168
+ * already maintained by the user — and the schema already has the right
169
+ * slot for it (`task` is display-only and already refreshed on every
170
+ * coordination call).
171
+ *
172
+ * Never throws and never blocks meaningfully on a slow or failing read:
173
+ * every source here is either a fast env-var check or a single best-effort
174
+ * file/DB read, and any failure just leaves the explicit `task` (possibly
175
+ * absent) untouched.
176
+ *
177
+ * A thread title is user-authored prose this publishes into a shared org
178
+ * room the instant it is sent — `RUN402_NO_TASK_FROM_TITLE=1` opts out
179
+ * entirely, matching design task 3.2's "never send a title for a room the
180
+ * session did not deliberately join".
181
+ */
182
+ export async function resolveTaskLabel({
183
+ explicitTask,
184
+ env = process.env,
185
+ existsSyncImpl = existsSync,
186
+ readFileSyncImpl = readFileSync,
187
+ readdirSyncImpl = readdirSync,
188
+ homedirImpl = homedir,
189
+ } = {}) {
190
+ const trimmed = typeof explicitTask === "string" ? explicitTask.trim() : "";
191
+ if (trimmed) return { task: trimmed, source: "explicit" };
192
+ if (isTruthyEnvValue(env[TASK_FROM_TITLE_OPT_OUT_ENV])) return { task: null, source: "opted_out" };
193
+
194
+ const claude = readClaudeCodeThreadTitle({ env, existsSyncImpl, readFileSyncImpl, readdirSyncImpl, homedirImpl });
195
+ if (claude) return { task: claude.slice(0, TASK_MAX_LENGTH), source: "claude_code_thread_title" };
196
+
197
+ const codex = await readCodexThreadTitle({ env, existsSyncImpl, homedirImpl });
198
+ if (codex) return { task: codex.slice(0, TASK_MAX_LENGTH), source: "codex_thread_title" };
199
+
200
+ return { task: null, source: "none" };
201
+ }
package/lib/messages.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  getRoomState,
34
34
  updateRoomState,
35
35
  } from "./rooms-context.mjs";
36
+ import { resolveTaskLabel } from "./harness-context.mjs";
36
37
 
37
38
  export const IMPORTANCE = ["normal", "high"];
38
39
 
@@ -57,6 +58,10 @@ Notes:
57
58
  message. --ack asks the recipient to confirm they saw it.
58
59
  - Messages are durable. An agent that is not running now reads them when it
59
60
  next wakes.
61
+ - send carries this session's identity like \`rooms join\` does: it resumes
62
+ your existing presence (no rename) when one is resolvable, and --task —
63
+ or, if omitted, your harness's own thread title — refreshes what the room
64
+ sees you working on.
60
65
  - The room itself — arriving, leaving, seeing who is live — is \`run402 rooms\`.
61
66
  `;
62
67
 
@@ -112,7 +117,8 @@ async function send(args) {
112
117
  org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
113
118
  });
114
119
  try {
115
- const result = await withPresenceRetry(room.orgId, room.roomKey, (presenceId) =>
120
+ const { task } = await resolveTaskLabel({ explicitTask: flagValue(a, "--task") });
121
+ const result = await withPresenceRetry(room.orgId, room.roomKey, (presenceId, sessionKey) =>
116
122
  getSdk().rooms.sendMessage(room.orgId, room.roomKey, {
117
123
  body: positionals[0],
118
124
  to: splitNames(flagValue(a, "--to")),
@@ -122,10 +128,11 @@ async function send(args) {
122
128
  ackRequired: a.includes("--ack"),
123
129
  idempotencyKey: flagValue(a, "--idempotency-key") ?? undefined,
124
130
  presenceId: presenceId ?? undefined,
131
+ sessionKey,
125
132
  requestedName: flagValue(a, "--name") ?? undefined,
126
- task: flagValue(a, "--task") ?? undefined,
133
+ task: task ?? undefined,
127
134
  }),
128
- { name: flagValue(a, "--name"), task: flagValue(a, "--task") });
135
+ { name: flagValue(a, "--name"), task });
129
136
  rememberPresence(room.orgId, room.roomKey, result.sender_presence, flagValue(a, "--name"));
130
137
  console.log(JSON.stringify(result, null, 2));
131
138
  } catch (err) {
@@ -149,12 +156,13 @@ async function list(args) {
149
156
  const stored = getRoomState(room.orgId, room.roomKey).cursor;
150
157
  const cursor = flagValue(a, "--cursor") ?? (before ? undefined : (typeof stored === "string" ? stored : undefined));
151
158
  try {
152
- const page = await withPresenceRetry(room.orgId, room.roomKey, (presenceId) =>
159
+ const page = await withPresenceRetry(room.orgId, room.roomKey, (presenceId, sessionKey) =>
153
160
  getSdk().rooms.listMessages(room.orgId, room.roomKey, {
154
161
  ...(before ? { order: "desc", before } : cursor ? { cursor } : {}),
155
162
  threadId: flagValue(a, "--thread") ?? undefined,
156
163
  ...(unread ? { addressedTo: "me", unread: true } : {}),
157
164
  presenceId: presenceId ?? undefined,
165
+ sessionKey,
158
166
  limit: limit != null ? parseIntegerFlag("--limit", limit, { min: 1, max: 200 }) : undefined,
159
167
  }));
160
168
  // NOTE: an unread/addressed_to=me read needs a resolvable "me"; the retry
@@ -197,9 +205,10 @@ async function ack(args) {
197
205
  org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
198
206
  });
199
207
  try {
200
- const result = await withPresenceRetry(room.orgId, room.roomKey, (presenceId) =>
208
+ const result = await withPresenceRetry(room.orgId, room.roomKey, (presenceId, sessionKey) =>
201
209
  getSdk().rooms.ackMessage(room.orgId, room.roomKey, positionals[0], {
202
210
  presenceId: presenceId ?? undefined,
211
+ sessionKey,
203
212
  }));
204
213
  console.log(JSON.stringify(result, null, 2));
205
214
  } catch (err) {