@retasc/cli 1.2.0 → 1.2.2

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.
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename, dirname, resolve, sep } from "node:path";
4
- import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, } from "../lib/claim.js";
4
+ import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, workspacePrefix, } from "../lib/claim.js";
5
+ import { loadConfig } from "../config.js";
5
6
  function git(args, cwd) {
6
7
  return spawnSync("git", args, { encoding: "utf8", cwd });
7
8
  }
@@ -28,6 +29,50 @@ export async function claimAction(opts) {
28
29
  note(" (Looked at $RETASC_MCP_KEY and ./.mcp.json.)");
29
30
  process.exit(1);
30
31
  }
32
+ // RTSC-148: resolve the requested issue id up front — a positional (`claim 143`,
33
+ // `claim RTSC-143`) or --id, normalized to PREFIX-NN. An unresolvable reference
34
+ // fails HERE; it must never fall through to "claim whatever is next".
35
+ // A bare number expands against the WORKSPACE's prefix (whoami over this
36
+ // workspace's key — the key decides the org/project), falling back to the
37
+ // global config only if the server can't be asked.
38
+ let prefix;
39
+ let prefixResolved = false;
40
+ let requestedId;
41
+ for (const [label, raw] of [
42
+ ["issue", opts.issueArg],
43
+ ["--id", opts.id],
44
+ ]) {
45
+ // Only ABSENT skips — an empty string (e.g. `claim "$UNSET_VAR"`) must hit
46
+ // the loud-failure path below, not fall through to next_issue.
47
+ if (raw === undefined)
48
+ continue;
49
+ if (/^\d+$/.test(raw.trim()) && !prefixResolved) {
50
+ prefix = (await workspacePrefix(conn)) ?? loadConfig().defaultProjectPrefix;
51
+ prefixResolved = true;
52
+ }
53
+ const norm = normalizeIssueId(raw, prefix);
54
+ if (!norm) {
55
+ if (/^\d+$/.test(raw.trim()) && !prefix) {
56
+ note(`✗ "${raw}" is a bare number and the project prefix couldn't be resolved from this workspace.`);
57
+ note(" Use the full id (e.g. RTSC-" + raw.trim() + ").");
58
+ }
59
+ else {
60
+ note(`✗ "${raw}" doesn't look like an issue id (expected RTSC-NN or a bare number).`);
61
+ }
62
+ process.exit(1);
63
+ }
64
+ if (requestedId && requestedId !== norm) {
65
+ note(`✗ Conflicting issue ids: positional "${requestedId}" vs ${label} "${norm}". Pass just one.`);
66
+ process.exit(1);
67
+ }
68
+ requestedId = norm;
69
+ }
70
+ // --mine filters the next_issue pull; on a targeted claim it would be
71
+ // silently ignored — reject instead of letting the intent evaporate.
72
+ if (requestedId && opts.mine) {
73
+ note(`✗ --mine only applies when pulling the next issue; drop it to claim ${requestedId} directly.`);
74
+ process.exit(1);
75
+ }
31
76
  const makeWorktree = opts.worktree !== false;
32
77
  // Must be in a git repo to place the worktree. Resolve the *main* checkout so
33
78
  // worktrees are always siblings of it, even when claiming from another worktree.
