@retasc/cli 1.39.3 → 1.40.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.40.0 (2026-09-04)
10
+
11
+ - **RTSC-820** — every session now tells Retasc which conversation it is. `retasc setup`
12
+ wires a Claude Code `SessionStart` hook (`retasc hook session-start`) into
13
+ `~/.claude/settings.json`, idempotently and beside whatever hooks you already have; the
14
+ hook leaves the session's id in the keystore dir, and the watchdog proxy picks it up and
15
+ records it on its session key through a new `record_session` tool. Grok needs no hook:
16
+ the proxy reads `GROK_SESSION_ID` at startup. The Dash's Agents page then ends each
17
+ session's panel with the line that reopens it on the machine that ran it,
18
+ `claude --resume <uuid>`. Only the NAME of the transcript is stored, never its
19
+ contents and never a path. A session started before `setup` re-ran shows
20
+ "none recorded" and names the missing hook.
21
+
22
+ ## 1.39.4 (2026-09-03)
23
+
24
+ - **RTSC-810** — `retasc unbind` revokes the key it says it revokes. It had two faults in
25
+ one lookup, and each on its own was enough to leave the credential live: it destructured
26
+ `{ keys }` off `listKeys`, which returns a bare array, and it derived the folder's key
27
+ prefix with a 12-character slice while the server stores 14. An exact compare between a
28
+ 12-character string and a 14-character one is never true, so every run since `unbind`
29
+ shipped (RTSC-721) printed "not found server-side (already revoked, or the org is
30
+ gone)" and moved on. The length now mirrors the server's own `displayPrefixOf`, and the
31
+ test reads that file, so the two cannot drift apart again. A session child key can no
32
+ longer be the one revoked either: those are minted in memory by the proxy and never
33
+ reach the keystore, so a match on one would mean revoking a key this folder does not own.
34
+ - **RTSC-810** — `retasc bind --org-id X --project-id Y` learns the project's prefix. The
35
+ provisioning form skipped every branch that looks a project up, so nothing knew the
36
+ prefix: the key went out nameless, the keystore entry was written without `prefix` or
37
+ `orgName`, and the receipt card printed an empty one. It now resolves the project the
38
+ same way the interactive pickers do.
39
+ - **RTSC-810** — a key is named after the folder it was bound in, not after the project.
40
+ The Keys list is the folder map (`client-a` → `ENG`), which is what tells you which
41
+ machine a credential belongs to; `ENG key` on every row told nobody anything. Naming is
42
+ the server's job now, so no door can store a nameless key: the Dash mint form with the
43
+ name left blank and `retasc key mint` without `--name` both fall back to the project's
44
+ own name rather than leaving the Dash to print "Unnamed key".
45
+
9
46
  ## 1.39.3 (2026-09-02)
10
47
 
11
48
  - **RTSC-801** — (security) `save_attachment_file` no longer reads a workspace's own secrets,
@@ -455,6 +455,20 @@ export async function completeWorkspaceSetup(args) {
455
455
  throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
456
456
  }
457
457
  }
458
+ // RTSC-810 — a `--project-id` skipped every branch above, so nothing here knew the
459
+ // project's prefix: the key went out nameless (`keyName` was built from `prefix`), the
460
+ // keystore entry was written without `prefix`/`orgName`, and the receipt card printed
461
+ // an empty one. The provisioning form is documented and supported, so it fills the
462
+ // gap the way the pickers do: one list call. Best-effort on the match — a scoped
463
+ // member may hold an id the list does not show, and that folder still has to bind.
464
+ if (projectId && !prefix) {
465
+ const { projects } = (await api.listProjects({ orgId }));
466
+ const hit = (projects ?? []).find((p) => p.id === projectId);
467
+ if (hit) {
468
+ prefix = hit.prefix;
469
+ emptyProject = projectIsEmpty(hit);
470
+ }
471
+ }
458
472
  // --- make `retasc` durable BEFORE anything is committed (RTSC-493) ---------
459
473
  // The marker names a command something else spawns on every agent start, so it has to
460
474
  // name one proved to run on this machine. Resolved (and announced) here so the install
