@agentshouse/kit 0.2.1-alpha.5 → 0.2.1-alpha.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.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
4
+ import { request } from 'node:http';
5
+ import { constants as system } from 'node:os';
6
+ import { delimiter, dirname, join } from 'node:path';
7
+ import { WORKING_COPIES } from './git.js';
8
+ import { kitHome } from './home.js';
9
+ const VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--super-prefix']);
10
+ const READS = new Set([
11
+ 'blame',
12
+ 'branch',
13
+ 'cat-file',
14
+ 'check-ignore',
15
+ 'config',
16
+ 'describe',
17
+ 'diff',
18
+ 'fetch',
19
+ 'for-each-ref',
20
+ 'grep',
21
+ 'help',
22
+ 'log',
23
+ 'ls-files',
24
+ 'ls-tree',
25
+ 'reflog',
26
+ 'remote',
27
+ 'rev-list',
28
+ 'rev-parse',
29
+ 'shortlog',
30
+ 'show',
31
+ 'show-ref',
32
+ 'status',
33
+ 'tag',
34
+ 'version',
35
+ ]);
36
+ const RELAYED = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'];
37
+ function nativeGit() {
38
+ const shim = join(kitHome(), 'shim');
39
+ for (const directory of (process.env.PATH ?? '').split(delimiter)) {
40
+ if (directory === '')
41
+ continue;
42
+ try {
43
+ if (realpathSync(directory) === realpathSync(shim))
44
+ continue;
45
+ const candidate = join(directory, 'git');
46
+ if (!statSync(candidate).isFile())
47
+ continue;
48
+ accessSync(candidate, constants.X_OK);
49
+ return candidate;
50
+ }
51
+ catch { }
52
+ }
53
+ return null;
54
+ }
55
+ function captured(git, args) {
56
+ const ran = spawnSync(git, args, {
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ maxBuffer: Infinity,
59
+ env: { ...process.env, LC_ALL: 'C' },
60
+ });
61
+ return ran.status === 0 ? ran.stdout.toString('utf8') : null;
62
+ }
63
+ function delegated(git, args) {
64
+ return new Promise((resolve) => {
65
+ const child = spawn(git, args, { stdio: 'inherit' });
66
+ const relay = (signal) => child.kill(signal);
67
+ for (const signal of RELAYED)
68
+ process.on(signal, relay);
69
+ const settle = (outcome) => {
70
+ for (const signal of RELAYED)
71
+ process.off(signal, relay);
72
+ resolve(outcome);
73
+ };
74
+ child.once('error', (error) => {
75
+ process.stderr.write(`git: ${error.message}\n`);
76
+ settle({ status: 127, signal: null });
77
+ });
78
+ child.once('close', (status, signal) => settle({ status, signal }));
79
+ });
80
+ }
81
+ function staged(listing) {
82
+ const entries = new Map();
83
+ for (const record of (listing ?? '').split('\0')) {
84
+ const tab = record.indexOf('\t');
85
+ if (tab >= 0)
86
+ entries.set(record.slice(tab + 1), record.slice(0, tab).split(' ')[1]);
87
+ }
88
+ return entries;
89
+ }
90
+ function moved(planned, before, after) {
91
+ const moves = [];
92
+ for (const line of planned.split('\n')) {
93
+ if (!line.startsWith('Renaming '))
94
+ continue;
95
+ const renaming = line.slice('Renaming '.length);
96
+ for (let at = renaming.indexOf(' to '); at >= 0; at = renaming.indexOf(' to ', at + 1)) {
97
+ const from = renaming.slice(0, at);
98
+ const to = renaming.slice(at + 4);
99
+ if (before.has(from) && !after.has(from) && after.get(to) === before.get(from)) {
100
+ moves.push({ from, to });
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ return moves;
106
+ }
107
+ function reported(socketPath, body) {
108
+ return new Promise((resolve) => {
109
+ const sent = request({ socketPath, path: '/git/observed', method: 'POST' }, (answer) => {
110
+ answer.resume();
111
+ answer.on('end', resolve);
112
+ });
113
+ sent.on('error', () => resolve());
114
+ sent.end(JSON.stringify(body));
115
+ });
116
+ }
117
+ async function observed(git, args) {
118
+ let index = 0;
119
+ while (index < args.length && args[index].startsWith('-'))
120
+ index += VALUED.has(args[index]) ? 2 : 1;
121
+ const globals = args.slice(0, index);
122
+ const name = args[index];
123
+ const bridge = process.env.HOUSE_BRIDGE;
124
+ if (bridge === undefined || name === undefined || READS.has(name))
125
+ return delegated(git, args);
126
+ const located = captured(git, [...globals, 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
127
+ const [common, worktree] = located?.split('\n') ?? [];
128
+ if (common === undefined || worktree === undefined || !dirname(common).startsWith(`${WORKING_COPIES}/`)) {
129
+ return delegated(git, args);
130
+ }
131
+ const listing = [...globals, '-C', worktree, 'ls-files', '--stage', '-z'];
132
+ const before = name === 'mv' ? staged(captured(git, listing)) : null;
133
+ const planned = name === 'mv' ? (captured(git, [...globals, 'mv', '--dry-run', ...args.slice(index + 1)]) ?? '') : '';
134
+ const outcome = await delegated(git, args);
135
+ const moves = before !== null && outcome.status === 0 ? moved(planned, before, staged(captured(git, listing))) : [];
136
+ await reported(bridge, { common, worktree, moves });
137
+ return outcome;
138
+ }
139
+ const git = nativeGit();
140
+ if (git === null) {
141
+ process.stderr.write('git: no installed Git found on PATH\n');
142
+ process.exitCode = 127;
143
+ }
144
+ else {
145
+ observed(git, process.argv.slice(2)).then(({ status, signal }) => {
146
+ if (signal === null) {
147
+ process.exitCode = status ?? 1;
148
+ return;
149
+ }
150
+ process.exitCode = 128 + system.signals[signal];
151
+ process.kill(process.pid, signal);
152
+ });
153
+ }
package/dist/git.js ADDED
@@ -0,0 +1,81 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { withoutProxy } from './clis.js';
4
+ export const WORKING_COPIES = '/agents/house/working-copies';
5
+ export const HOUSE_REF = 'refs/house/received';
6
+ const COMMITTER = 'House Kit <house-kit@localhost>';
7
+ export function run(cwd, args, input, env = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn('git', args, { cwd, env: { ...withoutProxy(), ...env } });
10
+ const stdout = [];
11
+ let stderr = '';
12
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
13
+ child.stderr.on('data', (chunk) => {
14
+ stderr += chunk.toString('utf8');
15
+ });
16
+ child.on('error', reject);
17
+ child.on('close', (status) => resolve({ status, stdout: Buffer.concat(stdout), stderr }));
18
+ child.stdin.on('error', () => undefined);
19
+ child.stdin.end(input);
20
+ });
21
+ }
22
+ export async function git(cwd, args, input, env) {
23
+ const ran = await run(cwd, args, input, env);
24
+ if (ran.status !== 0)
25
+ throw new Error(`git ${args[0]} failed: ${ran.stderr.trim()}`);
26
+ return ran.stdout.toString('utf8');
27
+ }
28
+ export async function succeeded(cwd, args, env) {
29
+ return (await run(cwd, args, undefined, env)).status === 0;
30
+ }
31
+ export async function revision(cwd, name) {
32
+ return (await git(cwd, ['rev-parse', '--verify', `${name}^{commit}`])).trim();
33
+ }
34
+ export async function tree(repository, commit) {
35
+ const listed = await git(repository, ['ls-tree', '-r', '-z', commit]);
36
+ const entries = new Map();
37
+ for (const entry of listed.split('\0')) {
38
+ const tab = entry.indexOf('\t');
39
+ if (tab < 0)
40
+ continue;
41
+ const [, type, object] = entry.slice(0, tab).split(' ');
42
+ if (type === 'blob')
43
+ entries.set(entry.slice(tab + 1), object);
44
+ }
45
+ return entries;
46
+ }
47
+ export async function blobText(repository, object, path) {
48
+ const bytes = (await run(repository, ['cat-file', 'blob', object])).stdout;
49
+ try {
50
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
51
+ }
52
+ catch {
53
+ throw new Error(`${path} is not UTF-8 text`);
54
+ }
55
+ }
56
+ export function blobId(content) {
57
+ const bytes = Buffer.from(content, 'utf8');
58
+ return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
59
+ }
60
+ function quoted(path) {
61
+ return `"${path.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')}"`;
62
+ }
63
+ function data(text) {
64
+ const bytes = Buffer.from(text, 'utf8');
65
+ return [Buffer.from(`data ${bytes.length}\n`), bytes, Buffer.from('\n')];
66
+ }
67
+ export async function importCommits(repository, commits) {
68
+ const stream = [];
69
+ for (const [index, commit] of commits.entries()) {
70
+ stream.push(Buffer.from(`commit ${HOUSE_REF}\nmark :${index + 1}\ncommitter ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`), ...data(commit.message), ...(commit.from === null ? [] : [Buffer.from(`from ${commit.from}\n`)]), ...(commit.merge === undefined ? [] : [Buffer.from(`merge ${commit.merge}\n`)]), ...(commit.replaces === true ? [Buffer.from('deleteall\n')] : []));
71
+ for (const [path, content] of commit.files) {
72
+ if (content === null)
73
+ stream.push(Buffer.from(`D ${quoted(path)}\n`));
74
+ else
75
+ stream.push(Buffer.from(`M 100644 inline ${quoted(path)}\n`), ...data(content));
76
+ }
77
+ stream.push(Buffer.from('\n'));
78
+ }
79
+ await git(repository, ['fast-import', '--quiet', '--force'], Buffer.concat(stream));
80
+ return revision(repository, HOUSE_REF);
81
+ }
@@ -2,12 +2,15 @@
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { request } from 'node:http';
5
- import { basename } from 'node:path';
5
+ import { basename, join } from 'node:path';
6
+ import { kitHome } from './home.js';
6
7
  const USAGE = "usage: house <tool> ['<arguments as JSON>']";
8
+ const GIT_HELP = 'Submit a Room commit to House; default HEAD. Use --owner only outside an Agent conversation. Native git push is not a House remote.';
9
+ const GIT_USAGE = `usage: house git push [commit] [--owner]\n${GIT_HELP}`;
7
10
  const socketPath = process.env.HOUSE_BRIDGE;
8
- function bridged(path, body, headers = { 'content-type': 'application/json' }) {
11
+ function bridged(path, body, headers = { 'content-type': 'application/json' }, to = socketPath) {
9
12
  return new Promise((resolve, reject) => {
10
- const sent = request({ socketPath, path, method: 'POST', headers }, (answer) => {
13
+ const sent = request({ socketPath: to, path, method: 'POST', headers }, (answer) => {
11
14
  let text = '';
12
15
  answer.setEncoding('utf8');
13
16
  answer.on('data', (chunk) => {
@@ -47,9 +50,25 @@ async function upload(path, roomRef) {
47
50
  'x-house-byte-operation': declared.upload.operation,
48
51
  });
49
52
  }
53
+ async function gitPush(args) {
54
+ if (args.includes('--help'))
55
+ return GIT_USAGE;
56
+ if (args[0] !== 'push')
57
+ throw new Error(GIT_USAGE);
58
+ const owner = args.includes('--owner');
59
+ const commit = args.slice(1).find((arg) => arg !== '--owner') ?? 'HEAD';
60
+ if (owner && socketPath !== undefined)
61
+ throw new Error('house git push --owner runs outside an Agent conversation');
62
+ if (!owner && socketPath === undefined)
63
+ throw new Error('outside an Agent conversation, house git push needs --owner');
64
+ const pushed = JSON.parse(await bridged('/git/push', { cwd: process.cwd(), commit }, undefined, owner ? join(kitHome(), 'owner.sock') : socketPath));
65
+ if (pushed.refused)
66
+ throw new Error(pushed.text);
67
+ return pushed.text;
68
+ }
50
69
  async function house([verb, argument]) {
51
70
  if (verb === undefined || verb === '--help') {
52
- return [USAGE, ...(await tools()).map((tool) => `${tool.name}: ${tool.description}`)].join('\n');
71
+ return [USAGE, `git push [commit]: ${GIT_HELP}`, ...(await tools()).map((tool) => `${tool.name}: ${tool.description}`)].join('\n');
53
72
  }
54
73
  if (argument === '--help') {
55
74
  const tool = (await tools()).find((listed) => listed.name === verb);
@@ -73,12 +92,13 @@ async function house([verb, argument]) {
73
92
  throw new Error(text);
74
93
  return text;
75
94
  }
76
- if (socketPath === undefined) {
95
+ const argv = process.argv.slice(2);
96
+ if (argv[0] !== 'git' && socketPath === undefined) {
77
97
  process.stderr.write('house: house runs inside an Agent conversation\n');
78
98
  process.exitCode = 1;
79
99
  }
80
100
  else {
81
- house(process.argv.slice(2)).then((text) => {
101
+ (argv[0] === 'git' ? gitPush(argv.slice(1)) : house(argv)).then((text) => {
82
102
  process.stdout.write(`${text}\n`);
83
103
  }, (error) => {
84
104
  process.stderr.write(`house: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -1,6 +1,7 @@
1
1
  import { readEnrolment } from './home.js';
2
2
  const HOUSE_LINE = 'Work with House through the `house` CLI: run `house --help`.';
3
3
  const FILE_LINE = 'To give the User a file, upload it with `house upload_attachment` and link it in your answer.';
4
+ const COPIES_LINE = 'These House paths are Git working copies here; commit in one and run `house git push` to send the commit to House:';
4
5
  const HOW_WE_WORK = '/private/library/how-we-work.md';
5
6
  const UNREADABLE = /^(?:\S+: )?(?:path_not_found|room_not_found|operation_denied)\b/;
6
7
  const CUT = /^stderr: shell: output_cut (\d+) of \d+ bytes; continue with: (.+)$/;
@@ -24,8 +25,9 @@ async function howWeWork(bridge) {
24
25
  }
25
26
  return document.replace(/\n$/, '');
26
27
  }
27
- export async function instructions(bridge, base) {
28
+ export async function instructions(bridge, base, copies) {
28
29
  const app = new URL('/app/new.git', (await readEnrolment()).house).href;
29
30
  const appLine = `To show the User a page, upload it or push it to an App instead of starting a server; \`git clone ${app}\` starts a new App.`;
30
- return [`${HOUSE_LINE}\n${FILE_LINE}\n${appLine}`, base, await howWeWork(bridge)].filter((part) => part !== '').join('\n\n');
31
+ const lines = [HOUSE_LINE, FILE_LINE, appLine, ...(copies.length === 0 ? [] : [COPIES_LINE, ...copies])];
32
+ return [lines.join('\n'), base, await howWeWork(bridge)].filter((part) => part !== '').join('\n\n');
31
33
  }
@@ -0,0 +1,9 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ export function operationId() {
3
+ const bytes = randomBytes(16);
4
+ bytes.writeUIntBE(Date.now(), 0, 6);
5
+ bytes[6] = 0x70 | (bytes[6] & 0x0f);
6
+ bytes[8] = 0x80 | (bytes[8] & 0x3f);
7
+ const hex = bytes.toString('hex');
8
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
9
+ }
package/dist/resident.js CHANGED
@@ -1,8 +1,15 @@
1
+ import { once } from 'node:events';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { createServer } from 'node:http';
4
+ import { extname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
1
6
  import { killDescendants } from './acp.js';
2
7
  import { Agents } from './agents.js';
3
8
  import { house } from './api.js';
9
+ import { received } from './bridge.js';
4
10
  import { Conversations } from './conversations.js';
5
- import { readEnrolment } from './home.js';
11
+ import { WorkingCopies } from './copies.js';
12
+ import { kitHome, readEnrolment } from './home.js';
6
13
  import { SignIns } from './sign-in.js';
7
14
  import { holdStream } from './stream.js';
8
15
  function report(error) {
@@ -11,6 +18,36 @@ function report(error) {
11
18
  function logged(work) {
12
19
  work.catch(report);
13
20
  }
21
+ function quoted(value) {
22
+ return `'${value.replaceAll("'", `'\\''`)}'`;
23
+ }
24
+ async function installShim() {
25
+ const main = fileURLToPath(new URL(`./git-main${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
26
+ await mkdir(join(kitHome(), 'shim'), { recursive: true });
27
+ await writeFile(join(kitHome(), 'shim', 'git'), `#!/bin/sh\nexec ${quoted(process.execPath)} ${quoted(main)} "$@"\n`, {
28
+ mode: 0o755,
29
+ });
30
+ }
31
+ async function serveOwner(copies) {
32
+ const socketPath = join(kitHome(), 'owner.sock');
33
+ await rm(socketPath, { force: true });
34
+ const server = createServer(async (request, response) => {
35
+ try {
36
+ if (request.url !== '/git/push') {
37
+ response.writeHead(404).end();
38
+ return;
39
+ }
40
+ const { cwd, commit } = JSON.parse(await received(request));
41
+ const pushed = await copies.push(cwd, commit, copies.owner);
42
+ response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(pushed));
43
+ }
44
+ catch (error) {
45
+ response.writeHead(502).end(error instanceof Error ? error.message : String(error));
46
+ }
47
+ });
48
+ server.listen(socketPath);
49
+ await once(server, 'listening');
50
+ }
14
51
  export async function resident() {
15
52
  if ((await readEnrolment()) === null) {
16
53
  process.stderr.write('kit: this Environment is not enrolled; run kit login\n');
@@ -19,6 +56,10 @@ export async function resident() {
19
56
  }
20
57
  process.env.HOUSE_KIT_RESIDENT = String(process.pid);
21
58
  const agents = new Agents(house);
59
+ const copies = new WorkingCopies(house);
60
+ await copies.load();
61
+ await installShim();
62
+ await serveOwner(copies);
22
63
  let stream = null;
23
64
  let reported = false;
24
65
  const idle = () => conversations.idle() && signIns.idle();
@@ -34,6 +75,7 @@ export async function resident() {
34
75
  const conversations = new Conversations({
35
76
  house,
36
77
  agents,
78
+ copies,
37
79
  send: (frame) => stream?.send(frame) ?? false,
38
80
  changed,
39
81
  });
@@ -50,16 +92,22 @@ export async function resident() {
50
92
  opened: () => {
51
93
  conversations.opened();
52
94
  changed();
95
+ logged(copies.select());
53
96
  },
54
- frame: (received) => {
55
- if (received.type === 'work_available' && received.subject === 'agents')
97
+ frame: (arrived) => {
98
+ if (arrived.type === 'work_available' && arrived.subject === 'agents')
56
99
  logged(agents.refresh());
57
- if (received.type === 'input') {
100
+ if (arrived.type === 'work_available' && arrived.subject === 'working_copy')
101
+ logged(copies.select());
102
+ if (arrived.type === 'entries') {
103
+ copies.entries(arrived);
104
+ }
105
+ if (arrived.type === 'input') {
58
106
  reported = false;
59
- if (received.kind === 'sign_in')
60
- signIns.start(received);
107
+ if (arrived.kind === 'sign_in')
108
+ signIns.start(arrived);
61
109
  else
62
- conversations.input(received);
110
+ conversations.input(arrived);
63
111
  }
64
112
  },
65
113
  });
@@ -0,0 +1,49 @@
1
+ import { apply, parse, serialize } from '@agentshouse/mdmodel';
2
+ function same(left, right) {
3
+ return JSON.stringify(left) === JSON.stringify(right);
4
+ }
5
+ function applied(document, changes) {
6
+ let current = document;
7
+ for (const change of changes) {
8
+ const result = apply(current, change);
9
+ if (result.outcome !== 'applied')
10
+ return null;
11
+ current = result.document;
12
+ }
13
+ return serialize(current);
14
+ }
15
+ function components(path, before, after, text) {
16
+ const edits = [];
17
+ const changes = [];
18
+ if (before.preamble !== after.preamble) {
19
+ edits.push({ op: 'replace_preamble', path, base: before.preamble, content: after.preamble });
20
+ changes.push({ kind: 'preamble', value: after.preamble, prior: before.preamble });
21
+ }
22
+ for (const name of new Set([...Object.keys(before.fields), ...Object.keys(after.fields)])) {
23
+ const prior = before.fields[name];
24
+ const value = after.fields[name];
25
+ if (same(prior, value))
26
+ continue;
27
+ if (value === undefined)
28
+ return null;
29
+ edits.push({ op: 'set_field', path, field: name, ...(prior === undefined ? {} : { base: prior }), value });
30
+ changes.push({ kind: 'field', name, value, prior });
31
+ }
32
+ for (const name of new Set([...Object.keys(before.sections), ...Object.keys(after.sections)])) {
33
+ const prior = before.sections[name];
34
+ const value = after.sections[name];
35
+ if (prior === value)
36
+ continue;
37
+ if (prior === undefined || value === undefined)
38
+ return null;
39
+ edits.push({ op: 'replace_section', path, section: name, base: prior, content: value });
40
+ changes.push({ kind: 'section', name, value, prior });
41
+ }
42
+ return edits.length > 0 && applied(before, changes) === text ? edits : null;
43
+ }
44
+ export function changed(path, file, base, before, after) {
45
+ const parsedBefore = parse(before, file);
46
+ const parsedAfter = parse(after, file);
47
+ const structured = parsedBefore.ok && parsedAfter.ok ? components(path, parsedBefore.document, parsedAfter.document, after) : null;
48
+ return structured ?? [{ op: 'replace', path, base, content: after }];
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.2.1-alpha.5",
3
+ "version": "0.2.1-alpha.6",
4
4
  "description": "House Kit connects an Environment to agents.house.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,9 @@
20
20
  "test": "tsc -p tsconfig.json && vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@agentclientprotocol/sdk": "1.7.0"
23
+ "@agentclientprotocol/sdk": "1.7.0",
24
+ "@agentshouse/mdmodel": "0.1.0-alpha.15",
25
+ "yaml": "2.9.1"
24
26
  },
25
27
  "peerDependencies": {
26
28
  "@agentclientprotocol/claude-agent-acp": "0.85.1",