@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 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
- writeHookRecord({ harness: "claude-code", sessionId: parsed.sessionId, cwd: parsed.cwd, at: Date.now() });
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 })
@@ -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: { transcriptId: opts.transcriptId, ...(opts.harness ? { harness: opts.harness } : {}) },
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";
@@ -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
- return rec;
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`. Only the id and the cwd are kept; the path is derivable and would bake in a
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 { sessionId, cwd };
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), HOOK_MARKER].join(" ");
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.SessionStart) ? hooks.SessionStart : [];
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(HOOK_MARKER)) {
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: "startup|resume", hooks: [{ type: "command", command, timeout: 10 }] });
283
+ list.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command, timeout: 10 }] });
186
284
  outcome = "added";
187
285
  }
188
- hooks.SessionStart = list;
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 merged = mergeClaudeHook(before, claudeHookCommand(launcher));
226
- if (!merged)
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
- if (merged.outcome !== "unchanged") {
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, merged.text);
339
+ writeFileSync(path, both.text);
231
340
  }
232
- return { ok: true, path, outcome: merged.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 (transcriptRecorded || sessionKeyFallback || activeKey === KEY)
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({ url: MCP_URL, key: activeKey, transcriptId: transcript.id, harness: transcript.harness, warn: log });
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
- if (transcript.harness !== "grok")
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" && !transcriptRecorded) {
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.41.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": {