@agentshouse/kit 0.2.1-alpha.9 → 0.2.1-c05cd91c26b1d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +45 -26
  2. package/dist/abort.js +9 -0
  3. package/dist/acp.js +48 -85
  4. package/dist/agents.js +312 -53
  5. package/dist/api.js +40 -4
  6. package/dist/app-git.js +48 -0
  7. package/dist/bootstrap.json +10 -14
  8. package/dist/bridge.js +92 -77
  9. package/dist/chatgpt.js +32 -0
  10. package/dist/clis-main.js +23 -0
  11. package/dist/clis.js +213 -71
  12. package/dist/codex-main.js +58 -0
  13. package/dist/conversations.js +428 -233
  14. package/dist/copies.js +233 -171
  15. package/dist/end.js +23 -0
  16. package/dist/enrol-main.js +7 -1
  17. package/dist/events.js +525 -0
  18. package/dist/files.js +24 -3
  19. package/dist/git-main.js +2 -2
  20. package/dist/git.js +15 -9
  21. package/dist/home.js +127 -9
  22. package/dist/house-cli.js +291 -0
  23. package/dist/house-main.js +5 -102
  24. package/dist/inputs.js +37 -0
  25. package/dist/instructions.js +10 -31
  26. package/dist/kit-cli.js +68 -0
  27. package/dist/kit-main.js +8 -16
  28. package/dist/login.js +129 -94
  29. package/dist/logout.js +19 -0
  30. package/dist/mcp.js +39 -0
  31. package/dist/operation.js +7 -4
  32. package/dist/parts.js +82 -0
  33. package/dist/placement.js +30 -0
  34. package/dist/refusals.js +53 -0
  35. package/dist/relay.js +20 -0
  36. package/dist/resident.js +122 -41
  37. package/dist/scope.js +203 -0
  38. package/dist/secret-input.js +7 -2
  39. package/dist/service-main.js +9 -0
  40. package/dist/service.js +33 -0
  41. package/dist/shell.js +3 -0
  42. package/dist/sign-in.js +47 -28
  43. package/dist/skills.js +80 -16
  44. package/dist/stream.js +89 -13
  45. package/dist/transcription.js +170 -0
  46. package/dist/update.js +138 -0
  47. package/dist/wire.js +6 -0
  48. package/dist/work.js +346 -0
  49. package/how-we-work.md +22 -14
  50. package/package.json +5 -7
  51. package/skills/ask-matt/SKILL.md +17 -11
  52. package/skills/ask-matt/agents/openai.yaml +1 -0
  53. package/skills/code-review/SKILL.md +5 -5
  54. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  55. package/skills/codebase-design/SKILL.md +0 -2
  56. package/skills/diagnosing-bugs/SKILL.md +2 -4
  57. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  58. package/skills/domain-modeling/SKILL.md +12 -14
  59. package/skills/grill-me/SKILL.md +1 -2
  60. package/skills/grill-me/agents/openai.yaml +1 -0
  61. package/skills/grill-with-docs/SKILL.md +1 -2
  62. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  63. package/skills/grilling/SKILL.md +2 -2
  64. package/skills/handoff/SKILL.md +2 -3
  65. package/skills/handoff/agents/openai.yaml +1 -0
  66. package/skills/implement/SKILL.md +5 -4
  67. package/skills/implement/agents/openai.yaml +1 -0
  68. package/skills/implement-spec/SKILL.md +40 -0
  69. package/skills/implement-spec/agents/openai.yaml +5 -0
  70. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  71. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  72. package/skills/pr/CREDITS.md +3 -0
  73. package/skills/pr/LICENSE +21 -0
  74. package/skills/pr/SKILL.md +170 -0
  75. package/skills/pr/agents/openai.yaml +3 -0
  76. package/skills/prototype/SKILL.md +0 -2
  77. package/skills/research/SKILL.md +0 -2
  78. package/skills/retro/LICENSE +21 -0
  79. package/skills/retro/SKILL.md +44 -0
  80. package/skills/retro/agents/openai.yaml +5 -0
  81. package/skills/tdd/SKILL.md +2 -4
  82. package/skills/teach/SKILL.md +3 -4
  83. package/skills/teach/agents/openai.yaml +1 -0
  84. package/skills/to-questionnaire/SKILL.md +1 -2
  85. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  86. package/skills/to-spec/SKILL.md +1 -2
  87. package/skills/to-spec/agents/openai.yaml +1 -0
  88. package/skills/to-tickets/SKILL.md +3 -4
  89. package/skills/to-tickets/agents/openai.yaml +1 -0
  90. package/skills/triage/AGENT-BRIEF.md +207 -0
  91. package/skills/triage/LICENSE +21 -0
  92. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  93. package/skills/triage/SKILL.md +112 -0
  94. package/skills/triage/agents/openai.yaml +5 -0
  95. package/skills/wait-what/SKILL.md +2 -3
  96. package/skills/wait-what/agents/openai.yaml +1 -0
  97. package/skills/wayfinder/SKILL.md +5 -6
  98. package/skills/wayfinder/agents/openai.yaml +1 -0
  99. package/skills/wizard/LICENSE +21 -0
  100. package/skills/wizard/SKILL.md +44 -0
  101. package/skills/wizard/agents/openai.yaml +3 -0
  102. package/skills/wizard/template.sh +223 -0
  103. package/skills/writing-for-agents/SKILL.md +0 -2
  104. package/dist/blocks.js +0 -51
  105. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  106. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  107. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
