@retasc/cli 1.42.0 → 1.42.1

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,20 @@ 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.1 (2026-09-05)
10
+
11
+ - **RTSC-832** — the model now actually appears. 1.42.0 read it from the SessionStart
12
+ hook's `model` field, and Claude Code 2.1.261 does not send one: a captured payload is
13
+ `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `source` and nothing else.
14
+ The row therefore filled in only after a `/model` switch, which is backwards.
15
+ Claude Code does write the model on every assistant message in the transcript, so the
16
+ proxy reads it from there instead, and every session gets one.
17
+ - The read is bounded to the last 64KB: transcripts reach megabytes and this runs on a
18
+ throttle for the whole session. Reading backwards from the end also means a `/model`
19
+ switch is picked up by the same read, with or without the hook.
20
+ - Only the model STRING is ever sent. The transcript path stays on the machine that
21
+ wrote it, and nothing else from the file leaves it.
22
+
9
23
  ## 1.42.0 (2026-09-05)
10
24
 
11
25
  - **RTSC-821** — a session now reports WHICH MODEL ran it. The MCP handshake carries the
package/dist/index.js CHANGED
@@ -625,6 +625,8 @@ hook
625
625
  ...(parsed.model ? { model: parsed.model } : {}),
626
626
  ...(parsed.agentType ? { agentType: parsed.agentType } : {}),
627
627
  ...(parsed.agentId ? { agentId: parsed.agentId } : {}),
628
+ // RTSC-832 — the proxy reads the model out of this. Stays on this machine.
629
+ ...(parsed.transcriptPath ? { transcriptPath: parsed.transcriptPath } : {}),
628
630
  });
629
631
  }
630
632
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
@@ -105,8 +105,96 @@ export function readHookRecord(cwd, opts = {}) {
105
105
  ...(ident(rec.model, 60) ? { model: ident(rec.model, 60) } : {}),
106
106
  ...(ident(rec.agentType, 60) ? { agentType: ident(rec.agentType, 60) } : {}),
107
107
  ...(ident(rec.agentId, 100) ? { agentId: ident(rec.agentId, 100) } : {}),
108
+ ...(typeof rec.transcriptPath === "string" && rec.transcriptPath ? { transcriptPath: rec.transcriptPath } : {}),
108
109
  };
109
110
  }
111
+ /**
112
+ * The model the harness is CURRENTLY running, read out of the transcript it writes
113
+ * (RTSC-832).
114
+ *
115
+ * Claude Code never puts `model` in the SessionStart payload, so this is the only source
116
+ * that works for a session nobody has run `/model` in — which is most of them. Each
117
+ * assistant message in the `.jsonl` carries `message.model`, and the LAST one is the
118
+ * model in force now, so a `/model` switch is picked up by the same read.
119
+ *
120
+ * Reads the TAIL only. Transcripts reach 24MB in this repo, and this is called on a
121
+ * throttle for the life of a session; pulling the whole file each time would be a
122
+ * megabyte-scale read on the proxy's response path. The last chunk is where the newest
123
+ * message is, and a line straddling the chunk boundary is simply skipped as unparseable.
124
+ *
125
+ * Never throws: a missing, empty, truncated or half-written file just means no model
126
+ * this time, exactly as a harness that reports none.
127
+ */
128
+ const TRANSCRIPT_TAIL_BYTES = 64 * 1024;
129
+ /** The one retry, for a session whose newest message is bigger than the window. */
130
+ const TRANSCRIPT_WIDE_BYTES = 2 * 1024 * 1024;
131
+ export function modelFromTranscript(path, tailBytes = TRANSCRIPT_TAIL_BYTES) {
132
+ // A single assistant message can exceed the window: 563 lines over 64KB exist on this
133
+ // machine, the largest 1.18MB. The window then holds only that line's tail fragment,
134
+ // which is not parseable JSON, and the read would report nothing at all. So a scan
135
+ // that finds nothing widens once before giving up.
136
+ return scanTail(path, tailBytes) ?? (tailBytes < TRANSCRIPT_WIDE_BYTES ? scanTail(path, TRANSCRIPT_WIDE_BYTES) : undefined);
137
+ }
138
+ function scanTail(path, tailBytes) {
139
+ let fd;
140
+ try {
141
+ const st = statSync(path);
142
+ // isFile FIRST, and it is load-bearing: `openSync` on a FIFO with no writer blocks
143
+ // forever, and this runs on the proxy's synchronous response path. The size check
144
+ // happened to prevent that before; relying on that ordering was an accident.
145
+ if (!st.isFile() || !st.size)
146
+ return undefined;
147
+ const start = Math.max(0, st.size - tailBytes);
148
+ const len = st.size - start;
149
+ const buf = Buffer.alloc(len);
150
+ fd = openSync(path, "r");
151
+ // The harness is APPENDING to this file. A short read, or a truncation between the
152
+ // stat and the read, leaves the rest of the zero-filled buffer glued to the newest
153
+ // line, which then fails to parse and silently yields an older model. Only the bytes
154
+ // actually read are text.
155
+ const n = readSync(fd, buf, 0, len, start);
156
+ if (!n)
157
+ return undefined;
158
+ const lines = buf.subarray(0, n).toString("utf8").split("\n");
159
+ // Backwards: the newest assistant message wins, so a mid-session switch lands.
160
+ for (let i = lines.length - 1; i >= 0; i--) {
161
+ const line = lines[i].trim();
162
+ if (!line || line[0] !== "{")
163
+ continue;
164
+ try {
165
+ const j = JSON.parse(line);
166
+ // ONLY a top-level assistant turn. Measured across 40 real transcripts, MCP
167
+ // sampling records (`attributionMcpServer` / `attributionMcpTool`, 91-271 per
168
+ // file) also carry `message.model`, and so do sidechain entries — a subagent on
169
+ // a different model would otherwise be reported as the session's model, and each
170
+ // flip would spend one of the 40 lookups plus a server round trip.
171
+ // `type: "assistant"` alone is NOT enough: a sampling record wears it too.
172
+ if (j?.type !== "assistant")
173
+ continue;
174
+ if (j?.isSidechain || j?.attributionMcpServer || j?.attributionMcpTool)
175
+ continue;
176
+ const m = ident(j?.message?.model, 60);
177
+ if (m)
178
+ return m;
179
+ }
180
+ catch {
181
+ /* a truncated line, or the one straddling the chunk edge */
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ finally {
190
+ if (fd !== undefined) {
191
+ try {
192
+ closeSync(fd);
193
+ }
194
+ catch { /* nothing to do */ }
195
+ }
196
+ }
197
+ }
110
198
  /**
111
199
  * Rewrite the model on the record for this folder, in place (RTSC-821).
112
200
  *
@@ -164,8 +252,13 @@ function ident(raw, max) {
164
252
  /**
165
253
  * Parse what Claude Code hands a SessionStart hook on stdin. Verified against the hooks
166
254
  * reference (2026-09-04): `session_id`, `transcript_path`, `cwd`, `hook_event_name`,
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).
255
+ * `source`, and `model` / `agent_id` / `agent_type` when it has them.
256
+ *
257
+ * The transcript path IS kept (RTSC-832), reversing RTSC-820. That was dropped on
258
+ * RTSC-791's rule that a path bakes in a username, and the rule still holds: it governs
259
+ * what we SEND, and the path never leaves this machine. It is kept because 2.1.261 puts
260
+ * no `model` in this payload at all, so the transcript is the only place the model
261
+ * exists. See `HookRecord.transcriptPath`.
169
262
  *
170
263
  * `model` is absent from some payloads by design, so a parse that finds an id and no
171
264
  * model is a SUCCESS carrying less, never a failure.
@@ -188,6 +281,8 @@ export function parseClaudeHookInput(stdin) {
188
281
  ...(ident(j?.model, 60) ? { model: ident(j.model, 60) } : {}),
189
282
  ...(ident(j?.agent_type, 60) ? { agentType: ident(j.agent_type, 60) } : {}),
190
283
  ...(ident(j?.agent_id, 100) ? { agentId: ident(j.agent_id, 100) } : {}),
284
+ // Kept for the proxy to READ, never to send. See `HookRecord.transcriptPath`.
285
+ ...(typeof j?.transcript_path === "string" && j.transcript_path ? { transcriptPath: j.transcript_path } : {}),
191
286
  };
192
287
  }
193
288
  /**
package/dist/proxy.js CHANGED
@@ -14,7 +14,7 @@ import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, should
14
14
  import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
15
15
  import { toolResult as parseTool } from "./lib/toolresult.js";
16
16
  import { mintSessionKey, appendFallbackNotice, recordSession, nameWorkspace, RPC_ID } from "./lib/session.js";
17
- import { readHookRecord, clearHookRecord } from "./lib/sessionHook.js";
17
+ import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
18
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
19
19
  import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
20
20
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
@@ -62,6 +62,7 @@ let transcriptRecorded = false;
62
62
  // longer the end of this proxy's interest in the record.
63
63
  let reportedModel;
64
64
  let lastModelCheckAt = 0;
65
+ let pendingModel;
65
66
  const MODEL_CHECK_INTERVAL_MS = 3_000;
66
67
  let transcriptLookups = 0;
67
68
  let transcriptInFlight = null;
@@ -233,6 +234,24 @@ function ownRecordModel() {
233
234
  const rec = readHookRecord(process.cwd(), { maxAgeMs: Number.POSITIVE_INFINITY });
234
235
  if (!rec || rec.sessionId !== transcript.id)
235
236
  return undefined;
237
+ // RTSC-832 — the TRANSCRIPT is the authority, and the only source that works at all
238
+ // for a session nobody ran `/model` in: Claude Code 2.1.261 puts no `model` in the
239
+ // SessionStart payload (captured 2026-09-05), so `rec.model` is set only by the
240
+ // PostModelSwitch hook. The file has one on every assistant message, newest last,
241
+ // so reading it covers the plain case AND a switch. `rec.model` stays as the fallback
242
+ // for a harness that reports a model but writes no transcript we can read.
243
+ // The switch hook is FRESHER than the file: right after `/model`, `rec.model` already
244
+ // holds the new one while the newest assistant line still holds the old. Preferring
245
+ // the file here delayed a switch to the next turn and lost it entirely if the session
246
+ // ended first, which 1.42.0 got right. Once reported, this falls through and the file
247
+ // is authoritative again (it also catches a switch that produced no hook at all).
248
+ if (rec.model && rec.model !== reportedModel)
249
+ return rec.model;
250
+ if (rec.transcriptPath) {
251
+ const fromFile = modelFromTranscript(rec.transcriptPath);
252
+ if (fromFile)
253
+ return fromFile;
254
+ }
236
255
  return rec.model;
237
256
  }
238
257
  /**
@@ -252,8 +271,10 @@ function modelMayHaveChanged() {
252
271
  if (now - lastModelCheckAt < MODEL_CHECK_INTERVAL_MS)
253
272
  return false;
254
273
  lastModelCheckAt = now;
255
- const model = ownRecordModel();
256
- return !!model && model !== reportedModel;
274
+ // Cached for the reporter that runs next: this whole path is synchronous on the
275
+ // response path, and scanning the transcript twice per change tick is pure waste.
276
+ pendingModel = ownRecordModel();
277
+ return !!pendingModel && pendingModel !== reportedModel;
257
278
  }
258
279
  async function reportTranscriptOnce() {
259
280
  // One budget for looking AND for asking: a server that keeps refusing is not asked
@@ -263,7 +284,8 @@ async function reportTranscriptOnce() {
263
284
  transcriptLookups += 1;
264
285
  if (transcriptRecorded) {
265
286
  // Recorded already, so we are here only for a model change — and only for OURS.
266
- const model = ownRecordModel();
287
+ // `modelMayHaveChanged` just did this scan; reuse it rather than paying it twice.
288
+ const model = pendingModel ?? ownRecordModel();
267
289
  if (!model || model === reportedModel || !transcript)
268
290
  return;
269
291
  const switched = await recordSession({ url: MCP_URL, key: activeKey, transcriptId: transcript.id, harness: transcript.harness, model, warn: log });
@@ -293,7 +315,11 @@ async function reportTranscriptOnce() {
293
315
  // harness, not our record (a Codex pane opened in a folder a Claude hook wrote for).
294
316
  if (rec.harness === "claude-code" && clientName && !/claude/i.test(clientName))
295
317
  return;
296
- transcript = { id: rec.sessionId, harness: rec.harness, model: rec.model, agentType: rec.agentType, agentId: rec.agentId };
318
+ // RTSC-832 the transcript usually has no assistant message yet at this point, so
319
+ // this is normally undefined and the model arrives on a later re-report. It matters
320
+ // when the proxy comes up mid-session (a restart), where the file already has one.
321
+ const startModel = (rec.transcriptPath ? modelFromTranscript(rec.transcriptPath) : undefined) ?? rec.model;
322
+ transcript = { id: rec.sessionId, harness: rec.harness, model: startModel, agentType: rec.agentType, agentId: rec.agentId };
297
323
  }
298
324
  const ok = await recordSession({
299
325
  url: MCP_URL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.42.0",
3
+ "version": "1.42.1",
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": {