@@ -501,7 +515,12 @@ export async function completeWorkspaceSetup(args) {
501
515
  projectId: projectId,
502
516
  agentName: opts.agent,
503
517
  runtime: opts.runtime ?? "claude-code",
504
- keyName: prefix ? `${prefix} key` : undefined,
518
+ // RTSC-810 named after the FOLDER, the leaf only, exactly as the setup-token
519
+ // door has done since RTSC-532: the Keys list is the folder map (`client-a →
520
+ // ENG`), and the old prefix-plus-"key" name told nobody which folder held it.
521
+ // The server cleans the name and falls back to the project's own when the
522
+ // leaf is empty.
523
+ keyName: basename(cwd),
505
524
  }));
506
525
  // The key is named in the receipt card below, not here — one mention, in the place
507
526
  // that says where it went (RTSC-673).
@@ -14,6 +14,7 @@
14
14
  // nothing (the reason `resolveLauncher` exists, RTSC-493), so a postinstall would never
15
15
  // run for them; and writing into somebody's global agent configs from a package install
16
16
  // is a thing to do in front of a human, not behind one.
17
+ import { wireClaudeHook, hookLauncher } from "../lib/sessionHook.js";
17
18
  import { AUTO_WORKSPACE } from "../lib/keystore.js";
18
19
  import { HARNESSES, detectHarnesses, tildePath } from "../lib/harness.js";
19
20
  import { resolveLauncher, launcherNote } from "../lib/launcher.js";
@@ -58,6 +59,13 @@ export function runSetup(opts) {
58
59
  result.failed.push({ label: h.label, reason: outcome.reason });
59
60
  }
60
61
  }
62
+ // RTSC-820 — Claude Code hands its session id to hooks, not to MCP servers, so the
63
+ // marker alone can never learn which conversation a session is. Same launcher as the
64
+ // marker, so both resolve or neither does.
65
+ if (presentIds.has("claude-code")) {
66
+ const hooked = wireClaudeHook(hookLauncher(resolved.launcher, VERSION));
67
+ result.hook = hooked.ok ? { where: tildePath(hooked.path), outcome: hooked.outcome } : { reason: hooked.reason };
68
+ }
61
69
  if (!opts.quiet)
62
70
  printSetup(result);
63
71
  return result;
@@ -82,6 +90,12 @@ export function printSetup(r) {
82
90
  }))));
83
91
  for (const f of r.failed)
84
92
  console.log(` ! ${f.label}: ${f.reason}`);
93
+ if (r.hook) {
94
+ if ("reason" in r.hook)
95
+ console.log(` ! Claude Code session hook: ${r.hook.reason}`);
96
+ else if (r.hook.outcome !== "unchanged")
97
+ console.log(` ${r.hook.outcome === "added" ? "+" : "~"} Claude Code session hook in ${r.hook.where}, so the Dash can name each session's transcript.`);
98
+ }
85
99
  if (r.absent.length)
86
100
  console.log(` Not on this machine: ${r.absent.join(", ")}.`);
