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.
Files changed (93) hide show
  1. package/README.md +3 -3
  2. package/apps/server/dist/claude.js +77 -28
  3. package/apps/server/dist/config.js +8 -1
  4. package/apps/server/dist/db.js +13 -0
  5. package/apps/server/dist/history.js +50 -16
  6. package/apps/server/dist/main.js +8 -2
  7. package/apps/server/dist/projects.js +92 -0
  8. package/apps/server/dist/run.js +7 -3
  9. package/apps/server/dist/sessions.js +49 -14
  10. package/apps/server/dist/socket.js +71 -17
  11. package/apps/server/dist/storage.js +50 -1
  12. package/apps/web/dist/assets/{angular-html-_TZra42G.js → angular-html-ji4UO7hp.js} +1 -1
  13. package/apps/web/dist/assets/{angular-ts-CVVZOQsp.js → angular-ts-CkobLhDa.js} +1 -1
  14. package/apps/web/dist/assets/{apl-Cyzt__4P.js → apl-DAjPJTs4.js} +1 -1
  15. package/apps/web/dist/assets/{astro-DvoD4nYz.js → astro-DlwA-zmc.js} +1 -1
  16. package/apps/web/dist/assets/{blade-SFrvNJcM.js → blade-wTlHdbgB.js} +1 -1
  17. package/apps/web/dist/assets/{c-BzXljoEl.js → c-HyCIpwKL.js} +1 -1
  18. package/apps/web/dist/assets/{chapel-CSqeAvDr.js → chapel-SOYLpFSa.js} +1 -1
  19. package/apps/web/dist/assets/{cobol-DnK1h_4j.js → cobol-DlvLD04V.js} +1 -1
  20. package/apps/web/dist/assets/{coffee-BrM2hs_V.js → coffee-CGp5oJTY.js} +1 -1
  21. package/apps/web/dist/assets/{cpp-C_7gVSp4.js → cpp-CzmH13Nm.js} +1 -1
  22. package/apps/web/dist/assets/{crystal-DbpY_y_D.js → crystal-zS14MbR_.js} +1 -1
  23. package/apps/web/dist/assets/{css-CcKWTG1c.js → css-DQEQn59R.js} +1 -1
  24. package/apps/web/dist/assets/{edge-CqcnepJa.js → edge-Bkv0KzZU.js} +1 -1
  25. package/apps/web/dist/assets/{elixir-DG0UMMTO.js → elixir-B8tse0js.js} +1 -1
  26. package/apps/web/dist/assets/{elm-D81_16hi.js → elm-F2InAxd7.js} +1 -1
  27. package/apps/web/dist/assets/{erb-BHb0Ji2T.js → erb-CAIamwOm.js} +1 -1
  28. package/apps/web/dist/assets/{git-rebase-Bzcz_AWI.js → git-rebase-U0z47rUi.js} +1 -1
  29. package/apps/web/dist/assets/{glimmer-js-BbGaPXN7.js → glimmer-js-Bg_zPoN-.js} +1 -1
  30. package/apps/web/dist/assets/{glimmer-ts-B2OBt1Cw.js → glimmer-ts-v3suqAPH.js} +1 -1
  31. package/apps/web/dist/assets/{glsl-Cuu3Xc_3.js → glsl-8io-Pcmd.js} +1 -1
  32. package/apps/web/dist/assets/{graphql-XBGrkq1T.js → graphql-B_q9cV-C.js} +1 -1
  33. package/apps/web/dist/assets/{hack-D-n58M0B.js → hack-DosfNWDX.js} +1 -1
  34. package/apps/web/dist/assets/{haml-06aMoWFC.js → haml-DcBtkGn5.js} +1 -1
  35. package/apps/web/dist/assets/{handlebars-BSCSPRHp.js → handlebars-2F0KxPFu.js} +1 -1
  36. package/apps/web/dist/assets/{html-CRYhxgas.js → html-DrWZZyp3.js} +1 -1
  37. package/apps/web/dist/assets/{html-derivative-El-EKb6H.js → html-derivative-BaV8vDp-.js} +1 -1
  38. package/apps/web/dist/assets/{http-Cj-Wbb2c.js → http-Dwo7FxJA.js} +1 -1
  39. package/apps/web/dist/assets/{hurl--dF5MzC1.js → hurl-9r6SjmMP.js} +1 -1
  40. package/apps/web/dist/assets/index-CIBt6t2M.css +2 -0
  41. package/apps/web/dist/assets/index-CPpMMYyZ.js +902 -0
  42. package/apps/web/dist/assets/{java-_zuIdTC3.js → java-CAkt7S2h.js} +1 -1
  43. package/apps/web/dist/assets/{javascript-Dw2bor_9.js → javascript-CMo8GEjU.js} +1 -1
  44. package/apps/web/dist/assets/{jinja-CtYhL6FS.js → jinja-XhRt7Tzv.js} +1 -1
  45. package/apps/web/dist/assets/{jison-BS6VCOAt.js → jison-BXZQEb4C.js} +1 -1
  46. package/apps/web/dist/assets/{json-D6Mznwzn.js → json-SyJDQmRp.js} +1 -1
  47. package/apps/web/dist/assets/{jsx-DLuxOqjU.js → jsx-B76-zKzH.js} +1 -1
  48. package/apps/web/dist/assets/{julia-CMgTtdec.js → julia-Ca5lbuMA.js} +1 -1
  49. package/apps/web/dist/assets/{just-D_e2Cdol.js → just-CDt2RojJ.js} +1 -1
  50. package/apps/web/dist/assets/{latex-C7F9cbcZ.js → latex-D9bybvWo.js} +1 -1
  51. package/apps/web/dist/assets/{liquid-DZ0eHbL-.js → liquid-DMp-tByG.js} +1 -1
  52. package/apps/web/dist/assets/{lua-DA_XbnYa.js → lua-zNnfh_4o.js} +1 -1
  53. package/apps/web/dist/assets/{marko-BOjbwMe_.js → marko-Ce5B11L0.js} +1 -1
  54. package/apps/web/dist/assets/{mdc-BLQdrSL9.js → mdc-Btapt9E8.js} +1 -1
  55. package/apps/web/dist/assets/{nginx-CRuAoyv9.js → nginx-Bzjverzh.js} +1 -1
  56. package/apps/web/dist/assets/{nim--m3L_KpX.js → nim-D6zpNhUX.js} +1 -1
  57. package/apps/web/dist/assets/{org-BNxSv-BH.js → org-DTy-FvFD.js} +1 -1
  58. package/apps/web/dist/assets/{perl-DEB_cAoG.js → perl-BV8Dxusy.js} +1 -1
  59. package/apps/web/dist/assets/{php-Cei4P5ds.js → php-BjkIa26S.js} +1 -1
  60. package/apps/web/dist/assets/{pug-BtkQ8Y8B.js → pug-DdfmTHk6.js} +1 -1
  61. package/apps/web/dist/assets/{qml-yI-6sShm.js → qml-BWLYImhB.js} +1 -1
  62. package/apps/web/dist/assets/{r-CJ8zo4yy.js → r-CKc9klse.js} +1 -1
  63. package/apps/web/dist/assets/{razor-CTPI_hHO.js → razor-YcF2a59Y.js} +1 -1
  64. package/apps/web/dist/assets/{regexp-j43xPEIH.js → regexp-D3esVJ1X.js} +1 -1
  65. package/apps/web/dist/assets/{rst-CfIeFcNC.js → rst-DVaj4AQD.js} +1 -1
  66. package/apps/web/dist/assets/{ruby-CtmudUkS.js → ruby-DBJQEVev.js} +1 -1
  67. package/apps/web/dist/assets/{sas-BBgd1Oa5.js → sas-NSuLkGD1.js} +1 -1
  68. package/apps/web/dist/assets/{scss-BOSyZrIf.js → scss-TMy8m-so.js} +1 -1
  69. package/apps/web/dist/assets/{shellscript-BES_U9_l.js → shellscript-BVuIsFkY.js} +1 -1
  70. package/apps/web/dist/assets/{shellsession-CdF1AgdN.js → shellsession-CV7MHIu6.js} +1 -1
  71. package/apps/web/dist/assets/{soy-hqjXYWz3.js → soy-DDY0EoKE.js} +1 -1
  72. package/apps/web/dist/assets/{sql-D5hYy2r7.js → sql-CoZqqtiv.js} +1 -1
  73. package/apps/web/dist/assets/{stata-GNNI7e-r.js → stata-B8C1noIb.js} +1 -1
  74. package/apps/web/dist/assets/{surrealql-gm8__PDR.js → surrealql-BYiUJt-V.js} +1 -1
  75. package/apps/web/dist/assets/{svelte-DK6xQ-Vs.js → svelte-DGeZ8dPs.js} +1 -1
  76. package/apps/web/dist/assets/{templ-Dy-PHEH0.js → templ-Dn21SE_C.js} +1 -1
  77. package/apps/web/dist/assets/{tex-gYu3IM_T.js → tex-Da9GBCsM.js} +1 -1
  78. package/apps/web/dist/assets/{ts-tags-C8akqtaV.js → ts-tags-BuL1T6K3.js} +1 -1
  79. package/apps/web/dist/assets/{tsx-CtF99HYc.js → tsx-DiLCnfgl.js} +1 -1
  80. package/apps/web/dist/assets/{twig-D7-O9s37.js → twig-Cx1peiH-.js} +1 -1
  81. package/apps/web/dist/assets/{typescript-o4QHcKOF.js → typescript-pVxvHJ4R.js} +1 -1
  82. package/apps/web/dist/assets/{typst-CsLzgkYX.js → typst-CJQbitzj.js} +1 -1
  83. package/apps/web/dist/assets/{vue-C67X0ZYE.js → vue-CXKyVnbB.js} +1 -1
  84. package/apps/web/dist/assets/{vue-html-3wcw93oK.js → vue-html-AQjnBMTh.js} +1 -1
  85. package/apps/web/dist/assets/{vue-vine-Bq8-4EKU.js → vue-vine-DYxt9AbK.js} +1 -1
  86. package/apps/web/dist/assets/{xml-DXCI-6Wi.js → xml-CEJ01uJ1.js} +1 -1
  87. package/apps/web/dist/assets/{xsl-Dr0qfgBg.js → xsl-CULFBy6v.js} +1 -1
  88. package/apps/web/dist/assets/{yaml-DHhOWIuf.js → yaml-BNf68xM_.js} +1 -1
  89. package/apps/web/dist/index.html +2 -2
  90. package/package.json +2 -1
  91. package/apps/server/dist/folders.js +0 -12
  92. package/apps/web/dist/assets/index-CN9uEzWa.js +0 -899
  93. 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`. Set `CLAUDE_CONFIG_DIR` too if it should have its own Claude Code sessions and login.
28
+ Give each instance its own port and `--dir`.
29
29
 
30
30
  ```sh
