@venturewild/workspace 0.6.1 → 0.6.2

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 (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -85
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +974 -974
  10. package/server/src/bazaar/index.mjs +88 -88
  11. package/server/src/bazaar/mcp-server.mjs +429 -429
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
  24. package/server/src/canvas/core.mjs +446 -446
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -108
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +3332 -3332
  34. package/server/src/listings-rails.mjs +156 -156
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-registry.mjs +295 -295
  51. package/server/src/workspaces.mjs +145 -145
  52. package/web/dist/assets/index-BgFan7ls.js +131 -0
  53. package/web/dist/assets/index-DHts78rO.css +32 -0
  54. package/web/dist/index.html +2 -2
  55. package/web/dist/assets/index-CzUrGoMW.css +0 -32
  56. package/web/dist/assets/index-ZYLNuQRa.js +0 -131
@@ -1,156 +1,156 @@
1
- // ListingsRails — the cross-user bazaar marketplace client (next push §N).
2
- //
3
- // A thin client over bmo-sync's POST /api/listings/{publish,list,delete,report}
4
- // (account-token self-authed, exactly like canvas-rails.mjs). The owner is
5
- // DERIVED server-side from the account token, so a caller can only ever
6
- // publish/delete its OWN listing. v1 carries themes only (Class A — pure-data
7
- // hex bundles); spike 1 (12/12) validated this exact shape.
8
- //
9
- // degrade-never-throw, like CanvasRails: a down rail or a logged-out install
10
- // never breaks the local bazaar. `publish`/`delete`/`report` resolve to false on
11
- // any failure; `list` resolves to { ok:false, listings:[] } so the caller serves
12
- // seeds + the user's own listings (the offline tier). The rails are the network
13
- // LAYER on top of the local file-backed bazaar — never a hard dependency.
14
-
15
- const DEFAULT_TIMEOUT_MS = 4000;
16
-
17
- export class ListingsRails {
18
- constructor({
19
- bmoSyncUrl,
20
- accountToken,
21
- timeoutMs = DEFAULT_TIMEOUT_MS,
22
- fetchImpl = (...a) => globalThis.fetch(...a),
23
- } = {}) {
24
- this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
25
- this.accountToken = accountToken || null;
26
- this.timeoutMs = timeoutMs;
27
- this.fetchImpl = fetchImpl;
28
- // Inert without a token (can't key it) or without a server URL.
29
- this.capable = Boolean(this.accountToken) && Boolean(this.bmoSyncUrl);
30
- }
31
-
32
- async _post(path, body) {
33
- if (!this.capable) return null;
34
- const ctrl = new AbortController();
35
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
36
- if (timer.unref) timer.unref();
37
- try {
38
- const r = await this.fetchImpl(`${this.bmoSyncUrl}${path}`, {
39
- method: 'POST',
40
- headers: { 'content-type': 'application/json' },
41
- body: JSON.stringify({ account_token: this.accountToken, ...body }),
42
- signal: ctrl.signal,
43
- });
44
- if (!r || !r.ok) return null;
45
- return await r.json().catch(() => null);
46
- } catch {
47
- return null; // network / abort / parse — caller degrades to local-only
48
- } finally {
49
- clearTimeout(timer);
50
- }
51
- }
52
-
53
- /**
54
- * Publish (upsert) one listing card to the global pool. Best-effort.
55
- * @returns {Promise<boolean>} true iff the rails accepted it.
56
- */
57
- async publish(listing) {
58
- const resp = await this._post('/api/listings/publish', { listing });
59
- return Boolean(resp && resp.ok === true);
60
- }
61
-
62
- /**
63
- * The global pool, optionally filtered by kind (v1: 'theme').
64
- * @returns {Promise<{ok:boolean, listings:Array}>}
65
- * ok:true → the rails answered; `listings` is the raw ListItem array
66
- * ({id,kind,json,producer_handle,producer_name,created_at,updated_at}).
67
- * ok:false → unreachable / not configured → caller serves seeds + own only.
68
- */
69
- async list({ kind = 'theme' } = {}) {
70
- const resp = await this._post('/api/listings/list', { kind });
71
- if (!resp || resp.ok !== true || !Array.isArray(resp.listings)) {
72
- return { ok: false, listings: [] };
73
- }
74
- return { ok: true, listings: resp.listings };
75
- }
76
-
77
- /** Soft-delete the caller's own listing. Best-effort. */
78
- async delete(id) {
79
- const resp = await this._post('/api/listings/delete', { id });
80
- return Boolean(resp && resp.ok === true);
81
- }
82
-
83
- /** Report a listing to the ops inbox (the GLM day-one must-have). Best-effort. */
84
- async report(id, reason = '') {
85
- const resp = await this._post('/api/listings/report', { id, reason: String(reason || '').slice(0, 500) });
86
- return Boolean(resp && resp.ok === true);
87
- }
88
- }
89
-
90
- /**
91
- * Turn a rails ListItem into a bazaar theme card: parse the stored card JSON and
92
- * OVERRIDE the producer with the server-DERIVED public identity (the real owner's
93
- * display name + stable handle — never "You", never the email). Returns null for a
94
- * non-theme / unparsable row. The theme bundle is re-validated by the bazaar core
95
- * on read (normalizeTheme), so this stays a pure shape transform.
96
- */
97
- export function railItemToCard(item) {
98
- if (!item || typeof item !== 'object') return null;
99
- let card;
100
- try {
101
- card = JSON.parse(item.json);
102
- } catch {
103
- return null;
104
- }
105
- if (!card || typeof card !== 'object' || card.kind !== 'theme') return null;
106
- return {
107
- ...card,
108
- id: item.id, // trust the server's stable id, not the embedded copy
109
- kind: 'theme',
110
- source: 'rails',
111
- producer: {
112
- name: item.producer_name || 'A maker',
113
- handle: item.producer_handle || 'maker',
114
- kind: 'maker',
115
- },
116
- };
117
- }
118
-
119
- /**
120
- * Turn a rails ListItem into a bazaar RECIPE card: parse the stored card JSON and
121
- * OVERRIDE the producer with the server-DERIVED public identity (never the email).
122
- * Carries `knowHow` through (the payload a consumer's agent absorbs). Returns null
123
- * for a non-recipe / unparsable row. The bazaar core re-runs the Class-B scan on
124
- * this know-how on read (readRailsRecipes) and drops it if unsafe, so this stays a
125
- * pure shape transform.
126
- */
127
- export function railItemToRecipeCard(item) {
128
- if (!item || typeof item !== 'object') return null;
129
- let card;
130
- try {
131
- card = JSON.parse(item.json);
132
- } catch {
133
- return null;
134
- }
135
- if (!card || typeof card !== 'object' || card.kind !== 'recipe') return null;
136
- return {
137
- ...card,
138
- id: item.id, // trust the server's stable id, not the embedded copy
139
- kind: 'recipe',
140
- source: 'rails',
141
- producer: {
142
- name: item.producer_name || 'A maker',
143
- handle: item.producer_handle || 'maker',
144
- kind: 'maker',
145
- },
146
- };
147
- }
148
-
149
- /** Build the client from server config + account (or an inert one when logged out). */
150
- export function createListingsRails(config, account, fetchImpl) {
151
- return new ListingsRails({
152
- bmoSyncUrl: config?.bmoSyncServerUrl,
153
- accountToken: account?.accountToken,
154
- fetchImpl,
155
- });
156
- }
1
+ // ListingsRails — the cross-user bazaar marketplace client (next push §N).
2
+ //
3
+ // A thin client over bmo-sync's POST /api/listings/{publish,list,delete,report}
4
+ // (account-token self-authed, exactly like canvas-rails.mjs). The owner is
5
+ // DERIVED server-side from the account token, so a caller can only ever
6
+ // publish/delete its OWN listing. v1 carries themes only (Class A — pure-data
7
+ // hex bundles); spike 1 (12/12) validated this exact shape.
8
+ //
9
+ // degrade-never-throw, like CanvasRails: a down rail or a logged-out install
10
+ // never breaks the local bazaar. `publish`/`delete`/`report` resolve to false on
11
+ // any failure; `list` resolves to { ok:false, listings:[] } so the caller serves
12
+ // seeds + the user's own listings (the offline tier). The rails are the network
13
+ // LAYER on top of the local file-backed bazaar — never a hard dependency.
14
+
15
+ const DEFAULT_TIMEOUT_MS = 4000;
16
+
17
+ export class ListingsRails {
18
+ constructor({
19
+ bmoSyncUrl,
20
+ accountToken,
21
+ timeoutMs = DEFAULT_TIMEOUT_MS,
22
+ fetchImpl = (...a) => globalThis.fetch(...a),
23
+ } = {}) {
24
+ this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
25
+ this.accountToken = accountToken || null;
26
+ this.timeoutMs = timeoutMs;
27
+ this.fetchImpl = fetchImpl;
28
+ // Inert without a token (can't key it) or without a server URL.
29
+ this.capable = Boolean(this.accountToken) && Boolean(this.bmoSyncUrl);
30
+ }
31
+
32
+ async _post(path, body) {
33
+ if (!this.capable) return null;
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
36
+ if (timer.unref) timer.unref();
37
+ try {
38
+ const r = await this.fetchImpl(`${this.bmoSyncUrl}${path}`, {
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({ account_token: this.accountToken, ...body }),
42
+ signal: ctrl.signal,
43
+ });
44
+ if (!r || !r.ok) return null;
45
+ return await r.json().catch(() => null);
46
+ } catch {
47
+ return null; // network / abort / parse — caller degrades to local-only
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Publish (upsert) one listing card to the global pool. Best-effort.
55
+ * @returns {Promise<boolean>} true iff the rails accepted it.
56
+ */
57
+ async publish(listing) {
58
+ const resp = await this._post('/api/listings/publish', { listing });
59
+ return Boolean(resp && resp.ok === true);
60
+ }
61
+
62
+ /**
63
+ * The global pool, optionally filtered by kind (v1: 'theme').
64
+ * @returns {Promise<{ok:boolean, listings:Array}>}
65
+ * ok:true → the rails answered; `listings` is the raw ListItem array
66
+ * ({id,kind,json,producer_handle,producer_name,created_at,updated_at}).
67
+ * ok:false → unreachable / not configured → caller serves seeds + own only.
68
+ */
69
+ async list({ kind = 'theme' } = {}) {
70
+ const resp = await this._post('/api/listings/list', { kind });
71
+ if (!resp || resp.ok !== true || !Array.isArray(resp.listings)) {
72
+ return { ok: false, listings: [] };
73
+ }
74
+ return { ok: true, listings: resp.listings };
75
+ }
76
+
77
+ /** Soft-delete the caller's own listing. Best-effort. */
78
+ async delete(id) {
79
+ const resp = await this._post('/api/listings/delete', { id });
80
+ return Boolean(resp && resp.ok === true);
81
+ }
82
+
83
+ /** Report a listing to the ops inbox (the GLM day-one must-have). Best-effort. */
84
+ async report(id, reason = '') {
85
+ const resp = await this._post('/api/listings/report', { id, reason: String(reason || '').slice(0, 500) });
86
+ return Boolean(resp && resp.ok === true);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Turn a rails ListItem into a bazaar theme card: parse the stored card JSON and
92
+ * OVERRIDE the producer with the server-DERIVED public identity (the real owner's
93
+ * display name + stable handle — never "You", never the email). Returns null for a
94
+ * non-theme / unparsable row. The theme bundle is re-validated by the bazaar core
95
+ * on read (normalizeTheme), so this stays a pure shape transform.
96
+ */
97
+ export function railItemToCard(item) {
98
+ if (!item || typeof item !== 'object') return null;
99
+ let card;
100
+ try {
101
+ card = JSON.parse(item.json);
102
+ } catch {
103
+ return null;
104
+ }
105
+ if (!card || typeof card !== 'object' || card.kind !== 'theme') return null;
106
+ return {
107
+ ...card,
108
+ id: item.id, // trust the server's stable id, not the embedded copy
109
+ kind: 'theme',
110
+ source: 'rails',
111
+ producer: {
112
+ name: item.producer_name || 'A maker',
113
+ handle: item.producer_handle || 'maker',
114
+ kind: 'maker',
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Turn a rails ListItem into a bazaar RECIPE card: parse the stored card JSON and
121
+ * OVERRIDE the producer with the server-DERIVED public identity (never the email).
122
+ * Carries `knowHow` through (the payload a consumer's agent absorbs). Returns null
123
+ * for a non-recipe / unparsable row. The bazaar core re-runs the Class-B scan on
124
+ * this know-how on read (readRailsRecipes) and drops it if unsafe, so this stays a
125
+ * pure shape transform.
126
+ */
127
+ export function railItemToRecipeCard(item) {
128
+ if (!item || typeof item !== 'object') return null;
129
+ let card;
130
+ try {
131
+ card = JSON.parse(item.json);
132
+ } catch {
133
+ return null;
134
+ }
135
+ if (!card || typeof card !== 'object' || card.kind !== 'recipe') return null;
136
+ return {
137
+ ...card,
138
+ id: item.id, // trust the server's stable id, not the embedded copy
139
+ kind: 'recipe',
140
+ source: 'rails',
141
+ producer: {
142
+ name: item.producer_name || 'A maker',
143
+ handle: item.producer_handle || 'maker',
144
+ kind: 'maker',
145
+ },
146
+ };
147
+ }
148
+
149
+ /** Build the client from server config + account (or an inert one when logged out). */
150
+ export function createListingsRails(config, account, fetchImpl) {
151
+ return new ListingsRails({
152
+ bmoSyncUrl: config?.bmoSyncServerUrl,
153
+ accountToken: account?.accountToken,
154
+ fetchImpl,
155
+ });
156
+ }
@@ -1,98 +1,98 @@
1
- // logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
2
- // the operator channel all read the same set, and a tiny append/rotate/tail
3
- // helper for the new logs we write ourselves (cli, operator).
4
- //
5
- // WHY a registry and not just scattered paths: a brand-new user's install is the
6
- // riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
7
- // and "I can't see what happened on their machine" is the #1 un-debuggable
8
- // failure. Centralising the log locations is what lets `wild-workspace doctor`
9
- // gather them and the operator channel tail them by name — never by arbitrary
10
- // path (the tail allowlist is TAILABLE below).
11
- //
12
- // NOTE: the supervisor + daemon-supervisor keep writing their logs at the
13
- // global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
14
- // are reboot-proven and pinned by tests via an injected globalDir, so we mirror
15
- // them here for READING rather than moving them. Everything lives under
16
- // ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
17
-
18
- import os from 'node:os';
19
- import path from 'node:path';
20
- import fs from 'node:fs';
21
-
22
- // THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
23
- // defaults. Overridable via env for tests / unusual homes.
24
- export function globalDir(env = process.env) {
25
- return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
26
- }
27
-
28
- // Logical name → filename. The first three are written by existing components;
29
- // cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
30
- export const LOG_FILES = Object.freeze({
31
- supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
32
- server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
33
- daemon: 'daemon.log', // the bmo-sync daemon
34
- cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
- operator: 'operator.log', // the consented operator channel's audit trail
36
- audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
37
- });
38
-
39
- // Logs the operator channel may tail BY NAME — never an arbitrary path.
40
- export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
41
-
42
- export function logFile(name, env = process.env) {
43
- return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
44
- }
45
-
46
- export function diagnosticsDir(env = process.env) {
47
- return path.join(globalDir(env), 'diagnostics');
48
- }
49
-
50
- const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
51
-
52
- // Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
53
- export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
54
- try {
55
- if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
56
- } catch {
57
- /* missing / racing rename — nothing to rotate */
58
- }
59
- }
60
-
61
- // Append a timestamped line to a named log; ensures the dir + rotates. Returns
62
- // the file path. Never throws — logging must not break the caller's path.
63
- export function appendLine(name, line, env = process.env) {
64
- const file = logFile(name, env);
65
- try {
66
- fs.mkdirSync(path.dirname(file), { recursive: true });
67
- rotateIfBig(file);
68
- fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
69
- } catch {
70
- /* read-only fs etc. — degrade silently */
71
- }
72
- return file;
73
- }
74
-
75
- // Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
76
- // Returns '' when the file is missing/unreadable.
77
- export function tailFile(file, n = 200) {
78
- try {
79
- // Drop the trailing newline so the last line isn't an empty entry.
80
- const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
81
- return lines.slice(Math.max(0, lines.length - n)).join('\n');
82
- } catch {
83
- return '';
84
- }
85
- }
86
-
87
- // All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
88
- export function listLogs(env = process.env) {
89
- return TAILABLE.map((name) => {
90
- const file = logFile(name, env);
91
- try {
92
- const st = fs.statSync(file);
93
- return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
94
- } catch {
95
- return { name, file, exists: false, size: null, mtime: null };
96
- }
97
- });
98
- }
1
+ // logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
2
+ // the operator channel all read the same set, and a tiny append/rotate/tail
3
+ // helper for the new logs we write ourselves (cli, operator).
4
+ //
5
+ // WHY a registry and not just scattered paths: a brand-new user's install is the
6
+ // riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
7
+ // and "I can't see what happened on their machine" is the #1 un-debuggable
8
+ // failure. Centralising the log locations is what lets `wild-workspace doctor`
9
+ // gather them and the operator channel tail them by name — never by arbitrary
10
+ // path (the tail allowlist is TAILABLE below).
11
+ //
12
+ // NOTE: the supervisor + daemon-supervisor keep writing their logs at the
13
+ // global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
14
+ // are reboot-proven and pinned by tests via an injected globalDir, so we mirror
15
+ // them here for READING rather than moving them. Everything lives under
16
+ // ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
17
+
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ // THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
23
+ // defaults. Overridable via env for tests / unusual homes.
24
+ export function globalDir(env = process.env) {
25
+ return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
26
+ }
27
+
28
+ // Logical name → filename. The first three are written by existing components;
29
+ // cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
30
+ export const LOG_FILES = Object.freeze({
31
+ supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
32
+ server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
33
+ daemon: 'daemon.log', // the bmo-sync daemon
34
+ cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
+ operator: 'operator.log', // the consented operator channel's audit trail
36
+ audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
37
+ });
38
+
39
+ // Logs the operator channel may tail BY NAME — never an arbitrary path.
40
+ export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
41
+
42
+ export function logFile(name, env = process.env) {
43
+ return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
44
+ }
45
+
46
+ export function diagnosticsDir(env = process.env) {
47
+ return path.join(globalDir(env), 'diagnostics');
48
+ }
49
+
50
+ const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
51
+
52
+ // Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
53
+ export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
54
+ try {
55
+ if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
56
+ } catch {
57
+ /* missing / racing rename — nothing to rotate */
58
+ }
59
+ }
60
+
61
+ // Append a timestamped line to a named log; ensures the dir + rotates. Returns
62
+ // the file path. Never throws — logging must not break the caller's path.
63
+ export function appendLine(name, line, env = process.env) {
64
+ const file = logFile(name, env);
65
+ try {
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ rotateIfBig(file);
68
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
69
+ } catch {
70
+ /* read-only fs etc. — degrade silently */
71
+ }
72
+ return file;
73
+ }
74
+
75
+ // Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
76
+ // Returns '' when the file is missing/unreadable.
77
+ export function tailFile(file, n = 200) {
78
+ try {
79
+ // Drop the trailing newline so the last line isn't an empty entry.
80
+ const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
81
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ // All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
88
+ export function listLogs(env = process.env) {
89
+ return TAILABLE.map((name) => {
90
+ const file = logFile(name, env);
91
+ try {
92
+ const st = fs.statSync(file);
93
+ return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
94
+ } catch {
95
+ return { name, file, exists: false, size: null, mtime: null };
96
+ }
97
+ });
98
+ }
@@ -1,45 +1,45 @@
1
- // Consent for the proactive session + install observability feed (session-reporter.mjs
2
- // + transcript.mjs).
3
- //
4
- // DEFAULT-ON: an absent file means consented — because the disclosure is shown at
5
- // onboarding, and this is the model the apps users already trust use (they retain
6
- // sessions by default with an easy off). An explicit opt-out persists here. Kept
7
- // in its own file (not agent-identity.json) so it's readable at first load —
8
- // before the agent is even named — and trivial to flip. Mirrors operator.mjs.
9
- //
10
- // What "on" actually streams: WHAT happened + install health, never the words
11
- // (redaction lives in session-reporter.mjs). Off also hard-stops the kill switch
12
- // path WILD_WORKSPACE_NO_TELEMETRY=1, which disables ALL telemetry regardless.
13
-
14
- import fs from 'node:fs';
15
- import path from 'node:path';
16
-
17
- export const OBSERVABILITY_VERSION = 1;
18
-
19
- function consentFile(dataDir) {
20
- return path.join(dataDir, 'observability.json');
21
- }
22
-
23
- export function loadObservabilityConsent(dataDir) {
24
- try {
25
- const p = JSON.parse(fs.readFileSync(consentFile(dataDir), 'utf8'));
26
- return {
27
- enabled: p.enabled !== false,
28
- decidedAt: p.decidedAt ? Number(p.decidedAt) : null,
29
- version: Number(p.version) || OBSERVABILITY_VERSION,
30
- };
31
- } catch {
32
- return { enabled: true, decidedAt: null, version: OBSERVABILITY_VERSION }; // default-on
33
- }
34
- }
35
-
36
- export function setObservabilityConsent(dataDir, enabled) {
37
- const rec = { enabled: Boolean(enabled), decidedAt: Date.now(), version: OBSERVABILITY_VERSION };
38
- try {
39
- fs.mkdirSync(dataDir, { recursive: true });
40
- fs.writeFileSync(consentFile(dataDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
41
- } catch {
42
- /* read-only fs — fall back to in-memory for this run */
43
- }
44
- return rec;
45
- }
1
+ // Consent for the proactive session + install observability feed (session-reporter.mjs
2
+ // + transcript.mjs).
3
+ //
4
+ // DEFAULT-ON: an absent file means consented — because the disclosure is shown at
5
+ // onboarding, and this is the model the apps users already trust use (they retain
6
+ // sessions by default with an easy off). An explicit opt-out persists here. Kept
7
+ // in its own file (not agent-identity.json) so it's readable at first load —
8
+ // before the agent is even named — and trivial to flip. Mirrors operator.mjs.
9
+ //
10
+ // What "on" actually streams: WHAT happened + install health, never the words
11
+ // (redaction lives in session-reporter.mjs). Off also hard-stops the kill switch
12
+ // path WILD_WORKSPACE_NO_TELEMETRY=1, which disables ALL telemetry regardless.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export const OBSERVABILITY_VERSION = 1;
18
+
19
+ function consentFile(dataDir) {
20
+ return path.join(dataDir, 'observability.json');
21
+ }
22
+
23
+ export function loadObservabilityConsent(dataDir) {
24
+ try {
25
+ const p = JSON.parse(fs.readFileSync(consentFile(dataDir), 'utf8'));
26
+ return {
27
+ enabled: p.enabled !== false,
28
+ decidedAt: p.decidedAt ? Number(p.decidedAt) : null,
29
+ version: Number(p.version) || OBSERVABILITY_VERSION,
30
+ };
31
+ } catch {
32
+ return { enabled: true, decidedAt: null, version: OBSERVABILITY_VERSION }; // default-on
33
+ }
34
+ }
35
+
36
+ export function setObservabilityConsent(dataDir, enabled) {
37
+ const rec = { enabled: Boolean(enabled), decidedAt: Date.now(), version: OBSERVABILITY_VERSION };
38
+ try {
39
+ fs.mkdirSync(dataDir, { recursive: true });
40
+ fs.writeFileSync(consentFile(dataDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
41
+ } catch {
42
+ /* read-only fs — fall back to in-memory for this run */
43
+ }
44
+ return rec;
45
+ }