87
101
  console.log("\nThese entries carry no key and name no project, so they are correct in every folder.\n" +
@@ -27,6 +27,35 @@ import { removeProjectMarker, tryClaudeCliRemove } from "./mcp.js";
27
27
  * • **The session is left alone.** Signing out is `logout`'s job; conflating the two
28
28
  * turns "detach this folder" into "log me out everywhere", which nobody asked.
29
29
  */
30
+ /**
31
+ * The prefix the SERVER stored for a raw key — `displayPrefixOf` in `convex/lib/keys.ts`,
32
+ * mirrored here because the CLI cannot import backend code.
33
+ *
34
+ * RTSC-810: this length is the join `unbind` revokes on, and it was wrong. The local side
35
+ * sliced 12 while the server stores 14, so the exact compare was never true once, and
36
+ * `unbind` reported "not found server-side" on every run while leaving the key LIVE —
37
+ * the same outcome as the `{ keys }` destructure below, from the other half of the same
38
+ * lookup. A change to the server's slice has to change this one; `unbindRevoke810.test.mjs`
39
+ * reads `convex/lib/keys.ts` and fails if the two ever disagree again.
40
+ */
41
+ export const DISPLAY_PREFIX_LEN = 14;
42
+ export function displayPrefixOf(key) {
43
+ return key.slice(0, DISPLAY_PREFIX_LEN);
44
+ }
45
+ /**
46
+ * This folder's live key among the org's, by display prefix — the join `unbind` revokes
47
+ * on. Takes the ARRAY `listKeys` returns; anything else is "nothing to revoke", never a
48
+ * throw, because the caller's fallback is to name the Dash rather than to fail.
49
+ *
50
+ * WORKSPACE keys only: a session child (`parentKeyId` set) is minted in memory by the
51
+ * proxy and never reaches the keystore, so one matching here would mean revoking a key
52
+ * this folder does not own.
53
+ */
54
+ export function liveKeyFor(keys, prefix) {
55
+ if (!Array.isArray(keys) || !prefix)
56
+ return undefined;
57
+ return keys.find((k) => k?.displayPrefix === prefix && !k.revokedAt && !k.parentKeyId && typeof k.id === "string");
58
+ }
30
59
  export async function unbindAction(opts) {
31
60
  const cwd = process.cwd();
32
61
  const existing = readLocalBinding(cwd);
@@ -35,7 +64,7 @@ export async function unbindAction(opts) {
35
64
  return;
36
65
  }
37
66
  const entry = existing.workspaceId ? getBinding(existing.workspaceId) : undefined;
38
- const keyPrefix = (entry?.key ?? existing.key ?? "").slice(0, 12);
67
+ const keyPrefix = displayPrefixOf(entry?.key ?? existing.key ?? "");
39
68
  console.log(`This will disconnect ${cwd} from Retasc:`);
40
69
  if (entry?.orgName || entry?.prefix) {
41
70
  console.log(` bound to: ${entry.orgName ?? entry.orgId} / ${entry.prefix ?? entry.projectId}`);
@@ -58,14 +87,18 @@ export async function unbindAction(opts) {
58
87
  return;
59
88
  }
60
89
  // Revoke FIRST, while the keystore still holds what identifies the key. displayPrefix
61
- // is how the server names keys (the raw value is never stored there), so the first 12
62
- // chars of ours is the join.
90
+ // is how the server names keys (the raw value is never stored there), so the first
91
+ // DISPLAY_PREFIX_LEN chars of ours is the join — the server's own slice, not a shorter
92
+ // guess at it (RTSC-810).
63
93
  if (entry?.orgId && keyPrefix && isLoggedIn()) {
64
94
  try {
65
- const { keys } = (await api.listKeys({ orgId: entry.orgId }));
66
- const mine = (keys ?? []).find((k) => k.displayPrefix === keyPrefix && !k.revokedAt);
95
+ // RTSC-810 `listKeys` returns a bare ARRAY (as `retasc key list` has always
96
+ // read it). This destructured `{ keys }` off it, got `undefined`, and so every
97
+ // unbind since RTSC-721 printed "not found server-side" and left the key LIVE —
98
+ // the exact half of hand-editing the command exists to stop.
99
+ const mine = liveKeyFor(await api.listKeys({ orgId: entry.orgId }), keyPrefix);
67
100
  if (mine) {
68
- await api.revokeKey({ keyId: mine.id ?? mine._id });
101
+ await api.revokeKey({ keyId: mine.id });
69
102
  console.log(`✓ Revoked key ${keyPrefix}… server-side.`);
70
103
  }
71
104
  else {
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { findBindingByPath } from "./lib/keystore.js";
3
+ import { parseClaudeHookInput, writeHookRecord } from "./lib/sessionHook.js";
2
4
  import { Command } from "commander";
3
5
  import { VERSION } from "./version.js";
4
6
  import { selfCommand, versionStamp } from "./lib/launcher.js";
@@ -593,6 +595,35 @@ mcp
593
595
  .command("proxy", { hidden: true })
594
596
  .description("Run the liveness watchdog proxy (spawned by your harness; wired by mcp install).")
595
597
  .action(() => runProxy());
598
+ // RTSC-820 — what a harness's SessionStart hook runs. Reads the harness's JSON on stdin
599
+ // and leaves the session id where the proxy will find it. Exits 0 no matter what: a
600
+ // hook that fails must never fail the session it was told about.
601
+ const hook = program.command("hook").description("Harness hook handlers (wired by `retasc setup`).");
602
+ hook
603
+ .command("session-start")
604
+ .description("Claude Code SessionStart hook: record this session's transcript id for the proxy.")
605
+ .action(async () => {
606
+ try {
607
+ // The hook is wired machine-wide, so it fires in every folder Claude Code opens.
608
+ // Only a folder bound to Retasc has a proxy to hand the id to; anywhere else a
609
+ // record would be a note about a project Retasc was never told about.
610
+ if (process.stdin.isTTY)
611
+ return;
612
+ const chunks = [];
613
+ for await (const c of process.stdin)
614
+ chunks.push(c);
615
+ const parsed = parseClaudeHookInput(Buffer.concat(chunks).toString("utf8"));
616
+ if (parsed && findBindingByPath(parsed.cwd)) {
617
+ writeHookRecord({ harness: "claude-code", sessionId: parsed.sessionId, cwd: parsed.cwd, at: Date.now() });
618
+ }
619
+ }
620
+ catch {
621
+ /* never fail the harness's session start */
622
+ }
623
+ finally {
624
+ process.exitCode = 0;
625
+ }
626
+ });
596
627
  // Top-level alias so harness config can spawn `retasc mcp-proxy`.
597
628
  program
598
629
  .command("mcp-proxy", { hidden: true })
@@ -91,3 +91,43 @@ export function appendFallbackNotice(toolName, resp, degraded) {
91
91
  result.content.push({ type: "text", text: MINT_FALLBACK_NOTICE });
92
92
  }
93
93
  }
94
+ /**
95
+ * Record the harness's transcript id on THIS session's key (RTSC-820). Best effort and
96
+ * quiet: the worst case is the Dash saying "none recorded", which is what it said
97
+ * before this existed. Returns true once the server has it (already-recorded counts).
98
+ */
99
+ export async function recordSession(opts) {
100
+ const fetchImpl = opts.fetchImpl ?? fetch;
101
+ const warn = opts.warn ?? ((msg) => process.stderr.write(`[retasc] ${msg}\n`));
102
+ try {
103
+ const res = await fetchImpl(opts.url, {
104
+ method: "POST",
105
+ headers: {
106
+ Authorization: `Bearer ${opts.key}`,
107
+ "Content-Type": "application/json",
108
+ Accept: "application/json",
109
+ },
110
+ body: JSON.stringify({
111
+ jsonrpc: "2.0",
112
+ id: MINT_RPC_ID - 1,
113
+ method: "tools/call",
114
+ params: {
115
+ name: "record_session",
116
+ arguments: { transcriptId: opts.transcriptId, ...(opts.harness ? { harness: opts.harness } : {}) },
117
+ },
118
+ }),
119
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
120
+ });
121
+ const text = await res.text();
122
+ if (!res.ok) {
123
+ warn(`record_session failed (HTTP ${res.status})`);
124
+ return false;
125
+ }
126
+ const r = toolResult(text ? JSON.parse(text) : null, warn);
127
+ return !!(r && typeof r === "object" && "transcriptId" in r);
128
+ }
129
+ catch (e) {
130
+ warn(`record_session failed: ${String(e?.message ?? e)}`);
131
+ return false;
132
+ }
133
+ }
@@ -0,0 +1,233 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ /** Where the records live. `RETASC_DIR` relocates it, like the keystore (tests, sandboxes). */
7
+ export function sessionsDir() {
8
+ return join(process.env.RETASC_DIR || join(homedir(), ".retasc"), "sessions");
9
+ }
10
+ /** The folder as the kernel names it: the hook's `cwd` and the proxy's `process.cwd()`
11
+ * may spell a symlinked path differently, and both must land on one file. */
12
+ export function canonPath(p) {
13
+ const r = resolve(p);
14
+ try {
15
+ return realpathSync(r);
16
+ }
17
+ catch {
18
+ return r;
19
+ }
20
+ }
21
+ /** One file per folder: a stable, filename-safe key for the cwd. */
22
+ export function hookFilePath(cwd, dir = sessionsDir()) {
23
+ const h = createHash("sha256").update(canonPath(cwd)).digest("hex").slice(0, 16);
24
+ return join(dir, `${h}.json`);
25
+ }
26
+ /** Records nobody consumed in a day are nobody's session any more: dropped on the next
27
+ * write, so the dir never becomes an index of every folder ever opened. Best effort. */
28
+ const RECORD_TTL_MS = 24 * 60 * 60 * 1000;
29
+ function sweepOldRecords(dir, now) {
30
+ let names;
31
+ try {
32
+ names = readdirSync(dir);
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const n of names) {
38
+ if (!n.endsWith(".json"))
39
+ continue;
40
+ try {
41
+ const path = join(dir, n);
42
+ if (now - statSync(path).mtimeMs > RECORD_TTL_MS)
43
+ unlinkSync(path);
44
+ }
45
+ catch {
46
+ /* someone else's problem */
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * What a hook writes. Never throws past its caller: a hook that fails must not fail the
52
+ * harness's session start, and the worst case is the Dash saying "none recorded".
53
+ */
54
+ export function writeHookRecord(rec, dir = sessionsDir()) {
55
+ const path = hookFilePath(rec.cwd, dir);
56
+ // 0o700 all the way up: the hook is wired machine-wide and can be the first thing to
57
+ // create ~/.retasc, and the keystore's own mkdir is a no-op on a directory that
58
+ // already exists (keystore.ts). The two must agree on user-only.
59
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
60
+ writeFileSync(path, JSON.stringify(rec), { mode: 0o600 });
61
+ sweepOldRecords(dirname(path), rec.at);
62
+ return path;
63
+ }
64
+ /**
65
+ * The record for THIS folder, if a hook left one recently enough to be this session's.
66
+ * `maxAgeMs` is measured from `now`; the proxy passes its own start time so a record
67
+ * written a little before the proxy came up still counts. `notAfter` is the other edge:
68
+ * a record written well AFTER the proxy started belongs to the next session in this
69
+ * folder, and a long-lived proxy that never found its own must not adopt it.
70
+ */
71
+ export function readHookRecord(cwd, opts = {}) {
72
+ const path = hookFilePath(cwd, opts.dir ?? sessionsDir());
73
+ if (!existsSync(path))
74
+ return null;
75
+ let rec;
76
+ try {
77
+ rec = JSON.parse(readFileSync(path, "utf8"));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ if (!rec || typeof rec !== "object")
83
+ return null;
84
+ // Same shape the server accepts: a filename stem, never something a shell or a CLI
85
+ // would read as a flag (first character alphanumeric).
86
+ if (typeof rec.sessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(rec.sessionId))
87
+ return null;
88
+ if (typeof rec.harness !== "string" || !rec.harness)
89
+ return null;
90
+ if (typeof rec.cwd !== "string" || canonPath(rec.cwd) !== canonPath(cwd))
91
+ return null;
92
+ const now = opts.now ?? Date.now();
93
+ const maxAge = opts.maxAgeMs ?? 10 * 60 * 1000;
94
+ if (typeof rec.at !== "number" || rec.at > now + 60_000 || now - rec.at > maxAge)
95
+ return null;
96
+ if (opts.notAfter !== undefined && rec.at > opts.notAfter)
97
+ return null;
98
+ return rec;
99
+ }
100
+ /** Consume a record once it has been reported, so no later session can adopt it. */
101
+ export function clearHookRecord(cwd, dir = sessionsDir()) {
102
+ try {
103
+ unlinkSync(hookFilePath(cwd, dir));
104
+ }
105
+ catch {
106
+ /* already gone */
107
+ }
108
+ }
109
+ /**
110
+ * Parse what Claude Code hands a SessionStart hook on stdin. Verified against the hooks
111
+ * reference (2026-09-04): `session_id`, `transcript_path`, `cwd`, `hook_event_name`,
112
+ * `source`. Only the id and the cwd are kept; the path is derivable and would bake in a
113
+ * username (RTSC-791).
114
+ */
115
+ export function parseClaudeHookInput(stdin) {
116
+ let j;
117
+ try {
118
+ j = JSON.parse(stdin);
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ const sessionId = typeof j?.session_id === "string" ? j.session_id.trim() : "";
124
+ const cwd = typeof j?.cwd === "string" && j.cwd ? j.cwd : process.cwd();
125
+ if (!sessionId)
126
+ return null;
127
+ return { sessionId, cwd };
128
+ }
129
+ // --- wiring the Claude Code hook ---------------------------------------------------
130
+ export const HOOK_MARKER = "hook session-start";
131
+ /** A shell word: bare when it is plainly one, single-quoted otherwise. Claude Code runs
132
+ * hook commands through a shell, and an absolute launcher path can carry a space
133
+ * (`/Users/John Doe/...`); unquoted, the hook would fail on every session start and
134
+ * nothing would name the cause. */
135
+ export function shellWord(part) {
136
+ return /^[A-Za-z0-9_.\/@:=+%,-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`;
137
+ }
138
+ /** The command the hook runs: the same launcher the MCP marker names, so an `npx`
139
+ * install and a global one both resolve. The marker verb stays bare, so idempotence
140
+ * can find the entry by it. */
141
+ export function claudeHookCommand(launcher) {
142
+ return [...[launcher.command, ...launcher.args].map(shellWord), HOOK_MARKER].join(" ");
143
+ }
144
+ /**
145
+ * Merge our SessionStart hook into a Claude Code `settings.json` text, and say what
146
+ * happened. Idempotent: an entry already carrying our marker is updated in place if
147
+ * its command changed (a moved launcher), left alone if not, never duplicated. Every
148
+ * other hook, and every other setting, survives untouched. Returns null when the file
149
+ * is not JSON we can read, rather than replacing something we cannot parse.
150
+ */
151
+ export function mergeClaudeHook(text, command) {
152
+ let settings = {};
153
+ if (text && text.trim()) {
154
+ try {
155
+ settings = JSON.parse(text);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
161
+ return null;
162
+ }
163
+ const hooks = (settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {});
164
+ const list = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
165
+ let outcome = "unchanged";
166
+ let found = false;
167
+ for (const entry of list) {
168
+ // Someone else's entries may be any shape; only walk the ones shaped like hooks.
169
+ if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks))
170
+ continue;
171
+ for (const h of entry.hooks) {
172
+ if (h && typeof h === "object" && h.type === "command" && typeof h.command === "string" && h.command.includes(HOOK_MARKER)) {
173
+ found = true;
174
+ if (h.command !== command) {
175
+ h.command = command;
176
+ outcome = "updated";
177
+ }
178
+ }
179
+ }
180
+ }
181
+ if (!found) {
182
+ // `startup|resume` only: a compaction or a /clear re-fires SessionStart with the
183
+ // same id and would re-arm the record for a session already served. `timeout` in
184
+ // seconds: a hook that hangs (a cold npx offline) must never hold a session start.
185
+ list.push({ matcher: "startup|resume", hooks: [{ type: "command", command, timeout: 10 }] });
186
+ outcome = "added";
187
+ }
188
+ hooks.SessionStart = list;
189
+ settings.hooks = hooks;
190
+ return { text: JSON.stringify(settings, null, 2) + "\n", outcome };
191
+ }
192
+ /**
193
+ * The launcher the hook may name. The MCP marker's launcher is whatever `retasc` is on
194
+ * PATH, at whatever version; a 1.39 binary has no `hook` verb and would print "unknown
195
+ * command" on every session start, everywhere. So the hook gets that launcher only if it
196
+ * actually answers the verb, and the pinned npx form of THIS version otherwise.
197
+ */
198
+ export function hookLauncher(launcher, version, probe = (cmd, args) => {
199
+ try {
200
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 20_000, shell: process.platform === "win32" });
201
+ return !r.error && r.status === 0;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }) {
207
+ if (launcher.command === "npx")
208
+ return launcher; // pinned to a version that has it
209
+ if (probe(launcher.command, [...launcher.args, ...HOOK_MARKER.split(" "), "--help"]))
210
+ return launcher;
211
+ return { command: "npx", args: ["-y", `@retasc/cli@${version}`] };
212
+ }
213
+ /** `~/.claude/settings.json`, under the same home override the harness registry honours. */
214
+ export function claudeSettingsPath(home = process.env.RETASC_HOME || homedir()) {
215
+ return join(home, ".claude", "settings.json");
216
+ }
217
+ /**
218
+ * Wire the hook on this machine. Returns what it did, or why it could not. The file
219
+ * is user-owned config Claude Code reads at start (unlike `~/.claude.json`, which a
220
+ * running Claude Code rewrites and which is only ever edited through `claude mcp`).
221
+ */
222
+ export function wireClaudeHook(launcher, home) {
223
+ const path = claudeSettingsPath(home);
224
+ const before = existsSync(path) ? readFileSync(path, "utf8") : null;
225
+ const merged = mergeClaudeHook(before, claudeHookCommand(launcher));
226
+ if (!merged)
227
+ return { ok: false, reason: `${path} is not JSON I can read; add the hook by hand` };
228
+ if (merged.outcome !== "unchanged") {
229
+ mkdirSync(dirname(path), { recursive: true });
230
+ writeFileSync(path, merged.text);
231
+ }
232
+ return { ok: true, path, outcome: merged.outcome };
233
+ }
package/dist/proxy.js CHANGED
@@ -13,7 +13,8 @@ import { fileURLToPath } from "node:url";
13
13
  import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
14
14
  import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
15
15
  import { toolResult as parseTool } from "./lib/toolresult.js";
16
- import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
16
+ import { mintSessionKey, appendFallbackNotice, recordSession } from "./lib/session.js";
17
+ import { readHookRecord, clearHookRecord } from "./lib/sessionHook.js";
17
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
18
19
  import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
19
20
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
@@ -47,6 +48,24 @@ let authWarned = false;
47
48
  // Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
48
49
  // session key and switch to it so this session is distinguishable from others.
49
50
  let activeKey = KEY;
51
+ // RTSC-820 — the harness's transcript id, once we learn it, and whether the server has
52
+ // it. Grok exports GROK_SESSION_ID to MCP servers, so that one is known at startup.
53
+ // Claude Code hands its id to a SessionStart HOOK, which drops a record in the keystore
54
+ // dir (lib/sessionHook.ts); hook and proxy start in either order, so the record is
55
+ // looked for after the mint and then on each forwarded call until found, bounded.
56
+ const PROXY_STARTED_AT = Date.now();
57
+ const TRANSCRIPT_LOOKUPS_MAX = 40;
58
+ let transcript = process.env.GROK_SESSION_ID ? { id: process.env.GROK_SESSION_ID, harness: "grok" } : null;
59
+ let transcriptRecorded = false;
60
+ let transcriptLookups = 0;
61
+ let transcriptInFlight = null;
62
+ // The name the client gave at `initialize` (RTSC-717), so a Codex session started in a
63
+ // folder never adopts a claude-code hook's record.
64
+ let clientName = null;
65
+ // A hook record is this session's only if written within a minute BEFORE we started or
66
+ // a few minutes after; and a session that has not found one in five minutes never will.
67
+ const RECORD_NOT_AFTER_MS = 3 * 60_000;
68
+ const RECORD_SEARCH_MS = 5 * 60_000;
50
69
  // Set when session-key minting failed and we fell back to the workspace key —
51
70
  // whoami responses get a warning block appended so the agent sees the degraded state.
52
71
  let sessionKeyFallback = false;
@@ -171,6 +190,58 @@ async function adoptSessionKey() {
171
190
  sessionKeyFallback = true;
172
191
  }
173
192
  }
193
+ /**
194
+ * RTSC-820 — tell the server which conversation this session is, once we know. Only on
195
+ * a real session key: the workspace key is the folder, not a conversation, and the
196
+ * server refuses it anyway. Looks for the Claude Code hook's record while nothing has
197
+ * been found, a bounded number of times, then stops asking: a session whose harness
198
+ * wired no hook simply never learns its id, and the Dash says so.
199
+ */
200
+ async function reportTranscript() {
201
+ if (transcriptRecorded || sessionKeyFallback || activeKey === KEY)
202
+ return;
203
+ // Startup and the first forwarded call can both get here; one report at a time.
204
+ if (transcriptInFlight)
205
+ return transcriptInFlight;
206
+ transcriptInFlight = reportTranscriptOnce().finally(() => { transcriptInFlight = null; });
207
+ return transcriptInFlight;
208
+ }
209
+ async function reportTranscriptOnce() {
210
+ // One budget for looking AND for asking: a server that keeps refusing is not asked
211
+ // on every call for the rest of the session either.
212
+ if (transcriptLookups >= TRANSCRIPT_LOOKUPS_MAX)
213
+ return;
214
+ transcriptLookups += 1;
215
+ if (!transcript) {
216
+ const now = Date.now();
217
+ if (now - PROXY_STARTED_AT > RECORD_SEARCH_MS) {
218
+ transcriptLookups = TRANSCRIPT_LOOKUPS_MAX; // stop looking: any record now is the next session's
219
+ return;
220
+ }
221
+ // A record written within a minute before we started, or up to a few minutes after:
222
+ // the hook and the proxy are spawned together. Older belongs to an earlier session in
223
+ // this folder; newer belongs to a later one.
224
+ const rec = readHookRecord(process.cwd(), {
225
+ now,
226
+ maxAgeMs: now - PROXY_STARTED_AT + 60_000,
227
+ notAfter: PROXY_STARTED_AT + RECORD_NOT_AFTER_MS,
228
+ });
229
+ if (!rec)
230
+ return;
231
+ // A record names its harness; the client named itself at `initialize`. Different
232
+ // harness, not our record (a Codex pane opened in a folder a Claude hook wrote for).
233
+ if (rec.harness === "claude-code" && clientName && !/claude/i.test(clientName))
234
+ return;
235
+ transcript = { id: rec.sessionId, harness: rec.harness };
236
+ }
237
+ const ok = await recordSession({ url: MCP_URL, key: activeKey, transcriptId: transcript.id, harness: transcript.harness, warn: log });
238
+ if (ok) {
239
+ transcriptRecorded = true;
240
+ if (transcript.harness !== "grok")
241
+ clearHookRecord(process.cwd());
242
+ log(`session recorded as ${transcript.harness} transcript ${transcript.id}`);
243
+ }
244
+ }
174
245
  /**
175
246
  * RTSC-91 (DESIGN §13, D2): announce the binding to the HUMAN on startup, so the
176
247
  * org/project this session will write into is visible in the harness's MCP logs
@@ -443,6 +514,11 @@ async function handleLine(line) {
443
514
  }) + "\n");
444
515
  return;
445
516
  }
517
+ if (msg.method === "initialize") {
518
+ const n = msg.params?.clientInfo?.name;
519
+ if (typeof n === "string" && n)
520
+ clientName = n;
521
+ }
446
522
  let resp;
447
523
  try {
448
524
  resp = await postRemote(msg);
@@ -475,6 +551,9 @@ async function handleLine(line) {
475
551
  // flag the workspace-key fallback on whoami so the AGENT sees the degraded
476
552
  // state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
477
553
  let reapId;
554
+ if (msg.method === "tools/call" && !transcriptRecorded) {
555
+ reportTranscript().catch((e) => log(`record_session: ${String(e?.message ?? e)}`));
556
+ }
478
557
  if (msg.method === "tools/call" && resp) {
479
558
  const result = toolResult(resp, msg.params?.name);
480
559
  const obs = { toolName: msg.params?.name, args: msg.params?.arguments, result };
@@ -548,6 +627,7 @@ export async function runProxy() {
548
627
  // attributed to this session. stdin buffers in the OS pipe meanwhile.
549
628
  await adoptSessionKey();
550
629
  await announceBinding();
630
+ reportTranscript().catch((e) => log(`record_session: ${String(e?.message ?? e)}`));
551
631
  const timer = setInterval(() => void heartbeatAll(), HEARTBEAT_MS);
552
632
  timer.unref?.(); // the timer alone must not keep the process alive
553
633
  const rl = createInterface({ input: process.stdin });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.39.3",
3
+ "version": "1.40.0",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {