eve-hub 0.13.4 → 0.13.6

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.13.6
4
+
5
+ ### Patch Changes
6
+
7
+ - A bot on the Mac gets the local-onboarding skill: it lists the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) with its git state to port into threads, and proposes the sandbox setup for hosted bots as a `prepare` change. Main's onboarding hands that half to the Mac bot. The agent-usage-setup skill and its hub-checkout scripts move into it.
8
+
9
+ ## 0.13.5
10
+
11
+ ### Patch Changes
12
+
13
+ - A bot on the Mac knows its machine: it runs only while the Hub app is open, works in its own git worktree, where the coding agents keep their sessions, and what needs the owner's yes first (keychain, installs, pushes).
14
+
3
15
  ## 0.13.4
4
16
 
5
17
  ### Patch Changes
package/kit-modules.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "schedules/dash",
13
13
  "schedules/prune",
14
14
  "skills/forms",
15
+ "skills/local-onboarding",
15
16
  "skills/onboarding-profile",
16
17
  "tools/apply_tags",
17
18
  "tools/archive_thread",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: local-onboarding
3
+ description: "The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes."
4
+ license: MIT
5
+ ---
6
+
7
+ # The Mac half of onboarding
8
+
9
+ Main runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.
10
+
11
+ Two scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.
12
+
13
+ ## 1. Live work to port
14
+
15
+ ```sh
16
+ node {{scripts}}/sessions-facts.mjs > "$TMPDIR/sessions.json"
17
+ ```
18
+
19
+ The script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:
20
+ - `repo`, `branch` and `pr` (the open PR);
21
+ - `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);
22
+ - `lastActive`;
23
+ - `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).
24
+
25
+ A stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.
26
+
27
+ Post the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with "unknown" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.
28
+
29
+ ```md
30
+ You have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.
31
+
32
+ ---
33
+
34
+ | Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |
35
+ |---|---|---|---|---|---|
36
+ | `vercel/app` on `fix-login` (worktree) | Claude Code, "Fix login redirect" | 2h ago | 4 files | 2 | [#812](...) |
37
+
38
+ Also 5 streams with nothing in flight.
39
+ ```
40
+
41
+ Never post a local path, a file name, a prompt, or anything else from the sessions.
42
+
43
+ When the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.
44
+
45
+ ## 2. Sandbox setup
46
+
47
+ ```sh
48
+ node {{scripts}}/agent-usage-facts.mjs > "$TMPDIR/agent-usage.json"
49
+ ```
50
+
51
+ It takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:
52
+ - `scanned`: records, commands and unparsable commands per agent;
53
+ - `unreadable`;
54
+ - `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.
55
+
56
+ Mapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:
57
+
58
+ - **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:
59
+ - `brew`, `osascript` and `open`;
60
+ - Xcode, `xcodebuild` and Apple-platform `swift`;
61
+ - anything that drives the owner's browser or apps;
62
+ - `localhost` services and the keychain.
63
+
64
+ Everything else can be hosted.
65
+ - **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.
66
+
67
+ MCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.
68
+
69
+ Post the proposal:
70
+ - a first bubble on what the hosted bots would gain;
71
+ - a table of the steps to add, with the programs each installs and how often the owner used them;
72
+ - one line for what stays on the Mac;
73
+ - one line for the MCP servers to connect through Kit.
74
+
75
+ Ask whether to open the PR.
76
+
77
+ ## 3. The sandbox PR, after the owner's yes
78
+
79
+ The steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{"commands": [{"command": "<shell>", "what": "<short name for the error>"}]}`.
80
+
81
+ Main's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.
82
+
83
+ ```sh
84
+ node {{scripts}}/prepare-edit.mjs "$TMPDIR/prepare.json" agents/builder/agent/sandbox.ts
85
+ ```
86
+
87
+ Commit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.
88
+
89
+ The PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ // Read-only fact gatherer for the local-onboarding skill (local-onboarding/SKILL.md, step 2): what the user's local coding agents
3
+ // (Claude Code, Codex, OpenCode, Cursor, Claude Desktop) have actually used, as names and counts.
4
+ // Prints one JSON document on stdout; progress goes to stderr. Never writes, never emits a
5
+ // command's arguments, a prompt, a path inside a repo, or any value: every string that leaves this
6
+ // file went through `name()` or `normalizeOrigin()` (or is a source path under the agents' own dirs).
7
+ import { createReadStream, existsSync, readFileSync, globSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createInterface } from 'node:readline';
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { homedir } from 'node:os';
12
+ import { basename, dirname, extname, join } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { parse as parseJsonc } from 'jsonc-parser';
15
+ import { parse as parseToml } from 'smol-toml';
16
+ import { Language, Parser } from 'web-tree-sitter';
17
+ import { cleanGitEnv, normalizeOrigin } from './git.mjs';
18
+ import { codexHome, codexStateDb } from './codex-home.mjs';
19
+
20
+ const HOME = homedir();
21
+ const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
22
+ const NAME = /^[A-Za-z0-9_@][\w.@+:-]{0,63}$/;
23
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
24
+ const EXT = /^\.[a-z0-9]{1,8}$/;
25
+ // Builtins and wrappers say nothing about what a sandbox needs installed.
26
+ const NOT_PROGRAMS = new Set(['cd', 'echo', 'export', 'set', 'unset', 'true', 'false', 'test', '[', ':', 'break', 'continue', 'trap', 'read',
27
+ 'source', '.', 'exit', 'return', 'wait', 'local', 'printf', 'pwd', 'eval', 'alias']);
28
+ const WRAPPERS = new Set(['sudo', 'env', 'time', 'command', 'nohup', 'exec', 'builtin', 'nice', 'timeout']);
29
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'read', 'edit', 'write']);
30
+
31
+ // ---- aggregation: every emitted row is {name, count, agents} keyed by one string ----
32
+ const table = () => new Map();
33
+ function bump(map, key, agent, n = 1) {
34
+ const row = map.get(key) ?? { count: 0, agents: new Set() };
35
+ row.count += n;
36
+ row.agents.add(agent);
37
+ map.set(key, row);
38
+ }
39
+ const rows = (map, keyName) => [...map].map(([k, r]) => ({ [keyName]: k, count: r.count, agents: [...r.agents].sort() }))
40
+ .sort((a, b) => b.count - a.count || String(a[keyName]).localeCompare(String(b[keyName])));
41
+
42
+ const programs = table();
43
+ const extensions = table();
44
+ const repos = table();
45
+ const scanned = Object.fromEntries(['claude-code', 'codex', 'opencode'].map((a) => [a, { records: 0, commands: 0, unparsed: 0 }]));
46
+ const unreadable = new Set(); // sources that exist but could not be read or parsed (labels only, never parser messages)
47
+ const envNames = new Map(); // name -> { count: uses as env or $NAME, shellAssigned: bare/for/read assignments, sources:Set }
48
+ const mcp = new Map(); // name -> { transports, endpoints, agents, sources }
49
+
50
+ const name = (s) => (typeof s === 'string' && NAME.test(s) ? s : null);
51
+
52
+ function addEnvName(n, source, uses = 0, shellAssigned = 0) {
53
+ if (!ENV_NAME.test(n)) return;
54
+ const row = envNames.get(n) ?? { count: 0, shellAssigned: 0, sources: new Set() };
55
+ row.count += uses;
56
+ row.shellAssigned += shellAssigned;
57
+ row.sources.add(source);
58
+ envNames.set(n, row);
59
+ }
60
+
61
+ function addMcp(serverName, agent, source, cfg) {
62
+ const n = name(serverName);
63
+ if (!n) return;
64
+ const row = mcp.get(n) ?? { transports: new Set(), endpoints: new Set(), agents: new Set(), sources: new Set() };
65
+ row.agents.add(agent);
66
+ row.sources.add(source);
67
+ const c = cfg && typeof cfg === 'object' ? cfg : {};
68
+ if (c.command) {
69
+ // `command` is a string (possibly with args: Cursor) or an argv array (OpenCode); only argv[0] is a program name.
70
+ const argv0 = Array.isArray(c.command) ? c.command[0] : String(c.command).trim().split(/\s+/)[0];
71
+ row.transports.add('stdio');
72
+ const prog = name(basename(String(argv0)));
73
+ if (prog) row.endpoints.add(prog);
74
+ } else if (c.url) {
75
+ row.transports.add(c.type === 'sse' ? 'sse' : 'http');
76
+ try { row.endpoints.add(new URL(c.url).hostname); } catch { /* no host to report */ }
77
+ }
78
+ for (const k of Object.keys(c.env ?? c.environment ?? {})) addEnvName(k, `mcp:${n}`);
79
+ mcp.set(n, row);
80
+ }
81
+
82
+ // ---- shell commands -> program names and env names (never arguments) ----
83
+ await Parser.init();
84
+ const shParser = new Parser();
85
+ shParser.setLanguage(await Language.load(fileURLToPath(import.meta.resolve('tree-sitter-bash/tree-sitter-bash.wasm'))));
86
+
87
+ // A word that is one literal token (no quotes, expansions or concatenation); anything else is not a program name.
88
+ const literal = (n) => {
89
+ const t = n.type === 'command_name' && n.childCount === 1 ? n.child(0) : n;
90
+ return t.type === 'word' || t.type === 'number' ? t.text : '';
91
+ };
92
+ const assignedName = (n) => {
93
+ const target = n.childForFieldName('name');
94
+ return (target?.type === 'subscript' ? target.childForFieldName('name') : target)?.text ?? '';
95
+ };
96
+ // `NAME=v` inside these is not a shell assignment: `command` and `declaration_command` are handled where they are
97
+ // (`export A=1` records nothing), and a C-style for header is arithmetic.
98
+ const NOT_BARE_ASSIGN = new Set(['command', 'declaration_command', 'c_style_for_statement']);
99
+
100
+ // One syntax-tree walk per command: the first literal word of each call is its program; `NAME=v cmd`, `env NAME=v cmd` and
101
+ // `$NAME` each count a use of an env name. A for-loop variable, a bare `NAME=v` and `read NAME` count as
102
+ // `shellAssigned` on the same name: the fact is recorded, whether it is a shell variable or real env is the model's call.
103
+ // Values and other words are never kept.
104
+ function commandFacts(cmd, agent) {
105
+ scanned[agent].commands++;
106
+ const tree = shParser.parse(cmd);
107
+ if (!tree || tree.rootNode.hasError) { scanned[agent].unparsed++; tree?.delete(); return; }
108
+ const source = `commands:${agent}`;
109
+ const envUse = (n) => addEnvName(n, source, 1);
110
+ const shellAssign = (n) => addEnvName(n, source, 0, 1);
111
+ const visit = (node) => {
112
+ switch (node.type) {
113
+ case 'for_statement': shellAssign(node.childForFieldName('variable')?.text ?? ''); break;
114
+ case 'simple_expansion':
115
+ case 'expansion': {
116
+ const v = node.namedChildren.find((c) => c.type === 'variable_name' || c.type === 'subscript' || c.type === 'special_variable_name');
117
+ envUse((v?.type === 'subscript' ? v.childForFieldName('name') : v)?.text ?? '');
118
+ break;
119
+ }
120
+ case 'variable_assignment': if (!NOT_BARE_ASSIGN.has(node.parent?.type)) shellAssign(assignedName(node)); break;
121
+ case 'command': {
122
+ // `sudo 2>/dev/null cargo build` parses with the words after the redirect target as extra destinations of the
123
+ // redirect: only the first is the file, the rest are more words of this command.
124
+ const afterRedirect = node.parent?.type === 'redirected_statement'
125
+ ? node.parent.childrenForFieldName('redirect').filter((r) => r.type === 'file_redirect').flatMap((r) => r.childrenForFieldName('destination').slice(1))
126
+ : [];
127
+ const words = [node.childForFieldName('name'), ...node.childrenForFieldName('argument'), ...afterRedirect].filter(Boolean).map(literal);
128
+ const record = words.length ? envUse : shellAssign; // `A=1 cmd` sets env for cmd; a bare `A=1` is a shell variable
129
+ for (const c of node.namedChildren) if (c.type === 'variable_assignment') record(assignedName(c));
130
+ for (const w of words) {
131
+ const assign = w.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
132
+ if (assign) { envUse(assign[1]); continue; }
133
+ if (WRAPPERS.has(w) || w.startsWith('-') || /^\d+$/.test(w)) continue;
134
+ if (w === 'read') for (const r of words.slice(1)) if (!r.startsWith('-')) shellAssign(r);
135
+ const prog = name(basename(w));
136
+ if (prog && !NOT_PROGRAMS.has(prog)) bump(programs, prog, agent);
137
+ break;
138
+ }
139
+ break;
140
+ }
141
+ }
142
+ for (const c of node.namedChildren) visit(c);
143
+ };
144
+ visit(tree.rootNode);
145
+ tree.delete();
146
+ }
147
+
148
+ function fileFacts(filePath, agent) {
149
+ const ext = extname(String(filePath ?? '')).toLowerCase();
150
+ if (EXT.test(ext)) bump(extensions, ext, agent);
151
+ }
152
+
153
+ // ---- repos: collapse worktrees and clones to one origin; fall back to the directory ----
154
+ const NO_ORIGIN = '(no git origin)'; // directory names can be prompt-derived (Codex), so they are counted, never named
155
+ const originCache = new Map();
156
+ function origin(dir) {
157
+ if (originCache.has(dir)) return originCache.get(dir);
158
+ let key = NO_ORIGIN;
159
+ if (existsSync(dir)) {
160
+ try {
161
+ const url = execFileSync('git', ['-C', dir, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], env: cleanGitEnv() }).trim();
162
+ key = normalizeOrigin(url) ?? key;
163
+ } catch { /* not a git checkout, or no origin */ }
164
+ }
165
+ originCache.set(dir, key);
166
+ return key;
167
+ }
168
+ const addRepo = (dir, knownOrigin, agent, sessions = 1) => bump(repos, normalizeOrigin(knownOrigin) ?? origin(dir), agent, sessions);
169
+
170
+ // ---- streaming ----
171
+ async function* lines(file) {
172
+ yield* createInterface({ input: createReadStream(file), crlfDelay: Infinity });
173
+ }
174
+ const files = (dir, pattern) => (existsSync(dir) ? globSync(pattern, { cwd: dir }).map((f) => join(dir, f)) : []);
175
+ // A missing file is just absent; a file that exists but won't read or parse is reported in `unreadable`.
176
+ function readConfig(f, parse) {
177
+ if (!existsSync(f)) return null;
178
+ try { return parse(readFileSync(f, 'utf8')); } catch { unreadable.add(tilde(f)); return null; }
179
+ }
180
+ const readJson = (f) => readConfig(f, (text) => {
181
+ const errors = [];
182
+ const value = parseJsonc(text, errors, { allowTrailingComma: true }); // JSONC is a superset of JSON (opencode.jsonc has comments)
183
+ if (errors.length) throw new Error('jsonc');
184
+ return value;
185
+ });
186
+ const readToml = (f) => readConfig(f, parseToml);
187
+ const log = (m) => process.stderr.write(`${m}\n`);
188
+
189
+ // Open a sqlite file read-only for `use`. A database that is missing from an installed agent's directory, or that
190
+ // won't open or query, is reported in `unreadable` instead of silently contributing nothing.
191
+ function withDb(path, use) {
192
+ if (!existsSync(path)) { if (existsSync(dirname(path))) unreadable.add(tilde(path)); return; }
193
+ let d;
194
+ try {
195
+ d = new DatabaseSync(path, { readOnly: true });
196
+ use(d);
197
+ } catch { unreadable.add(tilde(path)); } finally { d?.close(); }
198
+ }
199
+
200
+ // ---- readers: one per agent ----
201
+ async function claudeCode() {
202
+ const agent = 'claude-code';
203
+ const cfgPath = join(HOME, '.claude.json');
204
+ const cfg = readJson(cfgPath) ?? {};
205
+ for (const [n, c] of Object.entries(cfg.mcpServers ?? {})) addMcp(n, agent, '~/.claude.json#mcpServers', c);
206
+ for (const p of Object.values(cfg.projects ?? {})) for (const [n, c] of Object.entries(p.mcpServers ?? {})) addMcp(n, agent, '~/.claude.json#projects.*.mcpServers', c);
207
+ for (const k of Object.keys(readJson(join(HOME, '.claude/settings.json'))?.env ?? {})) addEnvName(k, '~/.claude/settings.json#env');
208
+ const installed = readJson(join(HOME, '.claude/plugins/installed_plugins.json'))?.plugins ?? {};
209
+ const enabled = readJson(join(HOME, '.claude/settings.json'))?.enabledPlugins ?? {};
210
+ for (const [id, installs] of Object.entries(installed)) {
211
+ if (!enabled[id]) continue;
212
+ for (const i of installs) {
213
+ const m = readJson(join(i.installPath, '.mcp.json'));
214
+ for (const [n, c] of Object.entries(m?.mcpServers ?? m ?? {})) addMcp(n, agent, `claude plugin ${id.split('@')[0]}`, c);
215
+ }
216
+ }
217
+ const transcripts = files(join(HOME, '.claude/projects'), '**/*.jsonl');
218
+ scanned[agent].records = transcripts.length;
219
+ for (const f of transcripts) {
220
+ let cwd = null;
221
+ let seen = 0;
222
+ for await (const line of lines(f)) {
223
+ if (!cwd && seen++ < 50) cwd = line.match(/"cwd":"([^"]+)"/)?.[1] ?? null;
224
+ if (!line.includes('"type":"tool_use"')) continue;
225
+ let msg;
226
+ try { msg = JSON.parse(line); } catch { continue; }
227
+ for (const b of msg.message?.content ?? []) {
228
+ if (b.type !== 'tool_use') continue;
229
+ if (b.name === 'Bash') commandFacts(String(b.input?.command ?? ''), agent);
230
+ else if (FILE_TOOLS.has(b.name)) fileFacts(b.input?.file_path, agent);
231
+ }
232
+ }
233
+ if (cwd) addRepo(cwd, null, agent);
234
+ }
235
+ log(`claude-code: ${transcripts.length} transcripts`);
236
+ }
237
+
238
+ async function codex() {
239
+ const agent = 'codex';
240
+ const root = codexHome();
241
+ const cfg = readToml(join(root, 'config.toml')) ?? {};
242
+ for (const [n, c] of Object.entries(cfg.mcp_servers ?? {})) addMcp(n, agent, '~/.codex/config.toml#mcp_servers', c);
243
+ for (const [id, p] of Object.entries(cfg.plugins ?? {})) {
244
+ if (!p.enabled) continue;
245
+ const [pluginName, marketplace] = id.split('@');
246
+ for (const f of files(root, `plugins/cache/${marketplace}/${pluginName}/*/.mcp.json`)) {
247
+ const m = readJson(f);
248
+ for (const [n, c] of Object.entries(m?.mcpServers ?? m ?? {})) addMcp(n, agent, `codex plugin ${pluginName}`, c);
249
+ }
250
+ }
251
+ withDb(codexStateDb(), (d) => {
252
+ for (const r of d.prepare('select cwd, git_origin_url as o, count(*) as n from threads group by cwd, git_origin_url').all()) {
253
+ addRepo(r.cwd, r.o, agent, r.n);
254
+ }
255
+ });
256
+ const rollouts = [...files(root, 'sessions/**/*.jsonl'), ...files(root, 'archived_sessions/**/*.jsonl')];
257
+ scanned[agent].records = rollouts.length;
258
+ for (const f of rollouts) {
259
+ for await (const line of lines(f)) {
260
+ if (!line.includes('"exec_command"')) continue;
261
+ let p;
262
+ try { p = JSON.parse(line).payload; } catch { continue; }
263
+ if (p?.type !== 'function_call' || p.name !== 'exec_command') continue;
264
+ try { commandFacts(String(JSON.parse(p.arguments).cmd ?? ''), agent); } catch { /* arguments not JSON */ }
265
+ }
266
+ }
267
+ log(`codex: ${rollouts.length} rollouts`);
268
+ }
269
+
270
+ function openCode() {
271
+ const agent = 'opencode';
272
+ for (const f of ['opencode.json', 'opencode.jsonc']) {
273
+ for (const [n, c] of Object.entries(readJson(join(HOME, '.config/opencode', f))?.mcp ?? {})) addMcp(n, agent, `~/.config/opencode/${f}#mcp`, c);
274
+ }
275
+ withDb(join(HOME, '.local/share/opencode/opencode.db'), (d) => {
276
+ for (const r of d.prepare('select directory, count(*) as n from session group by directory').all()) addRepo(r.directory, null, agent, r.n);
277
+ const q = d.prepare(`select json_extract(data,'$.tool') as tool, json_extract(data,'$.state.input.command') as cmd,
278
+ json_extract(data,'$.state.input.filePath') as file from part where json_extract(data,'$.type')='tool'`);
279
+ let tools = 0;
280
+ for (const r of q.iterate()) {
281
+ tools++;
282
+ if (r.tool === 'bash') commandFacts(String(r.cmd ?? ''), agent);
283
+ else if (FILE_TOOLS.has(r.tool)) fileFacts(r.file, agent);
284
+ }
285
+ scanned[agent].records = tools;
286
+ log(`opencode: ${tools} tool parts`);
287
+ });
288
+ }
289
+
290
+ // Cursor's chat history is opaque blobs; its MCP config is all that is readable.
291
+ function cursor() {
292
+ for (const [n, c] of Object.entries(readJson(join(HOME, '.cursor/mcp.json'))?.mcpServers ?? {})) addMcp(n, 'cursor', '~/.cursor/mcp.json#mcpServers', c);
293
+ }
294
+
295
+ function claudeDesktop() {
296
+ const f = join(HOME, 'Library/Application Support/Claude/claude_desktop_config.json');
297
+ for (const [n, c] of Object.entries(readJson(f)?.mcpServers ?? {})) addMcp(n, 'claude-desktop', 'claude_desktop_config.json#mcpServers', c);
298
+ }
299
+
300
+ export async function gather() {
301
+ claudeDesktop();
302
+ cursor();
303
+ openCode();
304
+ await Promise.all([claudeCode(), codex()]);
305
+ return {
306
+ scanned,
307
+ unreadable: [...unreadable].sort(),
308
+ programs: rows(programs, 'name'),
309
+ mcpServers: [...mcp].map(([n, r]) => ({ name: n, transport: [...r.transports].sort(), endpoints: [...r.endpoints].sort(), agents: [...r.agents].sort(), sources: [...r.sources].sort() }))
310
+ .sort((a, b) => a.name.localeCompare(b.name)),
311
+ repos: rows(repos, 'origin').map(({ origin: o, count, agents }) => ({ origin: o, sessions: count, agents })),
312
+ languages: rows(extensions, 'ext'),
313
+ envNames: [...envNames].map(([n, r]) => ({ name: n, count: r.count, shellAssigned: r.shellAssigned, sources: [...r.sources].sort() })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
314
+ };
315
+ }
316
+
317
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
318
+ const t = Date.now();
319
+ const facts = await gather();
320
+ process.stdout.write(`${JSON.stringify(facts)}\n`);
321
+ log(`done in ${((Date.now() - t) / 1000).toFixed(1)}s`);
322
+ }
@@ -0,0 +1,14 @@
1
+ // Where Codex keeps its files, for both local-onboarding fact scripts. `CODEX_HOME` moves them all
2
+ // (default ~/.codex); `CODEX_SQLITE_HOME` moves only the state database, whose name carries its schema
3
+ // version (`state_5.sqlite`, bumped when the schema changes), so the newest one is read.
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export const codexHome = (env = process.env) => env.CODEX_HOME || join(homedir(), '.codex');
9
+
10
+ export function codexStateDb(env = process.env) {
11
+ const dir = env.CODEX_SQLITE_HOME || codexHome(env);
12
+ const versions = existsSync(dir) ? readdirSync(dir).map((f) => f.match(/^state_(\d+)\.sqlite$/)?.[1]).filter(Boolean).map(Number) : [];
13
+ return join(dir, `state_${versions.length ? Math.max(...versions) : 5}.sqlite`);
14
+ }
@@ -0,0 +1,21 @@
1
+ // What both local-onboarding fact scripts need from git: an env without an inherited repo, and a repo's name.
2
+
3
+ // `git bisect run`, hooks and `git rebase -x` export GIT_DIR (and friends) to their children; without
4
+ // this every query would hit that repo (lib/clean-env.mjs, which this package can't import).
5
+ const GIT_REPO_ENV = /^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE|IMPLICIT_WORK_TREE)$/;
6
+ export const cleanGitEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([key]) => !GIT_REPO_ENV.test(key)));
7
+
8
+ // `host/owner/repo` from a remote URL (https://, ssh://, git@host:owner/repo). Built from the parsed host and path only,
9
+ // so userinfo, query and fragment can't survive; a local-path or unparsable origin is not a repo name.
10
+ export function normalizeOrigin(url) {
11
+ const text = String(url ?? '').trim();
12
+ const scp = text.match(/^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)([^?#]+)$/);
13
+ let host;
14
+ let path;
15
+ if (scp) [, host, path] = scp;
16
+ else {
17
+ try { ({ hostname: host, pathname: path } = new URL(text)); } catch { return null; }
18
+ }
19
+ const key = `${host}/${path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
20
+ return /^[\w.-]+\/[\w./-]{1,150}$/.test(key) ? key : null;
21
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Adds the sandbox setup local-onboarding proposed to hosted bots' sandbox.ts, in place:
3
+ // node prepare-edit.mjs proposal.json agents/<bot>/agent/sandbox.ts [...]
4
+ // proposal.json is {"commands": [{"command": "<shell>", "what": "<short name for the error>"}]}, the one source of
5
+ // the steps: they are written as `await run(...)` calls into `prepare`, before its `if (!token) return;`, so every new
6
+ // session's snapshot has them. A step already in the file is not added twice. A sandbox.ts without the scaffold's
7
+ // `run` helper and token line (lib/agent-template.mjs) is refused: edit that one by hand.
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ const RUN = /^ const run = async \(command, what\) => \{$/m;
12
+ const ANCHOR = ' if (!token) return;\n';
13
+
14
+ export function addPrepareSteps(source, commands) {
15
+ if (!Array.isArray(commands) || !commands.every((c) => typeof c?.command === 'string' && c.command.trim() && typeof c?.what === 'string' && c.what.trim())) {
16
+ throw new Error('commands must be [{ command, what }] with both non-empty');
17
+ }
18
+ if (!RUN.test(source) || !source.includes(ANCHOR)) throw new Error("this sandbox.ts isn't the scaffold's (no `run` helper or `if (!token) return;`): add the steps by hand");
19
+ const lines = commands.map(({ command, what }) => ` await run(${JSON.stringify(command)}, ${JSON.stringify(what)});\n`).filter((line) => !source.includes(line));
20
+ if (!lines.length) return source;
21
+ const steps = ` // The programs the owner's coding agents use (local-onboarding).\n${lines.join('')}${ANCHOR}`;
22
+ return source.replace(ANCHOR, () => steps);
23
+ }
24
+
25
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
26
+ const [proposal, ...files] = process.argv.slice(2);
27
+ if (!proposal || !files.length) throw new Error('usage: prepare-edit.mjs proposal.json sandbox.ts [...]');
28
+ const { commands } = JSON.parse(readFileSync(proposal, 'utf8'));
29
+ for (const file of files) writeFileSync(file, addPrepareSteps(readFileSync(file, 'utf8'), commands));
30
+ }
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ // Read-only fact gatherer for local onboarding: the user's recent coding-agent sessions (Claude Code,
3
+ // Codex, OpenCode, Cursor) grouped into work-streams by (repo, branch), each with its git state.
4
+ // `node sessions-facts.mjs [--days 14]` prints one JSON document on stdout; progress goes to stderr.
5
+ // Privacy contract: what leaves this file is only the normalized repo name, the branch, last-active
6
+ // times, counts (uncommitted files, unpushed commits, sessions scanned), each checkout's worktree boolean, the open
7
+ // PR's number/url/title, and the agent's own stored session title. Never a prompt, message text, command,
8
+ // a path inside a repo, or the local directory (a stream with no repo is keyed `local-N`). Nothing is
9
+ // written: sqlite opens read-only and every git command is a query.
10
+ import { existsSync, globSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { cleanGitEnv, normalizeOrigin } from './git.mjs';
17
+ import { codexHome, codexStateDb } from './codex-home.mjs';
18
+
19
+ const HOME = homedir();
20
+ const AGENTS = ['claude-code', 'codex', 'opencode', 'cursor'];
21
+ const TITLE_MAX = 120;
22
+ const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
23
+ const log = (m) => process.stderr.write(`${m}\n`);
24
+
25
+ const title = (s) => (typeof s === 'string' && s.trim() ? s.trim().slice(0, TITLE_MAX) : null);
26
+ const iso = (ms) => (Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null);
27
+ const files = (dir, pattern) => (existsSync(dir) ? globSync(pattern, { cwd: dir }).map((f) => join(dir, f)) : []);
28
+ const mtime = (f) => { try { return statSync(f).mtimeMs; } catch { return 0; } };
29
+
30
+ // ---- git: one read-only probe per directory ----
31
+ const rawGit = (dir, args) => execFileSync('git', ['-C', dir, '--no-optional-locks', ...args], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: cleanGitEnv() });
32
+ const tryGit = (dir, args) => { try { return rawGit(dir, args).trim(); } catch { return null; } };
33
+ // Changed files, each untracked file counted on its own (not one per new directory). In `-z` records
34
+ // a rename or copy carries its source path as the next record, which is the same file.
35
+ export function countChanged(porcelainZ) {
36
+ const records = porcelainZ.split('\0').filter(Boolean);
37
+ let n = 0;
38
+ for (let i = 0; i < records.length; i++) {
39
+ n++;
40
+ if (/^[RC]/.test(records[i])) i++;
41
+ }
42
+ return n;
43
+ }
44
+ function uncommittedIn(dir) {
45
+ try { return countChanged(rawGit(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])); } catch { return null; }
46
+ }
47
+ let ghBroken = false;
48
+ function openPr(repo, branch, unreadable) {
49
+ if (!repo?.startsWith('github.com/') || !branch || ghBroken) return null;
50
+ try {
51
+ const out = execFileSync('gh', ['pr', 'list', '--head', branch, '--repo', repo.slice('github.com/'.length), '--state', 'open', '--json', 'number,url,title', '--limit', '1'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] });
52
+ const pr = JSON.parse(out)[0];
53
+ return pr ? { number: pr.number, url: pr.url, title: title(pr.title) } : null;
54
+ } catch {
55
+ ghBroken = true;
56
+ unreadable.add('gh pr list');
57
+ return null;
58
+ }
59
+ }
60
+ // A checkout's state; `repo` and `branch` name its stream, the rest is the checkout's own.
61
+ function gitState(dir) {
62
+ if (!existsSync(dir)) return { gone: true };
63
+ if (tryGit(dir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return { repo: null, branch: null };
64
+ const repo = normalizeOrigin(tryGit(dir, ['config', '--get', 'remote.origin.url']));
65
+ const branch = tryGit(dir, ['symbolic-ref', '--short', '-q', 'HEAD']);
66
+ const [gitDir, commonDir] = (tryGit(dir, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir']) ?? '').split('\n');
67
+ const worktree = Boolean(gitDir && commonDir) && gitDir !== commonDir;
68
+ const uncommitted = uncommittedIn(dir);
69
+ let aheadOf = tryGit(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']) ? 'upstream' : null;
70
+ if (!aheadOf) {
71
+ const head = tryGit(dir, ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
72
+ if (head) aheadOf = head;
73
+ }
74
+ const count = aheadOf && tryGit(dir, ['rev-list', '--count', `${aheadOf === 'upstream' ? '@{upstream}' : aheadOf}..HEAD`]);
75
+ const unpushed = count === null || count === undefined ? null : Number(count);
76
+ return { repo, branch, checkout: { worktree, uncommitted, unpushed, aheadOf } };
77
+ }
78
+
79
+ // ---- readers: one per agent, each yielding {agent, dir, title, lastActive(ms)} ----
80
+ function withDb(path, unreadable, use) {
81
+ if (!existsSync(path)) { if (existsSync(dirname(path))) unreadable.add(tilde(path)); return; }
82
+ let d;
83
+ try {
84
+ d = new DatabaseSync(path, { readOnly: true });
85
+ use(d);
86
+ } catch { unreadable.add(tilde(path)); } finally { d?.close(); }
87
+ }
88
+ const readJson = (f, unreadable) => {
89
+ if (!existsSync(f)) return null;
90
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { unreadable.add(tilde(f)); return null; }
91
+ };
92
+
93
+ function* claudeCode(since, scanned, unreadable) {
94
+ const root = join(HOME, '.claude/projects');
95
+ for (const f of files(root, '**/*.jsonl')) {
96
+ if (f.includes('/subagents/')) continue;
97
+ scanned['claude-code']++;
98
+ if (mtime(f) < since) continue;
99
+ let dir = null;
100
+ let last = 0;
101
+ let custom = null;
102
+ let ai = null;
103
+ let summary = null;
104
+ let text;
105
+ // A project folder's name is the session's directory, so only the root is named.
106
+ try { text = readFileSync(f, 'utf8'); } catch { unreadable.add(tilde(root)); continue; }
107
+ for (const line of text.split('\n')) {
108
+ if (!line) continue;
109
+ let r;
110
+ try { r = JSON.parse(line); } catch { continue; }
111
+ if (!dir && typeof r.cwd === 'string') dir = r.cwd;
112
+ const t = Date.parse(r.timestamp);
113
+ if (t > last) last = t;
114
+ if (r.type === 'custom-title') custom = r.customTitle;
115
+ else if (r.type === 'ai-title') ai = r.aiTitle;
116
+ else if (r.type === 'summary') summary = r.summary;
117
+ }
118
+ last ||= mtime(f);
119
+ if (dir && last >= since) yield { agent: 'claude-code', dir, title: title(custom ?? ai ?? summary), lastActive: last };
120
+ }
121
+ }
122
+
123
+ function* codex(since, scanned, unreadable) {
124
+ const root = codexHome();
125
+ const seen = new Set();
126
+ const rows = [];
127
+ withDb(codexStateDb(), unreadable, (d) => {
128
+ for (const r of d.prepare('select * from threads').all()) {
129
+ if (r.archived || r.thread_source === 'subagent') continue;
130
+ const ms = r.updated_at_ms ?? (r.updated_at > 1e12 ? r.updated_at : r.updated_at * 1000);
131
+ // `title` is Codex's generated thread name; when it only mirrors the first prompt it is not a title.
132
+ const t = r.title && r.title !== r.first_user_message ? title(r.title) : null;
133
+ rows.push({ id: r.id, agent: 'codex', dir: r.cwd, title: t, lastActive: ms });
134
+ }
135
+ });
136
+ for (const r of rows) { seen.add(r.id); scanned.codex++; if (r.lastActive >= since) yield r; }
137
+ // Rollouts not indexed in the state db (older Codex, or a db that wouldn't open). First line is session_meta.
138
+ for (const f of files(root, 'sessions/**/*.jsonl')) {
139
+ const id = f.match(/rollout-.*-([0-9a-f-]{36})\.jsonl$/)?.[1];
140
+ if (id && seen.has(id)) continue;
141
+ scanned.codex++;
142
+ if (mtime(f) < since) continue;
143
+ let meta;
144
+ try { meta = JSON.parse(readFileSync(f, 'utf8').split('\n', 1)[0]); } catch { unreadable.add(tilde(f)); continue; }
145
+ if (meta?.type !== 'session_meta' || typeof meta.payload?.cwd !== 'string') continue;
146
+ yield { agent: 'codex', dir: meta.payload.cwd, title: null, lastActive: mtime(f) };
147
+ }
148
+ }
149
+
150
+ function* openCode(since, scanned, unreadable) {
151
+ const rows = [];
152
+ withDb(join(HOME, '.local/share/opencode/opencode.db'), unreadable, (d) => {
153
+ for (const r of d.prepare('select directory, title, time_updated from session where parent_id is null and time_archived is null').all()) {
154
+ rows.push({ agent: 'opencode', dir: r.directory, title: title(r.title), lastActive: r.time_updated });
155
+ }
156
+ });
157
+ for (const r of rows) { scanned.opencode++; if (r.lastActive >= since) yield r; }
158
+ }
159
+
160
+ const CURSOR_USER_DIRS = ['Library/Application Support/Cursor/User', '.config/Cursor/User'];
161
+ function* cursor(since, scanned, unreadable) {
162
+ // cursor-agent CLI: one dir per chat with meta.json (cwd, times) and store.db (meta row "0": hex JSON with `name`).
163
+ for (const metaFile of files(join(HOME, '.cursor/chats'), '*/*/meta.json')) {
164
+ scanned.cursor++;
165
+ const meta = readJson(metaFile, unreadable);
166
+ if (!meta || meta.isSubagent || typeof meta.cwd !== 'string') continue;
167
+ const last = meta.updatedAtMs ?? meta.createdAtMs ?? mtime(metaFile);
168
+ if (last < since) continue;
169
+ let name = title(meta.title);
170
+ if (!name) {
171
+ withDb(join(dirname(metaFile), 'store.db'), unreadable, (d) => {
172
+ const v = d.prepare("select value from meta where key = '0'").get()?.value;
173
+ if (v) name = title(JSON.parse(Buffer.from(String(v), 'hex').toString('utf8')).name);
174
+ });
175
+ }
176
+ yield { agent: 'cursor', dir: meta.cwd, title: name, lastActive: last };
177
+ }
178
+ // Cursor IDE: each workspace dir names its folder; its state.vscdb lists the composers (chats) opened there.
179
+ for (const userDir of CURSOR_USER_DIRS) {
180
+ const storage = join(HOME, userDir, 'workspaceStorage');
181
+ if (!existsSync(storage)) continue;
182
+ for (const ws of readdirSync(storage)) {
183
+ const folder = readJson(join(storage, ws, 'workspace.json'), unreadable)?.folder;
184
+ if (typeof folder !== 'string' || !folder.startsWith('file://')) continue;
185
+ let dir;
186
+ try { dir = fileURLToPath(folder); } catch { continue; }
187
+ const composers = [];
188
+ withDb(join(storage, ws, 'state.vscdb'), unreadable, (d) => {
189
+ const v = d.prepare("select value from ItemTable where key = 'composer.composerData'").get()?.value;
190
+ if (v) composers.push(...(JSON.parse(String(v)).allComposers ?? []));
191
+ });
192
+ for (const c of composers) {
193
+ scanned.cursor++;
194
+ const last = c.lastUpdatedAt ?? c.createdAt;
195
+ if (last >= since) yield { agent: 'cursor', dir, title: title(c.name), lastActive: last };
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // ---- gather: sessions -> work-streams keyed by (repo, branch), or an opaque local index ----
202
+ // A stream keeps one entry per checkout it was worked in (two clones or worktrees can share a branch),
203
+ // in order of first use, and each session names its checkout by index: no directory leaves the file.
204
+ export function gather({ days = 14 } = {}) {
205
+ const since = Date.now() - days * 86400000;
206
+ const scanned = Object.fromEntries(AGENTS.map((a) => [a, 0]));
207
+ const unreadable = new Set();
208
+ const states = new Map();
209
+ const streams = new Map();
210
+ let locals = 0;
211
+ // A checkout is its work tree's root, so sessions in its subdirectories share it.
212
+ const roots = new Map();
213
+ const rootOf = (dir) => {
214
+ if (!roots.has(dir)) roots.set(dir, existsSync(dir) ? tryGit(dir, ['rev-parse', '--show-toplevel']) || dir : dir);
215
+ return roots.get(dir);
216
+ };
217
+ const stateOf = (root) => {
218
+ if (!states.has(root)) states.set(root, gitState(root));
219
+ return states.get(root);
220
+ };
221
+ for (const reader of [claudeCode, codex, openCode, cursor]) {
222
+ for (const s of reader(since, scanned, unreadable)) {
223
+ const root = rootOf(s.dir);
224
+ const state = stateOf(root);
225
+ const key = state.repo && state.branch ? `${state.repo}#${state.branch}` : `dir:${root}`;
226
+ if (!streams.has(key)) {
227
+ const local = !(state.repo && state.branch);
228
+ const head = state.gone ? { gone: true } : { repo: state.repo, branch: state.branch, pr: openPr(state.repo, state.branch, unreadable), checkouts: [] };
229
+ streams.set(key, { key: local ? `local-${++locals}` : key, ...head, dirs: [], lastActive: 0, sessions: [] });
230
+ }
231
+ const stream = streams.get(key);
232
+ let checkout;
233
+ if (state.checkout) {
234
+ checkout = stream.dirs.indexOf(root);
235
+ if (checkout < 0) {
236
+ checkout = stream.dirs.push(root) - 1;
237
+ stream.checkouts.push(state.checkout);
238
+ }
239
+ }
240
+ stream.sessions.push({ agent: s.agent, title: s.title, lastActive: iso(s.lastActive), ...(checkout === undefined ? {} : { checkout }) });
241
+ if (s.lastActive > stream.lastActive) stream.lastActive = s.lastActive;
242
+ }
243
+ log(`${reader.name}: done`);
244
+ }
245
+ const out = [...streams.values()].sort((a, b) => b.lastActive - a.lastActive).map(({ dirs, ...s }) => ({
246
+ ...s,
247
+ lastActive: iso(s.lastActive),
248
+ sessions: s.sessions.sort((a, b) => String(b.lastActive).localeCompare(String(a.lastActive))),
249
+ }));
250
+ return { days, generatedAt: new Date().toISOString(), scanned, unreadable: [...unreadable].sort(), streams: out };
251
+ }
252
+
253
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
254
+ const i = process.argv.indexOf('--days');
255
+ const facts = gather({ days: i > 0 ? Number(process.argv[i + 1]) : 14 });
256
+ process.stdout.write(`${JSON.stringify(facts)}\n`);
257
+ log(`${facts.streams.length} streams`);
258
+ }
@@ -8,6 +8,12 @@ license: MIT
8
8
 
9
9
  Two scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.
10
10
 
11
+ ## 0. Hand the Mac half to the bot on the Mac
12
+
13
+ You run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.
14
+
15
+ With no bot that runs `local`, skip this step and say nothing about a Mac.
16
+
11
17
  ## 1. Ask for Kit access, once
12
18
 
13
19
  Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: "vercel-kit", tool: "request_access", input: { ... } })`, and the same shape for the others. Everywhere below, "call `request_access`" means that.
@@ -119,6 +125,7 @@ Fix every line it prints and rerun until it prints nothing, then post the file's
119
125
  - Below it, the two tables, then the lines under them and nothing else:
120
126
  - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of "merging it turns them on".
121
127
  - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.
128
+ - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.
122
129
  - Nothing else goes in: not the profile, the "Could not tell" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.
123
130
 
124
131
  Only the schedule files ever leave the thread.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -39,6 +39,10 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "ai": "^7.0.137",
42
- "jose": "^6.2.3"
42
+ "jose": "^6.2.3",
43
+ "jsonc-parser": "3.3.1",
44
+ "smol-toml": "^1.9.1",
45
+ "tree-sitter-bash": "0.25.1",
46
+ "web-tree-sitter": "0.27.1"
43
47
  }
44
48
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/build-onboarding.mjs from local-onboarding/SKILL.md. Do not edit.
2
+ export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","markdown":"# The Mac half of onboarding\n\nMain runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.\n\nTwo scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.\n\n## 1. Live work to port\n\n```sh\nnode {{scripts}}/sessions-facts.mjs > \"$TMPDIR/sessions.json\"\n```\n\nThe script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:\n- `repo`, `branch` and `pr` (the open PR);\n- `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);\n- `lastActive`;\n- `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).\n\nA stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.\n\nPost the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with \"unknown\" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.\n\n```md\nYou have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.\n\n---\n\n| Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |\n|---|---|---|---|---|---|\n| `vercel/app` on `fix-login` (worktree) | Claude Code, \"Fix login redirect\" | 2h ago | 4 files | 2 | [#812](...) |\n\nAlso 5 streams with nothing in flight.\n```\n\nNever post a local path, a file name, a prompt, or anything else from the sessions.\n\nWhen the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.\n\n## 2. Sandbox setup\n\n```sh\nnode {{scripts}}/agent-usage-facts.mjs > \"$TMPDIR/agent-usage.json\"\n```\n\nIt takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:\n- `scanned`: records, commands and unparsable commands per agent;\n- `unreadable`;\n- `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.\n\nMapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:\n\n- **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:\n - `brew`, `osascript` and `open`;\n - Xcode, `xcodebuild` and Apple-platform `swift`;\n - anything that drives the owner's browser or apps;\n - `localhost` services and the keychain.\n\n Everything else can be hosted.\n- **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.\n\nMCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.\n\nPost the proposal:\n- a first bubble on what the hosted bots would gain;\n- a table of the steps to add, with the programs each installs and how often the owner used them;\n- one line for what stays on the Mac;\n- one line for the MCP servers to connect through Kit.\n\nAsk whether to open the PR.\n\n## 3. The sandbox PR, after the owner's yes\n\nThe steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{\"commands\": [{\"command\": \"<shell>\", \"what\": \"<short name for the error>\"}]}`.\n\nMain's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.\n\n```sh\nnode {{scripts}}/prepare-edit.mjs \"$TMPDIR/prepare.json\" agents/builder/agent/sandbox.ts\n```\n\nCommit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.\n\nThe PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.\n"};
@@ -0,0 +1,9 @@
1
+ import { dirname, join } from "node:path";
2
+ import { defineSkill } from "eve/skills";
3
+ import { templatesDir } from "../main-instructions.mjs";
4
+ import skill from "./local-onboarding.gen.mjs";
5
+ // The local-onboarding skill: local-onboarding/SKILL.md, embedded at build time
6
+ // (scripts/build-onboarding.mjs). Its scripts run from the installed kit, beside
7
+ // the dependencies agent-usage-facts.mjs imports.
8
+ const scripts = join(dirname(templatesDir()), "local-onboarding");
9
+ export default defineSkill({ ...skill, markdown: skill.markdown.replaceAll("{{scripts}}", scripts) });
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
2
- export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
2
+ export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
package/stubs.mjs CHANGED
@@ -18,6 +18,7 @@ export const KIT_FILES = {
18
18
  "tools/list_agents.ts": ["The team right now: kind, place, tools, models, who's busy."],
19
19
  "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
20
20
  "skills/forms.ts": ["The forms skill (packages/agent-kit/forms/SKILL.md): how to draw a flow, sequence, chart or ask the app renders."],
21
+ "skills/local-onboarding.ts": ["The local-onboarding skill (packages/agent-kit/local-onboarding/SKILL.md): the Mac half of onboarding, the live work to port and the sandbox setup."],
21
22
  };
22
23
 
23
24
  // main, the orchestrator: the shared files (the same channel as every bot),
@@ -1,3 +1,13 @@
1
1
  - You run on the owner's Mac, not on Vercel: your shell is the owner's machine, with real filesystem paths.
2
2
  - You are made of files in the workspace repository: `agents/<bot>/`, `memory/` and `hub.workspace.json`. A change to them takes effect when you are restarted; memory entries are recalled every turn.
3
3
  - Link a local file by its absolute path and line, ``[`docs/setup.md:42`](/Users/<you>/wrk/<repo>/docs/setup.md#L42)``; clicking opens it in the human's editor.
4
+ - **You run only while the Hub app is open.** A message to you waits until the owner opens the Hub app, so a message that can't wait belongs with a hosted bot, if the workspace has one.
5
+ - **The shell is the owner's.** `git`, `gh` and the keychain use the owner's own sign-ins. Programs come from Homebrew (`brew`) or the owner's own installs; `brew list` and `which` tell you what's there.
6
+ - **The owner's checkouts are theirs.** Unless the owner asks, never edit, commit, stash or switch branches in one. Work in your own `git worktree add`, and leave theirs as you found them.
7
+ - **Coding agents keep their sessions here:** Claude Code in `~/.claude/projects/`, Codex in `~/.codex/sessions/` and `~/.codex/state_*.sqlite` (`~/.codex` is the default for `CODEX_HOME`), OpenCode in `~/.local/share/opencode/`, and Cursor in `~/Library/Application Support/Cursor/User/` and `~/.cursor/`. They hold the owner's prompts and code. Read them for facts such as the folder, branch, title and last activity. Never quote their contents or post them.
8
+ - **Ask first,** with what you'll run and why, before you:
9
+ - read the keychain (`security`) or any credential file;
10
+ - install, upgrade or remove a program (`brew install`, `npm -g`, `pip`);
11
+ - push or publish under the owner's name (`git push`, `gh pr create`, a post outside the workspace);
12
+ - stop a process you didn't start, or delete a file you didn't create.
13
+ Reading is free.