@@ -52,8 +97,8 @@ export async function claimAction(opts) {
52
97
  // --- claim over MCP ------------------------------------------------------
53
98
  let claim;
54
99
  try {
55
- const result = opts.id
56
- ? await mcpCall(conn, "claim_issue", { identifier: opts.id })
100
+ const result = requestedId
101
+ ? await mcpCall(conn, "claim_issue", { identifier: requestedId })
57
102
  : await mcpCall(conn, "next_issue", opts.mine ? { mine: true } : {});
58
103
  claim = parseClaimResult(result);
59
104
  }
@@ -62,9 +107,9 @@ export async function claimAction(opts) {
62
107
  note(`✗ ${msg}`);
63
108
  // Re-claiming an issue you already hold is the "resume my own work" case —
64
109
  // point at the worktree instead of leaving the user stuck on the error.
65
- if (opts.id && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
66
- note(` ${opts.id} is held. If it's your session, resume in ${parentDir}/${repoName}-${opts.id.toLowerCase()}`);
67
- note(` or \`retasc release ${opts.id}\` first.`);
110
+ if (requestedId && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
111
+ note(` ${requestedId} is held. If it's your session, resume in ${parentDir}/${repoName}-${requestedId.toLowerCase()}`);
112
+ note(` or \`retasc release ${requestedId}\` first.`);
68
113
  }
69
114
  process.exit(1);
70
115
  }
package/dist/index.js CHANGED
@@ -414,12 +414,19 @@ function addClaimFlags(cmd) {
414
414
  .option("--print-path", "Print only the worktree path on stdout (for `cd \"$(…)\"`)")
415
415
  .option("--json", "Emit the claim + worktree as JSON");
416
416
  }
417
+ // RTSC-148: `claim` takes the issue as a positional too (`retasc claim 143` /
418
+ // `retasc claim RTSC-143`). Excess arguments are rejected loudly on both
419
+ // commands — Commander otherwise silently drops an undeclared positional, which
420
+ // turned "claim this specific issue" into "claim whatever is next".
417
421
  addClaimFlags(program
418
422
  .command("claim")
419
- .description("Claim an issue (--id, or the next unblocked) and drop into a fresh worktree.")).action((opts) => claimAction(opts).catch(fail));
423
+ .description("Claim an issue (RTSC-NN, a bare number, --id, or the next unblocked) and drop into a fresh worktree.")
424
+ .argument("[issue]", "Issue to claim, e.g. RTSC-143 or 143 (defaults to the next unblocked)")
425
+ .allowExcessArguments(false)).action((issue, opts) => claimAction({ ...opts, issueArg: issue }).catch(fail));
420
426
  addClaimFlags(program
421
427
  .command("next")
422
- .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")).action((opts) => claimAction(opts).catch(fail));
428
+ .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")
429
+ .allowExcessArguments(false)).action((opts) => claimAction(opts).catch(fail));
423
430
  // --- branch hygiene (close the worktree+branch claim opened) ----------------
424
431
  program
425
432
  .command("tidy")
package/dist/lib/claim.js CHANGED
@@ -5,6 +5,10 @@
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { resolveConn } from "./keystore.js";
8
+ // Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
9
+ // re-exported so existing importers of this module keep working.
10
+ import { toolResult } from "./toolresult.js";
11
+ export { toolResult };
8
12
  const DEFAULT_MCP_URL = "https://mcp.retasc.com/mcp";
9
13
  /**
10
14
  * Resolve the agent API key + MCP URL the same way the workspace's MCP wiring does,
@@ -56,6 +60,36 @@ export function slugFromTitle(title, max = 50) {
56
60
  export function isValidIssueId(id) {
57
61
  return typeof id === "string" && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(id);
58
62
  }
63
+ /**
64
+ * Normalize a user-typed issue reference (`143`, `rtsc-143`, `RTSC-143`) to the
65
+ * canonical `PREFIX-NN` form. A bare number needs `defaultPrefix` (from the
66
+ * logged-in project) to be resolvable. Returns null when the input can't be an
67
+ * issue id — the caller decides how to fail loudly (RTSC-148: a typo must never
68
+ * silently fall through to `next_issue`).
69
+ */
70
+ /**
71
+ * The workspace's project prefix, asked of the server over the workspace's own
72
+ * key (`whoami`). Org/project routing IS the key — the global config's prefix
73
+ * may belong to a different workspace, so this is the authoritative source for
74
+ * expanding a bare issue number. Best-effort: undefined on any failure.
75
+ */
76
+ export async function workspacePrefix(conn, fetchImpl = fetch) {
77
+ try {
78
+ const who = (await mcpCall(conn, "whoami", {}, fetchImpl));
79
+ const p = who?.project?.prefix;
80
+ return typeof p === "string" && p ? p : undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ export function normalizeIssueId(input, defaultPrefix) {
87
+ const s = input.trim();
88
+ if (/^\d+$/.test(s)) {
89
+ return defaultPrefix ? `${defaultPrefix.toUpperCase()}-${s}` : null;
90
+ }
91
+ return isValidIssueId(s) ? s.toUpperCase() : null;
92
+ }
59
93
  /**
60
94
  * Derive the branch + sibling worktree path from an issue, matching the CLAUDE.md
61
95
  * convention: branch `rtsc-NN/<slug>`, worktree dir `../<repo>-rtsc-NN`.
@@ -84,16 +118,6 @@ export function parseClaimResult(result) {
84
118
  const title = typeof r.issue === "object" ? r.issue?.title : undefined;
85
119
  return { issueId, title, claimToken: r.claimToken };
86
120
  }
87
- /** The tool payload (JSON inside result.content[0].text), or the raw result. */
88
- export function toolResult(resp) {
89
- try {
90
- const t = resp?.result?.content?.[0]?.text;
91
- return t ? JSON.parse(t) : resp?.result;
92
- }
93
- catch {
94
- return resp?.result;
95
- }
96
- }
97
121
  /**
98
122
  * Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
99
123
  * and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
@@ -0,0 +1,93 @@
1
+ // Session-key adoption core (RTSC-143) — the mint call the proxy runs on startup
2
+ // (RTSC-50), extracted here so the retry + failure behavior is unit-testable with
3
+ // a stubbed fetch. Fail-soft stands: a broken mint must never take the MCP down —
4
+ // but the fallback must be LOUD, because on the shared workspace key concurrent
5
+ // sessions collapse into one server-side identity and the per-session claim fence
6
+ // (RTSC-49/50) is silently inert.
7
+ import { toolResult } from "./toolresult.js";
8
+ // Out-of-band ids are negative so they never collide with the harness's (the
9
+ // same convention as the proxy's heartbeat ids; announceBinding uses -1001).
10
+ const MINT_RPC_ID = -1000;
11
+ // The mint runs BEFORE the proxy serves `initialize`, so a hung server must not
12
+ // stall startup past the harness's MCP timeout — that would turn "degraded
13
+ // identity" into "no Retasc tools at all", the exact failure fail-soft exists
14
+ // to avoid. Two attempts fit comfortably inside a ~30s harness budget.
15
+ const MINT_TIMEOUT_MS = 5_000;
16
+ const FALLBACK_CONSEQUENCE = "concurrent sessions will be indistinguishable to the server and the " +
17
+ "per-session claim fence is OFF for this session. Restart the session to retry.";
18
+ /**
19
+ * Mint a per-session key, retrying once (a startup network blip must not demote
20
+ * the whole session's identity). Never throws. On double failure, emits an
21
+ * unmissable multi-line warning stating the consequence and returns {ok:false}
22
+ * so the caller can keep flagging the degraded state (e.g. on whoami).
23
+ */
24
+ export async function mintSessionKey(opts) {
25
+ const fetchImpl = opts.fetchImpl ?? fetch;
26
+ const warn = opts.warn ?? ((msg) => process.stderr.write(`[retasc] ${msg}\n`));
27
+ const retryDelayMs = opts.retryDelayMs ?? 500;
28
+ for (let attempt = 1; attempt <= 2; attempt++) {
29
+ try {
30
+ const res = await fetchImpl(opts.url, {
31
+ method: "POST",
32
+ headers: {
33
+ Authorization: `Bearer ${opts.key}`,
34
+ "Content-Type": "application/json",
35
+ Accept: "application/json",
36
+ },
37
+ body: JSON.stringify({
38
+ jsonrpc: "2.0",
39
+ id: MINT_RPC_ID,
40
+ method: "tools/call",
41
+ params: { name: "mint_session_key", arguments: { label: opts.label } },
42
+ }),
43
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
44
+ });
45
+ const text = await res.text();
46
+ // A non-2xx (revoked key → 401, server error → 5xx) must not masquerade
47
+ // as "no key in response" — surface the status so a dead key is obvious.
48
+ if (!res.ok) {
49
+ const snippet = text ? `: ${text.slice(0, 120).replace(/\s+/g, " ").trim()}` : "";
50
+ warn(`mint_session_key attempt ${attempt} failed (HTTP ${res.status})${snippet}`);
51
+ }
52
+ else {
53
+ const r = toolResult(text ? JSON.parse(text) : null, warn);
54
+ if (r && typeof r === "object" && typeof r.key === "string") {
55
+ return { ok: true, key: r.key, session: r.session };
56
+ }
57
+ warn(`mint_session_key attempt ${attempt} returned no key`);
58
+ }
59
+ }
60
+ catch (e) {
61
+ warn(`mint_session_key attempt ${attempt} failed: ${String(e?.message ?? e)}`);
62
+ }
63
+ if (attempt === 1 && retryDelayMs > 0) {
64
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
65
+ }
66
+ }
67
+ warn("WARNING: could not mint a per-session key (retried once).");
68
+ warn(`WARNING: continuing on the SHARED WORKSPACE KEY — ${FALLBACK_CONSEQUENCE}`);
69
+ return { ok: false };
70
+ }
71
+ /**
72
+ * The warning content block appended to whoami responses when the session is
73
+ * running on the workspace-key fallback — so the AGENT sees the degraded state,
74
+ * not just whoever reads the MCP log files.
75
+ */
76
+ export const MINT_FALLBACK_NOTICE = "⚠ Retasc watchdog: this session could not mint a per-session key and is using " +
77
+ `the shared workspace key — ${FALLBACK_CONSEQUENCE}`;
78
+ /**
79
+ * Append MINT_FALLBACK_NOTICE to a whoami response when degraded. Defensive on
80
+ * purpose: anything unexpected about the shape (error response, missing/non-array
81
+ * content) leaves the response untouched — a malformed notice must never break
82
+ * the protocol stream this rides on.
83
+ */
84
+ export function appendFallbackNotice(toolName, resp, degraded) {
85
+ if (!degraded || toolName !== "whoami")
86
+ return;
87
+ const result = resp?.result;
88
+ if (result?.isError)
89
+ return; // don't decorate an error response
90
+ if (Array.isArray(result?.content)) {
91
+ result.content.push({ type: "text", text: MINT_FALLBACK_NOTICE });
92
+ }
93
+ }
@@ -0,0 +1,94 @@
1
+ // Tolerant MCP tool-result parsing (RTSC-143) — pure, shared by proxy.ts and
2
+ // lib/claim.ts (previously two silent near-identical copies). The RTSC-91 footer
3
+ // regression survived unnoticed because a payload of `{json}\n\n— footer` failed
4
+ // JSON.parse and the parser silently handed back the raw result envelope —
5
+ // downstream code (lease tracking, claim parsing) then quietly misbehaved. This
6
+ // parser (a) recovers the JSON prefix when the text has trailing non-JSON, and
7
+ // (b) says so, so a shape regression is visible in MCP logs instead of vanishing.
8
+ /** Pure core: classify + extract the payload from a JSON-RPC tools/call response. */
9
+ export function parseToolResult(resp) {
10
+ const result = resp?.result;
11
+ const text = result?.content?.[0]?.text;
12
+ if (typeof text !== "string" || !text)
13
+ return { kind: "raw", value: result };
14
+ try {
15
+ return { kind: "json", value: JSON.parse(text) };
16
+ }
17
+ catch {
18
+ /* fall through to prefix recovery */
19
+ }
20
+ // Never RECOVER from an error response: error text is prose that may embed
21
+ // JSON-looking fragments (issue ids, tokens), and a recovered fragment would
22
+ // flow into lease tracking as if the call had succeeded.
23
+ if (!result?.isError) {
24
+ const recovered = parseJsonPrefix(text);
25
+ if (recovered)
26
+ return { kind: "recovered", value: recovered.value };
27
+ }
28
+ return { kind: "raw", value: result, unparseable: true };
29
+ }
30
+ const MAX_PREFIX_ATTEMPTS = 16;
31
+ /**
32
+ * "Strip trailing junk after the top-level JSON value". Fast path: JSON.parse
33
+ * errors name the offending position (V8/JSC), which for a value-plus-footer is
34
+ * exactly where the junk starts. Fallback: a bounded backward scan for the
35
+ * payload's closing `}`/`]`. Either way a candidate is only accepted when the
36
+ * discarded tail is NOT itself JSON-like — concatenated JSON values (NDJSON
37
+ * style) are a shape break where silently dropping the second value would be
38
+ * worse than the raw fallback.
39
+ */
40
+ function parseJsonPrefix(text) {
41
+ if (!/^\s*[{[]/.test(text))
42
+ return undefined; // only object/array payloads
43
+ const accept = (end) => {
44
+ if (/^\s*[{[]/.test(text.slice(end)))
45
+ return undefined; // tail is more JSON — refuse
46
+ try {
47
+ return { value: JSON.parse(text.slice(0, end)) };
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ try {
54
+ JSON.parse(text);
55
+ }
56
+ catch (e) {
57
+ const m = /position\s+(\d+)/i.exec(String(e?.message ?? e));
58
+ if (m) {
59
+ const hit = accept(Number(m[1]));
60
+ if (hit)
61
+ return hit;
62
+ }
63
+ }
64
+ let end = text.length;
65
+ for (let attempt = 0; attempt < MAX_PREFIX_ATTEMPTS; attempt++) {
66
+ const close = Math.max(text.lastIndexOf("}", end - 1), text.lastIndexOf("]", end - 1));
67
+ if (close < 0)
68
+ return undefined;
69
+ const hit = accept(close + 1);
70
+ if (hit)
71
+ return hit;
72
+ end = close; // that candidate wasn't the payload's end — try the previous one
73
+ }
74
+ return undefined;
75
+ }
76
+ function warnStderr(msg) {
77
+ process.stderr.write(`[retasc] ${msg}\n`);
78
+ }
79
+ /**
80
+ * The tool payload (JSON inside result.content[0].text), or the raw result.
81
+ * Drop-in for the old silent copies, except a degraded parse now WARNS (stderr —
82
+ * stdout may be the MCP channel): callers that only care about the value keep
83
+ * their one-liner, and a server shape regression is at least visible in logs.
84
+ */
85
+ export function toolResult(resp, warn = warnStderr) {
86
+ const p = parseToolResult(resp);
87
+ if (p.kind === "recovered") {
88
+ warn("tool result had trailing non-JSON text — recovered the leading JSON payload");
89
+ }
90
+ else if (p.unparseable) {
91
+ warn("tool result text is not JSON — falling back to the raw result envelope");
92
+ }
93
+ return p.value;
94
+ }
package/dist/proxy.js CHANGED
@@ -9,6 +9,8 @@ import { createInterface } from "node:readline";
9
9
  import { hostname } from "node:os";
10
10
  import { applyObservation, heartbeatRequest, isClaimLost, } from "./lib/watchdog.js";
11
11
  import { resolveConn } from "./lib/keystore.js";
12
+ import { toolResult as parseTool } from "./lib/toolresult.js";
13
+ import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
12
14
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
13
15
  // the direct commands (claim/tidy/done) can never diverge. The proxy carries its
14
16
  // binding in its own env (RETASC_MCP_KEY legacy, or RETASC_WORKSPACE → keystore).
@@ -21,6 +23,9 @@ let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with
21
23
  // Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
22
24
  // session key and switch to it so this session is distinguishable from others.
23
25
  let activeKey = KEY;
26
+ // Set when session-key minting failed and we fell back to the workspace key —
27
+ // whoami responses get a warning block appended so the agent sees the degraded state.
28
+ let sessionKeyFallback = false;
24
29
  // stderr only: stdout is the MCP channel and must carry ONLY protocol messages.
25
30
  function log(msg) {
26
31
  process.stderr.write(`[retasc-watchdog] ${msg}\n`);
@@ -42,30 +47,20 @@ async function postRemote(body) {
42
47
  * Auto-adopt a per-session key (RTSC-50). Mint one bound to our identity and use
43
48
  * it for the rest of the session, so concurrent sessions of the same agent are
44
49
  * distinguishable. Fail-soft: if minting is unavailable (older server, error),
45
- * keep using the workspace key — no worse than before.
50
+ * keep using the workspace key — no worse than before — but LOUDLY (RTSC-143),
51
+ * because on the shared key the per-session claim fence is inert.
46
52
  */
47
53
  async function adoptSessionKey() {
48
54
  if (!KEY)
49
55
  return;
50
56
  const label = process.env.RETASC_SESSION_LABEL || `${hostname()}#${process.pid}`;
51
- try {
52
- const resp = await postRemote({
53
- jsonrpc: "2.0",
54
- id: -1000,
55
- method: "tools/call",
56
- params: { name: "mint_session_key", arguments: { label } },
57
- });
58
- const r = toolResult(resp);
59
- if (r && typeof r === "object" && typeof r.key === "string") {
60
- activeKey = r.key; // switch to the session key
61
- log(`adopted session key "${r.session ?? label}"`);
62
- }
63
- else {
64
- log("mint_session_key unavailable — using the workspace key");
65
- }
57
+ const outcome = await mintSessionKey({ url: MCP_URL, key: KEY, label, warn: log });
58
+ if (outcome.ok) {
59
+ activeKey = outcome.key; // switch to the session key
60
+ log(`adopted session key "${outcome.session ?? label}"`);
66
61
  }
67
- catch (e) {
68
- log(`session-key mint failed (${String(e?.message ?? e)}) — using the workspace key`);
62
+ else {
63
+ sessionKeyFallback = true;
69
64
  }
70
65
  }
71
66
  /**
@@ -81,7 +76,7 @@ async function announceBinding() {
81
76
  method: "tools/call",
82
77
  params: { name: "whoami", arguments: {} },
83
78
  });
84
- const r = toolResult(resp);
79
+ const r = toolResult(resp, "whoami");
85
80
  if (r?.org && r?.project) {
86
81
  log(`bound → org "${r.org.name}" / project ${r.project.prefix}`);
87
82
  }
@@ -90,15 +85,11 @@ async function announceBinding() {
90
85
  /* announcement is best-effort */
91
86
  }
92
87
  }
93
- // The tool result payload — the JSON inside result.content[0].text or the raw result.
94
- function toolResult(resp) {
95
- try {
96
- const t = resp?.result?.content?.[0]?.text;
97
- return t ? JSON.parse(t) : resp?.result;
98
- }
99
- catch {
100
- return resp?.result;
101
- }
88
+ // The tool result payload — shared tolerant parser (RTSC-143), warning through
89
+ // our stderr logger (tool-labeled, so a busy MCP log names which call degraded)
90
+ // when it had to recover a JSON prefix or fall back raw.
91
+ function toolResult(resp, tool) {
92
+ return parseTool(resp, (msg) => log(tool ? `${tool}: ${msg}` : msg));
102
93
  }
103
94
  async function handleLine(line) {
104
95
  const trimmed = line.trim();
@@ -126,13 +117,16 @@ async function handleLine(line) {
126
117
  }
127
118
  return;
128
119
  }
129
- // Watch tools/call traffic for claims/releases (request args + result).
120
+ // Watch tools/call traffic for claims/releases (request args + result), and
121
+ // flag the workspace-key fallback on whoami so the AGENT sees the degraded
122
+ // state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
130
123
  if (msg.method === "tools/call" && resp) {
131
124
  applyObservation(leases, {
132
125
  toolName: msg.params?.name,
133
126
  args: msg.params?.arguments,
134
- result: toolResult(resp),
127
+ result: toolResult(resp, msg.params?.name),
135
128
  });
129
+ appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
136
130
  }
137
131
  // Relay the response (requests have an id; notifications don't).
138
132
  if (resp != null && msg.id !== undefined) {
@@ -143,7 +137,7 @@ async function heartbeatAll() {
143
137
  for (const [issueId, token] of [...leases]) {
144
138
  try {
145
139
  const resp = await postRemote(heartbeatRequest(hbSeq--, issueId, token));
146
- if (isClaimLost(toolResult(resp))) {
140
+ if (isClaimLost(toolResult(resp, "heartbeat"))) {
147
141
  leases.delete(issueId);
148
142
  log(`lease for ${issueId} is gone — stopped tracking`);
149
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Retasc CLI — sign in with GitHub, 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": {