@vgai/sdk 0.5.5 → 0.5.6

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.5",
5
+ "version": "0.5.6",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -24,12 +24,13 @@
24
24
  "./project-tool-catalog": "./src/project-tool-catalog.ts",
25
25
  "./project-inspection-node": "./src/project/inspection-node.ts",
26
26
  "./registry": "./src/registry.ts",
27
+ "./run-name": "./src/project/run-name.ts",
27
28
  "./session-journal": "./src/project/session-journal.ts",
28
29
  "./tools": "./src/tools.ts"
29
30
  },
30
31
  "dependencies": {
31
32
  "@modelcontextprotocol/sdk": "^1.30.0",
32
- "@vgai/engine": "0.5.5",
33
+ "@vgai/engine": "0.5.6",
33
34
  "playwright": "^1.58.2",
34
35
  "zod": "^4.3.6"
35
36
  }
package/src/account.ts CHANGED
@@ -32,6 +32,13 @@ export const AccountSpendPolicySchema = z.object({
32
32
  })
33
33
  .optional(),
34
34
  });
35
+ export const AccountAlertSchema = z.object({
36
+ id: z.string().min(1),
37
+ kind: z.enum(['threshold', 'auto_reload_succeeded', 'auto_reload_failed']),
38
+ message: z.string().min(1),
39
+ createdAt: z.string().datetime(),
40
+ readAt: z.string().datetime().optional(),
41
+ });
35
42
  export const AccountUserSchema = z.object({
36
43
  id: z.string().min(1),
37
44
  email: z.string().email(),
@@ -57,13 +64,46 @@ export const AccountOrganizationSchema = z.object({
57
64
  export const AccountUsageEntrySchema = z.object({
58
65
  id: z.string().min(1),
59
66
  occurredAt: z.string().datetime(),
60
- kind: z.literal('generation'),
67
+ kind: z.enum(['inference', 'generation', 'workspace', 'storage', 'relay', 'deployment']),
61
68
  provider: z.string().min(1),
62
69
  operation: z.string().optional(),
63
70
  credits: z.number().nonnegative(),
64
71
  state: z.enum(['reserved', 'settled', 'released']),
65
72
  externalId: z.string().optional(),
66
73
  });
74
+ export const AccountPlanIdSchema = z.enum(['free', 'creator', 'max', 'ultra']);
75
+ export const AccountCatalogSchema = z.object({
76
+ plans: z.object({
77
+ free: z.object({
78
+ name: z.string().min(1),
79
+ includedCredits: z.number().int().nonnegative(),
80
+ monthlyPriceUsd: z.number().nonnegative().optional(),
81
+ }),
82
+ creator: z.object({
83
+ name: z.string().min(1),
84
+ includedCredits: z.number().int().nonnegative(),
85
+ monthlyPriceUsd: z.number().nonnegative().optional(),
86
+ }),
87
+ max: z.object({
88
+ name: z.string().min(1),
89
+ includedCredits: z.number().int().nonnegative(),
90
+ monthlyPriceUsd: z.number().nonnegative().optional(),
91
+ }),
92
+ ultra: z.object({
93
+ name: z.string().min(1),
94
+ includedCredits: z.number().int().nonnegative(),
95
+ monthlyPriceUsd: z.number().nonnegative().optional(),
96
+ }),
97
+ }),
98
+ creditPacks: z.record(
99
+ z.string().min(1),
100
+ z.object({
101
+ name: z.string().min(1),
102
+ credits: z.number().int().positive(),
103
+ expiresInDays: z.number().int().positive().optional(),
104
+ }),
105
+ ),
106
+ });
67
107
  export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
68
108
  z.object({ authenticated: z.literal(false), backend: AccountBackendSchema }),
69
109
  z.object({
@@ -74,6 +114,7 @@ export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
74
114
  plan: AccountPlanSchema,
75
115
  credits: AccountCreditsSchema,
76
116
  spendPolicy: AccountSpendPolicySchema,
117
+ alerts: z.array(AccountAlertSchema).max(100).optional(),
77
118
  entitlements: z.object({
78
119
  generation: z.boolean(),
79
120
  dailyJobs: z.number().int().positive().optional(),
@@ -83,7 +124,12 @@ export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
83
124
  /** Product-facing routes. Provider tools may translate BYOK to their native
84
125
  * transport name (`direct`) internally. */
85
126
  export const GenerationExecutionRouteSchema = z.enum(['mock', 'managed', 'byok']);
86
- export const ProviderCredentialIdSchema = z.enum(['fal', 'tripo', 'worldlabs']);
127
+ export const ProviderCredentialIdSchema = z.enum(['fal', 'tripo', 'worldlabs', 'openrouter']);
128
+ export const CodingInferenceSettingsSchema = z.object({
129
+ enabled: z.boolean(),
130
+ provider: z.literal('openrouter'),
131
+ model: z.string().trim().min(1).max(256),
132
+ });
87
133
  export const ProviderCredentialSourceSchema = z.enum(['environment', 'system', 'session', 'none']);
88
134
  export const ProviderCredentialStatusSchema = z.object({
89
135
  provider: ProviderCredentialIdSchema,
@@ -106,21 +152,29 @@ export type AccountBackend = z.infer<typeof AccountBackendSchema>;
106
152
  export type AccountPlan = z.infer<typeof AccountPlanSchema>;
107
153
  export type AccountCredits = z.infer<typeof AccountCreditsSchema>;
108
154
  export type AccountSpendPolicy = z.infer<typeof AccountSpendPolicySchema>;
155
+ export type AccountAlert = z.infer<typeof AccountAlertSchema>;
109
156
  export type AccountUser = z.infer<typeof AccountUserSchema>;
110
157
  export type AccountOrganization = z.infer<typeof AccountOrganizationSchema>;
111
158
  export type AccountOrganizationDomain = z.infer<typeof AccountOrganizationDomainSchema>;
112
159
  export type AccountUsageEntry = z.infer<typeof AccountUsageEntrySchema>;
160
+ export type AccountPlanId = z.infer<typeof AccountPlanIdSchema>;
161
+ export type AccountCatalog = z.infer<typeof AccountCatalogSchema>;
113
162
  export type AccountSnapshot = z.infer<typeof AccountSnapshotSchema>;
114
163
  export type GenerationExecutionRoute = z.infer<typeof GenerationExecutionRouteSchema>;
164
+ export type CodingInferenceSettings = z.infer<typeof CodingInferenceSettingsSchema>;
115
165
  export type ProviderCredentialId = z.infer<typeof ProviderCredentialIdSchema>;
116
166
  export type ProviderCredentialSource = z.infer<typeof ProviderCredentialSourceSchema>;
117
167
  export type ProviderCredentialStatus = z.infer<typeof ProviderCredentialStatusSchema>;
118
168
  export type ProviderCredentialTestResult = z.infer<typeof ProviderCredentialTestResultSchema>;
119
169
  export type EditorAccountSnapshot = AccountSnapshot & {
170
+ /** Which identity/payment surface backs this editor session. The in-process
171
+ * mock value exists only for isolated unit tests. */
172
+ accountEnvironment: 'test-mock' | 'development-twin' | 'production';
120
173
  routes: { mock: true; managed: boolean; byok: boolean; byokProviders: string[] };
121
174
  preferredRoute: GenerationExecutionRoute;
122
175
  /** Editor-only metadata. Credential values never cross the server boundary. */
123
176
  providerCredentials: ProviderCredentialStatus[];
177
+ codingInference: CodingInferenceSettings;
124
178
  };
125
179
  export interface GenerationAccountProjection {
126
180
  routes: EditorAccountSnapshot['routes'];
@@ -142,6 +196,7 @@ export function generationAccountProjection(
142
196
  export const EditorAccountSnapshotSchema: z.ZodType<EditorAccountSnapshot> = z.intersection(
143
197
  AccountSnapshotSchema,
144
198
  z.object({
199
+ accountEnvironment: z.enum(['test-mock', 'development-twin', 'production']),
145
200
  routes: z.object({
146
201
  mock: z.literal(true),
147
202
  managed: z.boolean(),
@@ -150,5 +205,6 @@ export const EditorAccountSnapshotSchema: z.ZodType<EditorAccountSnapshot> = z.i
150
205
  }),
151
206
  preferredRoute: GenerationExecutionRouteSchema,
152
207
  providerCredentials: z.array(ProviderCredentialStatusSchema),
208
+ codingInference: CodingInferenceSettingsSchema,
153
209
  }),
154
210
  );
@@ -62,6 +62,9 @@ export const GenerationJobSchema = z.object({
62
62
  cancel: GenerationOperationReferenceSchema.optional(),
63
63
  accept: GenerationOperationReferenceSchema.optional(),
64
64
  acceptedAt: z.string().datetime().optional(),
65
+ /** Last time a human/agent opened the native result. A later provider
66
+ * update makes the terminal result unread again without changing status. */
67
+ readAt: z.string().datetime().optional(),
65
68
  provenanceOperationId: z.string().optional(),
66
69
  outputPaths: z.array(z.string()).optional(),
67
70
  });
@@ -103,6 +106,9 @@ export interface GenerationJobUpdate {
103
106
  message?: string;
104
107
  billing?: GenerationBilling;
105
108
  cancel?: { tool: string; input: unknown };
109
+ /** `null` withdraws an acceptance action after polling proves that a
110
+ * successful operation produced no downloadable project output. */
111
+ accept?: { tool: string; input: unknown } | null;
106
112
  accepted?: {
107
113
  provenanceOperationId: string;
108
114
  outputPaths: string[];
@@ -44,8 +44,8 @@
44
44
  */
45
45
 
46
46
  import { spawnSync } from 'node:child_process';
47
- import { existsSync, readdirSync, statSync } from 'node:fs';
48
- import { join } from 'node:path';
47
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
48
+ import { dirname, join } from 'node:path';
49
49
 
50
50
  // ---------------------------------------------------------------------------
51
51
  // Shared vocabulary
@@ -146,27 +146,33 @@ function cadenceLine(work: UncommittedWork): string {
146
146
  * The escalating stderr line/banner for uncommitted work — pure, driven
147
147
  * directly by a test. `null` below NOTICE and whenever there is no batch at
148
148
  * all.
149
+ *
150
+ * THE TWO TIERS SAY DIFFERENT KINDS OF THING, deliberately. Notice DESCRIBES
151
+ * (`cadenceLine`): at ten minutes a reader is mid-slice and the useful signal
152
+ * is the clock. Loud PRESCRIBES: it opens with the imperative and the literal
153
+ * two commands, because the measured failure is not ignorance of the rule —
154
+ * three consecutive probes quoted "one commit per slice" back at the reader
155
+ * and then batch-committed anyway. A banner that restates a rule the reader
156
+ * already agrees with adds nothing; the one that names the next keystroke is
157
+ * the one that can change the outcome.
149
158
  */
150
159
  export function commitCadenceBanner(work: UncommittedWork | null): string | null {
151
160
  const tier = commitCadenceTier(work);
152
161
  if (!work || tier === 'silent') return null;
153
162
  if (tier === 'notice') return cadenceLine(work);
154
163
  const age = formatElapsed(work.ageMs);
155
- const files = `${work.fileCount} file(s)`;
156
164
  return [
157
165
  '================================================================',
158
- ` UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
166
+ ` COMMIT NOW — UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
159
167
  '================================================================',
160
- ` ${files} have been dirty for ${age} with no commit behind them.`,
161
- '',
162
- ' The bar is ONE COMMIT PER SLICE. A build that lands as a single',
163
- ' end-of-run commit cannot be reviewed, bisected, or partially',
164
- ' recovered when a later step goes wrong — and a build that reaches',
165
- ' this banner is on exactly that path.',
168
+ ' Commit NOW, one commit per slice (AGENTS.md):',
169
+ ' git add <your files> && git commit -m "<mechanic>"',
166
170
  '',
167
- ' Commit what already works now, then keep going:',
168
- ' git add <the files of the slice you finished>',
169
- ' git commit -m "<the slice>"',
171
+ ` Uncommitted work is ${age} old across ${work.fileCount} file(s).`,
172
+ ' A build that lands as a single end-of-run commit cannot be',
173
+ ' reviewed, bisected, or partially recovered when a later step goes',
174
+ ' wrong — and a build that reaches this banner is on exactly that',
175
+ ' path. Commit the slice that already works, then keep going.',
170
176
  '================================================================',
171
177
  ].join('\n');
172
178
  }
@@ -571,3 +577,145 @@ export function advanceTripwireGate(
571
577
  gate: { evaluatedAtMs: now, announced: tier },
572
578
  };
573
579
  }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // The gate, across process restarts
583
+ // ---------------------------------------------------------------------------
584
+
585
+ /**
586
+ * WHY THE GATE IS ON DISK.
587
+ *
588
+ * Measured failure (the "foundry" blind probe, 45 minutes): the commit-cadence
589
+ * tripwire announced at NOTICE mid-run — `{"tier":"notice","ageMs":679238,
590
+ * "fileCount":23}` is in that session's journal — and the LOUD step never
591
+ * announced once across three editor-server restarts, while the agent went on
592
+ * to batch-commit all 23 files at the end. The gate above is correct and the
593
+ * process holding it is not: an editor server restarts (a crash, a config
594
+ * change, `vgai restart`) far more often than a dirty batch resolves, and each
595
+ * restart re-armed at `announced: 'silent'`, so a batch that had already been
596
+ * noticed simply got noticed AGAIN at the same tier — never escalated. The
597
+ * loudest step of the escalation was unreachable by construction for exactly
598
+ * the builds it exists to catch.
599
+ *
600
+ * WHAT IS KEYED, AND WHY IT IS NOT A TIMESTAMP. The persisted record carries a
601
+ * KEY alongside the announced tier, and a key that differs from the one being
602
+ * asked about reads as a fresh gate. The key is what "the tripwire went quiet"
603
+ * means for that tripwire — the same thing the in-memory gate re-arms on:
604
+ * - `commit-cadence` — the last commit (`commitCadenceGateKey`). A commit
605
+ * lands, the key changes, the next batch is heard from zero.
606
+ * - `unplayed-session` — the newest live evidence
607
+ * (`unplayedSessionGateKey`). A play happens, the key changes, the clock
608
+ * starts over.
609
+ * `evaluatedAtMs` is deliberately NOT persisted: it is the read cap, a fact
610
+ * about one process's event rate, and carrying it across a restart would blind
611
+ * the first minute of the new session for no benefit.
612
+ */
613
+
614
+ /** The tripwires with a persisted gate — the same names the session journal
615
+ * uses, so a journal line and a gate entry can never disagree about which
616
+ * tripwire is meant. */
617
+ export type TripwireName = 'commit-cadence' | 'unplayed-session';
618
+
619
+ /** One tripwire's persisted state. */
620
+ interface PersistedGate {
621
+ readonly key: string;
622
+ readonly announced: TripwireTier;
623
+ }
624
+
625
+ /**
626
+ * Where the gate lives, project-relative.
627
+ *
628
+ * Under `.vgai/` beside the other machine-local caches (`check-idioms.json`),
629
+ * and inside a directory `newestSourceMtime` already skips — so the file the
630
+ * tripwire writes can never read as a source change and make the tripwire cry
631
+ * wolf on its own output. It is gitignored in the scaffold for the same
632
+ * reason it must never count: a gate write that dirtied the tree would add a
633
+ * file to the very `fileCount` it is reporting.
634
+ */
635
+ export const TRIPWIRE_GATE_PATH = join('.vgai', 'tripwire-gate.json');
636
+
637
+ function gateFilePath(projectRoot: string): string {
638
+ return join(projectRoot, TRIPWIRE_GATE_PATH);
639
+ }
640
+
641
+ /** The whole file, or an empty record. Corrupt, missing, unreadable and
642
+ * not-an-object are ONE case: a fresh gate, never a throw. A discipline
643
+ * banner that crashed a save would be worse than one that repeated itself. */
644
+ function readGateFile(projectRoot: string): Record<string, PersistedGate> {
645
+ try {
646
+ const parsed: unknown = JSON.parse(readFileSync(gateFilePath(projectRoot), 'utf-8'));
647
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
648
+ return parsed as Record<string, PersistedGate>;
649
+ } catch {
650
+ return {};
651
+ }
652
+ }
653
+
654
+ /** Best-effort write. Same rule as the read: never throws. */
655
+ function writeGateFile(projectRoot: string, file: Record<string, PersistedGate>): void {
656
+ const path = gateFilePath(projectRoot);
657
+ try {
658
+ mkdirSync(dirname(path), { recursive: true });
659
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
660
+ } catch {
661
+ /* an unwritable project degrades to the in-memory gate */
662
+ }
663
+ }
664
+
665
+ /**
666
+ * The highest tier already announced for `name` under `key` — `silent` when
667
+ * nothing is recorded, when the file is unreadable, or when the recorded key
668
+ * is a different one (which is what "the tripwire went quiet" looks like on
669
+ * disk).
670
+ */
671
+ export function readAnnouncedTier(
672
+ projectRoot: string,
673
+ name: TripwireName,
674
+ key: string,
675
+ ): TripwireTier {
676
+ const entry = readGateFile(projectRoot)[name];
677
+ if (!entry || entry.key !== key) return 'silent';
678
+ return entry.announced in TIER_RANK ? entry.announced : 'silent';
679
+ }
680
+
681
+ /** The commit-cadence key: the commit the current batch sits on top of.
682
+ * A constant outside a work tree — the tripwire is silent there anyway. */
683
+ export function commitCadenceGateKey(projectRoot: string | null): string {
684
+ if (!projectRoot) return 'no-project';
685
+ return git(projectRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
686
+ }
687
+
688
+ /** The unplayed-session key: the newest live evidence this project has, which
689
+ * is precisely what makes that tripwire go quiet. */
690
+ export function unplayedSessionGateKey(newestEvidence: number | null): string {
691
+ return newestEvidence === null ? 'never-played' : String(newestEvidence);
692
+ }
693
+
694
+ /**
695
+ * {@link advanceTripwireGate}, with the announced tier read from and written
696
+ * back to the project — so a crossing announces once per crossing rather than
697
+ * once per crossing PER PROCESS.
698
+ *
699
+ * The in-memory gate is still the caller's: it carries `evaluatedAtMs` (the
700
+ * read cap) and its own `announced` is folded in as a floor, so a failed write
701
+ * degrades to the old behavior rather than to a repeating banner. Persistence
702
+ * rides this call and nothing else — there is no poller, no watcher, and no
703
+ * second place that touches the file.
704
+ */
705
+ export function advancePersistedTripwireGate(
706
+ projectRoot: string,
707
+ name: TripwireName,
708
+ key: string,
709
+ gate: TripwireGate,
710
+ tier: TripwireTier,
711
+ now: number,
712
+ ): { readonly announce: boolean; readonly gate: TripwireGate } {
713
+ const onDisk = readAnnouncedTier(projectRoot, name, key);
714
+ const announced = TIER_RANK[onDisk] > TIER_RANK[gate.announced] ? onDisk : gate.announced;
715
+ const step = advanceTripwireGate({ evaluatedAtMs: gate.evaluatedAtMs, announced }, tier, now);
716
+ writeGateFile(projectRoot, {
717
+ ...readGateFile(projectRoot),
718
+ [name]: { key, announced: step.gate.announced },
719
+ });
720
+ return step;
721
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a run is CALLED — one owner of the bound, and of the fallback a run
3
+ * gets when nobody named it.
4
+ *
5
+ * WHY THIS EXISTS. `vgai play --name <text>` and `npm run playtest --
6
+ * '{"name":"…"}'` both exist so a run is findable by what it was testing, and
7
+ * the measured answer to an optional field is that it stays empty: every play
8
+ * event in the foundry probe's 45-minute session journal reads `"name":null`.
9
+ * A label nobody supplies indexes nothing, so the run that DOES know what it
10
+ * was testing supplies it — a route-targeted playtest is named after its
11
+ * routes, and a full-suite run is named `playtest`. Nothing is taught and no
12
+ * habit is required; the default carries the information.
13
+ *
14
+ * WHAT STAYS UNNAMED, deliberately: interactive `vgai play` with no `--name`.
15
+ * A person pressing play is not testing a named thing, and inventing a label
16
+ * for it would put noise in exactly the directory this makes greppable.
17
+ *
18
+ * Pure — no filesystem, no clock. `editor-server.ts`'s `playRunSlug` is the
19
+ * OTHER half (slugging a name into a filename segment) and reads the same
20
+ * bound from here, so the two can never disagree about how long a run name
21
+ * may be.
22
+ */
23
+
24
+ /** Bound on a run's name — long enough to stay recognisable in a directory
25
+ * listing, short enough that the timestamp beside it is still readable. */
26
+ export const MAX_RUN_NAME = 40;
27
+
28
+ /** The name a full-suite run gets: it targets no route in particular, and
29
+ * "which run was that" is still a question worth being able to answer. */
30
+ export const FULL_SUITE_RUN_NAME = 'playtest';
31
+
32
+ /**
33
+ * The name for one playtest run.
34
+ *
35
+ * An explicit name always wins — it is the caller saying what this run was,
36
+ * and no derivation is better informed than that. Otherwise the routes name
37
+ * it, joined with `+` (a separator that survives being read back as a list,
38
+ * unlike the `-` that route names themselves use), bounded at
39
+ * {@link MAX_RUN_NAME}; and a run that named no route is
40
+ * {@link FULL_SUITE_RUN_NAME}.
41
+ *
42
+ * `null` is impossible by construction — every playtest run gets a name — but
43
+ * an explicit blank/whitespace name is treated as no name at all, the same
44
+ * case a missing one is.
45
+ */
46
+ export function derivePlaytestRunName(
47
+ explicit: string | null | undefined,
48
+ routes: readonly string[],
49
+ ): string {
50
+ if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim();
51
+ if (routes.length === 0) return FULL_SUITE_RUN_NAME;
52
+ return routes.join('+').slice(0, MAX_RUN_NAME).replace(/\+$/, '');
53
+ }
@@ -32,7 +32,7 @@
32
32
  * grows unbounded, which is what an append-only record means.
33
33
  */
34
34
 
35
- import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
35
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
37
  import type { TripwireTier } from './build-discipline';
38
38
 
@@ -101,6 +101,148 @@ export type SessionJournalEvent =
101
101
  readonly action: 'start' | 'stop';
102
102
  readonly name: string | null;
103
103
  readonly logFile: string | null;
104
+ }
105
+ /**
106
+ * THE TRANSPORT ARM. Everything below is one line per transport or
107
+ * presence fact, and it exists because the journal was BLIND to all of
108
+ * them: on 2026-08-09 a session refused five `vgai play` commands against
109
+ * a healthy connected tab and the whole file it left behind was
110
+ * `session-started` plus validation lines. A relay that can refuse has to
111
+ * be able to say who it refused, to which tab, and on what evidence.
112
+ *
113
+ * Ids are truncated to 8 characters (`clientId8`, `tabId8`, `requestId8`)
114
+ * — enough to correlate lines within one session, short enough that a
115
+ * human can scan a column of them. No payload bodies, ever: a state
116
+ * snapshot or a command argument list would turn an append-only record
117
+ * into a memory dump.
118
+ */
119
+ /** A control connection opened (either transport). */
120
+ | {
121
+ readonly kind: 'client-connected';
122
+ readonly clientId8: string;
123
+ readonly transport: 'ws' | 'sse';
124
+ readonly participant: string | null;
125
+ }
126
+ /** A control connection closed, and how many pending commands it settled. */
127
+ | {
128
+ readonly kind: 'client-disconnected';
129
+ readonly clientId8: string;
130
+ readonly transport: 'ws' | 'sse';
131
+ /** WebSocket close code, or null for SSE (which has none). */
132
+ readonly code: number | null;
133
+ readonly reason: string | null;
134
+ readonly commandsSettled: number;
135
+ }
136
+ /** The server granted a socket the duplex control capability. */
137
+ | { readonly kind: 'duplex-granted'; readonly clientId8: string }
138
+ /** A command left the relay for one tab's command channel. */
139
+ | {
140
+ readonly kind: 'command-relayed';
141
+ readonly command: string;
142
+ readonly requestId8: string;
143
+ readonly tabId8: string | null;
144
+ readonly clientId8: string | null;
145
+ }
146
+ /** The tab said its command listener PICKED THE COMMAND UP. */
147
+ | { readonly kind: 'command-receipt'; readonly requestId8: string }
148
+ /** The command settled — by the tab's own answer or by the relay refusing. */
149
+ | {
150
+ readonly kind: 'command-result';
151
+ readonly requestId8: string;
152
+ readonly ok: boolean;
153
+ /** Present only when `ok` is false; the refusal/failure text. */
154
+ readonly error?: string;
155
+ }
156
+ /**
157
+ * The relay HELD a command instead of refusing it: the target tab is
158
+ * present by heartbeat but its command channel is not carrying right now
159
+ * (mid-reload, or a channel that has not answered an echo).
160
+ */
161
+ | {
162
+ readonly kind: 'command-held';
163
+ readonly requestId8: string;
164
+ readonly tabId8: string;
165
+ readonly reason: 'no-channel' | 'unacknowledged';
166
+ }
167
+ /** The main-thread echo probe over a duplex socket. */
168
+ | {
169
+ readonly kind: 'echo-probe';
170
+ readonly clientId8: string;
171
+ readonly answered: boolean;
172
+ readonly waitedMs: number;
173
+ }
174
+ /** The blessed tab changed (or was chosen for the first time). */
175
+ | {
176
+ readonly kind: 'tab-blessed';
177
+ readonly tabId8: string;
178
+ readonly previousTabId8: string | null;
179
+ readonly reason: 'sticky' | 'oldest' | 'claimed';
180
+ }
181
+ /** An extra tab was told to yield. */
182
+ | { readonly kind: 'tab-yielded'; readonly tabId8: string }
183
+ /**
184
+ * PRESENCE, IN TAB VOCABULARY. These five say what the tab table saw, and
185
+ * they deliberately do NOT use socket words: a socket closing is not a tab
186
+ * leaving, and that conflation is what produced "editor tab lost —
187
+ * reopening" against a tab that had never gone anywhere.
188
+ */
189
+ /** A tabId beat for the first time. */
190
+ | {
191
+ readonly kind: 'tab-appeared';
192
+ readonly tabId8: string;
193
+ readonly visibility: 'visible' | 'hidden';
194
+ }
195
+ /** Beats stopped arriving for longer than the notice threshold. */
196
+ | { readonly kind: 'tab-heartbeat-gap'; readonly tabId8: string; readonly sinceMs: number }
197
+ /** A gap closed — the same tab resumed beating. `gapMs` is its full length. */
198
+ | { readonly kind: 'tab-gap-closed'; readonly tabId8: string; readonly gapMs: number }
199
+ /** Same tabId, new epoch: the page reloaded. The tab never left. */
200
+ | { readonly kind: 'tab-reloaded'; readonly tabId8: string; readonly epochCount: number }
201
+ /** Absent past its grace: this tab is gone. */
202
+ | { readonly kind: 'tab-departed'; readonly tabId8: string; readonly absentMs: number }
203
+ /**
204
+ * WHAT THE TAB WAS HOLDING WHEN IT DIED WITHOUT A GOODBYE.
205
+ *
206
+ * Emitted beside `client-disconnected` on an ABNORMAL close (1006 — the
207
+ * socket ended with no close frame, which is what a killed renderer process
208
+ * leaves behind; an ordinary tab close sends one). Measured 2026-08-10: a
209
+ * game tab's renderer was killed repeatedly at Chrome's undocumented
210
+ * per-process ceiling and the whole session record said `1006` and nothing
211
+ * else — the recovery worked perfectly and the CAUSE was unrecorded.
212
+ *
213
+ * `census` is the tab's last resource profile and `censusAgeMs` how stale it
214
+ * was; both null for a tab that never reported one (an older page, a
215
+ * tunnelled tab). Numbers only — this stays a journal line, not a dump.
216
+ */
217
+ | {
218
+ readonly kind: 'tab-death-profile';
219
+ readonly tabId8: string;
220
+ /** The abnormal close code that triggered the line. */
221
+ readonly code: number;
222
+ readonly censusAgeMs: number | null;
223
+ readonly census: {
224
+ readonly heapUsedMB: number | null;
225
+ readonly heapLimitMB: number | null;
226
+ readonly canvases: number;
227
+ readonly canvasMB: number;
228
+ readonly textures?: number;
229
+ readonly geometries?: number;
230
+ readonly programs?: number;
231
+ } | null;
232
+ }
233
+ /** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
234
+ | { readonly kind: 'tab-duplicated'; readonly tabId8: string }
235
+ /**
236
+ * The tab is present (its worker is beating) but its PAGE has never opened
237
+ * a command channel this page-load. A heartbeat proves the tab exists, not
238
+ * that the document works — a main thread that died after the inline
239
+ * bootstrap beats forever and can run nothing. Such a tab is passed over
240
+ * for blessing and named in the refusal.
241
+ */
242
+ | {
243
+ readonly kind: 'tab-unresponsive';
244
+ readonly tabId8: string;
245
+ readonly noChannelForMs: number;
104
246
  };
105
247
 
106
248
  /** A parsed journal line: the event plus when it was appended. */
@@ -194,6 +336,152 @@ export function newestSessionJournal(projectRoot: string): string | null {
194
336
  }
195
337
  }
196
338
 
339
+ /**
340
+ * The newest journal's last `limit` lines of the requested kinds, oldest first.
341
+ *
342
+ * WHY A READER EXISTS AT ALL. The journal was written as a file an agent
343
+ * "can read at any time", and the measured answer to that invitation was: it
344
+ * doesn't. The foundry probe's journal held the notice-tier tripwire crossing
345
+ * that predicted its own end-of-run batch commit, and nothing that agent ran
346
+ * ever rendered a line of it. So the record grows a RENDERER on the command
347
+ * agents already poll (`vgai status`) — same principle as the tripwires' own
348
+ * origin: the mechanism was right and the delivery assumption was false.
349
+ *
350
+ * Every failure is the same case — no project, no journal, unreadable file, a
351
+ * line that will not parse — and it is the empty list, never a throw. A status
352
+ * command that crashed on a malformed log line would be a worse outcome than
353
+ * one that says nothing about it.
354
+ *
355
+ * The whole file is read, which is what bounds this: a journal is one editor
356
+ * session's surface-worthy transitions (the happy-path silence rule keeps
357
+ * clean saves out), the pruner caps how many exist, and the alternative — a
358
+ * reverse streaming reader over a file measured in kilobytes — is machinery
359
+ * with nothing to buy.
360
+ */
361
+ export function readRecentJournalEvents(
362
+ projectRoot: string,
363
+ kinds: readonly SessionJournalEvent['kind'][],
364
+ limit: number,
365
+ ): SessionJournalLine[] {
366
+ const path = newestSessionJournal(projectRoot);
367
+ if (path === null || limit <= 0) return [];
368
+ let raw: string;
369
+ try {
370
+ raw = readFileSync(path, 'utf-8');
371
+ } catch {
372
+ return [];
373
+ }
374
+ const wanted = new Set<string>(kinds);
375
+ const matched: SessionJournalLine[] = [];
376
+ for (const line of raw.split('\n')) {
377
+ if (line === '') continue;
378
+ let parsed: SessionJournalLine;
379
+ try {
380
+ parsed = JSON.parse(line) as SessionJournalLine;
381
+ } catch {
382
+ continue; // a torn final line is not a reason to report nothing
383
+ }
384
+ if (parsed && typeof parsed === 'object' && wanted.has(parsed.kind)) matched.push(parsed);
385
+ }
386
+ return matched.slice(-limit);
387
+ }
388
+
389
+ /**
390
+ * One journal line as a scannable terminal line: `HH:MM:SS`, the kind, and
391
+ * the few facts that line is about.
392
+ *
393
+ * ONE OWNER of this rendering, for `sessionJournalPointerLine`'s reason — the
394
+ * events already have exactly one wording as data, and a second prose form per
395
+ * caller is how a reader ends up believing there are two records.
396
+ */
397
+ export function formatJournalLine(line: SessionJournalLine): string {
398
+ const at = line.at.slice(11, 19);
399
+ switch (line.kind) {
400
+ case 'tripwire':
401
+ return line.tripwire === 'commit-cadence'
402
+ ? `journal: ${at} tripwire commit-cadence ${line.tier} (${line.fileCount} files, ${Math.round(line.ageMs / 60_000)}m)`
403
+ : `journal: ${at} tripwire unplayed-session ${line.tier} (${Math.round(line.servingForMs / 60_000)}m)`;
404
+ case 'validation':
405
+ return `journal: ${at} validation ${line.ok ? 'ok' : 'FAILED'} ${line.path}`;
406
+ case 'play':
407
+ return `journal: ${at} play ${line.action}${line.name === null ? '' : ` [${line.name}]`}`;
408
+ case 'session-started':
409
+ return `journal: ${at} session-started pid ${line.pid}`;
410
+ case 'project-opened':
411
+ return `journal: ${at} project-opened ${line.project}`;
412
+ case 'session-shutdown':
413
+ return `journal: ${at} session-shutdown`;
414
+ // The TRANSPORT arm. One short line each, and every one of them names the
415
+ // TAB or the request it is about — a column of ids is how these lines get
416
+ // correlated, and a paragraph per line is how a reader stops reading.
417
+ case 'client-connected':
418
+ return `journal: ${at} client-connected ${line.clientId8} (${line.transport})`;
419
+ case 'client-disconnected':
420
+ return `journal: ${at} client-disconnected ${line.clientId8} code ${line.code ?? '-'} settled ${line.commandsSettled}`;
421
+ case 'duplex-granted':
422
+ return `journal: ${at} duplex-granted ${line.clientId8}`;
423
+ case 'command-relayed':
424
+ return `journal: ${at} command-relayed ${line.command} ${line.requestId8} -> tab ${line.tabId8 ?? 'none'}`;
425
+ case 'command-receipt':
426
+ return `journal: ${at} command-receipt ${line.requestId8}`;
427
+ case 'command-result':
428
+ return `journal: ${at} command-result ${line.requestId8} ${line.ok ? 'ok' : `FAILED ${line.error ?? ''}`}`;
429
+ case 'command-held':
430
+ return `journal: ${at} command-held ${line.requestId8} tab ${line.tabId8} (${line.reason})`;
431
+ case 'echo-probe':
432
+ return `journal: ${at} echo-probe ${line.clientId8} ${line.answered ? 'answered' : 'UNANSWERED'} in ${line.waitedMs}ms`;
433
+ case 'tab-blessed':
434
+ return `journal: ${at} tab-blessed ${line.tabId8} (${line.reason})`;
435
+ case 'tab-yielded':
436
+ return `journal: ${at} tab-yielded ${line.tabId8}`;
437
+ case 'tab-appeared':
438
+ return `journal: ${at} tab-appeared ${line.tabId8} (${line.visibility})`;
439
+ case 'tab-heartbeat-gap':
440
+ return `journal: ${at} tab-heartbeat-gap ${line.tabId8} ${Math.round(line.sinceMs / 100) / 10}s`;
441
+ case 'tab-gap-closed':
442
+ return `journal: ${at} tab-gap-closed ${line.tabId8} after ${Math.round(line.gapMs / 100) / 10}s`;
443
+ case 'tab-reloaded':
444
+ return `journal: ${at} tab-reloaded ${line.tabId8} (page load ${line.epochCount})`;
445
+ case 'tab-departed':
446
+ return `journal: ${at} tab-departed ${line.tabId8} absent ${Math.round(line.absentMs / 100) / 10}s`;
447
+ case 'tab-duplicated':
448
+ return `journal: ${at} tab-duplicated ${line.tabId8}`;
449
+ case 'tab-unresponsive':
450
+ return `journal: ${at} tab-unresponsive ${line.tabId8} no channel for ${Math.round(line.noChannelForMs / 1000)}s`;
451
+ case 'tab-death-profile':
452
+ return `journal: ${at} tab-death-profile ${line.tabId8} code ${line.code} — ${tabDeathProfileBody(line)}`;
453
+ }
454
+ }
455
+
456
+ /** The census half of a `tab-death-profile` line, split out so the switch above
457
+ * stays one expression per case. */
458
+ function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-profile' }): string {
459
+ const census = formatTabCensus(line.census);
460
+ if (line.censusAgeMs === null) return census;
461
+ return `${census} (sampled ${Math.round(line.censusAgeMs / 100) / 10}s before)`;
462
+ }
463
+
464
+ /** The resource profile a `tab-death-profile` line and a `vgai status` tab row
465
+ * both quote. ONE wording, for `sessionJournalPointerLine`'s reason: two
466
+ * phrasings of the same numbers is how a reader ends up believing there are
467
+ * two measurements. Renderer counts appear only when the page could read them
468
+ * (no mounted render-debug adapter = the words are absent, never a zero). */
469
+ export function formatTabCensus(
470
+ census: (SessionJournalEvent & { kind: 'tab-death-profile' })['census'],
471
+ ): string {
472
+ if (census === null) return 'no resource census';
473
+ const parts = [
474
+ census.heapUsedMB === null
475
+ ? 'heap n/a'
476
+ : `heap ${census.heapUsedMB}MB${census.heapLimitMB === null ? '' : `/${census.heapLimitMB}MB`}`,
477
+ `canvas ${census.canvasMB}MB in ${census.canvases}`,
478
+ ];
479
+ if (census.textures !== undefined) parts.push(`tex ${census.textures}`);
480
+ if (census.geometries !== undefined) parts.push(`geo ${census.geometries}`);
481
+ if (census.programs !== undefined) parts.push(`prog ${census.programs}`);
482
+ return parts.join(', ');
483
+ }
484
+
197
485
  /**
198
486
  * The ONE wording for "here is the journal" — printed by the editor server's
199
487
  * boot block, by `vgai edit`'s ready/detach lines, and by `vgai status`.