@retasc/cli 1.48.1 → 1.49.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,43 @@ 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.49.0 (2026-09-09)
10
+
11
+ - **RTSC-879** — a committed `.mcp.json` now names `npx -y @retasc/cli@<version> mcp-proxy`
12
+ instead of a bare `retasc`, so it starts on a machine that is not the one that wrote it.
13
+ `portableLauncher` used to pass an on-PATH resolution straight through on the belief that
14
+ a bare binary "was at least portable"; it is portable only across machines that happen to
15
+ have a global install. A container has none. Measured in a Claude Code cloud session on a
16
+ fresh clone: the MCP server died at `ENOENT: Executable not found in $PATH: retasc` before
17
+ it could do anything else. Startup on the shared marker now pays npx resolution (about 6s
18
+ cold, ~1s off the cache); user-level configs, which never travel, keep the direct binary.
19
+ - **RTSC-879** — `retasc bind --json` can now sign in from a container. An agent-driven
20
+ bind takes the DEVICE grant instead of the browser one, in two steps: the first call
21
+ prints an approve URL and an eight-character code and **exits**, the human approves on
22
+ any device, and running the same command again resumes the same grant and finishes.
23
+ Previously an agent got the browser door, which redirects to `127.0.0.1` on the machine
24
+ running the CLI — unreachable when the human is somewhere else, which in a cloud
25
+ container they always are. New continuable outcome state `SIGN_IN_PENDING` so an agent
26
+ can tell "waiting on a click" from "nothing has started" and does not issue a second
27
+ code that invalidates the one its human is reading. CI and `RETASC_NO_BROWSER` still
28
+ refuse immediately: neither has anyone to approve anything.
29
+ - **RTSC-879** — `retasc doctor` no longer installs a package to check a marker. For an
30
+ npx marker it probes `npx --version` rather than executing the pinned package spec,
31
+ which was a registry fetch on every run and reported "your agent CANNOT start Retasc"
32
+ for a healthy marker whenever npm was unreachable. Its remedy line is corrected too: a
33
+ global install can no longer change what `bind` writes into a shared marker.
34
+ - **RTSC-879** — the repo's own committed `.mcp.json`, and the marker blocks published in
35
+ `web/auth.md` and the agent-skill, now show the npx form. They were the bare binary,
36
+ which is the exact thing this release stops emitting. Those hand-written blocks say
37
+ `@latest` rather than a pinned version on purpose: `release:cli` publishes from `main`
38
+ AFTER a merge, so a committed marker pinning the version being released would 404 for
39
+ every fresh clone in the window between the two — trading this release's ENOENT for an
40
+ E404. `bind` still writes the pinned form, which is correct because it only ever names
41
+ a version that already exists.
42
+ - **[no-issue]** — test fix: the setup-hook test pinned `RETASC_HOME` to a temp dir. It
43
+ fell through to the real home directory, so it passed on a clean CI runner and failed
44
+ on any machine that had actually run `retasc setup`.
45
+
9
46
  ## 1.48.1 (2026-09-08)
10
47
 
11
48
  - **RTSC-878** — `retasc doctor` no longer condemns the entry `retasc setup` writes. Its
package/dist/auth.js CHANGED
@@ -287,6 +287,123 @@ async function browserLogin(deploymentUrl, dashUrl, onUrl) {
287
287
  // what actually worked.
288
288
  patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
289
289
  }
290
+ /** Ask the provider for a code. Returns immediately; nobody waits here. */
291
+ export async function deviceStart(provider, deploymentUrl) {
292
+ if (provider === "google") {
293
+ const convex = new ConvexHttpClient(deploymentUrl);
294
+ const start = (await convex.action(googleDeviceStart, {}));
295
+ return {
296
+ provider,
297
+ deploymentUrl,
298
+ deviceCode: start.deviceCode,
299
+ userCode: start.userCode,
300
+ verificationUri: start.verificationUrl,
301
+ ...(start.verificationUrlComplete
302
+ ? { verificationUriComplete: start.verificationUrlComplete }
303
+ : {}),
304
+ intervalMs: (start.intervalSeconds || 5) * 1000,
305
+ expiresAt: Date.now() + start.expiresInSeconds * 1000,
306
+ };
307
+ }
308
+ if (!GITHUB_CLIENT_ID) {
309
+ throw new Error("GitHub client id not configured. Set RETASC_GITHUB_CLIENT_ID (the OAuth App's public Client ID).");
310
+ }
311
+ const start = await postForm(DEVICE_CODE_URL, {
312
+ client_id: GITHUB_CLIENT_ID,
313
+ scope: "read:user user:email",
314
+ });
315
+ if (!start.device_code) {
316
+ // Same rule as `githubDeviceToken`: quote what GitHub WROTE for a person, never the
317
+ // raw payload.
318
+ const said = start.error_description ?? start.error;
319
+ throw new Error(`GitHub didn't hand back a sign-in code${said ? `: ${clean(said)}` : "."} ` +
320
+ `Check your connection and try again.`);
321
+ }
322
+ return {
323
+ provider,
324
+ deploymentUrl,
325
+ deviceCode: start.device_code,
326
+ userCode: start.user_code,
327
+ verificationUri: start.verification_uri,
328
+ ...(start.verification_uri_complete
329
+ ? { verificationUriComplete: start.verification_uri_complete }
330
+ : {}),
331
+ intervalMs: (start.interval || 5) * 1000,
332
+ expiresAt: Date.now() + start.expires_in * 1000,
333
+ };
334
+ }
335
+ /**
336
+ * Poll an already-started grant for at most `budgetMs`, and sign in if it completed.
337
+ *
338
+ * BOUNDED ON PURPOSE. The caller is an agent, and an agent that blocks for the grant's
339
+ * full fifteen minutes is the very failure this split exists to avoid. `false` means
340
+ * "not yet, ask again" — the human has not clicked — and is not an error. Anything
341
+ * genuinely terminal (denied, expired, a misconfigured client) throws, because those
342
+ * cannot be fixed by asking again and an agent must be told to stop rather than loop.
343
+ */
344
+ export async function deviceFinish(pending, deploymentUrl, budgetMs) {
345
+ const convex = new ConvexHttpClient(deploymentUrl);
346
+ let intervalMs = pending.intervalMs || 5000;
347
+ const stopAt = Math.min(Date.now() + budgetMs, pending.expiresAt);
348
+ let upstream;
349
+ while (Date.now() < stopAt) {
350
+ // Never poll SOONER than the provider asked. Clamping the sleep to the remaining
351
+ // budget would do exactly that on the last iteration — and after a `slow_down` that
352
+ // interval is the provider telling us to back off. Stop instead; the caller
353
+ // re-enters on its next invocation anyway.
354
+ const wait = Math.min(intervalMs, Math.max(0, stopAt - Date.now()));
355
+ if (wait < intervalMs)
356
+ break;
357
+ await sleep(wait);
358
+ if (Date.now() > pending.expiresAt)
359
+ break;
360
+ if (pending.provider === "google") {
361
+ const r = (await convex.action(googleDevicePoll, { deviceCode: pending.deviceCode }));
362
+ if (r.status === "ok") {
363
+ upstream = r.googleToken;
364
+ break;
365
+ }
366
+ if (r.status === "slowDown")
367
+ intervalMs += 5000;
368
+ continue;
369
+ }
370
+ const r = await postForm(TOKEN_URL, {
371
+ client_id: GITHUB_CLIENT_ID,
372
+ device_code: pending.deviceCode,
373
+ grant_type: GRANT,
374
+ });
375
+ if (r.access_token) {
376
+ upstream = r.access_token;
377
+ break;
378
+ }
379
+ if (r.error === "authorization_pending")
380
+ continue;
381
+ if (r.error === "slow_down") {
382
+ intervalMs += 5000;
383
+ continue;
384
+ }
385
+ throw new Error(`Authorization failed: ${clean(String(r.error_description ?? r.error))}`);
386
+ }
387
+ if (!upstream) {
388
+ if (Date.now() >= pending.expiresAt) {
389
+ throw new Error("The sign-in code expired before it was approved. Start again.");
390
+ }
391
+ return false;
392
+ }
393
+ const res = await convex.action(signIn, {
394
+ provider: pending.provider === "google" ? "google-device" : "github-device",
395
+ params: pending.provider === "google" ? { googleToken: upstream } : { githubToken: upstream },
396
+ });
397
+ const tokens = res?.tokens;
398
+ if (!tokens?.token)
399
+ throw new Error("Sign-in did not return a session token.");
400
+ patchConfig({
401
+ token: tokens.token,
402
+ refreshToken: tokens.refreshToken,
403
+ loginProvider: pending.provider,
404
+ });
405
+ return true;
406
+ }
290
407
  /**
291
408
  * Run a device-flow login and persist the resulting Convex Auth session to
292
409
  * ~/.retasc/config.json. Prints the user code + verification URL.
@@ -1,6 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { api, cliError } from "../api.js";
3
- import { deviceLogin } from "../auth.js";
3
+ import { deviceLogin, deviceStart, deviceFinish } from "../auth.js";
4
4
  import { loadConfig, patchConfig } from "../config.js";
5
5
  import { installMarker, printMarkerBlock } from "./mcp.js";
6
6
  import { runSetup } from "./setup.js";
@@ -730,6 +730,30 @@ export const NEXT_STEP = "Start your agent in this folder, or restart it if it's
730
730
  * because "run this, but run the other one first" is not a single instruction. So the
731
731
  * sign-in is explicit, announced, and only when a human is present to authorize it.
732
732
  */
