@quickchatai/quickreview 1.1.0 → 1.2.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.
Files changed (225) hide show
  1. package/README.md +35 -19
  2. package/config.example.toml +41 -9
  3. package/dist/extensions/cli/login-route.js +5 -0
  4. package/dist/extensions/cli/login-route.js.map +1 -0
  5. package/dist/extensions/login-route.js +180 -0
  6. package/dist/extensions/login-route.js.map +1 -0
  7. package/dist/extensions/onboarding.js +29 -6
  8. package/dist/extensions/onboarding.js.map +1 -1
  9. package/dist/src/ciretry.js +2 -1
  10. package/dist/src/ciretry.js.map +1 -1
  11. package/dist/src/cli.js +43 -12
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/cliops.js +3 -2
  14. package/dist/src/cliops.js.map +1 -1
  15. package/dist/src/commands.js +25 -2
  16. package/dist/src/commands.js.map +1 -1
  17. package/dist/src/config.js +16 -2
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/daemon.js +24 -8
  20. package/dist/src/daemon.js.map +1 -1
  21. package/dist/src/debugroundpi.js +5 -1
  22. package/dist/src/debugroundpi.js.map +1 -1
  23. package/dist/src/dispatch.js +5 -4
  24. package/dist/src/dispatch.js.map +1 -1
  25. package/dist/src/engine/debugpi.js +17 -5
  26. package/dist/src/engine/debugpi.js.map +1 -1
  27. package/dist/src/execboundary.js +10 -3
  28. package/dist/src/execboundary.js.map +1 -1
  29. package/dist/src/github.js +51 -16
  30. package/dist/src/github.js.map +1 -1
  31. package/dist/src/githubtoken.js +346 -0
  32. package/dist/src/githubtoken.js.map +1 -0
  33. package/dist/src/loginhint.js +8 -3
  34. package/dist/src/loginhint.js.map +1 -1
  35. package/dist/src/loginrefresh/pirefresh.js +3 -0
  36. package/dist/src/loginrefresh/pirefresh.js.map +1 -1
  37. package/dist/src/npmverbs.js +4 -2
  38. package/dist/src/npmverbs.js.map +1 -1
  39. package/dist/src/onboard/choices.js +29 -1
  40. package/dist/src/onboard/choices.js.map +1 -1
  41. package/dist/src/onboard/engine.js +8 -23
  42. package/dist/src/onboard/engine.js.map +1 -1
  43. package/dist/src/onboard/launch.js +12 -20
  44. package/dist/src/onboard/launch.js.map +1 -1
  45. package/dist/src/onboard/pipin.js +22 -0
  46. package/dist/src/onboard/pipin.js.map +1 -0
  47. package/dist/src/onboard/stepforward.js +41 -0
  48. package/dist/src/onboard/stepforward.js.map +1 -0
  49. package/dist/src/onboard/steps/appslug.js +4 -1
  50. package/dist/src/onboard/steps/appslug.js.map +1 -1
  51. package/dist/src/onboard/steps/compose.js +29 -0
  52. package/dist/src/onboard/steps/compose.js.map +1 -1
  53. package/dist/src/onboard/steps/configfill.js +102 -32
  54. package/dist/src/onboard/steps/configfill.js.map +1 -1
  55. package/dist/src/onboard/steps/githubaccount.js +595 -0
  56. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  57. package/dist/src/onboard/steps/githubapp.js +65 -7
  58. package/dist/src/onboard/steps/githubapp.js.map +1 -1
  59. package/dist/src/onboard/steps/golive.js +2 -0
  60. package/dist/src/onboard/steps/golive.js.map +1 -1
  61. package/dist/src/onboard/steps/index.js +11 -0
  62. package/dist/src/onboard/steps/index.js.map +1 -1
  63. package/dist/src/onboard/steps/ingress.js +80 -25
  64. package/dist/src/onboard/steps/ingress.js.map +1 -1
  65. package/dist/src/onboard/steps/llmauth.js +103 -30
  66. package/dist/src/onboard/steps/llmauth.js.map +1 -1
  67. package/dist/src/onboard/steps/memory.js +53 -1
  68. package/dist/src/onboard/steps/memory.js.map +1 -1
  69. package/dist/src/onboard/steps/proof.js +366 -56
  70. package/dist/src/onboard/steps/proof.js.map +1 -1
  71. package/dist/src/onboard/steps/topology.js +6 -6
  72. package/dist/src/onboard/steps/topology.js.map +1 -1
  73. package/dist/src/onboard/steps/transport.js +9 -1
  74. package/dist/src/onboard/steps/transport.js.map +1 -1
  75. package/dist/src/onboard/steps/tuiroute.js +102 -0
  76. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  77. package/dist/src/onboard/steps/verifyup.js +17 -1
  78. package/dist/src/onboard/steps/verifyup.js.map +1 -1
  79. package/dist/src/onboard/toolnames.js +30 -0
  80. package/dist/src/onboard/toolnames.js.map +1 -0
  81. package/dist/src/operatorwrite.js +23 -0
  82. package/dist/src/operatorwrite.js.map +1 -0
  83. package/dist/src/outcomes.js +4 -1
  84. package/dist/src/outcomes.js.map +1 -1
  85. package/dist/src/posting-journal.js +2 -1
  86. package/dist/src/posting-journal.js.map +1 -1
  87. package/dist/src/posting-outbox.js +6 -2
  88. package/dist/src/posting-outbox.js.map +1 -1
  89. package/dist/src/posting-receipt.js +14 -1
  90. package/dist/src/posting-receipt.js.map +1 -1
  91. package/dist/src/recheck.js +2 -1
  92. package/dist/src/recheck.js.map +1 -1
  93. package/dist/src/reconcile.js +22 -2
  94. package/dist/src/reconcile.js.map +1 -1
  95. package/dist/src/reviewhistory.js +32 -6
  96. package/dist/src/reviewhistory.js.map +1 -1
  97. package/dist/src/reviewnow.js +45 -0
  98. package/dist/src/reviewnow.js.map +1 -0
  99. package/dist/src/state.js.map +1 -1
  100. package/dist/src/statemachine.js +4 -1
  101. package/dist/src/statemachine.js.map +1 -1
  102. package/dist/src/takeoverpi.js +13 -0
  103. package/dist/src/takeoverpi.js.map +1 -1
  104. package/dist/src/tui/app.js +49 -0
  105. package/dist/src/tui/app.js.map +1 -1
  106. package/dist/src/tui/board.js +88 -12
  107. package/dist/src/tui/board.js.map +1 -1
  108. package/dist/src/tui/cmdbar.js +4 -1
  109. package/dist/src/tui/cmdbar.js.map +1 -1
  110. package/dist/src/tui/companion.js +74 -10
  111. package/dist/src/tui/companion.js.map +1 -1
  112. package/dist/src/tui/connect.js +7 -1
  113. package/dist/src/tui/connect.js.map +1 -1
  114. package/dist/src/tui/filters.js +5 -2
  115. package/dist/src/tui/filters.js.map +1 -1
  116. package/dist/src/tui/ghread.js +61 -5
  117. package/dist/src/tui/ghread.js.map +1 -1
  118. package/dist/src/tui/index.js +106 -20
  119. package/dist/src/tui/index.js.map +1 -1
  120. package/dist/src/tui/memforward.js +39 -2
  121. package/dist/src/tui/memforward.js.map +1 -1
  122. package/dist/src/tui/model.js +25 -0
  123. package/dist/src/tui/model.js.map +1 -1
  124. package/dist/src/tui/prpause.js +7 -0
  125. package/dist/src/tui/prpause.js.map +1 -1
  126. package/dist/src/tui/remote.js +7 -1
  127. package/dist/src/tui/remote.js.map +1 -1
  128. package/dist/src/tui/route.js +143 -0
  129. package/dist/src/tui/route.js.map +1 -0
  130. package/dist/src/tui/setupstate.js +29 -0
  131. package/dist/src/tui/setupstate.js.map +1 -0
  132. package/dist/src/tui/sshtriage.js +102 -5
  133. package/dist/src/tui/sshtriage.js.map +1 -1
  134. package/dist/src/tui/theme.js +2 -1
  135. package/dist/src/tui/theme.js.map +1 -1
  136. package/dist/src/tui/unwedge.js +2 -12
  137. package/dist/src/tui/unwedge.js.map +1 -1
  138. package/dist/src/verdicts.js +17 -5
  139. package/dist/src/verdicts.js.map +1 -1
  140. package/dist/src/version.js +1 -1
  141. package/dist/src/version.js.map +1 -1
  142. package/dist/src/webhook.js +12 -1
  143. package/dist/src/webhook.js.map +1 -1
  144. package/dist/src/wire.js +28 -6
  145. package/dist/src/wire.js.map +1 -1
  146. package/dist/src/workerworld.js +7 -1
  147. package/dist/src/workerworld.js.map +1 -1
  148. package/docker/companion/entrypoint.sh +20 -1
  149. package/docker/entrypoint.sh +17 -0
  150. package/extensions/cli/login-route.ts +10 -0
  151. package/extensions/login-route.ts +220 -0
  152. package/extensions/onboarding.ts +35 -6
  153. package/package.json +2 -2
  154. package/prompts/onboarding.md +57 -18
  155. package/scripts/mint-token.sh +25 -4
  156. package/src/ciretry.ts +2 -1
  157. package/src/cli.ts +49 -14
  158. package/src/cliops.ts +3 -2
  159. package/src/commands.ts +28 -2
  160. package/src/config.ts +24 -4
  161. package/src/daemon.ts +32 -7
  162. package/src/debugroundpi.ts +11 -1
  163. package/src/dispatch.ts +5 -4
  164. package/src/engine/debugpi.ts +17 -5
  165. package/src/execboundary.ts +16 -4
  166. package/src/github.ts +57 -15
  167. package/src/githubtoken.ts +435 -0
  168. package/src/loginhint.ts +8 -3
  169. package/src/loginrefresh/pirefresh.ts +3 -0
  170. package/src/npmverbs.ts +4 -2
  171. package/src/onboard/choices.ts +34 -1
  172. package/src/onboard/engine.ts +22 -3
  173. package/src/onboard/launch.ts +17 -29
  174. package/src/onboard/pipin.ts +31 -0
  175. package/src/onboard/stepforward.ts +61 -0
  176. package/src/onboard/steps/appslug.ts +4 -1
  177. package/src/onboard/steps/compose.ts +31 -0
  178. package/src/onboard/steps/configfill.ts +121 -33
  179. package/src/onboard/steps/githubaccount.ts +716 -0
  180. package/src/onboard/steps/githubapp.ts +92 -8
  181. package/src/onboard/steps/golive.ts +2 -0
  182. package/src/onboard/steps/index.ts +11 -0
  183. package/src/onboard/steps/ingress.ts +98 -31
  184. package/src/onboard/steps/llmauth.ts +114 -29
  185. package/src/onboard/steps/memory.ts +58 -1
  186. package/src/onboard/steps/proof.ts +473 -55
  187. package/src/onboard/steps/topology.ts +7 -6
  188. package/src/onboard/steps/transport.ts +15 -0
  189. package/src/onboard/steps/tuiroute.ts +119 -0
  190. package/src/onboard/steps/verifyup.ts +20 -1
  191. package/src/onboard/toolnames.ts +34 -0
  192. package/src/operatorwrite.ts +25 -0
  193. package/src/outcomes.ts +8 -1
  194. package/src/posting-journal.ts +6 -1
  195. package/src/posting-outbox.ts +10 -2
  196. package/src/posting-receipt.ts +17 -0
  197. package/src/recheck.ts +2 -1
  198. package/src/reconcile.ts +29 -3
  199. package/src/reviewhistory.ts +20 -6
  200. package/src/reviewnow.ts +76 -0
  201. package/src/state.ts +18 -0
  202. package/src/statemachine.ts +4 -1
  203. package/src/takeoverpi.ts +13 -0
  204. package/src/tui/app.ts +47 -0
  205. package/src/tui/board.ts +111 -14
  206. package/src/tui/cmdbar.ts +4 -1
  207. package/src/tui/companion.ts +78 -10
  208. package/src/tui/connect.ts +7 -1
  209. package/src/tui/filters.ts +7 -3
  210. package/src/tui/ghread.ts +74 -7
  211. package/src/tui/index.ts +114 -23
  212. package/src/tui/memforward.ts +52 -3
  213. package/src/tui/model.ts +36 -2
  214. package/src/tui/prpause.ts +11 -2
  215. package/src/tui/remote.ts +9 -1
  216. package/src/tui/route.ts +173 -0
  217. package/src/tui/setupstate.ts +52 -0
  218. package/src/tui/sshtriage.ts +127 -6
  219. package/src/tui/theme.ts +2 -1
  220. package/src/tui/unwedge.ts +2 -14
  221. package/src/verdicts.ts +23 -5
  222. package/src/version.ts +1 -1
  223. package/src/webhook.ts +10 -1
  224. package/src/wire.ts +36 -7
  225. package/src/workerworld.ts +7 -1
