@retasc/cli 1.41.1 → 1.42.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 +19 -0
- package/dist/index.js +38 -2
- package/dist/lib/session.js +7 -1
- package/dist/lib/sessionHook.js +125 -16
- package/dist/proxy.js +88 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ 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.42.0 (2026-09-05)
|
|
10
|
+
|
|
11
|
+
- **RTSC-821** — a session now reports WHICH MODEL ran it. The MCP handshake carries the
|
|
12
|
+
client's name and version only, which is the CLI build and not the model, so this could
|
|
13
|
+
never have come from the connection: `retasc hook session-start` now passes the
|
|
14
|
+
SessionStart payload's `model` (plus `agent_type` and `agent_id` when the session is a
|
|
15
|
+
subagent) alongside the transcript id, and the Dash's session panel names it.
|
|
16
|
+
- A second hook, `retasc hook model-switch`, is wired on `PostModelSwitch`, so changing
|
|
17
|
+
model mid-conversation reaches the Dash and it can say what the session started on.
|
|
18
|
+
`retasc setup` wires both in one write. It deliberately does NOT touch the record's
|
|
19
|
+
timestamp: that is the window that stops an old record being adopted by a later session
|
|
20
|
+
in the same folder, and a switch an hour in must not make an hour-old record look new.
|
|
21
|
+
- The proxy keeps watching for a model change after the id is recorded, under the same
|
|
22
|
+
bounded budget it already had, so a session that switches model on every turn still
|
|
23
|
+
cannot make it talk to the server on every turn.
|
|
24
|
+
- Absent stays ordinary. Claude Code omits `model` from some payloads and Codex, Gemini,
|
|
25
|
+
Cursor and OpenCode have no session hook at all, so a junk or missing model is dropped
|
|
26
|
+
rather than raised, and never costs the transcript id it travelled with.
|
|
27
|
+
|
|
9
28
|
## 1.41.1 (2026-09-04)
|
|
10
29
|
|
|
11
30
|
- **RTSC-812** — no runtime change. Two comments in `cli/src` said project deletion did
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { findBindingByPath } from "./lib/keystore.js";
|
|
3
|
-
import { parseClaudeHookInput, writeHookRecord } from "./lib/sessionHook.js";
|
|
3
|
+
import { parseClaudeHookInput, parseClaudeModelSwitchInput, updateHookRecordModel, writeHookRecord } from "./lib/sessionHook.js";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { VERSION } from "./version.js";
|
|
6
6
|
import { selfCommand, versionStamp } from "./lib/launcher.js";
|
|
@@ -614,7 +614,18 @@ hook
|
|
|
614
614
|
chunks.push(c);
|
|
615
615
|
const parsed = parseClaudeHookInput(Buffer.concat(chunks).toString("utf8"));
|
|
616
616
|
if (parsed && findBindingByPath(parsed.cwd)) {
|
|
617
|
-
|
|
617
|
+
// RTSC-821 — model/agentType/agentId ride along when the payload has them.
|
|
618
|
+
// Claude Code omits `model` from some payloads, so the record simply carries
|
|
619
|
+
// less; the id is what makes the record worth writing.
|
|
620
|
+
writeHookRecord({
|
|
621
|
+
harness: "claude-code",
|
|
622
|
+
sessionId: parsed.sessionId,
|
|
623
|
+
cwd: parsed.cwd,
|
|
624
|
+
at: Date.now(),
|
|
625
|
+
...(parsed.model ? { model: parsed.model } : {}),
|
|
626
|
+
...(parsed.agentType ? { agentType: parsed.agentType } : {}),
|
|
627
|
+
...(parsed.agentId ? { agentId: parsed.agentId } : {}),
|
|
628
|
+
});
|
|
618
629
|
}
|
|
619
630
|
}
|
|
620
631
|
catch {
|
|
@@ -624,6 +635,31 @@ hook
|
|
|
624
635
|
process.exitCode = 0;
|
|
625
636
|
}
|
|
626
637
|
});
|
|
638
|
+
// RTSC-821 — `/model` mid-conversation. Rewrites the model on this folder's record and
|
|
639
|
+
// nothing else; the proxy notices and re-reports. Deliberately does NOT touch `at`: that
|
|
640
|
+
// is the window that stops an old record being adopted by a later session, and a switch
|
|
641
|
+
// an hour in must not make an hour-old record look new.
|
|
642
|
+
hook
|
|
643
|
+
.command("model-switch")
|
|
644
|
+
.description("Claude Code PostModelSwitch hook: update this session's model for the proxy.")
|
|
645
|
+
.action(async () => {
|
|
646
|
+
try {
|
|
647
|
+
if (process.stdin.isTTY)
|
|
648
|
+
return;
|
|
649
|
+
const chunks = [];
|
|
650
|
+
for await (const c of process.stdin)
|
|
651
|
+
chunks.push(c);
|
|
652
|
+
const parsed = parseClaudeModelSwitchInput(Buffer.concat(chunks).toString("utf8"));
|
|
653
|
+
if (parsed && findBindingByPath(parsed.cwd))
|
|
654
|
+
updateHookRecordModel(parsed.cwd, parsed.model, parsed.sessionId);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
/* never fail the harness's model switch */
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
process.exitCode = 0;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
627
663
|
// Top-level alias so harness config can spawn `retasc mcp-proxy`.
|
|
628
664
|
program
|
|
629
665
|
.command("mcp-proxy", { hidden: true })
|
package/dist/lib/session.js
CHANGED
|
@@ -157,7 +157,13 @@ export async function recordSession(opts) {
|
|
|
157
157
|
...opts,
|
|
158
158
|
id: RPC_ID.recordSession,
|
|
159
159
|
name: "record_session",
|
|
160
|
-
args: {
|
|
160
|
+
args: {
|
|
161
|
+
transcriptId: opts.transcriptId,
|
|
162
|
+
...(opts.harness ? { harness: opts.harness } : {}),
|
|
163
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
164
|
+
...(opts.agentType ? { agentType: opts.agentType } : {}),
|
|
165
|
+
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
|
166
|
+
},
|
|
161
167
|
expect: "transcriptId",
|
|
162
168
|
});
|
|
163
169
|
return out === "ok";
|
package/dist/lib/sessionHook.js
CHANGED
|
@@ -95,7 +95,52 @@ export function readHookRecord(cwd, opts = {}) {
|
|
|
95
95
|
return null;
|
|
96
96
|
if (opts.notAfter !== undefined && rec.at > opts.notAfter)
|
|
97
97
|
return null;
|
|
98
|
-
|
|
98
|
+
// RTSC-821 — a junk model must not take the record down with it: the id is the
|
|
99
|
+
// valuable part and the model is a bonus, so a bad one is dropped, not fatal.
|
|
100
|
+
return {
|
|
101
|
+
harness: rec.harness,
|
|
102
|
+
sessionId: rec.sessionId,
|
|
103
|
+
cwd: rec.cwd,
|
|
104
|
+
at: rec.at,
|
|
105
|
+
...(ident(rec.model, 60) ? { model: ident(rec.model, 60) } : {}),
|
|
106
|
+
...(ident(rec.agentType, 60) ? { agentType: ident(rec.agentType, 60) } : {}),
|
|
107
|
+
...(ident(rec.agentId, 100) ? { agentId: ident(rec.agentId, 100) } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Rewrite the model on the record for this folder, in place (RTSC-821).
|
|
112
|
+
*
|
|
113
|
+
* `at` is deliberately LEFT ALONE. It is the freshness window that stops a record
|
|
114
|
+
* written for an earlier session being adopted by a later one, and a `/model` switch
|
|
115
|
+
* an hour into a conversation must not make an hour-old record look new. A switch in a
|
|
116
|
+
* folder with no record is a no-op: there is no session here to describe.
|
|
117
|
+
*
|
|
118
|
+
* `sessionId` is REQUIRED to match when the record has one. The file is keyed by FOLDER
|
|
119
|
+
* (`hookFilePath` is a hash of the cwd, nothing else), so two Claude Code sessions open
|
|
120
|
+
* in one directory share it: without this check, resuming or switching in session B
|
|
121
|
+
* would rewrite the model that session A's still-live proxy is about to report, and A
|
|
122
|
+
* would stamp B's model onto its own key. `PostModelSwitch` also fires on `resume`,
|
|
123
|
+
* which makes that a routine event rather than a race you would have to try for.
|
|
124
|
+
*/
|
|
125
|
+
export function updateHookRecordModel(cwd, model, sessionId, dir = sessionsDir()) {
|
|
126
|
+
const path = hookFilePath(cwd, dir);
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(path))
|
|
129
|
+
return false;
|
|
130
|
+
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
131
|
+
if (!rec || typeof rec !== "object" || typeof rec.at !== "number")
|
|
132
|
+
return false;
|
|
133
|
+
// A payload with no session id cannot prove ownership, so it does not get to write.
|
|
134
|
+
if (!sessionId || rec.sessionId !== sessionId)
|
|
135
|
+
return false;
|
|
136
|
+
if (rec.model === model)
|
|
137
|
+
return false;
|
|
138
|
+
writeFileSync(path, JSON.stringify({ ...rec, model }), { mode: 0o600 });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
99
144
|
}
|
|
100
145
|
/** Consume a record once it has been reported, so no later session can adopt it. */
|
|
101
146
|
export function clearHookRecord(cwd, dir = sessionsDir()) {
|
|
@@ -106,11 +151,24 @@ export function clearHookRecord(cwd, dir = sessionsDir()) {
|
|
|
106
151
|
/* already gone */
|
|
107
152
|
}
|
|
108
153
|
}
|
|
154
|
+
/** The shape the server accepts, checked here so a junk value never leaves the machine
|
|
155
|
+
* (RTSC-821). Same charset as the transcript id: the Dash renders these. */
|
|
156
|
+
function ident(raw, max) {
|
|
157
|
+
if (typeof raw !== "string")
|
|
158
|
+
return undefined;
|
|
159
|
+
const t = raw.trim();
|
|
160
|
+
if (!t)
|
|
161
|
+
return undefined;
|
|
162
|
+
return new RegExp(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,${max - 1}}$`).test(t) ? t : undefined;
|
|
163
|
+
}
|
|
109
164
|
/**
|
|
110
165
|
* Parse what Claude Code hands a SessionStart hook on stdin. Verified against the hooks
|
|
111
166
|
* reference (2026-09-04): `session_id`, `transcript_path`, `cwd`, `hook_event_name`,
|
|
112
|
-
* `source
|
|
113
|
-
* username (RTSC-791).
|
|
167
|
+
* `source`, and `model` / `agent_id` / `agent_type` when it has them. The transcript
|
|
168
|
+
* path is NOT kept: it is derivable and would bake in a username (RTSC-791).
|
|
169
|
+
*
|
|
170
|
+
* `model` is absent from some payloads by design, so a parse that finds an id and no
|
|
171
|
+
* model is a SUCCESS carrying less, never a failure.
|
|
114
172
|
*/
|
|
115
173
|
export function parseClaudeHookInput(stdin) {
|
|
116
174
|
let j;
|
|
@@ -124,10 +182,45 @@ export function parseClaudeHookInput(stdin) {
|
|
|
124
182
|
const cwd = typeof j?.cwd === "string" && j.cwd ? j.cwd : process.cwd();
|
|
125
183
|
if (!sessionId)
|
|
126
184
|
return null;
|
|
127
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
sessionId,
|
|
187
|
+
cwd,
|
|
188
|
+
...(ident(j?.model, 60) ? { model: ident(j.model, 60) } : {}),
|
|
189
|
+
...(ident(j?.agent_type, 60) ? { agentType: ident(j.agent_type, 60) } : {}),
|
|
190
|
+
...(ident(j?.agent_id, 100) ? { agentId: ident(j.agent_id, 100) } : {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Parse a `PostModelSwitch` payload (RTSC-821): `to_model` is the model from now on.
|
|
195
|
+
* `cwd` is the folder, as with SessionStart. `session_id` is present but deliberately
|
|
196
|
+
* NOT used to re-key the record: the switch updates the record for THIS folder in
|
|
197
|
+
* place, and re-arming it is exactly what must not happen (see `updateHookRecordModel`).
|
|
198
|
+
*/
|
|
199
|
+
export function parseClaudeModelSwitchInput(stdin) {
|
|
200
|
+
let j;
|
|
201
|
+
try {
|
|
202
|
+
j = JSON.parse(stdin);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const model = ident(j?.to_model, 60);
|
|
208
|
+
if (!model)
|
|
209
|
+
return null;
|
|
210
|
+
return {
|
|
211
|
+
model,
|
|
212
|
+
cwd: typeof j?.cwd === "string" && j.cwd ? j.cwd : process.cwd(),
|
|
213
|
+
...(ident(j?.session_id, 200) ? { sessionId: ident(j.session_id, 200) } : {}),
|
|
214
|
+
};
|
|
128
215
|
}
|
|
129
216
|
// --- wiring the Claude Code hook ---------------------------------------------------
|
|
130
217
|
export const HOOK_MARKER = "hook session-start";
|
|
218
|
+
/** RTSC-821 — the second hook. It must be a marker `HOOK_MARKER` cannot match and that
|
|
219
|
+
* cannot match `HOOK_MARKER`, or idempotence would see one entry as the other and
|
|
220
|
+
* rewrite a SessionStart hook into a model-switch one on the next `retasc setup`.
|
|
221
|
+
* "hook session-start" and "hook model-switch" share no substring, which is the
|
|
222
|
+
* property being relied on; a future third hook must keep it. */
|
|
223
|
+
export const MODEL_HOOK_MARKER = "hook model-switch";
|
|
131
224
|
/** A shell word: bare when it is plainly one, single-quoted otherwise. Claude Code runs
|
|
132
225
|
* hook commands through a shell, and an absolute launcher path can carry a space
|
|
133
226
|
* (`/Users/John Doe/...`); unquoted, the hook would fail on every session start and
|
|
@@ -138,8 +231,8 @@ export function shellWord(part) {
|
|
|
138
231
|
/** The command the hook runs: the same launcher the MCP marker names, so an `npx`
|
|
139
232
|
* install and a global one both resolve. The marker verb stays bare, so idempotence
|
|
140
233
|
* can find the entry by it. */
|
|
141
|
-
export function claudeHookCommand(launcher) {
|
|
142
|
-
return [...[launcher.command, ...launcher.args].map(shellWord),
|
|
234
|
+
export function claudeHookCommand(launcher, marker = HOOK_MARKER) {
|
|
235
|
+
return [...[launcher.command, ...launcher.args].map(shellWord), marker].join(" ");
|
|
143
236
|
}
|
|
144
237
|
/**
|
|
145
238
|
* Merge our SessionStart hook into a Claude Code `settings.json` text, and say what
|
|
@@ -148,7 +241,12 @@ export function claudeHookCommand(launcher) {
|
|
|
148
241
|
* other hook, and every other setting, survives untouched. Returns null when the file
|
|
149
242
|
* is not JSON we can read, rather than replacing something we cannot parse.
|
|
150
243
|
*/
|
|
151
|
-
export function mergeClaudeHook(text, command) {
|
|
244
|
+
export function mergeClaudeHook(text, command, opts = {}) {
|
|
245
|
+
const event = opts.event ?? "SessionStart";
|
|
246
|
+
const marker = opts.marker ?? HOOK_MARKER;
|
|
247
|
+
// SessionStart is matched on the session's SOURCE; PostModelSwitch has no source to
|
|
248
|
+
// match, and an empty matcher there means every switch (RTSC-821).
|
|
249
|
+
const matcher = opts.matcher ?? "startup|resume";
|
|
152
250
|
let settings = {};
|
|
153
251
|
if (text && text.trim()) {
|
|
154
252
|
try {
|
|
@@ -161,7 +259,7 @@ export function mergeClaudeHook(text, command) {
|
|
|
161
259
|
return null;
|
|
162
260
|
}
|
|
163
261
|
const hooks = (settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {});
|
|
164
|
-
const list = Array.isArray(hooks
|
|
262
|
+
const list = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
165
263
|
let outcome = "unchanged";
|
|
166
264
|
let found = false;
|
|
167
265
|
for (const entry of list) {
|
|
@@ -169,7 +267,7 @@ export function mergeClaudeHook(text, command) {
|
|
|
169
267
|
if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks))
|
|
170
268
|
continue;
|
|
171
269
|
for (const h of entry.hooks) {
|
|
172
|
-
if (h && typeof h === "object" && h.type === "command" && typeof h.command === "string" && h.command.includes(
|
|
270
|
+
if (h && typeof h === "object" && h.type === "command" && typeof h.command === "string" && h.command.includes(marker)) {
|
|
173
271
|
found = true;
|
|
174
272
|
if (h.command !== command) {
|
|
175
273
|
h.command = command;
|
|
@@ -182,10 +280,10 @@ export function mergeClaudeHook(text, command) {
|
|
|
182
280
|
// `startup|resume` only: a compaction or a /clear re-fires SessionStart with the
|
|
183
281
|
// same id and would re-arm the record for a session already served. `timeout` in
|
|
184
282
|
// seconds: a hook that hangs (a cold npx offline) must never hold a session start.
|
|
185
|
-
list.push({ matcher:
|
|
283
|
+
list.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command, timeout: 10 }] });
|
|
186
284
|
outcome = "added";
|
|
187
285
|
}
|
|
188
|
-
hooks
|
|
286
|
+
hooks[event] = list;
|
|
189
287
|
settings.hooks = hooks;
|
|
190
288
|
return { text: JSON.stringify(settings, null, 2) + "\n", outcome };
|
|
191
289
|
}
|
|
@@ -222,12 +320,23 @@ export function claudeSettingsPath(home = process.env.RETASC_HOME || homedir())
|
|
|
222
320
|
export function wireClaudeHook(launcher, home) {
|
|
223
321
|
const path = claudeSettingsPath(home);
|
|
224
322
|
const before = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
225
|
-
const
|
|
226
|
-
if (!
|
|
323
|
+
const start = mergeClaudeHook(before, claudeHookCommand(launcher));
|
|
324
|
+
if (!start)
|
|
325
|
+
return { ok: false, reason: `${path} is not JSON I can read; add the hook by hand` };
|
|
326
|
+
// RTSC-821 — the model-switch hook is merged into the SAME text, so one write lands
|
|
327
|
+
// both and a failure between them cannot leave a half-wired machine.
|
|
328
|
+
const both = mergeClaudeHook(start.text, claudeHookCommand(launcher, MODEL_HOOK_MARKER), {
|
|
329
|
+
event: "PostModelSwitch",
|
|
330
|
+
marker: MODEL_HOOK_MARKER,
|
|
331
|
+
matcher: "",
|
|
332
|
+
});
|
|
333
|
+
if (!both)
|
|
227
334
|
return { ok: false, reason: `${path} is not JSON I can read; add the hook by hand` };
|
|
228
|
-
|
|
335
|
+
// Either hook changing is a change worth writing; "unchanged" only when both agree.
|
|
336
|
+
const outcome = start.outcome !== "unchanged" ? start.outcome : both.outcome;
|
|
337
|
+
if (outcome !== "unchanged") {
|
|
229
338
|
mkdirSync(dirname(path), { recursive: true });
|
|
230
|
-
writeFileSync(path,
|
|
339
|
+
writeFileSync(path, both.text);
|
|
231
340
|
}
|
|
232
|
-
return { ok: true, path, outcome
|
|
341
|
+
return { ok: true, path, outcome };
|
|
233
342
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -57,6 +57,12 @@ const PROXY_STARTED_AT = Date.now();
|
|
|
57
57
|
const TRANSCRIPT_LOOKUPS_MAX = 40;
|
|
58
58
|
let transcript = process.env.GROK_SESSION_ID ? { id: process.env.GROK_SESSION_ID, harness: "grok" } : null;
|
|
59
59
|
let transcriptRecorded = false;
|
|
60
|
+
// RTSC-821 — the model we last told the server. A `/model` mid-session rewrites the
|
|
61
|
+
// hook record, and re-reporting is how that reaches the Dash, so recording the id is no
|
|
62
|
+
// longer the end of this proxy's interest in the record.
|
|
63
|
+
let reportedModel;
|
|
64
|
+
let lastModelCheckAt = 0;
|
|
65
|
+
const MODEL_CHECK_INTERVAL_MS = 3_000;
|
|
60
66
|
let transcriptLookups = 0;
|
|
61
67
|
let transcriptInFlight = null;
|
|
62
68
|
// The name the client gave at `initialize` (RTSC-717), so a Codex session started in a
|
|
@@ -198,7 +204,11 @@ async function adoptSessionKey() {
|
|
|
198
204
|
* wired no hook simply never learns its id, and the Dash says so.
|
|
199
205
|
*/
|
|
200
206
|
async function reportTranscript() {
|
|
201
|
-
if (
|
|
207
|
+
if (sessionKeyFallback || activeKey === KEY)
|
|
208
|
+
return;
|
|
209
|
+
// Once the id is recorded there is still one reason to come back: the model changed.
|
|
210
|
+
// Grok reports no model at all, so a grok session stops here exactly as before.
|
|
211
|
+
if (transcriptRecorded && (transcript?.harness === "grok" || !modelMayHaveChanged()))
|
|
202
212
|
return;
|
|
203
213
|
// Startup and the first forwarded call can both get here; one report at a time.
|
|
204
214
|
if (transcriptInFlight)
|
|
@@ -206,12 +216,63 @@ async function reportTranscript() {
|
|
|
206
216
|
transcriptInFlight = reportTranscriptOnce().finally(() => { transcriptInFlight = null; });
|
|
207
217
|
return transcriptInFlight;
|
|
208
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The record for this folder, but only if it is still describing OUR session (RTSC-821).
|
|
221
|
+
*
|
|
222
|
+
* The file is keyed by FOLDER alone, so a second session opened in the same directory
|
|
223
|
+
* overwrites it with its own id and model. `at` cannot separate them here: a switch
|
|
224
|
+
* deliberately leaves it untouched, so freshness says nothing on this path. The session
|
|
225
|
+
* id is the only thing that does, and it is what the discovery path already matched on.
|
|
226
|
+
*/
|
|
227
|
+
function ownRecordModel() {
|
|
228
|
+
if (!transcript)
|
|
229
|
+
return undefined;
|
|
230
|
+
// `maxAgeMs: Infinity` on purpose: a switch hours into a long conversation leaves `at`
|
|
231
|
+
// where it was, so re-checking freshness would make exactly the case this exists for
|
|
232
|
+
// invisible. The id check below is what makes dropping it safe.
|
|
233
|
+
const rec = readHookRecord(process.cwd(), { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
234
|
+
if (!rec || rec.sessionId !== transcript.id)
|
|
235
|
+
return undefined;
|
|
236
|
+
return rec.model;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Has the hook rewritten our record's model since we last reported one? (RTSC-821.)
|
|
240
|
+
*
|
|
241
|
+
* Throttled in TIME, not by the lookup budget: the budget is only spent when there is
|
|
242
|
+
* something to report, so in the steady state (the model never changes) nothing would
|
|
243
|
+
* ever increment it and every forwarded tool call would pay a realpath + hash + read +
|
|
244
|
+
* parse on the response path. A switch is a human pressing keys; three seconds of
|
|
245
|
+
* latency on noticing one is free, and a busy session makes hundreds of calls in that
|
|
246
|
+
* window.
|
|
247
|
+
*/
|
|
248
|
+
function modelMayHaveChanged() {
|
|
249
|
+
if (transcriptLookups >= TRANSCRIPT_LOOKUPS_MAX)
|
|
250
|
+
return false;
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
if (now - lastModelCheckAt < MODEL_CHECK_INTERVAL_MS)
|
|
253
|
+
return false;
|
|
254
|
+
lastModelCheckAt = now;
|
|
255
|
+
const model = ownRecordModel();
|
|
256
|
+
return !!model && model !== reportedModel;
|
|
257
|
+
}
|
|
209
258
|
async function reportTranscriptOnce() {
|
|
210
259
|
// One budget for looking AND for asking: a server that keeps refusing is not asked
|
|
211
260
|
// on every call for the rest of the session either.
|
|
212
261
|
if (transcriptLookups >= TRANSCRIPT_LOOKUPS_MAX)
|
|
213
262
|
return;
|
|
214
263
|
transcriptLookups += 1;
|
|
264
|
+
if (transcriptRecorded) {
|
|
265
|
+
// Recorded already, so we are here only for a model change — and only for OURS.
|
|
266
|
+
const model = ownRecordModel();
|
|
267
|
+
if (!model || model === reportedModel || !transcript)
|
|
268
|
+
return;
|
|
269
|
+
const switched = await recordSession({ url: MCP_URL, key: activeKey, transcriptId: transcript.id, harness: transcript.harness, model, warn: log });
|
|
270
|
+
if (switched) {
|
|
271
|
+
reportedModel = model;
|
|
272
|
+
log(`session model is now ${model}`);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
215
276
|
if (!transcript) {
|
|
216
277
|
const now = Date.now();
|
|
217
278
|
if (now - PROXY_STARTED_AT > RECORD_SEARCH_MS) {
|
|
@@ -232,14 +293,34 @@ async function reportTranscriptOnce() {
|
|
|
232
293
|
// harness, not our record (a Codex pane opened in a folder a Claude hook wrote for).
|
|
233
294
|
if (rec.harness === "claude-code" && clientName && !/claude/i.test(clientName))
|
|
234
295
|
return;
|
|
235
|
-
transcript = { id: rec.sessionId, harness: rec.harness };
|
|
236
|
-
}
|
|
237
|
-
const ok = await recordSession({
|
|
296
|
+
transcript = { id: rec.sessionId, harness: rec.harness, model: rec.model, agentType: rec.agentType, agentId: rec.agentId };
|
|
297
|
+
}
|
|
298
|
+
const ok = await recordSession({
|
|
299
|
+
url: MCP_URL,
|
|
300
|
+
key: activeKey,
|
|
301
|
+
transcriptId: transcript.id,
|
|
302
|
+
harness: transcript.harness,
|
|
303
|
+
model: transcript.model,
|
|
304
|
+
agentType: transcript.agentType,
|
|
305
|
+
agentId: transcript.agentId,
|
|
306
|
+
warn: log,
|
|
307
|
+
});
|
|
238
308
|
if (ok) {
|
|
239
309
|
transcriptRecorded = true;
|
|
240
|
-
|
|
310
|
+
reportedModel = transcript.model;
|
|
311
|
+
// RTSC-821 — claude-code's record is NOT consumed any more, whether or not it came
|
|
312
|
+
// with a model. Clearing it made `retasc hook model-switch` a permanent no-op for
|
|
313
|
+
// that folder (`updateHookRecordModel` returns early when the file is gone), and the
|
|
314
|
+
// session most in need of the switch hook is precisely the one whose SessionStart
|
|
315
|
+
// payload carried NO model — which Claude Code omits after a /clear and on recovery.
|
|
316
|
+
//
|
|
317
|
+
// Nothing adopts it in the meantime: the discovery path still checks `notAfter` and
|
|
318
|
+
// `maxAge`, and every re-read goes through `ownRecordModel`, which matches the
|
|
319
|
+
// session id. The next SessionStart in this folder overwrites the file, so it never
|
|
320
|
+
// accumulates, and an abandoned one is collected by the 24h sweep.
|
|
321
|
+
if (transcript.harness !== "grok" && transcript.harness !== "claude-code")
|
|
241
322
|
clearHookRecord(process.cwd());
|
|
242
|
-
log(`session recorded as ${transcript.harness} transcript ${transcript.id}`);
|
|
323
|
+
log(`session recorded as ${transcript.harness} transcript ${transcript.id}${transcript.model ? ` on ${transcript.model}` : ""}`);
|
|
243
324
|
}
|
|
244
325
|
}
|
|
245
326
|
/**
|
|
@@ -551,7 +632,7 @@ async function handleLine(line) {
|
|
|
551
632
|
// flag the workspace-key fallback on whoami so the AGENT sees the degraded
|
|
552
633
|
// state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
|
|
553
634
|
let reapId;
|
|
554
|
-
if (msg.method === "tools/call"
|
|
635
|
+
if (msg.method === "tools/call") {
|
|
555
636
|
reportTranscript().catch((e) => log(`record_session: ${String(e?.message ?? e)}`));
|
|
556
637
|
}
|
|
557
638
|
if (msg.method === "tools/call" && resp) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.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": {
|