733
+ /**
734
+ * RTSC-879 — thrown when a device grant has been started and the human has not approved
735
+ * it yet. NOT a failure: `bindAction` turns it into a CONTINUABLE outcome, because the
736
+ * only thing missing is a click that has not happened yet.
737
+ */
738
+ /**
739
+ * How long a resuming `bind --json` waits for the click before handing control back.
740
+ *
741
+ * Ninety seconds is chosen against the agent's loop, not the human's: long enough that
742
+ * a human who clicks while the agent is still talking is picked up on the same call,
743
+ * short enough that a run never looks hung. Being wrong is cheap in one direction only
744
+ * — too short just means one more `bind --json`, too long means an agent that looks
745
+ * dead — so it errs short.
746
+ */
747
+ const RESUME_POLL_MS = 90_000;
748
+ export class SignInPending extends Error {
749
+ url;
750
+ code;
751
+ constructor(url, code) {
752
+ super("sign-in pending: waiting for approval");
753
+ this.url = url;
754
+ this.code = code;
755
+ }
756
+ }
733
757
  export async function ensureSignedIn(agent) {
734
758
  if (loadConfig().token)
735
759
  return;
@@ -750,11 +774,81 @@ export async function ensureSignedIn(agent) {
750
774
  // would itself fail silently.
751
775
  cliError("UNAUTHENTICATED", `Not signed in. Run \`${selfCommand(VERSION)} login\` first (no TTY here for the device flow).`, versionStamp(VERSION).trim());
752
776
  }
777
+ // RTSC-879 — an AGENT always takes the device door, and takes it in two steps.
778
+ //
779
+ // Not the browser door, which is what an agent-driven run used to get. That door sends
780
+ // the human to a URL redirecting to `127.0.0.1` on THIS machine, so it only works when
781
+ // the browser and the CLI share a host. An agent's human may be anywhere, and in a
782
+ // cloud container they always are, so the click lands on a port nobody can reach and
783
+ // the CLI waits out its timeout. `canOpenBrowser` does not catch that: it looks for
784
+ // SSH, CI and an explicit opt-out, and a cloud container sets none of them, so it
785
+ // reports a browser that is not there. A device grant does not care where the human
786
+ // is, which is why it is the door for every agent rather than a fallback for some.
787
+ //
788
+ // Two steps because one does not work: an agent running a blocking command sees no
789
+ // stdout until the process exits, so a code printed above a fifteen-minute wait
790
+ // reaches nobody. START prints and exits; the next `bind --json` FINISHes.
791
+ if (agent) {
792
+ // Two doors stay shut, and only two. A device grant works wherever the human is, so
793
+ // SSH and a container are no longer dead ends — but neither of these is about WHERE
794
+ // the human is:
795
+ // • CI has nobody watching at all. A code emitted there is read by no one and the
796
+ // run would ask for it again forever.
797
+ // • RETASC_NO_BROWSER is a person saying "do not put me through browser auth". A
798
+ // device grant is still browser auth, just on a different device, so honouring
799
+ // the switch means honouring it here too.
800
+ if (process.env.CI || process.env.RETASC_NO_BROWSER) {
801
+ throw new Error("no browser door here: " +
802
+ (process.env.CI
803
+ ? "this is CI, and a sign-in code needs a human to approve it. Mint a key on a machine with a browser and set RETASC_MCP_KEY instead."
804
+ : "RETASC_NO_BROWSER is set. Unset it, or sign in on another machine and bind there."));
805
+ }
806
+ const cfg = loadConfig();
807
+ // A grant started against a DIFFERENT backend is not resumable here: Google's half
808
+ // lives in Convex, so polling another deployment asks a server about a code it never
809
+ // issued. Treat it as absent and start a fresh one.
810
+ const stored = cfg.pendingDevice && (cfg.pendingDevice.deploymentUrl ?? cfg.deploymentUrl) === cfg.deploymentUrl
811
+ ? cfg.pendingDevice
812
+ : undefined;
813
+ // Resume an approval already in flight. Bounded: `deviceFinish` returns false rather
814
+ // than holding the process open, so every invocation ends promptly whatever the
815
+ // human is doing.
816
+ if (stored && Date.now() < stored.expiresAt) {
817
+ let done;
818
+ try {
819
+ done = await deviceFinish(stored, cfg.deploymentUrl, RESUME_POLL_MS);
820
+ }
821
+ catch (e) {
822
+ // TERMINAL, so the grant must go. `deviceFinish` throws only on things retrying
823
+ // cannot fix — the human declined, the code expired, the client is misconfigured
824
+ // — and a dead grant left in the config is re-loaded by the branch above on every
825
+ // later run, which would rethrow the same refusal forever and leave no way to
826
+ // start a new one short of editing the file by hand.
827
+ patchConfig({ pendingDevice: undefined });
828
+ throw e;
829
+ }
830
+ if (done) {
831
+ patchConfig({ pendingDevice: undefined });
832
+ return;
833
+ }
834
+ throw new SignInPending(stored.verificationUriComplete || stored.verificationUri, stored.userCode);
835
+ }
836
+ // No grant, or the last one died of old age, or it belongs to another deployment.
837
+ // Clear before starting rather than relying on the write below to overwrite it: if
838
+ // `deviceStart` throws, a dead grant left behind would be re-read on the next run.
839
+ if (cfg.pendingDevice)
840
+ patchConfig({ pendingDevice: undefined });
841
+ const started = await deviceStart(cfg.loginProvider ?? "github", cfg.deploymentUrl);
842
+ patchConfig({ pendingDevice: started });
843
+ const url = started.verificationUriComplete || started.verificationUri;
844
+ agent.onDeviceCode?.({ url, code: started.userCode });
845
+ throw new SignInPending(url, started.userCode);
846
+ }
753
847
  // RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
754
848
  // announcing "GitHub" before the question would be wrong for the invited
755
849
  // teammate who signed up through Google — the exact person `join` is for.
756
850
  console.error("Not signed in yet — let's do that first.");
757
- await deviceLogin(undefined, agent ? { agentDriven: true, onUrl: agent.onUrl } : undefined);
851
+ await deviceLogin(undefined);
758
852
  }
