agent-relay 1.3.1 → 1.3.3

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 (202) hide show
  1. package/.trajectories/active/traj_3yx9dy148mge.json +42 -0
  2. package/.trajectories/completed/2026-01/traj_1g7yx6qtg4ai.json +49 -0
  3. package/.trajectories/completed/2026-01/traj_1g7yx6qtg4ai.md +31 -0
  4. package/.trajectories/completed/2026-01/traj_4qwd4zmhfwp4.json +49 -0
  5. package/.trajectories/completed/2026-01/traj_4qwd4zmhfwp4.md +31 -0
  6. package/.trajectories/completed/2026-01/traj_6unwwmgyj5sq.json +109 -0
  7. package/.trajectories/completed/2026-01/traj_a0tqx8biw9c4.json +49 -0
  8. package/.trajectories/completed/2026-01/traj_a0tqx8biw9c4.md +31 -0
  9. package/.trajectories/completed/2026-01/traj_ax8uungxz2qh.json +66 -0
  10. package/.trajectories/completed/2026-01/traj_ax8uungxz2qh.md +36 -0
  11. package/.trajectories/completed/2026-01/traj_c9izbh2snpzf.json +49 -0
  12. package/.trajectories/completed/2026-01/traj_c9izbh2snpzf.md +31 -0
  13. package/.trajectories/completed/2026-01/traj_cpn70dw066nt.json +65 -0
  14. package/.trajectories/completed/2026-01/traj_cpn70dw066nt.md +37 -0
  15. package/.trajectories/completed/2026-01/traj_erglv2f8t9eh.json +36 -0
  16. package/.trajectories/completed/2026-01/traj_erglv2f8t9eh.md +21 -0
  17. package/.trajectories/completed/2026-01/traj_he75f24d1xfm.json +101 -0
  18. package/.trajectories/completed/2026-01/traj_he75f24d1xfm.md +52 -0
  19. package/.trajectories/completed/2026-01/traj_lgtodco7dp1n.json +61 -0
  20. package/.trajectories/completed/2026-01/traj_lgtodco7dp1n.md +36 -0
  21. package/.trajectories/completed/2026-01/traj_oszg9flv74pk.json +73 -0
  22. package/.trajectories/completed/2026-01/traj_oszg9flv74pk.md +41 -0
  23. package/.trajectories/completed/2026-01/traj_pulomd3y8cvj.json +77 -0
  24. package/.trajectories/completed/2026-01/traj_pulomd3y8cvj.md +42 -0
  25. package/.trajectories/completed/2026-01/traj_rsavt0jipi3c.json +109 -0
  26. package/.trajectories/completed/2026-01/traj_rsavt0jipi3c.md +56 -0
  27. package/.trajectories/completed/2026-01/traj_x721m1j9rzup.json +113 -0
  28. package/.trajectories/completed/2026-01/traj_x721m1j9rzup.md +57 -0
  29. package/.trajectories/completed/2026-01/traj_xjqvmep5ed3h.json +61 -0
  30. package/.trajectories/completed/2026-01/traj_xjqvmep5ed3h.md +36 -0
  31. package/.trajectories/completed/2026-01/traj_y7n6hfbf7dmg.json +49 -0
  32. package/.trajectories/completed/2026-01/traj_y7n6hfbf7dmg.md +31 -0
  33. package/.trajectories/completed/2026-01/traj_yvfkwnkdiso2.json +49 -0
  34. package/.trajectories/completed/2026-01/traj_yvfkwnkdiso2.md +31 -0
  35. package/.trajectories/index.json +140 -1
  36. package/README.md +23 -9
  37. package/TRAIL_GIT_AUTH_FIX.md +113 -0
  38. package/deploy/workspace/codex.config.toml +1 -1
  39. package/deploy/workspace/entrypoint.sh +20 -79
  40. package/deploy/workspace/gh-relay +156 -0
  41. package/deploy/workspace/git-credential-relay +5 -1
  42. package/dist/bridge/multi-project-client.js +13 -10
  43. package/dist/bridge/spawner.d.ts +2 -0
  44. package/dist/bridge/spawner.js +58 -76
  45. package/dist/bridge/types.d.ts +2 -0
  46. package/dist/cli/index.d.ts +8 -6
  47. package/dist/cli/index.js +297 -30
  48. package/dist/cloud/api/admin.js +16 -3
  49. package/dist/cloud/api/codex-auth-helper.js +28 -8
  50. package/dist/cloud/api/consensus.d.ts +13 -0
  51. package/dist/cloud/api/consensus.js +259 -0
  52. package/dist/cloud/api/daemons.js +205 -1
  53. package/dist/cloud/api/git.js +37 -7
  54. package/dist/cloud/api/onboarding.js +4 -1
  55. package/dist/cloud/api/provider-env.d.ts +5 -0
  56. package/dist/cloud/api/provider-env.js +27 -0
  57. package/dist/cloud/api/providers.js +2 -0
  58. package/dist/cloud/api/test-helpers.js +130 -0
  59. package/dist/cloud/api/workspaces.js +38 -3
  60. package/dist/cloud/db/bulk-ingest.d.ts +88 -0
  61. package/dist/cloud/db/bulk-ingest.js +268 -0
  62. package/dist/cloud/db/drizzle.d.ts +33 -0
  63. package/dist/cloud/db/drizzle.js +174 -2
  64. package/dist/cloud/db/index.d.ts +24 -5
  65. package/dist/cloud/db/index.js +19 -4
  66. package/dist/cloud/db/schema.d.ts +397 -3
  67. package/dist/cloud/db/schema.js +75 -1
  68. package/dist/cloud/provisioner/index.d.ts +8 -0
  69. package/dist/cloud/provisioner/index.js +256 -50
  70. package/dist/cloud/server.js +47 -3
  71. package/dist/cloud/services/index.d.ts +1 -0
  72. package/dist/cloud/services/index.js +2 -0
  73. package/dist/cloud/services/nango.d.ts +3 -4
  74. package/dist/cloud/services/nango.js +11 -33
  75. package/dist/cloud/services/workspace-keepalive.d.ts +76 -0
  76. package/dist/cloud/services/workspace-keepalive.js +234 -0
  77. package/dist/config/relay-config.d.ts +23 -0
  78. package/dist/config/relay-config.js +23 -0
  79. package/dist/daemon/agent-manager.d.ts +20 -1
  80. package/dist/daemon/agent-manager.js +51 -0
  81. package/dist/daemon/agent-registry.js +4 -4
  82. package/dist/daemon/agent-signing.d.ts +158 -0
  83. package/dist/daemon/agent-signing.js +523 -0
  84. package/dist/daemon/api.js +18 -1
  85. package/dist/daemon/cli-auth.d.ts +4 -1
  86. package/dist/daemon/cli-auth.js +55 -11
  87. package/dist/daemon/cloud-sync.d.ts +47 -1
  88. package/dist/daemon/cloud-sync.js +152 -3
  89. package/dist/daemon/connection.d.ts +28 -0
  90. package/dist/daemon/connection.js +113 -22
  91. package/dist/daemon/consensus-integration.d.ts +167 -0
  92. package/dist/daemon/consensus-integration.js +371 -0
  93. package/dist/daemon/consensus.d.ts +271 -0
  94. package/dist/daemon/consensus.js +632 -0
  95. package/dist/daemon/delivery-tracker.d.ts +34 -0
  96. package/dist/daemon/delivery-tracker.js +104 -0
  97. package/dist/daemon/enhanced-features.d.ts +118 -0
  98. package/dist/daemon/enhanced-features.js +178 -0
  99. package/dist/daemon/index.d.ts +4 -0
  100. package/dist/daemon/index.js +5 -0
  101. package/dist/daemon/rate-limiter.d.ts +68 -0
  102. package/dist/daemon/rate-limiter.js +130 -0
  103. package/dist/daemon/router.d.ts +18 -11
  104. package/dist/daemon/router.js +57 -113
  105. package/dist/daemon/server.d.ts +13 -1
  106. package/dist/daemon/server.js +71 -9
  107. package/dist/daemon/sync-queue.d.ts +116 -0
  108. package/dist/daemon/sync-queue.js +361 -0
  109. package/dist/dashboard/out/404.html +1 -1
  110. package/dist/dashboard/out/_next/static/chunks/116-de2a4ac06e5000dc.js +1 -0
  111. package/dist/dashboard/out/_next/static/chunks/847-f1f467060f32afff.js +1 -0
  112. package/dist/dashboard/out/_next/static/chunks/919-87d604a5d76c1fbd.js +1 -0
  113. package/dist/dashboard/out/_next/static/chunks/app/app/{page-c617745b81344f4f.js → page-7f64824ae7d06707.js} +1 -1
  114. package/dist/dashboard/out/_next/static/chunks/app/cloud/link/page-3f559d393902aad2.js +1 -0
  115. package/dist/dashboard/out/_next/static/chunks/app/login/page-16d1715ddaa874ee.js +1 -0
  116. package/dist/dashboard/out/_next/static/chunks/app/{page-dc786c183425c2ac.js → page-814efc4d77b4191d.js} +1 -1
  117. package/dist/dashboard/out/_next/static/chunks/{main-2ee6beb2ae96d210.js → main-5a40a5ae29646e1b.js} +1 -1
  118. package/dist/dashboard/out/_next/static/css/44d2b52637b511bc.css +1 -0
  119. package/dist/dashboard/out/app/onboarding.html +1 -1
  120. package/dist/dashboard/out/app/onboarding.txt +1 -1
  121. package/dist/dashboard/out/app.html +1 -1
  122. package/dist/dashboard/out/app.txt +2 -2
  123. package/dist/dashboard/out/cloud/link.html +1 -0
  124. package/dist/dashboard/out/cloud/link.txt +7 -0
  125. package/dist/dashboard/out/connect-repos.html +1 -1
  126. package/dist/dashboard/out/connect-repos.txt +1 -1
  127. package/dist/dashboard/out/history.html +1 -1
  128. package/dist/dashboard/out/history.txt +2 -2
  129. package/dist/dashboard/out/index.html +1 -1
  130. package/dist/dashboard/out/index.txt +2 -2
  131. package/dist/dashboard/out/login.html +2 -3
  132. package/dist/dashboard/out/login.txt +2 -2
  133. package/dist/dashboard/out/metrics.html +1 -1
  134. package/dist/dashboard/out/metrics.txt +2 -2
  135. package/dist/dashboard/out/pricing.html +2 -2
  136. package/dist/dashboard/out/pricing.txt +1 -1
  137. package/dist/dashboard/out/providers/setup/claude.html +1 -1
  138. package/dist/dashboard/out/providers/setup/claude.txt +1 -1
  139. package/dist/dashboard/out/providers/setup/codex.html +1 -1
  140. package/dist/dashboard/out/providers/setup/codex.txt +1 -1
  141. package/dist/dashboard/out/providers.html +1 -1
  142. package/dist/dashboard/out/providers.txt +1 -1
  143. package/dist/dashboard/out/signup.html +2 -2
  144. package/dist/dashboard/out/signup.txt +1 -1
  145. package/dist/dashboard-server/server.js +244 -28
  146. package/dist/health-worker-manager.d.ts +62 -0
  147. package/dist/health-worker-manager.js +144 -0
  148. package/dist/health-worker.d.ts +9 -0
  149. package/dist/health-worker.js +79 -0
  150. package/dist/index.d.ts +2 -1
  151. package/dist/index.js +5 -1
  152. package/dist/memory/context-compaction.d.ts +156 -0
  153. package/dist/memory/context-compaction.js +453 -0
  154. package/dist/memory/index.d.ts +1 -0
  155. package/dist/memory/index.js +1 -0
  156. package/dist/protocol/channels.js +4 -4
  157. package/dist/protocol/framing.d.ts +72 -10
  158. package/dist/protocol/framing.js +194 -25
  159. package/dist/storage/adapter.d.ts +8 -1
  160. package/dist/storage/adapter.js +11 -0
  161. package/dist/storage/batched-sqlite-adapter.d.ts +71 -0
  162. package/dist/storage/batched-sqlite-adapter.js +183 -0
  163. package/dist/storage/dead-letter-queue.d.ts +196 -0
  164. package/dist/storage/dead-letter-queue.js +427 -0
  165. package/dist/storage/dlq-adapter.d.ts +195 -0
  166. package/dist/storage/dlq-adapter.js +664 -0
  167. package/dist/trajectory/config.d.ts +32 -14
  168. package/dist/trajectory/config.js +38 -16
  169. package/dist/trajectory/integration.js +217 -64
  170. package/dist/utils/git-remote.d.ts +47 -0
  171. package/dist/utils/git-remote.js +125 -0
  172. package/dist/utils/id-generator.d.ts +35 -0
  173. package/dist/utils/id-generator.js +60 -0
  174. package/dist/utils/index.d.ts +1 -0
  175. package/dist/utils/index.js +1 -0
  176. package/dist/utils/precompiled-patterns.d.ts +110 -0
  177. package/dist/utils/precompiled-patterns.js +322 -0
  178. package/dist/wrapper/auth-detection.js +1 -1
  179. package/dist/wrapper/base-wrapper.d.ts +40 -0
  180. package/dist/wrapper/base-wrapper.js +60 -6
  181. package/dist/wrapper/client.d.ts +14 -4
  182. package/dist/wrapper/client.js +89 -31
  183. package/dist/wrapper/idle-detector.d.ts +102 -0
  184. package/dist/wrapper/idle-detector.js +279 -0
  185. package/dist/wrapper/parser.d.ts +4 -0
  186. package/dist/wrapper/parser.js +19 -1
  187. package/dist/wrapper/pty-wrapper.d.ts +14 -2
  188. package/dist/wrapper/pty-wrapper.js +132 -32
  189. package/dist/wrapper/shared.d.ts +1 -1
  190. package/dist/wrapper/shared.js +1 -1
  191. package/dist/wrapper/tmux-wrapper.d.ts +20 -2
  192. package/dist/wrapper/tmux-wrapper.js +163 -40
  193. package/package.json +3 -1
  194. package/scripts/run-migrations.js +43 -0
  195. package/scripts/verify-schema.js +134 -0
  196. package/tests/benchmarks/protocol.bench.ts +310 -0
  197. package/dist/dashboard/out/_next/static/chunks/116-2502180def231162.js +0 -1
  198. package/dist/dashboard/out/_next/static/chunks/899-fc02ed79e3de4302.js +0 -1
  199. package/dist/dashboard/out/_next/static/chunks/app/login/page-c22d080201cbd9fb.js +0 -1
  200. package/dist/dashboard/out/_next/static/css/48a8fbe3e659080e.css +0 -1
  201. /package/dist/dashboard/out/_next/static/{sDcbGRTYLcpPvyTs_rsNb → R-uQOUcOLINtsp6ACeZa9}/_buildManifest.js +0 -0
  202. /package/dist/dashboard/out/_next/static/{sDcbGRTYLcpPvyTs_rsNb → R-uQOUcOLINtsp6ACeZa9}/_ssgManifest.js +0 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Consensus API Routes (Read-Only)