31
- CLAUDE_CONFIG_DIR=~/.claude-work npx offsite --port 47850 --dir ~/.offsite-work
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` and `~/.claude-test`, apart from your main instance.
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
- const env = {
25
- ...process.env,
26
- CLAUDE_CODE_ENTRYPOINT: 'offsite',
27
- };
28
- if (accountId !== DEFAULT_ACCOUNT) {
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
- const all = [
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 addAccount(name, token) {
117
- if (name.trim() === '')
118
- throw new Error('Give the account a name');
119
- if (!token.trim().startsWith('sk-ant-'))
120
- throw new Error('Paste the token printed by `claude setup-token`');
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: name.trim(), token: token.trim() },
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
- for (const key of probes.keys())
129
- if (key.startsWith(`${id}\n`))
130
- probes.delete(key);
131
- limits.delete(id);
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: 'default',
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
- const projectsDir = path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'), 'projects');
8
- async function messageTimes(sessionId) {
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
- for (const line of text.split('\n')) {
19
+ return text.split('\n').flatMap((line) => {
21
20
  const entry = parseLine(line);
22
- if (entry !== null &&
23
- typeof entry.uuid === 'string' &&
24
- typeof entry.timestamp === 'string')
25
- times.set(entry.uuid, Date.parse(entry.timestamp));
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, times] = await Promise.all([
59
+ const [disk, entries] = await Promise.all([
42
60
  getSessionMessages(sessionId, { dir: cwd }),
43
- messageTimes(sessionId),
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.flatMap((item) => item.type === 'system' || item.parent_tool_use_id !== null
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' &&
@@ -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
+ }
@@ -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: this.settings.accountId,
242
- limits: recordLimit(this.settings.accountId, message.rate_limit_info),
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
- return items.flatMap((item) => item.cwd === undefined
8
- ? []
9
- : [
10
- {
11
- id: item.sessionId,
12
- title: item.summary,
13
- cwd: item.cwd,
14
- branch: item.gitBranch ?? null,
15
- updatedAt: item.lastModified,
16
- pinned: marks.pinned.includes(item.sessionId),
17
- archived: marks.archived.includes(item.sessionId),
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)