@retasc/cli 1.39.4 → 1.41.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 +26 -0
- package/dist/commands/bind.js +10 -2
- package/dist/commands/setup.js +14 -0
- package/dist/index.js +33 -2
- package/dist/lib/keystore.js +3 -1
- package/dist/lib/session.js +90 -3
- package/dist/lib/sessionHook.js +233 -0
- package/dist/proxy.js +97 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ 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.41.0 (2026-09-04)
|
|
10
|
+
|
|
11
|
+
- **RTSC-825** — every workspace now carries the name of the folder it is bound to. The
|
|
12
|
+
server never receives a path, so the name can only come from the machine that has one:
|
|
13
|
+
`retasc bind` sends it when the key is minted, and the watchdog proxy re-sends it at
|
|
14
|
+
every session start, so a folder bound before this existed is named the next time an
|
|
15
|
+
agent runs there. Only the folder's own name travels, never the path above it, and it
|
|
16
|
+
is the folder the BINDING names — a session opened in a subdirectory still reports the
|
|
17
|
+
bound folder rather than renaming the row to whatever directory it started in. The
|
|
18
|
+
Dash shows it as `workspace` on the Agents page and on the Connect page, and shows
|
|
19
|
+
nothing at all where a workspace has not reported one; a key retired before this
|
|
20
|
+
shipped can never report one, so it stays blank.
|
|
21
|
+
|
|
22
|
+
## 1.40.0 (2026-09-04)
|
|
23
|
+
|
|
24
|
+
- **RTSC-820** — every session now tells Retasc which conversation it is. `retasc setup`
|
|
25
|
+
wires a Claude Code `SessionStart` hook (`retasc hook session-start`) into
|
|
26
|
+
`~/.claude/settings.json`, idempotently and beside whatever hooks you already have; the
|
|
27
|
+
hook leaves the session's id in the keystore dir, and the watchdog proxy picks it up and
|
|
28
|
+
records it on its session key through a new `record_session` tool. Grok needs no hook:
|
|
29
|
+
the proxy reads `GROK_SESSION_ID` at startup. The Dash's Agents page then ends each
|
|
30
|
+
session's panel with the line that reopens it on the machine that ran it,
|
|
31
|
+
`claude --resume <uuid>`. Only the NAME of the transcript is stored, never its
|
|
32
|
+
contents and never a path. A session started before `setup` re-ran shows
|
|
33
|
+
"none recorded" and names the missing hook.
|
|
34
|
+
|
|
9
35
|
## 1.39.4 (2026-09-03)
|
|
10
36
|
|
|
11
37
|
- **RTSC-810** — `retasc unbind` revokes the key it says it revokes. It had two faults in
|
package/dist/commands/bind.js
CHANGED
|
@@ -521,6 +521,9 @@ export async function completeWorkspaceSetup(args) {
|
|
|
521
521
|
// The server cleans the name and falls back to the project's own when the
|
|
522
522
|
// leaf is empty.
|
|
523
523
|
keyName: basename(cwd),
|
|
524
|
+
// RTSC-825 — and as the WORKSPACE, which is the field the Dash reads. `keyName`
|
|
525
|
+
// stays for the legacy label; nothing reads it as a folder any more.
|
|
526
|
+
workspace: basename(cwd),
|
|
524
527
|
}));
|
|
525
528
|
// The key is named in the receipt card below, not here — one mention, in the place
|
|
526
529
|
// that says where it went (RTSC-673).
|
|
@@ -646,7 +649,7 @@ export async function setupFromToken(token, opts, deps = {}) {
|
|
|
646
649
|
const cwd = process.cwd();
|
|
647
650
|
const guard = deps.guard ?? rebindGuard;
|
|
648
651
|
const redeem = deps.redeem ??
|
|
649
|
-
((t, label) => api.redeemSetupToken({ token: t, label }));
|
|
652
|
+
((t, label, workspace) => api.redeemSetupToken({ token: t, label, workspace }));
|
|
650
653
|
// BEFORE the redeem. `rebindGuard` prints its own explanation and, with no TTY,
|
|
651
654
|
// refuses with exit 1 unless `--yes` — which is exactly the required behaviour, so it
|
|
652
655
|
// is reused rather than restated. No org/project passed: its idempotent-converge
|
|
@@ -664,7 +667,12 @@ export async function setupFromToken(token, opts, deps = {}) {
|
|
|
664
667
|
// RTSC-532 — the LEAF name, never the full path. It names the key in the Dash, so it
|
|
665
668
|
// has to be recognisable ("client-a → ENG") without publishing where on her disk it
|
|
666
669
|
// sits. `basename` of the cwd is exactly that.
|
|
667
|
-
|
|
670
|
+
//
|
|
671
|
+
// RTSC-825 — sent TWICE, on purpose: once as the legacy `label` and once as the
|
|
672
|
+
// `workspace`. They happen to be the same string here and nowhere is that a rule —
|
|
673
|
+
// the label is a human field a future caller may set to anything, so the folder
|
|
674
|
+
// travels in its own argument rather than being read back out of it.
|
|
675
|
+
const redeemed = await redeem(token.trim(), basename(cwd), basename(cwd));
|
|
668
676
|
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
669
677
|
(deps.bind ?? setBinding)(workspaceId, {
|
|
670
678
|
orgId: redeemed.orgId,
|
package/dist/commands/setup.js
CHANGED
|
@@ -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" +
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
5
7
|
import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
|
|
6
8
|
import { installMcp, noteRuntimeIsALabel, normalizeScope } from "./commands/mcp.js";
|
|
7
|
-
import { runSetup } from "./commands/setup.js";
|
|
9
|
+
import { runSetup, printSetup } from "./commands/setup.js";
|
|
8
10
|
import { installGate, resolveGatePrefix } from "./commands/gate.js";
|
|
9
11
|
import { claimAction, releaseAction } from "./commands/claim.js";
|
|
10
12
|
import { bindAction, completeWorkspaceSetup, rebindGuard, setupFromToken } from "./commands/bind.js";
|
|
@@ -574,7 +576,7 @@ program
|
|
|
574
576
|
.option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
|
|
575
577
|
.allowExcessArguments(false)
|
|
576
578
|
.action((opts) => {
|
|
577
|
-
runSetup({ install: opts.install });
|
|
579
|
+
printSetup(runSetup({ install: opts.install, quiet: true }));
|
|
578
580
|
});
|
|
579
581
|
const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
|
|
580
582
|
mcp
|
|
@@ -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 })
|
package/dist/lib/keystore.js
CHANGED
|
@@ -202,6 +202,7 @@ export function resolveConn(opts) {
|
|
|
202
202
|
// came from the keystore, so it travels only to the keystore's url. That is what
|
|
203
203
|
// lets `auto` be written into a GLOBAL harness config: the entry is identical on
|
|
204
204
|
// every machine and in every folder, and carries nothing worth stealing.
|
|
205
|
+
let boundPath = "";
|
|
205
206
|
if (!key) {
|
|
206
207
|
const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
|
|
207
208
|
const b = wsId === AUTO_WORKSPACE
|
|
@@ -217,7 +218,8 @@ export function resolveConn(opts) {
|
|
|
217
218
|
// url" is stated here outright rather than left resting on that invariant, so
|
|
218
219
|
// restoring an unpaired url above cannot quietly resurrect the RTSC-800 redirect.
|
|
219
220
|
url = b.url;
|
|
221
|
+
boundPath = b.boundPath ?? "";
|
|
220
222
|
}
|
|
221
223
|
}
|
|
222
|
-
return { key, url: url || opts.defaultUrl || "" };
|
|
224
|
+
return { key, url: url || opts.defaultUrl || "", boundPath };
|
|
223
225
|
}
|
package/dist/lib/session.js
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
// sessions collapse into one server-side identity and the per-session claim fence
|
|
6
6
|
// (RTSC-49/50) is silently inert.
|
|
7
7
|
import { toolResult } from "./toolresult.js";
|
|
8
|
-
// Out-of-band ids are negative so they never collide with the harness's (the
|
|
9
|
-
//
|
|
10
|
-
|
|
8
|
+
// Out-of-band ids are negative so they never collide with the harness's (the same
|
|
9
|
+
// convention as the proxy's heartbeat ids). One id per out-of-band call, named rather
|
|
10
|
+
// than counted: the offsets these replaced had drifted into a collision, with
|
|
11
|
+
// `announceBinding` and `record_session` both sending -1001.
|
|
12
|
+
export const RPC_ID = {
|
|
13
|
+
mint: -1000,
|
|
14
|
+
announce: -1001,
|
|
15
|
+
recordSession: -1002,
|
|
16
|
+
nameWorkspace: -1003,
|
|
17
|
+
};
|
|
18
|
+
const MINT_RPC_ID = RPC_ID.mint;
|
|
11
19
|
// The mint runs BEFORE the proxy serves `initialize`, so a hung server must not
|
|
12
20
|
// stall startup past the harness's MCP timeout — that would turn "degraded
|
|
13
21
|
// identity" into "no Retasc tools at all", the exact failure fail-soft exists
|
|
@@ -91,3 +99,82 @@ export function appendFallbackNotice(toolName, resp, degraded) {
|
|
|
91
99
|
result.content.push({ type: "text", text: MINT_FALLBACK_NOTICE });
|
|
92
100
|
}
|
|
93
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* One out-of-band `tools/call` (RTSC-825). The proxy makes several of these around a
|
|
104
|
+
* session's start — mint the session key, announce the binding, record the transcript,
|
|
105
|
+
* name the folder — and they had been written out longhand each time, so a change to
|
|
106
|
+
* the transport (the auth header, the timeout, how a failure is surfaced) had to be
|
|
107
|
+
* made in three places and would have been made in one.
|
|
108
|
+
*
|
|
109
|
+
* Every caller here is best-effort: the session must serve MCP traffic whatever the
|
|
110
|
+
* server says, so this NEVER throws. `expect` is the field the tool returns on success;
|
|
111
|
+
* its presence is what separates a real result from an error envelope carrying prose.
|
|
112
|
+
*/
|
|
113
|
+
async function callTool(opts) {
|
|
114
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
115
|
+
const warn = opts.warn ?? ((msg) => process.stderr.write(`[retasc] ${msg}\n`));
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetchImpl(opts.url, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${opts.key}`,
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
Accept: "application/json",
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
jsonrpc: "2.0",
|
|
126
|
+
id: opts.id,
|
|
127
|
+
method: "tools/call",
|
|
128
|
+
params: { name: opts.name, arguments: opts.args },
|
|
129
|
+
}),
|
|
130
|
+
signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
|
|
131
|
+
});
|
|
132
|
+
const text = await res.text();
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
warn(`${opts.name} failed (HTTP ${res.status})`);
|
|
135
|
+
// 5xx is the server having a bad minute; anything else is it saying no.
|
|
136
|
+
return res.status >= 500 ? "unreachable" : "refused";
|
|
137
|
+
}
|
|
138
|
+
const r = toolResult(text ? JSON.parse(text) : null, warn);
|
|
139
|
+
if (r && typeof r === "object" && opts.expect in r)
|
|
140
|
+
return "ok";
|
|
141
|
+
// A 200 carrying an error result: an unknown tool on an older deployment, a
|
|
142
|
+
// revoked key, the wrong kind of key. All of them are answers, not outages.
|
|
143
|
+
return "refused";
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
warn(`${opts.name} failed: ${String(e?.message ?? e)}`);
|
|
147
|
+
return "unreachable";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Record the harness's transcript id on THIS session's key (RTSC-820). Best effort and
|
|
152
|
+
* quiet: the worst case is the Dash saying "none recorded", which is what it said
|
|
153
|
+
* before this existed. Returns true once the server has it (already-recorded counts).
|
|
154
|
+
*/
|
|
155
|
+
export async function recordSession(opts) {
|
|
156
|
+
const out = await callTool({
|
|
157
|
+
...opts,
|
|
158
|
+
id: RPC_ID.recordSession,
|
|
159
|
+
name: "record_session",
|
|
160
|
+
args: { transcriptId: opts.transcriptId, ...(opts.harness ? { harness: opts.harness } : {}) },
|
|
161
|
+
expect: "transcriptId",
|
|
162
|
+
});
|
|
163
|
+
return out === "ok";
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Tell Retasc which folder a workspace key is bound to (RTSC-825). Best effort and
|
|
167
|
+
* quiet, like `recordSession`: the worst case is the Dash omitting the workspace, which
|
|
168
|
+
* is what it did before this existed. "named" once the server holds this folder
|
|
169
|
+
* (already-correct counts).
|
|
170
|
+
*/
|
|
171
|
+
export async function nameWorkspace(opts) {
|
|
172
|
+
const out = await callTool({
|
|
173
|
+
...opts,
|
|
174
|
+
id: RPC_ID.nameWorkspace,
|
|
175
|
+
name: "name_workspace",
|
|
176
|
+
args: { workspace: opts.workspace },
|
|
177
|
+
expect: "workspace",
|
|
178
|
+
});
|
|
179
|
+
return out === "ok" ? "named" : out;
|
|
180
|
+
}
|
|
@@ -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
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { hostname } from "node:os";
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { basename, dirname, resolve } from "node:path";
|
|
12
12
|
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, nameWorkspace, RPC_ID } 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
|
|
@@ -180,7 +251,7 @@ async function announceBinding() {
|
|
|
180
251
|
try {
|
|
181
252
|
const resp = await postRemote({
|
|
182
253
|
jsonrpc: "2.0",
|
|
183
|
-
id:
|
|
254
|
+
id: RPC_ID.announce,
|
|
184
255
|
method: "tools/call",
|
|
185
256
|
params: { name: "whoami", arguments: {} },
|
|
186
257
|
});
|
|
@@ -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,21 @@ 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
|
+
// RTSC-825 — the folder this session runs in IS its workspace, and this process is
|
|
631
|
+
// the only thing that knows it. Sent on the WORKSPACE key (not the session child just
|
|
632
|
+
// adopted), so a folder bound before the CLI reported one, or renamed since, corrects
|
|
633
|
+
// itself the next time an agent starts. A no-op server-side when it already matches.
|
|
634
|
+
//
|
|
635
|
+
// The BOUND folder, not the cwd. The `auto` marker resolves this key by walking up
|
|
636
|
+
// from wherever the harness was started, so a session opened in `retasc/dash` runs on
|
|
637
|
+
// `retasc`'s key — naming it `dash` would be wrong, and the next session from the root
|
|
638
|
+
// would rename it back, flapping the row on every start. Empty for a key that came
|
|
639
|
+
// from the environment rather than the keystore: that one names no folder.
|
|
640
|
+
const BOUND_FOLDER = resolved.boundPath ? basename(resolved.boundPath) : "";
|
|
641
|
+
if (KEY && BOUND_FOLDER) {
|
|
642
|
+
nameWorkspace({ url: MCP_URL, key: KEY, workspace: BOUND_FOLDER, warn: log }).catch((e) => log(`name_workspace: ${String(e?.message ?? e)}`));
|
|
643
|
+
}
|
|
644
|
+
reportTranscript().catch((e) => log(`record_session: ${String(e?.message ?? e)}`));
|
|
551
645
|
const timer = setInterval(() => void heartbeatAll(), HEARTBEAT_MS);
|
|
552
646
|
timer.unref?.(); // the timer alone must not keep the process alive
|
|
553
647
|
const rl = createInterface({ input: process.stdin });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.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": {
|