wicked-crew 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/api/api-prefix.d.ts +13 -0
  2. package/dist/api/api-prefix.d.ts.map +1 -0
  3. package/dist/api/api-prefix.js +13 -0
  4. package/dist/api/api-prefix.js.map +1 -0
  5. package/dist/api/audit.d.ts +57 -0
  6. package/dist/api/audit.d.ts.map +1 -0
  7. package/dist/api/audit.js +115 -0
  8. package/dist/api/audit.js.map +1 -0
  9. package/dist/api/auth.d.ts +192 -0
  10. package/dist/api/auth.d.ts.map +1 -0
  11. package/dist/api/auth.js +515 -0
  12. package/dist/api/auth.js.map +1 -0
  13. package/dist/api/evidence.d.ts +11 -0
  14. package/dist/api/evidence.d.ts.map +1 -1
  15. package/dist/api/evidence.js +8 -3
  16. package/dist/api/evidence.js.map +1 -1
  17. package/dist/api/gate-cache.d.ts +8 -0
  18. package/dist/api/gate-cache.d.ts.map +1 -1
  19. package/dist/api/gate-cache.js +10 -0
  20. package/dist/api/gate-cache.js.map +1 -1
  21. package/dist/api/open-path.d.ts +18 -0
  22. package/dist/api/open-path.d.ts.map +1 -0
  23. package/dist/api/open-path.js +79 -0
  24. package/dist/api/open-path.js.map +1 -0
  25. package/dist/api/routes.d.ts +113 -1
  26. package/dist/api/routes.d.ts.map +1 -1
  27. package/dist/api/routes.js +516 -27
  28. package/dist/api/routes.js.map +1 -1
  29. package/dist/api/seat-health.d.ts +55 -0
  30. package/dist/api/seat-health.d.ts.map +1 -0
  31. package/dist/api/seat-health.js +273 -0
  32. package/dist/api/seat-health.js.map +1 -0
  33. package/dist/api/seat-signin.d.ts +27 -0
  34. package/dist/api/seat-signin.d.ts.map +1 -0
  35. package/dist/api/seat-signin.js +143 -0
  36. package/dist/api/seat-signin.js.map +1 -0
  37. package/dist/api/server.d.ts +111 -3
  38. package/dist/api/server.d.ts.map +1 -1
  39. package/dist/api/server.js +236 -11
  40. package/dist/api/server.js.map +1 -1
  41. package/dist/api/unit-output.d.ts +62 -0
  42. package/dist/api/unit-output.d.ts.map +1 -0
  43. package/dist/api/unit-output.js +108 -0
  44. package/dist/api/unit-output.js.map +1 -0
  45. package/dist/cli/index.js +97 -3
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/cli/mcp.d.ts +14 -0
  48. package/dist/cli/mcp.d.ts.map +1 -0
  49. package/dist/cli/mcp.js +119 -0
  50. package/dist/cli/mcp.js.map +1 -0
  51. package/dist/core/adapter.d.ts +106 -1
  52. package/dist/core/adapter.d.ts.map +1 -1
  53. package/dist/core/adapter.js +293 -12
  54. package/dist/core/adapter.js.map +1 -1
  55. package/dist/core/bridge-path.d.ts +17 -6
  56. package/dist/core/bridge-path.d.ts.map +1 -1
  57. package/dist/core/bridge-path.js +17 -6
  58. package/dist/core/bridge-path.js.map +1 -1
  59. package/dist/core/types.d.ts +23 -352
  60. package/dist/core/types.d.ts.map +1 -1
  61. package/dist/core/types.js +11 -4
  62. package/dist/core/types.js.map +1 -1
  63. package/dist/interactive/bridge-pool.d.ts +71 -0
  64. package/dist/interactive/bridge-pool.d.ts.map +1 -0
  65. package/dist/interactive/bridge-pool.js +187 -0
  66. package/dist/interactive/bridge-pool.js.map +1 -0
  67. package/dist/interactive/bridge-root.d.ts +36 -0
  68. package/dist/interactive/bridge-root.d.ts.map +1 -0
  69. package/dist/interactive/bridge-root.js +48 -0
  70. package/dist/interactive/bridge-root.js.map +1 -0
  71. package/dist/interactive/draft-events.d.ts +144 -0
  72. package/dist/interactive/draft-events.d.ts.map +1 -0
  73. package/dist/interactive/draft-events.js +490 -0
  74. package/dist/interactive/draft-events.js.map +1 -0
  75. package/dist/interactive/edit-events.d.ts +172 -0
  76. package/dist/interactive/edit-events.d.ts.map +1 -0
  77. package/dist/interactive/edit-events.js +530 -0
  78. package/dist/interactive/edit-events.js.map +1 -0
  79. package/dist/interactive/ledger.d.ts +39 -0
  80. package/dist/interactive/ledger.d.ts.map +1 -0
  81. package/dist/interactive/ledger.js +93 -0
  82. package/dist/interactive/ledger.js.map +1 -0
  83. package/dist/interactive/proxy-routes.d.ts +39 -0
  84. package/dist/interactive/proxy-routes.d.ts.map +1 -0
  85. package/dist/interactive/proxy-routes.js +189 -0
  86. package/dist/interactive/proxy-routes.js.map +1 -0
  87. package/dist/interactive/ws-relay.d.ts +85 -0
  88. package/dist/interactive/ws-relay.d.ts.map +1 -0
  89. package/dist/interactive/ws-relay.js +191 -0
  90. package/dist/interactive/ws-relay.js.map +1 -0
  91. package/dist/projects/activity.d.ts +29 -0
  92. package/dist/projects/activity.d.ts.map +1 -0
  93. package/dist/projects/activity.js +172 -0
  94. package/dist/projects/activity.js.map +1 -0
  95. package/dist/projects/charter.d.ts +28 -0
  96. package/dist/projects/charter.d.ts.map +1 -0
  97. package/dist/projects/charter.js +53 -0
  98. package/dist/projects/charter.js.map +1 -0
  99. package/dist/projects/events.d.ts +55 -0
  100. package/dist/projects/events.d.ts.map +1 -0
  101. package/dist/projects/events.js +141 -0
  102. package/dist/projects/events.js.map +1 -0
  103. package/dist/projects/membership-index.d.ts +25 -0
  104. package/dist/projects/membership-index.d.ts.map +1 -0
  105. package/dist/projects/membership-index.js +46 -0
  106. package/dist/projects/membership-index.js.map +1 -0
  107. package/dist/projects/routes.d.ts +90 -0
  108. package/dist/projects/routes.d.ts.map +1 -0
  109. package/dist/projects/routes.js +388 -0
  110. package/dist/projects/routes.js.map +1 -0
  111. package/dist/projects/settings.d.ts +32 -0
  112. package/dist/projects/settings.d.ts.map +1 -0
  113. package/dist/projects/settings.js +64 -0
  114. package/dist/projects/settings.js.map +1 -0
  115. package/dist/qe/acceptance.d.ts +137 -0
  116. package/dist/qe/acceptance.d.ts.map +1 -0
  117. package/dist/qe/acceptance.js +249 -0
  118. package/dist/qe/acceptance.js.map +1 -0
  119. package/dist/qe/gate-events.d.ts +111 -0
  120. package/dist/qe/gate-events.d.ts.map +1 -0
  121. package/dist/qe/gate-events.js +168 -0
  122. package/dist/qe/gate-events.js.map +1 -0
  123. package/dist/qe/ledger.d.ts +100 -0
  124. package/dist/qe/ledger.d.ts.map +1 -0
  125. package/dist/qe/ledger.js +154 -0
  126. package/dist/qe/ledger.js.map +1 -0
  127. package/dist/studio/assets/index-CCwXa1cn.js +428 -0
  128. package/dist/studio/assets/index-HWxo0h41.css +32 -0
  129. package/dist/studio/index.html +2 -2
  130. package/package.json +10 -4
  131. package/dist/studio/assets/index-Ci_R6ARr.js +0 -423
  132. package/dist/studio/assets/index-Dio_c1Q3.css +0 -32
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Injectable IO for tests: `home` replaces `os.homedir()` and `env` replaces `process.env`,
3
+ * so fixture dirs built with `mkdtemp` exercise every branch without touching the developer's
4
+ * real dotfiles. Production callers omit it.
5
+ */
6
+ export interface SigninProbeIo {
7
+ home?: string;
8
+ env?: Record<string, string | undefined>;
9
+ }
10
+ /**
11
+ * Cheap signed-in presence for one seat. `workerConfigRoot` is the settings-applied
12
+ * `WICKED_WORKER_HOME` value when set (the roster route passes the live env, which is exactly
13
+ * what the engine will read at the next spawn); omitted = the engine default `~/.wicked-worker`.
14
+ *
15
+ * Never throws: an unreadable file or dir is treated as the corresponding "not observable"
16
+ * answer for that seat's rule, never a 500 on the roster route.
17
+ */
18
+ export declare function signedInHeuristic(seatKey: string, workerConfigRoot?: string, io?: SigninProbeIo): boolean | null;
19
+ /**
20
+ * Apply the persisted `worker_config_root` setting to THIS process's environment. The engine
21
+ * reads `WICKED_WORKER_HOME` per worker spawn (acp_runner.rs — never cached at engine start),
22
+ * so calling this at daemon boot and again on every settings change is sufficient: the next
23
+ * spawn sees the new root with no daemon or engine restart. `settings.json` is the source of
24
+ * truth — unset/empty DELETES the env, restoring the engine default `~/.wicked-worker`.
25
+ */
26
+ export declare function applyWorkerConfigRoot(root: string | undefined): void;
27
+ //# sourceMappingURL=seat-signin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seat-signin.d.ts","sourceRoot":"","sources":["../../src/api/seat-signin.ts"],"names":[],"mappings":"AAkCA;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC1C;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,gBAAgB,CAAC,EAAE,MAAM,EACzB,EAAE,GAAE,aAAkB,GACrB,OAAO,GAAG,IAAI,CA2FhB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAMpE"}
@@ -0,0 +1,143 @@
1
+ // Seat sign-in presence (seat sign-in): does each council seat LOOK signed in?
2
+ //
3
+ // The doctrine mirrors seat health (crew#274): auth state is RUNTIME state the platform observes
4
+ // and displays, never config an operator hand-edits. The studio surfaces every seat's auth
5
+ // uniformly — `signed_in` says what the daemon can OBSERVE, and the seat's `login_invocation`
6
+ // (engine roster passthrough, wicked-core PR#278) is what the studio runs in a PTY terminal
7
+ // (`POST /terminals`) to fix it.
8
+ //
9
+ // This module is a HEURISTIC, deliberately cheap:
10
+ //
11
+ // - File/env PRESENCE only — no process is ever spawned (the roster route calls this per seat
12
+ // per request; a spawn here would be a fork bomb with a UI attached).
13
+ // - Three-valued: `true`/`false` when the observable half of the seat's auth state answers,
14
+ // `null` when it is unknowable cheaply. Keychain-backed credentials are the canonical `null`:
15
+ // a file can prove a login HAPPENED, but its absence proves nothing when the secret lives in
16
+ // the OS keychain — and `false` would send an operator to re-login a working seat.
17
+ // - `true` is "a credential artifact exists", NOT "the credential still works". An expired
18
+ // OAuth token reads `true` here; seat HEALTH (crew#274) is what catches it failing live.
19
+ //
20
+ // Per-seat rules (each documented at its branch):
21
+ // claude — the WORKER config home's `.claude.json` (per-config-dir keychain entry means the
22
+ // file is the observable half; see below), under `WICKED_WORKER_HOME`/the default.
23
+ // codex — `~/.codex/auth.json` presence.
24
+ // copilot — env token (COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN) → true; installed but no
25
+ // env → null (keychain state unknowable cheaply — NOT false); no trace → false.
26
+ // opencode — `~/.local/share/opencode/auth.json` presence.
27
+ // pi — `~/.pi/agent/auth.json` presence.
28
+ // agy — any `.json` under `~/.antigravitycli/` → true; else null (keyring unknowable).
29
+ // unknown — null (a seat this module has no rule for is exactly "unknown").
30
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
31
+ import { homedir } from 'node:os';
32
+ import { join } from 'node:path';
33
+ /**
34
+ * Cheap signed-in presence for one seat. `workerConfigRoot` is the settings-applied
35
+ * `WICKED_WORKER_HOME` value when set (the roster route passes the live env, which is exactly
36
+ * what the engine will read at the next spawn); omitted = the engine default `~/.wicked-worker`.
37
+ *
38
+ * Never throws: an unreadable file or dir is treated as the corresponding "not observable"
39
+ * answer for that seat's rule, never a 500 on the roster route.
40
+ */
41
+ export function signedInHeuristic(seatKey, workerConfigRoot, io = {}) {
42
+ const home = io.home ?? homedir();
43
+ const env = io.env ?? process.env;
44
+ switch (seatKey) {
45
+ case 'claude': {
46
+ // The engine places the claude WORKER home at `<root>/claude` (acp_runner.rs
47
+ // claude_worker_home; root = WICKED_WORKER_HOME or ~/.wicked-worker). Claude keeps the
48
+ // OAuth token itself in a PER-CONFIG-DIR keychain entry, so the token is not observable —
49
+ // but `/login` also writes an `oauthAccount` block into that dir's `.claude.json`, which
50
+ // is: present-with-key means a login completed for the WORKER home (not the operator's
51
+ // own ~/.claude). Heuristic by construction — a revoked token still reads true.
52
+ const root = workerConfigRoot !== undefined && workerConfigRoot !== ''
53
+ ? workerConfigRoot
54
+ : join(home, '.wicked-worker');
55
+ const file = join(root, 'claude', '.claude.json');
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
58
+ return typeof parsed === 'object' && parsed !== null && 'oauthAccount' in parsed;
59
+ }
60
+ catch {
61
+ return false; // missing / unreadable / malformed: no completed login is observable
62
+ }
63
+ }
64
+ case 'codex':
65
+ // `codex login` writes ~/.codex/auth.json (tokens live IN the file — presence is the state).
66
+ return existsSync(join(home, '.codex', 'auth.json'));
67
+ case 'copilot': {
68
+ // Env token wins: copilot honors these directly, and a set token IS a signed-in seat.
69
+ for (const key of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN']) {
70
+ const v = env[key];
71
+ if (typeof v === 'string' && v !== '')
72
+ return true;
73
+ }
74
+ // `copilot login` stores the TOKEN in the OS keychain, but it also records the logged-in
75
+ // USER in config.json (`lastLoggedInUser` / `loggedInUsers`) — verified in the field: a
76
+ // successful sign-in left the chip on null-neutral and the Sign in button up, reading as
77
+ // "didn't work". The file is JSONC (comment header), so this parses key-presence
78
+ // leniently rather than JSON.parse-ing the whole document.
79
+ const cfg = join(home, '.copilot', 'config.json');
80
+ if (existsSync(cfg)) {
81
+ try {
82
+ const raw = readFileSync(cfg, 'utf8');
83
+ // A non-empty STRING user, or an array whose first entry is a non-empty string —
84
+ // `[""]` must not read as signed in (Copilot, PR#282).
85
+ // Both shapes seen in the field: a plain string user, OR an OBJECT user
86
+ // ({host, login} — the live config on macOS). A non-empty "login" string inside
87
+ // either container is the common observable.
88
+ if (/"lastLoggedInUser"\s*:\s*"[^"]+"/.test(raw) ||
89
+ /"loggedInUsers"\s*:\s*\[\s*"[^"]+"/.test(raw) ||
90
+ // Object shapes, SCOPED to the user containers (an unrelated "login" string
91
+ // elsewhere in the config must not read as signed in — Copilot, PR#283).
92
+ /"lastLoggedInUser"\s*:\s*\{[^}]*"login"\s*:\s*"[^"]+"/.test(raw) ||
93
+ /"loggedInUsers"\s*:\s*\[\s*\{[^}]*"login"\s*:\s*"[^"]+"/.test(raw)) {
94
+ return true;
95
+ }
96
+ }
97
+ catch {
98
+ /* unreadable config falls through to null */
99
+ }
100
+ // Installed but no recorded user and no env: the keychain half stays unknowable.
101
+ return null;
102
+ }
103
+ return false; // no env, no config dir: nothing suggests this seat ever signed in
104
+ }
105
+ case 'opencode':
106
+ // `opencode auth login` writes the credential file itself — presence is the state.
107
+ return existsSync(join(home, '.local', 'share', 'opencode', 'auth.json'));
108
+ case 'pi':
109
+ // pi's auth flow writes ~/.pi/agent/auth.json — presence is the state.
110
+ return existsSync(join(home, '.pi', 'agent', 'auth.json'));
111
+ case 'agy': {
112
+ // Antigravity keeps its credential in the OS keyring; a `.json` in ~/.antigravitycli/ is
113
+ // the observable artifact a completed login leaves behind. Missing dir OR dir-with-no-json
114
+ // both mean the keyring state is unknowable cheaply → null (only a json upgrades to true).
115
+ let entries;
116
+ try {
117
+ entries = readdirSync(join(home, '.antigravitycli'));
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ return entries.some((f) => f.endsWith('.json')) ? true : null;
123
+ }
124
+ default:
125
+ return null; // a seat with no rule here is exactly "unknown"
126
+ }
127
+ }
128
+ /**
129
+ * Apply the persisted `worker_config_root` setting to THIS process's environment. The engine
130
+ * reads `WICKED_WORKER_HOME` per worker spawn (acp_runner.rs — never cached at engine start),
131
+ * so calling this at daemon boot and again on every settings change is sufficient: the next
132
+ * spawn sees the new root with no daemon or engine restart. `settings.json` is the source of
133
+ * truth — unset/empty DELETES the env, restoring the engine default `~/.wicked-worker`.
134
+ */
135
+ export function applyWorkerConfigRoot(root) {
136
+ if (typeof root === 'string' && root !== '') {
137
+ process.env['WICKED_WORKER_HOME'] = root;
138
+ }
139
+ else {
140
+ delete process.env['WICKED_WORKER_HOME'];
141
+ }
142
+ }
143
+ //# sourceMappingURL=seat-signin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seat-signin.js","sourceRoot":"","sources":["../../src/api/seat-signin.ts"],"names":[],"mappings":"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"}
@@ -1,14 +1,122 @@
1
1
  import Fastify from 'fastify';
