@oar-sh/oar 0.9.5 → 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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -30,4 +30,16 @@ If you have a `preview` tool, use it. Otherwise use the authenticated localhost
30
30
 
31
31
  ## Remote relays
32
32
 
33
- If you have a `remote_relay` tool, this relay is paired with other OAR relays and the tool works on them: list and read their sessions, prompt a session, start a new one and wait for its reply. Use it only when the user asks for work on another relay or mentions one, and start with `{"action":"list_relays"}`. A relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the remote agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
33
+ If you have a `remote_relay` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
34
+
35
+ A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it.
36
+
37
+ This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
38
+
39
+ - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
40
+ - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
41
+ - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
42
+ - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
43
+ - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
44
+
45
+ Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -7,6 +7,14 @@ export function createMessageRepository(db) {
7
7
  const shareVisibility = createShareVisibilityStatements(db);
8
8
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
9
  .some((column) => column.name === 'image_operation_id');
10
+ // A turn paused at the Claude usage limit (usage-limit-pause-service): a
11
+ // pending follow-up held until the reset. It is not work waiting for a
12
+ // worker, so it neither keeps one warm nor counts as queued.
13
+ const queueHasUsageLimitPause = db.prepare(`PRAGMA table_info(queue)`).all()
14
+ .some((column) => column.name === 'usage_limit_pause');
15
+ const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
16
+ ? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
17
+ : '');
10
18
  const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
11
19
  const messagesHaveSourceId = messageColumns.has('source_message_id');
12
20
  const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
@@ -270,6 +278,7 @@ export function createMessageRepository(db) {
270
278
  LEFT JOIN conversations c
271
279
  ON c.id = q.conversation_id
272
280
  WHERE q.status = 'pending'
281
+ ${notHeldForUsageLimit('q.')}
273
282
  AND COALESCE(
274
283
  NULLIF(q.owner_sdk_session_id, ''),
275
284
  NULLIF(rs.sdk_session_id, ''),
@@ -283,7 +292,7 @@ export function createMessageRepository(db) {
283
292
  ORDER BY MIN(q.timestamp) ASC
284
293
  LIMIT ?
285
294
  `),
286
- countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
295
+ countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') ${notHeldForUsageLimit()} GROUP BY status`),
287
296
  // Deferred host suspend: which conversations still hold live turns.
288
297
  listActiveQueueCountsByConversation: db.prepare(`
289
298
  SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
@@ -174,6 +174,7 @@ export function createQuestionRepository(db) {
174
174
  insertBoard: db.prepare(`INSERT INTO relay_boards (id, queue_id, conversation_id, message_id, board_type, relay_mode, title, body, actions_json, recommended_action, context_json, status, selected_action, acted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)`),
175
175
  getBoard: db.prepare(`SELECT * FROM relay_boards WHERE id = ?`),
176
176
  findBoardByMessageType: db.prepare(`SELECT * FROM relay_boards WHERE message_id = ? AND board_type = ? ORDER BY created_at DESC LIMIT 1`),
177
+ listBoardsForConversation: db.prepare(`SELECT * FROM relay_boards WHERE conversation_id = ? ORDER BY created_at ASC`),
177
178
  listBoards: db.prepare(`SELECT * FROM relay_boards WHERE status = ? AND (? IS NULL OR conversation_id = ?) ORDER BY created_at ASC`),
178
179
  markBoardAction: db.prepare(`UPDATE relay_boards SET status = 'acted', selected_action = ?, acted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'`),
179
180
  dismissBoard: db.prepare(`UPDATE relay_boards SET status = 'dismissed', selected_action = ?, acted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'`),
@@ -1,11 +1,20 @@
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
7
7
  // messages and conversations another relay's agent created. Tables and
8
8
  // columns come from migrations/0005-remote-relays.mjs.
9
+ //
10
+ // A relay whose agent wrote to a conversation is open to that conversation as
11
+ // well. That is read from the provenance, not stored as an unlock, so what the
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.
9
18
 
10
19
  function parseOrigin(json) {
11
20
  if (!json) return null;
@@ -26,6 +35,27 @@ export function createRemoteRelayRepository(db) {
26
35
  const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
27
36
  const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
28
37
  const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
38
+ const listUserMessageOrigins = db.prepare(`
39
+ SELECT DISTINCT origin_json FROM messages
40
+ WHERE conversation_id = ? AND role = 'user' AND origin_json IS NOT NULL
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
+ `);
54
+ const getLatestHumanMessage = db.prepare(`
55
+ SELECT id FROM messages
56
+ WHERE conversation_id = ? AND role = 'user' AND origin_json IS NULL
57
+ ORDER BY timestamp DESC LIMIT 1
58
+ `);
29
59
 
30
60
  return {
31
61
  hasUnlock(conversationId, remoteRelayId) {
@@ -46,6 +76,30 @@ export function createRemoteRelayRepository(db) {
46
76
  if (!conversation || !relay) return false;
47
77
  return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
48
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
+ },
49
103
  forgetRelay(remoteRelayId) {
50
104
  return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
51
105
  },
@@ -66,6 +120,23 @@ export function createRemoteRelayRepository(db) {
66
120
  getConversationOrigin(conversationId) {
67
121
  return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
68
122
  },
123
+ /**
124
+ * Did an agent on the relay with this instance id (the `relayId` of an
125
+ * origin, not the local registry id) write to the conversation: by
126
+ * starting it, or with a prompt in it?
127
+ */
128
+ hasContactFrom(conversationId, remoteInstanceId) {
129
+ const conversation = String(conversationId || '').trim();
130
+ const instance = String(remoteInstanceId || '').trim();
131
+ if (!conversation || !instance) return false;
132
+ if (parseOrigin(getConversationOrigin.get(conversation)?.origin_json)?.relayId === instance) return true;
133
+ return listUserMessageOrigins.all(conversation)
134
+ .some((row) => parseOrigin(row.origin_json)?.relayId === instance);
135
+ },
136
+ /** The id of the latest message the user wrote themselves, or null. */
137
+ latestHumanMessageId(conversationId) {
138
+ return getLatestHumanMessage.get(String(conversationId || ''))?.id || null;
139
+ },
69
140
  };
70
141
  }
71
142
 
@@ -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) {