@@ -0,0 +1,76 @@
1
+ // "Review this PR now" — the operator's `review-now` command (src/commands.ts,
2
+ // root-sealed operator channel only).
3
+ //
4
+ // It queues ONE pull request for a human-asked round, the same state-machine
5
+ // event a fresh trigger label produces (`trigger`, source `manual`, so the
6
+ // round's provenance is the person's ask and dispatch reviews it in full),
7
+ // and then pumps dispatch. It makes no GitHub write to ask for the review:
8
+ // the label path stays the way later reviews are requested, and every write
9
+ // the round itself makes still goes through the one MutationGate.
10
+ //
11
+ // The PR is read from GitHub first (one GET), so a closed or merged PR, or a
12
+ // number that is not a PR of the reviewed repo, is refused with its reason
13
+ // instead of being adopted into the state store.
14
+ import type { PrRef } from "./github.js";
15
+ import type { FileOutcome } from "./improvements.js";
16
+ import { applyEvent } from "./statemachine.js";
17
+ import type { StateStore } from "./state.js";
18
+
19
+ export interface ReviewNowDeps {
20
+ store: StateStore;
21
+ /** The PR as GitHub has it (read-only). */
22
+ getPr(n: number): Promise<PrRef>;
23
+ pump(): Promise<void>;
24
+ /** The App's bot login, whose own PRs are never reviewed. Absent on the
25
+ * own-account route, where the "bot" is the person and their own PRs are
26
+ * exactly what they ask to have reviewed. */
27
+ botLogin?: string;
28
+ now?: () => Date;
29
+ }
30
+
31
+ export async function reviewNow(deps: ReviewNowDeps, pr: number): Promise<FileOutcome> {
32
+ let ref: PrRef;
33
+ try {
34
+ ref = await deps.getPr(pr);
35
+ } catch (err) {
36
+ return {
37
+ ok: false,
38
+ reason: `could not read PR #${pr} from GitHub: ${err instanceof Error ? err.message : String(err)}`,
39
+ };
40
+ }
41
+ if (ref.merged) return { ok: false, reason: `PR #${pr} is merged — there is nothing to review` };
42
+ if (ref.state !== "open") return { ok: false, reason: `PR #${pr} is closed` };
43
+ const local = deps.store.getPr(pr);
44
+ if (local?.paused)
45
+ return { ok: false, reason: `PR #${pr} is paused — resume it first (qcr resume-pr ${pr})` };
46
+ // The worker crash-loop breaker parks a PR after repeated worker failures
47
+ // (a missing model login is the usual first-install cause). Asking for this
48
+ // PR's review is the operator's word that it may run again, exactly as
49
+ // rerun-round's unpark is — without it the new round could never start.
50
+ if (deps.store.clearWorkerBreaker(pr))
51
+ deps.store.logEvent("worker_unparked", pr, { by: "review-now" });
52
+ const result = applyEvent(
53
+ deps.store,
54
+ pr,
55
+ { type: "trigger", source: "manual" },
56
+ {
57
+ title: ref.title,
58
+ head_sha: ref.head_sha,
59
+ author: ref.author,
60
+ trigger_ts: (deps.now ?? (() => new Date()))().toISOString(),
61
+ },
62
+ {
63
+ ...(deps.botLogin ? { botLogin: deps.botLogin } : {}),
64
+ ...(deps.now ? { now: deps.now } : {}),
65
+ reason: "operator:review-now",
66
+ },
67
+ );
68
+ if (result.skipped === "bot-author")
69
+ return {
70
+ ok: false,
71
+ reason: `PR #${pr} is authored by the bot itself — it never reviews its own PRs`,
72
+ };
73
+ await deps.pump();
74
+ const state = deps.store.getPr(pr)?.state ?? result.to;
75
+ return { ok: true, pr, state, head_sha: ref.head_sha };
76
+ }
package/src/state.ts CHANGED
@@ -7767,12 +7767,30 @@ export interface RoundStageBounds {
7767
7767
  export const PR_RUNTIMES = ["tmux", "worker", "answer", "idle", "debug", "takeover"] as const;
7768
7768
  export type PrRuntime = (typeof PR_RUNTIMES)[number];
7769
7769
 
7770
+ /** One open pull request as the board lists it (src/reconcile.ts lists them). */
7771
+ export interface OpenPrSummary {
7772
+ number: number;
7773
+ title: string;
7774
+ author: string;
7775
+ draft: boolean;
7776
+ updated_at: string;
7777
+ }
7778
+
7779
+ export interface OpenPrsSnapshot {
7780
+ listed_at: string;
7781
+ prs: OpenPrSummary[];
7782
+ }
7783
+
7770
7784
  export interface StatusSnapshot {
7771
7785
  written_at: string;
7772
7786
  dry_run: boolean;
7773
7787
  /** Where the daemon runs (src/runhost.ts). Absent: not reported (an older
7774
7788
  * daemon, or one assembled without its host facts). */
7775
7789
  run_host?: import("./runhost.js").RunHostStatus;
7790
+ /** Every open pull request of the reviewed repo, as the last successful
7791
+ * reconcile pass listed it (tracked or not): what the board offers to
7792
+ * review. Absent until a pass has listed them. */
7793
+ open_prs?: OpenPrsSnapshot;
7776
7794
  prs: Array<
7777
7795
  Pick<PrStateRow, "number" | "state" | "title" | "head_sha" | "stale_recheck"> &
7778
7796
  Partial<Pick<PrStateRow, "trigger_ts" | "updated_at" | "author" | "paused">> & {
@@ -205,7 +205,10 @@ function applyEventTransaction(
205
205
  const now = opts.now ?? (() => new Date());
206
206
  const existing = store.getPr(pr);
207
207
  const author = meta.author ?? existing?.author ?? "";
208
- if (opts.botLogin && author && isBotAuthor(author, opts.botLogin)) {
208
+ // `botLogin` is the bot ACCOUNT (identity.ts `botAccountLogin`): "" in
209
+ // person mode, where the person's own PRs are the target and only
210
+ // `[bot]`-suffixed accounts are skipped. Absent ⇒ nothing is skipped.
211
+ if (opts.botLogin !== undefined && author && isBotAuthor(author, opts.botLogin)) {
209
212
  return {
210
213
  pr,
211
214
  from: existing?.state ?? null,
package/src/takeoverpi.ts CHANGED
@@ -28,6 +28,7 @@ import { chmodSync, copyFileSync, existsSync, readFileSync, unlinkSync } from "n
28
28
  import { dirname, join } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { GITHUB_TOKEN_ENVS } from "@quickchatai/pi-quickreview/src/envstrip.js";
31
+ import { routeModelSpecForLogin } from "@quickchatai/pi-quickreview/src/modelroute.js";
31
32
  import { CAP_FD_INDEX } from "./ipc/capability.js";
32
33
  import {
33
34
  PI_REFRESH_MARGIN_MS,
@@ -102,6 +103,15 @@ export function readTakeoverSpec(file: string): TakeoverSpec {
102
103
  return raw as TakeoverSpec;
103
104
  }
104
105
 
106
+ /** The login file in an agent dir, as text; null when there is none. */
107
+ export function readLogin(agentDir: string): string | null {
108
+ try {
109
+ return readFileSync(join(agentDir, "auth.json"), "utf8");
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
105
115
  /**
106
116
  * The pi's own agent dir: the owner's login borrowed as a refresh-blanked copy
107
117
  * (0600, refused when its token would expire inside the lease's idle window plus
@@ -259,6 +269,9 @@ export async function runTakeover(specFile: string, deps: TakeoverRunDeps = {}):
259
269
  return 3;
260
270
  }
261
271
  const env = takeoverEnv(procEnv, spec);
272
+ // An OpenAI model runs on the OpenAI login the borrowed copy holds
273
+ // (packages/pi-plugin/src/modelroute.ts): the spec's own provider when it is there.
274
+ spec = { ...spec, model: routeModelSpecForLogin(spec.model, readLogin(spec.borrow_dir), env) };
262
275
  const pi = paneLaunch(takeoverArgv(spec), deps.piBin ?? spec.pi_bin);
263
276
  const launch = toolIsolation(pi.bin, pi.argv, procEnv);
264
277
  if ("refused" in launch) {
package/src/tui/app.ts CHANGED
@@ -841,6 +841,13 @@ export class App {
841
841
  if (pr !== this.selectedPr) this.msgDraft = ""; // a cancelled draft belongs to the PR it was typed for
842
842
  this.selectedPr = pr;
843
843
  if (this.view.progress?.pr !== pr) this.view.progress = undefined;
844
+ // An offered row (not tracked yet) has no detail, round or session to
845
+ // read: its pane is the PR list's own fields and the way to review it.
846
+ if (pr !== undefined && this.offeredSelected()) {
847
+ this.syncPiPane();
848
+ if (!this.view.piFocus) this.selectPane(undefined);
849
+ return;
850
+ }
844
851
  void this.refreshDetail();
845
852
  void this.refreshProgress();
846
853
  this.followSelection();
@@ -967,6 +974,38 @@ export class App {
967
974
  return sorted[this.view.selected]?.number;
968
975
  }
969
976
 
977
+ /** Is the selected row an offered one (open on GitHub, not tracked yet)? */
978
+ private offeredSelected(): boolean {
979
+ return visiblePrs(this.view)[this.view.selected]?.offered === true;
980
+ }
981
+
982
+ /** A PR verb on an offered row: there is no session or round to act on. */
983
+ private refuseOffered(): boolean {
984
+ if (!this.offeredSelected()) return false;
985
+ this.notice(`#${this.selectedPrNumber()} is not reviewed yet — R reviews it now`);
986
+ return true;
987
+ }
988
+
989
+ /** `R`: ask the daemon to review the selected PR now — the operator's
990
+ * `review-now` command over the sealed channel, the same as
991
+ * `qcr review-now <n>`. Any open PR of the repo, tracked or not. */
992
+ private reviewNow(): void {
993
+ const pr = this.selectedPrNumber();
994
+ if (pr === undefined) {
995
+ this.notice("select a pull request first (j/k), then R reviews it");
996
+ return;
997
+ }
998
+ if (!this.hooks.operatorCommand) {
999
+ this.notice("review now: not wired (no remote runner)");
1000
+ return;
1001
+ }
1002
+ this.notice(`asking the daemon to review #${pr} now…`);
1003
+ void this.hooks
1004
+ .operatorCommand({ command: "review-now", pr }, this)
1005
+ .then(() => this.poller.tick())
1006
+ .catch(() => {});
1007
+ }
1008
+
970
1009
  /** Synthesize a keypress through the normal input path — the command bar's
971
1010
  * buttons and any tab code that wants "exactly what the key would do". */
972
1011
  pressKey(key: string): void {
@@ -1432,6 +1471,7 @@ export class App {
1432
1471
  // the whole TUI (AGENTS.md invariant 10, the fixed layout). With no PR
1433
1472
  // selected there is nothing to focus, and nothing else to open.
1434
1473
  if (this.selectedPr !== undefined) {
1474
+ if (this.refuseOffered()) break;
1435
1475
  this.focusPiPane();
1436
1476
  this.repaint();
1437
1477
  }
@@ -1443,6 +1483,7 @@ export class App {
1443
1483
  case "message": {
1444
1484
  const session = this.selectedSession();
1445
1485
  if (!session) break;
1486
+ if (this.refuseOffered()) break;
1446
1487
  if (this.hooks.message) this.hooks.message(session, this);
1447
1488
  else this.openMessageBox(session);
1448
1489
  break;
@@ -1457,6 +1498,7 @@ export class App {
1457
1498
  // overseer command, where the extra deliberation is the point.
1458
1499
  const pr = this.selectedPrNumber();
1459
1500
  if (pr === undefined) break;
1501
+ if (this.refuseOffered()) break;
1460
1502
  if (this.hooks.unwedge) this.hooks.unwedge(pr, this);
1461
1503
  else this.notice("unwedge: not wired (no remote runner)");
1462
1504
  break;
@@ -1482,6 +1524,11 @@ export class App {
1482
1524
  this.setBoardFilter(null);
1483
1525
  break;
1484
1526
  }
1527
+ // R reviews the selected PR now — tracked or offered.
1528
+ if (key.ch === "R" && this.tab === "board") {
1529
+ this.reviewNow();
1530
+ break;
1531
+ }
1485
1532
  // I starts setup — the companion takes it from there.
1486
1533
  if (key.ch === "I" && this.tab === "board") {
1487
1534
  if (this.hooks.setup) this.hooks.setup(this);
package/src/tui/board.ts CHANGED
@@ -11,7 +11,7 @@ import { cancelCauseLabel, isLoudCancelCause, type StatusSnapshot } from "../sta
11
11
  import type { AlertBadge } from "./alerts.js";
12
12
  import { barHeight, paintCmdBar, type BarButton, type LiveKeys } from "./cmdbar.js";
13
13
  import { filterLabel, visiblePrs, type BoardFilter } from "./filters.js";
14
- import { glyphFor, type EventLine, type SnapshotPr } from "./model.js";
14
+ import { glyphFor, OFFERED_GLYPH, type EventLine, type SnapshotPr } from "./model.js";
15
15
  import { windowStart } from "./panes.js";
16
16
  import {
17
17
  ACCENT,
@@ -47,7 +47,8 @@ import {
47
47
  type LiveAgentProgress,
48
48
  type RoundProgressSnapshot,
49
49
  } from "@quickchatai/pi-quickreview/src/round-progress.js";
50
- import type { ConnectionDiagnosis } from "./sshtriage.js";
50
+ import { isLocalDiagnosis, type ConnectionDiagnosis } from "./sshtriage.js";
51
+ import { setupLine, type SetupState } from "./setupstate.js";
51
52
  import { FG, RESET, fmtAge, legendLine, osc8, prUrl, stateColor, verdictChip } from "./theme.js";
52
53
 
53
54
  /** Every clickable thing the board declares. */
@@ -109,6 +110,9 @@ export interface BoardView {
109
110
  /** Active board filter (companion `board_filter` verb; F clears).
110
111
  * Consumed through `visiblePrs` — never filter a sortPrs call directly. */
111
112
  boardFilter?: BoardFilter | null;
113
+ /** Set while onboarding's checklist exists and is not complete
114
+ * (src/tui/setupstate.ts): the board says so before anything else. */
115
+ setup?: SetupState;
112
116
  }
113
117
 
114
118
  // ---- layout ----------------------------------------------------------------
@@ -197,22 +201,26 @@ export function listWidth(prs: SnapshotPr[], cols: number, now: Date = new Date(
197
201
  export function boardKeys(
198
202
  view: Pick<BoardView, "snapshot" | "boardFilter" | "selected" | "repo">,
199
203
  ): LiveKeys {
200
- const selected = visiblePrs(view)[view.selected] !== undefined;
204
+ const row = visiblePrs(view)[view.selected];
205
+ const selected = row !== undefined;
206
+ // An offered row (open on GitHub, not tracked yet) has no session, round or
207
+ // pane: `R` reviews it and `g` opens it, and nothing else addresses it.
208
+ const tracked = selected && row.offered !== true;
201
209
  const bar: BarButton[] = [];
202
- if (selected) bar.push({ key: "\r", label: "⏎ pi pane" }, { key: "m", label: "m message" });
210
+ if (selected) bar.push({ key: "R", label: "R review now" });
211
+ if (tracked) bar.push({ key: "\r", label: "⏎ pi pane" }, { key: "m", label: "m message" });
203
212
  if (selected && view.repo) bar.push({ key: "g", label: "g github" });
204
213
  bar.push({ key: "e", label: "e events" });
205
- if (selected) bar.push({ key: "u", label: "u unwedge" });
214
+ if (tracked) bar.push({ key: "u", label: "u unwedge" });
206
215
  // Setup is about the repo, not a PR — offered whatever is selected.
207
216
  bar.push({ key: "I", label: "I setup" });
208
217
  const inline: string[] = [];
209
- if (selected) inline.push("a");
218
+ if (tracked) inline.push("a");
210
219
  if (view.boardFilter != null) inline.push("F");
211
220
  // A worker-hosted PR's pane is the transcript viewer: `[ ]` step its streams
212
221
  // and `< >` its rounds. They are the pane header's keys (painted there), so
213
222
  // they exist only while the selected PR's pane is the viewer.
214
- const pr = visiblePrs(view)[view.selected];
215
- if (pr !== undefined && paneSurface(pr.runtime, pr.number).kind === "viewer")
223
+ if (tracked && paneSurface(row.runtime, row.number).kind === "viewer")
216
224
  inline.push("[", "]", "<", ">");
217
225
  return { bar, inline };
218
226
  }
@@ -287,6 +295,9 @@ const CONNECTION_LABEL: Record<ConnectionDiagnosis["kind"], string> = {
287
295
  "wedged-master": "ssh master wedged",
288
296
  "host-unreachable": "host down",
289
297
  unknown: "cause unknown",
298
+ "local-down": "daemon container not running",
299
+ "local-docker": "local docker not answering",
300
+ unconfigured: "no ssh_host",
290
301
  };
291
302
 
292
303
  /**
@@ -297,12 +308,30 @@ const CONNECTION_LABEL: Record<ConnectionDiagnosis["kind"], string> = {
297
308
  * the truth is "I could not ask"), the reason is named, and the remedy is a
298
309
  * command the operator can paste into their own shell.
299
310
  */
300
- export function unknownSourceLines(conn: ConnectionDiagnosis | undefined, width: number): string[] {
311
+ export function unknownSourceLines(
312
+ conn: ConnectionDiagnosis | undefined,
313
+ width: number,
314
+ setup?: SetupState,
315
+ ): string[] {
301
316
  const lines = [
302
317
  `${FG.anomaly}the daemon has not answered yet${RESET}`,
303
318
  `${FG.dim}nothing below is a measurement — it is not known${RESET}`,
304
319
  "",
305
320
  ];
321
+ if (setup) {
322
+ // A half-finished onboarding is the whole explanation: the stack is
323
+ // started by one of its later steps, so daemon or ssh troubleshooting
324
+ // here would send the operator after a fault that is not one.
325
+ for (const l of wrap(setupLine(setup), width)) lines.push(`${WARN}${l}${DRESET}`);
326
+ for (const l of wrap(
327
+ "the daemon, its memory and this board's connection are set up by the wizard's remaining steps",
328
+ width,
329
+ ))
330
+ lines.push(`${FG.dim}${l}${RESET}`);
331
+ lines.push("");
332
+ lines.push(`${FG.dim}r refreshes${RESET}`);
333
+ return lines.map((l) => truncate(l, width));
334
+ }
306
335
  if (conn && conn.kind !== "ok" && conn.kind !== "local") {
307
336
  for (const l of wrap(stripAnsi(conn.cause), width)) lines.push(`${FG.dim}${l}${RESET}`);
308
337
  if (conn.remedy !== "") {
@@ -337,7 +366,8 @@ export function unknownDetailLines(conn: ConnectionDiagnosis | undefined, width:
337
366
  ];
338
367
  if (conn?.detail) {
339
368
  lines.push("");
340
- for (const l of wrap(`ssh said: ${stripAnsi(conn.detail)}`, width))
369
+ const who = isLocalDiagnosis(conn) ? "docker" : "ssh";
370
+ for (const l of wrap(`${who} said: ${stripAnsi(conn.detail)}`, width))
341
371
  lines.push(`${FG.dim}${l}${RESET}`);
342
372
  }
343
373
  return lines.map((l) => truncate(l, width));
@@ -482,9 +512,17 @@ function barSegments(view: BoardView, alerts: boolean): string[] {
482
512
  health = `${WARN}▲ DATA STALE (${view.health.dataAgeS}s)${DRESET}`;
483
513
  break;
484
514
  case "unreachable":
485
- health = `${FG.anomaly}✖ unreachable${view.connection ? ` · ${CONNECTION_LABEL[view.connection.kind]}` : ""}${RESET}`;
515
+ // Under an unfinished setup the setup segment that replaces this is the
516
+ // cause; a transport label would only point at a fault that is not one
517
+ // (the list still says, in full, that the daemon has not answered).
518
+ health = view.setup
519
+ ? ""
520
+ : `${FG.anomaly}✖ unreachable${view.connection ? ` · ${CONNECTION_LABEL[view.connection.kind]}` : ""}${RESET}`;
486
521
  break;
487
522
  }
523
+ // Short on the bar, where every segment competes for the width; the list
524
+ // carries the full sentence with the next step.
525
+ const setupSeg = view.setup ? `${WARN}▲ setup not finished — qcr onboard${DRESET}` : "";
488
526
  const s = view.snapshot;
489
527
  const rounds = s ? `rounds ${s.running_rounds}/${s.max_concurrent_rounds ?? "?"}` : "";
490
528
  const spend = s?.today_spend_usd !== undefined ? `today $${s.today_spend_usd.toFixed(2)}` : "";
@@ -512,6 +550,7 @@ function barSegments(view: BoardView, alerts: boolean): string[] {
512
550
  `${HEADING}quickreview${DRESET}`,
513
551
  mode,
514
552
  health,
553
+ setupSeg,
515
554
  mem?.loud ? mem.text : "",
516
555
  loop?.loud ? loop.text : "",
517
556
  curation,
@@ -549,6 +588,51 @@ export function alertZone(view: BoardView, cols: number): { x1: number; x2: numb
549
588
  return { x1, x2: Math.min(cols - 1, x1 + width - 1) };
550
589
  }
551
590
 
591
+ /**
592
+ * The board's own guide to the first review, under the PRS header. With open
593
+ * PRs offered and none tracked it says what to do; with no open PR at all it
594
+ * says that, naming the repo; once a PR is tracked it is silent. Absent
595
+ * `open_prs` (a daemon that has not listed yet, or an older one) is unknown,
596
+ * so the guide says nothing rather than "no open PRs".
597
+ */
598
+ export function reviewGuideLines(
599
+ view: Pick<BoardView, "snapshot" | "boardFilter" | "repo">,
600
+ tracked: number,
601
+ offered: number,
602
+ width: number,
603
+ ): string[] {
604
+ if (view.boardFilter != null || tracked > 0 || view.snapshot?.open_prs === undefined) return [];
605
+ if (offered === 0)
606
+ return [
607
+ truncate(
608
+ `${FG.dim}no open pull requests in ${view.repo ?? "the repository"} — open one, and it is listed here within a few minutes${RESET}`,
609
+ width,
610
+ ),
611
+ ];
612
+ return [
613
+ truncate(`${FG.accent}select a pull request (j/k), then R reviews it now${RESET}`, width),
614
+ ];
615
+ }
616
+
617
+ /** The detail pane of an offered row: what is known (the PR list's own
618
+ * fields) and the one thing to do with it. */
619
+ function offeredDetailLines(pr: SnapshotPr, width: number, now: Date): string[] {
620
+ return [
621
+ `${FG.dim}${OFFERED_GLYPH}${RESET} ${STRONG}#${pr.number}${RESET} ${STRONG}${truncate(sanitizeText(pr.title), width - 8 - String(pr.number).length)}${RESET}`,
622
+ truncate(
623
+ `${FG.author}${pr.author ?? ""}${RESET} ${FG.dim}· updated ${fmtAge(pr.updated_at, now)}${RESET}`,
624
+ width,
625
+ ),
626
+ "",
627
+ "not reviewed yet",
628
+ "",
629
+ truncate(`${STRONG}R${RESET} reviews it now: the reviewer runs one full round`, width),
630
+ truncate("on this pull request, and its progress shows here.", width),
631
+ truncate(`${FG.dim}In dry-run (the top bar says so) the review is recorded,${RESET}`, width),
632
+ truncate(`${FG.dim}never posted to GitHub.${RESET}`, width),
633
+ ];
634
+ }
635
+
552
636
  function styledRow(
553
637
  pr: SnapshotPr,
554
638
  selected: boolean,
@@ -811,6 +895,7 @@ export function detailLines(
811
895
  ): string[] {
812
896
  const lines: string[] = [];
813
897
  if (!pr) return ["no PR selected"];
898
+ if (pr.offered) return offeredDetailLines(pr, width, now);
814
899
  lines.push(
815
900
  `${stateColor(pr.state)}${STRONG}#${pr.number}${RESET} ${STRONG}${truncate(sanitizeText(pr.title), width - 8 - String(pr.number).length)}${RESET}`,
816
901
  );
@@ -1010,7 +1095,7 @@ export function detailLines(
1010
1095
  export const HELP_LINES = [
1011
1096
  "1…8 the eight surfaces: board · memory · digest · tickets · deploy · metrics · feedback · scan",
1012
1097
  " the bar at the foot shows what THIS frame answers to; a button that is not there is a key that would do nothing",
1013
- "r / s / ? / q refresh · reconnect VM ssh (the one ssh key-passphrase prompt) · this overlay · quit (asks first)",
1098
+ "r / s / ? / q refresh · re-check the connection (VM route: ssh, the one key-passphrase prompt; local route: the daemon container) · this overlay · quit (asks first)",
1014
1099
  "Ctrl-c ×3 kill the TUI from anywhere (terminal restored) — the un-stuck hatch",
1015
1100
  " an alert in the top bar: click it or ⏎ to open the companion",
1016
1101
  "the companion column down the right edge is on every tab:",
@@ -1025,6 +1110,7 @@ export const HELP_LINES = [
1025
1110
  "< / > past / later round in the viewer; PgUp / wheel parks it, End follows the tail again",
1026
1111
  " its input line (focused pane): ⏎ steers the running round, or sends a message to an idle PR; Alt-⏎ queues a follow-up",
1027
1112
  " Alt-p marks the next message may-post (shown, reset after send); Ctrl-c asks to abort the round (y confirms); [ ] < > are keys only while the line is empty",
1113
+ "R review the selected PR now — any open PR, tracked or not (○ not reviewed): one round, nothing posted in dry-run",
1028
1114
  "m quick-message the selected PR's session",
1029
1115
  "u unwedge: cancel the running round and re-queue it (nothing posted, no GitHub write)",
1030
1116
  "e events overlay (j/k scroll, e/Esc close; click an event → its PR)",
@@ -1296,11 +1382,22 @@ export function renderBoard(view: BoardView, cols: number, rows: number): Frame
1296
1382
  // detail pane says "no data" rather than "no PR selected".
1297
1383
  const known = view.snapshot !== undefined;
1298
1384
  const flabel = filterLabel(view.boardFilter);
1385
+ const offered = sorted.filter((p) => p.offered === true).length;
1299
1386
  const listLines: string[] = known
1300
- ? [section(`PRS (${sorted.length})`, flabel !== "" ? flabel : "the daemon's live set")]
1387
+ ? [
1388
+ section(
1389
+ `PRS (${sorted.length})`,
1390
+ flabel !== ""
1391
+ ? flabel
1392
+ : offered > 0
1393
+ ? `${sorted.length - offered} tracked · ${offered} open, not reviewed`
1394
+ : "the daemon's live set",
1395
+ ),
1396
+ ...reviewGuideLines(view, sorted.length - offered, offered, listW - 1),
1397
+ ]
1301
1398
  : [
1302
1399
  section("PRS (?)", "unknown — the daemon has not answered"),
1303
- ...unknownSourceLines(view.connection, listW - 1),
1400
+ ...unknownSourceLines(view.connection, listW - 1, view.setup),
1304
1401
  ];
1305
1402
  // The list scrolls with the selection: the verbs act on the selected PR, so
1306
1403
  // it must never sit off-screen (`windowStart`, the tabs' shared rule). The
package/src/tui/cmdbar.ts CHANGED
@@ -51,7 +51,9 @@ const NAV: ReadonlyArray<BarButton & { tab: Tab }> = TABS.map((tab) => ({
51
51
  /** Row A right: always the same four, always in the same place. */
52
52
  const GLOBALS: readonly BarButton[] = [
53
53
  { key: "r", label: "r refresh" },
54
- { key: "s", label: "s ssh" },
54
+ // Transport-neutral: on the VM route `s` re-checks ssh (and offers the one
55
+ // key-passphrase prompt), on the local route the daemon container.
56
+ { key: "s", label: "s check" },
55
57
  { key: "?", label: "? help" },
56
58
  { key: "q", label: "q quit" },
57
59
  ];
@@ -73,6 +75,7 @@ export const GLOBAL_KEYS: readonly string[] = [
73
75
  */
74
76
  export const TAB_VOCABULARY: Readonly<Record<Tab, readonly BarButton[]>> = {
75
77
  board: [
78
+ { key: "R", label: "R review now" },
76
79
  { key: "\r", label: "⏎ pi pane" },
77
80
  { key: "m", label: "m message" },
78
81
  { key: "g", label: "g github" },