3
+ *
4
+ * Provides API endpoints for observing multi-agent consensus decisions.
5
+ * The dashboard is read-only - agents handle all consensus activity via relay messages.
6
+ *
7
+ * Architecture:
8
+ * - Agents create proposals and vote via ->relay:_consensus messages
9
+ * - The daemon processes these and syncs state to cloud via /sync endpoint
10
+ * - Dashboard reads consensus state for display only
11
+ */
12
+ import { Router } from 'express';
13
+ import { createHash } from 'crypto';
14
+ import { requireAuth } from './auth.js';
15
+ /**
16
+ * Hash an API key for lookup
17
+ */
18
+ function hashApiKey(apiKey) {
19
+ return createHash('sha256').update(apiKey).digest('hex');
20
+ }
21
+ export const consensusRouter = Router();
22
+ // ============================================================================
23
+ // In-Memory Consensus State (synced from daemon)
24
+ // ============================================================================
25
+ // Stores proposals synced from the daemon
26
+ // In production, this would be backed by a database
27
+ const workspaceProposals = new Map();
28
+ function getProposalsForWorkspace(workspaceId) {
29
+ let proposals = workspaceProposals.get(workspaceId);
30
+ if (!proposals) {
31
+ proposals = new Map();
32
+ workspaceProposals.set(workspaceId, proposals);
33
+ }
34
+ return proposals;
35
+ }
36
+ function computeStats(proposals) {
37
+ let pending = 0;
38
+ let approved = 0;
39
+ let rejected = 0;
40
+ let expired = 0;
41
+ let cancelled = 0;
42
+ for (const proposal of proposals.values()) {
43
+ switch (proposal.status) {
44
+ case 'pending':
45
+ pending++;
46
+ break;
47
+ case 'approved':
48
+ approved++;
49
+ break;
50
+ case 'rejected':
51
+ rejected++;
52
+ break;
53
+ case 'expired':
54
+ expired++;
55
+ break;
56
+ case 'cancelled':
57
+ cancelled++;
58
+ break;
59
+ }
60
+ }
61
+ return {
62
+ total: proposals.size,
63
+ pending,
64
+ approved,
65
+ rejected,
66
+ expired,
67
+ cancelled,
68
+ };
69
+ }
70
+ // ============================================================================
71
+ // Read-Only Routes (require user authentication)
72
+ // ============================================================================
73
+ /**
74
+ * GET /api/workspaces/:workspaceId/consensus/proposals
75
+ * List all proposals for a workspace (read-only)
76
+ */
77
+ consensusRouter.get('/workspaces/:workspaceId/consensus/proposals', requireAuth, async (req, res) => {
78
+ try {
79
+ const { workspaceId } = req.params;
80
+ const { status, agent } = req.query;
81
+ const proposalsMap = getProposalsForWorkspace(workspaceId);
82
+ let proposals = Array.from(proposalsMap.values());
83
+ // Filter by agent if provided
84
+ if (agent && typeof agent === 'string') {
85
+ proposals = proposals.filter(p => p.proposer === agent || p.participants.includes(agent));
86
+ }
87
+ // Filter by status if provided
88
+ if (status && typeof status === 'string') {
89
+ proposals = proposals.filter(p => p.status === status);
90
+ }
91
+ // Sort by creation time (most recent first)
92
+ proposals.sort((a, b) => b.createdAt - a.createdAt);
93
+ const stats = computeStats(proposalsMap);
94
+ res.json({
95
+ proposals,
96
+ stats,
97
+ });
98
+ }
99
+ catch (error) {
100
+ console.error('Error listing proposals:', error);
101
+ res.status(500).json({ error: 'Failed to list proposals' });
102
+ }
103
+ });
104
+ /**
105
+ * GET /api/workspaces/:workspaceId/consensus/proposals/:proposalId
106
+ * Get a specific proposal (read-only)
107
+ */
108
+ consensusRouter.get('/workspaces/:workspaceId/consensus/proposals/:proposalId', requireAuth, async (req, res) => {
109
+ try {
110
+ const { workspaceId, proposalId } = req.params;
111
+ const proposalsMap = getProposalsForWorkspace(workspaceId);
112
+ const proposal = proposalsMap.get(proposalId);
113
+ if (!proposal) {
114
+ return res.status(404).json({ error: 'Proposal not found' });
115
+ }
116
+ res.json({ proposal });
117
+ }
118
+ catch (error) {
119
+ console.error('Error getting proposal:', error);
120
+ res.status(500).json({ error: 'Failed to get proposal' });
121
+ }
122
+ });
123
+ /**
124
+ * GET /api/workspaces/:workspaceId/consensus/agents/:agentName/pending
125
+ * Get pending votes for an agent (read-only)
126
+ */
127
+ consensusRouter.get('/workspaces/:workspaceId/consensus/agents/:agentName/pending', requireAuth, async (req, res) => {
128
+ try {
129
+ const { workspaceId, agentName } = req.params;
130
+ const proposalsMap = getProposalsForWorkspace(workspaceId);
131
+ const proposals = Array.from(proposalsMap.values()).filter(p => {
132
+ if (p.status !== 'pending')
133
+ return false;
134
+ if (!p.participants.includes(agentName))
135
+ return false;
136
+ // Check if agent hasn't voted yet
137
+ return !p.votes.some(v => v.agent === agentName);
138
+ });
139
+ res.json({ proposals });
140
+ }
141
+ catch (error) {
142
+ console.error('Error getting pending votes:', error);
143
+ res.status(500).json({ error: 'Failed to get pending votes' });
144
+ }
145
+ });
146
+ /**
147
+ * GET /api/workspaces/:workspaceId/consensus/stats
148
+ * Get consensus statistics for a workspace (read-only)
149
+ */
150
+ consensusRouter.get('/workspaces/:workspaceId/consensus/stats', requireAuth, async (req, res) => {
151
+ try {
152
+ const { workspaceId } = req.params;
153
+ const proposalsMap = getProposalsForWorkspace(workspaceId);
154
+ const stats = computeStats(proposalsMap);
155
+ res.json({ stats });
156
+ }
157
+ catch (error) {
158
+ console.error('Error getting consensus stats:', error);
159
+ res.status(500).json({ error: 'Failed to get consensus stats' });
160
+ }
161
+ });
162
+ // ============================================================================
163
+ // Sync Endpoint (daemon -> cloud)
164
+ // ============================================================================
165
+ /**
166
+ * POST /api/daemons/consensus/sync
167
+ * Sync consensus state from daemon (called by daemon on proposal events)
168
+ *
169
+ * This endpoint receives state updates from the daemon and stores them
170
+ * so the dashboard can display agent consensus activity.
171
+ *
172
+ * Authentication options (in order of precedence):
173
+ * 1. Daemon API key (Authorization: Bearer ar_live_xxx) - workspace from daemon record
174
+ * 2. Workspace ID in request body - for self-hosted setups
175
+ * 3. Default workspace "local" - for simple local development
176
+ */
177
+ consensusRouter.post('/daemons/consensus/sync', async (req, res) => {
178
+ try {
179
+ const { proposal, event, workspaceId: bodyWorkspaceId } = req.body;
180
+ if (!proposal || !event) {
181
+ return res.status(400).json({ error: 'Missing proposal or event' });
182
+ }
183
+ let workspaceId;
184
+ // Try to authenticate via API key first
185
+ const authHeader = req.headers.authorization;
186
+ if (authHeader?.startsWith('Bearer ar_live_')) {
187
+ const apiKey = authHeader.replace('Bearer ', '');
188
+ const apiKeyHash = hashApiKey(apiKey);
189
+ const { db } = await import('../db/index.js');
190
+ const daemon = await db.linkedDaemons.findByApiKeyHash(apiKeyHash);
191
+ if (daemon?.workspaceId) {
192
+ workspaceId = daemon.workspaceId;
193
+ }
194
+ else if (bodyWorkspaceId) {
195
+ workspaceId = bodyWorkspaceId;
196
+ }
197
+ else {
198
+ return res.status(400).json({ error: 'Daemon not associated with a workspace' });
199
+ }
200
+ }
201
+ else if (bodyWorkspaceId) {
202
+ // Self-hosted: workspace specified in body
203
+ workspaceId = bodyWorkspaceId;
204
+ }
205
+ else {
206
+ // Default for simple local setups
207
+ workspaceId = 'local';
208
+ }
209
+ // Store/update the proposal
210
+ const proposalsMap = getProposalsForWorkspace(workspaceId);
211
+ proposalsMap.set(proposal.id, proposal);
212
+ console.log(`[consensus] Synced ${event} for proposal "${proposal.title}" (${proposal.id}) in workspace ${workspaceId}`);
213
+ res.json({ success: true, workspaceId });
214
+ }
215
+ catch (error) {
216
+ console.error('Error syncing consensus:', error);
217
+ res.status(500).json({ error: 'Failed to sync consensus' });
218
+ }
219
+ });
220
+ /**
221
+ * DELETE /api/daemons/consensus/proposals/:proposalId
222
+ * Remove a proposal from the sync cache (daemon cleanup)
223
+ *
224
+ * Authentication: Uses daemon API key (Authorization: Bearer ar_live_xxx)
225
+ * Workspace is derived from the linked daemon's record.
226
+ */
227
+ consensusRouter.delete('/daemons/consensus/proposals/:proposalId', async (req, res) => {
228
+ try {
229
+ // Check for daemon API key (Bearer token)
230
+ const authHeader = req.headers.authorization;
231
+ if (!authHeader?.startsWith('Bearer ar_live_')) {
232
+ return res.status(401).json({ error: 'Unauthorized - daemon API key required' });
233
+ }
234
+ // Validate the API key
235
+ const apiKey = authHeader.replace('Bearer ', '');
236
+ const apiKeyHash = hashApiKey(apiKey);
237
+ const { db } = await import('../db/index.js');
238
+ const daemon = await db.linkedDaemons.findByApiKeyHash(apiKeyHash);
239
+ if (!daemon) {
240
+ return res.status(401).json({ error: 'Invalid API key' });
241
+ }
242
+ if (!daemon.workspaceId) {
243
+ return res.status(400).json({ error: 'Daemon not associated with a workspace' });
244
+ }
245
+ const { proposalId } = req.params;
246
+ const proposalsMap = getProposalsForWorkspace(daemon.workspaceId);
247
+ const deleted = proposalsMap.delete(proposalId);
248
+ if (!deleted) {
249
+ return res.status(404).json({ error: 'Proposal not found' });
250
+ }
251
+ console.log(`[consensus] Removed proposal ${proposalId} from workspace ${daemon.workspaceId}`);
252
+ res.json({ success: true });
253
+ }
254
+ catch (error) {
255
+ console.error('Error removing proposal:', error);
256
+ res.status(500).json({ error: 'Failed to remove proposal' });
257
+ }
258
+ });
259
+ //# sourceMappingURL=consensus.js.map
@@ -40,11 +40,25 @@ function hashApiKey(apiKey) {
40
40
  */
41
41
  daemonsRouter.post('/link', requireAuth, async (req, res) => {
42
42
  const userId = req.session.userId;
43
- const { name, machineId, metadata } = req.body;
43
+ const { name, machineId, metadata, workspaceId } = req.body;
44
44
  if (!machineId || typeof machineId !== 'string') {
45
45
  return res.status(400).json({ error: 'machineId is required' });
46
46
  }
47
47
  try {
48
+ // Validate workspace ownership if provided
49
+ if (workspaceId) {
50
+ const workspace = await db.workspaces.findById(workspaceId);
51
+ if (!workspace) {
52
+ return res.status(404).json({ error: 'Workspace not found' });
53
+ }
54
+ if (workspace.userId !== userId) {
55
+ // Check if user is a member of the workspace
56
+ const member = await db.workspaceMembers.findMembership(workspaceId, userId);
57
+ if (!member) {
58
+ return res.status(403).json({ error: 'Not authorized to link to this workspace' });
59
+ }
60
+ }
61
+ }
48
62
  // Check if this machine is already linked
49
63
  const existing = await db.linkedDaemons.findByMachineId(userId, machineId);
50
64
  if (existing) {
@@ -54,6 +68,7 @@ daemonsRouter.post('/link', requireAuth, async (req, res) => {
54
68
  await db.linkedDaemons.update(existing.id, {
55
69
  name: name || existing.name,
56
70
  apiKeyHash,
71
+ workspaceId: workspaceId || existing.workspaceId,
57
72
  metadata: metadata || existing.metadata,
58
73
  status: 'online',
59
74
  lastSeenAt: new Date(),
@@ -61,6 +76,7 @@ daemonsRouter.post('/link', requireAuth, async (req, res) => {
61
76
  return res.json({
62
77
  success: true,
63
78
  daemonId: existing.id,
79
+ workspaceId: workspaceId || existing.workspaceId,
64
80
  apiKey, // Only returned once!
65
81
  message: 'Daemon re-linked with new API key',
66
82
  });
@@ -70,6 +86,7 @@ daemonsRouter.post('/link', requireAuth, async (req, res) => {
70
86
  const apiKeyHash = hashApiKey(apiKey);
71
87
  const daemon = await db.linkedDaemons.create({
72
88
  userId,
89
+ workspaceId: workspaceId || null,
73
90
  name: name || `Daemon on ${machineId.substring(0, 8)}`,
74
91
  machineId,
75
92
  apiKeyHash,
@@ -79,6 +96,7 @@ daemonsRouter.post('/link', requireAuth, async (req, res) => {
79
96
  res.status(201).json({
80
97
  success: true,
81
98
  daemonId: daemon.id,
99
+ workspaceId: workspaceId || null,
82
100
  apiKey, // Only returned once - user must save this!
83
101
  message: 'Daemon linked successfully. Save your API key - it cannot be retrieved later.',
84
102
  });
@@ -113,6 +131,59 @@ daemonsRouter.get('/', requireAuth, async (req, res) => {
113
131
  res.status(500).json({ error: 'Failed to list daemons' });
114
132
  }
115
133
  });
134
+ /**
135
+ * GET /api/daemons/workspace/:workspaceId/agents
136
+ * Get local agents for a specific workspace
137
+ */
138
+ daemonsRouter.get('/workspace/:workspaceId/agents', requireAuth, async (req, res) => {
139
+ const userId = req.session.userId;
140
+ const { workspaceId } = req.params;
141
+ try {
142
+ // Verify user has access to this workspace
143
+ const workspace = await db.workspaces.findById(workspaceId);
144
+ if (!workspace) {
145
+ return res.status(404).json({ error: 'Workspace not found' });
146
+ }
147
+ // Check if user owns the workspace or is a member
148
+ if (workspace.userId !== userId) {
149
+ const member = await db.workspaceMembers.findMembership(workspaceId, userId);
150
+ if (!member) {
151
+ return res.status(403).json({ error: 'Not authorized to access this workspace' });
152
+ }
153
+ }
154
+ // Get all linked daemons for this workspace
155
+ const daemons = await db.linkedDaemons.findByWorkspaceId(workspaceId);
156
+ // Extract agents from each daemon's metadata
157
+ const localAgents = daemons.flatMap((daemon) => {
158
+ const metadata = daemon.metadata;
159
+ const agents = metadata?.agents || [];
160
+ return agents.map((agent) => ({
161
+ name: agent.name,
162
+ status: agent.status,
163
+ isLocal: true,
164
+ daemonId: daemon.id,
165
+ daemonName: daemon.name,
166
+ daemonStatus: daemon.status,
167
+ machineId: daemon.machineId,
168
+ lastSeenAt: daemon.lastSeenAt,
169
+ }));
170
+ });
171
+ res.json({
172
+ agents: localAgents,
173
+ daemons: daemons.map((d) => ({
174
+ id: d.id,
175
+ name: d.name,
176
+ machineId: d.machineId,
177
+ status: d.status,
178
+ lastSeenAt: d.lastSeenAt,
179
+ })),
180
+ });
181
+ }
182
+ catch (error) {
183
+ console.error('Error fetching local agents:', error);
184
+ res.status(500).json({ error: 'Failed to fetch local agents' });
185
+ }
186
+ });
116
187
  /**
117
188
  * DELETE /api/daemons/:id
118
189
  * Unlink a daemon
@@ -321,4 +392,137 @@ daemonsRouter.get('/messages', requireDaemonAuth, async (req, res) => {
321
392
  res.status(500).json({ error: 'Failed to fetch messages' });
322
393
  }
323
394
  });
395
+ /**
396
+ * POST /api/daemons/messages/sync
397
+ * Sync messages from daemon to cloud storage
398
+ *
399
+ * Accepts batches of messages and stores them in agent_messages table.
400
+ * Uses upsert logic to handle duplicates (based on workspace_id + original_id).
401
+ *
402
+ * Request body:
403
+ * {
404
+ * messages: SyncMessageInput[]
405
+ * }
406
+ *
407
+ * Response:
408
+ * {
409
+ * success: true,
410
+ * synced: number, // Count of messages synced
411
+ * duplicates: number // Count of messages skipped (already existed)
412
+ * }
413
+ */
414
+ daemonsRouter.post('/messages/sync', requireDaemonAuth, async (req, res) => {
415
+ const daemon = req.daemon;
416
+ const { messages, repoFullName } = req.body;
417
+ if (!messages || !Array.isArray(messages)) {
418
+ return res.status(400).json({ error: 'messages array is required' });
419
+ }
420
+ if (messages.length === 0) {
421
+ return res.json({ success: true, synced: 0, duplicates: 0 });
422
+ }
423
+ // Limit batch size to prevent abuse
424
+ if (messages.length > 500) {
425
+ return res.status(400).json({ error: 'Maximum batch size is 500 messages' });
426
+ }
427
+ // Resolve workspace from git remote if not already linked
428
+ let workspaceId = daemon.workspaceId;
429
+ if (!workspaceId && repoFullName) {
430
+ // Try to find workspace by repository
431
+ const workspace = await db.workspaces.findByRepoFullName(repoFullName);
432
+ if (workspace) {
433
+ // Auto-link daemon to workspace
434
+ await db.linkedDaemons.update(daemon.id, { workspaceId: workspace.id });
435
+ workspaceId = workspace.id;
436
+ console.log(`[message-sync] Auto-linked daemon ${daemon.id} to workspace ${workspace.id} via repo ${repoFullName}`);
437
+ }
438
+ }
439
+ // Require workspace to be linked
440
+ if (!workspaceId) {
441
+ const hint = repoFullName
442
+ ? `Repository '${repoFullName}' not found in any workspace. Link the repo in the dashboard first.`
443
+ : 'Daemon must be linked to a workspace to sync messages. Re-link with a workspace ID.';
444
+ return res.status(400).json({ error: hint });
445
+ }
446
+ try {
447
+ // Get user plan to determine retention policy
448
+ const user = await db.users.findById(daemon.userId);
449
+ const plan = user?.plan || 'free';
450
+ // Calculate expires_at based on plan
451
+ let expiresAt = null;
452
+ if (plan === 'free') {
453
+ expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
454
+ }
455
+ else if (plan === 'pro') {
456
+ expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days
457
+ }
458
+ // Enterprise: null (never expires)
459
+ // Transform to NewAgentMessage format
460
+ const dbMessages = messages.map((msg) => ({
461
+ workspaceId,
462
+ daemonId: daemon.id,
463
+ originalId: msg.id,
464
+ fromAgent: msg.from,
465
+ toAgent: msg.to,
466
+ body: msg.body,
467
+ kind: msg.kind || 'message',
468
+ topic: msg.topic || null,
469
+ thread: msg.thread || null,
470
+ channel: msg.channel || null,
471
+ isBroadcast: msg.is_broadcast || msg.to === '*',
472
+ isUrgent: msg.is_urgent || false,
473
+ data: msg.data || null,
474
+ payloadMeta: msg.payload_meta || null,
475
+ messageTs: new Date(msg.ts),
476
+ expiresAt,
477
+ }));
478
+ // Use optimized bulk insert for high-volume message sync
479
+ // - Batches < 100: multi-row INSERT
480
+ // - Batches 100-1000: chunked multi-row INSERT
481
+ // - Batches > 1000: streaming COPY with staging table
482
+ const result = await db.bulk.optimizedInsert(db.getRawPool(), dbMessages);
483
+ const synced = result.inserted;
484
+ const duplicates = result.duplicates;
485
+ console.log(`[message-sync] Synced ${synced} messages for daemon ${daemon.id}, ${duplicates} duplicates skipped (${result.durationMs}ms)`);
486
+ res.json({
487
+ success: true,
488
+ synced,
489
+ duplicates,
490
+ });
491
+ }
492
+ catch (error) {
493
+ console.error('Error syncing messages:', error);
494
+ res.status(500).json({ error: 'Failed to sync messages' });
495
+ }
496
+ });
497
+ /**
498
+ * GET /api/daemons/messages/stats
499
+ * Get message sync statistics for this daemon's workspace
500
+ */
501
+ daemonsRouter.get('/messages/stats', requireDaemonAuth, async (req, res) => {
502
+ const daemon = req.daemon;
503
+ if (!daemon.workspaceId) {
504
+ return res.status(400).json({ error: 'Daemon must be linked to a workspace' });
505
+ }
506
+ try {
507
+ // Get message count and pool health in parallel
508
+ const [count, poolHealth, poolStats] = await Promise.all([
509
+ db.agentMessages.countByWorkspace(daemon.workspaceId),
510
+ db.bulk.checkHealth(),
511
+ Promise.resolve(db.bulk.getPoolStats()),
512
+ ]);
513
+ res.json({
514
+ workspaceId: daemon.workspaceId,
515
+ messageCount: count,
516
+ database: {
517
+ healthy: poolHealth.healthy,
518
+ latencyMs: poolHealth.latencyMs,
519
+ pool: poolStats,
520
+ },
521
+ });
522
+ }
523
+ catch (error) {
524
+ console.error('Error fetching message stats:', error);
525
+ res.status(500).json({ error: 'Failed to fetch message stats' });
526
+ }
527
+ });
324
528
  //# sourceMappingURL=daemons.js.map
@@ -152,12 +152,19 @@ gitRouter.get('/token', async (req, res) => {
152
152
  }
153
153
  // GitHub App installation tokens expire after 1 hour
154
154
  const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString(); // 55 min buffer
155
- console.log(`[git] Token fetched successfully for workspace ${workspaceId.substring(0, 8)}`);
155
+ // Prefer userToken for git operations - installation tokens (ghs_*) are API-only
156
+ // and don't work with git credential helpers. User OAuth tokens work for both
157
+ // git operations (clone, push, pull) AND gh CLI commands.
158
+ const primaryToken = userToken || installationToken;
159
+ const tokenType = userToken ? 'user' : 'installation';
160
+ console.log(`[git] Token fetched successfully for workspace ${workspaceId.substring(0, 8)} (type: ${tokenType})`);
156
161
  res.json({
157
- token: installationToken,
158
- userToken, // For gh CLI - may be null if not available
162
+ token: primaryToken, // Primary token for git/gh operations (prefer user token)
163
+ userToken, // Explicit user token field (may be null)
164
+ installationToken, // GitHub App installation token for API operations
159
165
  expiresAt,
160
- username: 'x-access-token', // GitHub App tokens use this as username
166
+ username: 'x-access-token', // Works with both token types
167
+ tokenType, // 'user' or 'installation' - helps clients know what they got
161
168
  });
162
169
  }
163
170
  catch (error) {
@@ -206,9 +213,9 @@ gitRouter.post('/token', async (req, res) => {
206
213
  code: 'NO_GITHUB_APP_CONNECTION',
207
214
  });
208
215
  }
209
- let token;
216
+ let installationToken;
210
217
  try {
211
- token = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
218
+ installationToken = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId);
212
219
  }
213
220
  catch (nangoError) {
214
221
  const errorMessage = nangoError instanceof Error ? nangoError.message : 'Unknown error';
@@ -219,11 +226,34 @@ gitRouter.post('/token', async (req, res) => {
219
226
  details: errorMessage,
220
227
  });
221
228
  }
229
+ // Try to get user OAuth token (preferred for git operations)
230
+ let userToken = null;
231
+ try {
232
+ userToken = await nangoService.getGithubUserOAuthToken(repoWithConnection.nangoConnectionId);
233
+ }
234
+ catch {
235
+ // Try the separate github user connection if available
236
+ const userRepo = repos.find(r => r.nangoConnectionId && r.nangoConnectionId !== repoWithConnection.nangoConnectionId);
237
+ if (userRepo?.nangoConnectionId) {
238
+ try {
239
+ userToken = await nangoService.getGithubUserToken(userRepo.nangoConnectionId);
240
+ }
241
+ catch {
242
+ console.log('[git] POST: No github user token available');
243
+ }
244
+ }
245
+ }
222
246
  const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString();
247
+ // Prefer userToken for git operations
248
+ const primaryToken = userToken || installationToken;
249
+ const tokenType = userToken ? 'user' : 'installation';
223
250
  res.json({
224
- token,
251
+ token: primaryToken,
252
+ userToken,
253
+ installationToken,
225
254
  expiresAt,
226
255
  username: 'x-access-token',
256
+ tokenType,
227
257
  });
228
258
  }
229
259
  catch (error) {
@@ -13,6 +13,7 @@ import { Router } from 'express';
13
13
  import * as crypto from 'crypto';
14
14
  import { requireAuth } from './auth.js';
15
15
  import { db } from '../db/index.js';
16
+ import { setProviderApiKeyEnv } from './provider-env.js';
16
17
  // Import for local use
17
18
  import { CLI_AUTH_CONFIG, runCLIAuthViaPTY, stripAnsiCodes, matchesSuccessPattern, findMatchingPrompt, validateProviderConfig, validateAllProviderConfigs, getSupportedProviders, } from './cli-pty-runner.js';
18
19
  // Re-export from shared module for backward compatibility
@@ -121,10 +122,11 @@ onboardingRouter.post('/cli/:provider/start', async (req, res) => {
121
122
  }
122
123
  const targetUrl = `${workspaceUrl}/auth/cli/${provider}/start`;
123
124
  console.log('[onboarding] Forwarding to workspace daemon:', targetUrl);
125
+ // Pass userId to enable per-user credential storage in multi-user workspaces
124
126
  const authResponse = await fetch(targetUrl, {
125
127
  method: 'POST',
126
128
  headers: { 'Content-Type': 'application/json' },
127
- body: JSON.stringify({ useDeviceFlow }),
129
+ body: JSON.stringify({ useDeviceFlow, userId }),
128
130
  });
129
131
  console.log('[onboarding] Workspace daemon response:', authResponse.status);
130
132
  if (!authResponse.ok) {
@@ -469,6 +471,7 @@ onboardingRouter.post('/token/:provider', async (req, res) => {
469
471
  scopes: getProviderScopes(provider),
470
472
  providerAccountEmail: email,
471
473
  });
474
+ await setProviderApiKeyEnv(userId, provider, token);
472
475
  res.json({
473
476
  success: true,
474
477
  message: `${provider} connected successfully`,
@@ -0,0 +1,5 @@
1
+ export declare function setProviderApiKeyEnv(userId: string, provider: string, apiKey: string): Promise<{
2
+ updated: number;
3
+ skipped: number;
4
+ }>;
5
+ //# sourceMappingURL=provider-env.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { db } from '../db/index.js';
2
+ import { getProvisioner } from '../provisioner/index.js';
3
+ const PROVIDER_ENV_VARS = {
4
+ google: 'GEMINI_API_KEY',
5
+ gemini: 'GEMINI_API_KEY',
6
+ };
7
+ export async function setProviderApiKeyEnv(userId, provider, apiKey) {
8
+ const envVarName = PROVIDER_ENV_VARS[provider];
9
+ if (!envVarName) {
10
+ return { updated: 0, skipped: 0 };
11
+ }
12
+ const workspaces = await db.workspaces.findByUserId(userId);
13
+ if (workspaces.length === 0) {
14
+ return { updated: 0, skipped: 0 };
15
+ }
16
+ const provisioner = getProvisioner();
17
+ const results = await Promise.all(workspaces.map(async (workspace) => {
18
+ if (!workspace.computeId) {
19
+ return 'skipped';
20
+ }
21
+ await provisioner.setWorkspaceEnvVars(workspace, { [envVarName]: apiKey });
22
+ return 'updated';
23
+ }));
24
+ const updated = results.filter((result) => result === 'updated').length;
25
+ return { updated, skipped: results.length - updated };
26
+ }
27
+ //# sourceMappingURL=provider-env.js.map
@@ -9,6 +9,7 @@ import { createClient } from 'redis';
9
9
  import { requireAuth } from './auth.js';
10
10
  import { getConfig } from '../config.js';
11
11
  import { db } from '../db/index.js';
12
+ import { setProviderApiKeyEnv } from './provider-env.js';
12
13
  export const providersRouter = Router();
13
14
  // All routes require authentication
14
15
  providersRouter.use(requireAuth);
@@ -312,6 +313,7 @@ providersRouter.post('/:provider/api-key', async (req, res) => {
312
313
  provider,
313
314
  scopes,
314
315
  });
316
+ await setProviderApiKeyEnv(userId, provider, apiKey);
315
317
  res.json({
316
318
  success: true,
317
319
  message: `${providerConfig.displayName} connected`,