2
- import type { CoreAdapter } from '../core/adapter.js';
2
+ import { type AuthOptions } from './auth.js';
3
+ import { CoreAdapter } from '../core/adapter.js';
3
4
  /**
4
5
  * The bundled studio SPA lives at `dist/studio` next to the compiled server
5
6
  * (`dist/api/server.js` → `../studio`). See DES-STUDIO-SERVING-001 §2.3/§3.1
6
- * and `scripts/bundle-studio.mjs` which copies `packages/studio/dist` there.
7
+ * and `scripts/bundle-studio.mjs`, which copies the installed `wicked-studio`
8
+ * package's `dist/` there (the SPA is its own product since the #98 carve —
9
+ * github.com/mikeparcewski/wicked-studio — consumed as a dist artifact).
7
10
  */
8
11
  export declare function defaultStudioRoot(): string;
9
12
  export interface CreateServerOptions {
10
13
  /** Override the studio asset root (tests point this at a temp fixture dir). */
11
14
  studioRoot?: string;
15
+ /**
16
+ * Opt-in QE gate-event consumption over wicked-bus (Phase 6a). When enabled,
17
+ * a durable subscriber folds `wicked.qe.gate.*` / `wicked.qe.deploy.completed`
18
+ * into the acceptance route's freshness cache; when absent (the default),
19
+ * the route's lazy ledger read stands alone — same answers, read on demand.
20
+ */
21
+ qeGateEvents?: {
22
+ enabled: boolean;
23
+ /** Bus db path; omit for wicked-bus's own default resolution. */
24
+ dbPath?: string;
25
+ /** Poll cadence, ms (tests shorten it). */
26
+ pollIntervalMs?: number;
27
+ };
28
+ /**
29
+ * Opt-in governed answering of wicked-interactive first-draft generation (task #86 spike,
30
+ * Phase 7c). When enabled, a durable subscriber answers `wicked.interactive.doc.created`
31
+ * (kind:source) with a governed `interactive-draft` run that ends in
32
+ * `wicked.interactive.draft.completed`; when absent (the default), interactive's own assist
33
+ * loop remains the answerer and crew never touches that bus traffic.
34
+ */
35
+ interactiveDraftEvents?: {
36
+ enabled: boolean;
37
+ /** Bus db path; omit for wicked-bus's own default resolution (honors WICKED_BUS_DATA_DIR). */
38
+ dbPath?: string;
39
+ /** Poll cadence, ms (tests shorten it). */
40
+ pollIntervalMs?: number;
41
+ /** Heartbeat narration cadence, ms (default 15000). */
42
+ heartbeatMs?: number;
43
+ /** Durable replay-dedup ledger path (default ~/.wicked-crew/interactive-draft-ledger.json). */
44
+ ledgerPath?: string;
45
+ /** Where governed workers write finished drafts (default ~/.wicked-crew/interactive-drafts). */
46
+ draftDir?: string;
47
+ /** Seat roster override (JSON array); omit for the production council roster. */
48
+ clisJson?: string;
49
+ };
50
+ /**
51
+ * Opt-in governed answering of wicked-interactive STRUCTURAL edits (task #86, Phase 7c final
52
+ * leg). When enabled, a durable subscriber answers `wicked.interactive.feedback.processed`
53
+ * (awaiting_structural > 0) with a governed `interactive-edit` run that ends in
54
+ * `wicked.interactive.edit.completed` — after a deterministic INV-2 pre-emit self-check;
55
+ * when absent (the default), interactive's own assist loop remains the answerer.
56
+ * Deterministic (content/style/remove) edits never reach this seam at all: the model-free
57
+ * service applies those instantly and hands off only the structural remainder.
58
+ */
59
+ interactiveEditEvents?: {
60
+ enabled: boolean;
61
+ /** Bus db path; omit for wicked-bus's own default resolution (honors WICKED_BUS_DATA_DIR). */
62
+ dbPath?: string;
63
+ /** Poll cadence, ms (tests shorten it). */
64
+ pollIntervalMs?: number;
65
+ /** Heartbeat narration cadence, ms (default 15000). */
66
+ heartbeatMs?: number;
67
+ /** Durable replay-dedup ledger path (default ~/.wicked-crew/interactive-edit-ledger.json). */
68
+ ledgerPath?: string;
69
+ /** Where handoff files land and workers write edited fragments
70
+ * (default ~/.wicked-crew/interactive-edits). */
71
+ editDir?: string;
72
+ /** Seat roster override (JSON array); omit for the production council roster. */
73
+ clisJson?: string;
74
+ };
75
+ /**
76
+ * The project bus seam (DES-PROJECT-001 §4/§5.2). DEFAULT-ON, unlike the opt-in seams
77
+ * above: the ADR's event vocabulary and the live activity bridge are part of the surface, not
78
+ * an integration experiment — but the posture stays LOUD-non-fatal (no wicked-bus / broken db
79
+ * ⇒ project CRUD works, events don't ride). `disabled: true` turns the whole seam off (tests).
80
+ */
81
+ projectEvents?: {
82
+ disabled?: boolean;
83
+ /** Bus db path; omit for wicked-bus's own default resolution (honors WICKED_BUS_DATA_DIR). */
84
+ dbPath?: string;
85
+ /** Poll cadence for the /ws activity bridge, ms (tests shorten it). */
86
+ pollIntervalMs?: number;
87
+ };
88
+ /**
89
+ * DES-MERGE-001 §5.4/§6.1 (slice 3) — the interactive /ws relay. DEFAULT-ON: every
90
+ * wicked.interactive.** bus event is bridged onto the /ws stream as an `interactiveEvent`
91
+ * frame so the studio needs exactly ONE socket. `disabled: true` turns it off (tests).
92
+ */
93
+ interactiveWsRelay?: {
94
+ disabled?: boolean;
95
+ /** Bus db path; omit for wicked-bus's own default resolution. */
96
+ dbPath?: string;
97
+ /** Poll cadence, ms (tests shorten it). */
98
+ pollIntervalMs?: number;
99
+ };
100
+ /**
101
+ * The identity/actor seam (task #88). Omit for full env/file resolution:
102
+ * OFF by default (the local loopback deployment — nothing changes), REQUIRED
103
+ * under `WICKED_RUNTIME=team` or `WICKED_CREW_AUTH=required`. See
104
+ * `src/api/auth.ts` + docs/auth.md.
105
+ */
106
+ auth?: AuthOptions;
107
+ /** Audit-trail path override (tests). Default `~/.wicked-crew/audit.log` / `WICKED_CREW_AUDIT_LOG`. */
108
+ auditPath?: string;
109
+ /**
110
+ * The seat-health recovery probe (crew#274): every `intervalMs` (default 10 min), INACTIVE
111
+ * seats only, run the seat's `version_probe`; exit 0 flips the seat active again. `enabled`
112
+ * defaults to ON in the daemon and OFF under a test runner (VITEST / NODE_ENV=test), so a
113
+ * test-built server never spawns CLI probes unless it opts in explicitly.
114
+ */
115
+ seatHealthProbe?: {
116
+ enabled?: boolean;
117
+ intervalMs?: number;
118
+ timeoutMs?: number;
119
+ };
12
120
  }
