@retasc/cli 1.30.0 → 1.31.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,59 @@ 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.31.1 (2026-08-23)
10
+
11
+ - **RTSC-715** — setup no longer finishes by wiring a `retasc` command that is not there.
12
+ Run as `npx @retasc/cli@latest bind`, the CLI asked whether `retasc` was on your PATH
13
+ and got yes, because npx puts its own cache directory on the PATH of whatever it runs.
14
+ So the answer was true while `bind` ran and false the moment it exited, and your agent
15
+ started with `ENOENT: Executable not found in $PATH: "retasc"` after a setup that had
16
+ reported success. It now checks that the command it found will still resolve afterwards,
17
+ and falls back to a global install or a pinned `npx` launcher when it will not.
18
+
19
+ ## 1.31.0 (2026-08-23)
20
+
21
+ - **RTSC-713** — your agent can set Retasc up for you. It runs `bind` itself now, so
22
+ nothing asks you to open a terminal and type a command: you paste one line into the
23
+ chat, click Approve in the browser that opens, and restart your agent. The command it
24
+ runs is `bind --json`, the door built for a machine to drive.
25
+ - **RTSC-713** — `bind --json` reports what it did and what it still needs, one JSON
26
+ object per line, and prints the approve URL the moment it exists rather than at the
27
+ end. Your agent posts that URL to you as a link, which is what makes this work on a
28
+ machine where the browser does not open by itself.
29
+ - **RTSC-713** — a setup that paused is no longer dressed as a crash. "Signed in, but you
30
+ have not said which project this folder is for" exits 0 and says exactly that; only a
31
+ real dead end exits non-zero. It used to exit 1 with `✗ UNAUTHENTICATED`, and an agent
32
+ reading that reasonably concluded setup had failed and stopped, one question short of
33
+ done, holding a session that had actually worked.
34
+ - **RTSC-713** — sign-in no longer refuses merely because nothing is attached to a
35
+ terminal. It asks whether a browser can be reached, which is the thing that actually
36
+ matters. SSH, containers and CI still refuse, and still refuse fast: over SSH the
37
+ approve link targets `127.0.0.1` on whichever machine opened it, so it could never
38
+ reach the CLI waiting on the remote host.
39
+ - **RTSC-713** — every outcome names the absolute folder it is about to connect, so a
40
+ wrong one can be caught before anything is written. A wrong folder has no symptom
41
+ otherwise: the agent still calls in and the Dash still looks healthy.
42
+ - **RTSC-691** — `retasc login` opens your browser instead of asking you to retype an
43
+ eight-character code. Click Approve once and you are signed in, and you pick GitHub,
44
+ Google or a passkey in the Dash where you are usually signed in already, instead of
45
+ answering that question in the terminal. The device flow is still there and still
46
+ works; it is the fallback now rather than the only door.
47
+ - **RTSC-691** — the code path stays for the places a browser round trip cannot work:
48
+ inside a container, and over SSH, where the redirect would target `127.0.0.1` on
49
+ whichever machine opened the link and so could never reach the CLI waiting on the
50
+ remote host. `RETASC_NO_BROWSER=1` forces it everywhere.
51
+ - **RTSC-691** — `retasc release <RTSC-NN> --claim-token <token>` exists. Two error
52
+ messages in `retasc claim` had been telling people to run it for a while, and it was
53
+ not a command. The token is the one printed when you claimed; the server fences on it,
54
+ which is what stops one agent releasing another's work. Releasing leaves your worktree
55
+ and branch alone — `retasc tidy` is what reaps those.
56
+ - **RTSC-691** — `retasc bind` prints "restart your agent" whether or not a human is
57
+ watching. It was suppressed when output was not a terminal, which is exactly when an
58
+ agent is the one reading it and the one that has to pass the message on.
59
+ - **RTSC-691** — `bind` clears the credential-less bootstrap entry that would otherwise
60
+ shadow the binding it just wrote, when the two land in different scopes.
61
+
9
62
  ## 1.30.0 (2026-08-20)
10
63
 
11
64
  - **RTSC-681** — your agent can read a file you attached to an issue. Uploading has
package/dist/auth.js CHANGED
@@ -3,6 +3,7 @@ import { ConvexHttpClient } from "convex/browser";
3
3
  import { makeFunctionReference } from "convex/server";
4
4
  import { loadConfig, patchConfig } from "./config.js";
5
5
  import { ask, isInteractive } from "./lib/prompt.js";
6
+ import { startBrowserLogin } from "./lib/browserLogin.js";
6
7
  // The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
7
8
  // secret). Baked in so `retasc login` works out-of-the-box; override via env.
8
9
  const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
@@ -29,6 +30,46 @@ async function postForm(url, body) {
29
30
  return res.json();
30
31
  }
