offsite 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/apps/server/dist/claude.js +77 -28
- package/apps/server/dist/config.js +8 -1
- package/apps/server/dist/db.js +13 -0
- package/apps/server/dist/history.js +50 -16
- package/apps/server/dist/main.js +8 -2
- package/apps/server/dist/projects.js +92 -0
- package/apps/server/dist/run.js +7 -3
- package/apps/server/dist/sessions.js +49 -14
- package/apps/server/dist/socket.js +71 -17
- package/apps/server/dist/storage.js +50 -1
- package/apps/web/dist/assets/{angular-html-_TZra42G.js → angular-html-ji4UO7hp.js} +1 -1
- package/apps/web/dist/assets/{angular-ts-CVVZOQsp.js → angular-ts-CkobLhDa.js} +1 -1
- package/apps/web/dist/assets/{apl-Cyzt__4P.js → apl-DAjPJTs4.js} +1 -1
- package/apps/web/dist/assets/{astro-DvoD4nYz.js → astro-DlwA-zmc.js} +1 -1
- package/apps/web/dist/assets/{blade-SFrvNJcM.js → blade-wTlHdbgB.js} +1 -1
- package/apps/web/dist/assets/{c-BzXljoEl.js → c-HyCIpwKL.js} +1 -1
- package/apps/web/dist/assets/{chapel-CSqeAvDr.js → chapel-SOYLpFSa.js} +1 -1
- package/apps/web/dist/assets/{cobol-DnK1h_4j.js → cobol-DlvLD04V.js} +1 -1
- package/apps/web/dist/assets/{coffee-BrM2hs_V.js → coffee-CGp5oJTY.js} +1 -1
- package/apps/web/dist/assets/{cpp-C_7gVSp4.js → cpp-CzmH13Nm.js} +1 -1
- package/apps/web/dist/assets/{crystal-DbpY_y_D.js → crystal-zS14MbR_.js} +1 -1
- package/apps/web/dist/assets/{css-CcKWTG1c.js → css-DQEQn59R.js} +1 -1
- package/apps/web/dist/assets/{edge-CqcnepJa.js → edge-Bkv0KzZU.js} +1 -1
- package/apps/web/dist/assets/{elixir-DG0UMMTO.js → elixir-B8tse0js.js} +1 -1
- package/apps/web/dist/assets/{elm-D81_16hi.js → elm-F2InAxd7.js} +1 -1
- package/apps/web/dist/assets/{erb-BHb0Ji2T.js → erb-CAIamwOm.js} +1 -1
- package/apps/web/dist/assets/{git-rebase-Bzcz_AWI.js → git-rebase-U0z47rUi.js} +1 -1
- package/apps/web/dist/assets/{glimmer-js-BbGaPXN7.js → glimmer-js-Bg_zPoN-.js} +1 -1
- package/apps/web/dist/assets/{glimmer-ts-B2OBt1Cw.js → glimmer-ts-v3suqAPH.js} +1 -1
- package/apps/web/dist/assets/{glsl-Cuu3Xc_3.js → glsl-8io-Pcmd.js} +1 -1
- package/apps/web/dist/assets/{graphql-XBGrkq1T.js → graphql-B_q9cV-C.js} +1 -1
- package/apps/web/dist/assets/{hack-D-n58M0B.js → hack-DosfNWDX.js} +1 -1
- package/apps/web/dist/assets/{haml-06aMoWFC.js → haml-DcBtkGn5.js} +1 -1
- package/apps/web/dist/assets/{handlebars-BSCSPRHp.js → handlebars-2F0KxPFu.js} +1 -1
- package/apps/web/dist/assets/{html-CRYhxgas.js → html-DrWZZyp3.js} +1 -1
- package/apps/web/dist/assets/{html-derivative-El-EKb6H.js → html-derivative-BaV8vDp-.js} +1 -1
- package/apps/web/dist/assets/{http-Cj-Wbb2c.js → http-Dwo7FxJA.js} +1 -1
- package/apps/web/dist/assets/{hurl--dF5MzC1.js → hurl-9r6SjmMP.js} +1 -1
- package/apps/web/dist/assets/index-CIBt6t2M.css +2 -0
- package/apps/web/dist/assets/index-CPpMMYyZ.js +902 -0
- package/apps/web/dist/assets/{java-_zuIdTC3.js → java-CAkt7S2h.js} +1 -1
- package/apps/web/dist/assets/{javascript-Dw2bor_9.js → javascript-CMo8GEjU.js} +1 -1
- package/apps/web/dist/assets/{jinja-CtYhL6FS.js → jinja-XhRt7Tzv.js} +1 -1
- package/apps/web/dist/assets/{jison-BS6VCOAt.js → jison-BXZQEb4C.js} +1 -1
- package/apps/web/dist/assets/{json-D6Mznwzn.js → json-SyJDQmRp.js} +1 -1
- package/apps/web/dist/assets/{jsx-DLuxOqjU.js → jsx-B76-zKzH.js} +1 -1
- package/apps/web/dist/assets/{julia-CMgTtdec.js → julia-Ca5lbuMA.js} +1 -1
- package/apps/web/dist/assets/{just-D_e2Cdol.js → just-CDt2RojJ.js} +1 -1
- package/apps/web/dist/assets/{latex-C7F9cbcZ.js → latex-D9bybvWo.js} +1 -1
- package/apps/web/dist/assets/{liquid-DZ0eHbL-.js → liquid-DMp-tByG.js} +1 -1
- package/apps/web/dist/assets/{lua-DA_XbnYa.js → lua-zNnfh_4o.js} +1 -1
- package/apps/web/dist/assets/{marko-BOjbwMe_.js → marko-Ce5B11L0.js} +1 -1
- package/apps/web/dist/assets/{mdc-BLQdrSL9.js → mdc-Btapt9E8.js} +1 -1
- package/apps/web/dist/assets/{nginx-CRuAoyv9.js → nginx-Bzjverzh.js} +1 -1
- package/apps/web/dist/assets/{nim--m3L_KpX.js → nim-D6zpNhUX.js} +1 -1
- package/apps/web/dist/assets/{org-BNxSv-BH.js → org-DTy-FvFD.js} +1 -1
- package/apps/web/dist/assets/{perl-DEB_cAoG.js → perl-BV8Dxusy.js} +1 -1
- package/apps/web/dist/assets/{php-Cei4P5ds.js → php-BjkIa26S.js} +1 -1
- package/apps/web/dist/assets/{pug-BtkQ8Y8B.js → pug-DdfmTHk6.js} +1 -1
- package/apps/web/dist/assets/{qml-yI-6sShm.js → qml-BWLYImhB.js} +1 -1
- package/apps/web/dist/assets/{r-CJ8zo4yy.js → r-CKc9klse.js} +1 -1
- package/apps/web/dist/assets/{razor-CTPI_hHO.js → razor-YcF2a59Y.js} +1 -1
- package/apps/web/dist/assets/{regexp-j43xPEIH.js → regexp-D3esVJ1X.js} +1 -1
- package/apps/web/dist/assets/{rst-CfIeFcNC.js → rst-DVaj4AQD.js} +1 -1
- package/apps/web/dist/assets/{ruby-CtmudUkS.js → ruby-DBJQEVev.js} +1 -1
- package/apps/web/dist/assets/{sas-BBgd1Oa5.js → sas-NSuLkGD1.js} +1 -1
- package/apps/web/dist/assets/{scss-BOSyZrIf.js → scss-TMy8m-so.js} +1 -1
- package/apps/web/dist/assets/{shellscript-BES_U9_l.js → shellscript-BVuIsFkY.js} +1 -1
- package/apps/web/dist/assets/{shellsession-CdF1AgdN.js → shellsession-CV7MHIu6.js} +1 -1
- package/apps/web/dist/assets/{soy-hqjXYWz3.js → soy-DDY0EoKE.js} +1 -1
- package/apps/web/dist/assets/{sql-D5hYy2r7.js → sql-CoZqqtiv.js} +1 -1
- package/apps/web/dist/assets/{stata-GNNI7e-r.js → stata-B8C1noIb.js} +1 -1
- package/apps/web/dist/assets/{surrealql-gm8__PDR.js → surrealql-BYiUJt-V.js} +1 -1
- package/apps/web/dist/assets/{svelte-DK6xQ-Vs.js → svelte-DGeZ8dPs.js} +1 -1
- package/apps/web/dist/assets/{templ-Dy-PHEH0.js → templ-Dn21SE_C.js} +1 -1
- package/apps/web/dist/assets/{tex-gYu3IM_T.js → tex-Da9GBCsM.js} +1 -1
- package/apps/web/dist/assets/{ts-tags-C8akqtaV.js → ts-tags-BuL1T6K3.js} +1 -1
- package/apps/web/dist/assets/{tsx-CtF99HYc.js → tsx-DiLCnfgl.js} +1 -1
- package/apps/web/dist/assets/{twig-D7-O9s37.js → twig-Cx1peiH-.js} +1 -1
- package/apps/web/dist/assets/{typescript-o4QHcKOF.js → typescript-pVxvHJ4R.js} +1 -1
- package/apps/web/dist/assets/{typst-CsLzgkYX.js → typst-CJQbitzj.js} +1 -1
- package/apps/web/dist/assets/{vue-C67X0ZYE.js → vue-CXKyVnbB.js} +1 -1
- package/apps/web/dist/assets/{vue-html-3wcw93oK.js → vue-html-AQjnBMTh.js} +1 -1
- package/apps/web/dist/assets/{vue-vine-Bq8-4EKU.js → vue-vine-DYxt9AbK.js} +1 -1
- package/apps/web/dist/assets/{xml-DXCI-6Wi.js → xml-CEJ01uJ1.js} +1 -1
- package/apps/web/dist/assets/{xsl-Dr0qfgBg.js → xsl-CULFBy6v.js} +1 -1
- package/apps/web/dist/assets/{yaml-DHhOWIuf.js → yaml-BNf68xM_.js} +1 -1
- package/apps/web/dist/index.html +2 -2
- package/package.json +2 -1
- package/apps/server/dist/folders.js +0 -12
- package/apps/web/dist/assets/index-CN9uEzWa.js +0 -899
- package/apps/web/dist/assets/index-Ck79gpc3.css +0 -2
package/README.md
CHANGED
|
@@ -25,10 +25,10 @@ Don't use the same session in offsite and the CLI at the same time. Switching be
|
|
|
25
25
|
|
|
26
26
|
## Separate instances
|
|
27
27
|
|
|
28
|
-
Give each instance its own port and `--dir`.
|
|
28
|
+
Give each instance its own port and `--dir`.
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
|
|
31
|
+
npx offsite --port 47850 --dir ~/.offsite-work
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Development
|
|
@@ -38,4 +38,4 @@ pnpm install
|
|
|
38
38
|
pnpm dev
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
The web app runs at [http://localhost:5173](http://localhost:5173). The server runs on port 47842 and keeps its data in `~/.offsite-dev
|
|
41
|
+
The web app runs at [http://localhost:5173](http://localhost:5173). The server runs on port 47842 and keeps its data in `~/.offsite-dev`, apart from your main instance.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { statSync } from 'node:fs';
|
|
2
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, rm, symlink } from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
7
|
+
import { readConfig } from './config.js';
|
|
6
8
|
import { broadcast } from './hub.js';
|
|
7
9
|
import { readJson, writeJson } from './storage.js';
|
|
8
|
-
export const DEFAULT_ACCOUNT = 'default';
|
|
9
10
|
const probes = new Map();
|
|
10
11
|
const limits = new Map();
|
|
11
12
|
let executable = '';
|
|
@@ -21,19 +22,18 @@ export function findClaude() {
|
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
23
24
|
export function claudeOptions(accountId, cwd) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const account = storedAccounts().find((item) => item.id === accountId);
|
|
30
|
-
if (account === undefined)
|
|
31
|
-
throw new Error('That account was removed');
|
|
32
|
-
env.CLAUDE_CODE_OAUTH_TOKEN = account.token;
|
|
33
|
-
}
|
|
25
|
+
if (accountId === null)
|
|
26
|
+
throw new Error('Add a Claude account in Settings → Accounts');
|
|
27
|
+
const account = storedAccounts().find((item) => item.id === accountId);
|
|
28
|
+
if (account === undefined)
|
|
29
|
+
throw new Error('That account was removed');
|
|
34
30
|
return {
|
|
35
31
|
cwd,
|
|
36
|
-
env
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
CLAUDE_CODE_ENTRYPOINT: 'offsite',
|
|
35
|
+
CLAUDE_CONFIG_DIR: account.configDir,
|
|
36
|
+
},
|
|
37
37
|
pathToClaudeCodeExecutable: executable,
|
|
38
38
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
39
39
|
hooks: {
|
|
@@ -87,11 +87,7 @@ export async function capabilities(accountId, cwd) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
export async function accounts() {
|
|
90
|
-
|
|
91
|
-
{ id: DEFAULT_ACCOUNT, name: 'Claude login' },
|
|
92
|
-
...storedAccounts().map(({ id, name }) => ({ id, name })),
|
|
93
|
-
];
|
|
94
|
-
return Promise.all(all.map(async (account) => {
|
|
90
|
+
return Promise.all(storedAccounts().map(async (account) => {
|
|
95
91
|
try {
|
|
96
92
|
const info = (await initialization(account.id, os.homedir())).account;
|
|
97
93
|
return {
|
|
@@ -100,6 +96,7 @@ export async function accounts() {
|
|
|
100
96
|
plan: info.subscriptionType ?? null,
|
|
101
97
|
error: null,
|
|
102
98
|
limits: accountLimits(account.id),
|
|
99
|
+
linked: linked(account.configDir),
|
|
103
100
|
};
|
|
104
101
|
}
|
|
105
102
|
catch (error) {
|
|
@@ -109,26 +106,45 @@ export async function accounts() {
|
|
|
109
106
|
plan: null,
|
|
110
107
|
error: error instanceof Error ? error.message : String(error),
|
|
111
108
|
limits: accountLimits(account.id),
|
|
109
|
+
linked: linked(account.configDir),
|
|
112
110
|
};
|
|
113
111
|
}
|
|
114
112
|
}));
|
|
115
113
|
}
|
|
116
|
-
export function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
export async function publishAccounts() {
|
|
115
|
+
try {
|
|
116
|
+
broadcast({ type: 'accounts', accounts: await accounts() });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
console.warn(`could not list accounts: ${error}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function addAccount(name, configDir) {
|
|
121
123
|
saveAccounts([
|
|
122
124
|
...storedAccounts(),
|
|
123
|
-
{ id: randomUUID(), name
|
|
125
|
+
{ id: randomUUID(), ...validAccount(name, configDir) },
|
|
124
126
|
]);
|
|
125
127
|
}
|
|
128
|
+
export function updateAccount(id, name, configDir) {
|
|
129
|
+
const account = validAccount(name, configDir);
|
|
130
|
+
saveAccounts(storedAccounts().map((item) => (item.id === id ? { id, ...account } : item)));
|
|
131
|
+
forget(id);
|
|
132
|
+
}
|
|
126
133
|
export function removeAccount(id) {
|
|
127
134
|
saveAccounts(storedAccounts().filter((item) => item.id !== id));
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
forget(id);
|
|
136
|
+
}
|
|
137
|
+
export async function linkAccount(id) {
|
|
138
|
+
const account = storedAccounts().find((item) => item.id === id);
|
|
139
|
+
if (account === undefined)
|
|
140
|
+
throw new Error('That account was removed');
|
|
141
|
+
if (linked(account.configDir))
|
|
142
|
+
throw new Error('This account is already linked');
|
|
143
|
+
const base = path.join(readConfig().claudeBaseDir, 'projects');
|
|
144
|
+
const link = path.join(account.configDir, 'projects');
|
|
145
|
+
await mkdir(base, { recursive: true });
|
|
146
|
+
await rm(link, { recursive: true, force: true });
|
|
147
|
+
await symlink(base, link, 'junction');
|
|
132
148
|
}
|
|
133
149
|
export function recordLimit(accountId, info) {
|
|
134
150
|
const current = limits.get(accountId) ?? new Map();
|
|
@@ -154,6 +170,39 @@ export function recordLimit(accountId, info) {
|
|
|
154
170
|
limits.set(accountId, current);
|
|
155
171
|
return accountLimits(accountId);
|
|
156
172
|
}
|
|
173
|
+
function validAccount(name, configDir) {
|
|
174
|
+
if (name.trim() === '')
|
|
175
|
+
throw new Error('Give the account a name');
|
|
176
|
+
const dir = configDir.trim();
|
|
177
|
+
if (dir === '~' || dir.startsWith('~/'))
|
|
178
|
+
return {
|
|
179
|
+
name: name.trim(),
|
|
180
|
+
configDir: path.join(os.homedir(), dir.slice(1)),
|
|
181
|
+
};
|
|
182
|
+
if (path.isAbsolute(dir))
|
|
183
|
+
return { name: name.trim(), configDir: dir };
|
|
184
|
+
throw new Error('Use an absolute config directory or one starting with ~/');
|
|
185
|
+
}
|
|
186
|
+
function linked(configDir) {
|
|
187
|
+
const base = readConfig().claudeBaseDir;
|
|
188
|
+
try {
|
|
189
|
+
if (realpathSync(configDir) === realpathSync(base))
|
|
190
|
+
return true;
|
|
191
|
+
return (realpathSync(path.join(configDir, 'projects')) ===
|
|
192
|
+
realpathSync(path.join(base, 'projects')));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error.code === 'ENOENT')
|
|
196
|
+
return false;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function forget(accountId) {
|
|
201
|
+
for (const key of probes.keys())
|
|
202
|
+
if (key.startsWith(`${accountId}\n`))
|
|
203
|
+
probes.delete(key);
|
|
204
|
+
limits.delete(accountId);
|
|
205
|
+
}
|
|
157
206
|
function accountLimits(accountId) {
|
|
158
207
|
return [...(limits.get(accountId)?.values() ?? [])];
|
|
159
208
|
}
|
|
@@ -7,8 +7,9 @@ export const version = '0.0.0';
|
|
|
7
7
|
const configFile = path.join(home, 'config.json');
|
|
8
8
|
export const defaults = {
|
|
9
9
|
theme: schemes[0].id,
|
|
10
|
+
claudeBaseDir: path.join(os.homedir(), '.claude'),
|
|
10
11
|
composer: {
|
|
11
|
-
accountId:
|
|
12
|
+
accountId: null,
|
|
12
13
|
model: 'haiku',
|
|
13
14
|
effort: 'low',
|
|
14
15
|
permissionMode: 'bypassPermissions',
|
|
@@ -56,6 +57,7 @@ const choices = {
|
|
|
56
57
|
'terminal.behavior.optionAsMeta': ['off', 'left', 'right', 'both'],
|
|
57
58
|
};
|
|
58
59
|
const nullable = [
|
|
60
|
+
'composer.accountId',
|
|
59
61
|
'composer.model',
|
|
60
62
|
'composer.effort',
|
|
61
63
|
'composer.permissionMode',
|
|
@@ -118,6 +120,7 @@ export function saveConfig(value) {
|
|
|
118
120
|
if (error !== null)
|
|
119
121
|
throw new Error(error);
|
|
120
122
|
writeJson('config.json', value);
|
|
123
|
+
process.env.CLAUDE_CONFIG_DIR = value.claudeBaseDir;
|
|
121
124
|
broadcast({ type: 'config', config: value });
|
|
122
125
|
return value;
|
|
123
126
|
}
|
|
@@ -159,6 +162,10 @@ function invalid(value, template, keys) {
|
|
|
159
162
|
}
|
|
160
163
|
if (nullable.includes(name) && value === null)
|
|
161
164
|
return null;
|
|
165
|
+
if (name === 'claudeBaseDir')
|
|
166
|
+
return typeof value === 'string' && path.isAbsolute(value)
|
|
167
|
+
? null
|
|
168
|
+
: 'claudeBaseDir must be an absolute path';
|
|
162
169
|
if (name in choices) {
|
|
163
170
|
if (choices[name].includes(value))
|
|
164
171
|
return null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { home } from './storage.js';
|
|
5
|
+
mkdirSync(home, { recursive: true });
|
|
6
|
+
export const db = new DatabaseSync(path.join(home, 'offsite.db'));
|
|
7
|
+
db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
9
|
+
path TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
logo TEXT
|
|
12
|
+
)
|
|
13
|
+
`);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { getSessionMessages, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { readConfig } from './config.js';
|
|
5
5
|
import { contextTokens, toMessage } from './content.js';
|
|
6
6
|
import { runs } from './run.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const times = new Map();
|
|
7
|
+
async function sessionEntries(sessionId) {
|
|
8
|
+
const projectsDir = path.join(readConfig().claudeBaseDir, 'projects');
|
|
10
9
|
for (const dir of await readdir(projectsDir)) {
|
|
11
10
|
let text;
|
|
12
11
|
try {
|
|
@@ -17,17 +16,31 @@ async function messageTimes(sessionId) {
|
|
|
17
16
|
continue;
|
|
18
17
|
throw error;
|
|
19
18
|
}
|
|
20
|
-
|
|
19
|
+
return text.split('\n').flatMap((line) => {
|
|
21
20
|
const entry = parseLine(line);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
return times;
|
|
21
|
+
return entry !== null && typeof entry.type === 'string'
|
|
22
|
+
? [{ ...entry, type: entry.type }]
|
|
23
|
+
: [];
|
|
24
|
+
});
|
|
28
25
|
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
function messageTimes(entries) {
|
|
29
|
+
const times = new Map();
|
|
30
|
+
for (const entry of entries)
|
|
31
|
+
if (typeof entry.uuid === 'string' && typeof entry.timestamp === 'string')
|
|
32
|
+
times.set(entry.uuid, Date.parse(entry.timestamp));
|
|
29
33
|
return times;
|
|
30
34
|
}
|
|
35
|
+
function compactedBefore(entries, uuid) {
|
|
36
|
+
if (uuid === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
const start = entries.findIndex((entry) => entry.uuid === uuid);
|
|
39
|
+
const index = entries.findLastIndex((entry, position) => position < start &&
|
|
40
|
+
entry.type === 'system' &&
|
|
41
|
+
entry.subtype === 'compact_boundary');
|
|
42
|
+
return index < 0 ? null : index;
|
|
43
|
+
}
|
|
31
44
|
function parseLine(line) {
|
|
32
45
|
try {
|
|
33
46
|
return JSON.parse(line);
|
|
@@ -36,19 +49,22 @@ function parseLine(line) {
|
|
|
36
49
|
return null;
|
|
37
50
|
}
|
|
38
51
|
}
|
|
52
|
+
function mainMessages(disk, times) {
|
|
53
|
+
return disk.flatMap((item) => item.type === 'system' || item.parent_tool_use_id !== null
|
|
54
|
+
? []
|
|
55
|
+
: [toMessage(item.type, item, times.get(item.uuid) ?? null)]);
|
|
56
|
+
}
|
|
39
57
|
export async function history(sessionId, cwd) {
|
|
40
58
|
const run = runs.get(sessionId);
|
|
41
|
-
const [disk,
|
|
59
|
+
const [disk, entries] = await Promise.all([
|
|
42
60
|
getSessionMessages(sessionId, { dir: cwd }),
|
|
43
|
-
|
|
61
|
+
sessionEntries(sessionId),
|
|
44
62
|
]);
|
|
45
63
|
const resumeAt = run?.resumeAt ?? null;
|
|
46
64
|
const kept = resumeAt === null
|
|
47
65
|
? disk
|
|
48
66
|
: disk.slice(0, disk.findIndex((item) => item.uuid === resumeAt) + 1);
|
|
49
|
-
const messages = kept
|
|
50
|
-
? []
|
|
51
|
-
: [toMessage(item.type, item, times.get(item.uuid) ?? null)]);
|
|
67
|
+
const messages = mainMessages(kept, messageTimes(entries));
|
|
52
68
|
messages.push(...(await subagentMessages(sessionId, cwd, messages)));
|
|
53
69
|
const seen = new Set(messages.map((item) => item.uuid));
|
|
54
70
|
const last = kept.findLast((item) => item.type === 'assistant' && item.parent_tool_use_id === null);
|
|
@@ -67,8 +83,26 @@ export async function history(sessionId, cwd) {
|
|
|
67
83
|
(tokens === null
|
|
68
84
|
? null
|
|
69
85
|
: { tokens, window: null, model: null, categories: [], sections: [] }),
|
|
86
|
+
before: compactedBefore(entries, disk[0]?.uuid),
|
|
70
87
|
};
|
|
71
88
|
}
|
|
89
|
+
export async function earlier(sessionId, cwd, before) {
|
|
90
|
+
const entries = await sessionEntries(sessionId);
|
|
91
|
+
const kept = entries.slice(0, before);
|
|
92
|
+
const disk = await getSessionMessages(sessionId, {
|
|
93
|
+
sessionStore: { append: () => Promise.resolve(), load: async () => kept },
|
|
94
|
+
});
|
|
95
|
+
const messages = mainMessages(disk, messageTimes(entries));
|
|
96
|
+
messages.push(...(await subagentMessages(sessionId, cwd, messages)));
|
|
97
|
+
messages.push({
|
|
98
|
+
type: 'notice',
|
|
99
|
+
uuid: `compacted:${before}`,
|
|
100
|
+
time: null,
|
|
101
|
+
text: 'Conversation compacted',
|
|
102
|
+
isError: false,
|
|
103
|
+
});
|
|
104
|
+
return { messages, before: compactedBefore(entries, disk[0]?.uuid) };
|
|
105
|
+
}
|
|
72
106
|
async function subagentMessages(sessionId, cwd, messages) {
|
|
73
107
|
const agents = new Set(messages.flatMap((message) => message.type === 'assistant'
|
|
74
108
|
? message.content.flatMap((block) => block.type === 'tool_use' &&
|
package/apps/server/dist/main.js
CHANGED
|
@@ -4,8 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import { WebSocketServer } from 'ws';
|
|
5
5
|
import { args } from './args.js';
|
|
6
6
|
import { findClaude } from './claude.js';
|
|
7
|
+
import { readConfig } from './config.js';
|
|
7
8
|
import { startPush } from './push.js';
|
|
8
9
|
import { stopAll } from './runs.js';
|
|
10
|
+
import { watchSessions } from './sessions.js';
|
|
9
11
|
import { connectClient } from './socket.js';
|
|
10
12
|
const contentTypes = {
|
|
11
13
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -23,6 +25,8 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
|
23
25
|
console.error(`invalid port ${args.port}`);
|
|
24
26
|
process.exit(1);
|
|
25
27
|
}
|
|
28
|
+
process.env.CLAUDE_CONFIG_DIR = readConfig().claudeBaseDir;
|
|
29
|
+
watchSessions();
|
|
26
30
|
if (findClaude() === null) {
|
|
27
31
|
console.error('could not find claude on PATH; install Claude Code first');
|
|
28
32
|
process.exit(1);
|
|
@@ -44,10 +48,12 @@ server.listen(port, args.host, () => {
|
|
|
44
48
|
startPush(`${args.host}:${port}`);
|
|
45
49
|
console.log(`offsite is running at http://${args.host}:${port}`);
|
|
46
50
|
});
|
|
47
|
-
process.on('SIGINT',
|
|
51
|
+
process.on('SIGINT', stop);
|
|
52
|
+
process.on('SIGTERM', stop);
|
|
53
|
+
function stop() {
|
|
48
54
|
stopAll();
|
|
49
55
|
process.exit(0);
|
|
50
|
-
}
|
|
56
|
+
}
|
|
51
57
|
function serveFile(pathname, response) {
|
|
52
58
|
const requested = path.join(publicDir, path.normalize(pathname));
|
|
53
59
|
const file = requested.startsWith(publicDir + path.sep) &&
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { db } from './db.js';
|
|
5
|
+
import { broadcast } from './hub.js';
|
|
6
|
+
const LOGO_TYPES = [
|
|
7
|
+
'image/png',
|
|
8
|
+
'image/jpeg',
|
|
9
|
+
'image/gif',
|
|
10
|
+
'image/webp',
|
|
11
|
+
'image/svg+xml',
|
|
12
|
+
'image/x-icon',
|
|
13
|
+
'image/vnd.microsoft.icon',
|
|
14
|
+
];
|
|
15
|
+
const LOGO_LIMIT = 512 * 1024;
|
|
16
|
+
export function projects() {
|
|
17
|
+
return db
|
|
18
|
+
.prepare('SELECT path, name, logo FROM projects')
|
|
19
|
+
.all()
|
|
20
|
+
.map(toProject);
|
|
21
|
+
}
|
|
22
|
+
export async function addProject(input) {
|
|
23
|
+
const folder = path.resolve(expandHome(input.trim()));
|
|
24
|
+
if (!(await stat(folder)).isDirectory())
|
|
25
|
+
throw new Error(`${folder} is not a folder`);
|
|
26
|
+
db.prepare('INSERT OR IGNORE INTO projects (path, name, logo) VALUES (?, ?, NULL)').run(folder, path.basename(folder) || folder);
|
|
27
|
+
publish();
|
|
28
|
+
const project = projects().find((item) => item.path === folder);
|
|
29
|
+
if (project === undefined)
|
|
30
|
+
throw new Error(`Could not add ${folder}`);
|
|
31
|
+
return project;
|
|
32
|
+
}
|
|
33
|
+
export function updateProject(folder, patch) {
|
|
34
|
+
if (patch.name !== undefined) {
|
|
35
|
+
const name = patch.name.trim();
|
|
36
|
+
if (name === '')
|
|
37
|
+
throw new Error('Project name cannot be empty');
|
|
38
|
+
db.prepare('UPDATE projects SET name = ? WHERE path = ?').run(name, folder);
|
|
39
|
+
}
|
|
40
|
+
if (patch.logo !== undefined) {
|
|
41
|
+
if (patch.logo !== null)
|
|
42
|
+
checkLogo(patch.logo);
|
|
43
|
+
db.prepare('UPDATE projects SET logo = ? WHERE path = ?').run(patch.logo, folder);
|
|
44
|
+
}
|
|
45
|
+
publish();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function removeProject(folder) {
|
|
49
|
+
db.prepare('DELETE FROM projects WHERE path = ?').run(folder);
|
|
50
|
+
publish();
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export async function directories(input) {
|
|
54
|
+
const folder = path.resolve(expandHome(input.trim()));
|
|
55
|
+
const entries = await readdir(folder, { withFileTypes: true });
|
|
56
|
+
const parent = path.dirname(folder);
|
|
57
|
+
return {
|
|
58
|
+
path: folder,
|
|
59
|
+
parent: parent === folder ? null : parent,
|
|
60
|
+
directories: entries
|
|
61
|
+
.filter((entry) => entry.isDirectory())
|
|
62
|
+
.map((entry) => ({
|
|
63
|
+
name: entry.name,
|
|
64
|
+
path: path.join(folder, entry.name),
|
|
65
|
+
}))
|
|
66
|
+
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function publish() {
|
|
70
|
+
broadcast({ type: 'projects', projects: projects() });
|
|
71
|
+
}
|
|
72
|
+
function toProject(row) {
|
|
73
|
+
if (typeof row.path !== 'string' ||
|
|
74
|
+
typeof row.name !== 'string' ||
|
|
75
|
+
(typeof row.logo !== 'string' && row.logo !== null))
|
|
76
|
+
throw new Error('The projects table has an unexpected row');
|
|
77
|
+
return { path: row.path, name: row.name, logo: row.logo };
|
|
78
|
+
}
|
|
79
|
+
function checkLogo(logo) {
|
|
80
|
+
const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
|
|
81
|
+
if (type === undefined || !LOGO_TYPES.includes(type))
|
|
82
|
+
throw new Error('Use a PNG, JPEG, GIF, WebP, SVG or ICO image');
|
|
83
|
+
if (logo.length > LOGO_LIMIT)
|
|
84
|
+
throw new Error('Use an image smaller than 384 KB');
|
|
85
|
+
}
|
|
86
|
+
function expandHome(input) {
|
|
87
|
+
if (input === '~')
|
|
88
|
+
return os.homedir();
|
|
89
|
+
if (input.startsWith('~/'))
|
|
90
|
+
return path.join(os.homedir(), input.slice(2));
|
|
91
|
+
return input;
|
|
92
|
+
}
|
package/apps/server/dist/run.js
CHANGED
|
@@ -235,13 +235,17 @@ export class Run {
|
|
|
235
235
|
text: message.event.delta.text,
|
|
236
236
|
});
|
|
237
237
|
return;
|
|
238
|
-
case 'rate_limit_event':
|
|
238
|
+
case 'rate_limit_event': {
|
|
239
|
+
const accountId = this.settings.accountId;
|
|
240
|
+
if (accountId === null)
|
|
241
|
+
throw new Error('Rate limit event for a run without an account');
|
|
239
242
|
broadcast({
|
|
240
243
|
type: 'limits',
|
|
241
|
-
accountId
|
|
242
|
-
limits: recordLimit(
|
|
244
|
+
accountId,
|
|
245
|
+
limits: recordLimit(accountId, message.rate_limit_info),
|
|
243
246
|
});
|
|
244
247
|
return;
|
|
248
|
+
}
|
|
245
249
|
case 'result':
|
|
246
250
|
if (message.subtype !== 'success')
|
|
247
251
|
this.add(notice(message.errors.join('\n') || message.subtype));
|
|
@@ -1,22 +1,57 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { deleteSession, forkSession, getSessionMessages, listSessions, renameSession, } from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { readConfig } from './config.js';
|
|
5
|
+
import { broadcast } from './hub.js';
|
|
6
|
+
import { projects } from './projects.js';
|
|
2
7
|
import { runs } from './run.js';
|
|
3
8
|
import { readJson, writeJson } from './storage.js';
|
|
9
|
+
let watcher = null;
|
|
10
|
+
let timer;
|
|
4
11
|
export async function sessions() {
|
|
5
|
-
const items = await listSessions({ includeProgrammatic: false });
|
|
6
12
|
const marks = readMarks();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const lists = await Promise.all(projects().map(async (project) => (await listSessions({
|
|
14
|
+
dir: project.path,
|
|
15
|
+
includeWorktrees: false,
|
|
16
|
+
includeProgrammatic: false,
|
|
17
|
+
})).map((item) => ({
|
|
18
|
+
id: item.sessionId,
|
|
19
|
+
title: item.summary,
|
|
20
|
+
cwd: project.path,
|
|
21
|
+
branch: item.gitBranch ?? null,
|
|
22
|
+
updatedAt: item.lastModified,
|
|
23
|
+
pinned: marks.pinned.includes(item.sessionId),
|
|
24
|
+
archived: marks.archived.includes(item.sessionId),
|
|
25
|
+
}))));
|
|
26
|
+
return lists.flat();
|
|
27
|
+
}
|
|
28
|
+
export async function publishSessions() {
|
|
29
|
+
try {
|
|
30
|
+
broadcast({ type: 'sessions', sessions: await sessions() });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.warn(`could not list threads: ${error}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function watchSessions() {
|
|
37
|
+
watcher?.close();
|
|
38
|
+
watcher = null;
|
|
39
|
+
const dir = path.join(readConfig().claudeBaseDir, 'projects');
|
|
40
|
+
try {
|
|
41
|
+
watcher = watch(dir, { recursive: true }, () => {
|
|
42
|
+
timer ??= setTimeout(() => {
|
|
43
|
+
timer = undefined;
|
|
44
|
+
void publishSessions();
|
|
45
|
+
}, 1000);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== 'ENOENT')
|
|
50
|
+
throw error;
|
|
51
|
+
console.warn(`${dir} does not exist; threads changed outside offsite will not show`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
watcher.on('error', (error) => console.warn(`stopped watching ${dir}: ${error}`));
|
|
20
55
|
}
|
|
21
56
|
export async function updateSession(id, patch) {
|
|
22
57
|
if (patch.title !== undefined)
|