759
853
  export async function bindAction(opts) {
760
854
  // RTSC-713 — `--json` is the signal that a machine is driving, and it turns three
@@ -763,7 +857,14 @@ export async function bindAction(opts) {
763
857
  // independently useful: structured output nobody can act on, or a door that leads to a
764
858
  // thrown Error, is each worse than neither.
765
859
  const agent = opts.json
766
- ? { onUrl: (url) => emit({ event: "approve_url", url }) }
860
+ ? {
861
+ onUrl: (url) => emit({ event: "approve_url", url }),
862
+ // RTSC-879 — the device grant emits the SAME event, plus the code. Without this
863
+ // the URL reached an agent only inside the outcome's free-text `next`, so a
864
+ // client parsing NDJSON (which is what `--json` is for) lost the one field it
865
+ // was watching for.
866
+ onDeviceCode: (d) => emit({ event: "approve_url", url: d.url, code: d.code }),
867
+ }
767
868
  : undefined;
768
869
  const folder = process.cwd();
769
870
  // RTSC-722 — the escape option's value is a sentinel, and this is where it stops being
@@ -808,6 +909,21 @@ export async function bindAction(opts) {
808
909
  catch (e) {
809
910
  if (!agent)
810
911
  throw e;
912
+ // RTSC-879 — started, not failed. The only missing thing is a click, so this is
913
+ // CONTINUABLE: exit 0, tell the agent what to show its human, and let the next
914
+ // `bind --json` resume the same grant. Reported as a failure it would read as
915
+ // "this cannot work here", which is exactly backwards.
916
+ if (e instanceof SignInPending) {
917
+ pause({
918
+ state: "SIGN_IN_PENDING",
919
+ next: `Sign-in has started and is waiting for your human. Show them BOTH of these, ` +
920
+ `then run this same command again once they say they have approved it:\n` +
921
+ ` Open: ${e.url}\n Enter code: ${e.code}\n` +
922
+ `They can open it on any device — a phone, another laptop — it does not have ` +
923
+ `to be this machine.`,
924
+ });
925
+ return;
926
+ }
811
927
  // Sign-in was ATTEMPTED and did not complete. Distinct from arriving with no session,
812
928
  // which is continuable — collapsing the two is what made the old refusal stop agents
813
929
  // that had actually succeeded.
@@ -44,6 +44,25 @@ export function launcherVerdict(local, probe = runsOk) {
44
44
  // The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
45
45
  const args = local.args ?? [];
46
46
  const probeArgs = args[args.length - 1] === "mcp-proxy" ? args.slice(0, -1) : args;
47
+ // RTSC-879 — an npx marker is probed by asking whether NPX runs, never by running the
48
+ // package spec. `runsOk` executes what it is handed, so probing `npx -y @retasc/cli@X`
49
+ // means a registry fetch and an install on every `doctor` — slow when it works, and a
50
+ // false "your agent CANNOT start Retasc" the moment the machine is offline or npm is
51
+ // unreachable. What this check is actually asking is whether the LAUNCHER exists, and
52
+ // for the npx form that question is answered by `npx --version`.
53
+ //
54
+ // WHAT THIS STOPS CHECKING, said out loud: it proves the launcher exists and nothing
55
+ // about the pinned spec. A version that is not on npm, a registry behind a proxy, or a
56
+ // corrupt npx cache all read as healthy here while the server dies at startup. That is
57
+ // the deliberate trade — a false "CANNOT start" every time someone is offline is worse
58
+ // than a rare false "can" — but since 1.49.0 every shared marker is this form, so it
59
+ // is a blind spot on the common path, not an edge one.
60
+ if (local.command === "npx") {
61
+ return {
62
+ startable: Boolean(probe("npx", ["--version"])),
63
+ shown: clean([local.command, ...probeArgs].join(" ")),
64
+ };
65
+ }
47
66
  return {
48
67
  startable: Boolean(probe(local.command, probeArgs)),
49
68
  shown: clean([local.command, ...probeArgs].join(" ")),
@@ -149,10 +168,19 @@ function checkLauncher(local) {
149
168
  ok(`your agent can start Retasc (${v.shown}).`);
150
169
  return;
151
170
  }
152
- bad(`your agent CANNOT start Retasc this folder is registered to run "${v.shown}",\n` +
153
- ` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
154
- ` This is what a bind through npx used to leave behind.\n` +
155
- ` Fix: npm install -g @retasc/cli then re-run \`retasc bind\` here.`);
171
+ // RTSC-879 the remedy differs by form, because a global install no longer changes
172
+ // what `bind` writes into a SHARED marker: `portableLauncher` emits the npx form for
173
+ // every resolution now, so "install it globally and re-bind" would rewrite the
174
+ // identical entry and fix nothing.
175
+ bad(local?.command === "npx"
176
+ ? `your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
177
+ ` and npx itself did not run here. That is a Node/npm problem, not a Retasc one.\n` +
178
+ ` Fix: make sure \`npx --version\` works, then try again. If this machine is\n` +
179
+ ` offline or npm is unreachable, that is the cause and nothing is broken.`
180
+ : `your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
181
+ ` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
182
+ ` Fix: re-run \`retasc bind\` here — it now writes a portable npx entry that\n` +
183
+ ` starts on any machine with Node, including one with no global install.`);
156
184
  }
157
185
  export async function doctorAction() {
158
186
  const cfg = loadConfig();
package/dist/config.js CHANGED
@@ -74,6 +74,48 @@ export function loadConfig() {
74
74
  updateNoticeAt: typeof stored.updateNoticeAt === "number" && Number.isFinite(stored.updateNoticeAt)
75
75
  ? stored.updateNoticeAt
76
76
  : undefined,
77
+ // RTSC-879 — and it MUST be here. `loadConfig` rebuilds the object field by field, so
78
+ // a field absent from this literal is invisible to every reader no matter what is on
79
+ // disk. Left out, `bind --json` never saw the grant it had just written: it minted a
80
+ // fresh code on every call, invalidating the one the human was reading, and exited 0
81
+ // forever. The whole suite stayed green because nothing round-tripped the grant
82
+ // through the config, which is why the test below does exactly that.
83
+ //
84
+ // Validated, same rule as the fields above: the file is hand-editable, and this one
85
+ // is fed straight to a polling loop. An unrecognised provider must not fall through
86
+ // to GitHub's branch by default, and a non-finite `expiresAt` would make the grant
87
+ // either immortal or already dead.
88
+ pendingDevice: readPendingDevice(stored.pendingDevice),
89
+ };
90
+ }
91
+ /** A stored device grant, or `undefined` if it is anything other than a complete one. */
92
+ function readPendingDevice(v) {
93
+ if (!v || typeof v !== "object")
94
+ return undefined;
95
+ const str = (x) => (typeof x === "string" && x.length > 0 ? x : undefined);
96
+ const provider = v.provider === "github" || v.provider === "google" ? v.provider : undefined;
97
+ const deviceCode = str(v.deviceCode);
98
+ const userCode = str(v.userCode);
99
+ const verificationUri = str(v.verificationUri);
100
+ const expiresAt = typeof v.expiresAt === "number" && Number.isFinite(v.expiresAt) ? v.expiresAt : undefined;
101
+ if (!provider || !deviceCode || !userCode || !verificationUri || expiresAt === undefined) {
102
+ return undefined;
103
+ }
104
+ // A negative or absent interval would busy-poll a provider that asked us not to.
105
+ const intervalMs = typeof v.intervalMs === "number" && Number.isFinite(v.intervalMs) && v.intervalMs > 0
106
+ ? v.intervalMs
107
+ : 5000;
108
+ return {
109
+ provider,
110
+ deviceCode,
111
+ userCode,
112
+ verificationUri,
113
+ ...(str(v.verificationUriComplete)
114
+ ? { verificationUriComplete: v.verificationUriComplete }
115
+ : {}),
116
+ intervalMs,
117
+ expiresAt,
118
+ ...(str(v.deploymentUrl) ? { deploymentUrl: v.deploymentUrl } : {}),
77
119
  };
78
120
  }
79
121
  /** Move a corrupt config aside to a unique sibling so a human can recover any
@@ -117,18 +117,35 @@ export function globalBinCandidates(prefix, win = WIN) {
117
117
  : [join(prefix, "bin", "retasc")];
118
118
  }
119
119
  /**
120
- * A launcher safe to write into a SHARED file.
120
+ * A launcher safe to write into a SHARED file. ALWAYS the pinned npx form.
121
121
  *
122
122
  * The absolute-path form is correct for the machine that resolved it and wrong everywhere
123
123
  * else: `./.mcp.json` is the secret-free marker the product describes as safe to commit,
124
124
  * so a path under someone's home directory both leaks their username into a committed
125
- * file and hands every teammate a command that does not exist on their machine. The bare
126
- * `retasc` it replaces was at least portable. So project-scoped writes get the pinned npx
127
- * form, which is slower but true on any machine.
125
+ * file and hands every teammate a command that does not exist on their machine.
126
+ *
127
+ * RTSC-879 the bare `retasc` is NOT the safe fallback this function used to treat it as.
128
+ * The old comment here said it "was at least portable", and that is only true across
129
+ * machines that happen to have a global install. A container has none. Measured
130
+ * 2026-09-09 in a Claude Code cloud session on a fresh clone of this repo: the committed
131
+ * marker said `command: "retasc"`, and the MCP server died at
132
+ * `ENOENT: Executable not found in $PATH: retasc` before it could do anything else. That
133
+ * marker was written by a laptop where `resolveLauncher` found the binary on a durable
134
+ * PATH and returned `how: "on-path"`, which this function then passed straight through as
135
+ * though on-PATH-here meant on-PATH-anywhere.
136
+ *
137
+ * So there is no `how` worth preserving. `absolute` names a path nobody else has,
138
+ * `on-path` and `installed` name a binary nobody else is guaranteed to have, and this
139
+ * file is committed ON PURPOSE (see `.gitignore`, and the pre-commit guard that keeps it
140
+ * secret-free). The only form true on every machine is the pinned npx one.
141
+ *
142
+ * COST, stated plainly: sessions using the committed marker now pay npx resolution at
143
+ * startup — about 6s cold, and roughly a second warm off the npx cache. That is the price
144
+ * of a marker that starts at all on a machine that is not the one that wrote it, and it
145
+ * is paid ONLY here. `resolveLauncher`'s fast direct form is still what goes into
146
+ * user-level configs, which never travel.
128
147
  */
129
- export function portableLauncher(r, version) {
130
- if (r.how !== "absolute")
131
- return r.launcher;
148
+ export function portableLauncher(_r, version) {
132
149
  return { command: "npx", args: ["-y", `${PKG}@${version}`] };
133
150
  }
134
151
  /**
@@ -66,6 +66,15 @@ import { homedir } from "node:os";
66
66
  export const CONTINUABLE_STATES = [
67
67
  /** No session, and no attempt has been made yet. The browser door is available. */
68
68
  "NEEDS_SIGN_IN",
69
+ /**
70
+ * RTSC-879 — a device grant HAS been started and is waiting on the human's click.
71
+ *
72
+ * Continuable, and the distinction from `NEEDS_SIGN_IN` is the whole point: there,
73
+ * nothing has happened and the agent should begin. Here a code is live, the human has
74
+ * been handed it, and re-starting would issue a second code that invalidates the one
75
+ * they are looking at. The agent's move is to run the same command again, unchanged.
76
+ */
77
+ "SIGN_IN_PENDING",
69
78
  /** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
70
79
  "NEEDS_ORG",
71
80
  /** Org settled. Zero projects, or more than one, so which project is unknown. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.48.1",
3
+ "version": "1.49.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": {