31
32
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ /**
34
+ * Can this machine plausibly complete a browser round trip (RTSC-691)?
35
+ *
36
+ * Three ways the answer is no, and each of them has cost somebody time:
37
+ *
38
+ * • **No human.** A scripted or CI run has nobody to click, so waiting is waiting for
39
+ * nothing.
40
+ * • **SSH.** `isInteractive()` is true over SSH, which makes a TTY check exactly the
41
+ * wrong gate. The redirect goes to 127.0.0.1 on the machine whose browser opened it,
42
+ * so a human clicking on their laptop can never reach a listener on the remote host.
43
+ * Not slow — impossible.
44
+ * • **Explicitly opted out.** `RETASC_NO_BROWSER` is the documented switch (see
45
+ * `cli/src/config.ts`) and is honoured HERE too, not only inside `openBrowser`.
46
+ * Reading it in one place and not the other produced the worst outcome available: no
47
+ * browser opens AND the caller still waits out the timeout. Deliberately ONE variable
48
+ * — a second, undocumented knob is one nobody can find when they need it.
49
+ */
50
+ export function canOpenBrowser(agentDriven = false) {
51
+ if (process.env.RETASC_NO_BROWSER)
52
+ return false;
53
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT)
54
+ return false;
55
+ // RTSC-713 — CI is the case the TTY check used to cover by accident.
56
+ //
57
+ // `agentDriven` drops the TTY requirement, because an agent's shell has no TTY and
58
+ // that was the entire reason cold `bind` was unreachable to one. But "no TTY" was also
59
+ // the only thing holding this door shut in CI, where nobody can click, the listener
60
+ // blocks for its full timeout, and the device flow it falls through to cannot complete
61
+ // either. So the guard the TTY check was silently providing is now stated outright,
62
+ // and it applies on BOTH paths: an agent running inside CI is still CI.
63
+ if (process.env.CI)
64
+ return false;
65
+ // An agent is a human's hands, not a human's absence — the person is in the chat, and
66
+ // the URL this door prints is relayed to them there. The property that must still hold
67
+ // is that a browser on THIS machine can reach the loopback listener, which is what the
68
+ // SSH check above is for and why it is not relaxed here.
69
+ if (agentDriven)
70
+ return true;
71
+ return isInteractive();
72
+ }
32
73
  /**
33
74
  * Open a URL in the machine's browser, best effort (RTSC-676).
34
75
  *
@@ -49,7 +90,11 @@ function openBrowser(url) {
49
90
  const [cmd, args] = process.platform === "darwin"
50
91
  ? ["open", [url]]
51
92
  : process.platform === "win32"
52
- ? ["start", ['""', url]]
93
+ ? // QUOTED: cmd.exe treats `&` as a command separator, and the sign-in URL is the
94
+ // first here to carry two query parameters (`?port=…&state=…`). Unquoted it
95
+ // opens a truncated link (the Dash then reports "that link is incomplete") and
96
+ // tries to run the remainder as a command.
97
+ ["start", ['""', `"${url}"`]]
53
98
  : ["xdg-open", [url]];
54
99
  try {
55
100
  const child = spawn(cmd, args, {
@@ -197,6 +242,42 @@ deps = {}) {
197
242
  }
198
243
  throw new Error("no valid choice — aborting");
199
244
  }
245
+ /**
246
+ * Sign in through the browser and persist the session (RTSC-691).
247
+ *
248
+ * Prints the URL BEFORE waiting, unconditionally, for the same reason `announceCode`
249
+ * does: an auto-open that silently failed must leave the screen exactly as useful as it
250
+ * was. On a headless box the printed link is the whole path, and when an agent is
251
+ * driving, that printed line is what it relays to its human.
252
+ */
253
+ async function browserLogin(deploymentUrl, dashUrl, onUrl) {
254
+ const { url, port, wait } = await startBrowserLogin(dashUrl);
255
+ // RTSC-713 — hand the URL to the caller BEFORE the wait, not only to the terminal.
256
+ //
257
+ // `startBrowserLogin` was already split in two so the URL exists before blocking; this
258
+ // is the other half of that split finally being used. An agent backgrounds this
259
+ // process and tails its output, so the URL reaching stdout here is what lets it post a
260
+ // clickable link into the chat while this call is still blocked on the click. Printed
261
+ // AND opened AND handed back: the browser popping up is the nice case, the link in the
262
+ // chat is the one that always works.
263
+ onUrl?.(url);
264
+ console.log(`\n Open: ${url}\n`);
265
+ console.log(" Waiting for you to approve it in the browser…");
266
+ openBrowser(url);
267
+ const { code } = await wait();
268
+ const convex = new ConvexHttpClient(deploymentUrl);
269
+ const res = await convex.action(signIn, {
270
+ provider: "cli-browser",
271
+ params: { code, port },
272
+ });
273
+ const tokens = res?.tokens;
274
+ if (!tokens?.token)
275
+ throw new Error("Sign-in did not return a session token.");
276
+ // No `loginProvider` recorded: this door does not name an upstream identity, and
277
+ // writing one would make the next device-flow default a guess rather than a memory of
278
+ // what actually worked.
279
+ patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
280
+ }
200
281
  /**
201
282
  * Run a device-flow login and persist the resulting Convex Auth session to
202
283
  * ~/.retasc/config.json. Prints the user code + verification URL.
@@ -204,8 +285,67 @@ deps = {}) {
204
285
  * With no `provider`, asks which door when a human is present and falls back to
205
286
  * GitHub when one isn't.
206
287
  */
