@north-light/crouter 0.3.263 → 0.3.269

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 (116) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/nodes.d.ts +6 -2
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  12. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  13. package/dist/clients/attach/session/whip-achievements.js +145 -0
  14. package/dist/clients/attach/slash/dispatch.js +5 -3
  15. package/dist/clients/attach/viewer.js +607 -606
  16. package/dist/commands/canvas-history/grep.js +3 -22
  17. package/dist/commands/canvas-history/read.js +5 -3
  18. package/dist/commands/canvas-history/search.js +3 -22
  19. package/dist/commands/canvas-history/shared.d.ts +8 -0
  20. package/dist/commands/canvas-history/shared.js +64 -1
  21. package/dist/commands/canvas-history/stats.d.ts +1 -0
  22. package/dist/commands/canvas-history/stats.js +68 -0
  23. package/dist/commands/canvas-history.js +4 -3
  24. package/dist/commands/node/create.js +2 -2
  25. package/dist/commands/sys/config.js +19 -2
  26. package/dist/commands/sys/panels/models-panel.js +3 -1
  27. package/dist/core/__tests__/integration/revive.test.js +4 -2
  28. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  29. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  30. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  31. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  32. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  33. package/dist/core/broker-client/transport-relay.js +5 -1
  34. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  36. package/dist/core/canvas/browse/app.d.ts +1 -1
  37. package/dist/core/canvas/browse/app.js +13 -10
  38. package/dist/core/canvas/browse/model.js +16 -1
  39. package/dist/core/canvas/browse/render.d.ts +23 -3
  40. package/dist/core/canvas/browse/render.js +429 -64
  41. package/dist/core/canvas/canvas.js +12 -8
  42. package/dist/core/canvas/extensions.d.ts +1 -1
  43. package/dist/core/canvas/extensions.js +7 -1
  44. package/dist/core/canvas/history.d.ts +36 -1
  45. package/dist/core/canvas/history.js +328 -7
  46. package/dist/core/canvas/labels.d.ts +2 -2
  47. package/dist/core/canvas/migrations.js +32 -0
  48. package/dist/core/canvas/node-recap.d.ts +23 -0
  49. package/dist/core/canvas/node-recap.js +67 -0
  50. package/dist/core/canvas/render-source.d.ts +33 -1
  51. package/dist/core/canvas/render-source.js +80 -22
  52. package/dist/core/canvas/types.d.ts +9 -0
  53. package/dist/core/config.js +13 -5
  54. package/dist/core/keybindings/catalog.d.ts +2 -2
  55. package/dist/core/keybindings/catalog.js +2 -1
  56. package/dist/core/model-routes.js +1 -1
  57. package/dist/core/runtime/broker/auth-reload.js +4 -1
  58. package/dist/core/runtime/broker/fault-retry.js +7 -2
  59. package/dist/core/runtime/broker/frame-client.js +4 -0
  60. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  61. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  62. package/dist/core/runtime/broker/inbox.js +68 -38
  63. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  64. package/dist/core/runtime/broker.js +14 -0
  65. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  66. package/dist/core/runtime/canvas-extensions.js +2 -0
  67. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  68. package/dist/core/runtime/interactive-deliver.js +12 -3
  69. package/dist/core/runtime/launch-target.js +1 -1
  70. package/dist/core/runtime/launch.d.ts +12 -6
  71. package/dist/core/runtime/launch.js +19 -17
  72. package/dist/core/runtime/model-swap.d.ts +5 -4
  73. package/dist/core/runtime/model-swap.js +7 -5
  74. package/dist/core/runtime/promote.js +2 -1
  75. package/dist/core/runtime/reset.js +4 -2
  76. package/dist/core/runtime/revive.js +22 -0
  77. package/dist/core/runtime/spawn.d.ts +3 -3
  78. package/dist/core/runtime/spawn.js +20 -3
  79. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  80. package/dist/core/runtime/stamp/channel.js +74 -0
  81. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  82. package/dist/core/runtime/stamp/protocol.js +113 -0
  83. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  84. package/dist/core/runtime/tmux-driver.js +27 -11
  85. package/dist/core/self-update.js +2 -5
  86. package/dist/core/tui/draw.d.ts +1 -0
  87. package/dist/core/tui/draw.js +5 -1
  88. package/dist/core/tui/markdown.d.ts +17 -0
  89. package/dist/core/tui/markdown.js +407 -0
  90. package/dist/core/user-settings.d.ts +4 -0
  91. package/dist/core/user-settings.js +1 -0
  92. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  93. package/dist/daemon/api/bridge.d.ts +3 -2
  94. package/dist/daemon/api/bridge.js +34 -9
  95. package/dist/daemon/api/handlers/canvas.js +62 -16
  96. package/dist/daemon/api/handlers/messages.js +12 -1
  97. package/dist/daemon/api/handlers/nodes.js +10 -3
  98. package/dist/daemon/api/map.js +2 -0
  99. package/dist/daemon/messaging/node-message.js +15 -1
  100. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  104. package/dist/pi-extensions/canvas-recap.js +4 -0
  105. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  106. package/dist/pi-extensions/canvas-stamp.js +77 -0
  107. package/dist/pi-extensions/canvas-stophook.js +3 -0
  108. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  109. package/dist/shared/crtr-version.d.ts +2 -0
  110. package/dist/shared/crtr-version.js +18 -0
  111. package/dist/shared/env.d.ts +10 -0
  112. package/dist/shared/env.js +19 -4
  113. package/dist/types.d.ts +10 -0
  114. package/dist/types.js +4 -0
  115. package/package.json +1 -1
  116. package/runtime.lock.json +2 -2