package/dist/bridge.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { once } from 'node:events';
3
- import { mkdir } from 'node:fs/promises';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { createServer, request as httpRequest } from 'node:http';
5
5
  import { request as httpsRequest } from 'node:https';
6
- import { delimiter, join } from 'node:path';
6
+ import { delimiter, dirname, join } from 'node:path';
7
7
  import { parse } from 'yaml';
8
- import { KIT_VERSION } from './clis.js';
9
- import { changedPaths, refusalCode, } from './copies.js';
10
- import { kitHome, readEnrolment } from './home.js';
8
+ import { finalRefusal, HouseRefusal, retryDelay, retrying } from './api.js';
9
+ import { loginPath } from './clis.js';
10
+ import { changedPaths, } from './copies.js';
11
+ import { kitHome, readEnrolment, socketDirectory } from './home.js';
12
+ import { callHouse, mcpBody, rpc, UNREACHABLE } from './mcp.js';
11
13
  import { operationId } from './operation.js';
12
- const MCP_PROTOCOL_VERSION = '2026-07-28';
14
+ import { refusalOf, retryable } from './refusals.js';
15
+ import { relayed } from './relay.js';
13
16
  const MCP_REQUEST_BYTES = 4 * 1024 * 1024;
14
- const FORWARDED = new Set(['/', '/kit/attachments/upload']);
15
17
  export async function received(request) {
16
18
  let text = '';
17
19
  request.setEncoding('utf8');
@@ -19,7 +21,8 @@ export async function received(request) {
19
21
  text += chunk;
20
22
  return text;
21
23
  }
22
- function relay(request, response, target, authorization) {
24
+ function relay(request, response, house, authorization) {
25
+ const target = relayed(house);
23
26
  const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
24
27
  const forwarded = send(target, {
25
28
  method: request.method,
@@ -31,16 +34,37 @@ function relay(request, response, target, authorization) {
31
34
  forwarded.on('error', () => response.destroy());
32
35
  request.pipe(forwarded);
33
36
  }
37
+ function refusalIn(text, status) {
38
+ return refusalOf(text) ?? { code: '', text: text === '' ? `House refused it with ${status}` : text, conflicts: [] };
39
+ }
34
40
  function answered(forwarded) {
35
- if (forwarded.status >= 500)
36
- throw new Error(`House answered ${forwarded.status}`);
37
41
  if (forwarded.status !== 200)
38
- return { refused: { code: refusalCode(forwarded.text), text: forwarded.text } };
42
+ return { refused: refusalIn(forwarded.text, forwarded.status) };
39
43
  const answer = JSON.parse(forwarded.text);
40
44
  if (answer.error !== undefined)
41
- return { refused: { code: refusalCode(answer.error.message), text: answer.error.message } };
45
+ return { refused: refusalIn(answer.error.message, forwarded.status) };
42
46
  const text = answer.result.content.map((content) => content.text).join('\n');
43
- return answer.result.isError === true ? { refused: { code: refusalCode(text), text } } : { text };
47
+ return answer.result.isError === true ? { refused: refusalIn(text, forwarded.status) } : { text };
48
+ }
49
+ function pendingSeconds(forwarded) {
50
+ if (forwarded.status !== 200)
51
+ return null;
52
+ const { result } = JSON.parse(forwarded.text);
53
+ if (result === undefined || result.isError === true)
54
+ return null;
55
+ const text = result.content.map((content) => content.text).join('\n');
56
+ if (!text.startsWith('{"pending":'))
57
+ return null;
58
+ const seconds = JSON.parse(text).pending.retry_after;
59
+ return typeof seconds === 'number' ? seconds : null;
60
+ }
61
+ function resendAfter(forwarded, attempt) {
62
+ if (forwarded === null)
63
+ return retryDelay(attempt);
64
+ if (forwarded.status !== 200)
65
+ return retryable(forwarded.text) === false ? null : retryDelay(attempt);
66
+ const seconds = pendingSeconds(forwarded);
67
+ return seconds === null ? null : seconds * 1000;
44
68
  }
45
69
  function editAnswer(forwarded) {
46
70
  const answer = answered(forwarded);
@@ -50,44 +74,33 @@ function editAnswer(forwarded) {
50
74
  return {
51
75
  accepted: results.flatMap((result) => [
52
76
  ...(result.from === undefined ? [] : [{ path: result.from, revision: null, content: null }]),
53
- { path: result.path, revision: result.op === 'remove' ? null : result.revision },
77
+ { path: result.path, revision: result.revision ?? null },
54
78
  ]),
55
79
  };
56
80
  }
57
81
  export async function openBridge(house, copies, conversationId, signal) {
82
+ const path = [join(kitHome(), 'shim'), dirname(process.execPath), await loginPath()].join(delimiter);
58
83
  const origin = (await readEnrolment()).house;
59
- const { credential } = await house.post(`/kit/conversations/${conversationId}/credential`, {}, signal);
84
+ const { credential } = await retrying(() => house.post(`/kit/conversations/${conversationId}/credential`, {}, signal), signal);
60
85
  const closed = new AbortController();
61
- const body = (message, operation) => JSON.stringify({
62
- ...message,
63
- params: {
64
- ...message.params,
65
- _meta: {
66
- 'io.modelcontextprotocol/protocolVersion': MCP_PROTOCOL_VERSION,
67
- 'io.modelcontextprotocol/clientInfo': { name: '@agentshouse/kit', version: KIT_VERSION },
68
- 'io.modelcontextprotocol/clientCapabilities': {},
69
- ...(operation === null ? {} : { 'agents.house/agent-operation': operation }),
70
- },
71
- },
72
- });
86
+ const sent = (message, operation) => callHouse(origin, credential, message, operation, closed.signal);
73
87
  const send = async (message, operation) => {
74
- const tool = message.method === 'tools/call' ? String(message.params?.name) : null;
75
- const answer = await fetch(new URL('/', origin), {
76
- method: 'POST',
77
- headers: {
78
- 'content-type': 'application/json',
79
- authorization: `Bearer ${credential}`,
80
- accept: 'application/json, text/event-stream',
81
- 'mcp-protocol-version': MCP_PROTOCOL_VERSION,
82
- 'mcp-method': message.method,
83
- ...(tool === null ? {} : { 'mcp-name': tool }),
84
- },
85
- body: body(message, operation),
86
- signal: closed.signal,
87
- });
88
- return { status: answer.status, text: await answer.text() };
88
+ if (operation === null)
89
+ return sent(message, null);
90
+ for (let attempt = 0;; attempt++) {
91
+ const forwarded = await sent(message, operation).catch((error) => {
92
+ if (closed.signal.aborted)
93
+ throw error;
94
+ return null;
95
+ });
96
+ const after = resendAfter(forwarded, attempt);
97
+ if (after === null)
98
+ return forwarded;
99
+ // A Tool call House did not settle is sent again on the jittered curve, or after the wait House names for a pending one.
100
+ await delay(after, undefined, { signal: closed.signal });
101
+ }
89
102
  };
90
- const call = (name, args) => ({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
103
+ const call = (name, args) => rpc('tools/call', { name, arguments: args });
91
104
  const staged = async (operation, changes) => {
92
105
  const bytes = Buffer.from(JSON.stringify({ changes }));
93
106
  const sha256 = createHash('sha256').update(bytes).digest('hex');
@@ -98,42 +111,39 @@ export async function openBridge(house, copies, conversationId, signal) {
98
111
  if ('refused' in prepared)
99
112
  return prepared;
100
113
  const { transfer } = parse(prepared.text);
101
- const posted = await fetch(transfer.url, {
102
- method: 'POST',
103
- headers: { 'content-type': 'text/plain; charset=utf-8' },
104
- body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
105
- signal: closed.signal,
106
- });
107
- const text = await posted.text();
108
- if (posted.status >= 500)
109
- throw new Error(`House answered ${posted.status}`);
110
- if (!posted.ok)
111
- return { refused: { code: refusalCode(text), text } };
114
+ let text;
115
+ try {
116
+ text = await retrying(async () => {
117
+ const posted = await fetch(relayed(transfer.url), {
118
+ method: 'POST',
119
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
120
+ body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
121
+ signal: closed.signal,
122
+ });
123
+ const answer = await posted.text();
124
+ if (!posted.ok)
125
+ throw new HouseRefusal('/', posted.status, answer);
126
+ return answer;
127
+ }, closed.signal);
128
+ }
129
+ catch (error) {
130
+ if (!finalRefusal(error))
131
+ throw error;
132
+ return { refused: refusalIn(error.text, error.status) };
133
+ }
112
134
  const held = JSON.parse(text).transfer;
113
135
  return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
114
136
  };
115
- const forward = async (path, text) => {
116
- if (path === '/') {
117
- const message = JSON.parse(text);
118
- const tool = message.method === 'tools/call' ? String(message.params?.name) : null;
119
- const writes = tool !== null &&
120
- (await house.post('/kit/tools/mutations', {}, closed.signal)).tools.some((listed) => listed.name === tool && listed.writes);
121
- return send(message, writes ? operationId() : null);
122
- }
123
- const answer = await fetch(new URL(path, origin), {
124
- method: 'POST',
125
- headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
126
- body: text,
127
- signal: closed.signal,
128
- });
129
- return { status: answer.status, text: await answer.text() };
137
+ const forward = (text) => {
138
+ const message = JSON.parse(text);
139
+ return send(message, message.method === 'tools/call' ? operationId() : null);
130
140
  };
131
141
  const caller = {
132
142
  key: `conversation:${conversationId}`,
133
143
  submit: async (operation, changes, held, retain) => {
134
144
  const inline = call('edit', { changes });
135
145
  let upload = held;
136
- if (upload === undefined && Buffer.byteLength(body(inline, operation)) > MCP_REQUEST_BYTES) {
146
+ if (upload === undefined && Buffer.byteLength(mcpBody(inline, operation)) > MCP_REQUEST_BYTES) {
137
147
  const prepared = await staged(operation, changes);
138
148
  if ('refused' in prepared)
139
149
  return prepared;
@@ -148,15 +158,15 @@ export async function openBridge(house, copies, conversationId, signal) {
148
158
  const { cwd, commit } = JSON.parse(text);
149
159
  return { status: 200, text: JSON.stringify(await copies.push(cwd, commit, caller)) };
150
160
  }
161
+ if (path === '/git/fetch')
162
+ return { status: 200, text: JSON.stringify(await copies.fetch()) };
151
163
  if (path === '/git/observed') {
152
164
  await copies.observed(JSON.parse(text));
153
165
  return { status: 200, text: '{}' };
154
166
  }
155
- return FORWARDED.has(path) ? forward(path, text) : { status: 404, text: '' };
167
+ return path === '/' ? forward(text) : { status: 404, text: '' };
156
168
  };
157
- const sockets = join(kitHome(), 'bridges');
158
- await mkdir(sockets, { recursive: true, mode: 0o700 });
159
- const socketPath = join(sockets, `${randomUUID()}.sock`);
169
+ const socketPath = join(socketDirectory(), `${randomUUID()}.sock`);
160
170
  const bridge = createServer(async (request, response) => {
161
171
  try {
162
172
  const path = request.url ?? '';
@@ -168,7 +178,12 @@ export async function openBridge(house, copies, conversationId, signal) {
168
178
  response.writeHead(answered.status, { 'content-type': 'application/json' }).end(answered.text);
169
179
  }
170
180
  catch (error) {
171
- response.writeHead(502).end(error instanceof Error ? error.message : String(error));
181
+ if (error instanceof HouseRefusal) {
182
+ response.writeHead(error.status, { 'content-type': 'application/json' }).end(error.text);
183
+ return;
184
+ }
185
+ const verb = request.url === '/git/push' || request.url === '/git/fetch';
186
+ response.writeHead(502).end(verb && error instanceof Error ? error.message : UNREACHABLE);
172
187
  }
173
188
  });
174
189
  bridge.listen(socketPath);
@@ -189,13 +204,13 @@ export async function openBridge(house, copies, conversationId, signal) {
189
204
  return {
190
205
  env: {
191
206
  HOUSE_BRIDGE: socketPath,
192
- PATH: `${join(kitHome(), 'shim')}${delimiter}${process.env.PATH ?? ''}`,
207
+ PATH: path,
193
208
  GIT_CONFIG_COUNT: '1',
194
209
  GIT_CONFIG_KEY_0: `url.${proxy}/app/.insteadOf`,
195
210
  GIT_CONFIG_VALUE_0: app,
196
211
  },
197
212
  tool: async (name, args) => {
198
- const forwarded = await forward('/', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }));
213
+ const forwarded = await forward(JSON.stringify(call(name, args)));
199
214
  if (forwarded.status !== 200)
200
215
  throw new Error(`House refused ${name} with ${forwarded.status}: ${forwarded.text}`);
201
216
  const answer = JSON.parse(forwarded.text);
@@ -0,0 +1,32 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { placeAppGit } from './app-git.js';
3
+ import { kitHome, readOwnAgent, writeOwnAgent } from './home.js';
4
+ import { accepted, DEFAULT_HOUSE } from './login.js';
5
+ const KEY_SOURCE = 'New key for a script on Connections';
6
+ const USAGE = `usage: kit chatgpt --key <key> [--house <origin>]
7
+ --key <key> a key from ${KEY_SOURCE}
8
+ --house <origin> the House of the key, ${DEFAULT_HOUSE} unless one is stored
9
+ `;
10
+ export async function chatgpt(argv) {
11
+ const { values } = parseArgs({
12
+ args: argv,
13
+ options: { key: { type: 'string' }, house: { type: 'string' }, help: { type: 'boolean', default: false } },
14
+ });
15
+ if (values.help) {
16
+ process.stdout.write(USAGE);
17
+ return;
18
+ }
19
+ if (values.key === undefined)
20
+ throw new Error(`pass --key with a key from ${KEY_SOURCE}`);
21
+ const before = await readOwnAgent();
22
+ const house = values.house ?? before?.house ?? DEFAULT_HOUSE;
23
+ if (!(await accepted(house, values.key)))
24
+ throw new Error(`${house} does not accept this key; pass one from ${KEY_SOURCE}`);
25
+ const relay = process.env.HOUSE_KIT_RELAY || undefined;
26
+ await writeOwnAgent({ house, ...(relay === undefined ? {} : { relay }), credential: values.key });
27
+ await placeAppGit(await readOwnAgent()).catch((error) => process.stderr.write(`kit: ${error.message}\n`));
28
+ if (before !== null && before.credential !== values.key) {
29
+ process.stdout.write('This replaced the key this Kit home held; give each session its own HOUSE_KIT_HOME to keep its own key.\n');
30
+ }
31
+ process.stdout.write(`house now calls ${house} as you, with the key kept in ${kitHome()}.\n`);
32
+ }
@@ -0,0 +1,23 @@
1
+ import { setGlobalProxyFromEnv } from 'node:http';
2
+ import { post } from './api.js';
3
+ import { CLIS, below, locate, versionOf } from './clis.js';
4
+ import { readEnrolment } from './home.js';
5
+ setGlobalProxyFromEnv();
6
+ try {
7
+ const { house, credential } = (await readEnrolment());
8
+ const { agents } = await post(house, '/kit/agents/desired', {}, credential);
9
+ for (const kind of agents) {
10
+ const cli = await locate(kind);
11
+ if (cli === null)
12
+ continue;
13
+ const release = await versionOf(cli);
14
+ if (release === null)
15
+ continue;
16
+ const { bin, minimum } = CLIS[kind];
17
+ process.stdout.write(`${[bin, cli, release, minimum, below(release, minimum) ? 'old' : 'current'].join('\t')}\n`);
18
+ }
19
+ }
20
+ catch (error) {
21
+ process.stderr.write(`kit: ${error.message}\n`);
22
+ process.exitCode = 1;
23
+ }
package/dist/clis.js CHANGED
@@ -1,83 +1,159 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants, readFileSync } from 'node:fs';
4
+ import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
5
+ import { homedir, userInfo } from 'node:os';
6
+ import { delimiter, dirname, isAbsolute, join } from 'node:path';
5
7
  import { kitHome } from './home.js';
6
- const RUNNING_JOB = new Set(['running', 'paused']);
7
- function threadStatus({ method, params }) {
8
- if (method !== 'session/update' || params?.update?.sessionUpdate !== 'session_info_update')
9
- return undefined;
10
- return params.update._meta?.codex?.threadStatus?.type;
11
- }
12
- function asyncTask({ method, params }) {
13
- const update = params?.update;
14
- if (method !== 'session/update' || update?.asyncTaskId === undefined)
15
- return null;
16
- if (update.sessionUpdate === 'async_task_spawned')
17
- return { id: update.asyncTaskId, running: true };
18
- if (update.sessionUpdate === 'async_task_state_update' && !RUNNING_JOB.has(update.state ?? '')) {
19
- return { id: update.asyncTaskId, running: false };
8
+ const HOUSE_EXECUTABLES = /^host_executable\(name = "house", paths = (\[.*\])\)$/m;
9
+ function houseRule(houses) {
10
+ return `prefix_rule(pattern = ["house"], decision = "allow")\nhost_executable(name = "house", paths = ${JSON.stringify(houses)})\n`;
11
+ }
12
+ const RAW_SUBTYPES = [
13
+ 'background_tasks_changed',
14
+ 'task_started',
15
+ 'task_updated',
16
+ 'task_notification',
17
+ 'session_state_changed',
18
+ 'hook_started',
19
+ 'hook_response',
20
+ ];
21
+ async function executable(path) {
22
+ try {
23
+ await access(path, constants.X_OK);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ async function listedHouses(file) {
31
+ let text;
32
+ try {
33
+ text = await readFile(file, 'utf8');
34
+ }
35
+ catch (error) {
36
+ if (error.code === 'ENOENT')
37
+ return [];
38
+ throw error;
39
+ }
40
+ try {
41
+ const listed = JSON.parse(HOUSE_EXECUTABLES.exec(text)?.[1] ?? '[]');
42
+ return Array.isArray(listed) ? listed.filter((house) => typeof house === 'string') : [];
43
+ }
44
+ catch {
45
+ return [];
20
46
  }
21
- return null;
47
+ }
48
+ export async function allowHouseInCodex(path) {
49
+ const candidates = [...new Set(path.split(delimiter).filter((directory) => isAbsolute(directory)))].map((directory) => join(directory, 'house'));
50
+ const rules = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'rules');
51
+ const file = join(rules, 'house.rules');
52
+ const houses = [];
53
+ for (const house of [...(await listedHouses(file)), ...candidates]) {
54
+ if (!houses.includes(house) && (await executable(house)))
55
+ houses.push(house);
56
+ }
57
+ await mkdir(rules, { recursive: true });
58
+ const temporary = `${file}.${randomUUID()}`;
59
+ await writeFile(temporary, houseRule(houses));
60
+ await rename(temporary, file);
61
+ }
62
+ async function nothing() { }
63
+ function answered(error) {
64
+ return typeof error === 'object' && error !== null ? error : {};
65
+ }
66
+ function bridgeInClaudeSandbox(bridge) {
67
+ return process.platform === 'darwin' ? { allowUnixSockets: [bridge] } : { allowAllUnixSockets: true };
22
68
  }
23
69
  export const CLIS = {
24
70
  'codex-acp': {
25
- package: '@agentclientprotocol/codex-acp',
26
- bin: 'codex-acp',
71
+ bin: 'codex',
72
+ minimum: '0.159.1',
73
+ install: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh',
74
+ adapter: {
75
+ package: '@agentclientprotocol/codex-acp',
76
+ bin: 'codex-acp',
77
+ executable: 'CODEX_PATH',
78
+ env: { CODEX_CONFIG: JSON.stringify({ features: { shell_zsh_fork: true } }) },
79
+ },
27
80
  args: [],
28
- signedIn: { command: ['cli', 'login', 'status'] },
29
- login: { args: ['cli', 'login', '--device-auth'], code: 'show' },
30
- queues: false,
31
- turnStarted: (notice) => threadStatus(notice) === 'active',
32
- turnEnded: (notice) => threadStatus(notice) === 'idle',
33
- job: asyncTask,
81
+ fullAccess: 'agent-full-access',
82
+ sessionMeta: () => ({}),
83
+ unknownSession: (error) => {
84
+ const { code, data } = answered(error);
85
+ return code === -32603 && typeof data?.details === 'string' && data.details.startsWith('no rollout found');
86
+ },
87
+ renames: true,
88
+ allowHouse: allowHouseInCodex,
89
+ signedIn: { command: ['login', 'status'] },
90
+ login: { args: ['login', '--device-auth'], code: 'show' },
91
+ air: ['asyncTasks', 'nativeSubagentSessions'],
34
92
  },
35
93
  'claude-agent-acp': {
36
- package: '@agentclientprotocol/claude-agent-acp',
37
- bin: 'claude-agent-acp',
94
+ bin: 'claude',
95
+ minimum: '2.1.286',
96
+ install: 'curl -fsSL https://claude.ai/install.sh | bash',
97
+ adapter: {
98
+ package: '@agentclientprotocol/claude-agent-acp',
99
+ bin: 'claude-agent-acp',
100
+ executable: 'CLAUDE_CODE_EXECUTABLE',
101
+ env: { CLAUDE_CODE_ENTRYPOINT: 'claude-agent-acp', IS_SANDBOX: '1' },
102
+ },
38
103
  args: [],
39
- signedIn: { command: ['--cli', 'auth', 'status'] },
40
- login: { args: ['--cli', 'auth', 'login', '--claudeai'], code: 'collect' },
41
- queues: false,
42
- turnStarted: () => false,
43
- turnEnded: ({ method, params }) => method === 'session/update' &&
44
- params?.update?.sessionUpdate === 'usage_update' &&
45
- params.update._meta?.['_claude/origin'] !== undefined,
46
- job: asyncTask,
104
+ fullAccess: 'bypassPermissions',
105
+ sessionMeta: (bridge, title) => ({
106
+ claudeCode: {
107
+ options: {
108
+ allowedTools: ['Bash(house:*)'],
109
+ includeHookEvents: true,
110
+ title,
111
+ settings: { sandbox: { network: bridgeInClaudeSandbox(bridge) } },
112
+ },
113
+ emitRawSDKMessages: RAW_SUBTYPES.map((subtype) => ({ type: 'system', subtype })),
114
+ },
115
+ }),
116
+ unknownSession: (error) => answered(error).code === -32002,
117
+ renames: false,
118
+ allowHouse: nothing,
119
+ signedIn: { command: ['auth', 'status'] },
120
+ login: { args: ['auth', 'login', '--claudeai'], code: 'collect', rejected: 'Invalid code' },
121
+ air: ['asyncTasks', 'sessionFailure'],
47
122
  },
48
123
  'grok-build': {
49
- package: '@xai-official/grok',
50
124
  bin: 'grok',
51
- args: ['agent', '--no-leader', 'stdio'],
125
+ minimum: '1.0.46',
126
+ install: 'curl -fsSL https://x.ai/cli/install.sh | bash',
127
+ adapter: null,
128
+ args: ['--sandbox', 'off', 'agent', '--always-approve', '--no-leader', 'stdio'],
129
+ fullAccess: null,
130
+ sessionMeta: () => ({}),
131
+ unknownSession: (error) => answered(error).data?.code === 'FS_NOT_FOUND',
132
+ renames: false,
133
+ allowHouse: nothing,
52
134
  signedIn: { initializeMeta: 'defaultAuthMethodId' },
53
135
  login: { args: ['login', '--device-auth'], code: 'show' },
54
- queues: true,
55
- turnStarted: ({ method, params }) => method === '_x.ai/queue/changed' && params?.runningPromptId !== undefined,
56
- turnEnded: ({ method, params }) => method === '_x.ai/session_notification' && params?.update?.sessionUpdate === 'turn_completed',
57
- job: ({ method, params }) => {
58
- if (method === '_x.ai/task_backgrounded')
59
- return { id: params.update.task_id, running: true };
60
- if (method === '_x.ai/task_completed')
61
- return { id: params.update.task_snapshot.task_id, running: false };
62
- return null;
63
- },
136
+ air: ['asyncTasks'],
64
137
  },
65
138
  };
66
139
  const KIT_PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
67
140
  export const KIT_VERSION = KIT_PACKAGE.version;
68
141
  const PROXY = /^https?_proxy$/i;
69
- export function pinned(kind) {
70
- return KIT_PACKAGE.peerDependencies[CLIS[kind].package];
71
- }
142
+ const VERSION = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/;
143
+ // An install still running after fifteen minutes is ended and reported as failed, so a hung download cannot hold its CLI's report.
144
+ const INSTALL_MS = 15 * 60_000;
145
+ // A login shell or a version read is ended after half a minute, so a profile that waits for input cannot stall the report.
146
+ const READ_MS = 30_000;
147
+ const CAUSE_CHARACTERS = 4000;
72
148
  function prefix(kind) {
73
149
  return join(kitHome(), 'agents', kind);
74
150
  }
75
- export function cliCommand(kind) {
76
- return join(prefix(kind), 'node_modules', '.bin', CLIS[kind].bin);
151
+ export function adapterCommand(kind) {
152
+ return join(prefix(kind), 'node_modules', '.bin', CLIS[kind].adapter.bin);
77
153
  }
78
- export async function installedRelease(kind) {
154
+ async function installedAdapter(kind) {
79
155
  try {
80
- const manifest = join(prefix(kind), 'node_modules', CLIS[kind].package, 'package.json');
156
+ const manifest = join(prefix(kind), 'node_modules', CLIS[kind].adapter.package, 'package.json');
81
157
  return JSON.parse(await readFile(manifest, 'utf8')).version;
82
158
  }
83
159
  catch {
@@ -87,28 +163,94 @@ export async function installedRelease(kind) {
87
163
  export function withoutProxy() {
88
164
  return Object.fromEntries(Object.entries(process.env).filter(([name]) => !PROXY.test(name)));
89
165
  }
90
- export function run(command, args, timeoutMs) {
166
+ export function run(command, args, timeoutMs, detached = false, signal) {
91
167
  return new Promise((resolve) => {
92
- const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: timeoutMs, env: withoutProxy() });
168
+ const child = spawn(command, args, {
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ timeout: timeoutMs,
171
+ env: withoutProxy(),
172
+ detached,
173
+ signal: detached ? undefined : signal,
174
+ });
175
+ const stop = () => {
176
+ try {
177
+ process.kill(-child.pid, 'SIGKILL');
178
+ }
179
+ catch { }
180
+ };
181
+ if (detached && signal?.aborted)
182
+ stop();
183
+ if (detached)
184
+ signal?.addEventListener('abort', stop, { once: true });
185
+ const ended = (ran) => {
186
+ signal?.removeEventListener('abort', stop);
187
+ resolve(ran);
188
+ };
189
+ let stdout = '';
93
190
  let output = '';
94
- child.stdout.on('data', (chunk) => {
95
- output += chunk.toString('utf8');
191
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
192
+ stdout += chunk;
193
+ output += chunk;
96
194
  });
97
- child.stderr.on('data', (chunk) => {
98
- output += chunk.toString('utf8');
195
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
196
+ output += chunk;
99
197
  });
100
- child.on('error', (error) => resolve({ status: null, output: error.message }));
101
- child.on('close', (status) => resolve({ status, output }));
198
+ child.on('error', (error) => ended({ status: null, stdout, output: error.message }));
199
+ child.on('close', (status) => ended({ status, stdout, output }));
102
200
  });
103
201
  }
104
- export async function install(kind) {
105
- const release = pinned(kind);
106
- if ((await installedRelease(kind)) === release)
107
- return release;
108
- const ran = await run('npm', ['install', '--prefix', prefix(kind), '--no-save', '--no-audit', '--no-fund', `${CLIS[kind].package}@${release}`], 15 * 60_000);
109
- if (ran.status !== 0) {
110
- process.stderr.write(`kit: ${kind} did not install: ${ran.output.trim()}\n`);
202
+ export function failureOf(ran, program) {
203
+ return ran.output.trim().slice(-CAUSE_CHARACTERS) || `${program} exited with ${ran.status ?? 'a signal'}`;
204
+ }
205
+ export async function installAdapter(kind, signal) {
206
+ const adapter = CLIS[kind].adapter;
207
+ if (adapter === null)
111
208
  return null;
209
+ const release = KIT_PACKAGE.peerDependencies[adapter.package];
210
+ if ((await installedAdapter(kind)) === release)
211
+ return null;
212
+ const ran = await run('npm', ['install', '--prefix', prefix(kind), '--cache', join(kitHome(), 'npm-cache'), '--no-save', '--no-audit', '--no-fund', '--omit=optional', `${adapter.package}@${release}`], INSTALL_MS, true, signal);
213
+ return ran.status === 0 ? null : failureOf(ran, 'npm');
214
+ }
215
+ export async function installCli(kind, signal) {
216
+ const ran = await run('bash', ['-o', 'pipefail', '-c', CLIS[kind].install], INSTALL_MS, true, signal);
217
+ return ran.status === 0 ? null : failureOf(ran, 'the install');
218
+ }
219
+ function loginShell() {
220
+ return process.env.SHELL || userInfo().shell;
221
+ }
222
+ const FOUND = 'house-kit-cli ';
223
+ const LOOKUP = `found=$(command -v "$1") || exit 0; case $found in /*) ;; *) found=$(pwd -P)/$found ;; esac; printf "${FOUND}%s\\n" "$found"`;
224
+ const USER_BIN = 'case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) PATH="$PATH:$HOME/.local/bin" ;; esac; export PATH';
225
+ function inLoginShell(script, argument = '', signal) {
226
+ return run(loginShell(), ['-l', '-i', '-c', `exec /bin/sh -c '${USER_BIN}; ${script}' sh ${argument}`], READ_MS, true, signal);
227
+ }
228
+ export async function locate(kind, signal) {
229
+ const ran = await inLoginShell(LOOKUP, CLIS[kind].bin, signal);
230
+ return ran.stdout.split('\n').findLast((line) => line.startsWith(FOUND))?.slice(FOUND.length) ?? null;
231
+ }
232
+ export async function loginPath() {
233
+ const marker = 'house-kit-path ';
234
+ const ran = await inLoginShell(`printf "${marker}%s\\n" "$PATH"`);
235
+ return ran.stdout.split('\n').findLast((line) => line.startsWith(marker)).slice(marker.length);
236
+ }
237
+ export async function writeCodexRule() {
238
+ const shell = await loginPath().catch(() => '');
239
+ await allowHouseInCodex([dirname(process.execPath), process.env.PATH ?? '', shell].join(delimiter)).catch((error) => {
240
+ throw new Error(`Codex's rule for house could not be written: ${error.message}`);
241
+ });
242
+ }
243
+ export async function versionOf(path, signal) {
244
+ return VERSION.exec((await run(path, ['--version'], READ_MS, false, signal)).stdout)?.[0] ?? null;
245
+ }
246
+ function numbers(version) {
247
+ return version.split(/[.-]/, 3).map(Number);
248
+ }
249
+ export function below(found, minimum) {
250
+ const [have, need] = [numbers(found), numbers(minimum)];
251
+ for (let index = 0; index < 3; index++) {
252
+ if (have[index] !== need[index])
253
+ return have[index] < need[index];
112
254
  }
113
- return installedRelease(kind);
255
+ return found.includes('-') && !minimum.includes('-');
114
256
  }