207
- export async function deviceLogin(provider) {
288
+ export async function deviceLogin(provider, opts) {
208
289
  const cfg = loadConfig();
290
+ // RTSC-691 — try the browser round trip FIRST when a human is present.
291
+ //
292
+ // For them it removes the eight characters they had to retype, and skips the
293
+ // which-door question entirely: they pick GitHub, Google or a passkey in the Dash,
294
+ // where they are often already signed in.
295
+ //
296
+ // GATED, and the gate is load-bearing rather than cautious. This path opens a listener
297
+ // and BLOCKS until somebody clicks. Where nobody can click it blocks for the full
298
+ // timeout and then falls through, turning a fast, honest refusal into a long hang — in
299
+ // CI, in a container, and in the CLI's own tests.
300
+ //
301
+ // A TTY check alone is NOT that gate, which is the trap here: `isInteractive()` is TRUE
302
+ // over SSH. On a remote box there is usually no browser to open, `xdg-open` is absent
303
+ // and its failure is swallowed by design, and — decisively — the redirect targets
304
+ // 127.0.0.1 on whichever machine opened the link, so a human clicking on their laptop
305
+ // can never reach a listener on the server. See canOpenBrowser().
306
+ //
307
+ // It does not help an agent-driven `bind` either, which was the original hope. An
308
+ // agent running a blocking command does not see stdout until the process exits, so a
309
+ // URL printed at the top of a five-minute wait reaches nobody. Making cold `bind`
310
+ // agent-runnable needs a two-step shape — print and exit, then complete — which is a
311
+ // different change; see RTSC-678.
312
+ //
313
+ // Naming a provider (`--github` / `--google`) still means the device flow, because
314
+ // that flag is a statement about which upstream identity to use and this path does not
315
+ // take one.
316
+ // RTSC-713 — an agent with no browser door has nowhere to fall to, so say so NOW.
317
+ //
318
+ // Found by the test for this, which hung. The device flow below prints a code to be
319
+ // retyped and then asks which door; both need a TTY, and an agent has neither. Falling
320
+ // through left the process blocked on a prompt nobody could see, which is the worst
321
+ // available outcome: not a refusal an agent can relay, just silence until something
322
+ // times out. SSH, containers and CI all land here, and for all three the honest answer
323
+ // is immediate.
324
+ if (opts?.agentDriven && !canOpenBrowser(true)) {
325
+ throw new Error("no browser available on this machine, and signing in needs one. " +
326
+ "Over SSH or in a container the approve link cannot reach this host: sign in on a " +
327
+ "machine with a browser and bind there, or run `retasc login` yourself at a terminal.");
328
+ }
329
+ if (!provider && canOpenBrowser(opts?.agentDriven)) {
330
+ try {
331
+ await browserLogin(cfg.deploymentUrl, cfg.dashUrl, opts?.onUrl);
332
+ return;
333
+ }
334
+ catch (e) {
335
+ // Every failure here is recoverable by the door below, so say what happened and
336
+ // carry on rather than ending a sign-in that can still succeed. A container with
337
+ // no browser is the expected case, not an error.
338
+ const why = e instanceof Error ? e.message : String(e);
339
+ console.error(`Browser sign-in didn't complete (${why}). Falling back to a code.`);
340
+ // RTSC-713 — but an agent has no fallback to fall to. The device flow below prints
341
+ // a code for someone to retype and then asks which door, both of which need a TTY.
342
+ // Continuing would hang until timeout and then fail with a message about a prompt
343
+ // nobody saw. Rethrow so the caller reports SIGN_IN_FAILED, which is honest and
344
+ // immediate, instead of a wait that ends in a lie.
345
+ if (opts?.agentDriven)
346
+ throw e;
347
+ }
348
+ }
209
349
  const door = provider ?? (await chooseProvider({ remembered: cfg.loginProvider }));
210
350
  const convex = new ConvexHttpClient(cfg.deploymentUrl);
211
351
  // Exchange the upstream token for a Retasc (Convex Auth) session. The token is
@@ -5,10 +5,11 @@ import { loadConfig, patchConfig } from "../config.js";
5
5
  import { installMarker, printMarkerBlock } from "./mcp.js";
6
6
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
7
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
8
- import { resolveLauncher, launcherNote, runsOk, selfCommand, versionStamp } from "../lib/launcher.js";
8
+ import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp } from "../lib/launcher.js";
9
9
  import { ask, confirm, isInteractive } from "../lib/prompt.js";
10
10
  import { clean } from "../lib/text.js";
11
11
  import { card, DOT } from "../lib/card.js";
12
+ import { emit } from "../lib/outcome.js";
12
13
  import { VERSION } from "../version.js";
13
14
  // RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
14
15
  // can use them without closing an import cycle (bind → auth → bind). Re-exported
@@ -176,7 +177,10 @@ export async function chooseInstall(
176
177
  flag, deps = {}) {
177
178
  if (flag === false)
178
179
  return false;
179
- const onPath = deps.onPath ?? (() => runsOk("retasc") !== null);
180
+ // RTSC-715 the same durable-PATH question `resolveLauncher` asks. Under npx a bare
181
+ // `retasc` resolves into the npx cache, so this shortcut used to conclude "nothing to
182
+ // install" on a machine that had nothing installed.
183
+ const onPath = deps.onPath ?? (() => onDurablePath("retasc"));
180
184
  // Nothing to install, so nothing to ask.
181
185
  if (onPath())
182
186
  return true;
@@ -445,8 +449,14 @@ export async function completeWorkspaceSetup(args) {
445
449
  //
446
450
  // AFTER the confirmation, and after everything that can throw, so it is only ever
447
451
  // printed by a run that actually finished. Nothing below it can fail.
452
+ // RTSC-691 — the restart line prints UNCONDITIONALLY. It was gated on a TTY, which is
453
+ // false in exactly the case that needs it most: when an agent runs `bind`, the agent is
454
+ // the only reader of this output, and it is the one that has to tell the human to
455
+ // restart. A setup that finished but never says so is indistinguishable from one that
456
+ // failed. The "what now" suggestions stay TTY-only — those are for a person browsing.
457
+ console.log(`\n${NEXT_STEP}\n`);
448
458
  if (isInteractive())
449
- console.log(`\n${NEXT_STEP}\n\n${whatNow(pfx, emptyProject)}\n`);
459
+ console.log(`${whatNow(pfx, emptyProject)}\n`);
450
460
  }
451
461
  /**
452
462
  * The two sentences under `NEXT_STEP`, chosen by whether there is anything to pull yet
@@ -559,10 +569,20 @@ export const NEXT_STEP = "Start your agent in this folder, or restart it if it's
559
569
  * because "run this, but run the other one first" is not a single instruction. So the
560
570
  * sign-in is explicit, announced, and only when a human is present to authorize it.
561
571
  */
562
- export async function ensureSignedIn() {
572
+ export async function ensureSignedIn(agent) {
563
573
  if (loadConfig().token)
564
574
  return;
565
- if (!isInteractive()) {
575
+ // RTSC-713 — the no-TTY refusal now depends on whether a DOOR exists, not on whether
576
+ // this process has a terminal.
577
+ //
578
+ // The refusal was right about its own case and wrong about the one that matters here.
579
+ // Both doors need a human to act, so a process with nobody watching should stop fast.
580
+ // But an agent-driven run is not nobody watching: the human is in the chat, and the
581
+ // approve URL reaches them there. What actually has to be true is that a browser on
582
+ // this machine can reach the loopback listener, which is exactly what
583
+ // `canOpenBrowser()` decides. So ask it, instead of using "has a TTY" as a proxy for
584
+ // it — the proxy is what made cold `bind` unreachable to an agent.
585
+ if (!isInteractive() && !agent) {
566
586
  // RTSC-672 — the stamp rides in the HINT, not the message. `formatError` keeps only
567
587
  // the first line of a message (deliberately, to strip stack noise), so a second line
568
588
  // here would be silently dropped — which is how a diagnostic aimed at silent failures
@@ -573,10 +593,47 @@ export async function ensureSignedIn() {
573
593
  // announcing "GitHub" before the question would be wrong for the invited
574
594
  // teammate who signed up through Google — the exact person `join` is for.
575
595
  console.error("Not signed in yet — let's do that first.");
576
- await deviceLogin();
596
+ await deviceLogin(undefined, agent ? { agentDriven: true, onUrl: agent.onUrl } : undefined);
577
597
  }
578
598
  export async function bindAction(opts) {
579
- await ensureSignedIn();
599
+ // RTSC-713 — `--json` is the signal that a machine is driving, and it turns three
600
+ // things on at once: NDJSON on stdout, the browser door without a TTY, and continuable
601
+ // outcomes instead of thrown errors. One flag rather than three, because they are not
602
+ // independently useful: structured output nobody can act on, or a door that leads to a
603
+ // thrown Error, is each worse than neither.
604
+ const agent = opts.json
605
+ ? { onUrl: (url) => emit({ event: "approve_url", url }) }
606
+ : undefined;
607
+ const folder = process.cwd();
608
+ /** Continuable: say what is missing, exit 0. See lib/outcome.ts for why 0. */
609
+ const pause = (o) => {
610
+ if (agent)
611
+ emit({ event: "outcome", outcome: { ...o, continuable: true, folder } });
612
+ };
613
+ /** Terminal: nothing to be done from here, so exit non-zero and say so. */
614
+ const stop = (o) => {
615
+ if (agent)
616
+ emit({ event: "outcome", outcome: { ...o, continuable: false, folder } });
617
+ process.exitCode = 1;
618
+ };
619
+ try {
620
+ await ensureSignedIn(agent);
621
+ }
622
+ catch (e) {
623
+ if (!agent)
624
+ throw e;
625
+ // Sign-in was ATTEMPTED and did not complete. Distinct from arriving with no session,
626
+ // which is continuable — collapsing the two is what made the old refusal stop agents
627
+ // that had actually succeeded.
628
+ const why = e instanceof Error ? e.message : String(e);
629
+ stop({
630
+ state: /no browser|no door|RETASC_NO_BROWSER/i.test(why) ? "NO_SIGN_IN_DOOR" : "SIGN_IN_FAILED",
631
+ next: `Sign-in did not complete: ${why}. Tell your human what happened. ` +
632
+ `If this machine has no browser (a container, or SSH to a remote box), they can ` +
633
+ `sign in on a machine that does and bind there instead.`,
634
+ });
635
+ return;
636
+ }
580
637
  const cfg = loadConfig();
581
638
  // --- loud on re-bind -------------------------------------------------------
582
639
  // Runs before the org step on purpose: `--org-name` commits an org server-side, and a
@@ -611,6 +668,7 @@ export async function bindAction(opts) {
611
668
  if (!orgId) {
612
669
  const me = (await api.me());
613
670
  const orgs = me.orgs ?? [];
671
+ const pendingInvites = me.pendingInvites ?? [];
614
672
  if (isInteractive()) {
615
673
  const chosen = await pick("Select an org", orgs, (o) => `${o.name} (${o.slug})`);
616
674
  if (chosen) {
@@ -625,13 +683,94 @@ export async function bindAction(opts) {
625
683
  console.log(`✓ Created org "${name}".`);
626
684
  }
627
685
  }
628
- else if (orgs.length === 1) {
629
- orgId = orgs[0].id; // unambiguous in a non-interactive run
686
+ else if (orgs.length === 1 && (!agent || !pendingInvites.length)) {
687
+ // Unambiguous in a non-interactive run. The invite check is scoped to `agent` ON
688
+ // PURPOSE: a scripted `bind` with one org has bound to that org since RTSC-91, and
689
+ // making a pending invitation suddenly turn that into an error would break existing
690
+ // automation for a question a script cannot answer anyway. The agent CAN ask, so
691
+ // for it the invite outranks the count.
692
+ orgId = orgs[0].id;
693
+ }
694
+ else if (agent) {
695
+ // RTSC-713 — the handoff, and the state this whole issue exists for.
696
+ //
697
+ // Sign-in SUCCEEDED and the session is on disk. All that is missing is which org
698
+ // this folder is for, which is a question, not a failure. It used to be
699
+ // `throw new Error("no org selected — pass --org-id …")`, written for a human at a
700
+ // shell, and an agent reading a thrown Error correctly concluded the run had failed
701
+ // and stopped one question short of done.
702
+ //
703
+ // Invites are checked BEFORE the count, and outrank a single existing org: someone
704
+ // holding an invitation who also owns a personal org must still be offered the
705
+ // team, or `bind` quietly points this folder at the wrong workspace and the join
706
+ // they were sent never happens.
707
+ const signedInAs = me.user?.email ?? me.user?.name;
708
+ pause({
709
+ state: pendingInvites.length ? "NEEDS_JOIN_OR_CREATE" : "NEEDS_ORG",
710
+ signedInAs,
711
+ orgs,
712
+ pendingInvites: pendingInvites.length ? pendingInvites : undefined,
713
+ missing: ["--org-id or --org-name", "--project or --project-id"],
714
+ next: pendingInvites.length
715
+ ? `Signed in as ${signedInAs}. They have been invited to ` +
716
+ `${pendingInvites.map((i) => `"${i.org}"`).join(", ")}. ASK whether they want to join ` +
717
+ `that team or start their own workspace — do not choose for them. Joining uses ` +
718
+ `\`join\`; starting fresh re-runs bind with --org-name. Getting this wrong creates a ` +
719
+ `second organization that cannot be deleted.`
720
+ : orgs.length === 0
721
+ ? `Signed in as ${signedInAs}, with no organization yet. Ask what to call their ` +
722
+ `workspace and their first project, then re-run with --org-name, --project and ` +
723
+ `--prefix. Confirm the folder with them first: this will connect ${folder}.`
724
+ : `Signed in as ${signedInAs}, with ${orgs.length} organizations. Ask which one ` +
725
+ `THIS FOLDER is for and which project inside it, then re-run with --org-id and ` +
726
+ `--project-id. Confirm the folder with them: this will connect ${folder}. ` +
727
+ `If the folder plainly matches one of the projects and they pick another, say ` +
728
+ `so once, then do as they say.`,
729
+ });
730
+ return;
630
731
  }
631
732
  else {
632
733
  throw new Error("no org selected — pass --org-id <id> or --org-name <name> (or run interactively).");
633
734
  }
634
735
  }
736
+ // RTSC-713 — settle the project BEFORE entering the strand window, when an agent is
737
+ // driving.
738
+ //
739
+ // Ordering, not tidiness. `completeWorkspaceSetup` resolves the project itself and
740
+ // ends in `cliError("AMBIGUOUS", …)` when it cannot, but by then this run may already
741
+ // have created an org — and an abort past that point strands one on the billing rail
742
+ // with no CLI delete (RTSC-297). Asking here means the pause happens while there is
743
+ // still nothing to strand.
744
+ //
745
+ // `firstProject()`, the from-scratch-or-import fork that mirrors the Dash's "where does
746
+ // your work come from?", is TTY-only and stays that way. Its question is now asked in
747
+ // chat instead, which is why the instruction below names the five trackers: without it
748
+ // someone arriving from Jira invents a project they did not want and leaves an empty
749
+ // one behind that cannot be deleted (RTSC-530).
750
+ if (agent && !opts.projectId && !(opts.project && opts.prefix)) {
751
+ const { projects } = (await api.listProjects({ orgId: orgId }));
752
+ const list = projects ?? [];
753
+ if (list.length === 1) {
754
+ opts = { ...opts, projectId: list[0].id }; // not a choice; asking would be noise
755
+ }
756
+ else {
757
+ pause({
758
+ state: "NEEDS_PROJECT",
759
+ org: { id: orgId, name: opts.orgName ?? orgId },
760
+ projects: list,
761
+ missing: list.length ? ["--project-id"] : ["--project", "--prefix"],
762
+ next: list.length
763
+ ? `That organization has ${list.length} projects. Ask which one THIS FOLDER is ` +
764
+ `for, then re-run with --project-id. Confirm the folder with them: this will ` +
765
+ `connect ${folder}.`
766
+ : `That organization has no projects yet. Ask what they are working on, and ` +
767
+ `whether they want to bring a backlog across from Linear, Jira, Asana, ClickUp ` +
768
+ `or Shortcut rather than start empty. Then re-run with --project and --prefix. ` +
769
+ `Confirm the folder with them: this will connect ${folder}.`,
770
+ });
771
+ return;
772
+ }
773
+ }
635
774
  // Everything from here to the binding write is the strand window: the org
636
775
  // exists but the workspace isn't bound yet. If we abort in it after creating
637
776
  // the org this run, name it and print the resume command (RTSC-297).
@@ -652,6 +791,31 @@ export async function bindAction(opts) {
652
791
  const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
653
792
  if (hint)
654
793
  console.error(hint);
794
+ if (agent) {
795
+ // The prose hint above is written for a terminal. An agent needs the same facts as
796
+ // a state it can act on, and this one IS terminal: a stranded org needs a human
797
+ // decision, not another pass.
798
+ stop({
799
+ state: "REFUSED",
800
+ org: { id: orgId, name: opts.orgName ?? orgId },
801
+ next: `Setup failed after the organization was created: ` +
802
+ `${err instanceof Error ? err.message : String(err)}. ` +
803
+ `Tell your human, and pass on that the organization already exists, so a retry ` +
804
+ `must use --org-id ${orgId} rather than --org-name, or they end up with two.`,
805
+ });
806
+ return;
807
+ }
655
808
  throw err;
656
809
  }
810
+ // Bound. The folder, the org and the project are all settled and on disk, and the one
811
+ // thing left is the restart — which the agent has to ask for, because a client reads
812
+ // its MCP config at startup and nothing here can do it for them.
813
+ pause({
814
+ state: "BOUND",
815
+ org: { id: orgId },
816
+ next: `Setup is complete and ${folder} is connected. TELL YOUR HUMAN it worked, name the ` +
817
+ `folder and the project, and ask them to restart you — your Retasc tools only load ` +
818
+ `when your client starts, so setup is not finished until they do. A resumed session ` +
819
+ `does not count; it has to be a new one. After the restart, call setup_status.`,
820
+ });
657
821
  }
@@ -109,7 +109,11 @@ export async function claimAction(opts) {
109
109
  // point at the worktree instead of leaving the user stuck on the error.
110
110
  if (requestedId && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
111
111
  note(` ${requestedId} is held. If it's your session, resume in ${parentDir}/${repoName}-${requestedId.toLowerCase()}`);
112
- note(` or \`retasc release ${requestedId}\` first.`);
112
+ // Deliberately does NOT suggest `release` here. A lease held by a DIFFERENT session
113
+ // is fenced: releasing it needs that session's claim token, which this caller does
114
+ // not have and should not have. Naming a command they cannot run is the dead end
115
+ // this hint exists to avoid. If it really is abandoned, the reclaimer frees it.
116
+ note(` If it isn't yours, wait for the lease to lapse — the server reclaims it automatically.`);
113
117
  }
114
118
  process.exit(1);
115
119
  }
@@ -186,7 +190,11 @@ export async function claimAction(opts) {
186
190
  note(` The branch already exists — attach without -b: git worktree add ${plan.path} ${plan.branch}`);
187
191
  }
188
192
  else {
189
- note(` Resolve the error and retry, or \`retasc release ${issueId}\` to return it to the pool.`);
193
+ // We hold this lease and its token, so name the command that actually works,
194
+ // token included — the caller has no other way to get it once this exits.
195
+ note(claim.claimToken
196
+ ? ` Resolve the error and retry, or return it to the pool:\n retasc release ${issueId} --claim-token ${claim.claimToken}`
197
+ : ` Resolve the error and retry.`);
190
198
  }
191
199
  process.exit(1);
192
200
  }
@@ -225,3 +233,48 @@ function finish(path, branch, issueId, title, claimToken, opts) {
225
233
  note("");
226
234
  note(` cd ${path}`);
227
235
  }
236
+ /**
237
+ * `retasc release <RTSC-NN>` — hand a claimed issue back to the queue (RTSC-691).
238
+ *
239
+ * This command is here because two error paths in this same file already told people to
240
+ * run it (`claim.ts`, the "already held" and "worktree failed" hints) and it did not
241
+ * exist. A CLI that names a command it does not have is worse than one that says nothing:
242
+ * it sends somebody to a dead end at the exact moment they are already stuck.
243
+ *
244
+ * Deliberately does NOT touch git. The worktree and branch may hold real work, and this
245
+ * only returns the LEASE — `retasc tidy` is the command that reaps checkouts, and it
246
+ * refuses to remove anything dirty or unmerged. Keeping the two separate means giving
247
+ * work back is never the thing that loses it.
248
+ */
249
+ export async function releaseAction(opts) {
250
+ const raw = opts.id ?? opts.issueArg;
251
+ if (!raw) {
252
+ throw new Error("Which issue? Pass one, e.g. `retasc release RTSC-42 --claim-token <token>`.");
253
+ }
254
+ const identifier = normalizeIssueId(raw);
255
+ if (!isValidIssueId(identifier)) {
256
+ throw new Error(`Not an issue id: ${raw}. Expected something like RTSC-42.`);
257
+ }
258
+ // The token is REQUIRED by the server and is not optional here either.
259
+ //
260
+ // `release_issue` is fenced: `assertHolds(issue, claimToken)` proves the caller is the
261
+ // session that actually holds the lease, which is the whole reason one agent cannot
262
+ // release another's work. The CLI does not persist claim tokens anywhere, so it cannot
263
+ // look one up — it prints the token when it claims, and this is where that print gets
264
+ // used. Refusing here, with the reason, beats sending a call the server will reject
265
+ // with a fence error the reader has no context for.
266
+ if (!opts.claimToken) {
267
+ throw new Error(`Releasing ${identifier} needs its claim token: \`retasc release ${identifier} --claim-token <token>\`.\n` +
268
+ ` The token is printed when you claim, and is in the \`--json\` output of \`retasc claim\`.\n` +
269
+ ` It proves you are the session holding the lease — without it the server cannot tell\n` +
270
+ ` your release from someone else's, and refuses.`);
271
+ }
272
+ const conn = resolveMcpConn({ mcpJson: readMcpJson() });
273
+ await mcpCall(conn, "release_issue", {
274
+ identifier,
275
+ claimToken: opts.claimToken,
276
+ ...(opts.note ? { note: opts.note } : {}),
277
+ });
278
+ console.log(`✓ Released ${identifier} back to the queue.`);
279
+ console.log(" Its worktree and branch are untouched — `retasc tidy` reaps those.");
280
+ }
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { resolveLauncher, launcherNote, portableLauncher, } from "../lib/launcher.js";
5
5
  import { VERSION } from "../version.js";
6
+ import { hasClaudeLocalPlaceholder } from "../lib/binding.js";
6
7
  export const SERVER_NAME = "retasc";
7
8
  /** Normalize a user-supplied scope string. `user` (global) is refused and
8
9
  * downgraded to `local`, loudly — per-folder binding is the only right way. */
@@ -207,6 +208,37 @@ export function installMcp(opts) {
207
208
  console.log(mcpConfigBlock(opts.url, opts.key));
208
209
  console.log(`\nYour agent can now reach Retasc at ${opts.url}.`);
209
210
  }
211
+ /**
212
+ * Clear a credential-less placeholder that would SHADOW the marker we are about to write
213
+ * (RTSC-691).
214
+ *
215
+ * Only ever removes an entry `isPlaceholderEntry` recognises — a bare URL with no
216
+ * credential — and only from the scope we are NOT writing to. Writing our own scope
217
+ * overwrites in place, so there is nothing to clean there.
218
+ *
219
+ * Removal goes through `claude mcp remove`, never by editing `~/.claude.json` ourselves.
220
+ * That file is rewritten constantly by a running Claude Code, so a read-modify-write from
221
+ * here would race it and could drop unrelated servers. Best-effort throughout: failing to
222
+ * tidy a placeholder must never fail a bind that otherwise succeeded, and `doctor`
223
+ * already reports a shadowed binding if one survives.
224
+ */
225
+ function clearShadowingPlaceholder(scope) {
226
+ const dir = process.cwd();
227
+ // We wrote claude-local, so a folder placeholder is now harmless (local wins). We wrote
228
+ // the folder, so a claude-local placeholder would take every call — that is the one to
229
+ // clear.
230
+ if (scope === "local")
231
+ return;
232
+ if (!hasClaudeLocalPlaceholder(dir))
233
+ return;
234
+ const r = spawnSync("claude", ["mcp", "remove", "retasc", "-s", "local"], { encoding: "utf8" });
235
+ if (r.error || r.status !== 0) {
236
+ console.log(" Note: a credential-less `retasc` entry is still registered in Claude Code's local scope\n" +
237
+ " and will shadow this one. Remove it with `claude mcp remove retasc -s local`.");
238
+ return;
239
+ }
240
+ console.log("✓ Removed the placeholder `retasc` entry that would have shadowed this binding.");
241
+ }
210
242
  /**
211
243
  * RTSC-92: wire the SECRET-FREE watchdog marker into this folder. The key lives
212
244
  * in the home keystore under `workspaceId`; this writes only the pointer (env
@@ -231,10 +263,14 @@ export function installMarker(opts) {
231
263
  if (res.ok) {
232
264
  if (!opts.quiet)
233
265
  console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
266
+ clearShadowingPlaceholder(scope);
234
267
  return { where: `Claude Code (${scope})`, wroteFile: false };
235
268
  }
236
269
  // Always ./.mcp.json, whatever scope was asked for, so always the shared form.
237
270
  const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
271
+ // We just wrote the folder marker, whatever scope was asked for, so a claude-local
272
+ // placeholder would now outrank it.
273
+ clearShadowingPlaceholder("project");
238
274
  if (!opts.quiet) {
239
275
  console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
240
276
  console.log(` ${fallbackNote(res)}`);
package/dist/config.js CHANGED
@@ -5,9 +5,11 @@ import { randomUUID } from "node:crypto";
5
5
  // Production defaults. Overridable via env for dev/testing.
6
6
  // RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
7
7
  // RETASC_MCP_URL — the MCP endpoint agents connect to
8
+ // RETASC_DASH_URL — the Dash, for the browser sign-in round trip (RTSC-691)
8
9
  export const DEFAULTS = {
9
10
  deploymentUrl: process.env.RETASC_DEPLOYMENT_URL ?? "https://unique-lyrebird-934.convex.cloud",
10
11
  mcpUrl: process.env.RETASC_MCP_URL ?? "https://mcp.retasc.com/mcp",
12
+ dashUrl: process.env.RETASC_DASH_URL ?? "https://dash.retasc.com",
11
13
  };
12
14
  /** The dir config lives in. RETASC_DIR overrides it (tests, sandboxes) — same
13
15
  * knob the keystore reads, so the two stay co-located. */
@@ -50,6 +52,7 @@ export function loadConfig() {
50
52
  return {
51
53
  deploymentUrl: stored.deploymentUrl ?? DEFAULTS.deploymentUrl,
52
54
  mcpUrl: stored.mcpUrl ?? DEFAULTS.mcpUrl,
55
+ dashUrl: stored.dashUrl ?? DEFAULTS.dashUrl,
53
56
  token: stored.token,
54
57
  refreshToken: stored.refreshToken,
55
58
  user: stored.user,
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { selfCommand, versionStamp } from "./lib/launcher.js";
5
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
6
6
  import { installMcp, normalizeScope } from "./commands/mcp.js";
7
7
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
8
- import { claimAction } from "./commands/claim.js";
8
+ import { claimAction, releaseAction } from "./commands/claim.js";
9
9
  import { bindAction, setupFromToken } from "./commands/bind.js";
10
10
  import { joinAction } from "./commands/join.js";
11
11
  import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
@@ -195,6 +195,13 @@ program
195
195
  // RTSC-495 — the agent's door. Everything this command normally asks was already
196
196
  // answered in the Dash, so the token stands in for all of it and nothing is prompted.
197
197
  .option("--setup <token>", "Complete setup from a Dash setup code — no sign-in, no prompts")
198
+ // RTSC-713 — the OTHER agent door, and the one that needs no Dash trip first.
199
+ //
200
+ // Three behaviours, deliberately on one flag because none is useful alone: NDJSON
201
+ // outcomes on stdout, the browser door without a TTY, and continuable states reported
202
+ // at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
203
+ // that leads to a thrown Error, is each worse than neither.
204
+ .option("--json", "Report machine-readable outcomes (for an agent driving setup)")
198
205
  .action(async (opts) => {
199
206
  // BEFORE requireLogin: the whole point is a machine that has never signed in. The
200
207
  // token is the authorization, and asking for a session here would refuse every
@@ -593,6 +600,13 @@ program
593
600
  // Normalize --only to the canonical uppercase id so it matches the branch ids
594
601
  // scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
595
602
  tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
603
+ program
604
+ .command("release [issue]")
605
+ .description("Hand a claimed issue back to the queue. Leaves your worktree and branch alone.")
606
+ .option("--id <RTSC-NN>", "The issue to release (or pass it positionally)")
607
+ .requiredOption("--claim-token <token>", "The claim token printed when you claimed it (the server fences on it)")
608
+ .option("--note <text>", "Handoff note for whoever picks it up next")
609
+ .action((issueArg, opts) => releaseAction({ issueArg, ...opts }).catch(fail));
596
610
  program
597
611
  .command("done")
598
612
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
@@ -119,6 +119,55 @@ export function readShadowedBinding(dir) {
119
119
  return undefined;
120
120
  return parseServerEntry(folderEntry(dir), "folder");
121
121
  }
122
+ /**
123
+ * Is this a PLACEHOLDER entry — a bare URL with no credential of any kind (RTSC-691)?
124
+ *
125
+ * RTSC-678's flow starts by pointing the client at `https://mcp.retasc.com/mcp` with
126
+ * nothing else in it, so the server can answer the handshake with setup instructions.
127
+ * That entry is not a binding: it cannot authenticate, and every tool call through it
128
+ * fails. It exists to be replaced by the real one `bind` writes.
129
+ *
130
+ * It has to be recognised so it can be REMOVED, because scope precedence would otherwise
131
+ * make it win. `claude mcp add -t http` writes Claude-local scope by default; a `bind
132
+ * --scope project` writes `./.mcp.json`; and claude-local beats the folder marker at
133
+ * runtime (see the precedence note at the top of this file). Left in place, the finished
134
+ * setup would sit in a file nothing reads while the credential-less entry took every
135
+ * call — a setup that looks done and works nowhere.
136
+ *
137
+ * Deliberately narrow: a `url` and NO `command`, no env credential, no Authorization
138
+ * header. Anything carrying a credential, or spawning something, is somebody's real
139
+ * configuration and is never silently removed.
140
+ */
141
+ export function isPlaceholderEntry(raw, knownUrls = PLACEHOLDER_URLS) {
142
+ if (!raw || typeof raw !== "object")
143
+ return false;
144
+ if (typeof raw.url !== "string" || raw.url.length === 0)
145
+ return false;
146
+ if (typeof raw.command === "string")
147
+ return false;
148
+ if (raw.env && Object.keys(raw.env).length > 0)
149
+ return false;
150
+ // ANY header, not just Authorization. A gateway that injects credentials under some
151
+ // other name is still somebody's real configuration.
152
+ if (raw.headers && Object.keys(raw.headers).length > 0)
153
+ return false;
154
+ // And it must be the bootstrap URL we ourselves tell people to paste. This is the
155
+ // clause that matters: Claude Code stores MCP OAuth tokens OUTSIDE the server entry,
156
+ // keyed by server name, so a fully authorized OAuth binding is byte-identical to a
157
+ // placeholder by shape alone. RTSC-678 — this issue's parent — is about adding exactly
158
+ // that OAuth door, so shape-only matching would have grown a way to silently delete a
159
+ // working binding at the moment we shipped one.
160
+ return knownUrls.includes(normalizeUrl(raw.url));
161
+ }
162
+ /** The bare URLs we hand out for bootstrapping, normalized. */
163
+ const PLACEHOLDER_URLS = ["https://mcp.retasc.com/mcp"];
164
+ function normalizeUrl(u) {
165
+ return u.trim().replace(/\/+$/, "").toLowerCase();
166
+ }
167
+ /** Is there a placeholder in Claude Code's LOCAL scope for this folder? */
168
+ export function hasClaudeLocalPlaceholder(dir) {
169
+ return isPlaceholderEntry(claudeLocalRetascEntry(dir));
170
+ }
122
171
  /** A user-scope (global) Retasc server: top-level in Claude Code's config,
123
172
  * NOT under projects[dir]. Illegal under DESIGN §13 override #1 — it applies
124
173
  * to every folder that has no binding of its own, so issues can land in the
@@ -0,0 +1,80 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ /** How long to wait for the human to click Approve. Generous: they may have to sign in
4
+ * to the Dash first, and possibly through GitHub or Google after that. */
5
+ const WAIT_MS = 5 * 60 * 1000;
6
+ /**
7
+ * Open a loopback listener and return the URL to send the browser to, plus a promise
8
+ * that resolves when the browser comes back.
9
+ *
10
+ * Split in two on purpose. The caller needs the URL BEFORE the wait starts, so it can
11
+ * print it — a printed URL is what makes this work when the browser fails to open, which
12
+ * is the common case on a headless Linux box where `xdg-open` is absent.
13
+ */
14
+ export async function startBrowserLogin(dashUrl) {
15
+ const state = randomBytes(24).toString("hex");
16
+ let resolve;
17
+ let reject;
18
+ const settled = new Promise((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ // The timeout below rejects this promise whether or not anyone is awaiting it. A caller
23
+ // that takes `url`/`cancel` and never calls `wait()` would otherwise get an unhandled
24
+ // rejection, which is fatal on Node 15+. Attaching an inert handler at construction
25
+ // makes the promise safe to ignore; `wait()` still sees the real rejection.
26
+ settled.catch(() => { });
27
+ const server = createServer((req, res) => {
28
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
29
+ const code = url.searchParams.get("code");
30
+ const gotState = url.searchParams.get("state");
31
+ // The browser is a person's, so answer it in a way a person can read. Whatever
32
+ // happens here, the tab is finished with.
33
+ const reply = (status, body) => {
34
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
35
+ res.end(`<!doctype html><meta charset="utf-8"><title>Retasc</title>` +
36
+ `<body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${body}</body>`);
37
+ };
38
+ if (!code || gotState !== state) {
39
+ // A mismatched nonce means this redirect did not come from the flow we started.
40
+ // The code is not forwarded, so it expires unspent two minutes later.
41
+ reply(400, "<h1>Sign-in could not be verified</h1><p>Close this tab and run <code>retasc login</code> again.</p>");
42
+ return;
43
+ }
44
+ reply(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
45
+ resolve({ code });
46
+ });
47
+ await new Promise((res, rej) => {
48
+ server.once("error", rej);
49
+ // Port 0: the OS picks a free one. Hard-coding a port would collide with a second
50
+ // `retasc login` and with whatever else the developer is running.
51
+ server.listen(0, "127.0.0.1", res);
52
+ });
53
+ const port = server.address().port;
54
+ const timer = setTimeout(() => {
55
+ reject(new Error(`TIMEOUT: no response from the browser within ${WAIT_MS / 60_000} minutes`));
56
+ }, WAIT_MS);
57
+ // Do not hold the process open on this timer alone.
58
+ timer.unref?.();
59
+ const close = () => {
60
+ clearTimeout(timer);
61
+ // `close()` stops accepting but leaves the browser's idle keep-alive socket open, and
62
+ // that handle holds the event loop until Node's 5s keepAliveTimeout fires — so the
63
+ // CLI would appear to hang for five seconds after a successful sign-in.
64
+ server.closeAllConnections?.();
65
+ server.close();
66
+ };
67
+ return {
68
+ url: `${dashUrl.replace(/\/+$/, "")}/cli-auth?port=${port}&state=${encodeURIComponent(state)}`,
69
+ port,
70
+ wait: async () => {
71
+ try {
72
+ return await settled;
73
+ }
74
+ finally {
75
+ close();
76
+ }
77
+ },
78
+ cancel: close,
79
+ };
80
+ }
@@ -37,6 +37,59 @@ export function runsOk(command, args = []) {
37
37
  // different program, and pointing a marker at it would be worse than not writing one.
38
38
  return /^\d+\.\d+\.\d+/.test(out) ? out : null;
39
39
  }
40
+ /**
41
+ * Is `retasc` on PATH in a way that OUTLIVES this process (RTSC-715)?
42
+ *
43
+ * `runsOk("retasc")` answers a subtly different question, and the difference cost a
44
+ * fresh-machine setup its tools. When `bind` runs under `npx @retasc/cli@latest`, npx puts
45
+ * its own cache directory on the child's PATH, so `retasc` resolves and runs:
46
+ *
47
+ * outside npx: which retasc => /Users/me/.npm-global/bin/retasc
48
+ * inside npx: which retasc => /Users/me/.npm/_npx/ccd2a9…/node_modules/.bin/retasc
49
+ *
50
+ * `resolveLauncher` read that as "already usable, leave it alone" and wrote a bare
51
+ * `retasc` into the marker, marked verified, having genuinely run it. Then npx exited,
52
+ * that directory left PATH, and the MCP client spawning the proxy got
53
+ * `ENOENT: Executable not found in $PATH: "retasc"`. Setup reported success, the Dash
54
+ * showed a healthy workspace, and no tools loaded.
55
+ *
56
+ * The probe was not sloppy. It measured the right thing on the wrong PATH: the one `bind`
57
+ * inherited, rather than the one the agent spawns with later. So resolve the command to a
58
+ * real path and reject the npx cache, which is transient by construction — the whole point
59
+ * of `_npx` is that it is not a durable install.
60
+ *
61
+ * Resolved with `which`/`where` rather than by walking PATH ourselves: that is the lookup
62
+ * the shell will actually do, PATHEXT and all, and reimplementing it is how this class of
63
+ * bug gets a second edition.
64
+ *
65
+ * Anything unresolvable answers FALSE, deliberately. Every caller fallback (global install,
66
+ * absolute path, pinned npx) still produces something that runs, whereas a wrong "yes"
67
+ * produces a marker that cannot start. The two errors are not symmetric.
68
+ */
69
+ export function onDurablePath(command = "retasc") {
70
+ let r;
71
+ try {
72
+ r = spawnSync(WIN ? "where" : "which", [command], {
73
+ encoding: "utf8",
74
+ shell: WIN,
75
+ timeout: 60_000,
76
+ });
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ if (r.error || r.status !== 0)
82
+ return false;
83
+ // `where` can print several matches; the first is the one that would run.
84
+ const resolved = (r.stdout || "").trim().split(/\r?\n/)[0]?.trim();
85
+ if (!resolved)
86
+ return false;
87
+ // The npx cache. Separators on both sides, so a project that happens to live in a
88
+ // directory called `_npx` is not caught by it.
89
+ if (/[\\/]_npx[\\/]/.test(resolved))
90
+ return false;
91
+ return runsOk(resolved) !== null;
92
+ }
40
93
  /** npm's global prefix, or null when npm itself can't be run. */
41
94
  export function npmGlobalPrefix() {
42
95
  let r;
@@ -156,8 +209,12 @@ function installGlobal(version) {
156
209
  * anyone asking. It is pinned here for exactly that reason.
157
210
  */
158
211
  export function resolveLauncher(opts) {
159
- // 1. Already usable? Leave it alone.
160
- if (runsOk("retasc")) {
212
+ // 1. Already usable, and still usable after we exit? Leave it alone.
213
+ //
214
+ // RTSC-715 — `onDurablePath`, not `runsOk`. Under npx the bare name resolves into the
215
+ // npx cache, which disappears when the command ends, so this branch used to write a
216
+ // marker that could not start.
217
+ if (onDurablePath("retasc")) {
161
218
  return { launcher: { command: "retasc", args: [] }, how: "on-path", verified: true };
162
219
  }
163
220
  const npxLauncher = { command: "npx", args: ["-y", `${PKG}@${opts.version}`] };
@@ -176,7 +233,12 @@ export function resolveLauncher(opts) {
176
233
  // 3. Prove it, by running it. An exit code of 0 is not evidence the command resolves:
177
234
  // npm can install happily into a prefix whose bin directory PATH never searches.
178
235
  if (!failure) {
179
- if (runsOk("retasc")) {
236
+ // RTSC-715 — `onDurablePath` here too, and this one is the subtler half. Under npx the
237
+ // bare name still resolves to the npx cache AHEAD of the global bin we just installed
238
+ // into, so a successful install would have been confirmed by running the wrong binary
239
+ // and written the same unusable marker. The absolute-path loop below then covers the
240
+ // real case this branch exists for: installed, but PATH cannot see it.
241
+ if (onDurablePath("retasc")) {
180
242
  return { launcher: { command: "retasc", args: [] }, how: "installed", verified: true };
181
243
  }
182
244
  // Installed, but PATH can't see it. Name the file directly — this is the case a
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What `bind` reports when an AGENT is driving it (RTSC-713).
3
+ *
4
+ * # Why this exists
5
+ *
6
+ * An agent decides whether to keep going from three signals, and all three are
7
+ * structural rather than semantic: the exit code, which stream the output came on, and
8
+ * whether the text reads like a fault. Before this module, "signed in, session written,
9
+ * nothing named yet" fired all three:
10
+ *
11
+ * ✗ UNAUTHENTICATED: Not signed in. Run `npx -y @retasc/cli@1.30.0 login` first
12
+ * (no TTY here for the device flow).
13
+ *
14
+ * An agent that stops there is reading the signals correctly. The signals were wrong.
15
+ * Sign-in had succeeded, the session was on disk, and the only thing left was a question
16
+ * it could have asked its human in one sentence.
17
+ *
18
+ * So the rule this module enforces: **the exit code and the framing encode "can I
19
+ * continue?", not "did something unexpected happen?"** A state the flow can be resumed
20
+ * from exits 0 on stdout and never says error, however incomplete it is. Only a genuine
21
+ * dead end — nothing the agent or its human can do from here — exits non-zero.
22
+ *
23
+ * # Why not just word the instruction better
24
+ *
25
+ * Telling the agent "if you see UNAUTHENTICATED it is fine, keep going" asks a model to
26
+ * override the strongest stop signal it has on the strength of a sentence it read
27
+ * earlier. Sometimes it will and sometimes it will not, and which one is not something
28
+ * this codebase controls. Exiting 0 is deterministic. This is the same reasoning that
29
+ * put the claim nudge and the setup instructions in the server response rather than in
30
+ * CLAUDE.md: a mechanism beats advice.
31
+ *
32
+ * # Why the states live HERE and not in the server's instruction string
33
+ *
34
+ * The obvious alternative was a lookup table in `convex/lib/setupInstructions.ts`: "if
35
+ * the CLI says X do this, if it says Y do that." That is a second copy of this list,
36
+ * living in a different language, in a different deploy cycle, maintained by whoever
37
+ * touches it next. They diverge, and the divergence is invisible until an agent acts on
38
+ * a state that no longer exists.
39
+ *
40
+ * This repo has already paid for that once. `setupInstructions.ts` opens by explaining
41
+ * it was lifted out of `http.ts` "because it now has TWO readers and they must never
42
+ * diverge", and RTSC-694 was an entire issue spent collapsing one setup command into one
43
+ * place. So: the CLI owns its states and each one carries its own next step, and the
44
+ * server instruction says only "run this, act on what it tells you." Adding a state
45
+ * costs one change, in this file, and no server string can go stale.
46
+ *
47
+ * There is a test that greps for these code strings under `convex/`. If one ever appears
48
+ * there, the split has been broken.
49
+ */
50
+ /**
51
+ * Every terminal outcome of an agent-driven `bind`, in two classes.
52
+ *
53
+ * CONTINUABLE — the flow is alive and wants something. Exit 0, stdout, no fault
54
+ * vocabulary. The agent reads it, asks its human the one question that applies, and runs
55
+ * the next pass.
56
+ *
57
+ * TERMINAL — nothing the agent or its human can do inside this flow. Exit non-zero and
58
+ * say so plainly, because stopping is genuinely the right move.
59
+ *
60
+ * Note where NEEDS_SIGN_IN sits. Reaching a command with no session, having not yet
61
+ * tried to get one, is CONTINUABLE: the answer is to sign in, not to give up. Only a
62
+ * sign-in that was attempted and failed is terminal. Collapsing those two was the
63
+ * original bug.
64
+ */
65
+ export const CONTINUABLE_STATES = [
66
+ /** No session, and no attempt has been made yet. The browser door is available. */
67
+ "NEEDS_SIGN_IN",
68
+ /** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
69
+ "NEEDS_ORG",
70
+ /** Org settled. Zero projects, or more than one, so which project is unknown. */
71
+ "NEEDS_PROJECT",
72
+ /**
73
+ * Signed in, and holding an invitation. The choice is join-or-create and it is the
74
+ * human's to make.
75
+ *
76
+ * NOT called PENDING_INVITE, deliberately: `convex/setupStatus.ts` already has a state
77
+ * by that name and it means something else — a membership offer the server is
78
+ * surfacing, rather than the reason `bind` stopped. Two different things sharing one
79
+ * identifier across the boundary is precisely the confusion this file's split exists to
80
+ * prevent, so this one is named for the decision it is asking for.
81
+ */
82
+ "NEEDS_JOIN_OR_CREATE",
83
+ /** Everything named and written. The folder is bound; the client must restart. */
84
+ "BOUND",
85
+ ];
86
+ export const TERMINAL_STATES = [
87
+ /** Sign-in was attempted and did not complete: declined, timed out, or rejected. */
88
+ "SIGN_IN_FAILED",
89
+ /** No door exists: no browser reachable and no TTY. SSH and CI land here. */
90
+ "NO_SIGN_IN_DOOR",
91
+ /** This folder is already bound and the run was refused rather than silently re-pointed. */
92
+ "ALREADY_BOUND",
93
+ /** The credential was refused: revoked key, suspended member, deleted workspace. */
94
+ "REFUSED",
95
+ ];
96
+ export function isContinuable(state) {
97
+ return CONTINUABLE_STATES.includes(state);
98
+ }
99
+ /** stdout, one line, no trailing prose. Never stderr: see the module header. */
100
+ export function emit(e) {
101
+ process.stdout.write(JSON.stringify(e) + "\n");
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "description": "Retasc CLI \u2014 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": {