@oar-sh/oar 0.9.6 → 0.9.8
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.
- package/CHANGELOG.md +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +149 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +468 -88
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"scope": "./",
|
|
8
8
|
"display_override": ["standalone"],
|
|
9
9
|
"display": "standalone",
|
|
10
|
-
"background_color": "#
|
|
11
|
-
"theme_color": "#
|
|
10
|
+
"background_color": "#0d1117",
|
|
11
|
+
"theme_color": "#0d1117",
|
|
12
12
|
"icons": [
|
|
13
13
|
{
|
|
14
|
-
"src": "app-icon.svg?v=
|
|
14
|
+
"src": "app-icon.svg?v=26",
|
|
15
15
|
"sizes": "any",
|
|
16
16
|
"type": "image/svg+xml",
|
|
17
17
|
"purpose": "any"
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"sizes": "512x512",
|
|
28
28
|
"type": "image/png",
|
|
29
29
|
"purpose": "any"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"src": "app-icon-maskable-512.png",
|
|
33
|
+
"sizes": "512x512",
|
|
34
|
+
"type": "image/png",
|
|
35
|
+
"purpose": "maskable"
|
|
30
36
|
}
|
|
31
37
|
]
|
|
32
38
|
}
|
package/server/public/sw.js
CHANGED
|
@@ -13,6 +13,11 @@ const STATIC_ASSETS = [
|
|
|
13
13
|
'app-icon-512.png',
|
|
14
14
|
'favicon.ico',
|
|
15
15
|
'app-icon.svg',
|
|
16
|
+
'app-icon-light-192.png',
|
|
17
|
+
'app-icon-light-512.png',
|
|
18
|
+
'app-icon-light.svg',
|
|
19
|
+
'app-icon-maskable-512.png',
|
|
20
|
+
'app-icon-light-maskable-512.png',
|
|
16
21
|
];
|
|
17
22
|
|
|
18
23
|
function sameOrigin(url) {
|
|
@@ -26,7 +31,7 @@ function isApiRequest(url) {
|
|
|
26
31
|
function isPwaMetadataRequest(url) {
|
|
27
32
|
if (!url.pathname.startsWith(REGISTRATION_SCOPE_PATH)) return false;
|
|
28
33
|
const relativePath = url.pathname.slice(REGISTRATION_SCOPE_PATH.length);
|
|
29
|
-
return /^(?:manifest\.webmanifest|app-icon(?:-\d+)?\.png|
|
|
34
|
+
return /^(?:manifest\.webmanifest|app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)|favicon\.ico)$/.test(relativePath);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function isApplicationModuleRequest(url) {
|
package/server/relay-tools.md
CHANGED
|
@@ -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
|
|
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.
|
|
@@ -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 =
|
|
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) {
|