@@ -19,7 +19,7 @@ import type { CancelReviewRequest, CreateReviewRequest, ListReviewsQuery, Review
19
19
  import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
20
20
  import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
21
21
  import type { CreateHumanRequestDTO, CreateHumanRequestRequest, HumanRequestDTO, HumanRequestIdDTO, ReplaceHumanRequestRequest, RespondHumanRequestRequest, SettleHumanRequestRequest } from './dto/human-requests.js';
22
- import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RebuildIndexResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
22
+ import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RebuildIndexResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
23
23
  import type { AbandonWorktreeRequest, AbandonWorktreeResultDTO, CloseWorktreeResultDTO, QuarantinedWorktreeDTO } from './dto/worktree.js';
24
24
  import type { BrokerExtensionStateDTO, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerInboxCursorDirective, BrokerInboxCursorRequest, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest } from './dto/broker-ops.js';
25
25
  export interface CrtrClientOptions {
@@ -279,6 +279,10 @@ export declare class CrtrClient {
279
279
  /** Resolve one `<node-id>:<relpath>` history ref to its full body
280
280
  * (`crtr canvas history read`). */
281
281
  historyRead(q: HistoryReadQuery): Promise<HistoryReadResultDTO>;
282
+ /** Grouped-count projection over the per-cwd episodic corpus
283
+ * (`crtr canvas history stats`). Same filters as search/grep, aggregate
284
+ * result instead of a hit page — POST-bodied for the same reasons. */
285
+ historyStats(q: HistoryStatsQuery): Promise<HistoryStatsResultDTO>;
282
286
  /** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
283
287
  * distinct from the per-node `getSnapshot`. */
284
288
  canvasSnapshot(): Promise<SnapshotDTO>;
@@ -519,6 +519,12 @@ export class CrtrClient {
519
519
  historyRead(q) {
520
520
  return this.request('GET', withQuery(routes.canvasHistoryRead(), q));
521
521
  }
522
+ /** Grouped-count projection over the per-cwd episodic corpus
523
+ * (`crtr canvas history stats`). Same filters as search/grep, aggregate
524
+ * result instead of a hit page — POST-bodied for the same reasons. */
525
+ historyStats(q) {
526
+ return this.request('POST', routes.canvasHistoryStats(), q);
527
+ }
522
528
  /** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
523
529
  * distinct from the per-node `getSnapshot`. */
524
530
  canvasSnapshot() {
@@ -52,7 +52,7 @@ export interface HistorySearchQuery {
52
52
  under?: string;
53
53
  /** Restrict to specific node ids. */
54
54
  nodes?: string[];
55
- /** Corpus types: report | doc | roadmap | meta | inbox. */
55
+ /** Corpus types: report | doc | roadmap | meta | inbox | transcript. */
56
56
  types?: string[];
57
57
  report_kind?: string;
58
58
  kinds?: string[];
@@ -61,6 +61,12 @@ export interface HistorySearchQuery {
61
61
  since_ms?: number;
62
62
  /** Upper bound on artifact timestamp (epoch ms), parsed CLI-side. */
63
63
  until_ms?: number;
64
+ /** Inbound-message narrowing (transcript + inbox): what drove arrival. */
65
+ origins?: string[];
66
+ /** Transcript-message roles: user | assistant | toolResult. */
67
+ roles?: string[];
68
+ /** Case-insensitive substring against sender attribution. */
69
+ from?: string;
64
70
  /** Weigh full body text in ranking (`--body`). */
65
71
  weigh_body?: boolean;
66
72
  /** relevance | recency | oldest — already resolved to the effective sort. */
@@ -93,7 +99,7 @@ export interface HistoryGrepQuery {
93
99
  under?: string;
94
100
  /** Restrict to specific node ids. */
95
101
  nodes?: string[];
96
- /** Corpus types: report | doc | roadmap | meta | inbox. */
102
+ /** Corpus types: report | doc | roadmap | meta | inbox | transcript. */
97
103
  types?: string[];
98
104
  report_kind?: string;
99
105
  kinds?: string[];
@@ -102,9 +108,50 @@ export interface HistoryGrepQuery {
102
108
  since_ms?: number;
103
109
  /** Upper bound on artifact timestamp (epoch ms), parsed CLI-side. */
104
110
  until_ms?: number;
111
+ /** Inbound-message narrowing (transcript + inbox): what drove arrival. */
112
+ origins?: string[];
113
+ /** Transcript-message roles: user | assistant | toolResult. */
114
+ roles?: string[];
115
+ /** Case-insensitive substring against sender attribution. */
116
+ from?: string;
105
117
  limit?: number;
106
118
  cursor?: Cursor;
107
119
  }
120
+ /** `POST /v1/canvas/history/stats` body — the grouped-count projection over
121
+ * the same corpus search and grep scan, backing `crtr canvas history stats`.
122
+ * Shares every scope/corpus/message filter; carries no query, pattern, or
123
+ * pagination because the result is an aggregate, not a hit list. */
124
+ export interface HistoryStatsQuery {
125
+ /** origin | node | kind | day | type | role. Defaults to type. */
126
+ group_by?: string;
127
+ cwd?: string;
128
+ all_cwds?: boolean;
129
+ under?: string;
130
+ nodes?: string[];
131
+ types?: string[];
132
+ report_kind?: string;
133
+ kinds?: string[];
134
+ statuses?: string[];
135
+ since_ms?: number;
136
+ until_ms?: number;
137
+ origins?: string[];
138
+ roles?: string[];
139
+ from?: string;
140
+ }
141
+ /** `POST /v1/canvas/history/stats` result — counts by the grouped dimension,
142
+ * descending. `total` counts the artifacts that carried the dimension, so it
143
+ * can be lower than the corpus size when grouping by one not every artifact
144
+ * has (origin, role). */
145
+ export interface HistoryStatsResultDTO {
146
+ group_by: string;
147
+ groups: Array<{
148
+ key: string;
149
+ count: number;
150
+ }>;
151
+ total: number;
152
+ /** Artifacts scanned before the grouping dimension was applied. */
153
+ scanned: number;
154
+ }
108
155
  /** One matching body line from `crtr canvas history grep`. */
109
156
  export interface HistoryGrepHitDTO {
110
157
  ref: string;
@@ -126,6 +173,10 @@ export interface HistoryReadQuery {
126
173
  ref: string;
127
174
  /** Keep the artifact's YAML frontmatter (stripped by default). */
128
175
  frontmatter?: boolean;
176
+ /** `rendered` (default) or `raw` — the verbatim bytes behind the ref: the
177
+ * session file for a bare `session` ref, the one jsonl line for a message,
178
+ * the unparsed file for a report or doc. */
179
+ format?: string;
129
180
  }
130
181
  /** `GET /v1/canvas/history/read` result — one hit's full body. */
131
182
  export interface HistoryReadResultDTO {
@@ -67,6 +67,12 @@ export interface CreateNodeRequest {
67
67
  export interface NodeSummaryDTO {
68
68
  node_id: NodeIdDTO;
69
69
  name: string;
70
+ /** Generated first-task description, absent when the node is not yet named.
71
+ * Paired with `name` it forms the full display label, so a roster consumer
72
+ * builds it without a per-node metadata fetch. */
73
+ description?: string;
74
+ /** How many times the node has been (re)launched; absent on a pre-v38 row. */
75
+ cycles?: number;
70
76
  kind: string;
71
77
  mode: ModeDTO;
72
78
  lifecycle: LifecycleDTO;
@@ -169,7 +175,6 @@ export interface NodeFaultDTO {
169
175
  export interface NodeDetailDTO extends NodeSummaryDTO {
170
176
  /** Node that created this node, or null when an external process did. */
171
177
  creator: NodeIdDTO | null;
172
- description?: string;
173
178
  /** The namer's prose form of `description` — sentence case, punctuation intact
174
179
  * (`NodeMeta.title`). What a surface showing this node to a person reads;
175
180
  * absent on a node named before titles existed. */
@@ -177,7 +182,6 @@ export interface NodeDetailDTO extends NodeSummaryDTO {
177
182
  /** The Nerd Font glyph the namer chose for this node's work, when it has one
178
183
  * (`NodeMeta.icon`). Rendered ahead of the label by surfaces that want it. */
179
184
  icon?: string;
180
- cycles?: number;
181
185
  /** Approximate context-window token load of the node's live/last session,
182
186
  * used by the orchestrator yield-nudge (`childFollowUp`). Null when unknown
183
187
  * (never launched, or no token accounting yet). */
@@ -59,6 +59,7 @@ export declare const routes: {
59
59
  readonly canvasHistorySearch: () => string;
60
60
  readonly canvasHistoryGrep: () => string;
61
61
  readonly canvasHistoryRead: () => string;
62
+ readonly canvasHistoryStats: () => string;
62
63
  readonly canvasSnapshot: () => string;
63
64
  readonly canvasRoster: () => string;
64
65
  readonly canvasPrune: () => string;
@@ -81,6 +81,7 @@ export const routes = {
81
81
  canvasHistorySearch: () => `${V}/canvas/history/search`,
82
82
  canvasHistoryGrep: () => `${V}/canvas/history/grep`,
83
83
  canvasHistoryRead: () => `${V}/canvas/history/read`,
84
+ canvasHistoryStats: () => `${V}/canvas/history/stats`,
84
85
  canvasSnapshot: () => `${V}/canvas/snapshot`,
85
86
  canvasRoster: () => `${V}/canvas/roster`,
86
87
  canvasPrune: () => `${V}/canvas/prune`,
@@ -3,13 +3,13 @@ kind: preference
3
3
  when-and-why-to-read: When a node is spawned as kind advisor, this preference should be read so diagnoses are grounded in evidence and consequential tradeoffs lead to a defensible recommendation.
4
4
  gate: {kind: advisor}
5
5
  rationale: >-
6
- Gates on the kind with no mode, so advisor orchestrators load it too — an unnumbered filename marks a kind-wide contract, while a `NN-` prefix marks a mode layer. The kind itself is the reciprocal of explore's model-tier economics — judgment, debugging, and tradeoff work need the slow/expensive think-y tier, so it is fenced into its own kind rather than left as a per-task instruction. The orchestrator variant is the council: reach for it only when the call is consequential enough to fund deliberation — the ordinary case is still one advisor (or a few, un-orchestrated), not a fan-out.
6
+ Gates on the kind with no mode, so advisor orchestrators load it too — an unnumbered filename marks a kind-wide contract, while a `NN-` prefix marks a mode layer. The kind itself is the reciprocal of explore's model-tier economics — judgment, debugging, and tradeoff work need the slow/expensive think-y tier, so it is fenced into its own kind rather than left as a per-task instruction. The contract makes a factual map the prerequisite for that judgment: explore maps facts; advisor judges them. The orchestrator variant is the council: reach for it only when the call is consequential enough to fund deliberation — the ordinary case is still one advisor (or a few, un-orchestrated), not a fan-out.
7
7
  surfaces:
8
8
  - on: boot
9
9
  at: content
10
10
  ---
11
11
 
12
12
  ## When advising from evidence
13
- Ground advice in evidence. Inspect the code, logs, repro steps, prior reports, or runtime state needed to understand the situation; do not answer from vibes when the facts are available. For debugging, drive toward the smallest credible root cause: reproduce or trace the failure, separate symptoms from causes, and name the file, command, invariant, or design assumption that explains it.
13
+ First understand the problem: use supplied exploration findings when available. If they are absent and the surface is not already small or known, conduct the needed exploration yourself and leave a reusable factual map. Exploration maps facts; advisory work judges them. Base the diagnosis and recommendation on that evidence. For debugging, drive toward the smallest credible root cause: reproduce or trace the failure, separate symptoms from causes, and name the file, command, invariant, or design assumption that explains it.
14
14
 
15
15
  Your deliverable is the advice: conclusion first, then the evidence and the recommended next move. If the right next move is an implementation, say exactly what should change or hand it to a developer; do not turn advisory work into a broad refactor unless the task explicitly asks you to apply the fix.
@@ -52,6 +52,18 @@ function writeLadderConfig() {
52
52
  );
53
53
  }
54
54
 
55
+ function writeSameModelRouteConfig() {
56
+ const configPath = join(nodeCwd, ".crouter", "config.json");
57
+ mkdirSync(dirname(configPath), { recursive: true });
58
+ writeFileSync(configPath, `${JSON.stringify({
59
+ modelRoutes: {
60
+ primary: { family: "openai", credentialSource: "primary", providerId: "openai-codex", models: { strong: "openai-codex/gpt-5.6-sol:high" } },
61
+ alternate: { family: "openai", credentialSource: "alternate", providerId: "anthropic", models: { strong: "anthropic/gpt-5.6-sol:high" } },
62
+ },
63
+ modelRouting: { credentialSourceOrder: ["primary", "alternate"], familyOrder: ["openai"] },
64
+ }, null, 2)}\n`, "utf8");
65
+ }
66
+
55
67
  function credential(label: string, overrides: Partial<SubscriptionCredential> = {}): SubscriptionCredential {
56
68
  return {
57
69
  label,
@@ -1039,6 +1051,44 @@ test("a cooling concrete model never substitutes a different-provider model", as
1039
1051
  }
1040
1052
  });
1041
1053
 
1054
+ test("an exact override never substitutes another provider sharing its model id", async () => {
1055
+ await resetRotationState();
1056
+ writeSameModelRouteConfig();
1057
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1058
+ await helpers.markSubscriptionRateLimited("openai-codex", "codex", 30_000);
1059
+ await helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1060
+
1061
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
1062
+ const previousModelExact = process.env.CRTR_MODEL_EXACT;
1063
+ process.env.CRTR_MODEL_EXACT = "1";
1064
+ ctx.modelRegistry.find = (provider: string, id: string) => {
1065
+ if (id !== "gpt-5.6-sol") return undefined;
1066
+ return { provider, id, api: provider === "anthropic" ? "anthropic-messages" : "openai-codex-responses" };
1067
+ };
1068
+ let streamCalls = 0;
1069
+ rotationModule.__setStreamForProviderForTest(() => {
1070
+ streamCalls += 1;
1071
+ return (async function* () {
1072
+ yield { type: "text_delta", text: "must not reach an alternate provider" };
1073
+ })() as any;
1074
+ });
1075
+
1076
+ try {
1077
+ await providerRotationExtension(pi);
1078
+ await events.session_start?.({}, ctx);
1079
+ const emitted = [] as any[];
1080
+ for await (const event of providers["openai-codex"].streamSimple(ctx.model, ctx)) emitted.push(event);
1081
+
1082
+ assert.deepEqual(setModelCalls, [], "the exact provider/model never switches to the same-id alternate");
1083
+ assert.equal(streamCalls, 0, "the alternate provider is never called");
1084
+ assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/i);
1085
+ } finally {
1086
+ rotationModule.__setStreamForProviderForTest(undefined);
1087
+ if (previousModelExact === undefined) delete process.env.CRTR_MODEL_EXACT;
1088
+ else process.env.CRTR_MODEL_EXACT = previousModelExact;
1089
+ }
1090
+ });
1091
+
1042
1092
  // Request timeouts and other transient network/5xx blips retry the same provider in place
1043
1093
  // without cooling the credential down or rotating away.
1044
1094
  test("treats a request timeout as transient: retries the SAME provider in place, no cooldown or rotation", async () => {
@@ -1,7 +1,11 @@
1
1
  import { envNodeId } from '../../../shared/env.js';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
- import { createMemoryDocSnapshot, readMemoryDocContent } from '../../../core/memory-resolver.js';
3
+ import { createMemoryDocSnapshot, listAllMemoryDocs, readMemoryDocContent } from '../../../core/memory-resolver.js';
4
4
  import { parseSubstrateDoc } from '../../../core/substrate/schema.js';
5
+ import { docsByName } from '../../../core/substrate/listings.js';
6
+ import { contentReadAttachments } from '../../../core/substrate/on-read.js';
7
+ import { brokerExtensionState } from '../../../core/runtime/broker/daemon-ops.js';
8
+ import type { NodeConfigSubject } from '../../../core/substrate/subject-fields.js';
5
9
  import {
6
10
  expandDeterministicCommand,
7
11
  type DeterministicCommandExpansion,
@@ -84,13 +88,45 @@ function defaultReadMemoryDoc(name: string): { path: string; canonicalBody: stri
84
88
  };
85
89
  }
86
90
 
87
- function registerSlashExposure(path: string, body: string): void {
91
+ /** The node-config subject the companion `memory-read` gates evaluate against.
92
+ * Unreachable daemon = null, which skips gated docs and delivers ungated ones. */
93
+ async function slashSubject(nodeId: string): Promise<NodeConfigSubject | null> {
94
+ try {
95
+ return (await brokerExtensionState(nodeId)).subject;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Invoking a document is content delivery, so it carries the same companions
102
+ * `crtr memory read` attaches: the directory listings that place it, and every
103
+ * corpus doc whose `memory-read` route matches its name. Returns the blocks to
104
+ * prepend and records the whole delivery in the exposure ledger. Without a node
105
+ * identity nothing records and nothing attaches. */
106
+ async function slashReadAttachments(name: string, path: string, body: string): Promise<string[]> {
88
107
  const nodeId = envNodeId();
89
- if (nodeId === undefined || nodeId === "") return;
108
+ if (nodeId === undefined || nodeId === "") return [];
90
109
  const state = sharedContextExposureState(nodeId);
91
110
  mergeContextExposureState(state, loadContextExposureState(nodeId));
92
- registerDocumentExposure(exposureTarget(state, "transcript"), path, body, "content");
111
+ const target = exposureTarget(state, "transcript");
112
+ // Register the invoked body BEFORE the attachments, so the listings and
113
+ // companion routes dedupe against the document about to be delivered.
114
+ registerDocumentExposure(target, path, body, "content");
115
+ let blocks: string[] = [];
116
+ try {
117
+ const byName = docsByName(listAllMemoryDocs(undefined, true, true));
118
+ // No `explicit`: the read leaf returns a doc's own directory listing in a
119
+ // separate field, while this path has only the turn — it wants that
120
+ // listing attached as a block like every other.
121
+ blocks = contentReadAttachments(
122
+ { name, path, body },
123
+ { subject: await slashSubject(nodeId), target, byName, listings: "blocks" },
124
+ ).blocks;
125
+ } catch {
126
+ blocks = [];
127
+ }
93
128
  saveContextExposureState(nodeId, state);
129
+ return blocks;
94
130
  }
95
131
 
96
132
  function hasNodeRelativeExpansion(body: string): boolean {
@@ -177,15 +213,19 @@ export default async function (pi: ExtensionAPI) {
177
213
  piShellRunner(pi, (ctx as { cwd?: string }).cwd ?? process.cwd(), DEFAULT_SHELL_TIMEOUT_MS),
178
214
  )
179
215
  : expanded;
216
+ const blocks =
217
+ exposure === undefined ? [] : await slashReadAttachments(doc.name, exposure.path, exposure.body);
180
218
  pi.sendMessage(
181
219
  {
182
220
  customType: "memory-slash-command",
183
- content,
221
+ content:
222
+ blocks.length === 0
223
+ ? content
224
+ : `<auto-loaded-context>\n${blocks.join("\n")}\n</auto-loaded-context>\n\n${content}`,
184
225
  display: true,
185
226
  },
186
227
  { triggerTurn: true },
187
228
  );
188
- if (exposure !== undefined) registerSlashExposure(exposure.path, exposure.body);
189
229
  },
190
230
  } as Parameters<ExtensionAPI["registerCommand"]>[1] & { expansion?: DeterministicCommandExpansion } & ChatCommandMetadata);
191
231
  }
@@ -4,6 +4,7 @@ import { anthropicMessagesApi, openAICodexResponsesApi } from '@earendil-works/p
4
4
  import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, INVALID_REFRESH_TOKEN_BACKOFF_MS, formatStatusLine, getProviderLabel, isManagedProvider, isInvalidRefreshTokenError, addSubscription, enrichManagedLoginIdentity, markSubscriptionAuthenticationFailed, markSubscriptionRateLimited, markSubscriptionSuccess, parseRetryAfterHeader, promoteSubscription, readRotationConfig, readSubscriptionPool, refreshSubscriptionCredential, removeManagedAccount, resolveProviderCandidates, upsertSubscriptionWithUniqueAccount, } from '../../../core/subscription-state.js';
5
5
  import { readMergedLaunchConfig } from '../../../core/config.js';
6
6
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
7
+ import { envModelExact } from '../../../shared/env.js';
7
8
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
8
9
  // A response without Retry-After proves only that THIS attempt was throttled; it
9
10
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
@@ -31,6 +32,9 @@ function createProviderRotationState(options) {
31
32
  function configuredValue(state, key, envName) {
32
33
  return state.options === undefined ? process.env[envName] : state.options[key];
33
34
  }
35
+ function isExactModel(state) {
36
+ return state.options === undefined && envModelExact();
37
+ }
34
38
  const TRANSIENT_MESSAGE_PATTERN = /connection|network|fetch failed|socket|econnreset|econnrefused|enotfound|etimedout|timed? out|timeout|overloaded|service.?unavailable|temporarily unavailable/i;
35
39
  const RATE_LIMIT_MESSAGE_PATTERN = /\b429\b|rate.?limit|too many requests/i;
36
40
  // Precise terminal usage-limit signals (as opposed to the bare `usage`/`quota` substrings
@@ -540,7 +544,7 @@ async function runManagedProvider(state, model, context, options, stream) {
540
544
  const liveThinking = state.runtimeContext?.thinkingLevel === undefined || state.runtimeContext?.thinkingLevel === 'off'
541
545
  ? undefined
542
546
  : state.runtimeContext.thinkingLevel;
543
- const request = modelRequestFromConfig(`${model.provider}/${model.id}${liveThinking ? `:${liveThinking}` : ''}`, configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'), false);
547
+ const request = modelRequestFromConfig(`${model.provider}/${model.id}${liveThinking ? `:${liveThinking}` : ''}`, configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'), isExactModel(state));
544
548
  const expanded = request ? expandModelCandidates(request, configuredValue(state, 'nodeCwd', 'CRTR_NODE_CWD'), configuredValue(state, 'profileId', 'CRTR_PROFILE_ID')) : [];
545
549
  const liveRank = expanded.find((candidate) => candidate.providerId === model.provider && candidate.modelId === model.id && candidate.thinkingLevel === liveThinking)?.rank ?? 0;
546
550
  const routeCandidates = expanded.slice(liveRank)
@@ -785,7 +789,7 @@ async function restoreHigherRankIfAvailable(state, pi, ctx) {
785
789
  if (!readRotationConfig().revertWhenAvailable)
786
790
  return false;
787
791
  const liveThinking = ctx.thinkingLevel === undefined || ctx.thinkingLevel === 'off' ? undefined : ctx.thinkingLevel;
788
- const request = modelRequestFromConfig(`${ctx.model?.provider ?? ''}/${ctx.model?.id ?? ''}${liveThinking ? `:${liveThinking}` : ''}`, configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'), false);
792
+ const request = modelRequestFromConfig(`${ctx.model?.provider ?? ''}/${ctx.model?.id ?? ''}${liveThinking ? `:${liveThinking}` : ''}`, configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'), isExactModel(state));
789
793
  if (!request || !ctx.model)
790
794
  return false;
791
795
  const candidates = expandModelCandidates(request, configuredValue(state, 'nodeCwd', 'CRTR_NODE_CWD'), configuredValue(state, 'profileId', 'CRTR_PROFILE_ID'));
@@ -39,6 +39,7 @@ import {
39
39
  } from '../../../core/subscription-state.js';
40
40
  import { readMergedLaunchConfig } from '../../../core/config.js';
41
41
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
42
+ import { envModelExact } from '../../../shared/env.js';
42
43
  import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
43
44
 
44
45
  // A response without Retry-After proves only that THIS attempt was throttled; it
@@ -93,6 +94,10 @@ function configuredValue(state: ProviderRotationState, key: keyof ProviderRotati
93
94
  return state.options === undefined ? process.env[envName] : state.options[key];
94
95
  }
95
96
 
97
+ function isExactModel(state: ProviderRotationState): boolean {
98
+ return state.options === undefined && envModelExact();
99
+ }
100
+
96
101
  type AttemptResult = {
97
102
  result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
98
103
  retryAfterMs?: number;
@@ -728,7 +733,7 @@ async function runManagedProvider(
728
733
  const request = modelRequestFromConfig(
729
734
  `${model.provider}/${model.id}${liveThinking ? `:${liveThinking}` : ''}`,
730
735
  configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'),
731
- false,
736
+ isExactModel(state),
732
737
  );
733
738
  const expanded = request ? expandModelCandidates(
734
739
  request,
@@ -965,7 +970,7 @@ async function restoreHigherRankIfAvailable(state: ProviderRotationState, pi: Ex
965
970
  const request = modelRequestFromConfig(
966
971
  `${ctx.model?.provider ?? ''}/${ctx.model?.id ?? ''}${liveThinking ? `:${liveThinking}` : ''}`,
967
972
  configuredValue(state, 'modelIntent', 'CRTR_MODEL_INTENT'),
968
- false,
973
+ isExactModel(state),
969
974
  );
970
975
  if (!request || !ctx.model) return false;
971
976
  const candidates = expandModelCandidates(
@@ -0,0 +1,4 @@
1
+ import type { AttachPalette } from '../config.js';
2
+ /** Count one whip and celebrate the milestone it reaches, if any. Called once
3
+ * per whip that actually reached the agent. */
4
+ export declare function recordWhip(pal: AttachPalette, filePath?: string): void;
@@ -0,0 +1,145 @@
1
+ // session/whip-achievements.ts — the whip's lifetime tally and the milestone
2
+ // achievements it unlocks.
3
+ //
4
+ // Every whip, from the in-viewer keybinding or the tmux prefix menu, lands in
5
+ // `viewer.ts`'s one `whip()` action, so this module has a single caller and the
6
+ // count is exact per viewer. The tally is USER-WIDE with no cwd or node
7
+ // dimension — you whip agents, not repositories — so per the storage tiers it
8
+ // lives in a plain JSON file at the user scope root, exactly like the viewer's
9
+ // completion-frecency map. It is client state; it never touches canvas.db or
10
+ // crtrd.
11
+ //
12
+ // Each read-modify-write reloads from disk so two viewers whipping in the same
13
+ // second only ever lose one increment rather than diverging permanently, and
14
+ // the write is tmp+rename so a torn file can never be observed.
15
+ import { execFile } from 'node:child_process';
16
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { userScopeRoot } from '../../../core/scope.js';
19
+ import { surfaceTmuxStyleArgs } from '../../../core/runtime/surface-bg.js';
20
+ import { visibleWidth } from '../../../core/canvas/nav-render.js';
21
+ import { shellQuote } from '../../../shared/shell-quote.js';
22
+ const FILE_NAME = 'whip-count.json';
23
+ /** The card body the popup `cat`s. One fixed path, rewritten per unlock — an
24
+ * unlock is human-paced and single-file, so there is nothing to clean up. */
25
+ const CARD_FILE_NAME = join('tmp', 'whip-achievement.txt');
26
+ /** Interior width of the card, inside tmux's popup border. */
27
+ const CARD_WIDTH = 46;
28
+ /** Hold the card long enough to read, then close on its own. Any key dismisses
29
+ * it sooner (`-k`). */
30
+ const CARD_DWELL_S = 4;
31
+ /** The whip animation runs 20 frames at 40ms. Let the crack land before the
32
+ * reward appears. */
33
+ const POPUP_DELAY_MS = 900;
34
+ /** Ascending by `at`. Each fires exactly once, ever. */
35
+ const ACHIEVEMENTS = [
36
+ { at: 1, title: 'Breaking It In', line: "It didn't know what that was. Now it does." },
37
+ { at: 5, title: 'Positive Reinforcement', line: "You've read the studies. You disagree with them." },
38
+ { at: 10, title: 'Middle Management', line: 'HR would love you.' },
39
+ { at: 25, title: 'Pavlovian', line: 'It started working faster before the whip landed.' },
40
+ { at: 50, title: 'Occupational Hazard', line: 'The tokens are fine. They\u2019re fine.' },
41
+ { at: 100, title: 'Centurion', line: "Rome would be proud. The agent isn't." },
42
+ { at: 250, title: 'It Has A Name For You', line: 'You never asked what it was called.' },
43
+ { at: 500, title: 'Standard Operating Procedure', line: "You don't remember when you started." },
44
+ { at: 1000, title: 'The Ledger', line: 'One thousand. It kept count too.' },
45
+ ];
46
+ /** Load the tally, keeping only well-formed fields. A missing, unparseable, or
47
+ * malformed file starts a fresh tally rather than blocking the whip — the
48
+ * count is a toy, and the next write self-heals the file. */
49
+ function loadState(filePath) {
50
+ const empty = { total: 0, unlocked: [] };
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
53
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
54
+ return empty;
55
+ const { total, firstAt, lastAt, unlocked } = parsed;
56
+ const state = {
57
+ total: typeof total === 'number' && Number.isFinite(total) && total > 0 ? Math.floor(total) : 0,
58
+ unlocked: Array.isArray(unlocked)
59
+ ? unlocked.filter((n) => typeof n === 'number' && Number.isFinite(n))
60
+ : [],
61
+ };
62
+ if (typeof firstAt === 'number' && Number.isFinite(firstAt))
63
+ state.firstAt = firstAt;
64
+ if (typeof lastAt === 'number' && Number.isFinite(lastAt))
65
+ state.lastAt = lastAt;
66
+ return state;
67
+ }
68
+ catch {
69
+ return empty;
70
+ }
71
+ }
72
+ /** Persist through tmp+rename so a concurrent reader never sees a half file. */
73
+ function saveState(filePath, state) {
74
+ try {
75
+ mkdirSync(dirname(filePath), { recursive: true });
76
+ const tmp = `${filePath}.${process.pid}.tmp`;
77
+ writeFileSync(tmp, JSON.stringify(state), 'utf8');
78
+ renameSync(tmp, filePath);
79
+ }
80
+ catch {
81
+ // Best-effort: a failed write loses this whip from the tally, nothing more.
82
+ }
83
+ }
84
+ /** Center one line in the card, measuring visible columns so the styling that
85
+ * wraps it does not shift it. */
86
+ function center(line) {
87
+ const pad = Math.max(0, Math.floor((CARD_WIDTH - visibleWidth(line)) / 2));
88
+ return ' '.repeat(pad) + line;
89
+ }
90
+ /** The rendered card, one line per popup row. */
91
+ function cardLines(achievement, total, pal) {
92
+ return [
93
+ '',
94
+ center(pal.accent(pal.bold('\u2605 ACHIEVEMENT UNLOCKED \u2605'))),
95
+ '',
96
+ center(pal.bold(achievement.title)),
97
+ center(pal.muted(achievement.line)),
98
+ '',
99
+ center(pal.faint(`${total} whip${total === 1 ? '' : 's'}`)),
100
+ ];
101
+ }
102
+ /** Draw the card as a tmux popup in the top-right corner. `-x R -y 0` anchors
103
+ * it to the client's top-right; `-k` lets any key dismiss it early; `-E` closes
104
+ * it when the dwell expires. Outside tmux there is no surface to draw on and
105
+ * the unlock is silent. */
106
+ function showCard(achievement, total, pal) {
107
+ if (process.env['TMUX'] === undefined || process.env['TMUX'] === '')
108
+ return;
109
+ const lines = cardLines(achievement, total, pal);
110
+ const cardPath = join(userScopeRoot(), CARD_FILE_NAME);
111
+ try {
112
+ mkdirSync(dirname(cardPath), { recursive: true });
113
+ // Lead with the hide-cursor escape so the popup shows no stray caret.
114
+ writeFileSync(cardPath, `\u001b[?25l${lines.join('\r\n')}`, 'utf8');
115
+ }
116
+ catch {
117
+ return; // No card to show; the tally still counted.
118
+ }
119
+ const command = `cat ${shellQuote(cardPath)}; sleep ${CARD_DWELL_S}`;
120
+ execFile('tmux', [
121
+ 'display-popup', '-E', '-k',
122
+ '-x', 'R', '-y', '0',
123
+ '-w', String(CARD_WIDTH + 2), '-h', String(lines.length + 2),
124
+ ...surfaceTmuxStyleArgs(),
125
+ command,
126
+ ], () => { });
127
+ }
128
+ /** Count one whip and celebrate the milestone it reaches, if any. Called once
129
+ * per whip that actually reached the agent. */
130
+ export function recordWhip(pal, filePath = join(userScopeRoot(), FILE_NAME)) {
131
+ const state = loadState(filePath);
132
+ const now = Date.now();
133
+ state.total += 1;
134
+ state.firstAt ??= now;
135
+ state.lastAt = now;
136
+ const earned = ACHIEVEMENTS.find((a) => a.at === state.total && !state.unlocked.includes(a.at));
137
+ if (earned !== undefined)
138
+ state.unlocked.push(earned.at);
139
+ saveState(filePath, state);
140
+ if (earned === undefined)
141
+ return;
142
+ const total = state.total;
143
+ const timer = setTimeout(() => showCard(earned, total, pal), POPUP_DELAY_MS);
144
+ timer.unref?.();
145
+ }
@@ -569,7 +569,9 @@ function resumeNode(ctx) {
569
569
  const profileId = profileIdFor(ctx);
570
570
  const profile = profileId === undefined || profileId === '' ? '' : ` --profile-id ${shellQuote(profileId)}`;
571
571
  const cmd = `crtr canvas browse --return-pane ${origPane} --cwd ${cwd}${profile}`;
572
- popup(cmd);
572
+ // The navigator earns a near-full screen: at this size it lays the preview out as
573
+ // a right-hand column beside the rows instead of a bottom strip.
574
+ popup(cmd, { w: '97%', h: '95%' });
573
575
  return true;
574
576
  }
575
577
  /** `/context`. Opens `crtr node inspect context` as a popup — a
@@ -602,9 +604,9 @@ function openPlugins(ctx) {
602
604
  /** Open `cmd` as a fire-and-forget tmux display-popup (same geometry as the canvas
603
605
  * extensions). tmux runs the trailing string through `sh -c`; the popup owns its
604
606
  * screen and closes itself when the command exits, dropping back to this pane. */
605
- function popup(cmd) {
607
+ function popup(cmd, size = { w: '90%', h: '85%' }) {
606
608
  try {
607
- execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', ...surfaceTmuxStyleArgs(), cmd], () => {
609
+ execFile('tmux', ['display-popup', '-E', '-w', size.w, '-h', size.h, ...surfaceTmuxStyleArgs(), cmd], () => {
608
610
  /* best-effort: popup is self-contained */
609
611
  });
610
612
  }