@oar-sh/oar 0.9.6 → 0.9.7

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 (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
3
+ import { REMOTE_RELAY_LOCAL_ID, normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
4
4
 
5
5
  // Storage for remote relays that is not app settings: which remote relays a
6
6
  // conversation has unlocked (the user mentioned them), and the provenance of
@@ -10,6 +10,11 @@ import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.m
10
10
  // A relay whose agent wrote to a conversation is open to that conversation as
11
11
  // well. That is read from the provenance, not stored as an unlock, so what the
12
12
  // user allowed and what another agent started stay apart.
13
+ //
14
+ // Agent sessions on this relay itself reuse both: the user's "Allow" for a
15
+ // conversation's agent to start sessions here is an unlock under the local
16
+ // target's id, and a conversation such an agent created carries an origin
17
+ // with `local: true` that names the conversation it was created from.
13
18
 
14
19
  function parseOrigin(json) {
15
20
  if (!json) return null;
@@ -34,6 +39,18 @@ export function createRemoteRelayRepository(db) {
34
39
  SELECT DISTINCT origin_json FROM messages
35
40
  WHERE conversation_id = ? AND role = 'user' AND origin_json IS NOT NULL
36
41
  `);
42
+ // Conversations an agent of this relay created that have a turn queued or
43
+ // running. The origin is filtered in SQL only roughly (it is JSON text); the
44
+ // caller matches the creating conversation on the parsed value.
45
+ const listActiveLocalOrigins = db.prepare(`
46
+ SELECT c.id, c.origin_json FROM conversations c
47
+ WHERE c.origin_json LIKE '%"local":true%'
48
+ AND COALESCE(c.status, '') <> 'deleted'
49
+ AND EXISTS (
50
+ SELECT 1 FROM queue q
51
+ WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked')
52
+ )
53
+ `);
37
54
  const getLatestHumanMessage = db.prepare(`
38
55
  SELECT id FROM messages
39
56
  WHERE conversation_id = ? AND role = 'user' AND origin_json IS NULL
@@ -59,6 +76,30 @@ export function createRemoteRelayRepository(db) {
59
76
  if (!conversation || !relay) return false;
60
77
  return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
61
78
  },
79
+ /** Did the user allow this conversation's agent to start sessions on this relay? */
80
+ hasLocalSessionApproval(conversationId) {
81
+ return !!hasUnlock.get(String(conversationId || ''), REMOTE_RELAY_LOCAL_ID);
82
+ },
83
+ /** Remembers that "Allow"; `messageId` is the turn it was given on. */
84
+ recordLocalSessionApproval(conversationId, messageId = null, nowIso = new Date().toISOString()) {
85
+ const conversation = String(conversationId || '').trim();
86
+ if (!conversation) return false;
87
+ return insertUnlock.run(conversation, REMOTE_RELAY_LOCAL_ID, messageId ? String(messageId) : null, nowIso).changes > 0;
88
+ },
89
+ /**
90
+ * The ids of the conversations this conversation's agent created on this
91
+ * relay that have a turn queued or running (pending, processing, parked).
92
+ */
93
+ listActiveLocalSessions(conversationId) {
94
+ const creator = String(conversationId || '').trim();
95
+ if (!creator) return [];
96
+ return listActiveLocalOrigins.all()
97
+ .filter((row) => {
98
+ const origin = parseOrigin(row.origin_json);
99
+ return origin?.local === true && origin.conversationId === creator;
100
+ })
101
+ .map((row) => String(row.id));
102
+ },
62
103
  forgetRelay(remoteRelayId) {
63
104
  return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
64
105
  },
@@ -14,6 +14,16 @@ export function createSessionRepository(db) {
14
14
  const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
15
15
  .some((column) => String(column?.name || '').trim() === 'origin_json');
16
16
  const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
17
+ // Claude Cloud conversations (migration 0007): what the cloud session
18
+ // clones, and what it has cost. NULL on a schema that predates it.
19
+ const conversationsHaveCloudSource = db.prepare(`PRAGMA table_info(conversations)`).all()
20
+ .some((column) => String(column?.name || '').trim() === 'cloud_source_json');
21
+ const conversationCloudSelect = [
22
+ conversationsHaveCloudSource ? 'c.cloud_source_json' : 'NULL AS cloud_source_json',
23
+ runtimeSessionColumns.has('claude_cloud_cost_usd')
24
+ ? 'rs.claude_cloud_cost_usd AS runtime_claude_cloud_cost_usd'
25
+ : 'NULL AS runtime_claude_cloud_cost_usd',
26
+ ].join(', ');
17
27
  return {
18
28
  // conversations
19
29
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -21,13 +31,22 @@ export function createSessionRepository(db) {
21
31
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
22
32
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
23
33
  runtimeSessionsSupportProviders,
24
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
34
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
25
35
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
26
36
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
27
37
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
28
38
  updateConvPreferences: db.prepare(`UPDATE conversations SET preferred_relay_mode = ?, preferred_model = ?, preferred_reasoning_effort = ?, updated_at = ? WHERE id = ?`),
29
39
  updateConvConfiguredWorkspaceRoot: db.prepare(`UPDATE conversations SET configured_workspace_root_path = ?, updated_at = ? WHERE id = ?`),
30
40
  updateConvRuntimeWorkspaceRoot: db.prepare(`UPDATE conversations SET runtime_workspace_root_path = ?, updated_at = ? WHERE id = ?`),
41
+ // Claude Cloud: the repository, branch and environment of the cloud session.
42
+ updateConvCloudSource: conversationsHaveCloudSource
43
+ ? db.prepare(`UPDATE conversations SET cloud_source_json = ? WHERE id = ?`)
44
+ : null,
45
+ // The repositories of earlier cloud chats, newest first: the suggestions
46
+ // of the New Chat repository picker.
47
+ listRecentCloudSources: conversationsHaveCloudSource
48
+ ? db.prepare(`SELECT cloud_source_json, updated_at FROM conversations WHERE cloud_source_json IS NOT NULL AND status != 'deleted' ORDER BY updated_at DESC LIMIT 200`)
49
+ : null,
31
50
  seedConvConfiguredWorkspaceRootIfMissing: db.prepare(`UPDATE conversations SET configured_workspace_root_path = ?, updated_at = ? WHERE id = ? AND (configured_workspace_root_path IS NULL OR configured_workspace_root_path = '')`),
32
51
  updateConvSeed: db.prepare(`UPDATE conversations SET summary_seed = ?, seed_pending = ?, compacted_from = ?, updated_at = ? WHERE id = ?`),
33
52
  markConvCompacted: db.prepare(`UPDATE conversations SET archived = 1, compacted_into = ?, updated_at = ? WHERE id = ?`),
@@ -36,6 +55,7 @@ export function createSessionRepository(db) {
36
55
  updateConvDraft: db.prepare(`UPDATE conversations SET draft_text = ?, draft_updated_at = ?, draft_updated_by_client_id = ? WHERE id = ?`),
37
56
  updateConvDraftAttachments: db.prepare(`UPDATE conversations SET draft_attachments = ?, draft_updated_at = ?, draft_updated_by_client_id = ? WHERE id = ?`),
38
57
  archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
58
+ unarchiveConv: db.prepare(`UPDATE conversations SET archived = 0, updated_at = ? WHERE id = ?`),
39
59
  deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
40
60
 
41
61
  // Queue and message statements live in message-repository.mjs — the
@@ -97,6 +117,19 @@ export function createSessionRepository(db) {
97
117
  WHERE conversation_id = ?
98
118
  `)
99
119
  : null,
120
+ // What a Claude Cloud worker reports: the cloud session id, the last
121
+ // handled event sequence and the cost so far. The caller merges a
122
+ // report with what is stored (claude-cloud-session-service.mjs).
123
+ updateRuntimeSessionClaudeCloudSession: runtimeSessionColumns.has('claude_cloud_session_id')
124
+ ? db.prepare(`
125
+ UPDATE runtime_sessions
126
+ SET claude_cloud_session_id = ?,
127
+ claude_cloud_last_sequence = ?,
128
+ claude_cloud_cost_usd = ?,
129
+ last_used_at = ?
130
+ WHERE conversation_id = ?
131
+ `)
132
+ : null,
100
133
  updateRuntimeSessionContextUsage: runtimeSessionColumns.has('context_usage_json')
101
134
  ? db.prepare(`
102
135
  UPDATE runtime_sessions
@@ -146,6 +179,19 @@ export function createSessionRepository(db) {
146
179
  `),
147
180
  deleteRecentWorkspaceRoots: db.prepare(`DELETE FROM recent_workspace_roots`),
148
181
 
182
+ // per-folder settings (migration 0006): the commit attribution
183
+ // override of a repo folder; keyed like the recent roots
184
+ getWorkspaceRootSettings: db.prepare(`SELECT * FROM workspace_root_settings WHERE path_key = ?`),
185
+ upsertWorkspaceRootAttribution: db.prepare(`
186
+ INSERT INTO workspace_root_settings (path_key, path, attribution_mode, updated_at)
187
+ VALUES (?, ?, ?, ?)
188
+ ON CONFLICT(path_key) DO UPDATE SET
189
+ path = excluded.path,
190
+ attribution_mode = excluded.attribution_mode,
191
+ updated_at = excluded.updated_at
192
+ `),
193
+ deleteWorkspaceRootSettings: db.prepare(`DELETE FROM workspace_root_settings WHERE path_key = ?`),
194
+
149
195
  // app settings
150
196
  getAppSetting: db.prepare(`
151
197
  SELECT key, value, updated_at
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ // Routes of the Claude Cloud provider (`claude-cloud`):
4
+ //
5
+ // GET /api/settings/claude-cloud the provider tab: switch, default model,
6
+ // environment, account, login state
7
+ // POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
8
+ // POST /api/claude-cloud-session the worker's binding report
9
+ // GET /api/claude-cloud/repos the repositories New Chat may suggest
10
+ // (`?refresh=1` reads them afresh)
11
+ // GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
12
+ // POST /api/claude-cloud/login-nudge make the Claude CLI refresh its login
13
+ // (a cloud worker asks before giving up
14
+ // on an expired token); answers what the
15
+ // credentials reader says, never the token
16
+ //
17
+ // The Claude login never passes through here: the settings body carries where
18
+ // it comes from and when it runs out, not the token.
19
+
20
+ /** Whether a query flag such as `?refresh=1` is set. */
21
+ function queryFlag(value) {
22
+ const text = Array.isArray(value) ? value[0] : value;
23
+ return ['1', 'true', 'yes'].includes(String(text ?? '').trim().toLowerCase());
24
+ }
25
+
26
+ export function registerClaudeCloudRoutes(app, deps = {}) {
27
+ const {
28
+ auth,
29
+ touchCli = () => {},
30
+ claudeCloudSettingsService = null,
31
+ claudeCloudSessionService = null,
32
+ claudeCloudRepoService = null,
33
+ // `{ describe() }` of the credentials reader and nothing more: no route
34
+ // can reach the login itself.
35
+ claudeCloudLoginStatus = null,
36
+ claudeAuthService = null,
37
+ } = deps;
38
+
39
+ if (claudeCloudSettingsService) {
40
+ app.get('/api/settings/claude-cloud', auth, async (_req, res) => {
41
+ try {
42
+ return res.json(await claudeCloudSettingsService.describe());
43
+ } catch (error) {
44
+ console.warn(`[claude-cloud] settings read failed: ${error?.code || 'error'}`);
45
+ return res.status(500).json({ error: 'Failed to read Claude Cloud settings' });
46
+ }
47
+ });
48
+
49
+ app.post('/api/settings/claude-cloud', auth, async (req, res) => {
50
+ let result;
51
+ try {
52
+ result = await claudeCloudSettingsService.update(req.body);
53
+ } catch (error) {
54
+ console.warn(`[claude-cloud] settings update failed: ${error?.code || 'error'}`);
55
+ return res.status(500).json({ error: 'Failed to update Claude Cloud settings' });
56
+ }
57
+ if (!result?.ok) {
58
+ return res.status(result?.statusCode || 400).json({
59
+ error: result?.error || 'Failed to update Claude Cloud settings',
60
+ ...(result?.code ? { code: result.code } : {}),
61
+ });
62
+ }
63
+ return res.json({ ok: true, ...result.settings });
64
+ });
65
+ }
66
+
67
+ if (claudeCloudSessionService) {
68
+ // POST /api/claude-cloud-session — the Claude Cloud worker reports the
69
+ // cloud session it created for a conversation and keeps the binding
70
+ // current: `{ conversationId, cloudSessionId, sessionUrl, lastSequence,
71
+ // pushedBranch?, costUsd?, model? }` after the session was created, after
72
+ // every result, and on a push. The stored sequence is where a restarted
73
+ // worker resumes the event stream.
74
+ app.post('/api/claude-cloud-session', auth, (req, res) => {
75
+ touchCli();
76
+ const result = claudeCloudSessionService.recordWorkerReport(req.body);
77
+ if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
78
+ return res.json({ ok: true, cloud: result.cloud });
79
+ });
80
+ }
81
+
82
+ if (claudeCloudLoginStatus) {
83
+ // The workers read the CLI's login file themselves and cannot run the
84
+ // CLI; the relay can, through the same probe the settings tab uses
85
+ // (`claude auth status`, which refreshes a login whose refresh token is
86
+ // still good). Serialised by the auth service; a failed probe is no
87
+ // error here: the file decides, and the answer says what it holds.
88
+ app.post('/api/claude-cloud/login-nudge', auth, async (_req, res) => {
89
+ let nudged = false;
90
+ try {
91
+ if (typeof claudeAuthService?.getStatus === 'function') {
92
+ await claudeAuthService.getStatus({ force: true });
93
+ nudged = true;
94
+ }
95
+ } catch (error) {
96
+ console.warn(`[claude-cloud] login nudge failed: ${error?.code || error?.message || 'error'}`);
97
+ }
98
+ let login = null;
99
+ try {
100
+ login = typeof claudeCloudLoginStatus.describe === 'function' ? claudeCloudLoginStatus.describe() : null;
101
+ } catch {
102
+ login = null;
103
+ }
104
+ return res.json({
105
+ ok: true,
106
+ nudged,
107
+ hasToken: login?.hasToken === true,
108
+ expiresAt: login?.expiresAt || null,
109
+ expired: login?.expired === true,
110
+ });
111
+ });
112
+ }
113
+
114
+ if (claudeCloudRepoService) {
115
+ // Every answer the service gives is 200, also an `ok: false` one: the
116
+ // browser shows the recent repositories with the error text beside them.
117
+ app.get('/api/claude-cloud/repos', auth, async (req, res) => {
118
+ try {
119
+ return res.json(await claudeCloudRepoService.listRepositories({ force: queryFlag(req.query?.refresh) }));
120
+ } catch (error) {
121
+ console.warn(`[claude-cloud] repository list failed: ${error?.code || 'error'}`);
122
+ return res.status(500).json({ error: 'Failed to list Claude Cloud repositories' });
123
+ }
124
+ });
125
+
126
+ app.get('/api/claude-cloud/branches', auth, async (req, res) => {
127
+ const repo = Array.isArray(req.query?.repo) ? req.query.repo[0] : req.query?.repo;
128
+ let result;
129
+ try {
130
+ result = await claudeCloudRepoService.listBranches(typeof repo === 'string' ? repo : '');
131
+ } catch (error) {
132
+ console.warn(`[claude-cloud] branch list failed: ${error?.code || 'error'}`);
133
+ return res.status(500).json({ error: 'Failed to list the repository branches' });
134
+ }
135
+ // A repository the service cannot even name is the caller's mistake.
136
+ if (!result?.ok && result?.error?.code === 'invalid_repo') return res.status(400).json(result);
137
+ return res.json(result);
138
+ });
139
+ }
140
+ }
@@ -4,6 +4,8 @@
4
4
  // resolved server-side from the conversation scope (mirroring the file
5
5
  // preview routes) — the client never sends absolute paths.
6
6
 
7
+ import { validateRequestedWorkspaceRoot } from '../services/workspace-root-path-policy.mjs';
8
+
7
9
  export function resolveGitScopedRootPath(req, {
8
10
  resolveConversationWorkspaceState,
9
11
  currentWorkspaceRootPath,
@@ -40,8 +42,43 @@ export function registerGitRoutes(app, deps) {
40
42
  currentWorkspaceRootPath,
41
43
  normalizeWorkspaceRelativePath,
42
44
  resolveWorkspaceFilePath,
45
+ gitRemoteService = null,
46
+ workspaceRootAllowList = [],
47
+ validateWorkspaceRoot = validateRequestedWorkspaceRoot,
43
48
  } = deps;
44
49
 
50
+ // GET /api/git/remote?root=<abs path> — where a folder lives on GitHub, for
51
+ // the New Chat modal (the repository and branch of a Claude Cloud chat).
52
+ // There is no conversation yet, so this is the one git route that takes a
53
+ // path from the client: it passes the same policy as a requested CWD (an
54
+ // existing directory, absolute, inside the workspace allow list).
55
+ if (gitRemoteService) {
56
+ app.get('/api/git/remote', auth, async (req, res) => {
57
+ res.setHeader?.('Cache-Control', 'no-store');
58
+ const validated = validateWorkspaceRoot(req.query?.root, { allowList: workspaceRootAllowList });
59
+ if (!validated.ok) {
60
+ return res.status(validated.code === 'root-path-not-allowed' ? 403 : 400)
61
+ .json({ ok: false, code: validated.code, error: validated.error });
62
+ }
63
+ const root = validated.realPath;
64
+ const payload = await gitRemoteService.describe(root);
65
+ if (!payload.ok) return res.status(500).json({ ok: false, error: payload.error || 'Failed to read the git remote' });
66
+ return res.json({
67
+ ok: true,
68
+ root,
69
+ hasGit: payload.hasGit,
70
+ remoteUrl: payload.remoteUrl,
71
+ repoUrl: payload.repoUrl,
72
+ slug: payload.slug,
73
+ branch: payload.branch,
74
+ upstream: payload.upstream,
75
+ ahead: payload.ahead,
76
+ behind: payload.behind,
77
+ dirty: payload.dirty,
78
+ });
79
+ });
80
+ }
81
+
45
82
  if (!gitChangesService) return;
46
83
 
47
84
  function requireRootPath(req, res) {
@@ -43,6 +43,8 @@ import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-s
43
43
  import { isLiveResend } from '../services/steer-resend.mjs';
44
44
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
45
45
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
46
+ import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
47
+ import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
46
48
  import {
47
49
  admitRemoteRelayRequest,
48
50
  findMentionedRemoteRelays,
@@ -114,6 +116,8 @@ function normalizeRequestedProviderType(value = '') {
114
116
  // "no provider stated" and the guards below could never fire.
115
117
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
116
118
  if (normalized === 'claude' || normalized === 'anthropic') return 'claude';
119
+ // Its own provider, never read as `claude`.
120
+ if (normalized === 'claude-cloud') return 'claude-cloud';
117
121
  return '';
118
122
  }
119
123
 
@@ -121,6 +125,7 @@ const BOOTSTRAP_ONLY_PROVIDER_LABELS = Object.freeze({
121
125
  cursor: 'Cursor',
122
126
  grok: 'Grok',
123
127
  claude: 'Claude',
128
+ 'claude-cloud': 'Claude Cloud',
124
129
  });
125
130
 
126
131
  function deriveModelOrigin(model) {
@@ -589,9 +594,17 @@ async function resolveRelayResponseText({
589
594
  }
590
595
  }
591
596
 
592
- export function resolveTerminalFailurePayload(payload = {}, { fallbackText = null } = {}) {
597
+ /**
598
+ * `inferFromText`: whether a text shaped like a failure note counts as one
599
+ * when no record came with it. A session worker always sends the record
600
+ * (`terminalError`), so for its publishes (an `attemptId` names the attempt)
601
+ * the text is a reply whatever it says — a reply that names a code after
602
+ * fixing a failed test is a reply. Senders without an attempt (the Copilot
603
+ * extension, a note read from another relay) still report as text.
604
+ */
605
+ export function resolveTerminalFailurePayload(payload = {}, { fallbackText = null, inferFromText = true } = {}) {
593
606
  const body = payload && typeof payload === 'object' ? payload : {};
594
- const parsedTextFailure = parseTerminalFailureText(body.text);
607
+ const parsedTextFailure = inferFromText ? parseTerminalFailureText(body.text) : null;
595
608
  const direct = body.terminalError && typeof body.terminalError === 'object'
596
609
  ? body.terminalError
597
610
  : null;
@@ -1121,6 +1134,7 @@ export function buildDequeuedRelayMessage({
1121
1134
  defaultRelayMode,
1122
1135
  defaultModel,
1123
1136
  getCursorProviderSettings = () => null,
1137
+ getClaudeCloudProviderSettings = () => null,
1124
1138
  } = {}) {
1125
1139
  if (!msg) return null;
1126
1140
  const attachments = parseAttachments(msg.attachments).map(hydrateAttachment).filter(Boolean);
@@ -1150,6 +1164,17 @@ export function buildDequeuedRelayMessage({
1150
1164
  .map((value) => String(value || '').trim())
1151
1165
  .filter(Boolean);
1152
1166
  }
1167
+ // A Claude Cloud worker creates its session from the first delivered
1168
+ // message and sends into it afterwards: the repository, the branch and the
1169
+ // binding it reported ride on every message, so a restarted worker needs
1170
+ // nothing but the delivery. Other providers never get the field.
1171
+ const claudeCloud = isClaudeCloudProviderType(providerType)
1172
+ ? buildClaudeCloudDelivery({
1173
+ conversation: stmts.getConvAnyStatus?.get?.(msg.conversation_id) || null,
1174
+ runtimeSession,
1175
+ fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1176
+ })
1177
+ : null;
1153
1178
  return {
1154
1179
  id: msg.id,
1155
1180
  conversationId: msg.conversation_id,
@@ -1163,6 +1188,7 @@ export function buildDequeuedRelayMessage({
1163
1188
  cursorAgentId: String(runtimeSession?.cursor_agent_id || '').trim() || null,
1164
1189
  cursorSubagentModels,
1165
1190
  grokNativeSessionId: String(runtimeSession?.grok_native_session_id || '').trim() || null,
1191
+ ...(isClaudeCloudProviderType(providerType) ? { claudeCloud } : {}),
1166
1192
  reasoningEffort: String(msg.reasoning_effort || '').trim() || null,
1167
1193
  contextTier: String(msg.context_tier || '').trim() || 'default',
1168
1194
  quality: normalizeOpenAIImageQuality(msg.reasoning_effort),
@@ -1783,6 +1809,7 @@ export function registerMessagesRoutes(app, deps) {
1783
1809
  getClaudeProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1784
1810
  getCursorProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1785
1811
  getGrokProviderSettings = () => ({ enabled: false, model: '', models: [] }),
1812
+ getClaudeCloudProviderSettings = () => ({ enabled: false, defaultModel: '', environmentId: '', models: [] }),
1786
1813
  rebindUnstartedOpenAIConversationModel = null,
1787
1814
  normalizeRelayMode,
1788
1815
  DEFAULT_RELAY_MODE,
@@ -4166,7 +4193,8 @@ export function registerMessagesRoutes(app, deps) {
4166
4193
  const runtimeBoundToExplicitProvider = runtimeProviderType === 'openai'
4167
4194
  || runtimeProviderType === 'claude'
4168
4195
  || runtimeProviderType === 'cursor'
4169
- || runtimeProviderType === 'grok';
4196
+ || runtimeProviderType === 'grok'
4197
+ || runtimeProviderType === 'claude-cloud';
4170
4198
  const configuredOpenAIModel = String(configuredOpenAI?.model || '').trim();
4171
4199
  const availableOpenAIModels = new Set(
4172
4200
  (Array.isArray(configuredOpenAI?.models) ? configuredOpenAI.models : [])
@@ -4290,6 +4318,17 @@ export function registerMessagesRoutes(app, deps) {
4290
4318
  });
4291
4319
  }
4292
4320
  }
4321
+ // Claude Cloud conversations are bootstrap-created only as well: each
4322
+ // carries the repository its cloud session clones. Only an explicit
4323
+ // provider counts here — the cloud's model ids are the Claude provider's
4324
+ // own, so an id alone never means "cloud".
4325
+ const runtimeUsesClaudeCloud = runtimeProviderType === 'claude-cloud';
4326
+ if (!shouldCreateConversation && !runtimeUsesClaudeCloud && requestedProviderType === 'claude-cloud') {
4327
+ return res.status(409).json({
4328
+ error: 'Claude Cloud requires creating a new Claude Cloud conversation',
4329
+ code: 'CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION',
4330
+ });
4331
+ }
4293
4332
  if (
4294
4333
  useOpenAIProvider
4295
4334
  && requestedOpenAIModel
@@ -4422,6 +4461,32 @@ export function registerMessagesRoutes(app, deps) {
4422
4461
  const grokModel = runtimeUsesGrok
4423
4462
  ? (pinnedGrokModel || String(configuredGrok?.model || '').trim())
4424
4463
  : '';
4464
+ // Claude Cloud conversations resolve models against the cloud provider's
4465
+ // own list (plain ids, no "[1m]" tier). The model is part of the cloud
4466
+ // session's configuration: it can still change before the first message
4467
+ // creates that session, and is locked afterwards.
4468
+ const configuredClaudeCloud = runtimeUsesClaudeCloud ? getClaudeCloudProviderSettings() : null;
4469
+ const pinnedClaudeCloudModel = runtimeUsesClaudeCloud
4470
+ ? stripModelTierSuffix(existingRuntimeSession?.provider_model)
4471
+ : '';
4472
+ const claudeCloudModelSelection = runtimeUsesClaudeCloud
4473
+ ? resolveProviderModelSelection({
4474
+ requestedModel: stripModelTierSuffix(model),
4475
+ configuredModel: pinnedClaudeCloudModel || String(configuredClaudeCloud?.defaultModel || '').trim(),
4476
+ availableModels: Array.isArray(configuredClaudeCloud?.models) ? configuredClaudeCloud.models : [],
4477
+ })
4478
+ : null;
4479
+ if (claudeCloudModelSelection && !claudeCloudModelSelection.ok) {
4480
+ return res.status(400).json({
4481
+ error: `Claude Cloud model "${claudeCloudModelSelection.requestedModel}" is not available`,
4482
+ code: 'CLAUDE_CLOUD_MODEL_UNAVAILABLE',
4483
+ supportedModels: claudeCloudModelSelection.availableModels || [],
4484
+ });
4485
+ }
4486
+ // A cloud session's model can be switched between turns (the worker
4487
+ // sends set_model before the message), so a changed model is taken as
4488
+ // for a local Claude chat.
4489
+ const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
4425
4490
  const modelResolution = useOpenAIProvider
4426
4491
  ? {
4427
4492
  ok: !!openAIModel,
@@ -4454,7 +4519,15 @@ export function registerMessagesRoutes(app, deps) {
4454
4519
  reasoningEffort: null,
4455
4520
  error: grokModel ? null : 'Grok model is not configured',
4456
4521
  }
4457
- : resolveRequestedModel(model))));
4522
+ : (runtimeUsesClaudeCloud
4523
+ ? {
4524
+ ok: !!claudeCloudModel,
4525
+ model: claudeCloudModel,
4526
+ modelVariantId: claudeCloudModel,
4527
+ reasoningEffort: null,
4528
+ error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4529
+ }
4530
+ : resolveRequestedModel(model)))));
4458
4531
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4459
4532
  const requestedModel = String(modelResolution.model || '').trim();
4460
4533
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4523,10 +4596,13 @@ export function registerMessagesRoutes(app, deps) {
4523
4596
  ? resolveCursorReasoningEffort()
4524
4597
  : (runtimeUsesGrok
4525
4598
  ? resolveGrokReasoningEffort()
4526
- : resolveRequestedReasoningEffort(
4527
- requestedModel,
4528
- explicitReasoningEffort || modelResolution.reasoningEffort || null,
4529
- ))));
4599
+ // A cloud session takes no effort: whatever was asked, it is none.
4600
+ : (runtimeUsesClaudeCloud
4601
+ ? { ok: true, effort: 'none', supported: ['none'] }
4602
+ : resolveRequestedReasoningEffort(
4603
+ requestedModel,
4604
+ explicitReasoningEffort || modelResolution.reasoningEffort || null,
4605
+ )))));
4530
4606
  if (!reasoningResolution?.ok && !explicitReasoningEffort) {
4531
4607
  const supportedEfforts = Array.isArray(reasoningResolution?.supported)
4532
4608
  ? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
@@ -4553,6 +4629,9 @@ export function registerMessagesRoutes(app, deps) {
4553
4629
  }
4554
4630
  } else if (!['default', 'long_context'].includes(requestedContextTier)) {
4555
4631
  return res.status(400).json({ error: 'Unsupported context tier' });
4632
+ } else if (runtimeUsesClaudeCloud) {
4633
+ // The cloud has no long-context tier to pick.
4634
+ normalizedContextTier = 'default';
4556
4635
  }
4557
4636
  if (!requestedRelayMode) return res.status(400).json({ error: 'Unsupported relay mode' });
4558
4637
  const conversationWorkspaceState = (!shouldCreateConversation && typeof resolveConversationWorkspaceState === 'function')
@@ -4784,6 +4863,17 @@ export function registerMessagesRoutes(app, deps) {
4784
4863
  ) {
4785
4864
  stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
4786
4865
  }
4866
+ // A cloud conversation follows the composer like a Claude one: the
4867
+ // worker switches the sandbox's model before the message.
4868
+ if (
4869
+ runtimeUsesClaudeCloud
4870
+ && claudeCloudModel
4871
+ && runtimeSession?.id
4872
+ && claudeCloudModel !== String(existingRuntimeSession?.provider_model || '').trim()
4873
+ && typeof stmts.updateRuntimeSessionProvider?.run === 'function'
4874
+ ) {
4875
+ stmts.updateRuntimeSessionProvider.run('claude-cloud', claudeCloudModel, claudeCloudModel, now, runtimeSession.id);
4876
+ }
4787
4877
  // Another relay's agent picks the model and mode of its own turn; the
4788
4878
  // composer keeps restoring the user's choice for this conversation.
4789
4879
  conversationPreferences = remoteRequest.remote && !shouldCreateConversation
@@ -5285,7 +5375,10 @@ export function registerMessagesRoutes(app, deps) {
5285
5375
  if (!runtimeSession) {
5286
5376
  return res.status(404).json({ error: 'Runtime session not found for conversation' });
5287
5377
  }
5288
- if (String(runtimeSession.provider_type || 'github').trim().toLowerCase() !== 'claude') {
5378
+ // The Claude Cloud worker reports the same shape (the cloud session's
5379
+ // context and token usage), so both providers are taken here.
5380
+ const contextUsageProvider = String(runtimeSession.provider_type || 'github').trim().toLowerCase();
5381
+ if (contextUsageProvider !== 'claude' && contextUsageProvider !== 'claude-cloud') {
5289
5382
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5290
5383
  }
5291
5384
  if (typeof stmts.updateRuntimeSessionContextUsage?.run !== 'function') {
@@ -5345,13 +5438,17 @@ export function registerMessagesRoutes(app, deps) {
5345
5438
  // Claude-bound conversation's worker may write the Claude card. The
5346
5439
  // snapshot itself is global, so a misrouted poster would corrupt it for
5347
5440
  // everyone.
5441
+ // A Claude Cloud worker posts here too; its report is kept under its own
5442
+ // snapshot key, so the Claude card stays what the Claude workers wrote.
5348
5443
  const conversationId = String(req.body?.conversationId || '').trim();
5444
+ let snapshotProvider = 'claude';
5349
5445
  if (conversationId) {
5350
5446
  const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5351
5447
  const boundProvider = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
5352
- if (runtimeSession && boundProvider !== 'claude') {
5448
+ if (runtimeSession && boundProvider !== 'claude' && boundProvider !== 'claude-cloud') {
5353
5449
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5354
5450
  }
5451
+ if (runtimeSession && boundProvider === 'claude-cloud') snapshotProvider = 'claude-cloud';
5355
5452
  }
5356
5453
  const usage = normalizeClaudePlanUsage(req.body?.usage)
5357
5454
  || claudePlanUsageFromResult({
@@ -5359,7 +5456,7 @@ export function registerMessagesRoutes(app, deps) {
5359
5456
  totalCostUsd: req.body?.totalCostUsd,
5360
5457
  });
5361
5458
  if (!usage) return res.status(400).json({ error: 'Missing usable usage payload' });
5362
- planUsageService.saveSnapshot('claude', usage, {
5459
+ planUsageService.saveSnapshot(snapshotProvider, usage, {
5363
5460
  source: 'worker',
5364
5461
  error: String(req.body?.error || '').trim() || null,
5365
5462
  });
@@ -5373,12 +5470,13 @@ export function registerMessagesRoutes(app, deps) {
5373
5470
  touchCli();
5374
5471
  if (!usageLimitPauseService) return res.status(501).json({ error: 'Usage limit handling is unavailable' });
5375
5472
  // The report is the account's, so only a Claude-bound conversation's
5376
- // worker may write it (same validation as the plan usage card).
5473
+ // worker may write it (same validation as the plan usage card); a Claude
5474
+ // Cloud chat runs on the same account and reports the same limit.
5377
5475
  const conversationId = String(req.body?.conversationId || '').trim();
5378
5476
  if (conversationId) {
5379
5477
  const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
5380
5478
  const boundProvider = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
5381
- if (runtimeSession && boundProvider !== 'claude') {
5479
+ if (runtimeSession && boundProvider !== 'claude' && boundProvider !== 'claude-cloud') {
5382
5480
  return res.status(409).json({ error: 'Conversation is not bound to the Claude provider' });
5383
5481
  }
5384
5482
  }
@@ -5982,6 +6080,7 @@ export function registerMessagesRoutes(app, deps) {
5982
6080
  defaultRelayMode: DEFAULT_RELAY_MODE,
5983
6081
  defaultModel: DEFAULT_MODEL,
5984
6082
  getCursorProviderSettings,
6083
+ getClaudeCloudProviderSettings,
5985
6084
  });
5986
6085
 
5987
6086
  if (out.attachments.length) {
@@ -6576,7 +6675,10 @@ export function registerMessagesRoutes(app, deps) {
6576
6675
  }
6577
6676
  };
6578
6677
  let trimmedText = String(text || '').trim();
6579
- let terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6678
+ let terminalFailure = resolveTerminalFailurePayload(req.body, {
6679
+ fallbackText: trimmedText,
6680
+ inferFromText: !String(req.body?.attemptId || '').trim(),
6681
+ });
6580
6682
  // Final digests of background workflows that settled during this turn —
6581
6683
  // the transcript's "Finished background task" cards. Same structural
6582
6684
  // sanitizer as the live panel rows; junk entries drop silently (this
@@ -7106,7 +7208,10 @@ export function registerMessagesRoutes(app, deps) {
7106
7208
  // Providers whose workers turn runtime-initiated activity into continuation
7107
7209
  // rows: the Claude worker (background tasks/agents) and the Copilot SDK
7108
7210
  // worker (detached background shells) serving both Copilot-CLI providers.
7109
- const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai']);
7211
+ // A Claude Cloud session can take a turn of its own too: one the agent
7212
+ // starts when its background work reports back, or one started on
7213
+ // claude.ai; the cloud worker follows it and registers it here.
7214
+ const CONTINUATION_PROVIDER_TYPES = new Set(['claude', 'github', 'openai', 'claude-cloud']);
7110
7215
 
7111
7216
  // POST /api/continuation-turn — a session worker's CLI started a turn on its
7112
7217
  // own (a background task's notification). The synthetic queue row gives that