13
121
  export declare function createServer(adapter: CoreAdapter, options?: CreateServerOptions): Promise<ReturnType<typeof Fastify>>;
14
122
  export interface StartedServer {
@@ -16,5 +124,5 @@ export interface StartedServer {
16
124
  port: number;
17
125
  host: string;
18
126
  }
19
- export declare function startServer(adapter: CoreAdapter, port?: number, host?: string): Promise<StartedServer>;
127
+ export declare function startServer(adapter: CoreAdapter, port?: number, host?: string, options?: CreateServerOptions): Promise<StartedServer>;
20
128
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/api/server.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAW9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAOtD;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,MAAM,WAAW,mBAAmB;IAClC,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC,CA8GrC;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,WAAW,EACpB,IAAI,SAAO,EACX,IAAI,SAAc,GACjB,OAAO,CAAC,aAAa,CAAC,CAQxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/api/server.ts"],"names":[],"mappings":"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"}
@@ -6,8 +6,20 @@ import { fileURLToPath } from 'node:url';
6
6
  import { registerRoutes } from './routes.js';
7
7
  import { GateCache } from './gate-cache.js';
8
8
  import { ElicitationCache } from './elicitation-cache.js';
9
+ import { registerAuthHooks, resolveAuth } from './auth.js';
10
+ import { AuditLog } from './audit.js';
9
11
  import { registerClient, broadcast } from '../events/bus.js';
10
12
  import { TerminalHub, registerTerminalWs } from '../events/terminals.js';
13
+ import { QeGateCache, startQeGateSubscriber } from '../qe/gate-events.js';
14
+ import { startInteractiveDraftSubscriber } from '../interactive/draft-events.js';
15
+ import { startInteractiveEditSubscriber } from '../interactive/edit-events.js';
16
+ import { startProjectBus, MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
17
+ import { startInteractiveWsRelay, registerInteractiveEventRoutes } from '../interactive/ws-relay.js';
18
+ import { MembershipIndex } from '../projects/membership-index.js';
19
+ import { writeRunEvidencePointer } from '../projects/charter.js';
20
+ import { CoreAdapter } from '../core/adapter.js';
21
+ import { SeatHealthTracker, startSeatHealthProbe } from './seat-health.js';
22
+ import { applyWorkerConfigRoot } from './seat-signin.js';
11
23
  // Allow the studio (a separate localhost origin, e.g. :4200) to call the
12
24
  // daemon's REST API. Restricted to loopback origins — the daemon only binds
13
25
  // 127.0.0.1, so this never widens exposure beyond the local machine.
@@ -15,7 +27,9 @@ const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost):\d+$/;
15
27
  /**
16
28
  * The bundled studio SPA lives at `dist/studio` next to the compiled server
17
29
  * (`dist/api/server.js` → `../studio`). See DES-STUDIO-SERVING-001 §2.3/§3.1
18
- * and `scripts/bundle-studio.mjs` which copies `packages/studio/dist` there.
30
+ * and `scripts/bundle-studio.mjs`, which copies the installed `wicked-studio`
31
+ * package's `dist/` there (the SPA is its own product since the #98 carve —
32
+ * github.com/mikeparcewski/wicked-studio — consumed as a dist artifact).
19
33
  */
20
34
  export function defaultStudioRoot() {
21
35
  return fileURLToPath(new URL('../studio', import.meta.url));
@@ -25,23 +39,219 @@ export async function createServer(adapter, options) {
25
39
  const gateCache = new GateCache();
26
40
  const elicitationCache = new ElicitationCache();
27
41
  const terminals = new TerminalHub();
42
+ const qeGateCache = new QeGateCache();
43
+ // Per-seat runtime health (crew#274): folded from the single CoreEvent subscription below,
44
+ // surfaced on GET /roster, recovered by the low-frequency probe armed further down.
45
+ const seatHealth = new SeatHealthTracker();
46
+ // The identity/actor seam (task #88). Resolved ONCE, before any hook exists:
47
+ // a malformed token file or a configured-but-unimplemented OIDC block must
48
+ // fail the boot here, never a request mid-flight. In the default local mode
49
+ // this reads no file and installs a hook that only pins the local actor.
50
+ const auth = resolveAuth(options?.auth, (m) => app.log.warn(m));
51
+ if (auth.mode === 'required') {
52
+ app.log.info('auth REQUIRED: bearer tokens enforced on /api/v1 and /ws');
53
+ }
54
+ const audit = new AuditLog(options?.auditPath, (m) => app.log.warn(m));
55
+ app.addHook('onClose', async () => {
56
+ await audit.flush(); // don't lose the trail's tail on shutdown
57
+ });
58
+ // Seat sign-in: export the persisted worker-config root as WICKED_WORKER_HOME at boot (the
59
+ // PUT /settings route re-applies it on every change). The engine reads the env PER SPAWN
60
+ // (acp_runner.rs claude_worker_home), so boot + on-change application is sufficient — no
61
+ // engine restart is ever needed. settings.json is the source of truth: unset/empty deletes
62
+ // the env, restoring the engine default ~/.wicked-worker.
63
+ applyWorkerConfigRoot((await adapter.getSettings()).worker_config_root);
64
+ // The project seam (DES-PROJECT-001): the bus handle for post-commit event emission + the
65
+ // /ws activity bridge, and the run→project index that tags outbound frames (§5.2). Hydrated
66
+ // from the engine so a restarted daemon tags correctly from the first frame. Created BEFORE
67
+ // the interactive seams below: their project-bound launches share the launch route's
68
+ // post-commit half (index tag + membership.attached emit) via `fileRun`.
69
+ const membershipIndex = new MembershipIndex();
70
+ await membershipIndex.hydrate(adapter, (m) => app.log.warn(m));
71
+ const projectBus = options?.projectEvents?.disabled === true
72
+ ? null
73
+ : await startProjectBus({
74
+ ...(options?.projectEvents?.dbPath !== undefined
75
+ ? { dbPath: options.projectEvents.dbPath }
76
+ : {}),
77
+ ...(options?.projectEvents?.pollIntervalMs !== undefined
78
+ ? { pollIntervalMs: options.projectEvents.pollIntervalMs }
79
+ : {}),
80
+ log: (m) => app.log.warn(m),
81
+ });
82
+ if (projectBus !== null) {
83
+ app.log.info('project bus seam armed (wicked.crew.project.* + /ws activity bridge)');
84
+ app.addHook('onClose', async () => {
85
+ await projectBus.stop();
86
+ });
87
+ }
88
+ // The interactive relay seam (DES-MERGE-001 §5.4/§6.1, slice 3): every wicked.interactive.**
89
+ // bus event becomes an `interactiveEvent` frame on the SAME /ws socket the studio already
90
+ // holds, and POST /projects/:id/interactive-events puts a whitelisted UI event back on the bus.
91
+ // Default ON — the merged skin needs it to render a generating doc — with the seam's usual
92
+ // posture: a machine without wicked-bus gets a logged null and boots anyway.
93
+ const interactiveRelay = options?.interactiveWsRelay?.disabled === true
94
+ ? null
95
+ : await startInteractiveWsRelay({
96
+ ...(options?.interactiveWsRelay?.dbPath !== undefined
97
+ ? { dbPath: options.interactiveWsRelay.dbPath }
98
+ : {}),
99
+ ...(options?.interactiveWsRelay?.pollIntervalMs !== undefined
100
+ ? { pollIntervalMs: options.interactiveWsRelay.pollIntervalMs }
101
+ : {}),
102
+ log: (m) => app.log.warn(m),
103
+ });
104
+ if (interactiveRelay !== null) {
105
+ app.log.info('interactive /ws relay armed (filter wicked.interactive.** → interactiveEvent)');
106
+ app.addHook('onClose', async () => {
107
+ await interactiveRelay.stop();
108
+ });
109
+ }
110
+ /** The post-commit half of a project-FILED launch, shared with the launch route (§2.2/§4):
111
+ * the engine already attached the crew.run membership atomically with the launch — here we
112
+ * tag future /ws frames and announce the attach on the project bus. */
113
+ const fileRun = (runId, projectId) => {
114
+ membershipIndex.set(runId, projectId);
115
+ projectBus?.emit(MEMBERSHIP_ATTACHED,
116
+ // A daemon-internal launcher (the interactive-edit/draft subscribers),
117
+ // not an HTTP caller — the id is set server-side, so it stays honest
118
+ // under the task #88 rule that event actors are never caller-supplied.
119
+ { project_id: projectId, member: { kind: 'crew.run', ref: runId }, actor: 'interactive' }, membershipAttachedKey(projectId, 'crew.run', runId, Date.now()));
120
+ };
121
+ // Arm the opt-in QE gate-event subscription (crew's bus seam). Failure to
122
+ // arm is LOUD but non-fatal: the acceptance route never depends on the bus.
123
+ if (options?.qeGateEvents?.enabled === true) {
124
+ const { dbPath, pollIntervalMs } = options.qeGateEvents;
125
+ const sub = await startQeGateSubscriber(qeGateCache, {
126
+ ...(dbPath !== undefined ? { dbPath } : {}),
127
+ ...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
128
+ log: (m) => app.log.warn(m),
129
+ });
130
+ if (sub !== null) {
131
+ app.log.info(`qe gate-event subscription armed (filter wicked.qe.**)`);
132
+ app.addHook('onClose', async () => {
133
+ await sub.stop();
134
+ });
135
+ }
136
+ }
137
+ // Arm the opt-in interactive-draft answering seam (task #86 spike). Same posture as the QE
138
+ // seam: failure to arm is LOUD but non-fatal — interactive's assist loop is the fallback
139
+ // answerer, and this daemon must boot on a machine whose bus is broken.
140
+ if (options?.interactiveDraftEvents?.enabled === true) {
141
+ const o = options.interactiveDraftEvents;
142
+ const sub = await startInteractiveDraftSubscriber(adapter, {
143
+ ...(o.dbPath !== undefined ? { dbPath: o.dbPath } : {}),
144
+ ...(o.pollIntervalMs !== undefined ? { pollIntervalMs: o.pollIntervalMs } : {}),
145
+ ...(o.heartbeatMs !== undefined ? { heartbeatMs: o.heartbeatMs } : {}),
146
+ ...(o.ledgerPath !== undefined ? { ledgerPath: o.ledgerPath } : {}),
147
+ ...(o.draftDir !== undefined ? { draftDir: o.draftDir } : {}),
148
+ ...(o.clisJson !== undefined ? { clisJson: o.clisJson } : {}),
149
+ onRunFiled: fileRun,
150
+ log: (m) => app.log.warn(m),
151
+ });
152
+ if (sub !== null) {
153
+ app.log.info('interactive-draft subscription armed (filter wicked.interactive.doc.created)');
154
+ app.addHook('onClose', async () => {
155
+ await sub.stop();
156
+ });
157
+ }
158
+ }
159
+ // Arm the opt-in interactive STRUCTURAL-edit answering seam (task #86 final leg). Same
160
+ // posture as the draft seam: failure to arm is LOUD but non-fatal — interactive's assist
161
+ // loop is the fallback answerer, and this daemon must boot on a machine whose bus is broken.
162
+ if (options?.interactiveEditEvents?.enabled === true) {
163
+ const o = options.interactiveEditEvents;
164
+ const sub = await startInteractiveEditSubscriber(adapter, {
165
+ ...(o.dbPath !== undefined ? { dbPath: o.dbPath } : {}),
166
+ ...(o.pollIntervalMs !== undefined ? { pollIntervalMs: o.pollIntervalMs } : {}),
167
+ ...(o.heartbeatMs !== undefined ? { heartbeatMs: o.heartbeatMs } : {}),
168
+ ...(o.ledgerPath !== undefined ? { ledgerPath: o.ledgerPath } : {}),
169
+ ...(o.editDir !== undefined ? { editDir: o.editDir } : {}),
170
+ ...(o.clisJson !== undefined ? { clisJson: o.clisJson } : {}),
171
+ onRunFiled: fileRun,
172
+ log: (m) => app.log.warn(m),
173
+ });
174
+ if (sub !== null) {
175
+ app.log.info('interactive-edit subscription armed (filter wicked.interactive.feedback.processed)');
176
+ app.addHook('onClose', async () => {
177
+ await sub.stop();
178
+ });
179
+ }
180
+ }
28
181
  // The daemon's single CoreEvent subscription fans out here: cache gate prompts
29
182
  // (§3.3), cache elicitation prompts (DES-002), route terminal frames to their owning
30
- // per-terminal socket (by id, DES-TERMINAL-001 §6), then forward every frame verbatim
31
- // to all `/ws` clients (§2.1).
32
- adapter.onEvent((event) => {
183
+ // per-terminal socket (by id, DES-TERMINAL-001 §6), then forward every frame — tagged
184
+ // with its run's `project_id` when the membership table files it (DES-PROJECT-001
185
+ // §5.2; no new socket, additive field) — to all `/ws` clients (§2.1).
186
+ //
187
+ // Unregistered on close: a process can build more than one server over the same
188
+ // adapter (tests do), and a closed server's caches must stop consuming events —
189
+ // otherwise every discarded server keeps folding state forever (listener leak).
190
+ const offEvent = adapter.onEvent((event) => {
33
191
  gateCache.ingest(event);
34
192
  elicitationCache.ingest(event);
193
+ seatHealth.ingest(event);
35
194
  terminals.route(event);
36
- broadcast(event);
195
+ const session = typeof event.session === 'string' ? event.session : undefined;
196
+ const projectId = session !== undefined ? membershipIndex.projectOf(session) : undefined;
197
+ broadcast(projectId !== undefined ? { ...event, project_id: projectId } : event);
198
+ // The foundation record's evidence pointer (§3.2 row 3): a project-bound run that completes
199
+ // gets its run-scope pointer written, best-effort, off the hot path.
200
+ if (event.type === 'sessionCompleted' && session !== undefined && projectId !== undefined) {
201
+ void (async () => {
202
+ try {
203
+ const views = await adapter.sessionsDetail();
204
+ const view = views.find((v) => v.session.id === session);
205
+ const repoRef = view?.session.repo_ref ?? null;
206
+ const repoRoot = repoRef !== null
207
+ ? ((await adapter.listRepos()).find((r) => r.id === repoRef)?.root_path ?? null)
208
+ : null;
209
+ // The STORED scope, not a derived spelling: `Project.scope` is the designed tenancy
210
+ // seam (ADR §3.1 — a future `org:<o>/project:<id>` prefix must not strand pointers).
211
+ const project = await adapter.projectGet(projectId);
212
+ await writeRunEvidencePointer(adapter, project?.scope ?? `project:${projectId}`, session, repoRoot, (m) => app.log.warn(m));
213
+ }
214
+ catch (err) {
215
+ app.log.warn(`[projects] evidence-pointer lookup for ${session} failed: ${err instanceof Error ? err.message : String(err)}`);
216
+ }
217
+ })();
218
+ }
219
+ });
220
+ app.addHook('onClose', async () => {
221
+ offEvent();
37
222
  });
223
+ // The seat-health recovery probe (crew#274 §3). A plain setInterval, unref'd, torn down on
224
+ // close. Default ON in the daemon, OFF under a test runner — a test suite building servers
225
+ // must never spawn `<cli> --version` children unless it opts in with `enabled: true`.
226
+ const probeCfg = options?.seatHealthProbe;
227
+ const underTestRunner = process.env['VITEST'] !== undefined || process.env['NODE_ENV'] === 'test';
228
+ if (probeCfg?.enabled ?? !underTestRunner) {
229
+ const probe = startSeatHealthProbe(seatHealth, () => CoreAdapter.roster(), {
230
+ ...(probeCfg?.intervalMs !== undefined ? { intervalMs: probeCfg.intervalMs } : {}),
231
+ ...(probeCfg?.timeoutMs !== undefined ? { timeoutMs: probeCfg.timeoutMs } : {}),
232
+ log: (m) => app.log.warn(m),
233
+ });
234
+ app.addHook('onClose', async () => {
235
+ probe.stop();
236
+ });
237
+ }
38
238
  app.addHook('onRequest', async (req, reply) => {
39
239
  const origin = req.headers.origin;
40
- if (origin && LOOPBACK_ORIGIN.test(origin)) {
240
+ // Loopback origins are always allowed (the studio on another localhost
241
+ // port). NON-loopback origins are allowed ONLY when auth is required — the
242
+ // R2 pairing: a hosted skin needs CORS, and CORS beyond the machine is
243
+ // safe exactly when every request must carry a bearer token (no ambient
244
+ // credential exists for a foreign page to ride). An explicit allowlist
245
+ // (`allowedOrigins` / WICKED_CREW_ALLOWED_ORIGINS) narrows it further.
246
+ const allowed = origin !== undefined &&
247
+ (LOOPBACK_ORIGIN.test(origin) ||
248
+ (auth.mode === 'required' &&
249
+ (auth.allowedOrigins === null || auth.allowedOrigins.includes(origin))));
250
+ if (origin && allowed) {
41
251
  reply.header('Access-Control-Allow-Origin', origin);
42
252
  reply.header('Vary', 'Origin');
43
- reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
44
- reply.header('Access-Control-Allow-Headers', 'Content-Type');
253
+ reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
254
+ reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
45
255
  }
46
256
  if (req.method === 'OPTIONS') {
47
257
  await reply.code(204).send();
@@ -60,13 +270,28 @@ export async function createServer(adapter, options) {
60
270
  }
61
271
  });
62
272
  await app.register(fastifyWebsocket);
273
+ // Identity (401) + trust (403) hooks. Ordering is load-bearing twice over:
274
+ // AFTER the CORS hook, so an OPTIONS preflight (which carries no
275
+ // Authorization by design) is answered 204 above and never reaches the token
276
+ // check — and AFTER the websocket plugin, whose own onRequest hook flags
277
+ // upgrade requests (`request.ws`); if auth 401s first, that flag is never
278
+ // set and the plugin's onResponse cleanup skips destroying the raw upgrade
279
+ // socket, which then holds `app.close()` open forever.
280
+ registerAuthHooks(app, auth);
63
281
  app.get('/ws', { websocket: true }, (socket) => {
64
282
  // Late-join gets no replay; the studio reconciles with a one-shot GET /runs.
65
283
  registerClient(socket);
66
284
  });
67
285
  // One dedicated WS channel per PTY: /ws/terminals/:id (DES-TERMINAL-001 §6).
68
286
  registerTerminalWs(app, adapter, terminals);
69
- registerRoutes(app, adapter, gateCache, elicitationCache);
287
+ registerRoutes(app, adapter, gateCache, elicitationCache, qeGateCache, {
288
+ bus: projectBus,
289
+ index: membershipIndex,
290
+ log: (m) => app.log.warn(m),
291
+ }, { audit, authMode: auth.mode }, { seatHealth });
292
+ // The UI-emittable direction of the interactive seam. Registered unconditionally (a null relay
293
+ // answers 503, not 404) and BEFORE the static/SPA fallback below, like every other API route.
294
+ registerInteractiveEventRoutes(app, interactiveRelay);
70
295
  // Serve the bundled studio SPA same-origin (DES-STUDIO-SERVING-001 §3). The
71
296
  // API routes, `/ws`, and terminal WS are registered ABOVE and keep winning:
72
297
  // static uses `wildcard: false` (only serves files that physically exist),
@@ -121,8 +346,8 @@ export async function createServer(adapter, options) {
121
346
  }
122
347
  return app;
123
348
  }
124
- export async function startServer(adapter, port = 7701, host = '127.0.0.1') {
125
- const app = await createServer(adapter);
349
+ export async function startServer(adapter, port = 7701, host = '127.0.0.1', options) {
350
+ const app = await createServer(adapter, options);
126
351
  await app.listen({ port, host });
127
352
  const addr = app.server.address();
128
353
  const boundPort = typeof addr === 'object' && addr ? addr.port : port;