@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 +14 -0
- package/dist/index.js +2 -0
- package/dist/lib/sessionHook.js +98 -3
- package/dist/proxy.js +31 -5
- package/package.json +1 -1
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
|
}
|
package/dist/lib/sessionHook.js
CHANGED
|
@@ -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.
|
|
168
|
-
*
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|