agent-office-studio 0.4.0 → 0.6.0
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 +24 -0
- package/README.md +0 -1
- package/dist/assets/index-B9ZRfVrF.js +4475 -0
- package/dist/assets/index-BiK6nglq.css +1 -0
- package/dist/index.html +10 -23
- package/docs/README.md +0 -1
- package/docs/claude-access.md +1 -2
- package/docs/configuration.md +2 -6
- package/docs/getting-started.md +0 -1
- package/docs/guides/troubleshooting.md +0 -10
- package/docs/guides/updating.md +0 -2
- package/docs/how-it-works.md +0 -2
- package/docs/linked-offices.md +61 -8
- package/docs/packaging.md +3 -4
- package/docs/phone.md +1 -2
- package/docs/security.md +1 -7
- package/docs/using-the-office.md +1 -1
- package/package.json +2 -7
- package/server/guests.js +315 -7
- package/server/icons.js +82 -0
- package/server/index.js +237 -42
- package/server/links.js +48 -9
- package/server/office-mcp.js +19 -0
- package/server/settings.js +1 -1
- package/server/visits.js +475 -0
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +0 -1
- package/dist/assets/addon-fit-BwprjgOP.js +0 -1
- package/dist/assets/index-CiEWLydu.css +0 -32
- package/dist/assets/index-S_6NhxZ0.js +0 -4457
- package/dist/assets/xterm-C7kOH91j.js +0 -9
- package/docs/terminal.md +0 -46
- package/server/terminal.js +0 -255
package/server/office-mcp.js
CHANGED
|
@@ -63,6 +63,25 @@ const TOOLS = [
|
|
|
63
63
|
},
|
|
64
64
|
];
|
|
65
65
|
|
|
66
|
+
// A job whose project is at another office (OFFICE_FILES=read|edit) reaches
|
|
67
|
+
// it only through these; the office passes each call to that office.
|
|
68
|
+
const FILES = process.env.OFFICE_FILES;
|
|
69
|
+
const relPath = { type: 'string', description: 'Path from the project root, like src/app.js' };
|
|
70
|
+
if (FILES === 'read' || FILES === 'edit') {
|
|
71
|
+
TOOLS.push(
|
|
72
|
+
{ name: 'files_list', description: 'List a folder of the project (folders end in /). depth 1-4, default 1.', inputSchema: obj({ path: relPath, depth: { type: 'integer' } }, []) },
|
|
73
|
+
{ name: 'files_read', description: 'Read a text file with line numbers. offset is the first line (from 1), limit how many; long files come in pieces.', inputSchema: obj({ path: relPath, offset: { type: 'integer' }, limit: { type: 'integer' } }, ['path']) },
|
|
74
|
+
{ name: 'files_search', description: 'Search file contents with an extended regex (grep -E). Optional path to narrow it and glob to filter names, like *.js.', inputSchema: obj({ pattern: { type: 'string' }, path: relPath, glob: { type: 'string' } }, ['pattern']) },
|
|
75
|
+
{ name: 'files_find', description: 'Find files by name pattern, like **/*.test.js or package.json.', inputSchema: obj({ pattern: { type: 'string' } }, ['pattern']) },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (FILES === 'edit') {
|
|
79
|
+
TOOLS.push(
|
|
80
|
+
{ name: 'files_write', description: 'Create or overwrite a file (about 40 KB a call). append adds to the end instead, for longer files.', inputSchema: obj({ path: relPath, content: { type: 'string' }, append: { type: 'boolean' } }, ['path', 'content']) },
|
|
81
|
+
{ name: 'files_edit', description: 'Replace exact text in a file. old must match exactly once, unless all is set.', inputSchema: obj({ path: relPath, old: { type: 'string' }, new: { type: 'string' }, all: { type: 'boolean' } }, ['path', 'old', 'new']) },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
const out = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
67
86
|
|
|
68
87
|
async function call(name, args) {
|
package/server/settings.js
CHANGED
|
@@ -12,7 +12,7 @@ export const SETTINGS = [
|
|
|
12
12
|
{ key: 'owner', group: 'Office', label: 'Office owner', kind: 'text', max: 40, hint: 'Whose office this is. It names the office in the header. Blank uses the name on this account.', fallback: () => String(env('OFFICE_OWNER') || '').trim() },
|
|
13
13
|
{ key: 'shareTitles', group: 'Office', label: 'Share job titles with linked offices', kind: 'bool', hint: 'Off: linked offices only see that an agent is busy.', fallback: () => env('OFFICE_SHARE_JOB_TITLES') === '1' },
|
|
14
14
|
|
|
15
|
-
{ key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it
|
|
15
|
+
{ key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it; if it does not, the office clears it. 0 turns this off.', fallback: () => Math.max(0, parseInt(env('AGENT_CLEAR_TOKENS') ?? '150000', 10) || 0) },
|
|
16
16
|
{ key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
|
|
17
17
|
{ key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
|
|
18
18
|
|
package/server/visits.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// Visits: a guest office's own agents working in one of our projects. The
|
|
2
|
+
// agent runs on the guest's machine and the guest's Claude account; only its
|
|
3
|
+
// file tools reach us, as `visit.*` requests over the guest link. We answer
|
|
4
|
+
// them here, against the limits on the link (Guests checks the policy first).
|
|
5
|
+
//
|
|
6
|
+
// An agent never sees the project folder itself. Each visit gets a scratch
|
|
7
|
+
// copy under data/visits/<id>: the working tree as it stood (tracked and
|
|
8
|
+
// untracked files, not ignored ones, so .env and node_modules stay home),
|
|
9
|
+
// checked out from a throwaway index. Every path is held inside that copy,
|
|
10
|
+
// symlinks included, and .git and key files are refused. What the agent
|
|
11
|
+
// changes stays in the copy until the boss applies it from the job card.
|
|
12
|
+
//
|
|
13
|
+
// Each visit shows in our job list as a job of its own (`visit-<hex>`), with a
|
|
14
|
+
// log of every file the agent touched.
|
|
15
|
+
// See docs/linked-offices.md, "Their agents in your projects".
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFile, spawn } from 'node:child_process';
|
|
22
|
+
import { snapshotTree, summarize, fileDiff } from './changes.js';
|
|
23
|
+
|
|
24
|
+
export const ACCESS = ['off', 'read', 'edit'];
|
|
25
|
+
const MAX_OPEN = 3; // working visits per guest link
|
|
26
|
+
const MAX_READ = 24 * 1024; // what one answer carries, under the relay's 64 KB a frame
|
|
27
|
+
const MAX_WRITE = 40 * 1024; // what one write may carry, likewise
|
|
28
|
+
const MAX_FILE = 2 * 1024 * 1024; // the most a file may grow to
|
|
29
|
+
const MAX_WRITTEN = 50 * 1024 * 1024; // the most one visit may write in all
|
|
30
|
+
const MAX_LIST = 400;
|
|
31
|
+
const MAX_LOG = 300;
|
|
32
|
+
const SEARCH_MS = 10_000;
|
|
33
|
+
const IDLE_MS = 60 * 60 * 1000; // a visit nobody has used for this long is closed
|
|
34
|
+
const VISIT_ID = /^visit-[0-9a-f]{8}$/;
|
|
35
|
+
// Never read or written, wherever they sit in the copy.
|
|
36
|
+
const SECRET = [/^\.env(\..*)?$/i, /\.(pem|key|p12|pfx|keystore|jks)$/i, /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, /^\.(npmrc|netrc|pypirc)$/i];
|
|
37
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
38
|
+
|
|
39
|
+
const git = (cwd, args, env = {}, input) => new Promise((resolve, reject) => {
|
|
40
|
+
// As in changes.js: nothing the project's config names should run.
|
|
41
|
+
const child = execFile('git', ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', ...args], { cwd, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024, timeout: 120_000 }, (err, out, errOut) => {
|
|
42
|
+
if (err) reject(Object.assign(err, { stderr: String(errOut || '') })); else resolve(out);
|
|
43
|
+
});
|
|
44
|
+
if (input !== undefined) child.stdin.end(input);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const tmpIndex = () => path.join(os.tmpdir(), `office-visit-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
|
48
|
+
|
|
49
|
+
// What the boss's page shows of a visit: job-shaped, so it sits in the job
|
|
50
|
+
// list. The capability (sid), the copy's path and the base stay here.
|
|
51
|
+
export function visitJob(v, tail = 40) {
|
|
52
|
+
const { sid: _s, dir: _d, base: _b, theirJob: _t, written: _w, usedAt: _u, ...rest } = v;
|
|
53
|
+
return { ...rest, log: v.log.slice(-tail) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Visits {
|
|
57
|
+
// office: { repoPath(name), send(msg), post(text, extra), feed(linkId, msg) }
|
|
58
|
+
constructor(dataDir, { office }) {
|
|
59
|
+
this.office = office;
|
|
60
|
+
this.root = path.join(dataDir, 'visits');
|
|
61
|
+
this.file = path.join(dataDir, 'visits.json');
|
|
62
|
+
this.list = [];
|
|
63
|
+
try { this.list = JSON.parse(fs.readFileSync(this.file, 'utf8')).visits || []; } catch {}
|
|
64
|
+
this.sweeper = setInterval(() => this.sweep(), 5 * 60 * 1000);
|
|
65
|
+
this.sweeper.unref?.();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
save() {
|
|
69
|
+
try {
|
|
70
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
71
|
+
fs.writeFileSync(this.file + '.tmp', JSON.stringify({ visits: this.list }), { mode: 0o600 });
|
|
72
|
+
fs.renameSync(this.file + '.tmp', this.file);
|
|
73
|
+
} catch (err) { console.error('[visits] could not save', err.message); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
push(v) {
|
|
77
|
+
this.office.send({ t: 'job', job: visitJob(v, 0) });
|
|
78
|
+
this.save();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
log(v, summary, kind = 'tool', extra = {}) {
|
|
82
|
+
const entry = { kind, summary, ts: Date.now(), ...extra };
|
|
83
|
+
v.log.push(entry);
|
|
84
|
+
if (v.log.length > MAX_LOG) v.log.splice(0, v.log.length - MAX_LOG);
|
|
85
|
+
this.office.send({ t: 'log', jobId: v.id, entry });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
jobs() { return this.list.map((v) => visitJob(v)); }
|
|
89
|
+
get(id) { return VISIT_ID.test(String(id)) ? this.list.find((v) => v.id === id) || null : null; }
|
|
90
|
+
|
|
91
|
+
// ---------- requests from the guest's office ----------
|
|
92
|
+
|
|
93
|
+
// A guest's agent starting (or, on a follow-up, going back to) work in one
|
|
94
|
+
// of our projects. The same job of theirs gets the same copy until the
|
|
95
|
+
// boss applies or throws it away, so a reply carries on where it stopped.
|
|
96
|
+
async open(link, policy, args) {
|
|
97
|
+
const repo = String(args.repo ?? '');
|
|
98
|
+
if (!policy.repos.includes(repo)) throw fail(403, 'That project isn’t shared with you.');
|
|
99
|
+
if (!ACCESS.includes(policy.theirAgents) || policy.theirAgents === 'off') throw fail(403, 'This office doesn’t let your agents work in its projects.');
|
|
100
|
+
const theirJob = String(args.job ?? '').slice(0, 64);
|
|
101
|
+
const agent = { name: String(args.agent?.name ?? '').slice(0, 40) || 'Their agent', role: String(args.agent?.role ?? '').slice(0, 60) };
|
|
102
|
+
const title = String(args.title ?? '').slice(0, 200) || 'Work in progress';
|
|
103
|
+
const access = policy.theirAgents;
|
|
104
|
+
let v = this.list.find((x) => x.guest.link === link.id && x.theirJob === theirJob && x.repo === repo && !x.applied && !x.discarded);
|
|
105
|
+
if (v && v.status === 'working') return { sid: v.sid, access: v.access };
|
|
106
|
+
if (this.list.filter((x) => x.guest.link === link.id && x.status === 'working').length >= MAX_OPEN) {
|
|
107
|
+
throw fail(429, `You can have ${MAX_OPEN} agents working here at once. Wait for one to finish.`);
|
|
108
|
+
}
|
|
109
|
+
if (v && fs.existsSync(v.dir)) {
|
|
110
|
+
Object.assign(v, { status: 'working', access, sid: crypto.randomBytes(16).toString('hex'), endedAt: null, error: null, usedAt: Date.now(), title });
|
|
111
|
+
this.log(v, `${agent.name} came back to it.`, 'system');
|
|
112
|
+
this.push(v);
|
|
113
|
+
return { sid: v.sid, access };
|
|
114
|
+
}
|
|
115
|
+
const src = this.office.repoPath(repo);
|
|
116
|
+
if (!src) throw fail(404, 'That project folder is gone.');
|
|
117
|
+
const base = await snapshotTree(src);
|
|
118
|
+
if (!base) throw fail(409, `${repo} isn’t a git repository, so it can’t be shared with visiting agents.`);
|
|
119
|
+
const id = `visit-${crypto.randomBytes(4).toString('hex')}`;
|
|
120
|
+
const dir = path.join(this.root, id);
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
const idx = tmpIndex();
|
|
123
|
+
try {
|
|
124
|
+
await git(src, ['read-tree', base], { GIT_INDEX_FILE: idx });
|
|
125
|
+
await git(src, [`--work-tree=${dir}`, 'checkout-index', '-a', '-f'], { GIT_INDEX_FILE: idx });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
throw fail(500, `Could not copy ${repo}: ${String(err.stderr || err.message).slice(0, 200)}`);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(idx, { force: true });
|
|
131
|
+
}
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
v = {
|
|
134
|
+
id, repo, title, mode: access === 'edit' ? 'edit' : 'plan', access, status: 'working',
|
|
135
|
+
createdAt: now, queuedAt: now, startedAt: now, endedAt: null, agentId: null,
|
|
136
|
+
visitor: { ...agent, handle: link.peer }, from: `@${link.peer}`, guest: { link: link.id },
|
|
137
|
+
turns: 0, tools: 0, error: null, changes: null, applied: null, discarded: null, log: [],
|
|
138
|
+
sid: crypto.randomBytes(16).toString('hex'), dir, base, theirJob, written: 0, usedAt: now,
|
|
139
|
+
};
|
|
140
|
+
this.list.push(v);
|
|
141
|
+
this.log(v, `${agent.name} from @${link.peer} started on a copy of ${repo} (${access === 'edit' ? 'can edit the copy' : 'read only'}): ${title}`, 'system');
|
|
142
|
+
this.push(v);
|
|
143
|
+
this.office.post(`**Visitor** · ${agent.name} from @${link.peer} is working in a copy of ${repo}, ${access === 'edit' ? 'and can edit it. Nothing reaches the real folder until you apply it.' : 'read only.'} “${title}”`, { jobId: id });
|
|
144
|
+
return { sid: v.sid, access };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The visit a request names, if it's this guest's and still open.
|
|
148
|
+
session(link, sid) {
|
|
149
|
+
const v = this.list.find((x) => x.guest.link === link.id && x.sid && x.sid === String(sid ?? ''));
|
|
150
|
+
if (!v) throw fail(404, 'That work session has ended. Start the job again.');
|
|
151
|
+
if (v.status !== 'working') throw fail(410, v.status === 'stopped' ? 'The project’s owner stopped this. Stop work and say so.' : 'That work session has ended.');
|
|
152
|
+
v.usedAt = Date.now();
|
|
153
|
+
return v;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async fs(link, args, policy) {
|
|
157
|
+
const v = this.session(link, args.sid);
|
|
158
|
+
if (policy && !policy.repos.includes(v.repo)) {
|
|
159
|
+
await this.end(v, 'stopped', '', 'Stopped: that project isn’t shared with them any more.');
|
|
160
|
+
throw fail(403, 'That project isn’t shared with you any more.');
|
|
161
|
+
}
|
|
162
|
+
if (policy?.theirAgents === 'read' && v.access === 'edit') {
|
|
163
|
+
v.access = 'read';
|
|
164
|
+
this.log(v, 'The owner made this read only.', 'system');
|
|
165
|
+
}
|
|
166
|
+
const op = String(args.op ?? '');
|
|
167
|
+
v.tools += 1;
|
|
168
|
+
try {
|
|
169
|
+
const text = await this.run(v, op, args);
|
|
170
|
+
return { text };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.log(v, `${op} refused: ${err.message}`, 'error');
|
|
173
|
+
throw err.code ? err : fail(400, err.message);
|
|
174
|
+
} finally {
|
|
175
|
+
this.save();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async run(v, op, args) {
|
|
180
|
+
const rel = typeof args.path === 'string' ? args.path : '';
|
|
181
|
+
switch (op) {
|
|
182
|
+
case 'list': {
|
|
183
|
+
const { abs, shown } = this.resolve(v, rel || '.');
|
|
184
|
+
const depth = Math.min(Math.max(Number.isInteger(args.depth) ? args.depth : 1, 1), 4);
|
|
185
|
+
const out = [];
|
|
186
|
+
this.walk(v, abs, depth, out);
|
|
187
|
+
this.log(v, `Listed ${shown}`);
|
|
188
|
+
if (!out.length) return '(empty)';
|
|
189
|
+
return out.slice(0, MAX_LIST).join('\n') + (out.length > MAX_LIST ? `\n… ${out.length - MAX_LIST} more. List a subfolder.` : '');
|
|
190
|
+
}
|
|
191
|
+
case 'read': {
|
|
192
|
+
const { abs, shown } = this.resolve(v, rel);
|
|
193
|
+
const st = this.stat(abs, shown);
|
|
194
|
+
if (!st.isFile()) throw fail(400, `${shown} is not a file.`);
|
|
195
|
+
if (st.size > MAX_FILE) throw fail(413, `${shown} is ${Math.round(st.size / 1024)} KB, too big to read here.`);
|
|
196
|
+
const buf = fs.readFileSync(abs);
|
|
197
|
+
if (buf.subarray(0, 8000).includes(0)) return `${shown} is a binary file (${st.size} bytes).`;
|
|
198
|
+
const lines = buf.toString('utf8').split('\n');
|
|
199
|
+
const from = Math.max(Number.isInteger(args.offset) ? args.offset : 1, 1);
|
|
200
|
+
const want = Math.min(Math.max(Number.isInteger(args.limit) ? args.limit : 2000, 1), 2000);
|
|
201
|
+
let out = '', i = from - 1;
|
|
202
|
+
for (; i < lines.length && i < from - 1 + want; i++) {
|
|
203
|
+
const row = `${String(i + 1).padStart(6)}\t${lines[i].length > 2000 ? `${lines[i].slice(0, 2000)}…` : lines[i]}\n`;
|
|
204
|
+
if (out.length + row.length > MAX_READ) break;
|
|
205
|
+
out += row;
|
|
206
|
+
}
|
|
207
|
+
this.log(v, `Read ${shown}`);
|
|
208
|
+
if (i < lines.length) out += `… ${lines.length - i} more lines. Read on with offset ${i + 1}.`;
|
|
209
|
+
return out || `(${shown} is empty${from > 1 ? ' past that line' : ''})`;
|
|
210
|
+
}
|
|
211
|
+
case 'search': {
|
|
212
|
+
const pattern = String(args.pattern ?? '');
|
|
213
|
+
if (!pattern || pattern.length > 500) throw fail(400, 'Give a pattern to search for.');
|
|
214
|
+
const { abs, shown } = this.resolve(v, rel || '.');
|
|
215
|
+
const glob = typeof args.glob === 'string' && args.glob && !args.glob.includes('\0') ? ['--include', args.glob.slice(0, 200)] : [];
|
|
216
|
+
const out = await this.grep(v, ['-rnIE', '--exclude-dir=.git', ...glob, '-e', pattern, '--', path.relative(v.dir, abs) || '.']);
|
|
217
|
+
this.log(v, `Searched ${shown} for “${pattern.slice(0, 80)}”`);
|
|
218
|
+
return out || 'No matches.';
|
|
219
|
+
}
|
|
220
|
+
case 'find': {
|
|
221
|
+
const pattern = String(args.pattern ?? '');
|
|
222
|
+
if (!pattern || pattern.length > 200) throw fail(400, 'Give a file name pattern, like **/*.test.js.');
|
|
223
|
+
const re = globRe(pattern);
|
|
224
|
+
const out = [];
|
|
225
|
+
this.walk(v, v.dir, 12, out, true);
|
|
226
|
+
const hits = out.filter((p) => re.test(p));
|
|
227
|
+
this.log(v, `Looked for ${pattern}`);
|
|
228
|
+
if (!hits.length) return 'No files match.';
|
|
229
|
+
return hits.slice(0, MAX_LIST).join('\n') + (hits.length > MAX_LIST ? `\n… ${hits.length - MAX_LIST} more.` : '');
|
|
230
|
+
}
|
|
231
|
+
case 'write': case 'edit': {
|
|
232
|
+
if (v.access !== 'edit') throw fail(403, 'This project is shared read only. You can’t change files in it.');
|
|
233
|
+
const { abs, shown } = this.resolve(v, rel, true);
|
|
234
|
+
let before = null;
|
|
235
|
+
try { const st = fs.lstatSync(abs); if (!st.isFile()) throw fail(400, `${shown} is not a file.`); before = fs.readFileSync(abs, 'utf8'); } catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
236
|
+
let after;
|
|
237
|
+
if (op === 'write') {
|
|
238
|
+
const content = String(args.content ?? '');
|
|
239
|
+
if (Buffer.byteLength(content) > MAX_WRITE) throw fail(413, `Write at most ${MAX_WRITE / 1024} KB at a time. Write the first part, then add the rest with append.`);
|
|
240
|
+
after = args.append === true ? (before ?? '') + content : content;
|
|
241
|
+
} else {
|
|
242
|
+
if (before === null) throw fail(404, `${shown} doesn’t exist. Create it with files_write.`);
|
|
243
|
+
const old = String(args.old ?? ''), now = String(args.new ?? '');
|
|
244
|
+
if (!old) throw fail(400, 'Say which text to replace.');
|
|
245
|
+
if (Buffer.byteLength(now) > MAX_WRITE) throw fail(413, `Replace at most ${MAX_WRITE / 1024} KB at a time.`);
|
|
246
|
+
const n = before.split(old).length - 1;
|
|
247
|
+
if (!n) throw fail(400, `That text isn’t in ${shown}. Read the file again and copy it exactly.`);
|
|
248
|
+
if (n > 1 && args.all !== true) throw fail(400, `That text is in ${shown} ${n} times. Add more around it so it’s unique, or set all.`);
|
|
249
|
+
after = args.all === true ? before.split(old).join(now) : before.replace(old, () => now);
|
|
250
|
+
}
|
|
251
|
+
const size = Buffer.byteLength(after);
|
|
252
|
+
if (size > MAX_FILE) throw fail(413, `${shown} would be over ${MAX_FILE / 1024 / 1024} MB.`);
|
|
253
|
+
if (v.written + size > MAX_WRITTEN) throw fail(413, 'This visit has written as much as it may.');
|
|
254
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
255
|
+
fs.writeFileSync(abs, after);
|
|
256
|
+
v.written += size;
|
|
257
|
+
this.log(v, `${before === null ? 'Created' : 'Changed'} ${shown}`);
|
|
258
|
+
return `${before === null ? 'Created' : 'Updated'} ${shown}.`;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
throw fail(400, 'Unknown file operation.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A path the agent named, as an absolute path inside the copy, or a refusal.
|
|
266
|
+
// `forWrite` lets the last parts not exist yet.
|
|
267
|
+
resolve(v, rel, forWrite = false) {
|
|
268
|
+
if (typeof rel !== 'string' || rel.includes('\0') || rel.length > 1000) throw fail(400, 'That isn’t a path.');
|
|
269
|
+
const clean = rel.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
270
|
+
const abs = path.resolve(v.dir, clean || '.');
|
|
271
|
+
const inside = (p) => p === v.dir || p.startsWith(v.dir + path.sep);
|
|
272
|
+
if (!inside(abs)) throw fail(403, 'Paths stay inside the project.');
|
|
273
|
+
const shown = path.relative(v.dir, abs) || '.';
|
|
274
|
+
for (const part of shown.split(path.sep)) {
|
|
275
|
+
if (part.toLowerCase() === '.git') throw fail(403, 'The .git folder is off limits.');
|
|
276
|
+
if (SECRET.some((re) => re.test(part))) throw fail(403, `${shown} looks like a secret, so it isn’t shared.`);
|
|
277
|
+
}
|
|
278
|
+
// Symlinks: what the path really points at must be inside the copy too.
|
|
279
|
+
const realRoot = fs.realpathSync(v.dir);
|
|
280
|
+
let probe = abs;
|
|
281
|
+
while (!fs.existsSync(probe)) {
|
|
282
|
+
if (!forWrite) throw fail(404, `${shown} doesn’t exist.`);
|
|
283
|
+
probe = path.dirname(probe);
|
|
284
|
+
}
|
|
285
|
+
const real = fs.realpathSync(probe);
|
|
286
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) throw fail(403, 'That path leads outside the project.');
|
|
287
|
+
if (forWrite && probe === abs && fs.lstatSync(abs).isSymbolicLink()) throw fail(403, 'Links can’t be written through.');
|
|
288
|
+
return { abs, shown };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
stat(abs, shown) {
|
|
292
|
+
try { return fs.statSync(abs); } catch { throw fail(404, `${shown} doesn’t exist.`); }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Paths under `abs`, relative to the copy, folders with a trailing slash.
|
|
296
|
+
// Links are listed but not followed; .git and secrets are left out.
|
|
297
|
+
walk(v, abs, depth, out, filesOnly = false) {
|
|
298
|
+
if (out.length > 20_000) return;
|
|
299
|
+
let entries;
|
|
300
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
301
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
302
|
+
for (const e of entries) {
|
|
303
|
+
if (e.name.toLowerCase() === '.git' || SECRET.some((re) => re.test(e.name))) continue;
|
|
304
|
+
const rel = path.relative(v.dir, path.join(abs, e.name)).split(path.sep).join('/');
|
|
305
|
+
if (e.isDirectory()) {
|
|
306
|
+
if (!filesOnly) out.push(`${rel}/`);
|
|
307
|
+
if (depth > 1) this.walk(v, path.join(abs, e.name), depth - 1, out, filesOnly);
|
|
308
|
+
} else out.push(rel);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// grep in the copy, with a deadline and a cap on what comes back. grep's
|
|
313
|
+
// matcher doesn't backtrack, so a pattern can't hang it the way it could a
|
|
314
|
+
// JavaScript RegExp.
|
|
315
|
+
grep(v, args) {
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
const child = spawn('grep', args, { cwd: v.dir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
318
|
+
let out = '', cut = false, errOut = '';
|
|
319
|
+
const timer = setTimeout(() => { cut = true; child.kill('SIGKILL'); }, SEARCH_MS);
|
|
320
|
+
child.stdout.on('data', (d) => {
|
|
321
|
+
if (out.length >= MAX_READ) return;
|
|
322
|
+
out += d;
|
|
323
|
+
if (out.length >= MAX_READ) { cut = true; child.kill('SIGKILL'); }
|
|
324
|
+
});
|
|
325
|
+
child.stderr.on('data', (d) => { errOut += d; });
|
|
326
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
327
|
+
child.on('close', (code) => {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
if (code === 2 && !out && !cut) return reject(fail(400, `Search failed: ${errOut.trim().slice(0, 200) || 'bad pattern'}`));
|
|
330
|
+
// Matches in secret-looking files don't go out either.
|
|
331
|
+
const rows = out.slice(0, MAX_READ).split('\n').filter((r) => r && !r.split(':')[0].split('/').some((p) => SECRET.some((re) => re.test(p))));
|
|
332
|
+
resolve(rows.join('\n') + (cut ? '\n… more matches cut. Narrow the search.' : ''));
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The guest's job ended (or the agent finished a turn and the job closed).
|
|
338
|
+
async close(link, args) {
|
|
339
|
+
const v = this.session(link, args.sid);
|
|
340
|
+
await this.end(v, ['done', 'failed', 'stopped'].includes(args.status) ? args.status : 'done', typeof args.summary === 'string' ? args.summary.slice(0, 2000) : '');
|
|
341
|
+
return { ok: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async end(v, status, summary = '', error = null) {
|
|
345
|
+
if (v.status !== 'working') return;
|
|
346
|
+
Object.assign(v, { status, endedAt: Date.now(), error });
|
|
347
|
+
if (summary) this.log(v, summary, 'text');
|
|
348
|
+
await this.record(v);
|
|
349
|
+
const n = v.changes?.count || 0;
|
|
350
|
+
const what = v.access !== 'edit' ? '' : n ? ` ${n} file${n === 1 ? '' : 's'} changed in the copy. Review them on the job and apply or discard.` : ' Nothing changed.';
|
|
351
|
+
this.log(v, `${{ done: 'Finished', failed: 'Hit a wall', stopped: 'Stopped' }[status] || 'Ended'}.${what}`, 'result', { ok: status === 'done' });
|
|
352
|
+
this.push(v);
|
|
353
|
+
if (status !== 'stopped' || error) {
|
|
354
|
+
this.office.post(`**Visitor** · ${v.visitor.name} from @${v.visitor.handle} ${status === 'done' ? 'finished' : status === 'failed' ? 'hit a wall on' : 'stopped'} “${v.title}” in ${v.repo}.${what}`, { jobId: v.id });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async record(v) {
|
|
359
|
+
if (!fs.existsSync(v.dir)) return;
|
|
360
|
+
const src = this.office.repoPath(v.repo);
|
|
361
|
+
if (!src) return;
|
|
362
|
+
const end = await this.snapshot(v, src).catch(() => null);
|
|
363
|
+
if (end) v.changes = await summarize(src, v.base, end);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The copy as a tree in the project's object store, from a throwaway index
|
|
367
|
+
// seeded with the base so it's quick.
|
|
368
|
+
async snapshot(v, src) {
|
|
369
|
+
const idx = tmpIndex();
|
|
370
|
+
try {
|
|
371
|
+
await git(src, ['read-tree', v.base], { GIT_INDEX_FILE: idx });
|
|
372
|
+
await git(src, [`--work-tree=${v.dir}`, 'add', '-A'], { GIT_INDEX_FILE: idx });
|
|
373
|
+
return (await git(src, ['write-tree'], { GIT_INDEX_FILE: idx })).trim();
|
|
374
|
+
} finally {
|
|
375
|
+
fs.rmSync(idx, { force: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------- the boss ----------
|
|
380
|
+
|
|
381
|
+
stop(v, why = 'Stopped by you.') {
|
|
382
|
+
if (v.status !== 'working') return;
|
|
383
|
+
this.office.feed(v.guest.link, { t: 'visit', job: v.theirJob, ended: 'stopped' });
|
|
384
|
+
return this.end(v, 'stopped', '', why);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async diff(v, file) {
|
|
388
|
+
const src = this.office.repoPath(v.repo);
|
|
389
|
+
if (!src) throw fail(404, 'That project folder is gone.');
|
|
390
|
+
return fileDiff(src, v.changes, file);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The copy's changes onto the real folder. git apply takes all of it or
|
|
394
|
+
// none, so a project that has moved on underneath gets a clear refusal.
|
|
395
|
+
async apply(v) {
|
|
396
|
+
if (v.status === 'working') throw fail(409, 'Wait for them to finish, or stop them first.');
|
|
397
|
+
if (v.applied || v.discarded) throw fail(409, 'Already dealt with.');
|
|
398
|
+
if (!v.changes?.count) throw fail(400, 'Nothing changed.');
|
|
399
|
+
const src = this.office.repoPath(v.repo);
|
|
400
|
+
if (!src) throw fail(404, 'That project folder is gone.');
|
|
401
|
+
const patch = await git(src, ['diff', '--binary', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', v.changes.base, v.changes.end]);
|
|
402
|
+
try {
|
|
403
|
+
await git(src, ['apply', '--binary', '--whitespace=nowarn', '-'], {}, patch);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
throw fail(409, `Those changes don’t apply cleanly: ${String(err.stderr || err.message).split('\n')[0].slice(0, 200)}. The project has moved on since; nothing was changed.`);
|
|
406
|
+
}
|
|
407
|
+
v.applied = Date.now();
|
|
408
|
+
this.log(v, `Applied to ${v.repo}.`, 'system');
|
|
409
|
+
this.drop(v);
|
|
410
|
+
this.push(v);
|
|
411
|
+
return { ok: true };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
discard(v) {
|
|
415
|
+
if (v.status === 'working') throw fail(409, 'Stop them first.');
|
|
416
|
+
if (v.applied || v.discarded) throw fail(409, 'Already dealt with.');
|
|
417
|
+
v.discarded = Date.now();
|
|
418
|
+
this.log(v, 'Thrown away.', 'system');
|
|
419
|
+
this.drop(v);
|
|
420
|
+
this.push(v);
|
|
421
|
+
return { ok: true };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Clear finished: visits that are dealt with go. One with changes still
|
|
425
|
+
// waiting for review stays, so a click can't throw work away unseen.
|
|
426
|
+
clear() {
|
|
427
|
+
const done = (v) => v.status !== 'working' && (v.applied || v.discarded || !v.changes?.count || v.access !== 'edit');
|
|
428
|
+
for (const v of this.list) if (done(v)) this.drop(v);
|
|
429
|
+
this.list = this.list.filter((v) => !done(v));
|
|
430
|
+
this.save();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
drop(v) {
|
|
434
|
+
fs.rmSync(v.dir, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------- housekeeping ----------
|
|
438
|
+
|
|
439
|
+
// Links that went: their visits stop at once.
|
|
440
|
+
linksChanged(live) {
|
|
441
|
+
for (const v of this.list) if (v.status === 'working' && !live.has(v.guest.link)) this.end(v, 'stopped', '', 'Stopped: the guest was unlinked.').catch(() => {});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The boss changed a guest's limits: visits they no longer allow stop now,
|
|
445
|
+
// not at the guest's next call.
|
|
446
|
+
policyChanged(linkId, policy) {
|
|
447
|
+
for (const v of this.list) {
|
|
448
|
+
if (v.status !== 'working' || v.guest.link !== linkId) continue;
|
|
449
|
+
if (!['read', 'edit'].includes(policy?.theirAgents)) this.stop(v, 'Stopped: you turned their agents off.')?.catch(() => {});
|
|
450
|
+
else if (!policy.repos.includes(v.repo)) this.stop(v, 'Stopped: that project isn’t shared with them any more.')?.catch(() => {});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// A visit nobody has touched in a while is closed, so a guest office that
|
|
455
|
+
// went away mid-job doesn't leave one open for ever.
|
|
456
|
+
sweep() {
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
for (const v of this.list) if (v.status === 'working' && now - (v.usedAt || v.startedAt) > IDLE_MS) this.end(v, 'stopped', '', 'Closed: nothing happened in it for an hour.').catch(() => {});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A glob (`*`, `**`, `?`) as a RegExp over slash paths. Built by hand from
|
|
463
|
+
// escaped literals, so it can't backtrack badly.
|
|
464
|
+
export function globRe(glob) {
|
|
465
|
+
let re = '';
|
|
466
|
+
for (let i = 0; i < glob.length; i++) {
|
|
467
|
+
const c = glob[i];
|
|
468
|
+
if (c === '*' && glob[i + 1] === '*') { re += glob[i + 2] === '/' ? '(?:[^/]*/)*' : '.*'; i += glob[i + 2] === '/' ? 2 : 1; }
|
|
469
|
+
else if (c === '*') re += '[^/]*';
|
|
470
|
+
else if (c === '?') re += '[^/]';
|
|
471
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
472
|
+
}
|
|
473
|
+
// A bare name matches at any depth, as people expect from `*.js`.
|
|
474
|
+
return new RegExp(glob.includes('/') ? `^${re}$` : `(?:^|/)${re}$`);
|
|
475
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
function e(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}export{e as g};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{g as f}from"./_commonjsHelpers-CqkleIqs.js";function g(i,n){for(var o=0;o<n.length;o++){const t=n[o];if(typeof t!="string"&&!Array.isArray(t)){for(const e in t)if(e!=="default"&&!(e in i)){const r=Object.getOwnPropertyDescriptor(t,e);r&&Object.defineProperty(i,e,r.get?r:{enumerable:!0,get:()=>t[e]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}var l={exports:{}};(function(i,n){(function(o,t){i.exports=t()})(self,()=>(()=>{var o={};return(()=>{var t=o;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const r=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(r._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,r=e._renderService.dimensions;if(r.css.cell.width===0||r.css.cell.height===0)return;const c=this._terminal.options.scrollback===0?0:e.viewport.scrollBarWidth,a=window.getComputedStyle(this._terminal.element.parentElement),d=parseInt(a.getPropertyValue("height")),m=Math.max(0,parseInt(a.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),h=d-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),u=m-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-c;return{cols:Math.max(2,Math.floor(u/r.css.cell.width)),rows:Math.max(1,Math.floor(h/r.css.cell.height))}}}})(),o})())})(l);var p=l.exports;const _=f(p),w=g({__proto__:null,default:_},[p]);export{w as a};
|